commit c85df728b7041a14fcfac1ce730dd901d67881bc Author: Aaron Date: Wed Dec 10 18:51:31 2025 -0500 Add dashboard UI updates and settings modal diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ef8a0b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Node +node_modules/ +pikit-web/node_modules/ +pikit-web/dist/ +pikit-web/test-results/ +pikit-web/.vite/ +pikit-web/coverage/ +pikit-web/playwright-report/ +pikit-web/.cache/ + +# Python +__pycache__/ +*.pyc + +# OS/Editor +.DS_Store +Thumbs.db +*.swp + +# Build artifacts +*.log + +# QEMU images / large artifacts +qemu-dietpi/ + +# Stock images (large) +images/stock/ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..016c3f8 --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# Pi-Kit Dashboard + +Lightweight dashboard for DietPi-based Pi-Kit images. Two pieces live in this repo: + +- `pikit-api.py`: tiny Python HTTP API (status, services, auto updates, factory reset). Runs on localhost:4000 and writes to `/etc/pikit/services.json`. +- `pikit-web/`: static Vite site served by nginx from `/var/www/pikit-web`. Sources live in `pikit-web/assets/`; Playwright E2E tests in `pikit-web/tests/`. + +## Local development +- Dashboard: `cd pikit-web && npm install` (first run), then `npm run dev` for Vite, `npm test` for Playwright, `npm run build` for production bundle. +- API: `python pikit-api.py` to run locally (listens on 127.0.0.1:4000). + +## Deploying to a Pi-Kit box +1. Copy `pikit-api.py` to the device (e.g., `/usr/local/bin/`) and restart the service unit that wraps it. +2. Sync `pikit-web/index.html` and `pikit-web/assets/*` (or the built `pikit-web/dist/*`) to `/var/www/pikit-web/`. +3. The API surfaces clear errors if firewall tooling (`ufw`) is missing when ports are opened/closed. +4. Factory reset sets `root` and `dietpi` passwords to `pikit`. + +## Notes +- Service paths are normalized (leading slash) and URLs include optional subpaths. +- Firewall changes raise explicit errors when `ufw` is unavailable so the UI can surface what failed. +- Access the device UI at `http://pikit.local/` (mDNS). diff --git a/check-pikit-clean.sh b/check-pikit-clean.sh new file mode 100755 index 0000000..7719c48 --- /dev/null +++ b/check-pikit-clean.sh @@ -0,0 +1,49 @@ + #!/bin/bash + set -euo pipefail + + echo "== Identity files ==" + ls -l /etc/machine-id || true + cat /etc/machine-id || true + [ -e /var/lib/dbus/machine-id ] && echo "dbus machine-id exists" || echo "dbus machine-id missing (expected)" + ls -l /var/lib/systemd/random-seed || true + + echo -e "\n== SSH host keys ==" + ls /etc/ssh/ssh_host_* 2>/dev/null || echo "no host keys (expected)" + + echo -e "\n== SSH client traces ==" + for f in /root/.ssh/known_hosts /home/dietpi/.ssh/known_hosts /home/dietpi/.ssh/authorized_keys; do + if [ -e "$f" ]; then + printf "%s: size %s\n" "$f" "$(stat -c%s "$f")" + [ -s "$f" ] && echo " WARNING: not empty" + else + echo "$f: missing" + fi + done + + echo -e "\n== Ready flag ==" + [ -e /var/run/pikit-ready ] && echo "READY FLAG STILL PRESENT" || echo "ready flag absent (expected)" + + echo -e "\n== Logs ==" + du -sh /var/log 2>/dev/null + du -sh /var/log/nginx 2>/dev/null + find /var/log -type f -maxdepth 2 -printf "%p %s bytes\n" + + echo -e "\n== DietPi RAM logs ==" + if [ -d /var/tmp/dietpi/logs ]; then + find /var/tmp/dietpi/logs -type f -printf "%p %s bytes\n" + else + echo "/var/tmp/dietpi/logs missing" + fi + + echo -e "\n== Caches ==" + du -sh /var/cache/apt /var/lib/apt/lists /var/cache/debconf 2>/dev/null || true + + echo -e "\n== Temp dirs ==" + du -sh /tmp /var/tmp 2>/dev/null || true + find /tmp /var/tmp -maxdepth 1 -mindepth 1 ! -name 'systemd-private-*' -print + + echo -e "\n== DHCP lease ==" + ls -l /var/lib/dhcp/dhclient.eth0.leases 2>/dev/null || echo "lease file missing (expected)" + + echo -e "\n== Nginx cache dirs ==" + [ -d /var/lib/nginx ] && find /var/lib/nginx -maxdepth 2 -type d -print || echo "/var/lib/nginx missing" diff --git a/flash_sd.sh b/flash_sd.sh new file mode 100755 index 0000000..44ad4aa --- /dev/null +++ b/flash_sd.sh @@ -0,0 +1,43 @@ +#!/bin/bash +set -euo pipefail + +# Pi-Kit flashing helper (small wrapper around xzcat | dd). +# Usage: sudo ./flash_sd.sh /path/to/image.img.xz /dev/sdX +# Example: sudo ./flash_sd.sh images/output/DietPi_RPi5-ARMv8-Trixie-base.img.xz /dev/sdb +# Safety guardrails: +# - Requires root (sudo). +# - WILL wipe the target block device; prompt asks for YES before writing. +# - If a .sha256 sits next to the image, consider verifying it first. + +img="${1:-}" +dev="${2:-}" + +if [[ -z "$img" || -z "$dev" ]]; then + echo "Usage: sudo $0 image.img.xz /dev/sdX" + exit 1 +fi + +if [[ $EUID -ne 0 ]]; then + echo "Please run as root (sudo)." + exit 1 +fi + +if [[ ! -b "$dev" ]]; then + echo "Device $dev not found or not a block device." + exit 1 +fi + +echo "About to wipe and flash $dev with $img" +read -rp "Type YES to continue: " yn +[[ "$yn" == "YES" ]] || { echo "Aborted."; exit 1; } + +echo "Decompressing and writing... this may take several minutes." +xzcat "$img" | pv | dd of="$dev" bs=4M conv=fsync status=none + +echo "Syncing..." +sync + +echo "Flashed $img to $dev successfully." + +echo "Ejecting $dev..." +sudo eject "$dev" || true diff --git a/pikit-api.py b/pikit-api.py new file mode 100644 index 0000000..68cec02 --- /dev/null +++ b/pikit-api.py @@ -0,0 +1,716 @@ +#!/usr/bin/env python3 +import json, os, subprocess, socket, shutil, pathlib, datetime +from http.server import BaseHTTPRequestHandler, HTTPServer +import re + +HOST = "127.0.0.1" +PORT = 4000 +SERVICE_JSON = pathlib.Path("/etc/pikit/services.json") +RESET_LOG = pathlib.Path("/var/log/pikit-reset.log") +API_LOG = pathlib.Path("/var/log/pikit-api.log") +DEBUG_FLAG = pathlib.Path("/boot/pikit-debug").exists() +HTTPS_PORTS = {443, 5252} +CORE_PORTS = {80} +CORE_NAME = "Pi-Kit Dashboard" +READY_FILE = pathlib.Path("/var/run/pikit-ready") +APT_AUTO_CFG = pathlib.Path("/etc/apt/apt.conf.d/20auto-upgrades") +APT_UA_BASE = pathlib.Path("/etc/apt/apt.conf.d/50unattended-upgrades") +APT_UA_OVERRIDE = pathlib.Path("/etc/apt/apt.conf.d/51pikit-unattended.conf") +DEFAULT_UPDATE_TIME = "04:00" +DEFAULT_UPGRADE_TIME = "04:30" +SECURITY_PATTERNS = [ + 'origin=Debian,codename=${distro_codename},label=Debian-Security', + 'origin=Debian,codename=${distro_codename}-security,label=Debian-Security', +] +ALL_PATTERNS = [ + 'origin=Debian,codename=${distro_codename},label=Debian', + *SECURITY_PATTERNS, +] + + +class FirewallToolMissing(Exception): + """Raised when ufw is unavailable but a firewall change was requested.""" + pass + + +def normalize_path(path: str | None) -> str: + """Normalize optional service path. Empty -> "". Ensure leading slash.""" + if not path: + return "" + p = str(path).strip() + if not p: + return "" + if not p.startswith("/"): + p = "/" + p + return p + + +def dbg(msg): + if not DEBUG_FLAG: + return + API_LOG.parent.mkdir(parents=True, exist_ok=True) + ts = datetime.datetime.utcnow().isoformat() + with API_LOG.open("a") as f: + f.write(f"[{ts}] {msg}\n") + + +def set_ssh_password_auth(allow: bool): + """ + Enable/disable SSH password authentication without requiring the current password. + Used during factory reset to restore a predictable state. + """ + cfg = pathlib.Path("/etc/ssh/sshd_config") + text = cfg.read_text() if cfg.exists() else "" + + def set_opt(key, value): + nonlocal text + pattern = f"{key} " + lines = text.splitlines() + replaced = False + for idx, line in enumerate(lines): + if line.strip().startswith(pattern): + lines[idx] = f"{key} {value}" + replaced = True + break + if not replaced: + lines.append(f"{key} {value}") + text_new = "\n".join(lines) + "\n" + return text_new + + text = set_opt("PasswordAuthentication", "yes" if allow else "no") + text = set_opt("KbdInteractiveAuthentication", "no") + text = set_opt("ChallengeResponseAuthentication", "no") + text = set_opt("PubkeyAuthentication", "yes") + text = set_opt("PermitRootLogin", "yes" if allow else "prohibit-password") + cfg.write_text(text) + subprocess.run(["systemctl", "restart", "ssh"], check=False) + return True, f"SSH password auth {'enabled' if allow else 'disabled'}" + + +def load_services(): + if SERVICE_JSON.exists(): + try: + data = json.loads(SERVICE_JSON.read_text()) + # Normalize entries: ensure url built from port if missing + host = socket.gethostname() + for svc in data: + svc_path = normalize_path(svc.get("path")) + if svc_path: + svc["path"] = svc_path + if svc.get("port"): + scheme = svc.get("scheme") + if not scheme: + scheme = "https" if int(svc["port"]) in HTTPS_PORTS else "http" + svc["scheme"] = scheme + svc["url"] = f"{scheme}://{host}:{svc['port']}{svc_path}" + return data + except Exception: + dbg("Failed to read services.json") + return [] + return [] + + +def save_services(services): + SERVICE_JSON.parent.mkdir(parents=True, exist_ok=True) + SERVICE_JSON.write_text(json.dumps(services, indent=2)) + + +def auto_updates_enabled(): + if not APT_AUTO_CFG.exists(): + return False + text = APT_AUTO_CFG.read_text() + return 'APT::Periodic::Unattended-Upgrade "1";' in text + + +def set_auto_updates(enable: bool): + """ + Toggle unattended upgrades in a way that matches systemd state, not just the + apt config file. Assumes unattended-upgrades is already installed. + """ + units_maskable = [ + "apt-daily.service", + "apt-daily-upgrade.service", + "apt-daily.timer", + "apt-daily-upgrade.timer", + "unattended-upgrades.service", + ] + timers = ["apt-daily.timer", "apt-daily-upgrade.timer"] + service = "unattended-upgrades.service" + + APT_AUTO_CFG.parent.mkdir(parents=True, exist_ok=True) + if enable: + APT_AUTO_CFG.write_text( + 'APT::Periodic::Update-Package-Lists "1";\n' + 'APT::Periodic::Unattended-Upgrade "1";\n' + ) + for unit in units_maskable: + subprocess.run(["systemctl", "unmask", unit], check=False) + # Enable timers and the service; start them so state matches immediately + for unit in timers + [service]: + subprocess.run(["systemctl", "enable", unit], check=False) + for unit in timers: + subprocess.run(["systemctl", "start", unit], check=False) + subprocess.run(["systemctl", "start", service], check=False) + else: + APT_AUTO_CFG.write_text( + 'APT::Periodic::Update-Package-Lists "0";\n' + 'APT::Periodic::Unattended-Upgrade "0";\n' + ) + # Stop/disable and mask to mirror DietPi defaults + for unit in timers + [service]: + subprocess.run(["systemctl", "stop", unit], check=False) + subprocess.run(["systemctl", "disable", unit], check=False) + for unit in units_maskable: + subprocess.run(["systemctl", "mask", unit], check=False) + + +def _systemctl_is(unit: str, verb: str) -> bool: + try: + out = subprocess.check_output(["systemctl", verb, unit], text=True).strip() + return out == "enabled" if verb == "is-enabled" else out == "active" + except Exception: + return False + + +def auto_updates_state(): + config_on = auto_updates_enabled() + service = "unattended-upgrades.service" + timers = ["apt-daily.timer", "apt-daily-upgrade.timer"] + state = { + "config_enabled": config_on, + "service_enabled": _systemctl_is(service, "is-enabled"), + "service_active": _systemctl_is(service, "is-active"), + "timers_enabled": {}, + "timers_active": {}, + } + for t in timers: + state["timers_enabled"][t] = _systemctl_is(t, "is-enabled") + state["timers_active"][t] = _systemctl_is(t, "is-active") + # Consider overall enabled only if config is on and both timers & service are enabled + state["enabled"] = ( + config_on + and state["service_enabled"] + and all(state["timers_enabled"].values()) + ) + return state + + +def reboot_required(): + return pathlib.Path("/run/reboot-required").exists() + + +def _parse_directive(text: str, key: str, default=None, as_bool=False, as_int=False): + text = _strip_comments(text) + pattern = rf'{re.escape(key)}\s+"?([^";\n]+)"?;' + m = re.search(pattern, text) + if not m: + return default + val = m.group(1).strip() + if as_bool: + return val.lower() in ("1", "true", "yes", "on") + if as_int: + try: + return int(val) + except ValueError: + return default + return val + + +def _parse_origins_patterns(text: str): + text = _strip_comments(text) + m = re.search(r"Unattended-Upgrade::Origins-Pattern\s*{([^}]*)}", text, re.S) + patterns = [] + if not m: + return patterns + body = m.group(1) + for line in body.splitlines(): + ln = line.strip().strip('";') + if ln: + patterns.append(ln) + return patterns + + +def _read_timer_time(timer: str): + try: + out = subprocess.check_output( + ["systemctl", "show", "--property=TimersCalendar", timer], text=True + ) + # Example: TimersCalendar={ OnCalendar=*-*-* 03:10:00 ; next_elapse=... } + m = re.search(r"OnCalendar=[^0-9]*([0-9]{1,2}):([0-9]{2})", out) + if m: + return f"{int(m.group(1)):02d}:{m.group(2)}" + except Exception: + pass + return None + + +def _strip_comments(text: str): + """Remove // and # line comments for safer parsing.""" + lines = [] + for ln in text.splitlines(): + l = ln.strip() + if l.startswith("//") or l.startswith("#"): + continue + lines.append(ln) + return "\n".join(lines) + + +def _validate_time(val: str, default: str): + if not val: + return default + m = re.match(r"^(\d{1,2}):(\d{2})$", val.strip()) + if not m: + return default + h, mi = int(m.group(1)), int(m.group(2)) + if 0 <= h < 24 and 0 <= mi < 60: + return f"{h:02d}:{mi:02d}" + return default + + +def read_updates_config(state=None): + """ + Return a normalized unattended-upgrades configuration snapshot. + Values are sourced from the Pi-Kit override file when present, else the base file. + """ + text = "" + for path in (APT_UA_OVERRIDE, APT_UA_BASE): + if path.exists(): + try: + text += path.read_text() + "\n" + except Exception: + pass + scope_hint = None + m_scope = re.search(r"PIKIT_SCOPE:\s*(\w+)", text) + if m_scope: + scope_hint = m_scope.group(1).lower() + cleaned = _strip_comments(text) + patterns = _parse_origins_patterns(cleaned) + scope = ( + scope_hint + or ("all" if any("label=Debian" in p and "-security" not in p for p in patterns) else "security") + ) + cleanup = _parse_directive(text, "Unattended-Upgrade::Remove-Unused-Dependencies", False, as_bool=True) + auto_reboot = _parse_directive(text, "Unattended-Upgrade::Automatic-Reboot", False, as_bool=True) + reboot_time = _validate_time(_parse_directive(text, "Unattended-Upgrade::Automatic-Reboot-Time", DEFAULT_UPGRADE_TIME), DEFAULT_UPGRADE_TIME) + reboot_with_users = _parse_directive(text, "Unattended-Upgrade::Automatic-Reboot-WithUsers", False, as_bool=True) + bandwidth = _parse_directive(text, "Acquire::http::Dl-Limit", None, as_int=True) + + update_time = _read_timer_time("apt-daily.timer") or DEFAULT_UPDATE_TIME + upgrade_time = _read_timer_time("apt-daily-upgrade.timer") or DEFAULT_UPGRADE_TIME + + state = state or auto_updates_state() + return { + "enabled": bool(state.get("enabled", False)), + "scope": scope, + "cleanup": bool(cleanup), + "bandwidth_limit_kbps": bandwidth, + "auto_reboot": bool(auto_reboot), + "reboot_time": reboot_time, + "reboot_with_users": bool(reboot_with_users), + "update_time": update_time, + "upgrade_time": upgrade_time, + "state": state, + } + + +def _write_timer_override(timer: str, time_str: str): + time_norm = _validate_time(time_str, DEFAULT_UPDATE_TIME) + override_dir = pathlib.Path(f"/etc/systemd/system/{timer}.d") + override_dir.mkdir(parents=True, exist_ok=True) + override_file = override_dir / "pikit.conf" + override_file.write_text( + "[Timer]\n" + f"OnCalendar=*-*-* {time_norm}\n" + "Persistent=true\n" + "RandomizedDelaySec=30min\n" + ) + subprocess.run(["systemctl", "daemon-reload"], check=False) + subprocess.run(["systemctl", "restart", timer], check=False) + + +def set_updates_config(opts: dict): + """ + Apply unattended-upgrades configuration from dashboard inputs. + """ + enable = bool(opts.get("enable", True)) + scope = opts.get("scope") or "all" + patterns = ALL_PATTERNS if scope == "all" else SECURITY_PATTERNS + cleanup = bool(opts.get("cleanup", False)) + bandwidth = opts.get("bandwidth_limit_kbps") + auto_reboot = bool(opts.get("auto_reboot", False)) + reboot_time = _validate_time(opts.get("reboot_time"), DEFAULT_UPGRADE_TIME) + reboot_with_users = bool(opts.get("reboot_with_users", False)) + update_time = _validate_time(opts.get("update_time"), DEFAULT_UPDATE_TIME) + upgrade_time = _validate_time(opts.get("upgrade_time") or opts.get("update_time"), DEFAULT_UPGRADE_TIME) + + APT_AUTO_CFG.parent.mkdir(parents=True, exist_ok=True) + set_auto_updates(enable) + + lines = [ + "// Managed by Pi-Kit dashboard", + f"// PIKIT_SCOPE: {scope}", + "Unattended-Upgrade::Origins-Pattern {", + ] + for p in patterns: + lines.append(f' "{p}";') + lines.append("};") + lines.append(f'Unattended-Upgrade::Remove-Unused-Dependencies {"true" if cleanup else "false"};') + lines.append(f'Unattended-Upgrade::Automatic-Reboot {"true" if auto_reboot else "false"};') + lines.append(f'Unattended-Upgrade::Automatic-Reboot-Time "{reboot_time}";') + lines.append( + f'Unattended-Upgrade::Automatic-Reboot-WithUsers {"true" if reboot_with_users else "false"};' + ) + if bandwidth is not None: + lines.append(f'Acquire::http::Dl-Limit "{int(bandwidth)}";') + APT_UA_OVERRIDE.parent.mkdir(parents=True, exist_ok=True) + APT_UA_OVERRIDE.write_text("\n".join(lines) + "\n") + + # Timer overrides for when upgrades run + _write_timer_override("apt-daily.timer", update_time) + _write_timer_override("apt-daily-upgrade.timer", upgrade_time) + return read_updates_config() + + +def detect_https(host, port): + try: + import ssl + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + with socket.create_connection((host, int(port)), timeout=1.5) as sock: + with ctx.wrap_socket(sock, server_hostname=host): + return True + except Exception: + return False + + +def factory_reset(): + # Restore services config + if pathlib.Path("/boot/custom-files/pikit-services.json").exists(): + shutil.copy("/boot/custom-files/pikit-services.json", SERVICE_JSON) + else: + SERVICE_JSON.write_text(json.dumps([ + {"name": "Pi-Kit Dashboard", "port": 80}, + {"name": "DietPi Dashboard", "port": 5252}, + ], indent=2)) + # Reset firewall + reset_firewall() + # Reset SSH auth to password and set defaults + set_ssh_password_auth(True) + for user in ("root", "dietpi"): + try: + subprocess.run(["chpasswd"], input=f"{user}:pikit".encode(), check=True) + except Exception: + pass + # Ensure dietpi exists + if not pathlib.Path("/home/dietpi").exists(): + subprocess.run(["useradd", "-m", "-s", "/bin/bash", "dietpi"], check=False) + subprocess.run(["chpasswd"], input=b"dietpi:pikit", check=False) + # Log and reboot + pathlib.Path("/var/log/pikit-reset.log").write_text("Factory reset triggered\n") + subprocess.Popen(["/bin/sh", "-c", "sleep 2 && systemctl reboot >/dev/null 2>&1"], close_fds=True) + + +def port_online(host, port): + try: + with socket.create_connection((host, int(port)), timeout=1.5): + return True + except Exception: + return False + + +def ufw_status_allows(port: int) -> bool: + try: + out = subprocess.check_output(["ufw", "status"], text=True) + return f"{port}" in out and "ALLOW" in out + except Exception: + return False + + +def allow_port_lan(port: int): + """Open a port to RFC1918 subnets; raise if ufw is missing so callers can surface the error.""" + if not shutil.which("ufw"): + raise FirewallToolMissing("Cannot update firewall: ufw is not installed on this system.") + for subnet in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "169.254.0.0/16"): + subprocess.run(["ufw", "allow", "from", subnet, "to", "any", "port", str(port)], check=False) + + +def remove_port_lan(port: int): + """Close a LAN rule for a port; raise if ufw is missing so callers can surface the error.""" + if not shutil.which("ufw"): + raise FirewallToolMissing("Cannot update firewall: ufw is not installed on this system.") + for subnet in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "169.254.0.0/16"): + subprocess.run(["ufw", "delete", "allow", "from", subnet, "to", "any", "port", str(port)], check=False) + + +def reset_firewall(): + subprocess.run(["ufw", "--force", "reset"], check=False) + subprocess.run(["ufw", "default", "deny", "incoming"], check=False) + subprocess.run(["ufw", "default", "deny", "outgoing"], check=False) + # Outbound essentials + LAN + for port in ("53", "80", "443", "123", "67", "68"): + subprocess.run(["ufw", "allow", "out", port], check=False) + subprocess.run(["ufw", "allow", "out", "on", "lo"], check=False) + for subnet in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "169.254.0.0/16"): + subprocess.run(["ufw", "allow", "out", "to", subnet], check=False) + for subnet in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "169.254.0.0/16"): + for port in ("22", "80", "443", "5252", "5253"): + subprocess.run(["ufw", "allow", "from", subnet, "to", "any", "port", port], check=False) + subprocess.run(["ufw", "--force", "enable"], check=False) + + +class Handler(BaseHTTPRequestHandler): + """Minimal JSON API for the dashboard (status, services, updates, reset).""" + def _send(self, code, data): + body = json.dumps(data).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, fmt, *args): + return + + def do_GET(self): + if self.path.startswith("/api/status"): + uptime = float(open("/proc/uptime").read().split()[0]) + load1, load5, load15 = os.getloadavg() + meminfo = {} + for ln in open("/proc/meminfo"): + k, v = ln.split(":", 1) + meminfo[k] = int(v.strip().split()[0]) + total = meminfo.get("MemTotal", 0)//1024 + free = meminfo.get("MemAvailable", 0)//1024 + disk = shutil.disk_usage("/") + # CPU temperature (best-effort) + cpu_temp = None + for path in ("/sys/class/thermal/thermal_zone0/temp",): + if pathlib.Path(path).exists(): + try: + cpu_temp = float(pathlib.Path(path).read_text().strip())/1000.0 + break + except Exception: + pass + # LAN IP (first non-loopback) + ip_addr = None + try: + out = subprocess.check_output(["hostname", "-I"], text=True).strip() + ip_addr = out.split()[0] if out else None + except Exception: + pass + # OS version + os_ver = "DietPi" + try: + for line in pathlib.Path("/etc/os-release").read_text().splitlines(): + if line.startswith("PRETTY_NAME="): + os_ver = line.split("=",1)[1].strip().strip('"') + break + except Exception: + pass + updates_state = auto_updates_state() + updates_config = read_updates_config(updates_state) + services = [] + for svc in load_services(): + svc = dict(svc) + port = svc.get("port") + if port: + svc["online"] = port_online("127.0.0.1", port) + svc["firewall_open"] = ufw_status_allows(port) + services.append(svc) + data = { + "hostname": socket.gethostname(), + "uptime_seconds": uptime, + "load": [load1, load5, load15], + "memory_mb": {"total": total, "free": free}, + "disk_mb": {"total": disk.total//1024//1024, "free": disk.free//1024//1024}, + "cpu_temp_c": cpu_temp, + "lan_ip": ip_addr, + "os_version": os_ver, + "auto_updates_enabled": updates_state.get("enabled", False), + "auto_updates": updates_state, + "updates_config": updates_config, + "reboot_required": reboot_required(), + "ready": READY_FILE.exists(), + "services": services + } + self._send(200, data) + elif self.path.startswith("/api/services"): + services = [] + for svc in load_services(): + svc = dict(svc) + port = svc.get("port") + if port: + svc["online"] = port_online("127.0.0.1", port) + svc["firewall_open"] = ufw_status_allows(port) + services.append(svc) + self._send(200, {"services": services}) + elif self.path.startswith("/api/updates/auto"): + state = auto_updates_state() + self._send(200, {"enabled": state.get("enabled", False), "details": state}) + elif self.path.startswith("/api/updates/config"): + cfg = read_updates_config() + self._send(200, cfg) + else: + self._send(404, {"error": "not found"}) + + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + payload = json.loads(self.rfile.read(length) or "{}") + if self.path.startswith("/api/reset"): + if payload.get("confirm") == "YES": + self._send(200, {"message": "Resetting and rebooting..."}) + dbg("Factory reset triggered via API") + factory_reset() + else: + self._send(400, {"error": "type YES to confirm"}) + return + if self.path.startswith("/api/updates/auto"): + enable = bool(payload.get("enable")) + set_auto_updates(enable) + dbg(f"Auto updates set to {enable}") + state = auto_updates_state() + return self._send(200, {"enabled": state.get("enabled", False), "details": state}) + if self.path.startswith("/api/updates/config"): + try: + cfg = set_updates_config(payload or {}) + dbg(f"Update settings applied: {cfg}") + return self._send(200, cfg) + except Exception as e: + dbg(f"Failed to apply updates config: {e}") + return self._send(500, {"error": str(e)}) + if self.path.startswith("/api/services/add"): + name = payload.get("name") + port = int(payload.get("port", 0)) + if not name or not port: + return self._send(400, {"error": "name and port required"}) + if port in CORE_PORTS and name != CORE_NAME: + return self._send(400, {"error": f"Port {port} is reserved for {CORE_NAME}"}) + services = load_services() + if any(s.get("port") == port for s in services): + return self._send(400, {"error": "port already exists"}) + host = socket.gethostname() + scheme = payload.get("scheme") + if scheme not in ("http", "https"): + scheme = "https" if detect_https(host, port) else "http" + notice = (payload.get("notice") or "").strip() + notice_link = (payload.get("notice_link") or "").strip() + self_signed = bool(payload.get("self_signed")) + path = normalize_path(payload.get("path")) + svc = {"name": name, "port": port, "scheme": scheme, "url": f"{scheme}://{host}:{port}{path}"} + if notice: + svc["notice"] = notice + if notice_link: + svc["notice_link"] = notice_link + if self_signed: + svc["self_signed"] = True + if path: + svc["path"] = path + services.append(svc) + save_services(services) + try: + allow_port_lan(port) + except FirewallToolMissing as e: + return self._send(500, {"error": str(e)}) + return self._send(200, {"services": services, "message": f"Added {name} on port {port} and opened LAN firewall"}) + if self.path.startswith("/api/services/remove"): + port = int(payload.get("port", 0)) + if not port: + return self._send(400, {"error": "port required"}) + if port in CORE_PORTS: + return self._send(400, {"error": f"Cannot remove core service on port {port}"}) + services = [s for s in load_services() if s.get("port") != port] + try: + remove_port_lan(port) + except FirewallToolMissing as e: + return self._send(500, {"error": str(e)}) + save_services(services) + return self._send(200, {"services": services, "message": f"Removed service on port {port}"}) + if self.path.startswith("/api/services/update"): + port = int(payload.get("port", 0)) + new_name = payload.get("name") + new_port = payload.get("new_port") + new_scheme = payload.get("scheme") + notice = payload.get("notice") + notice_link = payload.get("notice_link") + new_path = payload.get("path") + self_signed = payload.get("self_signed") + services = load_services() + updated = False + for svc in services: + if svc.get("port") == port: + if new_name: + # Prevent renaming core service to something else if still on core port + if port in CORE_PORTS and new_name != CORE_NAME: + return self._send(400, {"error": f"Core service on port {port} must stay named {CORE_NAME}"}) + svc["name"] = new_name + target_port = svc.get("port") + port_changed = False + if new_port is not None: + new_port_int = int(new_port) + if new_port_int != port: + if new_port_int in CORE_PORTS and svc.get("name") != CORE_NAME: + return self._send(400, {"error": f"Port {new_port_int} is reserved for {CORE_NAME}"}) + if any(s.get("port") == new_port_int and s is not svc for s in services): + return self._send(400, {"error": "new port already in use"}) + try: + remove_port_lan(port) + allow_port_lan(new_port_int) + except FirewallToolMissing as e: + return self._send(500, {"error": str(e)}) + svc["port"] = new_port_int + target_port = new_port_int + port_changed = True + host = socket.gethostname() + if new_path is not None: + path = normalize_path(new_path) + if path: + svc["path"] = path + elif "path" in svc: + svc.pop("path", None) + else: + path = normalize_path(svc.get("path")) + if path: + svc["path"] = path + if new_scheme: + scheme = new_scheme if new_scheme in ("http", "https") else None + else: + scheme = svc.get("scheme") + if not scheme or scheme == "auto": + scheme = "https" if detect_https(host, target_port) else "http" + svc["scheme"] = scheme + svc["url"] = f"{scheme}://{host}:{target_port}{path}" + if notice is not None: + text = (notice or "").strip() + if text: + svc["notice"] = text + elif "notice" in svc: + svc.pop("notice", None) + if notice_link is not None: + link = (notice_link or "").strip() + if link: + svc["notice_link"] = link + elif "notice_link" in svc: + svc.pop("notice_link", None) + if self_signed is not None: + if bool(self_signed): + svc["self_signed"] = True + else: + svc.pop("self_signed", None) + updated = True + break + if not updated: + return self._send(404, {"error": "service not found"}) + save_services(services) + return self._send(200, {"services": services, "message": "Service updated"}) + self._send(404, {"error": "not found"}) + + +def main(): + load_services() + server = HTTPServer((HOST, PORT), Handler) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/pikit-prep-spec.md b/pikit-prep-spec.md new file mode 100644 index 0000000..96b6643 --- /dev/null +++ b/pikit-prep-spec.md @@ -0,0 +1,391 @@ +# Pi-Kit DietPi Image Prep Spec + +This file defines how to design a **prep script** for a DietPi-based Pi-Kit image. + +The script’s job: +Prepare a running Pi-Kit system to be cloned as a “golden image” **without** removing any intentional software, configs, hostname, or passwords. + +--- + +## 0. Context & Goals + +**Starting point** + +- OS: DietPi (Debian-based), already installed. +- Extra software: web stack, Pi-Kit dashboard, DNS/ad-blocker, DBs, monitoring, etc. +- System has been used for testing (logs, histories, test data, junk). + +**Goal** + +- Prepare system for cloning as a product image. +- **KEEP**: + - All intentionally installed packages/software. + - All custom configs (web, apps, DietPi configs, firewall). + - Current hostname. + - Existing passwords (system + services) as shipped defaults. +- **RESET/CLEAR**: + - Host-unique identity data (machine-id, SSH host keys, etc.). + - Logs, histories, caches. + - Test/personal accounts and data. + +--- + +## 1. Discovery Phase (MUST HAPPEN BEFORE SCRIPT DESIGN) + +Before writing any code, inspect the system and external docs. + +The AI MUST: + +1. **Detect installed components** + - Determine which key packages/services are present, e.g.: + - Web server (nginx, lighttpd, apache2, etc.). + - DNS/ad-blocker (Pi-hole or similar). + - DB engines (MariaDB, PostgreSQL, SQLite usage). + - Monitoring/metrics (Netdata, Uptime Kuma, etc.). + - Use this to decide which cleanup sections apply. + +2. **Verify paths/layouts** + - For each service or category: + - Confirm relevant paths/directories actually exist. + - Do not assume standard paths without checking. + - Example: Only treat `/var/log/nginx` as Nginx logs if: + - Nginx is installed, AND + - That directory exists. + +3. **Consult upstream docs (online)** + - Check current: + - DietPi docs and/or DietPi GitHub. + - Docs for major services (e.g. Pi-hole, Nginx, MariaDB, etc.). + - Use docs to confirm: + - Data vs config locations. + - Safe cache/log cleanup methods. + - Prefer documented behavior over guesses. + +4. **Classify actions** + - For each potential cleanup: + - Mark as **safe** if clearly understood and documented. + - Mark as **uncertain** if layout deviates or docs are unclear. + - Plan to: + - Perform safe actions. + - Skip uncertain actions and surface them for manual review. + +5. **Fail safe** + - If something doesn’t match expectations: + - Do NOT plan a destructive operation on it. + - Flag it as “needs manual review” in the confirmation phase. + +--- + +## 2. Identity & Host-Specific Secrets + +**DO NOT CHANGE:** + +- Hostname (whatever it currently is). +- Any existing passwords (system or service-level) that are part of the appliance defaults. + +**RESET/CLEAR:** + +1. **Machine identity** + - Clear: + - `/etc/machine-id` + - `/var/lib/dbus/machine-id` (if present) + - Rely on OS to recreate them on next boot. + +2. **Random seed** + - Clear persisted random seed (e.g. `/var/lib/systemd/random-seed`) so each clone gets unique entropy. + +3. **SSH host keys** + - Remove all SSH **host key** files (server keys only). + - Leave user SSH keypairs unless explicitly identified as dev/test and safe to remove. + +4. **SSH known_hosts** + - Clear `known_hosts` for: + - `root` + - `dietpi` (or primary DietPi user) + - Any other persistent users + +5. **VPN keys (conditional)** + - If keys are meant to be unique per device: + - Remove WireGuard/OpenVPN private keys and per-device configs embedding them. + - If the design requires fixed server keys: + - KEEP server keys. + - REMOVE test/client keys/profiles that are tied to dev use. + +6. **TLS certificates (conditional)** + - REMOVE: + - Let’s Encrypt/ACME certs tied to personal domains. + - Per-device self-signed certs that should regenerate. + - KEEP: + - Shared CAs/certs only if explicitly part of product design. + +--- + +## 3. Users & Personal Traces + +1. **Accounts** + - KEEP: + - Accounts that are part of the product. + - REMOVE: + - Test-only accounts (users created for dev/debug). + +2. **Shell histories** + - Clear shell histories for all remaining users: + - `root`, `dietpi`, others that stay. + +3. **Home directories** + - For users that remain: + - KEEP: + - Intentional config/dotfiles (shell rc, app config, etc.). + - REMOVE: + - Downloads, random files, scratch notes. + - Editor backup/swap files, stray temp files. + - Debug dumps, one-off scripts not part of product. + - For users that are removed: + - Delete their home dirs entirely. + +4. **SSH client keys** + - REMOVE: + - Clearly personal/test keys (e.g. with your email in comments). + - KEEP: + - Only keys explicitly required by product design. + +--- + +## 4. Logs & Telemetry + +1. **System logs** + - Clear: + - Systemd journal (persistent logs). + - `/var/log` files + rotated/compressed variants, where safe. + +2. **Service logs** + - For installed services (web servers, DNS/ad-blockers, DBs, etc.): + - Clear their log files and rotated versions. + +3. **Monitoring/metrics** + - For tools like Netdata, Uptime Kuma, etc.: + - KEEP: + - Config, target definitions. + - CLEAR: + - Historical metric/alert data (TSDBs, history files, etc.). + +--- + +## 5. Package Manager & Caches + +1. **APT** + - Clear: + - Downloaded `.deb` archives. + - Safe APT caches (as per documentation). + +2. **Other caches** + - Under `/var/cache` and `~/.cache`: + - CLEAR: + - Caches known to be safe and auto-regenerated. + - DO NOT CLEAR: + - Caches that are required for correct functioning or very expensive to rebuild, unless docs confirm safety. + +3. **Temp directories** + - Empty: + - `/tmp` + - `/var/tmp` + +4. **Crash dumps** + - Remove crash dumps and core files (e.g. `/var/crash` and similar locations). + +--- + +## 6. Service Data vs Config (Per-App Logic) + +General rule: + +> Keep configuration & structure. Remove dev/test data, history, and personal content. + +The AI must apply this using detected services + docs. + +### 6.1 Web Servers (nginx / lighttpd / apache2) + +- KEEP: + - Main config and site configs that define Pi-Kit behavior. + - App code in `/var/www/...` (or equivalent Pi-Kit web root). +- CLEAR: + - Access/error logs. + - Non-critical caches if docs confirm they’re safe to recreate. + +### 6.2 DNS / Ad-blockers (Pi-hole or similar) + +- KEEP: + - Upstream DNS settings. + - Blocklists / adlists / local DNS overrides. + - DHCP config if it is part of the product’s behavior. +- CLEAR: + - Query history / statistics DB. + - Log files. +- DO NOT: + - Change the current admin password (it is the product default). + +### 6.3 Databases (MariaDB, PostgreSQL, SQLite, etc.) + +- KEEP: + - DB schema. + - Seed/default data required for every user. +- REMOVE/RESET: + - Dev/test user accounts (with your email, etc.). + - Test content/records not meant for production image. + - Access tokens, session records, API keys tied to dev use. +- For SQLite-based apps: + - Decide per app (based on docs) whether to: + - Ship a pre-seeded “clean” DB, OR + - Let it auto-create DB on first run. + +### 6.4 Other services (Nextcloud, Jellyfin, Gotify, Uptime Kuma, etc.) + +For each detected service: + +- KEEP: + - Global config, ports, base URLs, application settings needed for Pi-Kit. +- CLEAR: + - Personal/dev user accounts. + - Your media/content (unless intentionally shipping sample content). + - Notification endpoints tied to your own email / Gotify / Telegram, unless explicitly desired. + +If docs or structure are unclear, mark cleanup as **uncertain** and surface in confirmation instead of guessing. + +--- + +## 7. Networking & Firewall + +**HARD CONSTRAINTS:** + +- Do NOT modify hostname. +- Do NOT weaken/remove the product firewall rules. + +1. **Firewall** + - Detect firewall system in use (iptables, nftables, UFW, etc.). + - KEEP: + - All persistent firewall configs that define Pi-Kit’s security behavior. + - DO NOT: + - Flush or reset firewall rules unless it’s clearly a dev-only configuration (and that’s confirmed). + +2. **Other networking state** + - Safe to CLEAR: + - DHCP lease files. + - DNS caches. + - DO NOT ALTER: + - Static IP/bridge/VLAN config that appears to be part of the intended appliance setup. + +--- + +## 8. DietPi-Specific State & First-Boot Behavior + +1. **DietPi automation/config** + - Identify DietPi automation configuration (e.g. `dietpi.txt`, related files). + - KEEP: + - The intended defaults (locale, timezone, etc.). + - Any automation that is part of Pi-Kit behavior. + - AVOID: + - Re-triggering DietPi’s generic first-boot flow unless that is intentionally desired. + +2. **DietPi logs/temp** + - CLEAR: + - DietPi-specific logs and temp files. + - KEEP: + - All DietPi configuration and automation files. + +3. **Pi-Kit first-boot logic** + - Ensure any Pi-Kit specific first-run services/hooks are: + - Enabled. + - Not dependent on data being cleaned (e.g., they must not require removed dev tokens/paths). + +--- + +## 9. Shell & Tooling State + +1. **Tool caches** + - For root and main user(s), CLEAR: + - Safe caches in `~/.cache` (pip, npm, cargo, etc.), if not needed at runtime. + - Avoid clearing caches that are critical or painful to rebuild unless doc-backed. + +2. **Build artifacts** + - REMOVE: + - Source trees, build directories, and other dev artifacts that are not part of final product. + +3. **Cronjobs / timers** + - Audit: + - User crontabs. + - System crontabs. + - Systemd timers. + - KEEP: + - Jobs that are part of Pi-Kit behavior. + - REMOVE: + - Jobs/timers clearly used for dev/testing only. + +--- + +## 10. Implementation Requirements (For the Future Script) + +When generating the actual script, the AI MUST: + +1. **Error handling** + - Check exit statuses where relevant. + - Handle missing paths/directories gracefully: + - If a path doesn’t exist, skip and log; do not fail hard. + - Avoid wide-destructive operations without validation: + - No “blind” deletions on unverified globs. + +2. **Idempotency** + - Script can run multiple times without progressively breaking the system. + - After repeated runs, image should remain valid and “clean”. + +3. **Conservative behavior** + - If uncertain about an operation: + - Do NOT perform it. + - Log a warning and mark for manual review. + +4. **Logging** + - For each major category (identity, logs, caches, per-service cleanup, etc.): + - Log what was targeted and outcome: + - `cleaned` + - `skipped (not installed/not found)` + - `skipped (uncertain; manual review)` + - Provide a summary at the end. + +--- + +## 11. Mandatory Pre-Script Confirmation Step + +**Before writing any script, the AI MUST:** + +1. **Present a system-specific plan** + - Based on discovery + docs, list: + - Exactly which paths, files, DBs, and data types it intends to: + - Remove + - Reset + - Leave untouched + - For each item or group: a short explanation of **why**. + +2. **Highlight conflicts / ambiguities** + - If any cleanup might: + - Affect passwords, + - Affect hostname, + - Affect firewall rules, + - Or contradict this spec in any way, + - The AI must: + - Call it out explicitly. + - Explain tradeoffs and propose a safe option. + +3. **Highlight extra opportunities** + - If the AI finds additional cleanup opportunities not explicitly listed here (e.g., new DietPi features, new log paths): + - Describe them clearly. + - Explain pros/cons of adding them. + - Ask whether to include them. + +4. **Wait for explicit approval** + - Do NOT generate the script until: + - The user (me) has reviewed the plan. + - Conflicts and extra opportunities have been discussed. + - Explicit approval (with any modifications) has been given. + +Only after that confirmation may the AI produce the actual prep script. + +--- diff --git a/pikit-prep.sh b/pikit-prep.sh new file mode 100644 index 0000000..be340e1 --- /dev/null +++ b/pikit-prep.sh @@ -0,0 +1,245 @@ +#!/bin/bash +# Pi-Kit DietPi image prep script +# Cleans host-unique data, logs, caches, and temp files per pikit-prep-spec. + +set -euo pipefail + +status() { printf '[%s] %s\n' "$1" "$2"; } + +clean_logs_dir() { + local dir="$1" pattern="${2:-*}" + if [ -d "$dir" ]; then + find "$dir" -type f -name "$pattern" -print -delete 2>/dev/null | sed 's/^/[CLEANED] /' || true + status CLEANED "logs $pattern in $dir" + else + status SKIP "$dir (missing)" + fi +} + +truncate_file() { + local file="$1" + if [ -e "$file" ]; then + :> "$file" && status CLEANED "truncated $file" || status FAIL "truncate $file" + else + status SKIP "$file (missing)" + fi +} + +clean_file() { + local path="$1" + if [ -e "$path" ]; then + rm -f "$path" && status CLEANED "$path" || status FAIL "$path" + else + status SKIP "$path (missing)" + fi +} + +clean_dir_files() { + local dir="$1" pattern="$2" + if [ -d "$dir" ]; then + find "$dir" -type f -name "$pattern" -print -delete 2>/dev/null | sed 's/^/[CLEANED] /' || true + status CLEANED "files $pattern in $dir" + else + status SKIP "$dir (missing)" + fi +} + +truncate_dir() { + local dir="$1" + if [ -d "$dir" ]; then + # keep systemd-private dirs intact while services run + find "$dir" -mindepth 1 -maxdepth 1 ! -path "$dir/systemd-private-*" -exec rm -rf {} + 2>/dev/null + status CLEANED "$dir" + else + status SKIP "$dir (missing)" + fi +} + +clean_backups() { + local dir="$1" + if [ -d "$dir" ]; then + find "$dir" -type f \( -name '*~' -o -name '*.bak*' -o -name '*.orig*' \) -print -delete 2>/dev/null | sed 's/^/[CLEANED] /' || true + status CLEANED "backup/editor files in $dir" + else + status SKIP "$dir (missing)" + fi +} + +# --- Identity --- +# Keep machine-id file present but empty so systemd regenerates cleanly on next boot. +truncate -s 0 /etc/machine-id && status CLEANED /etc/machine-id || status FAIL /etc/machine-id +mkdir -p /var/lib/dbus || true +rm -f /var/lib/dbus/machine-id +ln -s /etc/machine-id /var/lib/dbus/machine-id && status CLEANED "/var/lib/dbus/machine-id -> /etc/machine-id" || status FAIL "/var/lib/dbus/machine-id" +clean_file /var/lib/systemd/random-seed + +# --- SSH host keys --- +if ls /etc/ssh/ssh_host_* >/dev/null 2>&1; then + rm -f /etc/ssh/ssh_host_* && status CLEANED "SSH host keys" || status FAIL "SSH host keys" +else + status SKIP "SSH host keys (none)" +fi + +# --- SSH client traces --- +:> /root/.ssh/known_hosts 2>/dev/null && status CLEANED "/root/.ssh/known_hosts" || status SKIP "/root/.ssh/known_hosts" +:> /home/dietpi/.ssh/known_hosts 2>/dev/null && status CLEANED "/home/dietpi/.ssh/known_hosts" || status SKIP "/home/dietpi/.ssh/known_hosts" +:> /home/dietpi/.ssh/authorized_keys 2>/dev/null && status CLEANED "/home/dietpi/.ssh/authorized_keys" || status SKIP "/home/dietpi/.ssh/authorized_keys" +:> /root/.ssh/authorized_keys 2>/dev/null && status CLEANED "/root/.ssh/authorized_keys" || status SKIP "/root/.ssh/authorized_keys" + +# --- Shell history --- +:> /root/.bash_history 2>/dev/null && status CLEANED "/root/.bash_history" || status SKIP "/root/.bash_history" +:> /home/dietpi/.bash_history 2>/dev/null && status CLEANED "/home/dietpi/.bash_history" || status SKIP "/home/dietpi/.bash_history" + +# --- Ready flag --- +clean_file /var/run/pikit-ready + +# --- Backup/editor cruft --- +clean_backups /var/www/pikit-web +clean_backups /usr/local/bin + +# --- Logs --- +clean_dir_files /var/log "*" +clean_dir_files /var/log/nginx "*" +# systemd journal (persistent) if present +if [ -d /var/log/journal ]; then + find /var/log/journal -type f -print -delete 2>/dev/null | sed 's/^/[CLEANED] /' || true + status CLEANED "/var/log/journal" +else + status SKIP "/var/log/journal (missing)" +fi +# crash dumps +if [ -d /var/crash ]; then + find /var/crash -type f -print -delete 2>/dev/null | sed 's/^/[CLEANED] /' || true + status CLEANED "/var/crash" +else + status SKIP "/var/crash (missing)" +fi + +# Service-specific logs (best-effort, skip if absent) +if command -v pihole >/dev/null 2>&1; then + pihole -f >/dev/null 2>&1 && status CLEANED "pihole logs via pihole -f" || status FAIL "pihole -f" + clean_logs_dir /var/log/pihole '*' + clean_file /etc/pihole/pihole-FTL.db # resets long-term query history; leave gravity.db untouched +fi + +if [ -x /opt/AdGuardHome/AdGuardHome ]; then + clean_logs_dir /var/opt/AdGuardHome/data/logs '*' + clean_file /opt/AdGuardHome/data/querylog.db +fi + +if command -v ufw >/dev/null 2>&1; then + truncate_file /var/log/ufw.log +fi + +if command -v fail2ban-client >/dev/null 2>&1; then + truncate_file /var/log/fail2ban.log +fi + +clean_logs_dir /var/log/unbound '*' +clean_logs_dir /var/log/dnsmasq '*' +clean_logs_dir /var/log/powerdns '*' +clean_logs_dir /var/lib/technitium-dns/Logs '*' + +clean_logs_dir /var/log/jellyfin '*' +clean_logs_dir /var/lib/jellyfin/log '*' +clean_logs_dir /var/log/jellyseerr '*' +clean_logs_dir /opt/jellyseerr/logs '*' +clean_logs_dir /var/log/ustreamer '*' +clean_logs_dir /var/log/gitea '*' +clean_logs_dir /var/lib/gitea/log '*' +clean_logs_dir /var/log/fmd '*' +clean_logs_dir /var/log/uptime-kuma '*' +clean_logs_dir /opt/uptime-kuma/data/logs '*' +clean_logs_dir /var/log/romm '*' +clean_logs_dir /var/log/privatebin '*' +clean_logs_dir /var/log/crafty '*' +clean_logs_dir /var/log/rustdesk '*' +clean_logs_dir /var/log/memos '*' +clean_logs_dir /var/lib/memos/logs '*' +clean_logs_dir /var/log/traccar '*' +clean_logs_dir /var/log/webmin '*' +clean_logs_dir /var/log/homarr '*' +clean_logs_dir /var/log/termix '*' +clean_logs_dir /var/log/syncthing '*' +clean_logs_dir /var/log/netdata '*' +clean_logs_dir /var/lib/netdata/dbengine '*' +clean_logs_dir /var/log/AdGuardHome '*' + +# DB / metrics / web stacks +clean_logs_dir /var/log/mysql '*' +clean_logs_dir /var/log/mariadb '*' +clean_logs_dir /var/log/postgresql '*' +truncate_file /var/log/redis/redis-server.log +clean_logs_dir /var/log/influxdb '*' +clean_logs_dir /var/log/prometheus '*' +clean_logs_dir /var/log/grafana '*' +clean_logs_dir /var/log/loki '*' +clean_logs_dir /var/log/caddy '*' +clean_logs_dir /var/log/apache2 '*' +clean_logs_dir /var/log/lighttpd '*' +clean_logs_dir /var/log/samba '*' +clean_logs_dir /var/log/mosquitto '*' +clean_logs_dir /var/log/openvpn '*' +clean_logs_dir /var/log/wireguard '*' +clean_logs_dir /var/log/node-red '*' +truncate_file /var/log/nodered-install.log +clean_logs_dir /var/log/transmission-daemon '*' +clean_logs_dir /var/log/deluge '*' +clean_logs_dir /var/log/qbittorrent '*' +clean_logs_dir /var/log/paperless-ngx '*' +clean_logs_dir /var/log/photoprism '*' +clean_logs_dir /var/log/navidrome '*' +clean_logs_dir /var/log/minio '*' +clean_logs_dir /var/log/nzbget '*' +clean_logs_dir /var/log/sabnzbd '*' +clean_logs_dir /var/log/jackett '*' +clean_logs_dir /var/log/radarr '*' +clean_logs_dir /var/log/sonarr '*' +clean_logs_dir /var/log/lidarr '*' +clean_logs_dir /var/log/prowlarr '*' +clean_logs_dir /var/log/bazarr '*' +clean_logs_dir /var/log/overseerr '*' +clean_logs_dir /var/log/emby-server '*' + +# App-specific logs stored with app data (truncate, keep structure) +truncate_file /home/homeassistant/.homeassistant/home-assistant.log +clean_logs_dir /home/homeassistant/.homeassistant/logs '*' +truncate_file /var/www/nextcloud/data/nextcloud.log +truncate_file /var/www/owncloud/data/owncloud.log + +# Docker container JSON logs +if [ -d /var/lib/docker/containers ]; then + find /var/lib/docker/containers -type f -name '*-json.log' -print0 2>/dev/null | while IFS= read -r -d '' f; do + :> "$f" && status CLEANED "truncated $f" || status FAIL "truncate $f" + done +else + status SKIP "/var/lib/docker/containers (missing)" +fi +clean_file /var/log/wtmp.db +clean_dir_files /var/tmp/dietpi/logs "*" + +# --- Caches --- +apt-get clean >/dev/null 2>&1 && status CLEANED "apt cache" || status FAIL "apt cache" +rm -rf /var/lib/apt/lists/* /var/cache/apt/* 2>/dev/null && status CLEANED "apt lists/cache" || status FAIL "apt lists/cache" +find /var/cache/debconf -type f -print -delete 2>/dev/null | sed 's/^/[CLEANED] /' || true +status CLEANED "/var/cache/debconf files" + +# --- Temp directories --- +truncate_dir /tmp +truncate_dir /var/tmp + +# --- DHCP leases --- +clean_file /var/lib/dhcp/dhclient.eth0.leases + +# --- Nginx caches --- +if [ -d /var/lib/nginx ]; then + find /var/lib/nginx -mindepth 1 -maxdepth 1 -type d -exec rm -rf {} + 2>/dev/null + status CLEANED "/var/lib/nginx/*" +else + status SKIP "/var/lib/nginx" +fi + +status DONE "Prep complete" + +# Self-delete to avoid leaving the prep tool on the image. +rm -- "$0" diff --git a/pikit-services.json b/pikit-services.json new file mode 100644 index 0000000..792bd27 --- /dev/null +++ b/pikit-services.json @@ -0,0 +1,3 @@ +[ + { "name": "DietPi Dashboard", "port": 5252, "scheme": "http" } +] diff --git a/pikit-web/LICENSE b/pikit-web/LICENSE new file mode 100644 index 0000000..2104127 --- /dev/null +++ b/pikit-web/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Pi-Kit contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/pikit-web/LICENSE.txt b/pikit-web/LICENSE.txt new file mode 100644 index 0000000..2104127 --- /dev/null +++ b/pikit-web/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Pi-Kit contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/pikit-web/RESCUE.md b/pikit-web/RESCUE.md new file mode 100644 index 0000000..e913617 --- /dev/null +++ b/pikit-web/RESCUE.md @@ -0,0 +1,41 @@ +# Pi-Kit quick rescue (offline note) + +Keep this handy if the web dashboard is down. You’re already in via SSH, so here’s what to check next. + +## Where to find this note +- `/root/RESCUE.md` +- `/home/dietpi/RESCUE.md` +- `/var/www/pikit-web/RESCUE.md` + +## Fast service reset +```bash +sudo systemctl status nginx pikit-api # check +sudo systemctl restart nginx pikit-api # restart both +``` + +## Logs to inspect +```bash +sudo tail -n 100 /var/log/nginx/error.log +sudo tail -n 100 /var/log/pikit-api.log +``` + +## Check system health +```bash +df -h # disk space +free -h # memory +sudo systemctl status unattended-upgrades # auto-update service +sudo systemctl status apt-daily.timer apt-daily-upgrade.timer +``` + +## If services won’t start +```bash +sudo nginx -t # validate nginx config +sudo journalctl -u nginx -u pikit-api -n 100 # detailed service logs +``` + +## Licenses (for distribution) +- `/var/www/pikit-web/LICENSE` (MIT for Pi-Kit) +- `/var/www/pikit-web/THIRD-PARTY-LICENSES.md` +- `/var/www/pikit-web/assets/fonts/OFL.txt` + +Tip: after any change, `sudo systemctl restart nginx pikit-api` then re-check logs above. diff --git a/pikit-web/THIRD-PARTY-LICENSES.md b/pikit-web/THIRD-PARTY-LICENSES.md new file mode 100644 index 0000000..88a5393 --- /dev/null +++ b/pikit-web/THIRD-PARTY-LICENSES.md @@ -0,0 +1,21 @@ +# Third-Party Licenses + +## Fonts (SIL Open Font License 1.1) +- Red Hat Display +- Red Hat Text +- Space Grotesk +- Manrope +- DM Sans +- Sora +- Chivo +- Atkinson Hyperlegible +- IBM Plex Sans + +Full text: assets/fonts/OFL.txt + +## Frontend tooling +- Vite — MIT License +- @fontsource packages — MIT License + +## Test/dev +- Playwright — Apache License 2.0 diff --git a/pikit-web/THIRD-PARTY-LICENSES.txt b/pikit-web/THIRD-PARTY-LICENSES.txt new file mode 100644 index 0000000..88a5393 --- /dev/null +++ b/pikit-web/THIRD-PARTY-LICENSES.txt @@ -0,0 +1,21 @@ +# Third-Party Licenses + +## Fonts (SIL Open Font License 1.1) +- Red Hat Display +- Red Hat Text +- Space Grotesk +- Manrope +- DM Sans +- Sora +- Chivo +- Atkinson Hyperlegible +- IBM Plex Sans + +Full text: assets/fonts/OFL.txt + +## Frontend tooling +- Vite — MIT License +- @fontsource packages — MIT License + +## Test/dev +- Playwright — Apache License 2.0 diff --git a/pikit-web/assets/api.js b/pikit-web/assets/api.js new file mode 100644 index 0000000..5aa27cc --- /dev/null +++ b/pikit-web/assets/api.js @@ -0,0 +1,89 @@ +// Lightweight fetch wrapper for the Pi-Kit API endpoints exposed by the mock server +// and on-device Python API. All helpers below return parsed JSON or throw the +// JSON error body when the response is not 2xx. +const headers = { "Content-Type": "application/json" }; + +export async function api(path, opts = {}) { + // When running `npm run dev` without the backend, allow mock JSON from /data/ + const isMock = import.meta?.env?.MODE === 'development' && path.startsWith('/api'); + const target = isMock + ? path.replace('/api/status', '/data/mock-status.json').replace('/api/updates/config', '/data/mock-updates.json') + : path; + + const res = await fetch(target, { headers, ...opts }); + + // If mock files are missing, surface a clear error instead of JSON parse of HTML + const text = await res.text(); + let data; + try { + data = JSON.parse(text); + } catch (e) { + throw new Error(`Expected JSON from ${target}, got: ${text.slice(0, 120)}...`); + } + + if (!res.ok) throw data; + return data; +} + +export const getStatus = () => api("/api/status"); +export const toggleUpdates = (enable) => + api("/api/updates/auto", { + method: "POST", + body: JSON.stringify({ enable }), + }); +export const getUpdateConfig = () => api("/api/updates/config"); +export const saveUpdateConfig = (config) => + api("/api/updates/config", { + method: "POST", + body: JSON.stringify(config), + }); + +export const triggerReset = (confirm) => + api("/api/reset", { + method: "POST", + body: JSON.stringify({ confirm }), + }); + +export const addService = ({ + name, + port, + scheme, + path, + notice, + notice_link, + self_signed, +}) => + api("/api/services/add", { + method: "POST", + body: JSON.stringify({ name, port, scheme, path, notice, notice_link, self_signed }), + }); + +export const updateService = ({ + port, + name, + new_port, + scheme, + path, + notice, + notice_link, + self_signed, +}) => + api("/api/services/update", { + method: "POST", + body: JSON.stringify({ + port, + name, + new_port, + scheme, + path, + notice, + notice_link, + self_signed, + }), + }); + +export const removeService = ({ port }) => + api("/api/services/remove", { + method: "POST", + body: JSON.stringify({ port }), + }); diff --git a/pikit-web/assets/fonts/Atkinson-Bold.woff2 b/pikit-web/assets/fonts/Atkinson-Bold.woff2 new file mode 100644 index 0000000..16ab6eb Binary files /dev/null and b/pikit-web/assets/fonts/Atkinson-Bold.woff2 differ diff --git a/pikit-web/assets/fonts/Atkinson-Regular.woff2 b/pikit-web/assets/fonts/Atkinson-Regular.woff2 new file mode 100644 index 0000000..7f0ce7c Binary files /dev/null and b/pikit-web/assets/fonts/Atkinson-Regular.woff2 differ diff --git a/pikit-web/assets/fonts/Chivo-Bold.woff2 b/pikit-web/assets/fonts/Chivo-Bold.woff2 new file mode 100644 index 0000000..852a1b3 Binary files /dev/null and b/pikit-web/assets/fonts/Chivo-Bold.woff2 differ diff --git a/pikit-web/assets/fonts/Chivo-Regular.woff2 b/pikit-web/assets/fonts/Chivo-Regular.woff2 new file mode 100644 index 0000000..0a89ebc Binary files /dev/null and b/pikit-web/assets/fonts/Chivo-Regular.woff2 differ diff --git a/pikit-web/assets/fonts/DMSans-Bold.woff2 b/pikit-web/assets/fonts/DMSans-Bold.woff2 new file mode 100644 index 0000000..8a1d2e0 Binary files /dev/null and b/pikit-web/assets/fonts/DMSans-Bold.woff2 differ diff --git a/pikit-web/assets/fonts/DMSans-Medium.woff2 b/pikit-web/assets/fonts/DMSans-Medium.woff2 new file mode 100644 index 0000000..e0927e7 Binary files /dev/null and b/pikit-web/assets/fonts/DMSans-Medium.woff2 differ diff --git a/pikit-web/assets/fonts/DMSans-Regular.woff2 b/pikit-web/assets/fonts/DMSans-Regular.woff2 new file mode 100644 index 0000000..63f6aea Binary files /dev/null and b/pikit-web/assets/fonts/DMSans-Regular.woff2 differ diff --git a/pikit-web/assets/fonts/Manrope-Regular.woff2 b/pikit-web/assets/fonts/Manrope-Regular.woff2 new file mode 100644 index 0000000..a5c0779 Binary files /dev/null and b/pikit-web/assets/fonts/Manrope-Regular.woff2 differ diff --git a/pikit-web/assets/fonts/Manrope-SemiBold.woff2 b/pikit-web/assets/fonts/Manrope-SemiBold.woff2 new file mode 100644 index 0000000..45cc908 Binary files /dev/null and b/pikit-web/assets/fonts/Manrope-SemiBold.woff2 differ diff --git a/pikit-web/assets/fonts/OFL.txt b/pikit-web/assets/fonts/OFL.txt new file mode 100644 index 0000000..72a3f60 --- /dev/null +++ b/pikit-web/assets/fonts/OFL.txt @@ -0,0 +1,101 @@ +This package includes open fonts licensed under the SIL Open Font License, Version 1.1. + +Fonts covered (used by Pi-Kit): +- Red Hat Display +- Red Hat Text +- Space Grotesk +- Manrope +- DM Sans +- Sora +- Chivo +- Atkinson Hyperlegible +- IBM Plex Sans + +Full license text: + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/pikit-web/assets/fonts/PlexSans-Regular.woff2 b/pikit-web/assets/fonts/PlexSans-Regular.woff2 new file mode 100644 index 0000000..93bcd64 Binary files /dev/null and b/pikit-web/assets/fonts/PlexSans-Regular.woff2 differ diff --git a/pikit-web/assets/fonts/PlexSans-SemiBold.woff2 b/pikit-web/assets/fonts/PlexSans-SemiBold.woff2 new file mode 100644 index 0000000..0ac91d6 Binary files /dev/null and b/pikit-web/assets/fonts/PlexSans-SemiBold.woff2 differ diff --git a/pikit-web/assets/fonts/RedHatDisplay-Bold.woff2 b/pikit-web/assets/fonts/RedHatDisplay-Bold.woff2 new file mode 100644 index 0000000..af96eb3 Binary files /dev/null and b/pikit-web/assets/fonts/RedHatDisplay-Bold.woff2 differ diff --git a/pikit-web/assets/fonts/RedHatDisplay-SemiBold.woff2 b/pikit-web/assets/fonts/RedHatDisplay-SemiBold.woff2 new file mode 100644 index 0000000..2ae3238 Binary files /dev/null and b/pikit-web/assets/fonts/RedHatDisplay-SemiBold.woff2 differ diff --git a/pikit-web/assets/fonts/RedHatText-Medium.woff2 b/pikit-web/assets/fonts/RedHatText-Medium.woff2 new file mode 100644 index 0000000..f52ca25 Binary files /dev/null and b/pikit-web/assets/fonts/RedHatText-Medium.woff2 differ diff --git a/pikit-web/assets/fonts/RedHatText-Regular.woff2 b/pikit-web/assets/fonts/RedHatText-Regular.woff2 new file mode 100644 index 0000000..af2c197 Binary files /dev/null and b/pikit-web/assets/fonts/RedHatText-Regular.woff2 differ diff --git a/pikit-web/assets/fonts/Sora-Regular.woff2 b/pikit-web/assets/fonts/Sora-Regular.woff2 new file mode 100644 index 0000000..f546071 Binary files /dev/null and b/pikit-web/assets/fonts/Sora-Regular.woff2 differ diff --git a/pikit-web/assets/fonts/Sora-SemiBold.woff2 b/pikit-web/assets/fonts/Sora-SemiBold.woff2 new file mode 100644 index 0000000..10ee773 Binary files /dev/null and b/pikit-web/assets/fonts/Sora-SemiBold.woff2 differ diff --git a/pikit-web/assets/fonts/SpaceGrotesk-Medium.woff2 b/pikit-web/assets/fonts/SpaceGrotesk-Medium.woff2 new file mode 100644 index 0000000..13ce842 Binary files /dev/null and b/pikit-web/assets/fonts/SpaceGrotesk-Medium.woff2 differ diff --git a/pikit-web/assets/fonts/SpaceGrotesk-Regular.woff2 b/pikit-web/assets/fonts/SpaceGrotesk-Regular.woff2 new file mode 100644 index 0000000..2cc29e9 Binary files /dev/null and b/pikit-web/assets/fonts/SpaceGrotesk-Regular.woff2 differ diff --git a/pikit-web/assets/fonts/SpaceGrotesk-SemiBold.woff2 b/pikit-web/assets/fonts/SpaceGrotesk-SemiBold.woff2 new file mode 100644 index 0000000..69ee399 Binary files /dev/null and b/pikit-web/assets/fonts/SpaceGrotesk-SemiBold.woff2 differ diff --git a/pikit-web/assets/main.js b/pikit-web/assets/main.js new file mode 100644 index 0000000..467ce54 --- /dev/null +++ b/pikit-web/assets/main.js @@ -0,0 +1,635 @@ +// Entry point for the dashboard: wires UI events, pulls status, and initializes +// feature modules (services, settings, stats). +import { getStatus, triggerReset } from "./api.js"; +import { placeholderStatus, renderStats } from "./status.js"; +import { initServiceControls, renderServices } from "./services.js"; +import { initSettings } from "./settings.js"; +import { initUpdateSettings, isUpdatesDirty } from "./update-settings.js"; + +const servicesGrid = document.getElementById("servicesGrid"); +const heroStats = document.getElementById("heroStats"); +const refreshHintMain = document.getElementById("refreshHintMain"); +const refreshHintServices = document.getElementById("refreshHintServices"); +const refreshFlagTop = document.getElementById("refreshFlagTop"); +const themeToggle = document.getElementById("themeToggle"); +const themeToggleIcon = document.getElementById("themeToggleIcon"); +const animToggle = document.getElementById("animToggle"); + +const resetConfirm = document.getElementById("resetConfirm"); +const resetBtn = document.getElementById("resetBtn"); +const updatesToggle = document.getElementById("updatesToggle"); +const updatesStatus = document.getElementById("updatesStatus"); +const updatesFlagTop = document.getElementById("updatesFlagTop"); +const updatesNoteTop = document.getElementById("updatesNoteTop"); +const tempFlagTop = document.getElementById("tempFlagTop"); +const refreshIntervalInput = document.getElementById("refreshIntervalInput"); +const refreshIntervalSave = document.getElementById("refreshIntervalSave"); +const refreshIntervalMsg = document.getElementById("refreshIntervalMsg"); +const toastPosSelect = document.getElementById("toastPosSelect"); +const toastAnimSelect = document.getElementById("toastAnimSelect"); +const toastSpeedInput = document.getElementById("toastSpeedInput"); +const toastDurationInput = document.getElementById("toastDurationInput"); +const fontSelect = document.getElementById("fontSelect"); +const updatesScope = document.getElementById("updatesScope"); +const updateTimeInput = document.getElementById("updateTimeInput"); +const upgradeTimeInput = document.getElementById("upgradeTimeInput"); +const updatesCleanup = document.getElementById("updatesCleanup"); +const updatesBandwidth = document.getElementById("updatesBandwidth"); +const updatesRebootToggle = document.getElementById("updatesRebootToggle"); +const updatesRebootTime = document.getElementById("updatesRebootTime"); +const updatesRebootUsers = document.getElementById("updatesRebootUsers"); +const updatesSaveBtn = document.getElementById("updatesSaveBtn"); +const updatesMsg = document.getElementById("updatesMsg"); +const updatesUnsavedNote = document.getElementById("updatesUnsavedNote"); +const updatesSection = document.getElementById("updatesSection"); +const svcName = document.getElementById("svcName"); +const svcPort = document.getElementById("svcPort"); +const svcPath = document.getElementById("svcPath"); +const svcAddBtn = document.getElementById("svcAddBtn"); +const svcMsg = document.getElementById("svcMsg"); +const svcScheme = document.getElementById("svcScheme"); +const svcNotice = document.getElementById("svcNotice"); +const svcNoticeLink = document.getElementById("svcNoticeLink"); +const svcSelfSigned = document.getElementById("svcSelfSigned"); +const svcSelfSignedLabel = document.querySelector("label[for='svcSelfSigned']") || null; +const addServiceModal = document.getElementById("addServiceModal"); +const addSvcClose = document.getElementById("addSvcClose"); +const addServiceOpen = document.getElementById("addServiceOpen"); +const menuModal = document.getElementById("menuModal"); +const menuTitle = document.getElementById("menuTitle"); +const menuSubtitle = document.getElementById("menuSubtitle"); +const menuRename = document.getElementById("menuRename"); +const menuPort = document.getElementById("menuPort"); +const menuPath = document.getElementById("menuPath"); +const menuScheme = document.getElementById("menuScheme"); +const menuNotice = document.getElementById("menuNotice"); +const menuNoticeLink = document.getElementById("menuNoticeLink"); +const menuSelfSigned = document.getElementById("menuSelfSigned"); +const menuSaveBtn = document.getElementById("menuSaveBtn"); +const menuCancelBtn = document.getElementById("menuCancelBtn"); +const menuRemoveBtn = document.getElementById("menuRemoveBtn"); +const menuMsg = document.getElementById("menuMsg"); +const menuClose = document.getElementById("menuClose"); + +const advBtn = document.getElementById("advBtn"); +const advModal = document.getElementById("advModal"); +const advClose = document.getElementById("advClose"); +const helpBtn = document.getElementById("helpBtn"); +const helpModal = document.getElementById("helpModal"); +const helpClose = document.getElementById("helpClose"); +const aboutBtn = document.getElementById("aboutBtn"); +const aboutModal = document.getElementById("aboutModal"); +const aboutClose = document.getElementById("aboutClose"); +const readyOverlay = document.getElementById("readyOverlay"); +const busyOverlay = document.getElementById("busyOverlay"); +const busyTitle = document.getElementById("busyTitle"); +const busyText = document.getElementById("busyText"); +const toastContainer = document.getElementById("toastContainer"); + +const TOAST_POS_KEY = "pikit-toast-pos"; +const TOAST_ANIM_KEY = "pikit-toast-anim"; +const TOAST_SPEED_KEY = "pikit-toast-speed"; +const TOAST_DURATION_KEY = "pikit-toast-duration"; +const FONT_KEY = "pikit-font"; +const ALLOWED_TOAST_POS = [ + "bottom-center", + "bottom-right", + "bottom-left", + "top-right", + "top-left", + "top-center", +]; +const ALLOWED_TOAST_ANIM = ["slide-in", "fade", "pop", "bounce", "drop", "grow"]; +const ALLOWED_FONTS = ["redhat", "space", "manrope", "dmsans", "sora", "chivo", "atkinson", "plex"]; + +let toastPosition = "bottom-center"; +let toastAnimation = "slide-in"; +let toastDurationMs = 5000; +let toastSpeedMs = 300; +let fontChoice = "redhat"; + +function applyToastSettings() { + if (!toastContainer) return; + toastContainer.className = `toast-container pos-${toastPosition}`; + document.documentElement.style.setProperty("--toast-speed", `${toastSpeedMs}ms`); + const dir = toastPosition.startsWith("top") ? -1 : 1; + const isLeft = toastPosition.includes("left"); + const isRight = toastPosition.includes("right"); + const slideX = isLeft ? -26 : isRight ? 26 : 0; + const slideY = isLeft || isRight ? 0 : dir * 24; + document.documentElement.style.setProperty("--toast-slide-offset", `${dir * 24}px`); + document.documentElement.style.setProperty("--toast-dir", `${dir}`); + document.documentElement.style.setProperty("--toast-slide-x", `${slideX}px`); + document.documentElement.style.setProperty("--toast-slide-y", `${slideY}px`); + if (toastDurationInput) toastDurationInput.value = toastDurationMs; +} + +function applyFontSetting() { + document.documentElement.setAttribute("data-font", fontChoice); + if (fontSelect) fontSelect.value = fontChoice; +} + +function loadToastSettings() { + try { + const posSaved = localStorage.getItem(TOAST_POS_KEY); + if (ALLOWED_TOAST_POS.includes(posSaved)) toastPosition = posSaved; + const animSaved = localStorage.getItem(TOAST_ANIM_KEY); + const migrated = + animSaved === "slide-up" || animSaved === "slide-left" || animSaved === "slide-right" + ? "slide-in" + : animSaved; + if (migrated && ALLOWED_TOAST_ANIM.includes(migrated)) toastAnimation = migrated; + const savedSpeed = Number(localStorage.getItem(TOAST_SPEED_KEY)); + if (!Number.isNaN(savedSpeed) && savedSpeed >= 100 && savedSpeed <= 3000) { + toastSpeedMs = savedSpeed; + } + const savedDur = Number(localStorage.getItem(TOAST_DURATION_KEY)); + if (!Number.isNaN(savedDur) && savedDur >= 1000 && savedDur <= 15000) { + toastDurationMs = savedDur; + } + const savedFont = localStorage.getItem(FONT_KEY); + if (ALLOWED_FONTS.includes(savedFont)) fontChoice = savedFont; + } catch (e) { + console.warn("Toast settings load failed", e); + } + if (toastPosSelect) toastPosSelect.value = toastPosition; + if (toastAnimSelect) toastAnimSelect.value = toastAnimation; + if (toastSpeedInput) toastSpeedInput.value = toastSpeedMs; + if (toastDurationInput) toastDurationInput.value = toastDurationMs; + if (fontSelect) fontSelect.value = fontChoice; + applyToastSettings(); + applyFontSetting(); +} + +function persistToastSettings() { + try { + localStorage.setItem(TOAST_POS_KEY, toastPosition); + localStorage.setItem(TOAST_ANIM_KEY, toastAnimation); + localStorage.setItem(TOAST_SPEED_KEY, String(toastSpeedMs)); + localStorage.setItem(TOAST_DURATION_KEY, String(toastDurationMs)); + localStorage.setItem(FONT_KEY, fontChoice); + } catch (e) { + console.warn("Toast settings save failed", e); + } +} + +function showToast(message, type = "info") { + if (!toastContainer || !message) return; + const t = document.createElement("div"); + t.className = `toast ${type} anim-${toastAnimation}`; + t.textContent = message; + toastContainer.appendChild(t); + const animOn = document.documentElement.getAttribute("data-anim") !== "off"; + if (!animOn) { + t.classList.add("show"); + } else { + requestAnimationFrame(() => t.classList.add("show")); + } + const duration = toastDurationMs; + setTimeout(() => { + const all = Array.from(toastContainer.querySelectorAll(".toast")); + const others = all.filter((el) => el !== t && !el.classList.contains("leaving")); + const first = new Map( + others.map((el) => [el, el.getBoundingClientRect()]), + ); + + t.classList.add("leaving"); + // force layout + void t.offsetHeight; + + requestAnimationFrame(() => { + const second = new Map( + others.map((el) => [el, el.getBoundingClientRect()]), + ); + others.forEach((el) => { + const dy = first.get(el).top - second.get(el).top; + if (Math.abs(dy) > 0.5) { + el.style.transition = "transform var(--toast-speed, 0.28s) ease"; + el.style.transform = `translateY(${dy}px)`; + requestAnimationFrame(() => { + el.style.transform = ""; + }); + } + }); + }); + + const removeDelay = animOn ? toastSpeedMs : 0; + setTimeout(() => { + t.classList.remove("show"); + t.remove(); + // clear transition styling + others.forEach((el) => (el.style.transition = "")); + }, removeDelay); + }, duration); +} + +function applyTooltips() { + const tips = { + updatesFlagTop: "Auto updates status; configure in Settings → Automatic updates.", + refreshFlagTop: "Auto-refresh interval; change in Settings → Refresh interval.", + tempFlagTop: "CPU temperature status; see details in the hero stats below.", + themeToggle: "Toggle light or dark theme", + helpBtn: "Open quick help", + advBtn: "Open settings", + animToggle: "Enable or disable dashboard animations", + refreshIntervalInput: "Seconds between automatic refreshes (5-120)", + refreshIntervalSave: "Save refresh interval", + svcName: "Display name for the service card", + svcPort: "Port number the service listens on", + svcPath: "Optional path like /admin", + svcScheme: "Choose HTTP or HTTPS link", + svcSelfSigned: "Mark service as using a self-signed certificate", + svcNotice: "Optional note shown on the service card", + svcNoticeLink: "Optional link for more info about the service", + svcAddBtn: "Add the service to the dashboard", + updatesToggle: "Turn unattended upgrades on or off", + updatesScope: "Select security-only or all updates", + updateTimeInput: "Time to download updates (24h)", + upgradeTimeInput: "Time to install updates (24h)", + updatesCleanup: "Remove unused dependencies after upgrades", + updatesBandwidth: "Limit download bandwidth in KB/s (0 = unlimited)", + updatesRebootToggle: "Auto-reboot if required by updates", + updatesRebootTime: "Scheduled reboot time when auto-reboot is on", + updatesRebootUsers: "Allow reboot even if users are logged in", + updatesSaveBtn: "Save unattended-upgrades settings", + resetConfirm: "Type YES to enable factory reset", + resetBtn: "Factory reset this Pi-Kit", + menuRename: "Change the service display name", + menuPort: "Change the service port", + menuPath: "Optional service path", + menuScheme: "Switch between HTTP and HTTPS", + menuSelfSigned: "Mark the service as self-signed", + menuNotice: "Edit the notice text shown on the card", + menuNoticeLink: "Optional link for the notice", + menuSaveBtn: "Save service changes", + menuCancelBtn: "Cancel changes", + menuRemoveBtn: "Remove this service", + }; + Object.entries(tips).forEach(([id, text]) => { + const el = document.getElementById(id); + if (el) el.title = text; + }); +} + +// Clamp name inputs to 30 chars +[svcName, menuRename].forEach((el) => { + if (!el) return; + el.setAttribute("maxlength", "32"); + el.addEventListener("input", () => { + if (el.value.length > 32) el.value = el.value.slice(0, 32); + }); +}); + +function setUpdatesUI(enabled) { + const on = !!enabled; + updatesToggle.checked = on; + updatesStatus.textContent = on ? "On" : "Off"; + updatesStatus.classList.toggle("chip-on", on); + updatesStatus.classList.toggle("chip-off", !on); +} + +async function loadStatus() { + try { + const data = await getStatus(); + renderStats(heroStats, data); + renderServices(servicesGrid, data.services, { openAddService }); + const updatesEnabled = + data?.auto_updates?.enabled ?? data.auto_updates_enabled; + if (updatesEnabled !== undefined && !isUpdatesDirty()) { + setUpdatesUI(updatesEnabled); + } + + // Updates chip + reboot note + updatesFlagEl( + updatesEnabled === undefined ? null : updatesEnabled === true, + ); + const cfg = data.updates_config || {}; + const rebootReq = data.reboot_required; + setTempFlag(data.cpu_temp_c); + if (updatesNoteTop) { + updatesNoteTop.textContent = ""; + updatesNoteTop.classList.remove("note-warn"); + if (rebootReq) { + if (cfg.auto_reboot) { + updatesNoteTop.textContent = `Reboot scheduled at ${cfg.reboot_time || "02:00"}.`; + } else { + updatesNoteTop.textContent = "Reboot required. Please reboot when you can."; + updatesNoteTop.classList.add("note-warn"); + } + } + } + if (readyOverlay) { + if (data.ready) { + readyOverlay.classList.add("hidden"); + } else { + readyOverlay.classList.remove("hidden"); + // When not ready, retry periodically until API reports ready + setTimeout(loadStatus, 3000); + } + } + } catch (e) { + console.error(e); + renderStats(heroStats, placeholderStatus); + } +} + +function setTempFlag(tempC) { + if (!tempFlagTop) return; + const t = typeof tempC === "number" ? tempC : null; + let label = "Temp: n/a"; + tempFlagTop.classList.remove("chip-on", "chip-warm", "chip-off"); + if (t !== null) { + if (t < 55) { + label = "Temp: OK"; + tempFlagTop.classList.add("chip-on"); + } else if (t < 70) { + label = "Temp: Warm"; + tempFlagTop.classList.add("chip-warm"); + } else { + label = "Temp: Hot"; + tempFlagTop.classList.add("chip-off"); + } + } + tempFlagTop.textContent = label; +} + +function updatesFlagEl(enabled) { + if (!updatesFlagTop) return; + updatesFlagTop.textContent = "Auto updates"; + updatesFlagTop.classList.remove("chip-on", "chip-off"); + if (enabled === true) updatesFlagTop.classList.add("chip-on"); + else if (enabled === false) updatesFlagTop.classList.add("chip-off"); +} + +function wireModals() { + advBtn.onclick = () => advModal.classList.remove("hidden"); + advClose.onclick = () => advModal.classList.add("hidden"); + helpBtn.onclick = () => helpModal.classList.remove("hidden"); + helpClose.onclick = () => helpModal.classList.add("hidden"); + aboutBtn.onclick = () => aboutModal.classList.remove("hidden"); + aboutClose.onclick = () => aboutModal.classList.add("hidden"); + menuClose.onclick = () => menuModal.classList.add("hidden"); + addServiceOpen?.addEventListener("click", openAddService); + addSvcClose?.addEventListener("click", () => addServiceModal?.classList.add("hidden")); + addServiceModal?.addEventListener("click", (e) => { + if (e.target === addServiceModal) addServiceModal.classList.add("hidden"); + }); +} + +function showBusy(title = "Working…", text = "This may take a few seconds.") { + if (!busyOverlay) return; + busyTitle.textContent = title; + busyText.textContent = text || ""; + busyText.classList.toggle("hidden", !text); + busyOverlay.classList.remove("hidden"); +} + +function hideBusy() { + busyOverlay?.classList.add("hidden"); +} + +// Testing hook +if (typeof window !== "undefined") { + window.__pikitTest = window.__pikitTest || {}; + window.__pikitTest.showBusy = showBusy; + window.__pikitTest.hideBusy = hideBusy; +} + +function wireResetAndUpdates() { + resetBtn.onclick = async () => { + resetBtn.disabled = true; + try { + await triggerReset(resetConfirm.value.trim()); + alert("Resetting now. The device will reboot."); + } catch (e) { + alert(e.error || "Reset failed"); + } finally { + resetBtn.disabled = false; + } + }; + + resetConfirm.addEventListener("input", () => { + resetBtn.disabled = resetConfirm.value.trim() !== "YES"; + }); +} + +function wireAccordions() { + const forceOpen = typeof window !== "undefined" && window.__pikitTest?.forceAccordionsOpen; + const accordions = document.querySelectorAll(".accordion"); + if (forceOpen) { + accordions.forEach((a) => a.classList.add("open")); + return; + } + document.querySelectorAll(".accordion-toggle").forEach((btn) => { + btn.addEventListener("click", () => { + const acc = btn.closest(".accordion"); + if (acc.classList.contains("open")) { + acc.classList.remove("open"); + } else { + // Keep a single accordion expanded at a time for readability + accordions.forEach((a) => a.classList.remove("open")); + acc.classList.add("open"); + } + }); + }); +} + +function collapseAccordions() { + document.querySelectorAll(".accordion").forEach((a) => a.classList.remove("open")); +} + +function openAddService() { + if (addServiceModal) addServiceModal.classList.remove("hidden"); + document.getElementById("svcName")?.focus(); +} +if (typeof window !== "undefined") { + window.__pikitOpenAddService = openAddService; +} + +function main() { + applyTooltips(); + wireModals(); + wireResetAndUpdates(); + wireAccordions(); + loadToastSettings(); + + if (advClose) { + advClose.onclick = () => { + advModal.classList.add("hidden"); + collapseAccordions(); + }; + } + + initServiceControls({ + gridEl: servicesGrid, + onChange: loadStatus, + overlay: { show: showBusy, hide: hideBusy }, + toast: showToast, + openAddService, + menu: { + modal: menuModal, + title: menuTitle, + subtitle: menuSubtitle, + renameInput: menuRename, + portInput: menuPort, + pathInput: menuPath, + schemeSelect: menuScheme, + saveBtn: menuSaveBtn, + cancelBtn: menuCancelBtn, + removeBtn: menuRemoveBtn, + msg: menuMsg, + noticeInput: menuNotice, + noticeLinkInput: menuNoticeLink, + selfSignedInput: menuSelfSigned, + }, + addForm: { + nameInput: svcName, + portInput: svcPort, + pathInput: svcPath, + schemeSelect: svcScheme, + addBtn: svcAddBtn, + msg: svcMsg, + noticeInput: svcNotice, + noticeLinkInput: svcNoticeLink, + selfSignedInput: svcSelfSigned, + }, + }); + + initSettings({ + refreshHintMain, + refreshHintServices, + refreshFlagTop, + refreshIntervalInput, + refreshIntervalSave, + refreshIntervalMsg, + themeToggle, + themeToggleIcon, + animToggle, + onTick: loadStatus, + toast: showToast, + onThemeToggle: () => { + document.body.classList.add("theming"); + setTimeout(() => document.body.classList.remove("theming"), 300); + }, + }); + + // Toast controls + toastPosSelect?.addEventListener("change", () => { + const val = toastPosSelect.value; + if (ALLOWED_TOAST_POS.includes(val)) { + toastPosition = val; + applyToastSettings(); + persistToastSettings(); + } else { + toastPosSelect.value = toastPosition; + showToast("Invalid toast position", "error"); + } + }); + toastAnimSelect?.addEventListener("change", () => { + let val = toastAnimSelect.value; + if (val === "slide-up" || val === "slide-left" || val === "slide-right") val = "slide-in"; // migrate old label if cached + if (ALLOWED_TOAST_ANIM.includes(val)) { + toastAnimation = val; + persistToastSettings(); + } else { + toastAnimSelect.value = toastAnimation; + showToast("Invalid toast animation", "error"); + } + }); + const clampSpeed = (val) => Math.min(3000, Math.max(100, val)); + const clampDuration = (val) => Math.min(15000, Math.max(1000, val)); + + toastSpeedInput?.addEventListener("input", () => { + const raw = toastSpeedInput.value; + if (raw === "") return; // allow typing + const val = Number(raw); + if (Number.isNaN(val) || val < 100 || val > 3000) return; // wait until valid + toastSpeedMs = val; + applyToastSettings(); + persistToastSettings(); + }); + toastSpeedInput?.addEventListener("blur", () => { + const raw = toastSpeedInput.value; + if (raw === "") { + toastSpeedInput.value = toastSpeedMs; + return; + } + const val = Number(raw); + if (Number.isNaN(val) || val < 100 || val > 3000) { + toastSpeedMs = clampSpeed(toastSpeedMs); + toastSpeedInput.value = toastSpeedMs; + showToast("Toast speed must be 100-3000 ms", "error"); + return; + } + toastSpeedMs = val; + toastSpeedInput.value = toastSpeedMs; + applyToastSettings(); + persistToastSettings(); + }); + + toastDurationInput?.addEventListener("input", () => { + const raw = toastDurationInput.value; + if (raw === "") return; // allow typing + const val = Number(raw); + if (Number.isNaN(val) || val < 1000 || val > 15000) return; // wait until valid + toastDurationMs = val; + persistToastSettings(); + }); + toastDurationInput?.addEventListener("blur", () => { + const raw = toastDurationInput.value; + if (raw === "") { + toastDurationInput.value = toastDurationMs; + return; + } + const val = Number(raw); + if (Number.isNaN(val) || val < 1000 || val > 15000) { + toastDurationMs = clampDuration(toastDurationMs); + toastDurationInput.value = toastDurationMs; + showToast("Toast duration must be 1000-15000 ms", "error"); + return; + } + toastDurationMs = val; + toastDurationInput.value = toastDurationMs; + persistToastSettings(); + }); + fontSelect?.addEventListener("change", () => { + const val = fontSelect.value; + if (!ALLOWED_FONTS.includes(val)) { + fontSelect.value = fontChoice; + showToast("Invalid font choice", "error"); + return; + } + fontChoice = val; + applyFontSetting(); + persistToastSettings(); + }); + toastTestBtn?.addEventListener("click", () => showToast("This is a test toast", "info")); + + initUpdateSettings({ + elements: { + updatesStatus, + updatesToggle, + scopeSelect: updatesScope, + updateTimeInput, + upgradeTimeInput, + cleanupToggle: updatesCleanup, + bandwidthInput: updatesBandwidth, + rebootToggle: updatesRebootToggle, + rebootTimeInput: updatesRebootTime, + rebootWithUsersToggle: updatesRebootUsers, + saveBtn: updatesSaveBtn, + msgEl: updatesMsg, + updatesUnsavedNote, + updatesSection, + }, + onAfterSave: loadStatus, + overlay: { show: showBusy, hide: hideBusy }, + toast: showToast, + }); + + // initial paint + renderStats(heroStats, placeholderStatus); + loadStatus(); +} + +main(); diff --git a/pikit-web/assets/services.js b/pikit-web/assets/services.js new file mode 100644 index 0000000..291b788 --- /dev/null +++ b/pikit-web/assets/services.js @@ -0,0 +1,361 @@ +import { addService, updateService, removeService } from "./api.js"; + +// Renders service cards and wires UI controls for add/edit/remove operations. +// All mutations round-trip through the API then invoke onChange to refresh data. + +let noticeModalRefs = null; +const DEFAULT_SELF_SIGNED_MSG = + "This service uses a self-signed certificate. Expect a browser warning; proceed only if you trust this device."; + +function isValidLink(str) { + if (!str) return true; // empty is allowed + try { + const u = new URL(str); + return u.protocol === "http:" || u.protocol === "https:"; + } catch (e) { + return false; + } +} + +function normalizePath(path) { + if (!path) return ""; + const trimmed = path.trim(); + if (!trimmed) return ""; + if (/\s/.test(trimmed)) return null; // no spaces or tabs in paths + if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return null; + return trimmed.startsWith("/") ? trimmed : `/${trimmed}`; +} + +function validateServiceFields({ name, port, path, notice, notice_link }, setMsg, toast) { + const fail = (m) => { + setMsg(""); + toast?.(m, "error"); + return false; + }; + if (!name || name.trim().length < 2) return fail("Name must be at least 2 characters."); + if (name.length > 48) return fail("Name is too long (max 48 chars)."); + if (!Number.isInteger(port) || port < 1 || port > 65535) return fail("Port must be 1-65535."); + if (path === null) return fail("Path must be relative (e.g. /admin) or blank."); + if (path.length > 200) return fail("Path is too long (max 200 chars)."); + if (notice && notice.length > 180) return fail("Notice text too long (max 180 chars)."); + if (notice_link && notice_link.length > 200) return fail("Notice link too long (max 200 chars)."); + if (!isValidLink(notice_link)) return fail("Enter a valid URL (http/https) or leave blank."); + return true; +} + +function ensureNoticeModal() { + if (noticeModalRefs) return noticeModalRefs; + const modal = document.createElement("div"); + modal.className = "modal hidden"; + modal.id = "noticeModal"; + modal.innerHTML = ` + + `; + document.body.appendChild(modal); + const title = modal.querySelector("#noticeTitle"); + const text = modal.querySelector("#noticeText"); + const link = modal.querySelector("#noticeLink"); + const closeBtn = modal.querySelector("#noticeClose"); + const close = () => modal.classList.add("hidden"); + closeBtn.addEventListener("click", close); + modal.addEventListener("click", (e) => { + if (e.target === modal) close(); + }); + noticeModalRefs = { modal, title, text, link, close }; + return noticeModalRefs; +} + +export function renderServices(gridEl, services = [], { openAddService } = {}) { + if (!gridEl) return; + gridEl.innerHTML = ""; + if (!services.length) { + gridEl.classList.add("empty"); + gridEl.innerHTML = ` +
+

