Rename the Python package to daynivo_kiosk
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:
1 parent
d0f3206ff4
commit
6733aacb6e
62 files changed
+43
-43
No files matched your search
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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; }
|
||||
@@ -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 %}
|
||||
@@ -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 & 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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://<hostname>.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 %}
|
||||
@@ -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", []))
|
||||
Reference in new issue
Block a user