Rename the Python package to daynivo_kiosk
CI / test-bookworm (push) Successful in 29s
CI / test-latest (push) Successful in 33s

src/voxip_kiosk -> src/daynivo_kiosk, with every import, the launchers,
install.sh, the Makefile and the manager's gunicorn app path. An upgrade
replaces /usr/lib/voxip-kiosk/python as a whole, so the old package
disappears; tested by upgrading an installation of the previous commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rothmanandClaude Opus 5.5 committed 2026-11-05 23:52:39 +01:00
1 parent d0f3206ff4
commit 6733aacb6e
62 files changed
+43 -43

No files matched your search

+73
View File
@@ -0,0 +1,73 @@
"""Daynivo Kiosk Manager — Flask application factory."""
from __future__ import annotations
import os
import secrets
from dataclasses import dataclass
from datetime import timedelta
from typing import Any
from flask import Flask
from ..cdp import CDP
from ..config import load_config
from ..helper_client import HelperClient
from ..paths import Paths, load_paths, read_version
from .auth import CredentialStore, LoginThrottle
@dataclass
class Services:
paths: Paths
credentials: CredentialStore
throttle: LoginThrottle
helper: Any
cdp: Any
version: str
def _secret_key(paths: Paths) -> bytes:
path = paths.secret_key_file
try:
key = path.read_bytes()
if len(key) >= 32:
return key
except FileNotFoundError:
pass
key = secrets.token_bytes(64)
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "wb") as fh:
fh.write(key)
return key
def create_app(paths: Paths | None = None, *, helper: Any = None, cdp: Any = None) -> Flask:
paths = paths or load_paths()
config = load_config(paths.config_file)
app = Flask(__name__)
app.secret_key = _secret_key(paths)
app.config.update(
SESSION_COOKIE_NAME="voxip_session",
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Strict",
SESSION_COOKIE_SECURE=config["admin_https"],
PERMANENT_SESSION_LIFETIME=timedelta(hours=12),
MAX_CONTENT_LENGTH=64 * 1024,
TEMPLATES_AUTO_RELOAD=False,
)
credentials = CredentialStore(paths.admin_file, paths.setup_code_file)
credentials.ensure_setup_code()
app.extensions["voxip"] = Services(
paths=paths,
credentials=credentials,
throttle=LoginThrottle(),
helper=helper or HelperClient(paths.helper),
cdp=cdp or CDP(timeout=5),
version=read_version(paths),
)
from .views import bp
app.register_blueprint(bp)
return app
+156
View File
@@ -0,0 +1,156 @@
"""Admin credentials (Argon2id) and login throttling."""
from __future__ import annotations
import json
import secrets
import threading
import time
from collections import deque
from pathlib import Path
from typing import Any
from argon2 import PasswordHasher, Type
from argon2.exceptions import InvalidHash, VerificationError, VerifyMismatchError
from .. import setupcode
from ..config import atomic_write_json
# 64 MiB / 3 passes / 2 lanes: ~0.5 s on a Raspberry Pi 3, fine for a single admin.
HASHER = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=2, hash_len=32, salt_len=16, type=Type.ID)
MIN_LENGTH = 10
MAX_LENGTH = 128
WEAK = {"admin", "administrator", "password", "wachtwoord", "raspberry", "voxip", "kiosk", "welkom", "welcome", "qwerty", "123456"}
def password_problem(password: str, confirm: str | None = None) -> str | None:
if confirm is not None and password != confirm:
return "De wachtwoorden komen niet overeen."
if len(password) < MIN_LENGTH:
return f"Gebruik minimaal {MIN_LENGTH} tekens."
if len(password) > MAX_LENGTH:
return f"Gebruik maximaal {MAX_LENGTH} tekens."
remainder = password.lower()
for word in WEAK:
remainder = remainder.replace(word, "")
if len(set(password)) < 4 or len(remainder) < 6 or remainder.isdigit():
return "Dit wachtwoord is te eenvoudig. Kies een uniek wachtwoord."
return None
class CredentialStore:
def __init__(self, admin_file: Path, setup_code_file: Path):
self.admin_file = admin_file
self.setup_code_file = setup_code_file
self._lock = threading.Lock()
def _load(self) -> dict[str, Any] | None:
try:
data = json.loads(self.admin_file.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
if not isinstance(data, dict) or not str(data.get("password_hash", "")).startswith("$argon2id$"):
return None
return data
def is_configured(self) -> bool:
return self._load() is not None
def ensure_setup_code(self) -> str | None:
"""Create a setup code while no password exists; remove it afterwards."""
if self.is_configured():
self.setup_code_file.unlink(missing_ok=True)
return None
code = setupcode.read(self.setup_code_file)
if not code:
code = setupcode.generate()
setupcode.write(self.setup_code_file, code)
return code
def check_setup_code(self, candidate: str) -> bool:
return setupcode.matches(self.setup_code_file, candidate)
def set_password(self, password: str) -> str:
"""Store a new password hash; returns the new session epoch (old sessions become invalid)."""
epoch = secrets.token_hex(16)
record = {
"password_hash": HASHER.hash(password),
"session_epoch": epoch,
"updated_at": int(time.time()),
}
with self._lock:
atomic_write_json(self.admin_file, record, mode=0o600)
self.setup_code_file.unlink(missing_ok=True)
return epoch
def verify(self, password: str) -> bool:
data = self._load()
if data is None or len(password) > MAX_LENGTH:
return False
try:
HASHER.verify(data["password_hash"], password)
except (VerifyMismatchError, VerificationError, InvalidHash):
return False
if HASHER.check_needs_rehash(data["password_hash"]):
data["password_hash"] = HASHER.hash(password)
with self._lock:
atomic_write_json(self.admin_file, data, mode=0o600)
return True
def session_epoch(self) -> str | None:
data = self._load()
return data.get("session_epoch") if data else None
class LoginThrottle:
"""Per-IP exponential lockout plus a global brake against distributed guessing."""
def __init__(self, free_attempts: int = 5, window: float = 900, max_lock: float = 900,
global_limit: int = 30, global_window: float = 300, clock=time.monotonic):
self.free_attempts = free_attempts
self.window = window
self.max_lock = max_lock
self.global_limit = global_limit
self.global_window = global_window
self.clock = clock
self._lock = threading.Lock()
self._failures: dict[str, deque] = {}
self._locked_until: dict[str, float] = {}
self._global: deque = deque()
def _prune(self, now: float) -> None:
while self._global and now - self._global[0] > self.global_window:
self._global.popleft()
for ip in list(self._failures):
q = self._failures[ip]
while q and now - q[0] > self.window:
q.popleft()
if not q and self._locked_until.get(ip, 0) <= now:
self._failures.pop(ip, None)
self._locked_until.pop(ip, None)
def retry_after(self, ip: str) -> int:
with self._lock:
now = self.clock()
self._prune(now)
wait = self._locked_until.get(ip, 0) - now
if len(self._global) >= self.global_limit:
wait = max(wait, self.global_window - (now - self._global[0]))
return max(0, int(wait + 0.999))
def failure(self, ip: str) -> None:
with self._lock:
now = self.clock()
self._prune(now)
q = self._failures.setdefault(ip, deque())
q.append(now)
self._global.append(now)
excess = len(q) - self.free_attempts
if excess >= 0:
self._locked_until[ip] = now + min(self.max_lock, 30 * (2 ** excess))
def success(self, ip: str) -> None:
with self._lock:
self._failures.pop(ip, None)
self._locked_until.pop(ip, None)
+90
View File
@@ -0,0 +1,90 @@
"""Request-level protections: client network filter, CSRF, security headers."""
from __future__ import annotations
import hmac
import ipaddress
import secrets
from urllib.parse import urlsplit
from flask import Request, session
CSRF_FIELD = "csrf_token"
CSRF_HEADER = "X-CSRF-Token"
_VPN_RANGES = (ipaddress.ip_network("100.64.0.0/10"),) # CGNAT, used by Tailscale and many VPNs
CSP = "; ".join([
"default-src 'self'",
"img-src 'self' data: blob:",
"style-src 'self'",
"script-src 'self'",
"connect-src 'self'",
"frame-ancestors 'none'",
"form-action 'self'",
"base-uri 'none'",
"object-src 'none'",
])
def client_allowed(remote_addr: str | None, allow_public: bool, local_networks: list[str] = ()) -> bool:
"""Only local networks may reach the admin interface unless explicitly opened up.
Local means: private/link-local/loopback ranges, CGNAT (VPN) or one of
the subnets this device itself is on (covers global IPv6 prefixes on
the LAN).
"""
if allow_public:
return True
try:
addr = ipaddress.ip_address((remote_addr or "").split("%", 1)[0])
except ValueError:
return False
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped:
addr = addr.ipv4_mapped
if addr.is_multicast:
return False
if addr.is_loopback or addr.is_link_local or addr.is_private:
return True
if any(addr in net for net in _VPN_RANGES if addr.version == net.version):
return True
for cidr in local_networks:
try:
net = ipaddress.ip_interface(cidr).network
except ValueError:
continue
if net.version == addr.version and addr in net and net.prefixlen >= (16 if net.version == 4 else 48):
return True
return False
def csrf_token() -> str:
token = session.get("csrf")
if not token:
token = secrets.token_urlsafe(32)
session["csrf"] = token
return token
def csrf_valid(request: Request) -> bool:
expected = session.get("csrf")
supplied = request.headers.get(CSRF_HEADER) or request.form.get(CSRF_FIELD, "")
if not expected or not supplied or not hmac.compare_digest(expected, supplied):
return False
origin = request.headers.get("Origin")
if origin and origin != "null":
return urlsplit(origin).netloc == request.host
return True
def apply_headers(response, secure: bool):
response.headers.setdefault("Content-Security-Policy", CSP)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "no-referrer"
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
if response.mimetype in ("text/html", "application/json") or response.mimetype.startswith("image/"):
response.headers["Cache-Control"] = "no-store"
if secure:
response.headers["Strict-Transport-Security"] = "max-age=31536000"
return response
+170
View File
@@ -0,0 +1,170 @@
:root {
--navy: #0b2545;
--navy-2: #13315c;
--accent: #0b2545;
--accent-dark: #13315c;
--bg: #f2f5f9;
--card: #ffffff;
--text: #1b2433;
--muted: #5c6b80;
--border: #dde3eb;
--ok: #1f9d55;
--warning: #d98a00;
--error: #d64545;
--stopped: #8592a3;
--starting: #2f80ed;
--radius: 8px;
--shadow: none;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
color: var(--text);
background: var(--bg);
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body { margin: 0; min-height: 100vh; display: flex; flex-direction: column; background: var(--bg); line-height: 1.5; }
a { color: var(--accent); }
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .92em; background: #eef2f7; padding: .05em .35em; border-radius: 4px; }
h1 { font-size: 1.6rem; margin: 0 0 .25rem; }
h2 { font-size: 1.1rem; margin: 0 0 .9rem; }
.mt { margin-top: 1.6rem; }
.mt-sm { margin-top: 1rem; }
.muted { color: var(--muted); }
.small { font-size: .875rem; }
.break { overflow-wrap: anywhere; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
/* --- top bar --------------------------------------------------------------- */
.topbar { background: var(--navy); color: #fff; position: sticky; top: 0; z-index: 10; }
.topbar-inner { max-width: 1200px; margin: 0 auto; padding: 0 1rem; display: flex; align-items: center; gap: 1rem; min-height: 56px; flex-wrap: wrap; }
.brand { display: flex; align-items: center; gap: .6rem; color: #fff; text-decoration: none; font-size: 1.1rem; margin-right: auto; }
/* The Daynivo mark has navy parts that would vanish on the navy bar. */
.brand-mark { display: inline-flex; align-items: center; background: #fff; border-radius: 6px; padding: 3px 5px; }
.brand-mark img { display: block; height: 22px; width: auto; }
.mainnav { display: flex; align-items: center; gap: .25rem; flex-wrap: wrap; }
.mainnav a, .mainnav button { color: #d6e2f3; text-decoration: none; padding: .45rem .7rem; border-radius: 6px; font: inherit; font-size: .95rem; background: none; border: 0; cursor: pointer; }
.mainnav a:hover, .mainnav button:hover { background: var(--navy-2); color: #fff; }
.mainnav a[aria-current="page"] { background: var(--navy-2); color: #fff; }
.nav-logout { margin: 0; }
.nav-menu { position: relative; }
.nav-menu summary { list-style: none; color: #d6e2f3; padding: .45rem .7rem; border-radius: 6px; font-size: .95rem; cursor: pointer; user-select: none; }
.nav-menu summary::-webkit-details-marker { display: none; }
.nav-menu summary:hover, .nav-menu[open] summary { background: var(--navy-2); color: #fff; }
.nav-menu-list { position: absolute; right: 0; top: calc(100% + 6px); min-width: 230px; padding: .3rem 0; background: #fff; border: 1px solid var(--border); border-radius: 6px; box-shadow: 0 8px 24px rgba(11, 37, 69, .15); z-index: 20; }
.nav-menu-list form { margin: 0; }
.mainnav .nav-menu-list button { display: block; width: 100%; text-align: left; padding: .55rem .9rem; border-radius: 0; color: var(--text); }
.mainnav .nav-menu-list button:hover { background: #f1f4f8; color: var(--text); }
.mainnav .nav-menu-list button.danger { color: #b03030; }
.nav-menu-list hr { border: 0; border-top: 1px solid var(--border); margin: .3rem 0; }
.nav-toggle { display: none; background: none; border: 1px solid rgba(255, 255, 255, .3); border-radius: 8px; width: 44px; height: 40px; cursor: pointer; }
.nav-toggle .bars, .nav-toggle .bars::before, .nav-toggle .bars::after { display: block; width: 20px; height: 2px; background: #fff; margin: 0 auto; position: relative; content: ""; }
.nav-toggle .bars::before { position: absolute; top: -6px; }
.nav-toggle .bars::after { position: absolute; top: 6px; }
@media (max-width: 860px) {
.nav-toggle { display: block; }
.mainnav { display: none; width: 100%; flex-direction: column; align-items: stretch; padding-bottom: .75rem; }
.mainnav.open { display: flex; }
.mainnav a, .mainnav button, .nav-menu summary { padding: .75rem; text-align: left; width: 100%; }
.nav-menu-list { position: static; min-width: 0; margin: .25rem 0 0 .75rem; background: none; border: 0; border-left: 1px solid rgba(255, 255, 255, .2); border-radius: 0; box-shadow: none; }
.mainnav .nav-menu-list button { color: #d6e2f3; }
.mainnav .nav-menu-list button:hover { background: var(--navy-2); color: #fff; }
.mainnav .nav-menu-list button.danger { color: #ffb4b4; }
.nav-menu-list hr { border-top-color: rgba(255, 255, 255, .2); }
}
/* --- layout ------------------------------------------------------------------ */
.container { width: 100%; max-width: 1200px; margin: 0 auto; padding: 1.5rem 1rem 2rem; flex: 1; }
.page-head { display: flex; align-items: baseline; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem; }
.page-head p { margin: 0; }
.grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); margin-bottom: 1rem; }
.grid-2 { grid-template-columns: repeat(auto-fill, minmax(min(100%, 460px), 1fr)); }
.card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.25rem; box-shadow: var(--shadow); margin-bottom: 1rem; min-width: 0; }
.grid > .card { margin-bottom: 0; }
.card-narrow { max-width: 460px; margin: 2rem auto; }
.card-pending { border-color: var(--warning); background: #fff8eb; }
.footer { display: flex; justify-content: space-between; flex-wrap: wrap; gap: .5rem; padding: 1rem; color: var(--muted); font-size: .85rem; max-width: 1200px; width: 100%; margin: 0 auto; }
/* --- key/value lists ---------------------------------------------------------- */
.kv { display: grid; grid-template-columns: minmax(110px, 38%) 1fr; gap: .55rem 1rem; margin: 0; }
.kv dt { color: var(--muted); }
.kv dd { margin: 0; min-width: 0; }
.kv-tight { margin-top: 1rem; font-size: .92rem; }
.note { margin: 1rem 0 0; padding: .6rem .8rem; background: #f4f6f9; border-radius: 6px; font-size: .9rem; }
/* --- status badges ------------------------------------------------------------- */
.badge { display: inline-flex; align-items: center; gap: .45rem; padding: .1rem .55rem; border-radius: 4px; font-size: .875rem; font-weight: 500; background: #eef1f5; color: var(--text); }
.badge .dot { width: .6rem; height: .6rem; border-radius: 50%; background: var(--stopped); flex: none; }
.badge-ok .dot { background: var(--ok); }
.badge-warning .dot { background: var(--warning); }
.badge-error { color: #a32c2c; } .badge-error .dot { background: var(--error); }
.badge-starting .dot { background: var(--starting); animation: pulse 1.2s infinite; }
.badge-stopped, .badge-unknown { color: var(--muted); }
@keyframes pulse { 50% { opacity: .35; } }
.meter { height: 6px; border-radius: 3px; background: #e6ebf2; margin-top: .35rem; overflow: hidden; }
.meter span { display: block; height: 100%; width: 0; background: #7d8ba0; transition: width .4s; }
.meter span.is-mid { background: var(--warning); }
.meter span.is-high { background: var(--error); }
/* --- buttons ---------------------------------------------------------------------- */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: .5rem; font: inherit; font-size: .95rem; font-weight: 500; padding: .45rem .9rem; border-radius: 6px; border: 1px solid transparent; cursor: pointer; text-decoration: none; transition: background .15s; min-height: 38px; }
.btn:disabled { opacity: .6; cursor: progress; }
.btn-primary { background: var(--accent); color: #fff; } .btn-primary:hover { background: var(--accent-dark); }
.btn-secondary { background: #fff; color: var(--text); border-color: #c5cfdb; } .btn-secondary:hover { background: #f1f4f8; }
.btn-danger { background: #fff; color: #b03030; border-color: #e3b5b5; } .btn-danger:hover { background: #fcf1f1; }
.btn-big { padding: .55rem 1.1rem; min-height: 42px; }
.btn-block { width: 100%; }
.actions-row { display: flex; flex-wrap: wrap; gap: .5rem; margin-bottom: 1rem; }
.inline-form { margin: 0; }
@media (max-width: 560px) {
.actions-row .inline-form, .actions-row .btn-big { width: 100%; }
}
/* --- forms -------------------------------------------------------------------------- */
.form { display: flex; flex-direction: column; gap: .9rem; }
.form label { display: flex; flex-direction: column; gap: .3rem; font-weight: 600; font-size: .95rem; }
.form small { font-weight: 400; color: var(--muted); }
.form input[type=text], .form input[type=url], .form input[type=password], .form input[type=number], .form select {
font: inherit; font-weight: 400; padding: .6rem .7rem; border: 1px solid #c5cfdb; border-radius: 8px; background: #fff; color: var(--text); width: 100%; min-height: 44px;
}
.form input:focus, .form select:focus { outline: 2px solid #9fb3cc; outline-offset: 1px; border-color: var(--navy); }
.form label.check { flex-direction: row; align-items: center; gap: .55rem; font-weight: 400; }
.form label.check input { width: 1.15rem; height: 1.15rem; }
.form .btn { align-self: flex-start; }
.form .btn-block { align-self: stretch; }
.form-inline { flex-direction: row; flex-wrap: wrap; align-items: flex-end; }
.form-inline label { min-width: 150px; }
.input-short { max-width: 6rem; text-transform: uppercase; }
.input-code { text-transform: uppercase; letter-spacing: .15em; font-family: ui-monospace, Menlo, monospace !important; }
.input-row { display: flex; gap: .5rem; }
.input-row input { flex: 1; }
.radio-row { border: 0; padding: 0; margin: 0; display: flex; gap: 1.5rem; flex-wrap: wrap; }
.radio-row legend { font-weight: 600; font-size: .95rem; margin-bottom: .3rem; padding: 0; }
.static-fields { display: flex; flex-direction: column; gap: .9rem; }
.static-fields[hidden] { display: none; }
.scan-results { list-style: none; margin: 0; padding: 0; border: 1px solid var(--border); border-radius: 8px; max-height: 240px; overflow: auto; }
.scan-results li button { width: 100%; display: flex; justify-content: space-between; gap: 1rem; padding: .55rem .75rem; background: none; border: 0; border-bottom: 1px solid var(--border); font: inherit; cursor: pointer; text-align: left; }
.scan-results li button:hover { background: #f1f4f8; }
.plain { margin: 0; padding-left: 1.2rem; }
.plain li { margin-bottom: .4rem; }
/* --- alerts --------------------------------------------------------------------------- */
.alert { padding: .7rem .9rem; border-radius: 6px; margin-bottom: 1rem; border: 1px solid; }
.alert-success { background: #e3f5ea; border-color: #b6e2c6; color: #145c33; }
.alert-error { background: #fde6e6; border-color: #f4bcbc; color: #8f2424; }
.alert-warning { background: #fff1d6; border-color: #f3d39b; color: #7a4e00; }
.alert-info { background: #e3eefc; border-color: #bcd4f7; color: #1d4f9c; }
/* --- logs & screenshot ----------------------------------------------------------------- */
.log { background: #0f1b2d; color: #d7e3f4; padding: 1rem; border-radius: 10px; font-size: .8rem; line-height: 1.45; overflow: auto; max-height: 65vh; white-space: pre-wrap; word-break: break-word; margin: 1rem 0 0; }
.log-short { max-height: 280px; margin-top: 0; }
.screenshot { background: #0f1b2d; border-radius: 10px; min-height: 160px; display: flex; align-items: center; justify-content: center; margin-bottom: .8rem; overflow: hidden; }
.screenshot p { color: #b9c7da; padding: 1rem; text-align: center; margin: 0; }
.screenshot img { width: 100%; height: auto; display: block; }
/* --- dialog ------------------------------------------------------------------------------ */
.dialog { border: 0; border-radius: var(--radius); padding: 1.5rem; max-width: min(440px, calc(100vw - 2rem)); box-shadow: 0 20px 60px rgba(0, 0, 0, .3); }
.dialog::backdrop { background: rgba(11, 37, 69, .55); }
.dialog form { margin: 0; }
.dialog-actions { display: flex; justify-content: flex-end; gap: .6rem; margin-top: 1.25rem; flex-wrap: wrap; }
+217
View File
@@ -0,0 +1,217 @@
/* Daynivo 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));
});
}
// Actions menu: close after choosing an item, on a click outside it or on Escape
const menu = $("[data-nav-menu]");
if (menu) {
document.addEventListener("click", (event) => {
if (menu.open && (!menu.contains(event.target) || event.target.closest(".nav-menu-list button"))) menu.open = false;
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && menu.open) menu.open = false;
});
}
// 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));
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -0,0 +1,17 @@
<fieldset class="radio-row">
<legend>IPv4-adres</legend>
<label class="check"><input type="radio" name="method" value="dhcp" {% if p.method != 'static' %}checked{% endif %}> Automatisch (DHCP)</label>
<label class="check"><input type="radio" name="method" value="static" {% if p.method == 'static' %}checked{% endif %}> Statisch</label>
</fieldset>
<div class="static-fields" data-static-fields>
<label>IP-adres met prefix
<input type="text" name="address" value="{{ p.address }}" placeholder="192.168.1.50/24" autocomplete="off" spellcheck="false">
</label>
<label>Gateway
<input type="text" name="gateway" value="{{ p.gateway }}" placeholder="192.168.1.1" autocomplete="off" spellcheck="false">
</label>
</div>
<label>DNS-servers
<input type="text" name="dns" value="{{ p.dns }}" placeholder="Leeg = via DHCP, bijv. 1.1.1.1, 9.9.9.9" autocomplete="off" spellcheck="false">
<small>Maximaal drie, gescheiden door komma's. Verplicht bij een statisch adres.</small>
</label>
@@ -0,0 +1,19 @@
{% macro badge(status, label, field=None) -%}
<span class="badge badge-{{ status }}" {% if field %}data-badge="{{ field }}"{% endif %}><span class="dot" aria-hidden="true"></span><span data-badge-label>{{ label }}</span></span>
{%- endmacro %}
{% macro menu_action(url, label, confirm=None, confirm_title=None, danger=False) -%}
<form method="post" action="{{ url }}" {% if confirm %}data-confirm="{{ confirm }}" data-confirm-title="{{ confirm_title or label }}"{% endif %}>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit"{% if danger %} class="danger"{% endif %}>{{ label }}</button>
</form>
{%- endmacro %}
{% macro csrf() -%}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{%- endmacro %}
{# Width is applied by app.js: the CSP does not allow inline style attributes. #}
{% macro meter(percent, field) -%}
<div class="meter" role="presentation"><span data-meter="{{ field }}" data-pct="{{ percent }}"></span></div>
{%- endmacro %}
+75
View File
@@ -0,0 +1,75 @@
{% from "_macros.html" import menu_action with context %}
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{% block title %}{{ title or "Beheer" }}{% endblock %} · Daynivo Kiosk Manager</title>
<link rel="icon" href="{{ url_for('static', filename='favicon.png') }}" type="image/png">
<link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}?v={{ version }}">
<script src="{{ url_for('static', filename='app.js') }}?v={{ version }}" defer></script>
</head>
<body>
<header class="topbar">
<div class="topbar-inner">
<a class="brand" href="{{ url_for('ui.dashboard') }}">
<span class="brand-mark"><img src="{{ url_for('static', filename='logo-mark.png') }}" alt="" width="34" height="22"></span>
<span><strong>Daynivo</strong> Kiosk Manager</span>
</a>
{% if logged_in %}
<button class="nav-toggle" type="button" aria-controls="mainnav" aria-expanded="false" data-nav-toggle>
<span class="sr-only">Menu</span><span class="bars" aria-hidden="true"></span>
</button>
<nav id="mainnav" class="mainnav">
<a href="{{ url_for('ui.dashboard') }}" {% if active == 'dashboard' %}aria-current="page"{% endif %}>Dashboard</a>
<a href="{{ url_for('ui.kiosk') }}" {% if active == 'kiosk' %}aria-current="page"{% endif %}>Kiosk</a>
<a href="{{ url_for('ui.system') }}" {% if active == 'system' %}aria-current="page"{% endif %}>Systeem</a>
<a href="{{ url_for('ui.network') }}" {% if active == 'network' %}aria-current="page"{% endif %}>Netwerk</a>
<a href="{{ url_for('ui.logs') }}" {% if active == 'logs' %}aria-current="page"{% endif %}>Logboek</a>
<a href="{{ url_for('ui.change_password') }}" {% if active == 'account' %}aria-current="page"{% endif %}>Wachtwoord</a>
<details class="nav-menu" data-nav-menu>
<summary>Acties <span aria-hidden="true">▾</span></summary>
<div class="nav-menu-list">
{{ menu_action(url_for('ui.kiosk_reload'), "Pagina herladen") }}
{{ menu_action(url_for('ui.kiosk_restart'), "Chromium herstarten", confirm="Chromium wordt afgesloten en opnieuw gestart. Het scherm is enkele seconden zwart.") }}
<hr>
{{ menu_action(url_for('ui.system_reboot'), "Raspberry Pi herstarten", confirm="De Raspberry Pi herstart. Het scherm en de beheerinterface zijn ongeveer een minuut niet beschikbaar.", danger=True) }}
{{ menu_action(url_for('ui.system_poweroff'), "Veilig uitschakelen", confirm="De Raspberry Pi wordt uitgeschakeld. Daarna kan hij alleen weer aan door de stroom los en weer vast te maken.", confirm_title="Raspberry Pi uitschakelen", danger=True) }}
</div>
</details>
<form method="post" action="{{ url_for('ui.logout') }}" class="nav-logout">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit">Uitloggen</button>
</form>
</nav>
{% endif %}
</div>
</header>
<main class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}" role="status">{{ message }}</div>
{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<footer class="footer">
<span>{{ device_name }} · {{ hostname }}</span>
<span>Daynivo Kiosk OS {{ version }}</span>
</footer>
<dialog id="confirm-dialog" class="dialog">
<form method="dialog">
<h2 data-confirm-title>Weet je het zeker?</h2>
<p data-confirm-text></p>
<div class="dialog-actions">
<button value="cancel" class="btn btn-secondary">Annuleren</button>
<button value="ok" class="btn btn-danger" data-confirm-ok>Doorgaan</button>
</div>
</form>
</dialog>
</body>
</html>
@@ -0,0 +1,62 @@
{% extends "base.html" %}
{% from "_macros.html" import badge, meter with context %}
{% set title = "Dashboard" %}
{% block content %}
<div class="page-head">
<h1>Dashboard</h1>
<p class="muted">Ververst automatisch elke 5 seconden.</p>
</div>
<div class="grid" data-live-status="{{ url_for('ui.api_status') }}">
<section class="card">
<h2>Kiosk</h2>
<dl class="kv">
<dt>Chromium</dt><dd>{{ badge(s.browser.status, s.browser.label, "browser") }}</dd>
<dt>Kiosk-URL</dt><dd class="break" data-field="url">{{ s.url }}</dd>
<dt>Verbinding</dt><dd>{{ badge(s.network.status, s.network.label, "network") }}</dd>
<dt>Herstarts</dt><dd data-field="browser.restarts">{{ s.browser.restarts }}</dd>
</dl>
{% if s.last_error %}
<p class="note">Laatste melding: <span data-field="last_error.message">{{ s.last_error.message }}</span></p>
{% endif %}
</section>
<section class="card">
<h2>Systeem</h2>
<dl class="kv">
<dt>Uptime</dt><dd data-field="uptime">{{ s.uptime }}</dd>
<dt>CPU-belasting</dt><dd><span data-field="cpu">{{ s.cpu }}</span> <span class="muted">(load <span data-field="load">{{ s.load }}</span>)</span></dd>
<dt>Temperatuur</dt><dd>{{ badge(s.temperature_status, s.temperature, "temperature") }}</dd>
{% if s.power %}<dt>Voeding</dt><dd>{{ badge(s.power.status, s.power.label, "power") }}</dd>{% endif %}
</dl>
</section>
<section class="card">
<h2>Geheugen &amp; opslag</h2>
<dl class="kv">
<dt>RAM</dt><dd><span data-field="memory">{{ s.memory }}</span>{{ meter(s.memory_percent, "memory_percent") }}</dd>
<dt>SD-kaart</dt><dd><span data-field="disk">{{ s.disk }}</span>{{ meter(s.disk_percent, "disk_percent") }}</dd>
</dl>
</section>
<section class="card">
<h2>Netwerk</h2>
<dl class="kv">
<dt>Ethernet</dt><dd data-field="eth0">{{ s.eth0 }}</dd>
<dt>Wifi</dt><dd data-field="wlan0">{{ s.wlan0 }}</dd>
<dt>Hostname</dt><dd>{{ hostname }}.local</dd>
</dl>
</section>
<section class="card">
<h2>Over dit apparaat</h2>
<dl class="kv">
<dt>Naam</dt><dd>{{ device_name }}</dd>
<dt>Versie</dt><dd>Daynivo Kiosk OS {{ s.version }}</dd>
<dt>Besturingssysteem</dt><dd>{{ s.os }}</dd>
<dt>Hardware</dt><dd>{{ s.model }}</dd>
</dl>
</section>
</div>
{% endblock %}
@@ -0,0 +1,7 @@
{% extends "base.html" %}
{% block content %}
<section class="card card-narrow">
<h1>{{ title }}</h1>
<p>{{ message }}</p>
</section>
{% endblock %}
@@ -0,0 +1,69 @@
{% extends "base.html" %}
{% from "_macros.html" import badge, csrf with context %}
{% set title = "Kiosk" %}
{% block content %}
<div class="page-head">
<h1>Kioskbeheer</h1>
{{ badge(s.browser.status, s.browser.label) }}
</div>
<div class="grid grid-2">
<section class="card">
<h2>Instellingen</h2>
<form method="post" action="{{ url_for('ui.kiosk_settings') }}" class="form">
{{ csrf() }}
<label>Kiosk-URL
<input type="url" name="url" value="{{ conf.url }}" required pattern="https://.+" maxlength="2048" autocomplete="off" spellcheck="false">
<small>Alleen HTTPS. Een toegangstoken in de URL (zoals <code>?token=</code> van Daynivo) wordt elders afgeschermd getoond.</small>
</label>
<label>Apparaatnaam
<input type="text" name="device_name" value="{{ conf.device_name }}" required maxlength="64">
</label>
<label>Automatisch verversen
<select name="refresh_interval">
{% set intervals = [(0, "Uit"), (60, "Elke minuut"), (300, "Elke 5 minuten"), (900, "Elk kwartier"), (1800, "Elk half uur"), (3600, "Elk uur"), (21600, "Elke 6 uur"), (86400, "Elke dag")] %}
{% set known = intervals|map(attribute=0)|list %}
{% for value, label in intervals %}<option value="{{ value }}" {% if conf.refresh_interval == value %}selected{% endif %}>{{ label }}</option>{% endfor %}
{% if conf.refresh_interval not in known %}<option value="{{ conf.refresh_interval }}" selected>Elke {{ conf.refresh_interval }} seconden</option>{% endif %}
</select>
<small>Wordt overgeslagen zolang de URL niet bereikbaar is, zodat een offline werkende pagina blijft staan.</small>
</label>
<label>Browserprofiel
<select name="browser_profile">
<option value="persistent" {% if conf.browser_profile == 'persistent' %}selected{% endif %}>Bewaren (cookies en offline-cache blijven na herstart)</option>
<option value="ephemeral" {% if conf.browser_profile == 'ephemeral' %}selected{% endif %}>Alleen in werkgeheugen (schoon bij iedere start)</option>
</select>
</label>
<label class="check"><input type="checkbox" name="browser_restart" value="1" {% if conf.browser_restart %}checked{% endif %}> Chromium automatisch herstarten na een crash</label>
<button type="submit" class="btn btn-primary">Opslaan</button>
</form>
</section>
<section class="card">
<h2>Huidig scherm</h2>
<div class="screenshot" data-screenshot="{{ url_for('ui.kiosk_screenshot') }}">
<p class="muted" data-screenshot-msg>Klik op ‘Screenshot maken’ om te zien wat de kiosk nu toont.</p>
<img alt="Screenshot van het kioskscherm" hidden>
</div>
<button type="button" class="btn btn-secondary" data-screenshot-btn>Screenshot maken</button>
<dl class="kv kv-tight">
<dt>Ingestelde URL</dt><dd class="break">{{ masked_url }}</dd>
<dt>Getoonde pagina</dt><dd class="break">{{ agent.page_url or "–" }}</dd>
<dt>Laatste controle</dt><dd>{% if agent.checked_at %}<time data-epoch="{{ agent.checked_at }}"></time> — {% if agent.target_reachable %}bereikbaar{% else %}{{ agent.reason or "niet bereikbaar" }}{% endif %}{% else %}–{% endif %}</dd>
{% if agent.offline_since %}<dt>Offline sinds</dt><dd><time data-epoch="{{ agent.offline_since }}"></time></dd>{% endif %}
</dl>
</section>
</div>
<section class="card">
<h2>Laatste meldingen</h2>
{% if errors %}
<pre class="log log-short">{% for line in errors %}{{ line }}
{% endfor %}</pre>
{% else %}
<p class="muted">Geen waarschuwingen of fouten.</p>
{% endif %}
<p><a href="{{ url_for('ui.logs', source='kiosk') }}">Alle kiosklogs bekijken →</a></p>
</section>
{% endblock %}
@@ -0,0 +1,17 @@
{% extends "base.html" %}
{% set title = "Inloggen" %}
{% block content %}
<section class="card card-narrow">
<h1>Inloggen</h1>
<p class="muted">{{ device_name }} · {{ hostname }}</p>
{% if error %}<div class="alert alert-error" role="alert">{{ error }}</div>{% endif %}
<form method="post" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label>Beheerderswachtwoord
<input type="password" name="password" required autofocus autocomplete="current-password" maxlength="128">
</label>
<button type="submit" class="btn btn-primary btn-block">Inloggen</button>
</form>
<p class="muted small">Wachtwoord vergeten? Log via SSH in en voer <code>sudo daynivo-kiosk-ctl reset-password</code> uit.</p>
</section>
{% endblock %}
+28
View File
@@ -0,0 +1,28 @@
{% extends "base.html" %}
{% set title = "Logboek" %}
{% block content %}
<div class="page-head"><h1>Logboek</h1></div>
<section class="card">
<form method="get" class="form form-inline">
<label>Bron
<select name="source">
{% for key, item in sources.items() %}<option value="{{ key }}" {% if key == source %}selected{% endif %}>{{ item[0] }}</option>{% endfor %}
</select>
</label>
<label>Regels
<select name="lines">
{% for n in line_options %}<option value="{{ n }}" {% if n == lines %}selected{% endif %}>{{ n }}</option>{% endfor %}
</select>
</label>
<label>Niveau
<select name="priority">
<option value="" {% if not priority %}selected{% endif %}>Alles</option>
<option value="warning" {% if priority == 'warning' %}selected{% endif %}>Waarschuwingen en fouten</option>
<option value="err" {% if priority == 'err' %}selected{% endif %}>Alleen fouten</option>
</select>
</label>
<button type="submit" class="btn btn-primary">Tonen</button>
</form>
<pre class="log" data-scroll-bottom>{{ text }}</pre>
</section>
{% endblock %}
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% set title = "Beheerinterface herstart" %}
{% block content %}
<section class="card card-narrow" data-redirect="{{ target }}" data-redirect-after="8">
<h1>Beheerinterface herstart</h1>
<p>De beheerinterface is straks bereikbaar op:</p>
<p><a href="{{ target }}"><code>{{ target }}</code></a></p>
<p class="muted">Je wordt over enkele seconden automatisch doorgestuurd. Bij HTTPS met een zelfondertekend
certificaat vraagt je browser eenmalig om bevestiging.</p>
</section>
{% endblock %}
@@ -0,0 +1,100 @@
{% extends "base.html" %}
{% from "_macros.html" import badge, csrf with context %}
{% set title = "Netwerk" %}
{% block content %}
<div class="page-head"><h1>Netwerkbeheer</h1></div>
{% if pending %}
<section class="card card-pending" data-pending="{{ url_for('ui.api_network_pending') }}" data-remaining="{{ pending.remaining }}">
<h2>Netwerkwijziging bevestigen</h2>
<p>De nieuwe instellingen voor <strong>{{ 'ethernet' if pending.interface == 'eth0' else 'wifi' }}</strong>
{% if pending.method == 'static' %}(statisch adres <strong>{{ pending.address }}</strong>){% else %}(DHCP){% endif %} zijn actief.
Werkt de beheerinterface nog? Bevestig dan. Zonder bevestiging worden over
<strong data-countdown>{{ pending.remaining }}</strong> seconden automatisch de vorige instellingen teruggezet.</p>
{% if pending.method == 'static' %}
<p class="muted">Is het adres veranderd? Open dan <code>{{ admin_scheme }}://{{ pending.address.split('/')[0] }}:{{ admin_port }}/network</code>, log in en bevestig daar.</p>
{% endif %}
<div class="actions-row">
<form method="post" action="{{ url_for('ui.network_confirm') }}" class="inline-form">{{ csrf() }}<button class="btn btn-primary btn-big">Bevestigen</button></form>
<form method="post" action="{{ url_for('ui.network_rollback') }}" class="inline-form">{{ csrf() }}<button class="btn btn-secondary btn-big">Nu terugdraaien</button></form>
</div>
</section>
{% endif %}
{% if not net.available %}
<div class="alert alert-error">NetworkManager (nmcli) is niet beschikbaar op dit systeem.</div>
{% endif %}
<div class="grid grid-2">
{% for iface in ['eth0', 'wlan0'] %}
{% set d = net.devices[iface] %}
<section class="card">
<h2>{{ 'Ethernet' if iface == 'eth0' else 'Wifi' }} <span class="muted small">({{ iface }})</span></h2>
<dl class="kv">
<dt>Status</dt><dd>{{ badge('ok' if d.connected else ('starting' if d.state == 'connecting' else 'stopped'), d.state_label) }}</dd>
{% if d.connection %}<dt>Verbinding</dt><dd>{{ d.connection }}</dd>{% endif %}
<dt>IP-adres</dt><dd>{{ d.addresses|join(', ') or '–' }}</dd>
<dt>Gateway</dt><dd>{{ d.gateway or '–' }}</dd>
<dt>DNS</dt><dd>{{ d.dns|join(', ') or '–' }}</dd>
{% if d.hwaddr %}<dt>MAC</dt><dd><code>{{ d.hwaddr }}</code></dd>{% endif %}
</dl>
</section>
{% endfor %}
</div>
<div class="grid grid-2">
<section class="card">
<h2>Ethernet instellen</h2>
{% set p = net.devices.eth0.profile %}
<form method="post" action="{{ url_for('ui.network_apply') }}" class="form" data-ipform
data-confirm="De netwerkinstellingen worden gewijzigd. Bevestig daarna binnen {{ rollback_seconds // 60 }} minuten, anders worden ze teruggezet." data-confirm-title="Ethernet wijzigen">
{{ csrf() }}
<input type="hidden" name="interface" value="eth0">
{% include "_ipv4_fields.html" %}
<button type="submit" class="btn btn-primary">Toepassen</button>
</form>
</section>
<section class="card">
<h2>Wifi instellen</h2>
{% set p = net.devices.wlan0.profile %}
<form method="post" action="{{ url_for('ui.network_apply') }}" class="form" data-ipform
data-confirm="De wifi-instellingen worden gewijzigd. Bevestig daarna binnen {{ rollback_seconds // 60 }} minuten, anders worden ze teruggezet." data-confirm-title="Wifi wijzigen">
{{ csrf() }}
<input type="hidden" name="interface" value="wlan0">
<label>Netwerknaam (SSID)
<span class="input-row">
<input type="text" name="ssid" value="{{ p.ssid }}" required maxlength="32" list="wifi-networks" autocomplete="off" spellcheck="false">
<button type="button" class="btn btn-secondary" data-wifi-scan="{{ url_for('ui.network_scan') }}">Scannen</button>
</span>
<datalist id="wifi-networks"></datalist>
</label>
<ul class="scan-results" data-scan-results hidden></ul>
<label>Wachtwoord
<input type="password" name="psk" minlength="8" maxlength="64" autocomplete="new-password" {% if p.exists %}placeholder="Leeg laten om het huidige wachtwoord te behouden"{% endif %}>
</label>
<label class="check"><input type="checkbox" name="open" value="1"> Open netwerk (zonder wachtwoord)</label>
<label class="check"><input type="checkbox" name="hidden" value="1" {% if p.hidden %}checked{% endif %}> Verborgen netwerk</label>
{% include "_ipv4_fields.html" %}
<button type="submit" class="btn btn-primary">Toepassen</button>
</form>
<h2 class="mt">Wifi-land</h2>
<form method="post" action="{{ url_for('ui.network_country') }}" class="form form-inline">
{{ csrf() }}
<label>Landcode
<input type="text" name="country" value="{{ net.wifi_country or 'NL' }}" required maxlength="2" pattern="[A-Za-z]{2}" class="input-short">
</label>
<button type="submit" class="btn btn-secondary">Instellen</button>
</form>
</section>
</div>
<section class="card">
<h2>Herstel bij een onbereikbare kiosk</h2>
<ul class="plain">
<li>Iedere wijziging wordt na {{ rollback_seconds // 60 }} minuten automatisch teruggedraaid als je hem niet bevestigt, ook als de stroom tussendoor uitvalt.</li>
<li>Noodherstel: zet een leeg bestand <code>daynivo-network-reset</code> op de bootpartitie van de SD-kaart en start de Pi. Alle Daynivo-netwerkprofielen worden dan verwijderd (terug naar DHCP).</li>
</ul>
</section>
{% endblock %}
@@ -0,0 +1,21 @@
{% extends "base.html" %}
{% set title = "Wachtwoord wijzigen" %}
{% block content %}
<section class="card card-narrow">
<h1>Wachtwoord wijzigen</h1>
<form method="post" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label>Huidig wachtwoord
<input type="password" name="current_password" required autocomplete="current-password" maxlength="128">
</label>
<label>Nieuw wachtwoord
<input type="password" name="password" required minlength="10" maxlength="128" autocomplete="new-password">
</label>
<label>Herhaal nieuw wachtwoord
<input type="password" name="password_confirm" required minlength="10" maxlength="128" autocomplete="new-password">
</label>
<button type="submit" class="btn btn-primary btn-block">Wijzigen</button>
</form>
<p class="muted small">Na het wijzigen worden alle andere sessies uitgelogd.</p>
</section>
{% endblock %}
@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% set title = "Eerste configuratie" %}
{% block content %}
<section class="card card-narrow">
<h1>Welkom bij Daynivo Kiosk Manager</h1>
<p>Stel eerst een uniek beheerderswachtwoord in. Je hebt daarvoor de <strong>setupcode</strong> nodig die op het
scherm van de kiosk staat (of via SSH: <code>sudo daynivo-kiosk-ctl setup-code</code>).</p>
{% if error %}<div class="alert alert-error" role="alert">{{ error }}</div>{% endif %}
<form method="post" class="form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label>Setupcode
<input type="text" name="setup_code" required autocomplete="off" autocapitalize="characters" spellcheck="false" placeholder="ABCD-EFGH" maxlength="12" class="input-code">
</label>
<label>Nieuw wachtwoord
<input type="password" name="password" required minlength="10" maxlength="128" autocomplete="new-password">
<small>Minimaal 10 tekens. Gebruik voor iedere kiosk een eigen wachtwoord.</small>
</label>
<label>Herhaal wachtwoord
<input type="password" name="password_confirm" required minlength="10" maxlength="128" autocomplete="new-password">
</label>
<button type="submit" class="btn btn-primary btn-block">Wachtwoord instellen</button>
</form>
</section>
{% endblock %}
+109
View File
@@ -0,0 +1,109 @@
{% extends "base.html" %}
{% from "_macros.html" import csrf with context %}
{% set title = "Systeem" %}
{% block content %}
<div class="page-head"><h1>Systeembeheer</h1></div>
<div class="grid grid-2">
<section class="card">
<h2>Scherm</h2>
<form method="post" action="{{ url_for('ui.system_display') }}" class="form">
{{ csrf() }}
<label>Rotatie
<select name="rotation">
{% for deg, label in [(0, "0° (normaal)"), (90, "90° (rechtsom)"), (180, "180° (ondersteboven)"), (270, "270° (linksom)")] %}
<option value="{{ deg }}" {% if conf.rotation == deg %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label>Resolutie
<select name="resolution">
<option value="auto" {% if conf.resolution == 'auto' %}selected{% endif %}>Automatisch (voorkeur van het scherm)</option>
{% for mode in modes %}<option value="{{ mode }}" {% if conf.resolution == mode %}selected{% endif %}>{{ mode }}</option>{% endfor %}
{% if conf.resolution != 'auto' and conf.resolution not in modes %}<option value="{{ conf.resolution }}" selected>{{ conf.resolution }}</option>{% endif %}
</select>
<small>{% if output %}Aangesloten: {{ output.name }}{% if output.current %}, nu {{ output.current }}{% endif %}.{% else %}Schermgegevens verschijnen zodra de kiosk draait.{% endif %}</small>
</label>
<label>Zoomfactor
<input type="number" name="zoom" min="0.25" max="4" step="0.05" value="{{ conf.zoom }}">
<small>1 = normaal. Groter maakt alles groter (handig op grote tv's).</small>
</label>
<label class="check"><input type="checkbox" name="screen_always_on" value="1" {% if conf.screen_always_on %}checked{% endif %} data-toggle-target="#screen-off"> Scherm altijd aan (geen screensaver of energiebeheer)</label>
<label id="screen-off">Scherm uitschakelen na (minuten zonder invoer)
<input type="number" name="screen_off_minutes" min="1" max="240" value="{{ conf.screen_off_minutes }}">
</label>
<label class="check"><input type="checkbox" name="apply_now" value="1" checked> Direct toepassen (Chromium herstart)</label>
<button type="submit" class="btn btn-primary">Opslaan</button>
</form>
</section>
<section class="card">
<h2>Hostname</h2>
<form method="post" action="{{ url_for('ui.system_hostname') }}" class="form">
{{ csrf() }}
<label>Hostname
<input type="text" name="hostname" value="{{ hostname }}" required maxlength="63" pattern="[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?" autocomplete="off" spellcheck="false">
<small>De beheerinterface is daarna bereikbaar via <code>http://&lt;hostname&gt;.local:{{ conf.admin_port }}</code>. Geef iedere kiosk op hetzelfde netwerk een unieke naam.</small>
</label>
<button type="submit" class="btn btn-primary">Hostname wijzigen</button>
</form>
<h2 class="mt">Beheerinterface</h2>
<form method="post" action="{{ url_for('ui.system_admin') }}" class="form" data-confirm="Als je poort of HTTPS wijzigt, herstart de beheerinterface en word je doorgestuurd naar het nieuwe adres." data-confirm-title="Beheerinterface aanpassen">
{{ csrf() }}
<label>Poort
<input type="number" name="admin_port" min="1024" max="65535" value="{{ conf.admin_port }}">
</label>
<label class="check"><input type="checkbox" name="admin_https" value="1" {% if conf.admin_https %}checked{% endif %}> HTTPS gebruiken (zelfondertekend certificaat)</label>
<label class="check"><input type="checkbox" name="admin_allow_public" value="1" {% if conf.admin_allow_public %}checked{% endif %}> Toegang vanaf publieke IP-adressen toestaan <span class="muted">(afgeraden; gebruik liever VPN)</span></label>
<button type="submit" class="btn btn-secondary">Opslaan</button>
</form>
</section>
</div>
<section class="card" id="updates">
<h2>Software-updates</h2>
<dl class="kv">
<dt>Geïnstalleerd</dt><dd>Daynivo Kiosk OS {{ s.version }}</dd>
<dt>Nieuwste versie</dt>
<dd>
{% if update.check_error %}<span class="muted">Onbekend — controleren mislukt: {{ update.check_error }}</span>
{% elif update.latest %}{{ update.latest }}{% if update.available %} <strong>— beschikbaar</strong>{% else %} <span class="muted">(je bent bij)</span>{% endif %}
{% else %}<span class="muted">Nog niet gecontroleerd</span>{% endif %}
</dd>
{% if update.checked %}<dt>Gecontroleerd</dt><dd>{{ update.checked }}</dd>{% endif %}
{% if update.running %}<dt>Status</dt><dd>Bezig met bijwerken naar {{ update.target }}…</dd>
{% elif update.status == 'failed' %}<dt>Laatste update</dt><dd>Mislukt{% if update.finished %} op {{ update.finished }}{% endif %}: {{ update.install_error }}</dd>
{% elif update.status == 'done' and update.finished %}<dt>Laatste update</dt><dd>Gelukt op {{ update.finished }}</dd>{% endif %}
</dl>
<form method="post" action="{{ url_for('ui.system_updates') }}" class="form mt-sm">
{{ csrf() }}
<label class="check"><input type="checkbox" name="auto_update" value="1" {% if conf.auto_update %}checked{% endif %}> Automatisch bijwerken <span class="muted">(elke nacht tussen 03:00 en 05:00, als er een nieuwe versie is)</span></label>
<button type="submit" class="btn btn-secondary">Opslaan</button>
</form>
<div class="actions-row mt-sm">
<form method="post" action="{{ url_for('ui.system_update_check') }}" class="inline-form">{{ csrf() }}<button type="submit" class="btn btn-secondary">Nu controleren</button></form>
{% if update.available and not update.running %}
<form method="post" action="{{ url_for('ui.system_update_install') }}" class="inline-form"
data-confirm="Daynivo Kiosk OS wordt bijgewerkt naar {{ update.latest }}. Het scherm en de beheerinterface zijn een paar minuten niet beschikbaar." data-confirm-title="Nu bijwerken">
{{ csrf() }}<button type="submit" class="btn btn-primary">Bijwerken naar {{ update.latest }}</button>
</form>
{% endif %}
</div>
<p class="muted small">De configuratie blijft behouden en wordt voor elke update eerst bewaard in <code>/var/backups</code>.</p>
</section>
<section class="card">
<h2>Systeeminformatie</h2>
<dl class="kv">
<dt>Versie</dt><dd>Daynivo Kiosk OS {{ s.version }}</dd>
<dt>Besturingssysteem</dt><dd>{{ s.os }}</dd>
<dt>Hardware</dt><dd>{{ s.model }}</dd>
<dt>Uptime</dt><dd>{{ s.uptime }}</dd>
</dl>
<p><a href="{{ url_for('ui.logs', source='system') }}">Systeemlogs bekijken →</a></p>
</section>
{% endblock %}
+603
View File
@@ -0,0 +1,603 @@
"""Routes of the Daynivo Kiosk Manager."""
from __future__ import annotations
import json
import logging
import socket
import time
from typing import Any
from flask import (Blueprint, Response, abort, current_app, flash, g, jsonify, redirect, render_template, request,
session, url_for)
from .. import netconfig, netinfo, services, sysinfo
from ..agent import read_state
from ..updater import read_state as read_update_state
from ..cdp import CDPError
from ..config import ConfigError, load_config, save_config
from ..helper_client import HelperError
from ..validation import ValidationError, mask_url
from . import security
from .auth import password_problem
bp = Blueprint("ui", __name__)
audit_log = logging.getLogger("voxip-kiosk-audit")
IDLE_TIMEOUT = 3600
PUBLIC_ENDPOINTS = {"ui.login", "ui.setup", "static"}
def svc():
return current_app.extensions["voxip"]
def cfg() -> dict[str, Any]:
if "cfg" not in g:
g.cfg = load_config(svc().paths.config_file)
return g.cfg
def client_ip() -> str:
return request.remote_addr or "?"
def audit(event: str, **details: Any) -> None:
record = {"time": int(time.time()), "event": event, "ip": client_ip(), **details}
audit_log.info(json.dumps(record, ensure_ascii=False))
try:
with open(svc().paths.audit_log, "a", encoding="utf-8") as fh:
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
except OSError:
pass
def wants_json() -> bool:
return request.path.startswith("/api/") or request.headers.get("Accept", "").startswith("application/json")
_local_nets_cache: tuple[float, list[str]] = (0.0, [])
def local_networks() -> list[str]:
global _local_nets_cache
now = time.monotonic()
if now - _local_nets_cache[0] > 60:
nets = [a for addrs in sysinfo.ip_addresses().values() for a in addrs]
_local_nets_cache = (now, nets)
return _local_nets_cache[1]
# -- request pipeline ----------------------------------------------------------
@bp.before_app_request
def guard():
if not security.client_allowed(request.remote_addr, cfg()["admin_allow_public"], local_networks()):
abort(403)
if request.endpoint == "static":
return None
creds = svc().credentials
if not creds.is_configured():
if request.endpoint != "ui.setup":
return redirect(url_for("ui.setup"))
elif request.endpoint == "ui.setup":
return redirect(url_for("ui.dashboard"))
if session.get("auth"):
expired = time.time() - session.get("seen", 0) > IDLE_TIMEOUT
if expired or session.get("epoch") != creds.session_epoch():
session.clear()
flash("Je sessie is verlopen. Log opnieuw in.", "info")
if request.endpoint not in PUBLIC_ENDPOINTS and not session.get("auth"):
if wants_json():
return jsonify(error="Niet ingelogd"), 401
return redirect(url_for("ui.login"))
if session.get("auth"):
session["seen"] = int(time.time())
if request.method == "POST" and not security.csrf_valid(request):
audit("csrf_rejected", path=request.path)
if wants_json():
return jsonify(error="Beveiligingstoken ongeldig. Herlaad de pagina."), 400
flash("Beveiligingstoken ongeldig of verlopen. Probeer het opnieuw.", "error")
return redirect(request.path if request.endpoint in PUBLIC_ENDPOINTS else url_for("ui.dashboard")), 303
return None
@bp.after_app_request
def headers(response: Response):
return security.apply_headers(response, secure=current_app.config["SESSION_COOKIE_SECURE"])
@bp.app_context_processor
def template_globals():
return {
"csrf_token": security.csrf_token,
"version": svc().version,
"device_name": cfg()["device_name"],
"hostname": socket.gethostname().split(".")[0],
"logged_in": bool(session.get("auth")),
"admin_port": cfg()["admin_port"],
"admin_scheme": "https" if cfg()["admin_https"] else "http",
}
@bp.app_errorhandler(403)
def forbidden(_):
return render_template("error.html", title="Geen toegang",
message="De beheerinterface is alleen bereikbaar vanaf het lokale netwerk of via VPN."), 403
@bp.app_errorhandler(404)
def not_found(_):
return render_template("error.html", title="Niet gevonden", message="Deze pagina bestaat niet."), 404
@bp.app_errorhandler(413)
def too_large(_):
return render_template("error.html", title="Te groot", message="Het verzoek is te groot."), 413
# -- setup & login --------------------------------------------------------------
@bp.route("/setup", methods=["GET", "POST"])
def setup():
creds = svc().credentials
error = None
if request.method == "POST":
ip = client_ip()
wait = svc().throttle.retry_after(ip)
password = request.form.get("password", "")
if wait:
error = f"Te veel pogingen. Probeer het over {wait} seconden opnieuw."
elif not creds.check_setup_code(request.form.get("setup_code", "")):
svc().throttle.failure(ip)
audit("setup_code_rejected")
error = "De setupcode klopt niet. Je vindt de code op het scherm van de kiosk."
else:
error = password_problem(password, request.form.get("password_confirm", ""))
if error is None:
epoch = creds.set_password(password)
svc().throttle.success(ip)
session.clear()
session.update(auth=True, epoch=epoch, seen=int(time.time()))
session.permanent = True
audit("setup_completed")
flash("Het beheerderswachtwoord is ingesteld. Welkom!", "success")
return redirect(url_for("ui.dashboard"))
return render_template("setup.html", error=error)
@bp.route("/login", methods=["GET", "POST"])
def login():
if session.get("auth"):
return redirect(url_for("ui.dashboard"))
error = None
if request.method == "POST":
ip = client_ip()
wait = svc().throttle.retry_after(ip)
if wait:
error = f"Te veel mislukte pogingen. Probeer het over {wait} seconden opnieuw."
elif svc().credentials.verify(request.form.get("password", "")):
svc().throttle.success(ip)
session.clear()
session.update(auth=True, epoch=svc().credentials.session_epoch(), seen=int(time.time()))
session.permanent = True
audit("login")
return redirect(url_for("ui.dashboard"))
else:
svc().throttle.failure(ip)
audit("login_failed")
error = "Onjuist wachtwoord."
return render_template("login.html", error=error)
@bp.post("/logout")
def logout():
audit("logout")
session.clear()
flash("Je bent uitgelogd.", "info")
return redirect(url_for("ui.login"))
@bp.route("/account/password", methods=["GET", "POST"])
def change_password():
if request.method == "POST":
creds = svc().credentials
if not creds.verify(request.form.get("current_password", "")):
audit("password_change_failed")
flash("Het huidige wachtwoord klopt niet.", "error")
else:
password = request.form.get("password", "")
problem = password_problem(password, request.form.get("password_confirm", ""))
if problem:
flash(problem, "error")
else:
epoch = creds.set_password(password)
session["epoch"] = epoch
audit("password_changed")
flash("Wachtwoord gewijzigd. Andere sessies zijn uitgelogd.", "success")
return redirect(url_for("ui.dashboard"))
return render_template("password.html", active="account")
# -- dashboard -------------------------------------------------------------------
def browser_state(units: dict[str, Any], agent: dict[str, Any]) -> tuple[str, str]:
unit = units.get("browser", {})
if unit.get("status") != "ok":
return unit.get("status", "unknown"), unit.get("label", "Onbekend")
if agent.get("browser") == "unreachable":
return "starting", "Bezig met starten"
page = agent.get("page")
if page == "offline":
return "warning", "Actief — offlinepagina"
if page == "setup":
return "warning", "Actief — wacht op initiële configuratie"
if page in ("start", "blank", "error"):
return "starting", "Pagina wordt geladen"
return "ok", "Actief"
def collect_status() -> dict[str, Any]:
conf = cfg()
paths = svc().paths
agent = read_state(paths.agent_state_file)
units = services.summary()
b_status, b_label = browser_state(units, agent)
mem = sysinfo.memory()
disk = sysinfo.disk("/")
temp = sysinfo.temperature_c()
load = sysinfo.load_average()
cpu = sysinfo.cpu_percent()
throttle = sysinfo.throttled()
reachable = agent.get("target_reachable")
if reachable is None:
net_status, net_label = "unknown", "Nog niet gecontroleerd"
elif reachable:
net_status, net_label = "ok", "Online — kiosk-URL bereikbaar"
else:
net_status, net_label = "error", f"Offline — {agent.get('reason') or 'niet bereikbaar'}"
addresses = sysinfo.ip_addresses()
return {
"browser": {"status": b_status, "label": b_label, "restarts": units.get("browser", {}).get("restarts", "0")},
"agent": units.get("agent", {}),
"url": mask_url(conf["url"]),
"refresh_interval": conf["refresh_interval"],
"uptime": sysinfo.format_duration(sysinfo.uptime_seconds()),
"cpu": f"{cpu:.0f}%".replace(".", ",") if cpu is not None else "–",
"cpu_percent": cpu or 0,
"load": " / ".join(f"{x:.2f}".replace(".", ",") for x in load) if load else "–",
"temperature": f"{temp:.1f} °C".replace(".", ",") if temp is not None else "–",
"temperature_status": "error" if temp and temp >= 80 else "warning" if temp and temp >= 70 else "ok",
"memory": f"{sysinfo.format_bytes(mem['used'])} van {sysinfo.format_bytes(mem['total'])}" if mem else "–",
"memory_percent": mem["percent"] if mem else 0,
"disk": f"{sysinfo.format_bytes(disk['used'])} van {sysinfo.format_bytes(disk['total'])}" if disk else "–",
"disk_percent": disk["percent"] if disk else 0,
"eth0": ", ".join(addresses.get("eth0", [])) or "Niet verbonden",
"wlan0": ", ".join(addresses.get("wlan0", [])) or "Niet verbonden",
"network": {"status": net_status, "label": net_label},
"power": {"status": "error" if throttle and throttle["now"] else "warning" if throttle and throttle["ever"] else "ok",
"label": ", ".join(throttle["flags"]) if throttle and throttle["flags"] else "In orde"} if throttle else None,
"last_error": agent.get("last_error"),
"version": svc().version,
"os": sysinfo.os_release(),
"model": sysinfo.model(),
}
@bp.get("/")
def dashboard():
return render_template("dashboard.html", active="dashboard", s=collect_status())
@bp.get("/api/status")
def api_status():
return jsonify(collect_status())
# -- kiosk -----------------------------------------------------------------------
@bp.get("/kiosk")
def kiosk():
agent = read_state(svc().paths.agent_state_file)
return render_template("kiosk.html", active="kiosk", conf=cfg(), agent=agent,
errors=services.recent_errors(25), masked_url=mask_url(cfg()["url"]),
s={"browser": dict(zip(("status", "label"), browser_state(services.summary(), agent)))})
def _form_bool(name: str) -> bool:
return request.form.get(name) in ("1", "on", "true", "yes")
def _save(updates: dict[str, Any]) -> dict[str, Any] | None:
try:
return save_config(svc().paths.config_file, updates)
except ConfigError as exc:
for message in exc.errors.values():
flash(message, "error")
except OSError as exc:
flash(f"Opslaan mislukt: {exc}", "error")
return None
@bp.post("/kiosk/settings")
def kiosk_settings():
updates = {
"url": request.form.get("url", ""),
"device_name": request.form.get("device_name", ""),
"refresh_interval": request.form.get("refresh_interval", "0"),
"browser_restart": _form_bool("browser_restart"),
"browser_profile": request.form.get("browser_profile", "persistent"),
}
old = cfg()
new = _save(updates)
if new is not None:
changed = sorted(k for k in updates if old.get(k) != new.get(k))
audit("kiosk_settings", changed=changed, url=mask_url(new["url"]))
message = "Instellingen opgeslagen."
if "url" in changed:
message += " De kiosk opent de nieuwe URL binnen enkele seconden."
if "browser_profile" in changed:
message += " Het profieltype geldt na het herstarten van Chromium."
flash(message, "success")
return redirect(url_for("ui.kiosk"))
def _action_response(ok: bool, message: str, back: str):
if wants_json():
return jsonify(ok=ok, message=message), (200 if ok else 500)
flash(message, "success" if ok else "error")
return redirect(back)
@bp.post("/kiosk/reload")
def kiosk_reload():
try:
svc().cdp.reload()
audit("page_reload")
return _action_response(True, "De pagina wordt herladen.", request.referrer or url_for("ui.dashboard"))
except CDPError as exc:
current_app.logger.warning("Herladen mislukt: %s", exc)
return _action_response(
False,
"Herladen mislukt: Chromium reageert niet. Als hij net (her)start, probeer het over een halve minuut opnieuw.",
request.referrer or url_for("ui.dashboard"),
)
def _helper_action(event: str, verb: str, *args: str, back: str):
try:
result = svc().helper.call(verb, *args)
audit(event, **({"args": list(args)} if args else {}))
return _action_response(True, result.get("message", "Uitgevoerd."), back)
except HelperError as exc:
audit(event + "_failed", error=str(exc))
return _action_response(False, str(exc), back)
@bp.post("/kiosk/restart")
def kiosk_restart():
return _helper_action("browser_restart", "restart-browser", back=request.referrer or url_for("ui.dashboard"))
@bp.get("/kiosk/screenshot.jpg")
def kiosk_screenshot():
try:
data = svc().cdp.screenshot(quality=70)
except CDPError as exc:
return Response(f"Screenshot niet beschikbaar: {exc}", status=503, mimetype="text/plain")
return Response(data, mimetype="image/jpeg")
# -- system ----------------------------------------------------------------------
def display_info() -> dict[str, Any]:
try:
return json.loads(svc().paths.display_info_file.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
@bp.get("/system")
def system():
info = display_info()
outputs = info.get("outputs") or []
modes = outputs[0]["modes"] if outputs else []
return render_template("system.html", active="system", conf=cfg(), modes=modes,
output=outputs[0] if outputs else None, s=collect_status(), update=update_view())
def _when(timestamp: Any) -> str | None:
if not isinstance(timestamp, (int, float)):
return None
return time.strftime("%d-%m-%Y %H:%M", time.localtime(timestamp))
def update_view() -> dict[str, Any]:
"""The updater's last result (update.json, written by root) in display form."""
state = read_update_state(svc().paths)
return {
**state,
"checked": _when(state.get("checked_at")),
"finished": _when(state.get("finished_at")),
# A run that started more than two hours ago and never finished is reported as failed.
"running": state.get("status") == "updating" and time.time() - state.get("started_at", 0) < 7200,
}
@bp.post("/system/display")
def system_display():
updates = {
"rotation": request.form.get("rotation", "0"),
"resolution": request.form.get("resolution", "auto"),
"zoom": request.form.get("zoom", "1").replace(",", "."),
"screen_always_on": _form_bool("screen_always_on"),
"screen_off_minutes": request.form.get("screen_off_minutes", "10"),
}
if _save(updates) is None:
return redirect(url_for("ui.system"))
audit("display_settings", **{k: v for k, v in updates.items()})
if _form_bool("apply_now"):
return _helper_action("browser_restart", "restart-browser", back=url_for("ui.system"))
flash("Scherminstellingen opgeslagen. Ze worden actief bij de volgende herstart van Chromium.", "success")
return redirect(url_for("ui.system"))
@bp.post("/system/hostname")
def system_hostname():
return _helper_action("hostname", "set-hostname", request.form.get("hostname", "").strip().lower(), back=url_for("ui.system"))
@bp.post("/system/reboot")
def system_reboot():
return _helper_action("reboot", "reboot", back=url_for("ui.dashboard"))
@bp.post("/system/poweroff")
def system_poweroff():
return _helper_action("poweroff", "poweroff", back=url_for("ui.dashboard"))
@bp.post("/system/admin")
def system_admin():
updates = {
"admin_port": request.form.get("admin_port", "8080"),
"admin_https": _form_bool("admin_https"),
"admin_allow_public": _form_bool("admin_allow_public"),
}
old = cfg()
new = _save(updates)
if new is None:
return redirect(url_for("ui.system"))
audit("admin_settings", **updates)
if any(old[k] != new[k] for k in ("admin_port", "admin_https")):
scheme = "https" if new["admin_https"] else "http"
target = f"{scheme}://{request.host.rsplit(':', 1)[0]}:{new['admin_port']}/"
try:
svc().helper.call("restart-manager")
except HelperError as exc:
flash(f"Opgeslagen, maar herstarten van de beheerinterface mislukte: {exc}", "error")
return redirect(url_for("ui.system"))
return render_template("moved.html", target=target)
flash("Instellingen opgeslagen.", "success")
return redirect(url_for("ui.system"))
@bp.post("/system/updates")
def system_updates():
auto = _form_bool("auto_update")
if _save({"auto_update": auto}) is not None:
audit("update_settings", auto_update=auto)
flash("Automatisch bijwerken staat " + ("aan." if auto else "uit."), "success")
return redirect(url_for("ui.system") + "#updates")
@bp.post("/system/update/check")
def system_update_check():
back = url_for("ui.system") + "#updates"
try:
state = svc().helper.call("update-check", timeout=60).get("update", {})
except HelperError as exc:
return _action_response(False, str(exc), back)
if state.get("check_error"):
return _action_response(False, f"Controleren mislukt: {state['check_error']}", back)
if state.get("available"):
return _action_response(True, f"Versie {state.get('latest')} is beschikbaar.", back)
return _action_response(True, f"Je hebt de nieuwste versie ({state.get('current')}).", back)
@bp.post("/system/update/install")
def system_update_install():
return _helper_action("update_install", "update-install", back=url_for("ui.system") + "#updates")
# -- logs ------------------------------------------------------------------------
@bp.get("/logs")
def logs():
source = request.args.get("source", "kiosk")
if source not in services.LOG_SOURCES:
source = "kiosk"
try:
lines = int(request.args.get("lines", 100))
except ValueError:
lines = 100
priority = request.args.get("priority") or None
text = services.journal(source, lines, priority)
return render_template("logs.html", active="logs", text=text, source=source, lines=lines, priority=priority,
sources=services.LOG_SOURCES, line_options=services.LOG_LINES)
# -- network -----------------------------------------------------------------------
def _pending() -> dict[str, Any] | None:
pending = netinfo.pending_change(svc().paths.network_pending_file)
if pending:
pending = {k: pending.get(k) for k in ("interface", "method", "address", "created", "deadline")}
pending["remaining"] = max(0, int(pending.get("deadline") or 0) - int(time.time()))
return pending
@bp.get("/network")
def network():
return render_template("network.html", active="network", net=netinfo.overview(), pending=_pending(),
rollback_seconds=netconfig.ROLLBACK_SECONDS)
@bp.get("/api/network/pending")
def api_network_pending():
return jsonify(pending=_pending())
@bp.post("/network/apply")
def network_apply():
interface = request.form.get("interface", "")
payload: dict[str, Any] = {
"interface": interface,
"method": request.form.get("method", "dhcp"),
"address": request.form.get("address", ""),
"gateway": request.form.get("gateway", ""),
"dns": request.form.get("dns", ""),
}
if interface == "wlan0":
payload["wifi"] = {
"ssid": request.form.get("ssid", ""),
"psk": request.form.get("psk", ""),
"hidden": _form_bool("hidden"),
"open": _form_bool("open"),
}
try:
netconfig.validate_request(payload) # early feedback; the helper validates again
svc().helper.call("network-apply", payload=payload, timeout=60)
except (ValidationError, HelperError) as exc:
flash(str(exc), "error")
return redirect(url_for("ui.network"))
audit("network_apply", interface=interface, method=payload["method"], address=payload["address"])
flash(f"Netwerkwijziging toegepast. Bevestig binnen {netconfig.ROLLBACK_SECONDS // 60} minuten, anders worden de "
"vorige instellingen automatisch teruggezet.", "warning")
return redirect(url_for("ui.network"))
@bp.post("/network/confirm")
def network_confirm():
return _helper_action("network_confirm", "network-confirm", back=url_for("ui.network"))
@bp.post("/network/rollback")
def network_rollback():
return _helper_action("network_rollback", "network-rollback", back=url_for("ui.network"))
@bp.post("/network/country")
def network_country():
return _helper_action("wifi_country", "set-wifi-country", request.form.get("country", "").strip().upper(), back=url_for("ui.network"))
@bp.post("/api/network/scan")
def network_scan():
try:
result = svc().helper.call("wifi-scan", timeout=60)
except HelperError as exc:
return jsonify(error=str(exc)), 500
return jsonify(networks=result.get("networks", []))