No web services detected yet.

+ +
`; + const cta = gridEl.querySelector("#addSvcCta"); + cta?.addEventListener("click", () => { + if (typeof window !== "undefined" && window.__pikitOpenAddService) { + window.__pikitOpenAddService(); + } else if (typeof openAddService === "function") { + openAddService(); + } + }); + return; + } + gridEl.classList.remove("empty"); + services.forEach((svc) => { + const card = document.createElement("div"); + const scheme = svc.scheme === "https" ? "https" : "http"; + const path = normalizePath(svc.path) || ""; + const url = svc.url || `${scheme}://pikit:${svc.port}${path}`; + const nameRaw = svc.name || svc.process || "service"; + const name = nameRaw.slice(0, 32); + const isSelfSigned = !!svc.self_signed; + const hasCustomNotice = !!(svc.notice && svc.notice.trim()); + const noticeText = hasCustomNotice ? svc.notice.trim() : ""; + const noticeLink = hasCustomNotice ? svc.notice_link || "" : ""; + card.className = `card clickable ${svc.online ? "" : "offline"}`.trim(); + card.dataset.url = url; + card.dataset.path = path; + card.tabIndex = 0; + card.innerHTML = ` +
+
+
${name}
+
+
${url}
+

Port ${svc.port}

+ ${ + isSelfSigned + ? `Self-signed` + : "" + } +
+ ${ + hasCustomNotice + ? `` + : "" + } + +
+ `; + gridEl.appendChild(card); + }); +} + +export function initServiceControls({ gridEl, menu, addForm, onChange, overlay, toast, openAddService }) { + if (!gridEl) return; + // Tracks which card was opened in the context menu + let menuContext = null; + + const showBusy = overlay?.show || (() => {}); + const hideBusy = overlay?.hide || (() => {}); + + const { + modal, + title, + subtitle, + renameInput, + portInput, + pathInput, + schemeSelect, + saveBtn, + cancelBtn, + removeBtn, + msg, + noticeInput, + noticeLinkInput, + selfSignedInput, + } = menu; + + const { + nameInput, + portInput: addPortInput, + pathInput: addPathInput, + schemeSelect: addSchemeSelect, + addBtn, + msg: addMsg, + noticeInput: addNoticeInput, + noticeLinkInput: addNoticeLinkInput, + selfSignedInput: addSelfSignedInput, + } = addForm; + + function enforceTlsCheckbox(selectEl, checkbox) { + if (!selectEl || !checkbox) return; + const update = () => { + const isHttps = selectEl.value === "https"; + checkbox.disabled = !isHttps; + if (!isHttps) checkbox.checked = false; + }; + selectEl.addEventListener("change", update); + update(); + } + + gridEl.addEventListener("click", (e) => { + const btn = e.target.closest(".menu-btn"); + if (!btn) return; + const port = Number(btn.dataset.port); + const name = btn.dataset.name || ""; + const schemeRaw = btn.dataset.scheme || "http"; + const scheme = schemeRaw === "https" ? "https" : "http"; + const path = decodeURIComponent(btn.dataset.path || "").trim(); + const notice = decodeURIComponent(btn.dataset.notice || "").trim(); + const notice_link = decodeURIComponent(btn.dataset.noticeLink || "").trim(); + const self_signed = btn.dataset.selfSigned === "1"; + menuContext = { port, name, scheme, path }; + if (title) title.textContent = `${name || "Service"} (${scheme}://${port})`; + if (subtitle) { + const p = normalizePath(path) || ""; + subtitle.textContent = `Current link: ${scheme}://pikit:${port}${p}`; + } + if (renameInput) renameInput.value = name; + if (portInput) portInput.value = port; + if (pathInput) pathInput.value = path; + if (schemeSelect) schemeSelect.value = scheme; + if (noticeInput) noticeInput.value = notice || ""; + if (noticeLinkInput) noticeLinkInput.value = notice_link || ""; + if (selfSignedInput) selfSignedInput.checked = !!self_signed; + if (msg) msg.textContent = ""; + modal?.classList.remove("hidden"); + }); + + gridEl.addEventListener("click", (e) => { + if (e.target.closest(".menu-btn") || e.target.closest(".info-btn") || e.target.closest("a")) return; + const card = e.target.closest(".card.clickable"); + if (!card) return; + const url = card.dataset.url; + if (url) window.open(url, "_blank", "noopener"); + }); + + gridEl.addEventListener("keydown", (e) => { + if (e.key !== "Enter" && e.key !== " ") return; + const card = e.target.closest(".card.clickable"); + if (!card || e.target.closest(".menu-btn")) return; + e.preventDefault(); + const url = card.dataset.url; + if (url) window.open(url, "_blank", "noopener"); + }); + + gridEl.addEventListener("click", (e) => { + const infoBtn = e.target.closest(".info-btn"); + if (!infoBtn) return; + e.stopPropagation(); + const text = decodeURIComponent(infoBtn.dataset.notice || "").trim(); + const link = decodeURIComponent(infoBtn.dataset.link || "").trim(); + const { modal, title, text: textEl, link: linkEl } = ensureNoticeModal(); + title.textContent = infoBtn.getAttribute("title") || "Notice"; + textEl.textContent = text || "No additional info."; + if (link) { + linkEl.textContent = "More info"; + linkEl.href = link; + linkEl.classList.remove("hidden"); + } else { + linkEl.textContent = ""; + linkEl.href = ""; + linkEl.classList.add("hidden"); + } + modal.classList.remove("hidden"); + }); + + enforceTlsCheckbox(schemeSelect, selfSignedInput); + enforceTlsCheckbox(addSchemeSelect, addSelfSignedInput); + + async function menuAction(action, body = {}) { + if (!menuContext) return; + msg.textContent = ""; + try { + const isRemove = action === "remove"; + const isSave = action === "save"; + if (isRemove) showBusy("Removing service", "Updating firewall rules…"); + if (isSave) showBusy("Saving service", "Opening/closing firewall rules as needed…"); + if (action === "remove") { + await removeService({ port: menuContext.port }); + } else { + await updateService({ + port: menuContext.port, + name: body.name, + new_port: body.new_port, + scheme: body.scheme, + path: body.path, + notice: body.notice, + notice_link: body.notice_link, + self_signed: body.self_signed, + }); + } + msg.textContent = ""; + toast?.(isRemove ? "Service removed" : "Service saved", "success"); + modal?.classList.add("hidden"); + menuContext = null; + await onChange?.(); + } catch (e) { + const err = e.error || "Action failed."; + msg.textContent = ""; + toast?.(err, "error"); + } finally { + hideBusy(); + } + } + + saveBtn?.addEventListener("click", () => { + if (!menuContext) return; + const name = (renameInput?.value || "").trim(); + const new_port = Number(portInput?.value); + const scheme = schemeSelect?.value === "https" ? "https" : "http"; + const pathRaw = pathInput?.value ?? ""; + const path = normalizePath(pathRaw); + const notice = (noticeInput?.value || "").trim(); + const notice_link = (noticeLinkInput?.value || "").trim(); + const self_signed = !!selfSignedInput?.checked; + if ( + !validateServiceFields( + { name, port: new_port, path, notice, notice_link }, + () => {}, + toast, + ) + ) + return; + menuAction("save", { name, new_port, scheme, path, notice, notice_link, self_signed }); + }); + cancelBtn?.addEventListener("click", () => { + modal?.classList.add("hidden"); + msg.textContent = ""; + menuContext = null; + }); + removeBtn?.addEventListener("click", () => menuAction("remove")); + + addBtn?.addEventListener("click", async () => { + addMsg.textContent = ""; + const name = (nameInput?.value || "").trim(); + const port = Number(addPortInput?.value); + const scheme = addSchemeSelect?.value === "https" ? "https" : "http"; + const pathRaw = addPathInput?.value ?? ""; + const path = normalizePath(pathRaw); + const notice = (addNoticeInput?.value || "").trim(); + const notice_link = (addNoticeLinkInput?.value || "").trim(); + const self_signed = !!addSelfSignedInput?.checked; + if ( + !validateServiceFields( + { name, port, path, notice, notice_link }, + () => {}, + toast, + ) + ) + return; + addBtn.disabled = true; + try { + showBusy("Adding service", "Opening firewall rules…"); + await addService({ name, port, scheme, path, notice, notice_link, self_signed }); + addMsg.textContent = ""; + toast?.("Service added", "success"); + await onChange?.(); + } catch (e) { + const err = e.error || "Failed to add."; + addMsg.textContent = ""; + toast?.(err, "error"); + } finally { + addBtn.disabled = false; + hideBusy(); + } + }); +} diff --git a/pikit-web/assets/settings.js b/pikit-web/assets/settings.js new file mode 100644 index 0000000..f843415 --- /dev/null +++ b/pikit-web/assets/settings.js @@ -0,0 +1,130 @@ +// Handles user-facing settings (theme, motion, refresh cadence) and persistence +// across reloads. Keeps side effects isolated so main.js simply wires callbacks. +const DEFAULT_REFRESH_SEC = 10; +const MIN_REFRESH_SEC = 5; +const MAX_REFRESH_SEC = 120; +const THEME_KEY = "pikit-theme"; +const ANIM_KEY = "pikit-anim"; +const REFRESH_KEY = "pikit-refresh-sec"; + +export function initSettings({ + refreshHintMain, + refreshHintServices, + refreshFlagTop, + refreshIntervalInput, + refreshIntervalSave, + refreshIntervalMsg, + themeToggle, + themeToggleIcon, + animToggle, + onTick, + toast = null, + defaultIntervalSec = DEFAULT_REFRESH_SEC, +}) { + let refreshIntervalMs = defaultIntervalSec * 1000; + let refreshTimer = null; + const prefersReduce = + typeof window !== "undefined" && + window.matchMedia && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + function updateRefreshHints(seconds) { + const text = `${seconds} second${seconds === 1 ? "" : "s"}`; + if (refreshHintMain) refreshHintMain.textContent = text; + if (refreshHintServices) refreshHintServices.textContent = text; + if (refreshFlagTop) refreshFlagTop.textContent = `Refresh: ${seconds}s`; + if (refreshIntervalInput) refreshIntervalInput.value = seconds; + } + + function setRefreshInterval(seconds, { silent = false } = {}) { + const sec = Math.max( + MIN_REFRESH_SEC, + Math.min(MAX_REFRESH_SEC, Math.floor(seconds)), + ); + // Clamp to safe bounds; store milliseconds for setInterval + refreshIntervalMs = sec * 1000; + if (refreshTimer) clearInterval(refreshTimer); + if (onTick) { + refreshTimer = setInterval(onTick, refreshIntervalMs); + } + updateRefreshHints(sec); + try { + localStorage.setItem(REFRESH_KEY, String(sec)); + } catch (e) { + console.warn("Refresh persistence unavailable", e); + } + if (!silent && refreshIntervalMsg) { + refreshIntervalMsg.textContent = ""; + } + } + + refreshIntervalSave?.addEventListener("click", () => { + if (!refreshIntervalInput) return; + const sec = Number(refreshIntervalInput.value); + if (Number.isNaN(sec)) { + if (refreshIntervalMsg) refreshIntervalMsg.textContent = ""; + toast?.("Enter seconds.", "error"); + return; + } + setRefreshInterval(sec); + onTick?.(); // immediate refresh on change + if (refreshIntervalMsg) refreshIntervalMsg.textContent = ""; + toast?.("Refresh interval updated", "success"); + }); + + function applyTheme(mode) { + const theme = mode === "light" ? "light" : "dark"; + document.documentElement.setAttribute("data-theme", theme); + if (themeToggleIcon) + themeToggleIcon.textContent = theme === "light" ? "☀️" : "🌙"; + try { + localStorage.setItem(THEME_KEY, theme); + } catch (e) { + console.warn("Theme persistence unavailable", e); + } + } + + themeToggle?.addEventListener("click", () => { + const current = + document.documentElement.getAttribute("data-theme") || "dark"; + applyTheme(current === "dark" ? "light" : "dark"); + }); + + function applyAnim(enabled) { + const on = enabled !== false; // default true + // Using a data attribute lets CSS fully disable motion when off + document.documentElement.setAttribute("data-anim", on ? "on" : "off"); + if (animToggle) animToggle.checked = on; + try { + localStorage.setItem(ANIM_KEY, on ? "on" : "off"); + } catch (e) { + console.warn("Anim persistence unavailable", e); + } + } + + animToggle?.addEventListener("change", () => { + applyAnim(animToggle.checked); + }); + + // Initialize defaults + let storedRefresh = defaultIntervalSec; + try { + const saved = localStorage.getItem(REFRESH_KEY); + if (saved) { + const n = Number(saved); + if (!Number.isNaN(n)) storedRefresh = n; + } + } catch (e) { + console.warn("Refresh persistence unavailable", e); + } + updateRefreshHints(storedRefresh); + setRefreshInterval(storedRefresh, { silent: true }); + applyTheme(localStorage.getItem(THEME_KEY) || "dark"); + const storedAnim = localStorage.getItem(ANIM_KEY); + const animDefault = + storedAnim === "on" || storedAnim === "off" + ? storedAnim === "on" + : !prefersReduce; // respect system reduce-motion if no user choice + applyAnim(animDefault); + return { setRefreshInterval, applyTheme }; +} diff --git a/pikit-web/assets/status.js b/pikit-web/assets/status.js new file mode 100644 index 0000000..c1413da --- /dev/null +++ b/pikit-web/assets/status.js @@ -0,0 +1,77 @@ +// Small helpers for rendering the status summary cards on the dashboard. +export const placeholderStatus = { + hostname: "Pi-Kit", + uptime_seconds: 0, + os_version: "Pi-Kit", + cpu_temp_c: null, + memory_mb: { total: 0, free: 0 }, + disk_mb: { total: 0, free: 0 }, + lan_ip: null, +}; + +function fmtUptime(sec) { + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + return `${h}h ${m}m`; +} + +function fmtOs(text) { + const raw = text || "DietPi"; + // If PRETTY_NAME looks like "Debian GNU/Linux 13 (trixie)" + const m = /Debian[^0-9]*?(\d+)(?:\s*\(([^)]+)\))?/i.exec(raw); + if (m) { + const version = m[1]; + return `DietPi · Debian ${version}`; + } + // If already contains DietPi, keep a concise form + if (/dietpi/i.test(raw)) return raw.replace(/GNU\/Linux\s*/i, "").trim(); + // Fallback: truncate to keep cards tidy + return raw.length > 30 ? `${raw.slice(0, 27)}…` : raw; +} + +function fmtSizeMb(mb) { + if (mb >= 1000) { + const gb = mb / 1024; + return `${gb.toFixed(gb >= 10 ? 0 : 1)} GB`; + } + return `${mb} MB`; +} + +export function renderStats(container, data) { + if (!container) return; + container.innerHTML = ""; + // Flatten the incoming status into label/value pairs before rendering cards + const stats = [ + ["Uptime", fmtUptime(data.uptime_seconds)], + ["OS", fmtOs(data.os_version)], + ["CPU Temp", data.cpu_temp_c ? `${data.cpu_temp_c.toFixed(1)} °C` : "n/a"], + [ + "Memory", + `${fmtSizeMb(data.memory_mb.total - data.memory_mb.free)} / ${fmtSizeMb(data.memory_mb.total)}`, + ], + [ + "Disk", + `${fmtSizeMb(data.disk_mb.total - data.disk_mb.free)} / ${fmtSizeMb(data.disk_mb.total)}`, + ], + [ + "LAN / Host", + data.lan_ip + ? `${data.lan_ip} (${data.hostname || "n/a"})` + : `n/a (${data.hostname || "n/a"})`, + ], + ]; + stats.forEach(([label, value]) => { + const div = document.createElement("div"); + div.className = "stat"; + div.innerHTML = `
${label}
${value}
`; + if (label === "OS") { + const valEl = div.querySelector(".value"); + if (valEl) { + valEl.style.whiteSpace = "normal"; + valEl.style.wordBreak = "break-word"; + valEl.style.lineHeight = "1.2"; + } + } + container.appendChild(div); + }); +} diff --git a/pikit-web/assets/style.css b/pikit-web/assets/style.css new file mode 100644 index 0000000..57fd612 --- /dev/null +++ b/pikit-web/assets/style.css @@ -0,0 +1,1574 @@ +@font-face { + font-family: "Red Hat Text"; + src: url("fonts/RedHatText-Regular.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "Red Hat Text"; + src: url("fonts/RedHatText-Medium.woff2") format("woff2"); + font-weight: 500; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "Red Hat Display"; + src: url("fonts/RedHatDisplay-SemiBold.woff2") format("woff2"); + font-weight: 600; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "Red Hat Display"; + src: url("fonts/RedHatDisplay-Bold.woff2") format("woff2"); + font-weight: 700; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "Space Grotesk"; + src: url("fonts/SpaceGrotesk-Regular.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "Space Grotesk"; + src: url("fonts/SpaceGrotesk-Medium.woff2") format("woff2"); + font-weight: 500; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "Space Grotesk"; + src: url("fonts/SpaceGrotesk-SemiBold.woff2") format("woff2"); + font-weight: 600; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "Manrope"; + src: url("fonts/Manrope-Regular.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "Manrope"; + src: url("fonts/Manrope-SemiBold.woff2") format("woff2"); + font-weight: 600; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "DM Sans"; + src: url("fonts/DMSans-Regular.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "DM Sans"; + src: url("fonts/DMSans-Medium.woff2") format("woff2"); + font-weight: 500; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "DM Sans"; + src: url("fonts/DMSans-Bold.woff2") format("woff2"); + font-weight: 700; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "Sora"; + src: url("fonts/Sora-Regular.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "Sora"; + src: url("fonts/Sora-SemiBold.woff2") format("woff2"); + font-weight: 600; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "Chivo"; + src: url("fonts/Chivo-Regular.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "Chivo"; + src: url("fonts/Chivo-Bold.woff2") format("woff2"); + font-weight: 700; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "Atkinson Hyperlegible"; + src: url("fonts/Atkinson-Regular.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "Atkinson Hyperlegible"; + src: url("fonts/Atkinson-Bold.woff2") format("woff2"); + font-weight: 700; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "IBM Plex Sans"; + src: url("fonts/PlexSans-Regular.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "IBM Plex Sans"; + src: url("fonts/PlexSans-SemiBold.woff2") format("woff2"); + font-weight: 600; + font-style: normal; + font-display: swap; +} + +:root { + --bg: #0f1117; + --panel: #161a23; + --panel-overlay: rgba(255, 255, 255, 0.02); + --card-overlay: rgba(255, 255, 255, 0.03); + --muted: #9ca3af; + --text: #e5e7eb; + --accent: #7dd3fc; + --accent-2: #22c55e; + --warning: #f59e0b; + --border: #1f2430; + --shadow: 0 12px 40px rgba(0, 0, 0, 0.3); + --topbar-bg: rgba(15, 17, 23, 0.8); + --toggle-track: #374151; + --input-bg: #0c0e14; + --input-border: var(--border); + --disabled-bg: #141a22; + --disabled-border: #2a313c; + --disabled-text: #7c8696; + --disabled-strong: #0b0f18; + --input-disabled-bg: #141a22; + --input-disabled-text: #7c8696; + --input-disabled-border: #2a313c; + --font-body: "Red Hat Text", "Inter", "Segoe UI", system-ui, -apple-system, + sans-serif; + --font-heading: "Red Hat Display", "Red Hat Text", system-ui, -apple-system, + sans-serif; + font-family: var(--font-body); +} +:root[data-font="space"] { + --font-body: "Space Grotesk", "Inter", "Segoe UI", system-ui, -apple-system, + sans-serif; + --font-heading: "Space Grotesk", "Red Hat Text", system-ui, -apple-system, + sans-serif; +} +:root[data-font="manrope"] { + --font-body: "Manrope", "Inter", "Segoe UI", system-ui, -apple-system, sans-serif; + --font-heading: "Manrope", "Manrope", system-ui, -apple-system, sans-serif; +} +:root[data-font="dmsans"] { + --font-body: "DM Sans", "Inter", "Segoe UI", system-ui, -apple-system, sans-serif; + --font-heading: "DM Sans", "Red Hat Display", system-ui, -apple-system, sans-serif; +} +:root[data-font="sora"] { + --font-body: "Sora", "Inter", "Segoe UI", system-ui, -apple-system, sans-serif; + --font-heading: "Sora", "Sora", system-ui, -apple-system, sans-serif; +} +:root[data-font="chivo"] { + --font-body: "Chivo", "Inter", "Segoe UI", system-ui, -apple-system, sans-serif; + --font-heading: "Chivo", "Chivo", system-ui, -apple-system, sans-serif; +} +:root[data-font="atkinson"] { + --font-body: "Atkinson Hyperlegible", "Inter", "Segoe UI", system-ui, -apple-system, + sans-serif; + --font-heading: "Atkinson Hyperlegible", "Atkinson Hyperlegible", system-ui, + -apple-system, sans-serif; +} +:root[data-font="plex"] { + --font-body: "IBM Plex Sans", "Inter", "Segoe UI", system-ui, -apple-system, sans-serif; + --font-heading: "IBM Plex Sans", "IBM Plex Sans", system-ui, -apple-system, sans-serif; +} +:root[data-theme="light"] { + --bg: #dfe4ee; + --panel: #f6f8fd; + --panel-overlay: rgba(10, 12, 18, 0.06); + --card-overlay: rgba(10, 12, 18, 0.11); + --muted: #4b5563; + --text: #0b1224; + --accent: #0077c2; + --accent-2: #15803d; + --warning: #b45309; + --border: #bcc5d6; + --shadow: 0 12px 30px rgba(12, 18, 32, 0.12); + --topbar-bg: rgba(249, 251, 255, 0.92); + --toggle-track: #d1d5db; + --input-bg: #f0f2f7; + --input-border: #c5ccd9; + --disabled-bg: #f4f6fb; + --disabled-border: #c8d0df; + --disabled-text: #7a8292; + --disabled-strong: #eef1f7; + --input-disabled-bg: #f8fafc; + --input-disabled-text: #6a6f7b; + --input-disabled-border: #c9d1df; +} + +* { + box-sizing: border-box; +} +body { + margin: 0; + background: + radial-gradient( + circle at 20% 20%, + rgba(125, 211, 252, 0.08), + transparent 32% + ), + radial-gradient(circle at 80% 0%, rgba(34, 197, 94, 0.06), transparent 28%), + linear-gradient(180deg, #0f1117 0%, #0e1119 55%, #0b0f15 100%); + background-attachment: fixed; + background-repeat: no-repeat; + color: var(--text); + line-height: 1.5; + transition: background 240ms ease, color 240ms ease; +} +:root[data-theme="light"] body { + background: + radial-gradient( + circle at 25% 18%, + rgba(0, 119, 194, 0.14), + transparent 34% + ), + radial-gradient(circle at 78% 8%, rgba(21, 128, 61, 0.12), transparent 30%), + linear-gradient(180deg, #f6f8fd 0%, #e8edf7 52%, #d6dde9 100%); + background-attachment: fixed; + background-repeat: no-repeat; +} + +.topbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 22px; + border-bottom: 1px solid var(--border); + backdrop-filter: blur(10px); + position: sticky; + top: 0; + z-index: 10; + background: var(--topbar-bg); +} +.brand { + display: flex; + align-items: center; + gap: 10px; + font-weight: 700; + letter-spacing: 0.3px; + font-family: var(--font-heading); +} +.brand .dot { + width: 12px; + height: 12px; + border-radius: 50%; + background: linear-gradient(135deg, #22c55e, #7dd3fc); + box-shadow: 0 0 10px rgba(125, 211, 252, 0.6); + cursor: help; +} +.top-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + justify-content: flex-end; +} +.top-indicators { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} +.chip-label { + font-size: 0.85rem; + color: var(--muted); + margin-right: 4px; +} +.status-chip { + padding: 6px 12px; + border-radius: 999px; + border: 1px solid var(--border); + color: var(--muted); + font-size: 0.9rem; + background: rgba(0, 0, 0, 0.06); +} +.status-chip.quiet { + opacity: 0.75; + font-size: 0.85rem; + background: rgba(0, 0, 0, 0.04); +} +.status-chip.chip-on { + color: var(--accent-2); + border-color: rgba(22, 163, 74, 0.4); + background: rgba(22, 163, 74, 0.08); +} +.status-chip.chip-off { + color: #e11d48; + border-color: rgba(225, 29, 72, 0.4); + background: rgba(225, 29, 72, 0.08); +} +.status-chip.chip-warm { + color: #d97706; + border-color: rgba(217, 119, 6, 0.35); + background: rgba(217, 119, 6, 0.12); +} +:root[data-theme="light"] .status-chip { + background: rgba(12, 18, 32, 0.06); + border-color: rgba(12, 18, 32, 0.14); + color: #1f2a3d; +} +:root[data-theme="light"] .status-chip.quiet { + background: rgba(12, 18, 32, 0.05); + color: #243247; + opacity: 0.92; +} +:root[data-theme="light"] .status-chip.chip-on { + background: rgba(34, 197, 94, 0.16); + border-color: rgba(34, 197, 94, 0.5); + color: #0f5132; +} +:root[data-theme="light"] .status-chip.chip-warm { + background: rgba(217, 119, 6, 0.16); + border-color: rgba(217, 119, 6, 0.5); + color: #8a4b08; +} +:root[data-theme="light"] .status-chip.chip-off { + background: rgba(225, 29, 72, 0.18); + border-color: rgba(225, 29, 72, 0.55); + color: #7a1028; +} +.toast-container { + position: fixed; + bottom: 16px; + left: 50%; + transform: translateX(-50%); + z-index: 40; + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: center; + gap: 10px; + pointer-events: none; +} +.toast { + min-width: 200px; + max-width: 320px; + max-height: 240px; + overflow: hidden; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--panel); + color: var(--text); + box-shadow: var(--shadow); + font-weight: 600; + pointer-events: auto; + opacity: 0; + transform: translateY(var(--toast-slide-offset, 14px)); + transition: + opacity var(--toast-speed, 0.28s) ease, + transform var(--toast-speed, 0.28s) ease, + max-height var(--toast-speed, 0.28s) ease, + padding var(--toast-speed, 0.28s) ease, + margin var(--toast-speed, 0.28s) ease; +} +.toast.show { + opacity: 1; + transform: translateY(0); +} +.toast.success { + border-color: rgba(34, 197, 94, 0.5); +} +.toast.warn { + border-color: rgba(217, 119, 6, 0.5); +} +.toast.error { + border-color: rgba(225, 29, 72, 0.6); +} +.toast.info { + border-color: rgba(125, 211, 252, 0.4); +} +html[data-anim="off"] .toast { + transition: none !important; +} + +.toast.anim-slide-in { + transform: translate(var(--toast-slide-x, 0px), var(--toast-slide-y, 24px)); + opacity: 0; +} +.toast.anim-slide-in.show { + transform: translate(0, 0); + opacity: 1; +} +.toast.anim-fade { + transform: none; + opacity: 0; +} +.toast.anim-fade.show { + opacity: 1; +} +.toast.anim-pop { + transform: scale(0.9); + opacity: 0; +} +.toast.anim-pop.show { + transform: scale(1); + opacity: 1; +} +.toast.anim-bounce { + opacity: 0; + transform: translateY(calc(var(--toast-dir, 1) * 20px)); +} +.toast.anim-bounce.show { + opacity: 1; + animation: toast-bounce var(--toast-speed, 0.46s) cubic-bezier(0.22, 1, 0.36, 1) forwards; +} +.toast.anim-drop { + opacity: 0; + transform: translateY(calc(var(--toast-dir, 1) * -24px)) scale(0.98); +} +.toast.anim-drop.show { + transform: translateY(0) scale(1); + opacity: 1; +} +.toast.anim-grow { + transform: scale(0.85); + opacity: 0; +} +.toast.anim-grow.show { + transform: scale(1); + opacity: 1; +} +.toast.leaving { + opacity: 0 !important; + transform: translateY(12px) !important; + max-height: 0 !important; + margin: 0 !important; + padding-top: 0 !important; + padding-bottom: 0 !important; +} + +@keyframes toast-bounce { + 0% { + opacity: 0; + transform: translateY(calc(var(--toast-dir, 1) * 20px)) scale(0.96); + } + 55% { + opacity: 1; + transform: translateY(calc(var(--toast-dir, 1) * -8px)) scale(1.03); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.toast-container.pos-bottom-center { + bottom: 16px; + left: 50%; + right: auto; + top: auto; + transform: translateX(-50%); + flex-direction: column-reverse; + align-items: center; +} +.toast-container.pos-bottom-right { + bottom: 16px; + right: 16px; + left: auto; + top: auto; + transform: none; + flex-direction: column-reverse; + align-items: flex-end; +} +.toast-container.pos-bottom-left { + bottom: 16px; + left: 16px; + right: auto; + top: auto; + transform: none; + flex-direction: column-reverse; + align-items: flex-start; +} +.toast-container.pos-top-right { + top: 16px; + right: 16px; + bottom: auto; + left: auto; + transform: none; + flex-direction: column; + align-items: flex-end; +} +.toast-container.pos-top-left { + top: 16px; + left: 16px; + bottom: auto; + right: auto; + transform: none; + flex-direction: column; + align-items: flex-start; +} +.toast-container.pos-top-center { + top: 16px; + left: 50%; + right: auto; + bottom: auto; + transform: translateX(-50%); + flex-direction: column; + align-items: center; +} +.host-chip { + border-radius: 10px; + background: rgba(255, 255, 255, 0.04); + color: var(--text); +} +.layout { + max-width: 1200px; + margin: 0 auto; + padding: 32px 18px 80px; +} + +.hero { + display: grid; + grid-template-columns: 1.1fr 0.9fr; + gap: 20px; + align-items: center; + margin-bottom: 24px; +} +.eyebrow { + text-transform: uppercase; + font-size: 0.75rem; + letter-spacing: 0.12rem; + color: var(--muted); + margin: 0 0 6px; + font-family: var(--font-heading); +} +.eyebrow.warning { + color: var(--warning); +} +h1 { + margin: 0 0 10px; + line-height: 1.2; + font-family: var(--font-heading); + font-weight: 700; +} +h2 { + margin: 0 0 6px; + line-height: 1.2; + font-family: var(--font-heading); + font-weight: 600; +} +.lede { + color: var(--muted); + margin: 0 0 14px; +} +.hint { + color: var(--muted); + margin: 0; + font-size: 0.95rem; +} +.hint.quiet { + opacity: 0.8; + font-size: 0.85rem; +} + + +.actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} +button { + background: linear-gradient(135deg, #22c55e, #7dd3fc); + color: #041012; + border: none; + padding: 10px 16px; + border-radius: 10px; + cursor: pointer; + font-weight: 600; + box-shadow: var(--shadow); +} +button.ghost { + background: transparent; + color: var(--text); + border: 1px solid var(--border); + box-shadow: none; +} +button.icon-btn { + width: 40px; + height: 40px; + padding: 0; + display: grid; + place-items: center; + font-size: 1.1rem; + box-shadow: none; +} +:root[data-theme="light"] button.icon-btn { + box-shadow: inset 0 0 0 1px var(--border); + border-radius: 10px; +} +button:active { + transform: translateY(1px); +} +button.danger-btn { + background: linear-gradient(135deg, #f87171, #ef4444); + color: #0f1117; +} +.menu-btn { + font-size: 1rem; + padding: 4px 8px; + border-radius: 8px; +} +.service-menu { + position: absolute; + top: 8px; + right: 8px; + display: flex; + gap: 6px; +} +.service-menu .ghost { + border: 1px solid color-mix(in srgb, var(--border) 60%, var(--text) 20%); +} +label.toggle { + position: relative; + display: inline-block; + width: 46px; + height: 24px; +} +label.toggle input { + display: none; +} +label.toggle .slider { + position: absolute; + cursor: pointer; + inset: 0; + background: var(--toggle-track); + border-radius: 24px; + transition: 0.2s; +} +label.toggle .slider:before { + position: absolute; + content: ""; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background: white; + border-radius: 50%; + transition: 0.2s; +} +label.toggle input:checked + .slider { + background: linear-gradient(135deg, #22c55e, #7dd3fc); +} +label.toggle input:checked + .slider:before { + transform: translateX(22px); +} + +.hero-stats { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 14px; + padding: 14px; + box-shadow: var(--shadow); + display: grid; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + gap: 12px; +} +.stat { + background: var(--card-overlay); + border: 1px solid var(--border); + border-radius: 12px; + padding: 9px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); +} +.stat .label { + color: var(--muted); + font-size: 0.85rem; +} +.stat .value { + font-size: 1.15rem; + font-weight: 700; + line-height: 1.25; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 16px; + padding: 18px; + margin-bottom: 18px; + box-shadow: var(--shadow); +} +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 12px; + font-family: var(--font-heading); +} +.panel-actions { + display: flex; + gap: 8px; + align-items: center; + margin-left: auto; +} +.panel-actions .icon-btn { + font-size: 1.15rem; + width: 38px; + height: 38px; + padding: 0; +} +.panel-header.small-gap { + margin-top: 12px; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 12px; +} +.grid.empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 220px; + width: 100%; +} +.empty-state { + text-align: center; + color: var(--muted); + padding: 16px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + width: 100%; + height: 100%; +} +.empty-state button { + margin-top: 6px; + box-shadow: none; +} +.card { + border: 1px solid var(--border); + background: var(--card-overlay); + padding: 12px; + padding-right: 68px; /* reserve room for action buttons */ + padding-bottom: 34px; /* reserve room for bottom badges */ + border-radius: 12px; + display: flex; + flex-direction: column; + gap: 6px; + position: relative; +} +.card.clickable { + cursor: pointer; +} +.card.offline { + border-color: rgba(225, 29, 72, 0.45); +} +.card a { + color: var(--accent); + text-decoration: none; + word-break: break-all; +} +.service-url { + color: var(--text); + font-weight: 600; + word-break: break-all; +} +.pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--accent) 18%, var(--panel) 82%); + border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border) 65%); + color: var(--text); + font-size: 0.85rem; + max-width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.pill-small { + font-size: 0.8rem; +} +.notice-pill { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + border: 1px dashed #6b7280; + color: #6b7280; + font-size: 0.78rem; + margin-left: 6px; +} +.self-signed-pill { + position: absolute; + right: 10px; + bottom: 10px; + margin-left: 0; +} +.notice-link { + display: inline-block; + margin-top: 8px; + color: var(--accent); + text-decoration: underline; +} +.info-btn { + width: 30px; + height: 30px; + padding: 0; + border: 1px solid color-mix(in srgb, var(--border) 60%, var(--text) 20%); + border-radius: 10px; + font-size: 1rem; + color: var(--text); + background: var(--card-overlay); + line-height: 1; +} +.control-actions.column > .checkbox-row.inline { + margin-top: 6px; +} +.status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: #e11d48; + box-shadow: 0 0 8px rgba(225, 29, 72, 0.5); +} +.status-dot.on { + background: #22c55e; + box-shadow: 0 0 8px rgba(34, 197, 94, 0.6); +} +html[data-anim="on"] .status-dot.on { + animation: pulse 2s ease-in-out infinite; +} +@keyframes pulse { + 0% { + box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.35); + } + 70% { + box-shadow: 0 0 0 10px rgba(34, 197, 94, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); + } +} +.status-dot.off { + background: #f87171; + box-shadow: 0 0 8px rgba(248, 113, 113, 0.5); +} +.service-header { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto; + align-items: center; + gap: 8px; + width: 100%; +} +.service-header .pill { + min-width: 0; + max-width: 100%; +} +.service-header .status-dot, +.service-header .menu-btn, +.service-header .notice-pill { + justify-self: start; +} +.service-header .menu-btn { + justify-self: end; +} +#servicesGrid { + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); +} +@media (min-width: 1180px) { + #servicesGrid { + grid-template-columns: repeat(3, 1fr); + } +} +@media (max-width: 560px) { + #servicesGrid { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + } +} + +.controls { + display: flex; + flex-direction: column; + gap: 10px; +} + +.accordion { + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; + background: var(--panel-overlay); +} +.accordion + .accordion { + margin-top: 8px; +} +.accordion-toggle { + width: 100%; + text-align: left; + background: transparent; + color: var(--text); + border: none; + padding: 12px 14px; + font-weight: 700; + cursor: pointer; +} +.accordion-toggle.danger-btn { + color: #0f1117; +} +.accordion-body { + padding: 0 14px 0; + max-height: 0; + overflow: hidden; + opacity: 0; + transition: + max-height 0.24s ease, + opacity 0.18s ease, + padding-bottom 0.18s ease, + padding-top 0.18s ease; +} +.accordion.open .accordion-body { + max-height: 1200px; + opacity: 1; + padding: 8px 12px 6px; +} +.accordion-body p { + margin: 0 0 6px; +} +.control-card { + border: 1px solid var(--border); + border-radius: 12px; + padding: 12px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} +.control-actions { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} +.control-actions.split-row { + justify-content: flex-start; + gap: 10px; + align-items: center; + margin-top: 4px; + flex-wrap: wrap; +} +.control-actions.column.tight { + gap: 6px; + align-items: flex-start; +} +.control-actions.column { + flex-direction: column; + align-items: stretch; +} +select, +input { + background: var(--input-bg); + color: var(--text); + border: 1px solid var(--input-border); + padding: 8px 10px; + border-radius: 10px; +} +.checkbox-row { + display: flex; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 0.95rem; +} +.checkbox-row.inline { + display: inline-flex; + gap: 10px; + align-items: center; +} +.checkbox-row.inline.tight { + margin-top: 6px; +} +.checkbox-row.inline.nowrap span { + white-space: nowrap; +} +.control-row.split { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; +} +.control-row.split > * { + margin: 0; +} +.dual-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + align-items: center; +} +.dual-row .dual-col:last-child { + text-align: right; +} +.dual-row .checkbox-row.inline { + justify-content: flex-start; + width: fit-content; +} +.form-grid { + display: grid; + gap: 6px; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); +} +.field { + display: flex; + flex-direction: column; + gap: 4px; + color: var(--muted); + font-size: 0.95rem; +} +.field input, +.field select { + width: 100%; +} +.checkbox-field .checkbox-row { + color: var(--text); +} +.status-msg.error { + color: #f87171; +} +.note-warn { + color: #f87171; + font-weight: 600; +} +.is-disabled { + opacity: 0.45; +} +.is-disabled input, +.is-disabled select, +.is-disabled textarea { + filter: grayscale(0.5); + background: var(--input-disabled-bg); + color: var(--input-disabled-text); + border-color: var(--input-disabled-border); + box-shadow: none; +} +.is-disabled label { + color: var(--disabled-text); +} +.is-disabled .slider { + filter: grayscale(0.7); +} +/* utility hidden class for non-overlay elements */ +.hidden { + display: none !important; +} +button:disabled { + opacity: 0.55; + cursor: not-allowed; + box-shadow: none; + background: #2f3844; + border: 1px solid #3b4756; + color: #c9d2dc; + pointer-events: none; +} +#acc-updates .accordion-body { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px 12px 6px !important; +} +#updatesSection { + width: 100%; + gap: 4px; + margin-bottom: 0; +} +#updatesControls { + display: flex; + flex-direction: column; + gap: 6px; + margin: 0; + padding: 0; + width: 100%; +} +#updatesControls.is-disabled { + opacity: 0.6; + background: var(--disabled-bg); + border: 1px dashed var(--disabled-border); + border-radius: 8px; + padding: 6px; +} +#updatesControls.is-disabled * { + pointer-events: none; +} +#updatesControls input:disabled, +#updatesControls select:disabled, +#updatesControls textarea:disabled { + background: var(--input-disabled-bg); + color: var(--input-disabled-text); + border-color: var(--input-disabled-border); +} +#updatesControls .checkbox-row input:disabled + span, +#updatesControls label, +#updatesControls .field > span, +#updatesControls .hint { + color: var(--disabled-text); +} +#updatesControls .control-actions, +#updatesControls .field { + opacity: 0.9; +} +#updatesControls .toggle .slider { + filter: grayscale(0.9); +} +/* Disabled styling scoped to updates section */ +#updatesControls.is-disabled input, +#updatesControls.is-disabled select, +#updatesControls.is-disabled textarea { + background: var(--input-disabled-bg); + color: var(--input-disabled-text); + border-color: var(--input-disabled-border); + box-shadow: none; +} +#updatesControls.is-disabled .checkbox-row span, +#updatesControls.is-disabled label, +#updatesControls.is-disabled .field > span, +#updatesControls.is-disabled .hint { + color: var(--disabled-text); +} +#updatesControls.is-disabled .control-actions, +#updatesControls.is-disabled .field { + opacity: 0.9; +} +#updatesControls.is-disabled .toggle .slider { + filter: grayscale(0.8); +} +/* Light theme contrast for disabled controls */ +:root[data-theme="light"] #updatesSection { + background: #f7f9fd; + border: 1px solid #d9dfeb; + border-radius: 10px; + padding: 8px 10px; +} +:root[data-theme="light"] #updatesControls { + gap: 8px; +} +:root[data-theme="light"] #updatesControls.is-disabled { + opacity: 1; + background: var(--disabled-bg); + border: 1px dashed var(--disabled-border); + border-radius: 8px; + padding: 6px; +} +:root[data-theme="light"] #updatesControls.is-disabled * { + pointer-events: none; +} +:root[data-theme="light"] #updatesControls.is-disabled input, +:root[data-theme="light"] #updatesControls.is-disabled select, +:root[data-theme="light"] #updatesControls.is-disabled textarea { + background: var(--input-disabled-bg) !important; + color: var(--input-disabled-text) !important; + border: 1px dashed var(--disabled-border) !important; + box-shadow: none !important; +} +:root[data-theme="light"] #updatesControls.is-disabled .checkbox-row input:disabled + span, +:root[data-theme="light"] #updatesControls.is-disabled label, +:root[data-theme="light"] #updatesControls.is-disabled .field > span, +:root[data-theme="light"] #updatesControls.is-disabled .hint { + color: var(--disabled-text) !important; +} +:root[data-theme="light"] #updatesControls.is-disabled .control-actions, +:root[data-theme="light"] #updatesControls.is-disabled .field { + opacity: 1; +} +:root[data-theme="light"] #updatesControls.is-disabled .toggle .slider { + filter: grayscale(0.2); +} +#updatesControls .form-grid { + margin: 0; +} +#updatesControls .control-actions.split-row { + margin: 0; +} +#acc-updates .accordion-body { + padding-bottom: 2px !important; +} + +/* Motion (opt-out via data-anim="off") */ +html[data-anim="on"] .card, +html[data-anim="on"] .stat, +html[data-anim="on"] button, +html[data-anim="on"] .accordion, +html[data-anim="on"] .modal-card { + transition: + transform 0.18s ease, + box-shadow 0.18s ease, + border-color 0.18s ease, + background-color 0.18s ease; +} + +html[data-anim="on"] .card:hover { + transform: translateY(-2px); + box-shadow: 0 14px 26px rgba(0, 0, 0, 0.12); +} + +html[data-anim="on"][data-theme="light"] .card:hover { + box-shadow: 0 14px 26px rgba(12, 18, 32, 0.12); +} + +html[data-anim="on"] button:hover { + transform: translateY(-1px); + box-shadow: var(--shadow); +} + +html[data-anim="on"] button:active { + transform: translateY(1px); +} + +html[data-anim="off"] .card, +html[data-anim="off"] .stat, +html[data-anim="off"] button, +html[data-anim="off"] .accordion, +html[data-anim="off"] .modal-card { + transition: none; +} + +/* Hard-stop any remaining motion (spinners, keyframes, incidental transitions) */ +html[data-anim="off"] *, +html[data-anim="off"] *::before, +html[data-anim="off"] *::after { + animation: none !important; + transition: none !important; +} + +/* Focus states */ +button:focus-visible, +input:focus-visible, +select:focus-visible, +.card:focus-visible, +.status-chip:focus-visible, +.accordion-toggle:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* Responsive tweaks */ +@media (max-width: 960px) { + .hero { + grid-template-columns: 1fr; + } + .layout { + padding: 24px 14px 60px; + } + .panel { + padding: 16px; + } + .grid { + gap: 10px; + } + .card { + padding: 12px; + } + .hero-stats { + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + } +} +.codeblock { + background: var(--input-bg); + padding: 10px; + border-radius: 10px; + max-width: 440px; + min-width: 260px; + border: 1px solid var(--border); + white-space: pre-wrap; + word-break: break-all; +} + +.modal { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + display: grid; + place-items: center; + opacity: 0; + pointer-events: none; + transition: opacity 0.18s ease; + z-index: 20; +} +.modal.hidden { + display: none; +} +.modal:not(.hidden) { + opacity: 1; + pointer-events: auto; +} +.modal-card { + background: var(--panel); + border: 1px solid var(--border); + padding: 18px; + border-radius: 14px; + min-width: 300px; + max-width: 420px; +} +.modal-card.wide { + max-width: 820px; + width: 90vw; + max-height: 90vh; + overflow-y: auto; + position: relative; + padding: 0; +} +.modal-card { + transform: translateY(6px) scale(0.99); + transition: + transform 0.18s ease, + box-shadow 0.18s ease; +} +.modal-card.wide .panel-header { + position: sticky; + top: 0; + z-index: 3; + margin: 0 0 12px; + padding: 18px 18px 12px; + background: var(--panel); + border-bottom: 1px solid var(--border); +} + +.modal-card.wide .help-body { + padding: 0 18px 18px; +} + +/* Extra breathing room for custom add-service modal */ +#addServiceModal .modal-card { + padding: 18px 18px 16px; +} +#addServiceModal .controls { + padding: 0 2px 4px; +} +.modal:not(.hidden) .modal-card { + transform: translateY(0) scale(1); +} +.modal-card .close-btn { + min-width: 0; + width: 36px; + height: 36px; + font-size: 1rem; + line-height: 1; + padding: 0; +} +.config-list { + display: flex; + flex-direction: column; + gap: 12px; +} +.config-row { + border: 1px solid var(--border); + border-radius: 12px; + padding: 12px; + display: flex; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + align-items: center; +} +.config-row.danger { + border-color: #ef4444; +} + +.modal-actions { + display: flex; + gap: 10px; + align-items: center; + margin-top: 16px; +} +.modal-actions .push { + flex: 1; +} +.modal-actions .primary { + background: linear-gradient(135deg, #16d0d8, #59e693); + color: #0c0f17; + border: none; + padding: 10px 14px; + border-radius: 10px; + font-weight: 600; +} +.modal-card .status-msg { + margin-top: 6px; +} +.config-label h4 { + margin: 0 0 4px; +} +.config-controls { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} +.config-controls textarea { + width: 100%; + resize: vertical; + min-height: 96px; +} +.config-controls input[type="text"], +.config-controls input[type="number"], +.config-controls select { + width: 100%; + max-width: 100%; +} +.status-msg { + margin-top: 4px; +} + +.overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: grid; + place-items: center; + z-index: 50; +} +.overlay.hidden { + display: none; +} +.overlay-box { + background: var(--panel); + border: 1px solid var(--border); + padding: 20px; + border-radius: 14px; + max-width: 420px; + text-align: center; + box-shadow: var(--shadow); +} +.spinner { + margin: 12px auto 4px; + width: 32px; + height: 32px; + border: 4px solid rgba(255, 255, 255, 0.2); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} +.modal-actions { + margin-top: 14px; + display: flex; + justify-content: flex-end; + gap: 10px; +} +.modal-actions button { + min-width: 100px; +} +.help-body h4 { + margin: 10px 0 6px; +} +.help-body ul { + margin: 0 0 12px 18px; + padding: 0; + color: var(--text); +} +.help-body ul a { + color: var(--accent); + text-decoration: underline; +} +.help-body li { + margin: 4px 0; +} +.danger { + border-color: #ef4444; +} +.modal-card label { + display: block; + margin-top: 10px; + color: var(--muted); + font-size: 0.95rem; +} +.modal-card input { + width: 100%; + margin-top: 4px; +} +.modal-actions { + margin-top: 14px; + display: flex; + justify-content: flex-end; + gap: 10px; +} + +/* Header and status chips responsive layout */ +@media (max-width: 760px) { + .topbar { + flex-direction: column; + align-items: stretch; + gap: 10px; + padding: 12px 16px; + } + .brand { + justify-content: flex-start; + } + .top-actions { + width: 100%; + justify-content: flex-start; + gap: 8px; + } + .top-actions .ghost { + padding: 8px 10px; + } + .top-indicators { + width: 100%; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 8px; + align-items: center; + } + .top-indicators .chip-label { + grid-column: 1 / -1; + margin-right: 0; + font-size: 0.8rem; + } + .top-indicators .status-chip, + .top-indicators .hint { + width: auto; + justify-self: flex-start; + } +} + +@media (max-width: 820px) { + .hero { + grid-template-columns: 1fr; + } + .hero-stats { + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + } +} diff --git a/pikit-web/assets/update-settings.js b/pikit-web/assets/update-settings.js new file mode 100644 index 0000000..43b8679 --- /dev/null +++ b/pikit-web/assets/update-settings.js @@ -0,0 +1,344 @@ +// UI controller for unattended-upgrades settings. +// Fetches current config, mirrors it into the form, and saves changes. +import { getUpdateConfig, saveUpdateConfig } from "./api.js"; + +const TIME_RE = /^(\d{1,2}):(\d{2})$/; +const MAX_BANDWIDTH_KBPS = 1_000_000; // 1 GB/s cap to prevent typos +const fallback = (val, def) => (val === undefined || val === null ? def : val); + +let updatesDirty = false; + +function isValidTime(value) { + if (!value) return false; + const m = TIME_RE.exec(value.trim()); + + if (!m) return false; + const h = Number(m[1]); + + const mi = Number(m[2]); + + return h >= 0 && h < 24 && mi >= 0 && mi < 60; +} + +function normalizeTime(value, def) { + return isValidTime(value) ? value.padStart(5, "0") : def; +} + +export function initUpdateSettings({ + elements, + onAfterSave, + overlay = { show: () => {}, hide: () => {} }, + toast = null, +}) { + const { + updatesStatus, + updatesToggle, + scopeSelect, + updateTimeInput, + upgradeTimeInput, + cleanupToggle, + bandwidthInput, + rebootToggle, + rebootTimeInput, + rebootWithUsersToggle, + saveBtn, + msgEl, + updatesUnsavedNote, + updatesSection, + updatesControls, + } = elements; + + let lastConfig = null; + let saving = false; + let dirty = false; + + function normalizeConfig(cfg) { + if (!cfg) return null; + return { + enable: + cfg.enable !== undefined ? !!cfg.enable : cfg.enabled !== undefined ? !!cfg.enabled : false, + scope: cfg.scope || "all", + update_time: normalizeTime(cfg.update_time, "04:00"), + upgrade_time: normalizeTime(cfg.upgrade_time, "04:30"), + cleanup: !!cfg.cleanup, + bandwidth_limit_kbps: + cfg.bandwidth_limit_kbps === null || cfg.bandwidth_limit_kbps === undefined + ? null + : Number(cfg.bandwidth_limit_kbps), + auto_reboot: !!cfg.auto_reboot, + reboot_time: normalizeTime(cfg.reboot_time, "04:30"), + reboot_with_users: !!cfg.reboot_with_users, + }; + } + + function setStatusChip(enabled) { + const on = !!enabled; + if (updatesToggle) updatesToggle.checked = on; + if (updatesStatus) { + updatesStatus.textContent = on ? "On" : "Off"; + updatesStatus.classList.toggle("chip-on", on); + + updatesStatus.classList.toggle("chip-off", !on); + + } + } + + function setControlsEnabled(on) { + const controls = [ + scopeSelect, + updateTimeInput, + upgradeTimeInput, + cleanupToggle, + bandwidthInput, + rebootToggle, + rebootTimeInput, + rebootWithUsersToggle, + ]; + controls.forEach((el) => { + if (el) el.disabled = !on; + }); + if (updatesControls) { + updatesControls.classList.toggle("is-disabled", !on); + } + // Reboot sub-controls follow their own toggle + if (rebootToggle) { + const allowReboot = on && rebootToggle.checked; + if (rebootTimeInput) rebootTimeInput.disabled = !allowReboot; + if (rebootWithUsersToggle) rebootWithUsersToggle.disabled = !allowReboot; + } + } + + function setRebootControlsState(on) { + if (rebootTimeInput) rebootTimeInput.disabled = !on; + if (rebootWithUsersToggle) rebootWithUsersToggle.disabled = !on; + } + + function showMessage(text, isError = false) { + if (!msgEl) return; + msgEl.textContent = text || ""; + msgEl.classList.toggle("error", isError); + + if (text) { + if (toast && msgEl.id !== "updatesMsg") toast(text, isError ? "error" : "success"); + setTimeout(() => (msgEl.textContent = ""), 2500); + } + + } + + function currentConfigFromForm() { + try { + return normalizeConfig(buildPayload()); + + } catch (e) { + return null; + } + } + + function setDirty(on) { + dirty = !!on; + updatesDirty = dirty; + if (saveBtn) saveBtn.disabled = !dirty; + if (updatesUnsavedNote) updatesUnsavedNote.classList.toggle("hidden", !dirty); + + } + + function populateForm(cfg) { + lastConfig = normalizeConfig(cfg); + + setStatusChip(cfg?.enabled); + + setControlsEnabled(cfg?.enabled); + + if (scopeSelect) scopeSelect.value = cfg.scope || "all"; + if (updateTimeInput) + updateTimeInput.value = normalizeTime(cfg.update_time, "04:00"); + + if (upgradeTimeInput) + upgradeTimeInput.value = normalizeTime(cfg.upgrade_time, "04:30"); + + if (cleanupToggle) cleanupToggle.checked = !!cfg.cleanup; + if (bandwidthInput) { + bandwidthInput.value = + cfg.bandwidth_limit_kbps === null || cfg.bandwidth_limit_kbps === undefined + ? "" + : cfg.bandwidth_limit_kbps; + } + if (rebootToggle) rebootToggle.checked = !!cfg.auto_reboot; + if (rebootTimeInput) + rebootTimeInput.value = normalizeTime(cfg.reboot_time, "04:30"); + + if (rebootWithUsersToggle) + rebootWithUsersToggle.checked = !!cfg.reboot_with_users; + setRebootControlsState(rebootToggle?.checked); + + setDirty(false); + + } + + function buildPayload() { + const enable = updatesToggle?.checked !== false; + const scope = scopeSelect?.value === "security" ? "security" : "all"; + const updateTime = normalizeTime(updateTimeInput?.value, "04:00"); + + const upgradeTime = normalizeTime( + upgradeTimeInput?.value || updateTime, + "04:30", + ); + + if (!isValidTime(updateTime) || !isValidTime(upgradeTime)) { + throw new Error("Time must be HH:MM (24h)."); + + } + const bwRaw = bandwidthInput?.value?.trim(); + + let bw = null; + if (bwRaw) { + const n = Number(bwRaw); + + if (Number.isNaN(n) || n < 0) throw new Error("Bandwidth must be >= 0."); + if (n > MAX_BANDWIDTH_KBPS) { + throw new Error(`Bandwidth too high (max ${MAX_BANDWIDTH_KBPS.toLocaleString()} KB/s).`); + } + + bw = n === 0 ? null : n; + } + const autoReboot = !!rebootToggle?.checked; + const rebootTime = normalizeTime( + rebootTimeInput?.value || upgradeTime, + "04:30", + ); + + if (autoReboot && !isValidTime(rebootTime)) { + throw new Error("Reboot time must be HH:MM (24h)."); + + } + return { + enable: enable !== false, + scope, + update_time: updateTime, + upgrade_time: upgradeTime, + cleanup: !!cleanupToggle?.checked, + bandwidth_limit_kbps: bw, + auto_reboot: autoReboot, + reboot_time: rebootTime, + reboot_with_users: !!rebootWithUsersToggle?.checked, + }; + } + + async function loadConfig() { + try { + const cfg = await getUpdateConfig(); + + populateForm(cfg); + + } catch (e) { + console.error("Failed to load update config", e); + + showMessage("Could not load update settings", true); + + } + } + + async function persistConfig({ overrideEnable = null } = {}) { + if (saving) return; + saving = true; + showMessage(""); + + try { + const payload = buildPayload(); + + if (overrideEnable !== null) payload.enable = !!overrideEnable; + overlay.show?.("Saving updates", "Applying unattended-upgrades settings…"); + + const cfg = await saveUpdateConfig(payload); + + populateForm(cfg); + + showMessage("Update settings saved."); + toast?.("Updates saved", "success"); + + onAfterSave?.(); + + setDirty(false); + + } catch (e) { + console.error(e); + + if (overrideEnable !== null && lastConfig) { + // revert toggle on failure + setStatusChip(lastConfig.enabled); + + setControlsEnabled(lastConfig.enabled); + + } + showMessage(e?.error || e?.message || "Save failed", true); + + } finally { + saving = false; + overlay.hide?.(); + + } + } + + updatesToggle?.addEventListener("change", () => { + setStatusChip(updatesToggle.checked); + + setControlsEnabled(updatesToggle.checked); + + const cfgNow = currentConfigFromForm(); + + setDirty(!lastConfig || (cfgNow && JSON.stringify(cfgNow) !== JSON.stringify(lastConfig))); + + }); + + + saveBtn?.addEventListener("click", async () => { + await persistConfig(); + + }); + + + rebootToggle?.addEventListener("change", () => { + setRebootControlsState(rebootToggle.checked); + + const cfgNow = currentConfigFromForm(); + + setDirty(!lastConfig || (cfgNow && JSON.stringify(cfgNow) !== JSON.stringify(lastConfig))); + + }); + + + [ + scopeSelect, + updateTimeInput, + upgradeTimeInput, + cleanupToggle, + bandwidthInput, + rebootTimeInput, + rebootWithUsersToggle, + ] + .filter(Boolean) + .forEach((el) => { + el.addEventListener("input", () => { + const cfgNow = currentConfigFromForm(); + + setDirty(!lastConfig || (cfgNow && JSON.stringify(cfgNow) !== JSON.stringify(lastConfig))); + + }); + + el.addEventListener("change", () => { + const cfgNow = currentConfigFromForm(); + + setDirty(!lastConfig || (cfgNow && JSON.stringify(cfgNow) !== JSON.stringify(lastConfig))); + + }); + + }); + + + loadConfig(); + + return { reload: loadConfig, isDirty: () => dirty }; +} + +export const isUpdatesDirty = () => updatesDirty; diff --git a/pikit-web/data/mock-updates.json b/pikit-web/data/mock-updates.json new file mode 100644 index 0000000..1d52c2c --- /dev/null +++ b/pikit-web/data/mock-updates.json @@ -0,0 +1,11 @@ +{ + "enabled": true, + "scope": "all", + "cleanup": true, + "bandwidth_limit_kbps": 0, + "auto_reboot": false, + "reboot_time": "05:30", + "reboot_with_users": false, + "update_time": "04:00", + "upgrade_time": "04:30" +} diff --git a/pikit-web/favicon.ico b/pikit-web/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/pikit-web/index.html b/pikit-web/index.html new file mode 100644 index 0000000..76cddee --- /dev/null +++ b/pikit-web/index.html @@ -0,0 +1,634 @@ + + + + + + Pi-Kit Dashboard + + + +
+
+
+ Pi-Kit +
+
+ Status + Auto updates + + Refresh: 10s + Temp: OK +
+
+ + + + +
+
+ +
+
+
+

All-in-one launcher

+

Welcome to your Pi-Kit homebase

+

+ Launch services, view quick health, and handle essentials without + cracking open SSH. +

+
+
+
+ +
+
+
+

Configured services

+

Web interfaces

+

+ Shortcuts to the web UIs running on your Pi. Click a card to open it. Use the + button to add another service; use the ⋮ menu on a card to edit or remove it. +

+
+
+ +
+
+
+
+
+ + + + + + + + + + + + + + + + +
+ + diff --git a/pikit-web/package-lock.json b/pikit-web/package-lock.json new file mode 100644 index 0000000..fcdd8e7 --- /dev/null +++ b/pikit-web/package-lock.json @@ -0,0 +1,1071 @@ +{ + "name": "pikit-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pikit-web", + "version": "0.1.0", + "dependencies": { + "@fontsource/atkinson-hyperlegible": "^5.0.8", + "@fontsource/chivo": "^5.0.8", + "@fontsource/dm-sans": "^5.0.8", + "@fontsource/ibm-plex-sans": "^5.0.8", + "@fontsource/manrope": "^5.0.8", + "@fontsource/red-hat-display": "^5.0.12", + "@fontsource/red-hat-text": "^5.0.12", + "@fontsource/sora": "^5.0.8", + "@fontsource/space-grotesk": "^5.0.8" + }, + "devDependencies": { + "@playwright/test": "^1.45.0", + "vite": "^5.3.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@fontsource/atkinson-hyperlegible": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@fontsource/atkinson-hyperlegible/-/atkinson-hyperlegible-5.0.8.tgz", + "integrity": "sha512-IvgsBF8cEaesG1XoyS53vtBrGmD7x8nnp97Aw6Wly5tO6KsJ5shk4cFjEaI8Cr5ve61AEaLp2PcEl7fXVssUYQ==", + "license": "OFL-1.1" + }, + "node_modules/@fontsource/chivo": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@fontsource/chivo/-/chivo-5.0.8.tgz", + "integrity": "sha512-q1G2R8Ytfno+RP0cT+uJoMjoaK01E3HoZnsg5Rp5R1/J1irlbBpg0aaw3vgCNrRVXcM8TMNL/9rgEEDg1m0hQg==", + "license": "OFL-1.1" + }, + "node_modules/@fontsource/dm-sans": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.0.8.tgz", + "integrity": "sha512-A0wgG0xRKPs7sspDs35m9He64jMaNtf/RFU+HnjNpq7MqUeVbenk7mwFMBZ4o++buKTX2NkscfBTDnRIgCs02g==", + "license": "OFL-1.1" + }, + "node_modules/@fontsource/ibm-plex-sans": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.0.8.tgz", + "integrity": "sha512-E1dEDsH2Jd80l63MMW2gQndIJheVp0r2DgLg0p9+fx6n0YxnV6TAoEY6/qeB8wd05St+27cKxsOXTK1iM6AfkQ==", + "license": "OFL-1.1" + }, + "node_modules/@fontsource/manrope": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@fontsource/manrope/-/manrope-5.0.8.tgz", + "integrity": "sha512-trqxGSH4YSekXDmto97FaWED4mf9xFL/iWfZe1ksHuE0pJB0C7OUKb2w2ckoAE4S1ys5fRNCNLNA1sATAjyVCQ==", + "license": "OFL-1.1" + }, + "node_modules/@fontsource/red-hat-display": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/@fontsource/red-hat-display/-/red-hat-display-5.0.12.tgz", + "integrity": "sha512-0oMHJgbrAi9uTYYUMX9wDB/cgNikwSNg1rcdOeYllnJaVcNhFRjKEU++e9riYmPdvEGupxXcgV90QTReKbB/2Q==", + "license": "OFL-1.1" + }, + "node_modules/@fontsource/red-hat-text": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/@fontsource/red-hat-text/-/red-hat-text-5.0.12.tgz", + "integrity": "sha512-ybiORZkCN4ylRw/PzdnldffLFVJ4aZedwlSelh0SKUX6sdViNiRykYzxto3rax2vOhfHNlvdamiB2iYGPh/n0w==", + "license": "OFL-1.1" + }, + "node_modules/@fontsource/sora": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@fontsource/sora/-/sora-5.0.8.tgz", + "integrity": "sha512-rNRJ3xiHK2xRY+FGNIsJOgI+Fdfxvf5j70Ex/JulXPLYZKssgnG4nmI5mnZej+sSp/RINli4aGhiIkceZAoALg==", + "license": "OFL-1.1" + }, + "node_modules/@fontsource/space-grotesk": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@fontsource/space-grotesk/-/space-grotesk-5.0.8.tgz", + "integrity": "sha512-5nRIHgh7E65MxkTHWdLol+uweRrQm671CD8bWR+pvYZOTtQi70tW8bwpgzNFzuYS6selvaM3vfKhT0TEgiEFoQ==", + "license": "OFL-1.1" + }, + "node_modules/@playwright/test": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", + "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", + "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", + "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", + "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", + "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", + "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", + "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", + "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", + "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", + "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", + "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", + "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", + "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", + "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", + "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", + "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", + "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", + "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", + "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", + "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", + "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", + "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/playwright": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.57.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.53.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", + "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.53.3", + "@rollup/rollup-android-arm64": "4.53.3", + "@rollup/rollup-darwin-arm64": "4.53.3", + "@rollup/rollup-darwin-x64": "4.53.3", + "@rollup/rollup-freebsd-arm64": "4.53.3", + "@rollup/rollup-freebsd-x64": "4.53.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", + "@rollup/rollup-linux-arm-musleabihf": "4.53.3", + "@rollup/rollup-linux-arm64-gnu": "4.53.3", + "@rollup/rollup-linux-arm64-musl": "4.53.3", + "@rollup/rollup-linux-loong64-gnu": "4.53.3", + "@rollup/rollup-linux-ppc64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-gnu": "4.53.3", + "@rollup/rollup-linux-riscv64-musl": "4.53.3", + "@rollup/rollup-linux-s390x-gnu": "4.53.3", + "@rollup/rollup-linux-x64-gnu": "4.53.3", + "@rollup/rollup-linux-x64-musl": "4.53.3", + "@rollup/rollup-openharmony-arm64": "4.53.3", + "@rollup/rollup-win32-arm64-msvc": "4.53.3", + "@rollup/rollup-win32-ia32-msvc": "4.53.3", + "@rollup/rollup-win32-x64-gnu": "4.53.3", + "@rollup/rollup-win32-x64-msvc": "4.53.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + } + } +} diff --git a/pikit-web/package.json b/pikit-web/package.json new file mode 100644 index 0000000..cd474c4 --- /dev/null +++ b/pikit-web/package.json @@ -0,0 +1,27 @@ +{ + "name": "pikit-web", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "vite --host --port 4173", + "build": "vite build --outDir dist --emptyOutDir", + "preview": "vite preview --host --port 4173", + "test:e2e": "playwright test", + "test": "npm run test:e2e" + }, + "devDependencies": { + "@playwright/test": "^1.45.0", + "vite": "^5.3.0" + }, + "dependencies": { + "@fontsource/atkinson-hyperlegible": "^5.0.8", + "@fontsource/chivo": "^5.0.8", + "@fontsource/dm-sans": "^5.0.8", + "@fontsource/ibm-plex-sans": "^5.0.8", + "@fontsource/manrope": "^5.0.8", + "@fontsource/red-hat-display": "^5.0.12", + "@fontsource/red-hat-text": "^5.0.12", + "@fontsource/sora": "^5.0.8", + "@fontsource/space-grotesk": "^5.0.8" + } +} diff --git a/pikit-web/playwright.config.js b/pikit-web/playwright.config.js new file mode 100644 index 0000000..8a6b6bf --- /dev/null +++ b/pikit-web/playwright.config.js @@ -0,0 +1,32 @@ +// @ts-check +import { defineConfig, devices } from '@playwright/test'; + +const PORT = 4173; +const HOST = 'localhost'; +const BASE_URL = `http://${HOST}:${PORT}`; + +export default defineConfig({ + testDir: './tests', + timeout: 30_000, + expect: { + timeout: 5_000, + }, + reporter: [['list']], + use: { + baseURL: BASE_URL, + trace: 'retain-on-failure', + }, + webServer: { + command: 'npm run dev', + url: BASE_URL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + stderr: 'pipe', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/pikit-web/tests/busy-overlay.spec.js b/pikit-web/tests/busy-overlay.spec.js new file mode 100644 index 0000000..cf1dc67 --- /dev/null +++ b/pikit-web/tests/busy-overlay.spec.js @@ -0,0 +1,49 @@ +import { test, expect } from '@playwright/test'; + +const baseStatus = { + hostname: 'pikit', + ready: true, + uptime_seconds: 100, + load: [0, 0, 0], + memory_mb: { total: 1024, free: 512 }, + disk_mb: { total: 10240, free: 9000 }, + cpu_temp_c: 40, + lan_ip: '10.0.0.10', + os_version: 'DietPi', + auto_updates_enabled: true, + auto_updates: { enabled: true }, + services: [], +}; +const defaultUpdatesConfig = { + enabled: true, + scope: 'all', + update_time: '04:00', + upgrade_time: '04:30', + cleanup: true, + bandwidth_limit_kbps: null, + auto_reboot: false, + reboot_time: '04:30', + reboot_with_users: false, +}; + +test('busy overlay appears while adding a service', async ({ page }) => { + let services = []; + await page.route('**/api/updates/config', async (route) => { + await route.fulfill({ json: defaultUpdatesConfig }); + }); + await page.route('**/api/status', async (route) => { + await route.fulfill({ json: { ...baseStatus, services } }); + }); + + await page.goto('/'); + + // Trigger busy overlay via test hook + await page.evaluate(() => { + window.__pikitTest?.showBusy('Adding service', 'Opening firewall rules…'); + setTimeout(() => window.__pikitTest?.hideBusy(), 300); + }); + + const busy = page.locator('#busyOverlay'); + await expect(busy).toBeVisible(); + await expect(busy).toBeHidden({ timeout: 2000 }); +}); diff --git a/pikit-web/tests/service-path.spec.js b/pikit-web/tests/service-path.spec.js new file mode 100644 index 0000000..fcb6a72 --- /dev/null +++ b/pikit-web/tests/service-path.spec.js @@ -0,0 +1,18 @@ +import { test, expect } from '@playwright/test'; + +const services = [ + { name: 'Pi-hole', port: 8089, scheme: 'http', path: '/admin', url: 'http://pikit:8089/admin', online: true, firewall_open: true }, +]; + +test('service cards show path in URL and preserve click target', async ({ page }) => { + await page.goto('/'); + + await page.evaluate(async (svcList) => { + const mod = await import('/assets/services.js'); + const grid = document.getElementById('servicesGrid'); + mod.renderServices(grid, svcList); + }, services); + + await expect(page.getByText('Pi-hole')).toBeVisible(); + await expect(page.getByText('http://pikit:8089/admin')).toBeVisible(); +}); diff --git a/pikit-web/tests/services-flow.spec.js b/pikit-web/tests/services-flow.spec.js new file mode 100644 index 0000000..e742208 --- /dev/null +++ b/pikit-web/tests/services-flow.spec.js @@ -0,0 +1,216 @@ +import { test, expect } from '@playwright/test'; + +const baseStatus = { + hostname: 'pikit', + ready: true, + uptime_seconds: 100, + load: [0, 0, 0], + memory_mb: { total: 1024, free: 512 }, + disk_mb: { total: 10240, free: 9000 }, + cpu_temp_c: 40, + lan_ip: '10.0.0.10', + os_version: 'DietPi', + auto_updates_enabled: true, + auto_updates: { enabled: true }, +}; +const defaultUpdatesConfig = { + enabled: true, + scope: 'all', + update_time: '04:00', + upgrade_time: '04:30', + cleanup: true, + bandwidth_limit_kbps: null, + auto_reboot: false, + reboot_time: '04:30', + reboot_with_users: false, +}; + +async function primeStatus(page, statusData) { + await page.route('**/api/status', async (route) => { + await route.fulfill({ json: statusData }); + }); +} + +async function stubUpdatesConfig(page, cfg = defaultUpdatesConfig) { + await page.route('**/api/updates/config', async (route) => { + if (route.request().method() === 'POST') { + const body = await route.request().postDataJSON(); + await route.fulfill({ json: { ...cfg, ...body } }); + return; + } + await route.fulfill({ json: cfg }); + }); +} + +test('renders services from status payload', async ({ page }) => { + await stubUpdatesConfig(page); + const statusData = { + ...baseStatus, + services: [ + { name: 'Pi-hole', port: 8089, scheme: 'http', path: '/admin', url: 'http://pikit:8089/admin', online: true }, + { name: 'Netdata', port: 19999, scheme: 'http', url: 'http://pikit:19999', online: false }, + ], + }; + await primeStatus(page, statusData); + await page.goto('/'); + await expect(page.getByText('Pi-hole')).toBeVisible(); + await expect(page.getByText('http://pikit:8089/admin')).toBeVisible(); + await expect(page.getByText('Netdata')).toBeVisible(); +}); + +test('add service shows busy overlay and new card', async ({ page }) => { + await stubUpdatesConfig(page); + await page.addInitScript(() => { + window.__pikitTest = window.__pikitTest || {}; + window.__pikitTest.forceAccordionsOpen = true; + window.__pikitTest.forceServiceFormVisible = () => { + const ids = ['acc-services', 'svcName', 'svcPort', 'svcPath', 'svcScheme', 'svcAddBtn']; + ids.forEach((id) => { + const el = document.getElementById(id); + if (el) { + el.style.display = 'block'; + el.style.opacity = '1'; + el.style.visibility = 'visible'; + el.style.maxHeight = '2000px'; + } + }); + }; + }); + const statusData = { ...baseStatus, services: [] }; + await page.route('**/api/status', async (route) => { + await route.fulfill({ json: statusData }); + }); + await page.route('**/api/services/add', async (route) => { + const body = await route.request().postDataJSON(); + statusData.services.push({ + name: body.name, + port: body.port, + scheme: body.scheme, + path: body.path, + url: `${body.scheme}://pikit:${body.port}${body.path || ''}`, + online: true, + }); + await route.fulfill({ status: 200, json: { services: statusData.services, message: 'Added' } }); + }); + + await page.goto('/'); + await page.click('#advBtn'); + await page.evaluate(() => { + const acc = document.querySelector('button[data-target="acc-services"]')?.closest('.accordion'); + acc?.classList.add('open'); + window.__pikitTest?.forceServiceFormVisible?.(); + }); + + await page.fill('#svcName', 'Grafana', { force: true }); + await page.fill('#svcPort', '3000', { force: true }); + await page.fill('#svcPath', '/dashboards', { force: true }); + await page.evaluate(() => { + const sel = document.getElementById('svcScheme'); + if (sel) { + sel.value = 'http'; + sel.dispatchEvent(new Event('change', { bubbles: true })); + } + }); + await page.click('#svcAddBtn', { force: true }); + + await expect(page.getByText('Grafana')).toBeVisible(); + await expect(page.getByText('http://pikit:3000/dashboards')).toBeVisible(); +}); + +test('path validation rejects absolute URLs', async ({ page }) => { + await stubUpdatesConfig(page); + await page.addInitScript(() => { + window.__pikitTest = window.__pikitTest || {}; + window.__pikitTest.forceAccordionsOpen = true; + window.__pikitTest.forceServiceFormVisible = () => { + const ids = ['acc-services', 'svcName', 'svcPort', 'svcPath', 'svcScheme', 'svcAddBtn']; + ids.forEach((id) => { + const el = document.getElementById(id); + if (el) { + el.style.display = 'block'; + el.style.opacity = '1'; + el.style.visibility = 'visible'; + el.style.maxHeight = '2000px'; + } + }); + }; + }); + const statusData = { ...baseStatus, services: [] }; + await primeStatus(page, statusData); + await page.goto('/'); + await page.click('#advBtn'); + await page.evaluate(() => { + const acc = document.querySelector('button[data-target="acc-services"]')?.closest('.accordion'); + acc?.classList.add('open'); + window.__pikitTest?.forceServiceFormVisible?.(); + }); + await page.fill('#svcName', 'BadPath', { force: true }); + await page.fill('#svcPort', '8080', { force: true }); + await page.fill('#svcPath', 'http://example.com', { force: true }); + await page.click('#svcAddBtn', { force: true }); + await expect(page.getByText('Path must be relative (e.g. /admin) or blank.')).toBeVisible(); +}); + +test('edit service updates path and scheme', async ({ page }) => { + await stubUpdatesConfig(page); + await page.addInitScript(() => { + window.__pikitTest = window.__pikitTest || {}; + window.__pikitTest.forceAccordionsOpen = true; + }); + const statusData = { + ...baseStatus, + services: [ + { name: 'Uptime Kuma', port: 3001, scheme: 'http', path: '', url: 'http://pikit:3001', online: true }, + ], + }; + await page.route('**/api/status', async (route) => { + await route.fulfill({ json: statusData }); + }); + await page.route('**/api/services/update', async (route) => { + const body = await route.request().postDataJSON(); + statusData.services = statusData.services.map((s) => + s.port === body.port ? { ...s, scheme: body.scheme, path: body.path, url: `${body.scheme}://pikit:${s.port}${body.path || ''}` } : s + ); + await route.fulfill({ status: 200, json: { services: statusData.services, message: 'Service updated' } }); + }); + + await page.goto('/'); + await page.click('.menu-btn'); + await page.fill('#menuPath', '/status'); + await page.selectOption('#menuScheme', 'https'); + const updateResp = await Promise.all([ + page.waitForResponse((r) => r.url().includes('/api/services/update') && r.status() === 200), + page.click('#menuSaveBtn'), + ]).then((res) => res[0]); + expect(updateResp.ok()).toBeTruthy(); + const statusAfter = await page.evaluate(async () => { + const res = await fetch('/api/status'); + return res.json(); + }); + const svc = statusAfter.services.find((s) => s.port === 3001); + expect(svc).toBeTruthy(); + expect(svc.url).toContain('https://pikit:3001/status'); + await page.reload(); + await expect(page.locator('.service-url')).toContainText('https://pikit:3001/status', { timeout: 8000 }); +}); + +test('remove service updates list', async ({ page }) => { + await stubUpdatesConfig(page); + let services = [ + { name: 'RemoveMe', port: 9000, scheme: 'http', url: 'http://pikit:9000', online: true }, + ]; + const statusData = { ...baseStatus, services }; + await page.route('**/api/status', async (route) => { + await route.fulfill({ json: { ...statusData, services } }); + }); + await page.route('**/api/services/remove', async (route) => { + const body = await route.request().postDataJSON(); + services = services.filter((s) => s.port !== body.port); + await route.fulfill({ status: 200, json: { services, message: 'Removed' } }); + }); + + await page.goto('/'); + await page.click('.menu-btn'); + await page.click('#menuRemoveBtn'); + await expect(page.locator('.pill', { hasText: 'RemoveMe' })).toHaveCount(0, { timeout: 2000 }); +}); diff --git a/pikit-web/tests/update-settings.spec.js b/pikit-web/tests/update-settings.spec.js new file mode 100644 index 0000000..87f1266 --- /dev/null +++ b/pikit-web/tests/update-settings.spec.js @@ -0,0 +1,114 @@ +import { test, expect } from '@playwright/test'; + +const baseStatus = { + hostname: 'pikit', + ready: true, + uptime_seconds: 100, + load: [0, 0, 0], + memory_mb: { total: 1024, free: 512 }, + disk_mb: { total: 10240, free: 9000 }, + cpu_temp_c: 40, + lan_ip: '10.0.0.10', + os_version: 'DietPi', + auto_updates_enabled: true, + auto_updates: { enabled: true }, +}; + +const defaultUpdatesConfig = { + enabled: true, + scope: 'all', + update_time: '04:00', + upgrade_time: '04:30', + cleanup: false, + bandwidth_limit_kbps: null, + auto_reboot: false, + reboot_time: '04:30', + reboot_with_users: false, +}; + +test('update settings form loads and saves config', async ({ page }) => { + let posted = null; + await page.addInitScript(() => { + window.__pikitTest = window.__pikitTest || {}; + window.__pikitTest.forceAccordionsOpen = true; + }); + + await page.route('**/api/status', async (route) => { + await route.fulfill({ json: { ...baseStatus, services: [] } }); + }); + await page.route('**/api/updates/config', async (route) => { + if (route.request().method() === 'POST') { + posted = await route.request().postDataJSON(); + await route.fulfill({ json: { ...defaultUpdatesConfig, ...posted } }); + return; + } + await route.fulfill({ json: defaultUpdatesConfig }); + }); + + await page.goto('/'); + await page.click('#advBtn'); + await expect(page.locator('#acc-updates')).toBeVisible(); + + await page.selectOption('#updatesScope', 'security'); + await page.fill('#updateTimeInput', '03:00'); + await page.fill('#upgradeTimeInput', '03:30'); + await page.click('#updatesCleanup'); + await page.fill('#updatesBandwidth', '500'); + await page.click('#updatesRebootToggle'); + await page.fill('#updatesRebootTime', '03:45'); + await page.click('#updatesRebootUsers'); + + const resp = await Promise.all([ + page.waitForResponse((r) => r.url().includes('/api/updates/config') && r.request().method() === 'POST'), + page.click('#updatesSaveBtn'), + ]); + expect(resp[0].ok()).toBeTruthy(); + + expect(posted).toMatchObject({ + enable: true, + scope: 'security', + update_time: '03:00', + upgrade_time: '03:30', + cleanup: true, + bandwidth_limit_kbps: 500, + auto_reboot: true, + reboot_time: '03:45', + reboot_with_users: true, + }); + + await expect(page.getByText('Update settings saved.')).toBeVisible({ timeout: 2000 }); +}); + +test('disabling updates disables controls and saves enable=false', async ({ page }) => { + let posted = null; + await page.addInitScript(() => { + window.__pikitTest = window.__pikitTest || {}; + window.__pikitTest.forceAccordionsOpen = true; + }); + + await page.route('**/api/status', async (route) => { + await route.fulfill({ json: { ...baseStatus, services: [] } }); + }); + await page.route('**/api/updates/config', async (route) => { + if (route.request().method() === 'POST') { + posted = await route.request().postDataJSON(); + await route.fulfill({ json: { ...defaultUpdatesConfig, ...posted } }); + return; + } + await route.fulfill({ json: defaultUpdatesConfig }); + }); + + await page.goto('/'); + await page.click('#advBtn'); + + await page.click('#updatesToggle + .slider', { force: true }); // disable via slider + await expect(page.locator('#updatesScope')).toBeDisabled(); + await expect(page.locator('#updateTimeInput')).toBeDisabled(); + + await Promise.all([ + page.waitForResponse((r) => r.url().includes('/api/updates/config') && r.request().method() === 'POST'), + page.click('#updatesSaveBtn'), + ]); + + expect(posted).toMatchObject({ enable: false }); +}); diff --git a/pikit-web/vite.config.js b/pikit-web/vite.config.js new file mode 100644 index 0000000..bae8e47 --- /dev/null +++ b/pikit-web/vite.config.js @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + root: '.', + server: { + host: true, + port: 4173, + proxy: { + // Forward API calls to the local Python API during dev so fetches + // return JSON instead of the Vite index.html shell. + '/api': 'http://127.0.0.1:4000', + }, + }, + preview: { + host: true, + port: 4173, + }, +}); diff --git a/set_ready.sh b/set_ready.sh new file mode 100755 index 0000000..996269c --- /dev/null +++ b/set_ready.sh @@ -0,0 +1,7 @@ +#!/bin/bash +echo "Waiting for pikit to resolve..." +while ! getent hosts pikit >/dev/null; + do sleep 1 +done +ssh -i ~/.ssh/pikit dietpi@pikit sudo touch /var/run/pikit-ready +echo "Done." diff --git a/start-codex.sh b/start-codex.sh new file mode 100755 index 0000000..87cfaba --- /dev/null +++ b/start-codex.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +set -e + +# 1) Refresh sudo once in the foreground so you enter your password cleanly +echo "Refreshing sudo credentials..." +sudo -v || { echo "sudo authentication failed"; exit 1; } + +# 2) Start non-interactive sudo keepalive loop in the background +# -n = never prompt; if the timestamp ever expires, this just exits +( while true; do sudo -n true 2>/dev/null || exit 0; sleep 180; done ) & +KEEPALIVE_PID=$! + +# 3) Ensure cleanup on exit (normal, error, or Ctrl+C) +cleanup() { + kill "$KEEPALIVE_PID" 2>/dev/null || true +} +trap cleanup EXIT + +# 4) Run Codex +codex resume --search +