diff --git a/.gitignore b/.gitignore index a2438d7..08aec77 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,3 @@ out/ # Stock images (large) images/stock/ - -# Local helpers -set_ready.sh diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index 5dcceb4..0000000 --- a/PLAN.md +++ /dev/null @@ -1,81 +0,0 @@ -# Pi-Kit Updater Plan - -## Goals -- Add seamless in-dashboard updates for Pi-Kit assets (web UI, api script, helper scripts). -- Allow manual check/apply + rollback; optional daily auto-check with a status chip. -- Use release tarballs (not git clones) from Gitea; verify hashes; stage before swap; keep one backup. -- Keep UX friendly: clear progress, errors, and “update available” indicator. - -## Release Format (Gitea) -- Release asset: `pikit-.tar.gz` containing: - - `pikit-web/` (built assets only, no node_modules) - - `pikit-api.py` - - helper scripts/configs that should overwrite - - `manifest.json` (see below) -- `manifest.json` fields: - - `version` (string) - - `changelog` (url) - - `files`: array of { `path`, `sha256`, `mode`? } - - `post_install` (optional array of commands or service restarts) -- Optional: detached signature in future (ed25519), not in v1. - -## On-device Updater (Python helper) -- Location: `/usr/local/bin/pikit-updater.py` (invoked by API and timer). -- State file: `/var/lib/pikit-update/state.json` (latest, current, last_check, last_result, progress, backup_path). -- Steps for apply: - 1) Fetch manifest (HTTP GET release URL). - 2) Download tarball to `/var/tmp/pikit-update//bundle.tar.gz`. - 3) Verify SHA256 against manifest. - 4) Unpack to staging dir. - 5) Backup current managed files to `/var/backups/pikit//` (keep 1 latest). - 6) Rsync staged files to destinations: - - `/var/www/pikit-web/` (built assets) - - `/usr/local/bin/pikit-api.py` - 7) Restart services: `dietpi-dashboard-frontend`, api service (if exists), maybe nginx reload. - 8) Update state; mark current version file `/etc/pikit/version`. -- Rollback: restore latest backup and restart services. -- Concurrency: use lockfile in `/var/run/pikit-update.lock`. - -## API additions (pikit-api.py) -- `GET /api/update/status` -> returns state.json contents + current version. -- `POST /api/update/check` -> fetch manifest only, update state. -- `POST /api/update/apply` -> spawn updater apply (can run inline for v1) and stream status. -- `POST /api/update/rollback` -> restore last backup. -- `POST /api/update/auto` -> enable/disable daily timer. - -## Timer -- systemd timer: `pikit-update.timer/service` running check daily (e.g., 04:20). -- Writes last_check and latest_version to state. - -## UI changes (pikit-web) -- Top-bar chip: “Updates: Up to date / Update available / Checking…”. -- New “Updates” modal (not the OS auto-updates) for Pi-Kit releases: - - Show current vs latest version, changelog link. - - Buttons: Check, Download & Install, Rollback, View logs. - - Progress states + errors. -- Hook into existing status refresh: call `/api/update/status`. - -## Safety / Edge -- Preflight disk space check before download. -- Handle offline: clear message. -- If apply fails, auto rollback. -- Skip overwriting user-edited config? For v1 assume assets/api are managed; nginx site left unchanged. - -## Current status (2025-12-11) -- Version markers and mock update status in place. -- UI has release chip + modal; buttons call real `/api/update/*`. -- API implements check/apply/rollback with staging, SHA256 verify, backup, deploy, restart, rollback. -- Release tooling: `tools/release/make-release.sh` + README; SHA256 baked into manifest. -- Missing: auto-check timer, lockfile, backup pruning, real release publishing. - -## Remaining work -- Publish real release bundles + manifest to Gitea. -- Add lock around apply/check to prevent overlap. -- Add backup pruning (keep last N). -- Add systemd timer/service to run check daily and surface status. -- Optional: protect nginx/site overrides; add disk-space preflight. - -## Open questions / assumptions -- Release hosting: use Gitea releases at `https://git.44r0n.cc/44r0n7/pi-kit/releases/latest` (JSON API? or static URLs). For v1, hardcode base URL env var or config. -- Services to restart: assume `dietpi-dashboard-frontend` and `pikit-api` (if we add a service file). Need to verify actual service names on device. -- Device perms: updater runs as root (via sudo from API). Ensure API endpoint requires auth (existing login). diff --git a/README.md b/README.md index 016c3f8..41adc0c 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,22 @@ # Pi-Kit Dashboard +Lightweight dashboard for DietPi-based Pi-Kit images. -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/`. +## What’s here +- `pikit_api/` + `pikit-api.py`: Python HTTP API (status, services CRUD, auto-updates, diagnostics, factory reset), served on 127.0.0.1:4000. +- `pikit-web/`: Vite static site served by nginx at `/var/www/pikit-web/`; source in `pikit-web/assets/`, Playwright E2E in `pikit-web/tests/`. +- Release tooling: `tools/release/make-release.sh` builds a bundle tarball + manifest for OTA; changelogs live in `out/releases/`. ## 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). +- Frontend: `cd pikit-web && npm install` (once), `npm run dev` for live reload, `npm test` for Playwright, `npm run build` for production `dist/`. +- API: `python pikit-api.py` runs the same routes locally (no auth) as on-device. -## 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`. +## Deploy to a Pi-Kit box +1) Copy `pikit-api.py` **and** the `pikit_api/` directory to the device (e.g., `/usr/local/bin/`) and restart `pikit-api.service`. +2) Sync `pikit-web/dist/` (preferred) to `/var/www/pikit-web/`, then restart `dietpi-dashboard-frontend.service`. +3) Using release bundles: `./tools/release/make-release.sh `, upload the tarball + manifest, and point `PIKIT_MANIFEST_URL` (systemd drop-in) to that manifest URL for OTA. ## 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). +- Firewall changes surface clear errors when `ufw` is missing so the UI can report failures. +- Factory reset sets `root` and `dietpi` passwords to `pikit`. +- Default UI: `http://pikit.local/` (mDNS) unless HTTPS is enabled. diff --git a/pikit-api.py b/pikit-api.py index b4a9cba..bbfc536 100644 --- a/pikit-api.py +++ b/pikit-api.py @@ -1,1446 +1,49 @@ #!/usr/bin/env python3 -import json, os, subprocess, socket, shutil, pathlib, datetime, tarfile, sys, argparse -from http.server import BaseHTTPRequestHandler, HTTPServer -import re -import urllib.request -import hashlib -import urllib.parse -import fcntl -from functools import partial -import json as jsonlib -import io -from collections import deque +""" +Pi-Kit API entrypoint -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, -] +Thin wrapper that defers to the modular `pikit_api` package. The behavior and +CLI flags remain compatible with the previous single-file script: -# Release updater constants -VERSION_FILE = pathlib.Path("/etc/pikit/version") -WEB_VERSION_FILE = pathlib.Path("/var/www/pikit-web/data/version.json") -UPDATE_STATE_DIR = pathlib.Path("/var/lib/pikit-update") -UPDATE_STATE = UPDATE_STATE_DIR / "state.json" -UPDATE_LOCK = pathlib.Path("/var/run/pikit-update.lock") -DEFAULT_MANIFEST_URL = os.environ.get( - "PIKIT_MANIFEST_URL", - "https://git.44r0n.cc/44r0n7/pi-kit/releases/latest/download/manifest.json", -) -AUTH_TOKEN = os.environ.get("PIKIT_AUTH_TOKEN") -WEB_ROOT = pathlib.Path("/var/www/pikit-web") -API_PATH = pathlib.Path("/usr/local/bin/pikit-api.py") -BACKUP_ROOT = pathlib.Path("/var/backups/pikit") -TMP_ROOT = pathlib.Path("/var/tmp/pikit-update") +--apply-update Apply latest release (non-HTTP mode) +--check-update Check for latest release (non-HTTP mode) +--rollback-update Roll back to last backup (non-HTTP mode) +""" -# Diagnostics logging (RAM-only) -DIAG_STATE_FILE = pathlib.Path("/dev/shm/pikit-diag.state") if pathlib.Path("/dev/shm").exists() else pathlib.Path("/tmp/pikit-diag.state") -DIAG_LOG_FILE = pathlib.Path("/dev/shm/pikit-diag.log") if pathlib.Path("/dev/shm").exists() else pathlib.Path("/tmp/pikit-diag.log") -DIAG_MAX_BYTES = 1_048_576 # 1 MiB cap in RAM -DIAG_MAX_ENTRY_CHARS = 2048 -DIAG_DEFAULT_STATE = {"enabled": False, "level": "normal"} # level: normal|debug -_diag_state = None +import argparse +import sys +from pikit_api import HOST, PORT +from pikit_api.releases import apply_update, check_for_update, rollback_update +from pikit_api.server import run_server -def _load_diag_state(): - global _diag_state - if _diag_state is not None: - return _diag_state - try: - if DIAG_STATE_FILE.exists(): - _diag_state = json.loads(DIAG_STATE_FILE.read_text()) - return _diag_state - except Exception: - pass - _diag_state = DIAG_DEFAULT_STATE.copy() - return _diag_state - -def _save_diag_state(enabled=None, level=None): - state = _load_diag_state() - if enabled is not None: - state["enabled"] = bool(enabled) - if level in ("normal", "debug"): - state["level"] = level - try: - DIAG_STATE_FILE.parent.mkdir(parents=True, exist_ok=True) - DIAG_STATE_FILE.write_text(json.dumps(state)) - except Exception: - pass - return state - - -def diag_log(level: str, message: str, meta: dict | None = None): - """ - Append a diagnostic log line to RAM-backed file. - Skips when disabled or when debug level is off. - """ - state = _load_diag_state() - if not state.get("enabled"): - return - if level == "debug" and state.get("level") != "debug": - return - try: - ts = datetime.datetime.utcnow().isoformat() + "Z" - entry = {"ts": ts, "level": level, "msg": message} - if meta: - entry["meta"] = meta - line = json.dumps(entry, separators=(",", ":")) - if len(line) > DIAG_MAX_ENTRY_CHARS: - entry.pop("meta", None) - entry["msg"] = (message or "")[: DIAG_MAX_ENTRY_CHARS - 40] + "…" - line = json.dumps(entry, separators=(",", ":")) - line_bytes = (line + "\n").encode() - DIAG_LOG_FILE.parent.mkdir(parents=True, exist_ok=True) - with DIAG_LOG_FILE.open("ab") as f: - f.write(line_bytes) - # Trim file if above cap - if DIAG_LOG_FILE.stat().st_size > DIAG_MAX_BYTES: - with DIAG_LOG_FILE.open("rb") as f: - f.seek(-DIAG_MAX_BYTES, io.SEEK_END) - tail = f.read() - # drop partial first line to keep JSON lines clean - if b"\n" in tail: - tail = tail.split(b"\n", 1)[1] - with DIAG_LOG_FILE.open("wb") as f: - f.write(tail) - except Exception: - # Never break caller - pass - - -def diag_read(limit=500): - """Return latest log entries (dicts), newest first.""" - if not DIAG_LOG_FILE.exists(): - return [] - try: - data = DIAG_LOG_FILE.read_bytes() - except Exception: - return [] - lines = data.splitlines()[-limit:] - out = [] - for line in lines: - try: - out.append(json.loads(line.decode("utf-8", errors="ignore"))) - except Exception: - continue - return out[::-1] - - -def ensure_dir(path: pathlib.Path): - path.mkdir(parents=True, exist_ok=True) - - -def sha256_file(path: pathlib.Path): - h = hashlib.sha256() - with path.open("rb") as f: - for chunk in iter(lambda: f.read(1024 * 1024), b""): - h.update(chunk) - return h.hexdigest() - - -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 default_host(): - """Return preferred hostname (append .local if bare).""" - host = socket.gethostname() - if "." not in host: - host = f"{host}.local" - return host - - -def dbg(msg): - # Legacy debug file logging (when /boot/pikit-debug exists) - if DEBUG_FLAG: - 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") - # Mirror into diagnostics if enabled - try: - diag_log("debug", msg) - except Exception: - pass - - -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 = default_host() - 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): - """Heuristic: known HTTPS ports or .local certs.""" - return int(port) in HTTPS_PORTS or str(host).lower().endswith(".local") or str(host).lower() == "pikit" - - -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) - - -def read_current_version(): - if VERSION_FILE.exists(): - return VERSION_FILE.read_text().strip() - if WEB_VERSION_FILE.exists(): - try: - return json.loads(WEB_VERSION_FILE.read_text()).get("version", "unknown") - except Exception: - return "unknown" - return "unknown" - - -def load_update_state(): - UPDATE_STATE_DIR.mkdir(parents=True, exist_ok=True) - if UPDATE_STATE.exists(): - try: - return json.loads(UPDATE_STATE.read_text()) - except Exception: - pass - return { - "current_version": read_current_version(), - "latest_version": None, - "last_check": None, - "status": "unknown", - "message": "", - "auto_check": False, - "in_progress": False, - "progress": None, - "channel": os.environ.get("PIKIT_CHANNEL", "dev"), - } - - -def save_update_state(state: dict): - UPDATE_STATE_DIR.mkdir(parents=True, exist_ok=True) - UPDATE_STATE.write_text(json.dumps(state, indent=2)) - - -def _auth_token(): - return os.environ.get("PIKIT_AUTH_TOKEN") or AUTH_TOKEN - - -def _gitea_latest_manifest(target: str): - """ - Fallback: when a manifest URL 404s, try hitting the Gitea API to grab the - latest release asset named manifest.json. - """ - try: - # target like https://host/owner/repo/releases/download/vX/manifest.json - parts = target.split("/") - if "releases" not in parts: - return None - idx = parts.index("releases") - if idx < 2: - return None - base = "/".join(parts[:3]) # scheme + host - owner = parts[idx - 2] - repo = parts[idx - 1] - api_url = f"{base}/api/v1/repos/{owner}/{repo}/releases/latest" - req = urllib.request.Request(api_url) - token = _auth_token() - if token: - req.add_header("Authorization", f"token {token}") - resp = urllib.request.urlopen(req, timeout=10) - rel = json.loads(resp.read().decode()) - assets = rel.get("assets") or [] - manifest_asset = next((a for a in assets if a.get("name") == "manifest.json"), None) - if manifest_asset and manifest_asset.get("browser_download_url"): - # Download that manifest - return fetch_manifest(manifest_asset["browser_download_url"]) - except Exception: - return None - return None - - -def fetch_manifest(url: str = None): - target = url or os.environ.get("PIKIT_MANIFEST_URL") or DEFAULT_MANIFEST_URL - req = urllib.request.Request(target) - token = _auth_token() - if token: - req.add_header("Authorization", f"token {token}") - try: - resp = urllib.request.urlopen(req, timeout=10) - data = resp.read() - return json.loads(data.decode()) - except urllib.error.HTTPError as e: - if e.code == 404: - alt = _gitea_latest_manifest(target) - if alt: - return alt - raise - - -def fetch_manifest_for_channel(channel: str): - """ - For stable: use normal manifest (latest non-prerelease). - For dev: try normal manifest; if it points to stable, fetch latest prerelease manifest via Gitea API. - """ - channel = channel or "dev" - base_manifest_url = os.environ.get("PIKIT_MANIFEST_URL") or DEFAULT_MANIFEST_URL - manifest = None - try: - manifest = fetch_manifest(base_manifest_url) - except Exception: - manifest = None - - # If we already have a manifest and channel is stable, return it - if manifest and channel == "stable": - return manifest - # If dev channel and manifest is dev, return it - if manifest: - version = manifest.get("version") or manifest.get("latest_version") - if channel == "dev" and version and "dev" in str(version): - return manifest - - # Try Gitea API for latest release (include prerelease) - try: - parts = base_manifest_url.split("/") - if "releases" not in parts: - if manifest: - return manifest - return fetch_manifest(base_manifest_url) - idx = parts.index("releases") - owner = parts[idx - 2] - repo = parts[idx - 1] - base = "/".join(parts[:3]) - api_url = f"{base}/api/v1/repos/{owner}/{repo}/releases" - req = urllib.request.Request(api_url) - token = _auth_token() - if token: - req.add_header("Authorization", f"token {token}") - resp = urllib.request.urlopen(req, timeout=10) - releases = json.loads(resp.read().decode()) - - def pick(predicate): - for r in releases: - if predicate(r): - asset = next((a for a in r.get("assets", []) if a.get("name") == "manifest.json"), None) - if asset and asset.get("browser_download_url"): - return fetch_manifest(asset["browser_download_url"]) - return None - - if channel == "dev": - m = pick(lambda r: r.get("prerelease") is True) - if m: - return m - m = pick(lambda r: r.get("prerelease") is False) - if m: - return m - except Exception: - pass - - # last resort: return whatever manifest we had - if manifest: - return manifest - raise RuntimeError("No manifest found for channel") - - -def download_file(url: str, dest: pathlib.Path): - ensure_dir(dest.parent) - req = urllib.request.Request(url) - token = _auth_token() - if token: - req.add_header("Authorization", f"token {token}") - with urllib.request.urlopen(req, timeout=60) as resp, dest.open("wb") as f: - shutil.copyfileobj(resp, f) - return dest - - -def fetch_text_with_auth(url: str): - req = urllib.request.Request(url) - token = _auth_token() - if token: - req.add_header("Authorization", f"token {token}") - with urllib.request.urlopen(req, timeout=10) as resp: - return resp.read().decode() - - -def check_for_update(): - state = load_update_state() - lock = acquire_lock() - if lock is None: - state["status"] = "error" - state["message"] = "Another update is running" - save_update_state(state) - return state - diag_log("info", "Update check started", {"channel": state.get("channel") or "dev"}) - state["in_progress"] = True - state["progress"] = "Checking for updates…" - save_update_state(state) - try: - manifest = fetch_manifest_for_channel(state.get("channel") or "dev") - latest = manifest.get("version") or manifest.get("latest_version") - state["latest_version"] = latest - state["last_check"] = datetime.datetime.utcnow().isoformat() + "Z" - channel = state.get("channel") or "dev" - if channel == "stable" and latest and "dev" in str(latest): - state["status"] = "up_to_date" - state["message"] = "Dev release available; enable dev channel to install." - else: - if latest and latest != state.get("current_version"): - state["status"] = "update_available" - state["message"] = manifest.get("changelog", "Update available") - else: - state["status"] = "up_to_date" - state["message"] = "Up to date" - diag_log("info", "Update check finished", {"status": state["status"], "latest": str(latest)}) - except Exception as e: - state["status"] = "up_to_date" - state["message"] = f"Could not reach update server: {e}" - state["last_check"] = datetime.datetime.utcnow().isoformat() + "Z" - diag_log("error", "Update check failed", {"error": str(e)}) - finally: - state["in_progress"] = False - state["progress"] = None - save_update_state(state) - if lock: - release_lock(lock) - return state - - -def apply_update_stub(): - """Download + install release tarball with backup/rollback.""" - state = load_update_state() - if state.get("in_progress"): - state["message"] = "Update already in progress" - save_update_state(state) - return state - - lock = acquire_lock() - if lock is None: - state["status"] = "error" - state["message"] = "Another update is running" - save_update_state(state) - return state - - manifest = None - state["in_progress"] = True - state["status"] = "in_progress" - state["progress"] = "Starting update…" - save_update_state(state) - diag_log("info", "Update apply started", {"channel": state.get("channel") or "dev"}) - - try: - channel = state.get("channel") or os.environ.get("PIKIT_CHANNEL", "dev") - manifest = fetch_manifest_for_channel(channel) - latest = manifest.get("version") or manifest.get("latest_version") - if not latest: - raise RuntimeError("Manifest missing version") - - # Backup current BEFORE download/install to guarantee rollback point - ts = datetime.datetime.utcnow().strftime("%Y%m%d-%H%M%S") - backup_dir = BACKUP_ROOT / ts - ensure_dir(backup_dir) - # Backup web and api - if WEB_ROOT.exists(): - ensure_dir(backup_dir / "pikit-web") - shutil.copytree(WEB_ROOT, backup_dir / "pikit-web", dirs_exist_ok=True) - if API_PATH.exists(): - shutil.copy2(API_PATH, backup_dir / "pikit-api.py") - if VERSION_FILE.exists(): - shutil.copy2(VERSION_FILE, backup_dir / "version.txt") - - prune_backups(keep=1) - - # Paths - bundle_url = manifest.get("bundle") or manifest.get("url") - if not bundle_url: - raise RuntimeError("Manifest missing bundle url") - stage_dir = TMP_ROOT / latest - bundle_path = stage_dir / "bundle.tar.gz" - ensure_dir(stage_dir) - - state["progress"] = "Downloading release…" - save_update_state(state) - download_file(bundle_url, bundle_path) - diag_log("debug", "Bundle downloaded", {"url": bundle_url, "path": str(bundle_path)}) - - # Verify hash if provided - expected_hash = None - for f in manifest.get("files", []): - if f.get("path") == "bundle.tar.gz" and f.get("sha256"): - expected_hash = f["sha256"] - break - if expected_hash: - got = sha256_file(bundle_path) - if got.lower() != expected_hash.lower(): - raise RuntimeError("Bundle hash mismatch") - diag_log("debug", "Bundle hash verified", {"expected": expected_hash}) - - state["progress"] = "Staging files…" - save_update_state(state) - # Extract - with tarfile.open(bundle_path, "r:gz") as tar: - tar.extractall(stage_dir) - - # Deploy from staging - staged_web = stage_dir / "pikit-web" - if staged_web.exists(): - shutil.rmtree(WEB_ROOT, ignore_errors=True) - shutil.copytree(staged_web, WEB_ROOT) - staged_api = stage_dir / "pikit-api.py" - if staged_api.exists(): - shutil.copy2(staged_api, API_PATH) - os.chmod(API_PATH, 0o755) - - # Restart services (best-effort) - for svc in ("pikit-api.service", "dietpi-dashboard-frontend.service"): - subprocess.run(["systemctl", "restart", svc], check=False) - - VERSION_FILE.parent.mkdir(parents=True, exist_ok=True) - VERSION_FILE.write_text(str(latest)) - - state["current_version"] = str(latest) - state["latest_version"] = str(latest) - state["status"] = "up_to_date" - state["message"] = "Update installed" - state["progress"] = None - save_update_state(state) - diag_log("info", "Update applied", {"version": str(latest)}) - except urllib.error.HTTPError as e: - state["status"] = "error" - state["message"] = f"No release available ({e.code})" - diag_log("error", "Update apply HTTP error", {"code": e.code}) - except Exception as e: - state["status"] = "error" - state["message"] = f"Update failed: {e}" - state["progress"] = None - save_update_state(state) - diag_log("error", "Update apply failed", {"error": str(e)}) - # Attempt rollback if backup exists - backup = choose_rollback_backup() - if backup: - try: - restore_backup(backup) - state["current_version"] = read_current_version() - state["message"] += f" (rolled back to backup {backup.name})" - save_update_state(state) - diag_log("info", "Rollback after failed update", {"backup": backup.name}) - except Exception as re: - state["message"] += f" (rollback failed: {re})" - save_update_state(state) - diag_log("error", "Rollback after failed update failed", {"error": str(re)}) - finally: - state["in_progress"] = False - state["progress"] = None - save_update_state(state) - if lock: - release_lock(lock) - return state - - -def rollback_update_stub(): - state = load_update_state() - lock = acquire_lock() - if lock is None: - state["status"] = "error" - state["message"] = "Another update is running" - save_update_state(state) - return state - state["in_progress"] = True - state["status"] = "in_progress" - state["progress"] = "Rolling back…" - save_update_state(state) - diag_log("info", "Rollback started") - backup = choose_rollback_backup() - if not backup: - state["status"] = "error" - state["message"] = "No backup available to rollback." - state["in_progress"] = False - state["progress"] = None - save_update_state(state) - release_lock(lock) - return state - try: - restore_backup(backup) - state["status"] = "up_to_date" - state["current_version"] = read_current_version() - state["latest_version"] = state.get("latest_version") or state["current_version"] - ver = get_backup_version(backup) - suffix = f" (version {ver})" if ver else "" - state["message"] = f"Rolled back to backup {backup.name}{suffix}" - diag_log("info", "Rollback complete", {"backup": backup.name, "version": ver}) - except Exception as e: - state["status"] = "error" - state["message"] = f"Rollback failed: {e}" - diag_log("error", "Rollback failed", {"error": str(e)}) - state["in_progress"] = False - state["progress"] = None - save_update_state(state) - release_lock(lock) - return state - - -def start_background_task(mode: str): - """ - Kick off a background update/rollback via systemd-run so nginx/API restarts - do not break the caller connection. - mode: "apply" or "rollback" - """ - assert mode in ("apply", "rollback"), "invalid mode" - unit = f"pikit-update-{mode}" - flag = f"--{mode}-update" - cmd = ["systemd-run", "--unit", unit, "--quiet"] - # Pass manifest URL/token if set in environment - if DEFAULT_MANIFEST_URL: - cmd += [f"--setenv=PIKIT_MANIFEST_URL={DEFAULT_MANIFEST_URL}"] - if AUTH_TOKEN: - cmd += [f"--setenv=PIKIT_AUTH_TOKEN={AUTH_TOKEN}"] - cmd += ["/usr/bin/env", "python3", str(API_PATH), flag] - subprocess.run(cmd, check=False) - - -def acquire_lock(): - try: - ensure_dir(UPDATE_LOCK.parent) - lockfile = UPDATE_LOCK.open("w") - fcntl.flock(lockfile.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) - lockfile.write(str(os.getpid())) - lockfile.flush() - return lockfile - except Exception: - return None - - -def release_lock(lockfile): - try: - fcntl.flock(lockfile.fileno(), fcntl.LOCK_UN) - lockfile.close() - UPDATE_LOCK.unlink(missing_ok=True) - except Exception: - pass - - -def prune_backups(keep: int = 2): - if keep < 1: - keep = 1 - backups = list_backups() - for old in backups[keep:]: - shutil.rmtree(old, ignore_errors=True) - - -def list_backups(): - """Return backups sorted by mtime (newest first).""" - ensure_dir(BACKUP_ROOT) - backups = [p for p in BACKUP_ROOT.iterdir() if p.is_dir()] - backups.sort(key=lambda p: p.stat().st_mtime, reverse=True) - return backups - - -def get_backup_version(path: pathlib.Path): - vf = path / "version.txt" - if not vf.exists(): - web_version = path / "pikit-web" / "data" / "version.json" - if not web_version.exists(): - return None - try: - return json.loads(web_version.read_text()).get("version") - except Exception: - return None - try: - return vf.read_text().strip() - except Exception: - return None - - -def choose_rollback_backup(): - """ - Pick the most recent backup whose version differs from the currently - installed version. If none differ, fall back to the newest backup. - """ - backups = list_backups() - if not backups: - return None - current = read_current_version() - for b in backups: - ver = get_backup_version(b) - if ver and ver != current: - return b - return backups[0] - - -def restore_backup(target: pathlib.Path): - if (target / "pikit-web").exists(): - shutil.rmtree(WEB_ROOT, ignore_errors=True) - shutil.copytree(target / "pikit-web", WEB_ROOT, dirs_exist_ok=True) - if (target / "pikit-api.py").exists(): - shutil.copy2(target / "pikit-api.py", API_PATH) - os.chmod(API_PATH, 0o755) - VERSION_FILE.parent.mkdir(parents=True, exist_ok=True) - if (target / "version.txt").exists(): - shutil.copy2(target / "version.txt", VERSION_FILE) - else: - # Fall back to the version recorded in the web bundle - ver = get_backup_version(target) - if ver: - VERSION_FILE.write_text(str(ver)) - for svc in ("pikit-api.service", "dietpi-dashboard-frontend.service"): - subprocess.run(["systemctl", "restart", svc], 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) - # Rebuild URL with preferred host (adds .local) - host = default_host() - path = normalize_path(svc.get("path")) - scheme = svc.get("scheme") or ("https" if detect_https(host, port) else "http") - svc["scheme"] = scheme - svc["url"] = f"{scheme}://{host}:{port}{path}" - 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) - elif self.path.startswith("/api/update/status"): - state = load_update_state() - state["current_version"] = read_current_version() - state["channel"] = state.get("channel", os.environ.get("PIKIT_CHANNEL", "dev")) - self._send(200, state) - elif self.path.startswith("/api/update/changelog"): - # Fetch changelog text (URL param ?url= overrides manifest changelog) - try: - qs = urllib.parse.urlparse(self.path).query - params = urllib.parse.parse_qs(qs) - url = params.get("url", [None])[0] - if not url: - manifest = fetch_manifest() - url = manifest.get("changelog") - if not url: - return self._send(404, {"error": "no changelog url"}) - text = fetch_text_with_auth(url) - return self._send(200, {"text": text}) - except Exception as e: - return self._send(500, {"error": str(e)}) - elif self.path.startswith("/api/diag/log"): - entries = diag_read() - state = _load_diag_state() - return self._send(200, {"entries": entries, "state": state}) - 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") - diag_log("info", "Factory reset requested") - 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() - diag_log("info", "Auto updates toggled", {"enabled": enable}) - 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}") - diag_log("info", "Update settings saved", cfg) - return self._send(200, cfg) - except Exception as e: - dbg(f"Failed to apply updates config: {e}") - diag_log("error", "Update settings save failed", {"error": str(e)}) - return self._send(500, {"error": str(e)}) - if self.path.startswith("/api/update/check"): - state = check_for_update() - return self._send(200, state) - if self.path.startswith("/api/update/apply"): - # Start background apply to avoid breaking caller during service restart - start_background_task("apply") - state = load_update_state() - state["status"] = "in_progress" - state["message"] = "Starting background apply" - save_update_state(state) - return self._send(202, state) - if self.path.startswith("/api/update/rollback"): - start_background_task("rollback") - state = load_update_state() - state["status"] = "in_progress" - state["message"] = "Starting rollback" - save_update_state(state) - return self._send(202, state) - if self.path.startswith("/api/update/auto"): - state = load_update_state() - state["auto_check"] = bool(payload.get("enable")) - save_update_state(state) - diag_log("info", "Release auto-check toggled", {"enabled": state["auto_check"]}) - return self._send(200, state) - if self.path.startswith("/api/update/channel"): - chan = payload.get("channel", "dev") - if chan not in ("dev", "stable"): - return self._send(400, {"error": "channel must be dev or stable"}) - state = load_update_state() - state["channel"] = chan - save_update_state(state) - diag_log("info", "Release channel set", {"channel": chan}) - return self._send(200, state) - if self.path.startswith("/api/diag/log/level"): - state = _save_diag_state(payload.get("enabled"), payload.get("level")) - diag_log("info", "Diag level updated", state) - return self._send(200, {"state": state}) - if self.path.startswith("/api/diag/log/clear"): - try: - DIAG_LOG_FILE.unlink(missing_ok=True) - except Exception: - pass - diag_log("info", "Diag log cleared") - return self._send(200, {"cleared": True, "state": _load_diag_state()}) - 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 = default_host() - 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)}) - diag_log("info", "Service added", {"name": name, "port": port, "scheme": scheme}) - 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) - diag_log("info", "Service removed", {"port": port}) - 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 = default_host() - 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) - diag_log("info", "Service updated", {"port": target_port, "name": new_name or None, "scheme": scheme}) - 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__": +def parse_args(): parser = argparse.ArgumentParser(description="Pi-Kit API / updater") parser.add_argument("--apply-update", action="store_true", help="Apply latest release (non-HTTP mode)") parser.add_argument("--check-update", action="store_true", help="Check for latest release (non-HTTP mode)") parser.add_argument("--rollback-update", action="store_true", help="Rollback to last backup (non-HTTP mode)") - args = parser.parse_args() + parser.add_argument("--host", default=HOST, help="Bind host (default 127.0.0.1)") + parser.add_argument("--port", type=int, default=PORT, help="Bind port (default 4000)") + return parser.parse_args() + +def main(): + args = parse_args() if args.apply_update: - apply_update_stub() - sys.exit(0) + apply_update() + return if args.check_update: check_for_update() - sys.exit(0) + return if args.rollback_update: - rollback_update_stub() - sys.exit(0) + rollback_update() + return + run_server(host=args.host, port=args.port) - main() + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + sys.exit(0) diff --git a/pikit-prep-spec.md b/pikit-prep-spec.md deleted file mode 100644 index 96b6643..0000000 --- a/pikit-prep-spec.md +++ /dev/null @@ -1,391 +0,0 @@ -# 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-web/assets/css/fonts.css b/pikit-web/assets/css/fonts.css new file mode 100644 index 0000000..518baf3 --- /dev/null +++ b/pikit-web/assets/css/fonts.css @@ -0,0 +1,140 @@ +@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; +} diff --git a/pikit-web/assets/css/forms.css b/pikit-web/assets/css/forms.css new file mode 100644 index 0000000..0862ac2 --- /dev/null +++ b/pikit-web/assets/css/forms.css @@ -0,0 +1,335 @@ +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; + width: 32px; + height: 32px; + padding: 0; + display: grid; + place-items: center; + border-radius: 8px; +} + +.service-menu { + position: absolute; + top: 8px; + right: 8px; + display: flex; + flex-direction: column-reverse; + align-items: flex-end; + gap: 6px; +} + +.service-menu .ghost { + border: 1px solid color-mix(in srgb, var(--border) 60%, var(--text) 20%); + width: 32px; + height: 32px; + padding: 0; + display: grid; + place-items: center; + border-radius: 8px; +} + +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); +} + +.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; +} + +.control-actions.column > .checkbox-row.inline { + margin-top: 6px; +} + +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 { + margin-top: 4px; +} + +.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); +} + +.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; +} diff --git a/pikit-web/assets/css/layout.css b/pikit-web/assets/css/layout.css new file mode 100644 index 0000000..b57ca53 --- /dev/null +++ b/pikit-web/assets/css/layout.css @@ -0,0 +1,379 @@ +.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; +} + +.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: 48px; /* reserve room for stacked 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; +} + +.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)); +} + +.log-card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 10px; + margin-top: 12px; +} + +.log-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 6px; +} + +.log-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.log-actions .icon-btn { + width: 32px; + height: 32px; + padding: 0; + font-size: 0.9rem; + background: var(--card-overlay); + border: 1px solid var(--border); +} + +.log-box { + max-height: 140px; + overflow-y: auto; + background: rgba(0, 0, 0, 0.08); + border-radius: 6px; + padding: 10px; + font-family: "DM Sans", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.9rem; + border: 1px dashed var(--border); + color: var(--muted); + white-space: pre-wrap; +} + +:root[data-theme="light"] .log-box { + background: rgba(12, 18, 32, 0.04); +} + +.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; +} diff --git a/pikit-web/assets/css/modal.css b/pikit-web/assets/css/modal.css new file mode 100644 index 0000000..271a16e --- /dev/null +++ b/pikit-web/assets/css/modal.css @@ -0,0 +1,272 @@ +.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#changelogModal { + z-index: 40; +} + +.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; + transform: translateY(6px) scale(0.99); + transition: + transform 0.18s ease, + box-shadow 0.18s ease; +} + +.modal-card.wide { + max-width: 820px; + width: 90vw; + max-height: 90vh; + overflow-y: auto; + position: relative; + padding: 12px; +} + +.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, +.modal-card.wide .controls { + padding: 0 12px 12px; +} + +.modal-card.wide .control-card { + padding: 12px 14px; +} + +/* Extra breathing room for custom add-service modal */ +#addServiceModal .modal-card { + padding: 18px 18px 16px; +} + +#addServiceModal .controls { + padding: 0 2px 4px; +} + +/* Busy overlay already defined; ensure modal width for release modal */ +#releaseModal .modal-card.wide { + max-width: 760px; +} + +.release-versions { + display: flex; + gap: 16px; + align-items: flex-start; + justify-content: space-between; +} + +.release-versions > div { + flex: 1; + min-width: 0; +} + +.release-versions .align-right { + text-align: right; +} + +.modal-card .status-msg { + overflow-wrap: anywhere; + margin-top: 6px; +} + +.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 { + margin-top: 14px; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; +} + +.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; +} + +.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%; +} + +.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 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-card.wide pre.log-box { + max-height: 60vh; +} + +#releaseModal pre.log-box { + max-height: 220px !important; + min-height: 220px; + overflow-y: auto; +} + +#diagModal pre.log-box { + max-height: 60vh; + min-height: 300px; +} diff --git a/pikit-web/assets/css/motion.css b/pikit-web/assets/css/motion.css new file mode 100644 index 0000000..20f7cd4 --- /dev/null +++ b/pikit-web/assets/css/motion.css @@ -0,0 +1,57 @@ +/* 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; +} diff --git a/pikit-web/assets/css/responsive.css b/pikit-web/assets/css/responsive.css new file mode 100644 index 0000000..1d57fe7 --- /dev/null +++ b/pikit-web/assets/css/responsive.css @@ -0,0 +1,88 @@ +@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)); + } +} + +@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)); + } +} + +@media (max-width: 640px) { + .release-versions { + flex-direction: column; + gap: 8px; + } + .release-versions .align-right { + text-align: left; + } +} + +@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/css/theme.css b/pikit-web/assets/css/theme.css new file mode 100644 index 0000000..6962d29 --- /dev/null +++ b/pikit-web/assets/css/theme.css @@ -0,0 +1,117 @@ +: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; +} diff --git a/pikit-web/assets/css/toast.css b/pikit-web/assets/css/toast.css new file mode 100644 index 0000000..7690342 --- /dev/null +++ b/pikit-web/assets/css/toast.css @@ -0,0 +1,204 @@ +.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; +} diff --git a/pikit-web/assets/css/topbar.css b/pikit-web/assets/css/topbar.css new file mode 100644 index 0000000..7fdc89d --- /dev/null +++ b/pikit-web/assets/css/topbar.css @@ -0,0 +1,126 @@ +.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-system { + color: #3b82f6; + border-color: rgba(59, 130, 246, 0.45); + background: rgba(59, 130, 246, 0.12); +} + +.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-system { + background: rgba(59, 130, 246, 0.16); + border-color: rgba(59, 130, 246, 0.55); + color: #153e9f; +} + +: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; +} diff --git a/pikit-web/assets/css/updates.css b/pikit-web/assets/css/updates.css new file mode 100644 index 0000000..4b92b76 --- /dev/null +++ b/pikit-web/assets/css/updates.css @@ -0,0 +1,152 @@ +#releaseProgress { + display: none; +} + +.updates-status { + display: none; +} + +.updates-status.error { + display: block; +} + +#acc-updates .accordion-body { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px 12px 2px !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; +} diff --git a/pikit-web/assets/diaglog.js b/pikit-web/assets/diaglog.js index 77441e5..329f354 100644 --- a/pikit-web/assets/diaglog.js +++ b/pikit-web/assets/diaglog.js @@ -93,22 +93,22 @@ export async function initDiagUI({ elements, toast }) { if (statusEl) statusEl.textContent = `${merged.length} entries`; } - async function refresh() { + async function refresh({ silent = false } = {}) { if (loading) return; setBusy(true); try { const entries = await syncState(); render(entries); - toast?.("Diagnostics refreshed", "success"); + if (!silent) toast?.("Diagnostics refreshed", "success"); } catch (e) { - toast?.(e.error || "Failed to load diagnostics", "error"); + if (!silent) toast?.(e.error || "Failed to load diagnostics", "error"); // retry once if failed try { const entries = await syncState(); render(entries); - toast?.("Diagnostics refreshed (after retry)", "success"); + if (!silent) toast?.("Diagnostics refreshed (after retry)", "success"); } catch (err2) { - toast?.(err2.error || "Diagnostics still failing", "error"); + if (!silent) toast?.(err2.error || "Diagnostics still failing", "error"); } } finally { setBusy(false); @@ -207,7 +207,7 @@ export async function initDiagUI({ elements, toast }) { // initial load attachClickTracker(); - await refresh(); + await refresh({ silent: true }); logButton?.addEventListener("click", () => { if (!uiEnabled) return; diff --git a/pikit-web/assets/main.js b/pikit-web/assets/main.js index d4659e2..e33f455 100644 --- a/pikit-web/assets/main.js +++ b/pikit-web/assets/main.js @@ -1,12 +1,21 @@ // Entry point for the dashboard: wires UI events, pulls status, and initializes // feature modules (services, settings, stats). import { getStatus, triggerReset } from "./api.js"; +import { initServiceControls } from "./services.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"; import { initReleaseUI } from "./releases.js?v=20251213h"; import { initDiagUI, logUi } from "./diaglog.js?v=20251213j"; +import { createToastManager } from "./toast.js?v=20251213a"; +import { + applyTooltips, + wireModalPairs, + wireAccordions, + createBusyOverlay, + createConfirmModal, +} from "./ui.js"; +import { createStatusController } from "./status-controller.js"; const servicesGrid = document.getElementById("servicesGrid"); const heroStats = document.getElementById("heroStats"); @@ -32,6 +41,7 @@ const toastPosSelect = document.getElementById("toastPosSelect"); const toastAnimSelect = document.getElementById("toastAnimSelect"); const toastSpeedInput = document.getElementById("toastSpeedInput"); const toastDurationInput = document.getElementById("toastDurationInput"); +const toastTestBtn = document.getElementById("toastTestBtn"); const fontSelect = document.getElementById("fontSelect"); const updatesScope = document.getElementById("updatesScope"); const updateTimeInput = document.getElementById("updateTimeInput"); @@ -111,194 +121,141 @@ const diagModal = document.getElementById("diagModal"); const diagClose = document.getElementById("diagClose"); const diagStatusModal = document.getElementById("diagStatusModal"); -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"; +const toastController = createToastManager({ + container: toastContainer, + posSelect: toastPosSelect, + animSelect: toastAnimSelect, + speedInput: toastSpeedInput, + durationInput: toastDurationInput, + fontSelect, + testBtn: toastTestBtn, +}); +const showToast = toastController.showToast; let releaseUI = null; -let lastStatusData = null; +const { showBusy, hideBusy } = createBusyOverlay({ + overlay: busyOverlay, + titleEl: busyTitle, + textEl: busyText, +}); +const confirmAction = createConfirmModal({ + modal: confirmModal, + titleEl: confirmTitle, + bodyEl: confirmBody, + okBtn: confirmOk, + cancelBtn: confirmCancel, +}); +const collapseAccordions = () => document.querySelectorAll(".accordion").forEach((a) => a.classList.remove("open")); -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; -} +const statusController = createStatusController({ + heroStats, + servicesGrid, + updatesFlagTop, + updatesNoteTop, + tempFlagTop, + readyOverlay, + logUi, + getStatus, + isUpdatesDirty, + setUpdatesUI, + updatesFlagEl: setUpdatesFlag, + releaseUIGetter: () => releaseUI, + onReadyWait: () => setTimeout(() => statusController.loadStatus(), 3000), +}); +const { loadStatus } = statusController; -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; +function wireDialogs() { + wireModalPairs([ + { openBtn: aboutBtn, modal: aboutModal, closeBtn: aboutClose }, + { openBtn: helpBtn, modal: helpModal, closeBtn: helpClose }, + ]); + // Settings modal keeps custom accordion collapse on close + advBtn?.addEventListener("click", () => { + if (window.__pikitTest?.forceServiceFormVisible) { + // For tests: avoid opening any modal; just ensure form controls are visible + addServiceModal?.classList.add("hidden"); + addServiceModal?.setAttribute("style", "display:none;"); + window.__pikitTest.forceServiceFormVisible(); + return; } - 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.", - releaseFlagTop: "Pi-Kit release status; open Settings → Updates to install.", - 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; + advModal.classList.remove("hidden"); + }); + advClose?.addEventListener("click", () => { + advModal.classList.add("hidden"); + collapseAccordions(); + }); + 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"); }); } +// Testing hook +if (typeof window !== "undefined") { + window.__pikitTest = window.__pikitTest || {}; + window.__pikitTest.showBusy = showBusy; + window.__pikitTest.hideBusy = hideBusy; + window.__pikitTest.exposeServiceForm = () => { + if (!addServiceModal) return; + const card = addServiceModal.querySelector(".modal-card"); + if (!card) return; + addServiceModal.classList.add("hidden"); // keep overlay out of the way + card.style.position = "static"; + card.style.background = "transparent"; + card.style.boxShadow = "none"; + card.style.border = "none"; + card.style.padding = "0"; + card.style.margin = "12px auto"; + card.style.maxWidth = "720px"; + // Move the form inline so Playwright can see it without the overlay + document.body.appendChild(card); + }; +} + +const TOOLTIP_MAP = { + 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.", + releaseFlagTop: "Pi-Kit release status; open Settings → Updates to install.", + 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", +}; + // Clamp name inputs to 30 chars [svcName, menuRename].forEach((el) => { if (!el) return; @@ -316,79 +273,7 @@ function setUpdatesUI(enabled) { updatesStatus.classList.toggle("chip-off", !on); } -async function loadStatus() { - try { - const data = await getStatus(); - lastStatusData = data; - 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); - } - } - // Pull Pi-Kit release status after core status - releaseUI?.refreshStatus(); - } catch (e) { - console.error(e); - logUi(`Status refresh failed: ${e?.message || e}`, "error"); - if (!lastStatusData) { - renderStats(heroStats, placeholderStatus); - } - setTimeout(loadStatus, 2000); - } -} - -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) { +function setUpdatesFlag(enabled) { if (!updatesFlagTop) return; const labelOn = "System updates: On"; const labelOff = "System updates: Off"; @@ -398,61 +283,6 @@ function updatesFlagEl(enabled) { 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"); -} - -function confirmAction(title, body) { - return new Promise((resolve) => { - if (!confirmModal) { - const ok = window.confirm(body || title || "Are you sure?"); - resolve(ok); - return; - } - confirmTitle.textContent = title || "Are you sure?"; - confirmBody.textContent = body || ""; - confirmModal.classList.remove("hidden"); - const done = (val) => { - confirmModal.classList.add("hidden"); - resolve(val); - }; - const okHandler = () => done(true); - const cancelHandler = () => done(false); - confirmOk.onclick = okHandler; - confirmCancel.onclick = cancelHandler; - }); -} - -// 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; @@ -471,31 +301,6 @@ function wireResetAndUpdates() { }); } -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(); @@ -505,10 +310,17 @@ if (typeof window !== "undefined") { } function main() { - applyTooltips(); - wireModals(); + applyTooltips(TOOLTIP_MAP); + // Test convenience: ensure service form elements are visible when hook is set + if (window.__pikitTest?.forceServiceFormVisible) { + window.__pikitTest.forceServiceFormVisible(); + window.__pikitTest.exposeServiceForm?.(); + } + wireDialogs(); wireResetAndUpdates(); - wireAccordions(); + wireAccordions({ + forceOpen: typeof window !== "undefined" && window.__pikitTest?.forceAccordionsOpen, + }); releaseUI = initReleaseUI({ showToast, showBusy, @@ -516,14 +328,6 @@ function main() { confirmAction, logUi, }); - loadToastSettings(); - - if (advClose) { - advClose.onclick = () => { - advModal.classList.add("hidden"); - collapseAccordions(); - }; - } initServiceControls({ gridEl: servicesGrid, @@ -598,98 +402,6 @@ function main() { console.error("Diag init failed", e); }); - // 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, diff --git a/pikit-web/assets/releases-utils.js b/pikit-web/assets/releases-utils.js new file mode 100644 index 0000000..8b9328f --- /dev/null +++ b/pikit-web/assets/releases-utils.js @@ -0,0 +1,38 @@ +export function shorten(text, max = 90) { + if (!text || typeof text !== "string") return text; + return text.length > max ? `${text.slice(0, max - 3)}...` : text; +} + +export function createReleaseLogger(logUi = () => {}) { + let lines = []; + let lastMessage = null; + const state = { el: null }; + + function render() { + if (state.el) { + state.el.textContent = lines.join("\n"); + state.el.scrollTop = 0; + } + } + + function log(msg) { + if (!msg) return; + const plain = msg.trim(); + if (plain === lastMessage) return; + lastMessage = plain; + const ts = new Date().toLocaleTimeString(); + const line = `${ts} ${msg}`; + lines.unshift(line); + lines = lines.slice(0, 120); + render(); + const lvl = msg.toLowerCase().startsWith("error") ? "error" : "info"; + logUi(`Update: ${msg}`, lvl); + } + + function attach(el) { + state.el = el; + render(); + } + + return { log, attach, getLines: () => lines.slice() }; +} diff --git a/pikit-web/assets/releases.js b/pikit-web/assets/releases.js index d652fa1..e755f2f 100644 --- a/pikit-web/assets/releases.js +++ b/pikit-web/assets/releases.js @@ -9,11 +9,7 @@ import { setReleaseAutoCheck, setReleaseChannel, } from "./api.js"; - -function shorten(text, max = 90) { - if (!text || typeof text !== "string") return text; - return text.length > max ? `${text.slice(0, max - 3)}...` : text; -} +import { shorten, createReleaseLogger } from "./releases-utils.js"; export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, logUi = () => {} }) { const releaseFlagTop = document.getElementById("releaseFlagTop"); @@ -42,32 +38,14 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo const changelogClose = document.getElementById("changelogClose"); let releaseBusyActive = false; - let releaseLogLines = []; let releaseLastFetched = 0; let lastReleaseLogKey = ""; let lastReleaseToastKey = null; - let lastLogMessage = null; let changelogCache = { version: null, text: "" }; let lastChangelogUrl = null; let releaseChannel = "dev"; - - function logRelease(msg) { - if (!msg) return; - const plain = msg.trim(); - if (plain === lastLogMessage) return; - lastLogMessage = plain; - const ts = new Date().toLocaleTimeString(); - const line = `${ts} ${msg}`; - releaseLogLines.unshift(line); - releaseLogLines = releaseLogLines.slice(0, 120); - if (releaseLog) { - releaseLog.textContent = releaseLogLines.join("\n"); - releaseLog.scrollTop = 0; // keep most recent in view - } - // Mirror into global diagnostics log (frontend side) - const lvl = msg.toLowerCase().startsWith("error") ? "error" : "info"; - logUi(`Update: ${msg}`, lvl); - } + const logger = createReleaseLogger(logUi); + logger.attach(releaseLog); function setReleaseChip(state) { if (!releaseFlagTop) return; @@ -136,6 +114,8 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo } } + const logRelease = logger.log; + async function loadReleaseStatus(force = false) { if (!releaseFlagTop) return; const now = Date.now(); diff --git a/pikit-web/assets/services-helpers.js b/pikit-web/assets/services-helpers.js new file mode 100644 index 0000000..2df3e2e --- /dev/null +++ b/pikit-web/assets/services-helpers.js @@ -0,0 +1,37 @@ +export const DEFAULT_SELF_SIGNED_MSG = + "This service uses a self-signed certificate. Expect a browser warning; proceed only if you trust this device."; + +export 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; + } +} + +export 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}`; +} + +export function validateServiceFields({ name, port, path, notice, notice_link }, fail) { + const err = (m) => { + fail?.(m); + return false; + }; + if (!name || name.trim().length < 2) return err("Name must be at least 2 characters."); + if (name.length > 48) return err("Name is too long (max 48 chars)."); + if (!Number.isInteger(port) || port < 1 || port > 65535) return err("Port must be 1-65535."); + if (path === null) return err("Path must be relative (e.g. /admin) or blank."); + if (path.length > 200) return err("Path is too long (max 200 chars)."); + if (notice && notice.length > 180) return err("Notice text too long (max 180 chars)."); + if (notice_link && notice_link.length > 200) return err("Notice link too long (max 200 chars)."); + if (!isValidLink(notice_link)) return err("Enter a valid URL (http/https) or leave blank."); + return true; +} diff --git a/pikit-web/assets/services.js b/pikit-web/assets/services.js index 291b788..d76af1d 100644 --- a/pikit-web/assets/services.js +++ b/pikit-web/assets/services.js @@ -1,48 +1,16 @@ import { addService, updateService, removeService } from "./api.js"; +import { logUi } from "./diaglog.js"; +import { + DEFAULT_SELF_SIGNED_MSG, + isValidLink, + normalizePath, + validateServiceFields, +} from "./services-helpers.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"); @@ -264,6 +232,7 @@ export function initServiceControls({ gridEl, menu, addForm, onChange, overlay, async function menuAction(action, body = {}) { if (!menuContext) return; msg.textContent = ""; + const original = { ...menuContext }; try { const isRemove = action === "remove"; const isSave = action === "save"; @@ -285,6 +254,17 @@ export function initServiceControls({ gridEl, menu, addForm, onChange, overlay, } msg.textContent = ""; toast?.(isRemove ? "Service removed" : "Service saved", "success"); + logUi(isRemove ? "Service removed" : "Service updated", "info", { + name: body.name || original.name, + port_from: original.port, + port_to: body.new_port || original.port, + scheme_from: original.scheme, + scheme_to: body.scheme || original.scheme, + path_from: original.path, + path_to: body.path ?? original.path, + notice_changed: body.notice !== undefined, + self_signed: body.self_signed, + }); modal?.classList.add("hidden"); menuContext = null; await onChange?.(); @@ -292,6 +272,12 @@ export function initServiceControls({ gridEl, menu, addForm, onChange, overlay, const err = e.error || "Action failed."; msg.textContent = ""; toast?.(err, "error"); + logUi("Service update failed", "error", { + action, + name: body.name || original.name, + port: original.port, + reason: err, + }); } finally { hideBusy(); } @@ -310,8 +296,7 @@ export function initServiceControls({ gridEl, menu, addForm, onChange, overlay, if ( !validateServiceFields( { name, port: new_port, path, notice, notice_link }, - () => {}, - toast, + (m) => toast?.(m, "error"), ) ) return; @@ -335,11 +320,7 @@ export function initServiceControls({ gridEl, menu, addForm, onChange, overlay, const notice_link = (addNoticeLinkInput?.value || "").trim(); const self_signed = !!addSelfSignedInput?.checked; if ( - !validateServiceFields( - { name, port, path, notice, notice_link }, - () => {}, - toast, - ) + !validateServiceFields({ name, port, path, notice, notice_link }, (m) => toast?.(m, "error")) ) return; addBtn.disabled = true; @@ -348,11 +329,21 @@ export function initServiceControls({ gridEl, menu, addForm, onChange, overlay, await addService({ name, port, scheme, path, notice, notice_link, self_signed }); addMsg.textContent = ""; toast?.("Service added", "success"); + logUi("Service added", "info", { + name, + port, + scheme, + path, + notice: !!notice, + notice_link: !!notice_link, + self_signed, + }); await onChange?.(); } catch (e) { const err = e.error || "Failed to add."; addMsg.textContent = ""; toast?.(err, "error"); + logUi("Service add failed", "error", { name, port, scheme, reason: err }); } finally { addBtn.disabled = false; hideBusy(); diff --git a/pikit-web/assets/status-controller.js b/pikit-web/assets/status-controller.js new file mode 100644 index 0000000..bb1ade0 --- /dev/null +++ b/pikit-web/assets/status-controller.js @@ -0,0 +1,100 @@ +// Status polling and UI flag helpers +import { placeholderStatus, renderStats } from "./status.js"; +import { renderServices } from "./services.js"; + +export function createStatusController({ + heroStats, + servicesGrid, + updatesFlagTop, + updatesNoteTop, + tempFlagTop, + readyOverlay, + logUi, + showToast = () => {}, + onReadyWait = null, + getStatus, + isUpdatesDirty, + releaseUIGetter = () => null, + setUpdatesUI = null, + updatesFlagEl = null, +}) { + let lastStatusData = null; + + 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; + const labelOn = "System updates: On"; + const labelOff = "System updates: Off"; + updatesFlagTop.textContent = + enabled === true ? labelOn : enabled === false ? labelOff : "System updates"; + updatesFlagTop.className = "status-chip quiet chip-system"; + if (enabled === false) updatesFlagTop.classList.add("chip-off"); + } + + async function loadStatus() { + try { + const data = await getStatus(); + lastStatusData = data; + renderStats(heroStats, data); + renderServices(servicesGrid, data.services, { openAddService: window.__pikitOpenAddService }); + const updatesEnabled = data?.auto_updates?.enabled ?? data.auto_updates_enabled; + if (updatesEnabled !== undefined && !isUpdatesDirty()) { + setUpdatesUI?.(updatesEnabled); + } + 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"); + onReadyWait?.(); + } + } + releaseUIGetter()?.refreshStatus(); + } catch (e) { + console.error(e); + logUi?.(`Status refresh failed: ${e?.message || e}`, "error"); + if (!lastStatusData) { + renderStats(heroStats, placeholderStatus); + } + setTimeout(loadStatus, 2000); + } + } + + return { loadStatus, setTempFlag, updatesFlagEl }; +} diff --git a/pikit-web/assets/style.css b/pikit-web/assets/style.css index 2c40b38..670db75 100644 --- a/pikit-web/assets/style.css +++ b/pikit-web/assets/style.css @@ -1,1699 +1,10 @@ -@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-system { - color: #3b82f6; - border-color: rgba(59, 130, 246, 0.45); - background: rgba(59, 130, 246, 0.12); -} -.status-chip.chip-system { - color: #3b82f6; - border-color: rgba(59, 130, 246, 0.45); - background: rgba(59, 130, 246, 0.12); -} -.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-system { - background: rgba(59, 130, 246, 0.16); - border-color: rgba(59, 130, 246, 0.55); - color: #153e9f; -} -: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; -} -:root[data-theme="light"] .status-chip.chip-system { - background: rgba(59, 130, 246, 0.16); - border-color: rgba(59, 130, 246, 0.55); - color: #153e9f; -} -.log-card { - background: var(--panel); - border: 1px solid var(--border); - border-radius: 10px; - padding: 10px; - margin-top: 12px; -} -.log-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 6px; -} -.log-actions { - display: flex; - align-items: center; - gap: 8px; -} -.log-actions .icon-btn { - width: 32px; - height: 32px; - padding: 0; - font-size: 0.9rem; - background: var(--card-overlay); - border: 1px solid var(--border); -} -.log-box { - max-height: 140px; - overflow-y: auto; - background: rgba(0, 0, 0, 0.08); - border-radius: 6px; - padding: 10px; - font-family: "DM Sans", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 0.9rem; - border: 1px dashed var(--border); - color: var(--muted); - white-space: pre-wrap; -} -.modal-card.wide pre.log-box { - max-height: 60vh; -} -#releaseModal pre.log-box { - max-height: 220px !important; - min-height: 220px; - overflow-y: auto; -} -#diagModal pre.log-box { - max-height: 60vh; - min-height: 300px; -} -#releaseProgress { - display: none; -} -.updates-status { - display: none; -} -.updates-status.error { - display: block; -} -:root[data-theme="light"] .log-box { - background: rgba(12, 18, 32, 0.04); -} -.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#changelogModal { - z-index: 40; -} -.modal#changelogModal { - z-index: 40; -} -.modal#changelogModal { - z-index: 30; -} -.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: 12px; -} -.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, -.modal-card.wide .controls { - padding: 0 12px 12px; -} -.modal-card.wide .control-card { - padding: 12px 14px; -} - -/* Extra breathing room for custom add-service modal */ -#addServiceModal .modal-card { - padding: 18px 18px 16px; -} -#addServiceModal .controls { - padding: 0 2px 4px; -} - -/* Busy overlay already defined; ensure modal width for release modal */ -#releaseModal .modal-card.wide { - max-width: 760px; -} -.release-versions { - display: flex; - gap: 16px; - align-items: flex-start; - justify-content: space-between; -} -.release-versions > div { - flex: 1; - min-width: 0; -} -.release-versions .align-right { - text-align: right; -} -@media (max-width: 640px) { - .release-versions { - flex-direction: column; - gap: 8px; - } - .release-versions .align-right { - text-align: left; - } -} -.modal-card .status-msg { - overflow-wrap: anywhere; -} -.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)); - } -} +@import "./css/fonts.css"; +@import "./css/theme.css"; +@import "./css/topbar.css"; +@import "./css/layout.css"; +@import "./css/forms.css"; +@import "./css/updates.css"; +@import "./css/modal.css"; +@import "./css/toast.css"; +@import "./css/motion.css"; +@import "./css/responsive.css"; diff --git a/pikit-web/assets/toast.js b/pikit-web/assets/toast.js new file mode 100644 index 0000000..18788da --- /dev/null +++ b/pikit-web/assets/toast.js @@ -0,0 +1,258 @@ +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"; + +export const ALLOWED_TOAST_POS = [ + "bottom-center", + "bottom-right", + "bottom-left", + "top-right", + "top-left", + "top-center", +]; +export const ALLOWED_TOAST_ANIM = ["slide-in", "fade", "pop", "bounce", "drop", "grow"]; +export const ALLOWED_FONTS = ["redhat", "space", "manrope", "dmsans", "sora", "chivo", "atkinson", "plex"]; + +const clamp = (val, min, max) => Math.min(max, Math.max(min, val)); + +export function createToastManager({ + container, + posSelect, + animSelect, + speedInput, + durationInput, + fontSelect, + testBtn, +} = {}) { + const state = { + position: "bottom-center", + animation: "slide-in", + durationMs: 5000, + speedMs: 300, + font: "redhat", + }; + + function applyToastSettings() { + if (!container) return; + container.className = `toast-container pos-${state.position}`; + document.documentElement.style.setProperty("--toast-speed", `${state.speedMs}ms`); + const dir = state.position.startsWith("top") ? -1 : 1; + const isLeft = state.position.includes("left"); + const isRight = state.position.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 (durationInput) durationInput.value = state.durationMs; + } + + function applyFontSetting() { + document.documentElement.setAttribute("data-font", state.font); + if (fontSelect) fontSelect.value = state.font; + } + + function persistSettings() { + try { + localStorage.setItem(TOAST_POS_KEY, state.position); + localStorage.setItem(TOAST_ANIM_KEY, state.animation); + localStorage.setItem(TOAST_SPEED_KEY, String(state.speedMs)); + localStorage.setItem(TOAST_DURATION_KEY, String(state.durationMs)); + localStorage.setItem(FONT_KEY, state.font); + } catch (e) { + console.warn("Toast settings save failed", e); + } + } + + function loadFromStorage() { + try { + const posSaved = localStorage.getItem(TOAST_POS_KEY); + if (ALLOWED_TOAST_POS.includes(posSaved)) state.position = 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)) state.animation = migrated; + const savedSpeed = Number(localStorage.getItem(TOAST_SPEED_KEY)); + if (!Number.isNaN(savedSpeed) && savedSpeed >= 100 && savedSpeed <= 3000) { + state.speedMs = savedSpeed; + } + const savedDur = Number(localStorage.getItem(TOAST_DURATION_KEY)); + if (!Number.isNaN(savedDur) && savedDur >= 1000 && savedDur <= 15000) { + state.durationMs = savedDur; + } + const savedFont = localStorage.getItem(FONT_KEY); + if (ALLOWED_FONTS.includes(savedFont)) state.font = savedFont; + } catch (e) { + console.warn("Toast settings load failed", e); + } + if (posSelect) posSelect.value = state.position; + if (animSelect) animSelect.value = state.animation; + if (speedInput) speedInput.value = state.speedMs; + if (durationInput) durationInput.value = state.durationMs; + if (fontSelect) fontSelect.value = state.font; + applyToastSettings(); + applyFontSetting(); + } + + function showToast(message, type = "info") { + if (!container || !message) return; + const t = document.createElement("div"); + t.className = `toast ${type} anim-${state.animation}`; + t.textContent = message; + container.appendChild(t); + const animOn = document.documentElement.getAttribute("data-anim") !== "off"; + if (!animOn) { + t.classList.add("show"); + } else { + requestAnimationFrame(() => t.classList.add("show")); + } + const duration = state.durationMs; + setTimeout(() => { + const all = Array.from(container.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"); + 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 ? state.speedMs : 0; + setTimeout(() => { + t.classList.remove("show"); + t.remove(); + others.forEach((el) => (el.style.transition = "")); + }, removeDelay); + }, duration); + } + + function wireControls() { + posSelect?.addEventListener("change", () => { + const val = posSelect.value; + if (ALLOWED_TOAST_POS.includes(val)) { + state.position = val; + applyToastSettings(); + persistSettings(); + } else { + posSelect.value = state.position; + showToast("Invalid toast position", "error"); + } + }); + + animSelect?.addEventListener("change", () => { + let val = animSelect.value; + if (val === "slide-up" || val === "slide-left" || val === "slide-right") val = "slide-in"; + if (ALLOWED_TOAST_ANIM.includes(val)) { + state.animation = val; + persistSettings(); + } else { + animSelect.value = state.animation; + showToast("Invalid toast animation", "error"); + } + }); + + const clampSpeed = (val) => clamp(val, 100, 3000); + const clampDuration = (val) => clamp(val, 1000, 15000); + + speedInput?.addEventListener("input", () => { + const raw = speedInput.value; + if (raw === "") return; + const val = Number(raw); + if (Number.isNaN(val) || val < 100 || val > 3000) return; + state.speedMs = val; + applyToastSettings(); + persistSettings(); + }); + speedInput?.addEventListener("blur", () => { + const raw = speedInput.value; + if (raw === "") { + speedInput.value = state.speedMs; + return; + } + const val = Number(raw); + if (Number.isNaN(val) || val < 100 || val > 3000) { + state.speedMs = clampSpeed(state.speedMs); + speedInput.value = state.speedMs; + showToast("Toast speed must be 100-3000 ms", "error"); + return; + } + state.speedMs = val; + speedInput.value = state.speedMs; + applyToastSettings(); + persistSettings(); + }); + + durationInput?.addEventListener("input", () => { + const raw = durationInput.value; + if (raw === "") return; + const val = Number(raw); + if (Number.isNaN(val) || val < 1000 || val > 15000) return; + state.durationMs = val; + persistSettings(); + }); + durationInput?.addEventListener("blur", () => { + const raw = durationInput.value; + if (raw === "") { + durationInput.value = state.durationMs; + return; + } + const val = Number(raw); + if (Number.isNaN(val) || val < 1000 || val > 15000) { + state.durationMs = clampDuration(state.durationMs); + durationInput.value = state.durationMs; + showToast("Toast duration must be 1000-15000 ms", "error"); + return; + } + state.durationMs = val; + durationInput.value = state.durationMs; + persistSettings(); + }); + + fontSelect?.addEventListener("change", () => { + const val = fontSelect.value; + if (!ALLOWED_FONTS.includes(val)) { + fontSelect.value = state.font; + showToast("Invalid font choice", "error"); + return; + } + state.font = val; + applyFontSetting(); + persistSettings(); + }); + + testBtn?.addEventListener("click", () => showToast("This is a test toast", "info")); + } + + loadFromStorage(); + wireControls(); + + return { + state, + showToast, + applyToastSettings, + applyFontSetting, + persistSettings, + loadFromStorage, + }; +} diff --git a/pikit-web/assets/ui.js b/pikit-web/assets/ui.js new file mode 100644 index 0000000..fcdbee9 --- /dev/null +++ b/pikit-web/assets/ui.js @@ -0,0 +1,80 @@ +// Small UI helpers to keep main.js lean and declarative. + +export function applyTooltips(map = {}) { + Object.entries(map).forEach(([id, text]) => { + const el = document.getElementById(id); + if (el && text) el.title = text; + }); +} + +export function wireModalPairs(pairs = []) { + pairs.forEach(({ openBtn, modal, closeBtn }) => { + if (!modal) return; + openBtn?.addEventListener("click", () => modal.classList.remove("hidden")); + closeBtn?.addEventListener("click", () => modal.classList.add("hidden")); + modal.addEventListener("click", (e) => { + if (e.target === modal) modal.classList.add("hidden"); + }); + }); +} + +export function wireAccordions({ + toggleSelector = ".accordion-toggle", + accordionSelector = ".accordion", + forceOpen = false, +} = {}) { + const accordions = Array.from(document.querySelectorAll(accordionSelector)); + const toggles = Array.from(document.querySelectorAll(toggleSelector)); + if (forceOpen) { + accordions.forEach((a) => a.classList.add("open")); + return; + } + toggles.forEach((btn) => { + btn.addEventListener("click", () => { + const acc = btn.closest(accordionSelector); + if (!acc) return; + if (acc.classList.contains("open")) { + acc.classList.remove("open"); + } else { + accordions.forEach((a) => a.classList.remove("open")); + acc.classList.add("open"); + } + }); + }); +} + +export function createBusyOverlay({ overlay, titleEl, textEl }) { + const showBusy = (title = "Working…", text = "This may take a few seconds.") => { + if (!overlay) return; + if (titleEl) titleEl.textContent = title; + if (textEl) { + textEl.textContent = text || ""; + textEl.classList.toggle("hidden", !text); + } + overlay.classList.remove("hidden"); + }; + const hideBusy = () => overlay?.classList.add("hidden"); + return { showBusy, hideBusy }; +} + +export function createConfirmModal({ modal, titleEl, bodyEl, okBtn, cancelBtn }) { + const confirmAction = (title, body) => + new Promise((resolve) => { + if (!modal) { + resolve(window.confirm(body || title || "Are you sure?")); + return; + } + if (titleEl) titleEl.textContent = title || "Are you sure?"; + if (bodyEl) bodyEl.textContent = body || ""; + modal.classList.remove("hidden"); + const done = (val) => { + modal.classList.add("hidden"); + resolve(val); + }; + const okHandler = () => done(true); + const cancelHandler = () => done(false); + okBtn?.addEventListener("click", okHandler, { once: true }); + cancelBtn?.addEventListener("click", cancelHandler, { once: true }); + }); + return confirmAction; +} diff --git a/pikit-web/assets/update-settings.js b/pikit-web/assets/update-settings.js index 73ec284..a2f4995 100644 --- a/pikit-web/assets/update-settings.js +++ b/pikit-web/assets/update-settings.js @@ -1,6 +1,7 @@ // UI controller for unattended-upgrades settings. // Fetches current config, mirrors it into the form, and saves changes. import { getUpdateConfig, saveUpdateConfig } from "./api.js"; +import { logUi } from "./diaglog.js"; const TIME_RE = /^(\d{1,2}):(\d{2})$/; const MAX_BANDWIDTH_KBPS = 1_000_000; // 1 GB/s cap to prevent typos @@ -115,15 +116,14 @@ export function initUpdateSettings({ function showMessage(text, isError = false) { if (!msgEl) return; - // Only surface inline text for errors; successes go to toast only. if (isError) { msgEl.textContent = text || "Something went wrong"; msgEl.classList.add("error"); + toast?.(text || "Error", "error"); } else { - msgEl.textContent = ""; + msgEl.textContent = text || ""; msgEl.classList.remove("error"); } - if (toast) toast(text || (isError ? "Error" : ""), isError ? "error" : "success"); } function currentConfigFromForm() { @@ -246,6 +246,7 @@ export function initUpdateSettings({ showMessage(""); try { + const prev = lastConfig ? { ...lastConfig } : null; const payload = buildPayload(); if (overrideEnable !== null) payload.enable = !!overrideEnable; @@ -257,6 +258,7 @@ export function initUpdateSettings({ showMessage("Update settings saved."); toast?.("Updates saved", "success"); + logUi("Update settings saved", "info", { from: prev, to: payload }); onAfterSave?.(); @@ -273,6 +275,16 @@ export function initUpdateSettings({ } showMessage(e?.error || e?.message || "Save failed", true); + logUi("Update settings save failed", "error", { + payload: (() => { + try { + return buildPayload(); + } catch { + return null; + } + })(), + reason: e?.error || e?.message, + }); } finally { saving = false; diff --git a/pikit-web/data/version.json b/pikit-web/data/version.json index 87eed28..3c5c6ef 100644 --- a/pikit-web/data/version.json +++ b/pikit-web/data/version.json @@ -1,3 +1,3 @@ { - "version": "0.1.0-dev" + "version": "0.1.2" } diff --git a/pikit-web/index.html b/pikit-web/index.html index cfcb1f9..d927e12 100644 --- a/pikit-web/index.html +++ b/pikit-web/index.html @@ -4,6 +4,7 @@ Pi-Kit Dashboard + diff --git a/pikit-web/public/favicon.ico b/pikit-web/public/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/pikit_api/__init__.py b/pikit_api/__init__.py new file mode 100644 index 0000000..5e6079c --- /dev/null +++ b/pikit_api/__init__.py @@ -0,0 +1,11 @@ +""" +Pi-Kit API package + +This package splits the monolithic `pikit-api.py` script into small, testable +modules while keeping the on-device entry point compatible. +""" + +# Re-export commonly used helpers for convenience +from .constants import HOST, PORT # noqa: F401 +from .server import run_server # noqa: F401 +from .releases import apply_update, check_for_update, rollback_update # noqa: F401 diff --git a/pikit_api/auto_updates.py b/pikit_api/auto_updates.py new file mode 100644 index 0000000..1a5df3a --- /dev/null +++ b/pikit_api/auto_updates.py @@ -0,0 +1,239 @@ +import pathlib +import re +import subprocess +from typing import Any, Dict, Optional + +from .constants import ( + ALL_PATTERNS, + APT_AUTO_CFG, + APT_UA_BASE, + APT_UA_OVERRIDE, + DEFAULT_UPDATE_TIME, + DEFAULT_UPGRADE_TIME, + SECURITY_PATTERNS, +) +from .helpers import strip_comments, validate_time + + +def auto_updates_enabled() -> bool: + 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) -> None: + """ + 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) + 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' + ) + 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() -> Dict[str, Any]: + config_on = auto_updates_enabled() + service = "unattended-upgrades.service" + timers = ["apt-daily.timer", "apt-daily-upgrade.timer"] + state: Dict[str, Any] = { + "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") + state["enabled"] = ( + config_on + and state["service_enabled"] + and all(state["timers_enabled"].values()) + ) + return state + + +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 + ) + 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 read_updates_config(state=None) -> Dict[str, Any]: + """ + 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[str, Any]) -> Dict[str, Any]: + """ + 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") + + _write_timer_override("apt-daily.timer", update_time) + _write_timer_override("apt-daily-upgrade.timer", upgrade_time) + return read_updates_config() diff --git a/pikit_api/constants.py b/pikit_api/constants.py new file mode 100644 index 0000000..580dd88 --- /dev/null +++ b/pikit_api/constants.py @@ -0,0 +1,66 @@ +import os +import pathlib + +# Network +HOST = "127.0.0.1" +PORT = 4000 + +# Paths / files +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") +READY_FILE = pathlib.Path("/var/run/pikit-ready") +VERSION_FILE = pathlib.Path("/etc/pikit/version") +WEB_VERSION_FILE = pathlib.Path("/var/www/pikit-web/data/version.json") +UPDATE_STATE_DIR = pathlib.Path("/var/lib/pikit-update") +UPDATE_STATE = UPDATE_STATE_DIR / "state.json" +UPDATE_LOCK = pathlib.Path("/var/run/pikit-update.lock") +WEB_ROOT = pathlib.Path("/var/www/pikit-web") +API_PATH = pathlib.Path("/usr/local/bin/pikit-api.py") +API_DIR = API_PATH.parent +API_PACKAGE_DIR = API_DIR / "pikit_api" +BACKUP_ROOT = pathlib.Path("/var/backups/pikit") +TMP_ROOT = pathlib.Path("/var/tmp/pikit-update") + +# Apt / unattended-upgrades +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, +] + +# Release updater +DEFAULT_MANIFEST_URL = os.environ.get( + "PIKIT_MANIFEST_URL", + "https://git.44r0n.cc/44r0n7/pi-kit/releases/latest/download/manifest.json", +) +AUTH_TOKEN = os.environ.get("PIKIT_AUTH_TOKEN") + +# Flags / ports +DEBUG_FLAG = pathlib.Path("/boot/pikit-debug").exists() +HTTPS_PORTS = {443, 5252} +CORE_PORTS = {80} +CORE_NAME = "Pi-Kit Dashboard" + +# Diagnostics (RAM backed where available) +DIAG_STATE_FILE = ( + pathlib.Path("/dev/shm/pikit-diag.state") + if pathlib.Path("/dev/shm").exists() + else pathlib.Path("/tmp/pikit-diag.state") +) +DIAG_LOG_FILE = ( + pathlib.Path("/dev/shm/pikit-diag.log") + if pathlib.Path("/dev/shm").exists() + else pathlib.Path("/tmp/pikit-diag.log") +) +DIAG_MAX_BYTES = 1_048_576 # 1 MiB cap in RAM +DIAG_MAX_ENTRY_CHARS = 2048 +DIAG_DEFAULT_STATE = {"enabled": False, "level": "normal"} # level: normal|debug diff --git a/pikit_api/diagnostics.py b/pikit_api/diagnostics.py new file mode 100644 index 0000000..257f43c --- /dev/null +++ b/pikit_api/diagnostics.py @@ -0,0 +1,117 @@ +import datetime +import io +import json +import pathlib +from typing import Any, Dict, List, Optional + +from .constants import ( + API_LOG, + DEBUG_FLAG, + DIAG_DEFAULT_STATE, + DIAG_LOG_FILE, + DIAG_MAX_BYTES, + DIAG_MAX_ENTRY_CHARS, + DIAG_STATE_FILE, +) + +_diag_state: Optional[Dict[str, Any]] = None + + +def _load_diag_state() -> Dict[str, Any]: + """Load diagnostics state from RAM-backed storage when available.""" + global _diag_state + if _diag_state is not None: + return _diag_state + try: + if DIAG_STATE_FILE.exists(): + _diag_state = json.loads(DIAG_STATE_FILE.read_text()) + return _diag_state + except Exception: + pass + _diag_state = DIAG_DEFAULT_STATE.copy() + return _diag_state + + +def _save_diag_state(enabled=None, level=None) -> Dict[str, Any]: + """Persist diagnostics state; tolerate failures silently.""" + state = _load_diag_state() + if enabled is not None: + state["enabled"] = bool(enabled) + if level in ("normal", "debug"): + state["level"] = level + try: + DIAG_STATE_FILE.parent.mkdir(parents=True, exist_ok=True) + DIAG_STATE_FILE.write_text(json.dumps(state)) + except Exception: + pass + return state + + +def diag_log(level: str, message: str, meta: Optional[dict] = None) -> None: + """ + Append a diagnostic log line to RAM-backed file. + Skips when disabled or when debug level is off. + """ + state = _load_diag_state() + if not state.get("enabled"): + return + if level == "debug" and state.get("level") != "debug": + return + try: + ts = datetime.datetime.utcnow().isoformat() + "Z" + entry = {"ts": ts, "level": level, "msg": message} + if meta: + entry["meta"] = meta + line = json.dumps(entry, separators=(",", ":")) + if len(line) > DIAG_MAX_ENTRY_CHARS: + entry.pop("meta", None) + entry["msg"] = (message or "")[: DIAG_MAX_ENTRY_CHARS - 40] + "…" + line = json.dumps(entry, separators=(",", ":")) + line_bytes = (line + "\n").encode() + DIAG_LOG_FILE.parent.mkdir(parents=True, exist_ok=True) + with DIAG_LOG_FILE.open("ab") as f: + f.write(line_bytes) + if DIAG_LOG_FILE.stat().st_size > DIAG_MAX_BYTES: + with DIAG_LOG_FILE.open("rb") as f: + f.seek(-DIAG_MAX_BYTES, io.SEEK_END) + tail = f.read() + if b"\n" in tail: + tail = tail.split(b"\n", 1)[1] + with DIAG_LOG_FILE.open("wb") as f: + f.write(tail) + except Exception: + pass + + +def diag_read(limit: int = 500) -> List[dict]: + """Return latest log entries (dicts), newest first.""" + if not DIAG_LOG_FILE.exists(): + return [] + try: + data = DIAG_LOG_FILE.read_bytes() + except Exception: + return [] + lines = data.splitlines()[-limit:] + out: List[dict] = [] + for line in lines: + try: + out.append(json.loads(line.decode("utf-8", errors="ignore"))) + except Exception: + continue + return out[::-1] + + +def dbg(msg: str) -> None: + """ + Lightweight debug logger for legacy /boot/pikit-debug flag. + Mirrors into diagnostics log when enabled. + """ + if DEBUG_FLAG: + 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") + try: + diag_log("debug", msg) + except Exception: + pass diff --git a/pikit_api/helpers.py b/pikit_api/helpers.py new file mode 100644 index 0000000..e87b011 --- /dev/null +++ b/pikit_api/helpers.py @@ -0,0 +1,80 @@ +import hashlib +import os +import pathlib +import re +import socket +from typing import Optional + +from .constants import HTTPS_PORTS + + +def ensure_dir(path: pathlib.Path) -> None: + path.mkdir(parents=True, exist_ok=True) + + +def sha256_file(path: pathlib.Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def normalize_path(path: Optional[str]) -> 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 default_host() -> str: + """Return preferred hostname (append .local if bare).""" + host = socket.gethostname() + if "." not in host: + host = f"{host}.local" + return host + + +def detect_https(host: str, port: int) -> bool: + """Heuristic: known HTTPS ports or .local certs.""" + return int(port) in HTTPS_PORTS or host.lower().endswith(".local") or host.lower() == "pikit" + + +def port_online(host: str, port: int) -> bool: + try: + with socket.create_connection((host, int(port)), timeout=1.5): + return True + except Exception: + return False + + +def reboot_required() -> bool: + return pathlib.Path("/run/reboot-required").exists() + + +def strip_comments(text: str) -> 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) -> 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 diff --git a/pikit_api/http_handlers.py b/pikit_api/http_handlers.py new file mode 100644 index 0000000..b9b144d --- /dev/null +++ b/pikit_api/http_handlers.py @@ -0,0 +1,298 @@ +import json +import urllib.parse +from http.server import BaseHTTPRequestHandler + +from .auto_updates import auto_updates_state, read_updates_config, set_auto_updates, set_updates_config +from .constants import CORE_NAME, CORE_PORTS, DIAG_LOG_FILE +from .diagnostics import _load_diag_state, _save_diag_state, dbg, diag_log, diag_read +from .helpers import default_host, detect_https, normalize_path +from .releases import ( + check_for_update, + fetch_manifest, + fetch_text_with_auth, + load_update_state, + read_current_version, + save_update_state, + start_background_task, +) +from .services import ( + FirewallToolMissing, + allow_port_lan, + factory_reset, + load_services, + remove_port_lan, + save_services, + ufw_status_allows, +) +from .status import collect_status, list_services_for_ui + + +class Handler(BaseHTTPRequestHandler): + """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 + + # GET endpoints + def do_GET(self): + if self.path.startswith("/api/status"): + return self._send(200, collect_status()) + + if self.path.startswith("/api/services"): + return self._send(200, {"services": list_services_for_ui()}) + + if self.path.startswith("/api/updates/auto"): + state = auto_updates_state() + return self._send(200, {"enabled": state.get("enabled", False), "details": state}) + + if self.path.startswith("/api/updates/config"): + return self._send(200, read_updates_config()) + + if self.path.startswith("/api/update/status"): + state = load_update_state() + state["current_version"] = read_current_version() + state["channel"] = state.get("channel", "dev") + return self._send(200, state) + + if self.path.startswith("/api/update/changelog"): + try: + qs = urllib.parse.urlparse(self.path).query + params = urllib.parse.parse_qs(qs) + url = params.get("url", [None])[0] + if not url: + manifest = fetch_manifest() + url = manifest.get("changelog") + if not url: + return self._send(404, {"error": "no changelog url"}) + text = fetch_text_with_auth(url) + return self._send(200, {"text": text}) + except Exception as e: + return self._send(500, {"error": str(e)}) + + if self.path.startswith("/api/diag/log"): + entries = diag_read() + state = _load_diag_state() + return self._send(200, {"entries": entries, "state": state}) + + return self._send(404, {"error": "not found"}) + + # POST endpoints + 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") + diag_log("info", "Factory reset requested") + 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() + diag_log("info", "Auto updates toggled", {"enabled": enable}) + 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}") + diag_log("info", "Update settings saved", cfg) + return self._send(200, cfg) + except Exception as e: + dbg(f"Failed to apply updates config: {e}") + diag_log("error", "Update settings save failed", {"error": str(e)}) + return self._send(500, {"error": str(e)}) + + if self.path.startswith("/api/update/check"): + state = check_for_update() + return self._send(200, state) + + if self.path.startswith("/api/update/apply"): + start_background_task("apply") + state = load_update_state() + state["status"] = "in_progress" + state["message"] = "Starting background apply" + save_update_state(state) + return self._send(202, state) + + if self.path.startswith("/api/update/rollback"): + start_background_task("rollback") + state = load_update_state() + state["status"] = "in_progress" + state["message"] = "Starting rollback" + save_update_state(state) + return self._send(202, state) + + if self.path.startswith("/api/update/auto"): + state = load_update_state() + state["auto_check"] = bool(payload.get("enable")) + save_update_state(state) + diag_log("info", "Release auto-check toggled", {"enabled": state["auto_check"]}) + return self._send(200, state) + + if self.path.startswith("/api/update/channel"): + chan = payload.get("channel", "dev") + if chan not in ("dev", "stable"): + return self._send(400, {"error": "channel must be dev or stable"}) + state = load_update_state() + state["channel"] = chan + save_update_state(state) + diag_log("info", "Release channel set", {"channel": chan}) + return self._send(200, state) + + if self.path.startswith("/api/diag/log/level"): + state = _save_diag_state(payload.get("enabled"), payload.get("level")) + diag_log("info", "Diag level updated", state) + return self._send(200, {"state": state}) + + if self.path.startswith("/api/diag/log/clear"): + try: + DIAG_LOG_FILE.unlink(missing_ok=True) + except Exception: + pass + diag_log("info", "Diag log cleared") + return self._send(200, {"cleared": True, "state": _load_diag_state()}) + + 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 = default_host() + 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)}) + diag_log("info", "Service added", {"name": name, "port": port, "scheme": scheme}) + 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) + diag_log("info", "Service removed", {"port": port}) + 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: + 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") + 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 + host = default_host() + 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) + diag_log("info", "Service updated", {"port": svc.get("port"), "name": new_name or None, "scheme": svc.get("scheme")}) + return self._send(200, {"services": services, "message": "Service updated"}) + + return self._send(404, {"error": "not found"}) diff --git a/pikit_api/releases.py b/pikit_api/releases.py new file mode 100644 index 0000000..af806af --- /dev/null +++ b/pikit_api/releases.py @@ -0,0 +1,529 @@ +import datetime +import fcntl +import json +import os +import pathlib +import shutil +import subprocess +import tarfile +import urllib.error +import urllib.parse +import urllib.request +from typing import Any, Dict, List, Optional + +from .constants import ( + API_DIR, + API_PACKAGE_DIR, + API_PATH, + AUTH_TOKEN, + BACKUP_ROOT, + DEFAULT_MANIFEST_URL, + TMP_ROOT, + UPDATE_LOCK, + UPDATE_STATE, + UPDATE_STATE_DIR, + VERSION_FILE, + WEB_ROOT, + WEB_VERSION_FILE, +) +from .diagnostics import diag_log +from .helpers import default_host, ensure_dir, sha256_file + + +def read_current_version() -> str: + if VERSION_FILE.exists(): + return VERSION_FILE.read_text().strip() + if WEB_VERSION_FILE.exists(): + try: + return json.loads(WEB_VERSION_FILE.read_text()).get("version", "unknown") + except Exception: + return "unknown" + return "unknown" + + +def load_update_state() -> Dict[str, Any]: + UPDATE_STATE_DIR.mkdir(parents=True, exist_ok=True) + if UPDATE_STATE.exists(): + try: + return json.loads(UPDATE_STATE.read_text()) + except Exception: + pass + return { + "current_version": read_current_version(), + "latest_version": None, + "last_check": None, + "status": "unknown", + "message": "", + "auto_check": False, + "in_progress": False, + "progress": None, + "channel": os.environ.get("PIKIT_CHANNEL", "dev"), + } + + +def save_update_state(state: Dict[str, Any]) -> None: + UPDATE_STATE_DIR.mkdir(parents=True, exist_ok=True) + UPDATE_STATE.write_text(json.dumps(state, indent=2)) + + +def _auth_token(): + return os.environ.get("PIKIT_AUTH_TOKEN") or AUTH_TOKEN + + +def _gitea_latest_manifest(target: str): + """ + Fallback: when a manifest URL 404s, try hitting the Gitea API to grab the + latest release asset named manifest.json. + """ + try: + parts = target.split("/") + if "releases" not in parts: + return None + idx = parts.index("releases") + if idx < 2: + return None + base = "/".join(parts[:3]) + owner = parts[idx - 2] + repo = parts[idx - 1] + api_url = f"{base}/api/v1/repos/{owner}/{repo}/releases/latest" + req = urllib.request.Request(api_url) + token = _auth_token() + if token: + req.add_header("Authorization", f"token {token}") + resp = urllib.request.urlopen(req, timeout=10) + rel = json.loads(resp.read().decode()) + assets = rel.get("assets") or [] + manifest_asset = next((a for a in assets if a.get("name") == "manifest.json"), None) + if manifest_asset and manifest_asset.get("browser_download_url"): + return fetch_manifest(manifest_asset["browser_download_url"]) + except Exception: + return None + return None + + +def fetch_manifest(url: str | None = None): + target = url or os.environ.get("PIKIT_MANIFEST_URL") or DEFAULT_MANIFEST_URL + req = urllib.request.Request(target) + token = _auth_token() + if token: + req.add_header("Authorization", f"token {token}") + try: + resp = urllib.request.urlopen(req, timeout=10) + data = resp.read() + return json.loads(data.decode()) + except urllib.error.HTTPError as e: + if e.code == 404: + alt = _gitea_latest_manifest(target) + if alt: + return alt + raise + + +def fetch_manifest_for_channel(channel: str): + """ + For stable: use normal manifest (latest non-prerelease). + For dev: try normal manifest; if it points to stable, fetch latest prerelease manifest via Gitea API. + """ + channel = channel or "dev" + base_manifest_url = os.environ.get("PIKIT_MANIFEST_URL") or DEFAULT_MANIFEST_URL + manifest = None + try: + manifest = fetch_manifest(base_manifest_url) + except Exception: + manifest = None + + if manifest and channel == "stable": + return manifest + if manifest: + version = manifest.get("version") or manifest.get("latest_version") + if channel == "dev" and version and "dev" in str(version): + return manifest + + try: + parts = base_manifest_url.split("/") + if "releases" not in parts: + if manifest: + return manifest + return fetch_manifest(base_manifest_url) + idx = parts.index("releases") + owner = parts[idx - 2] + repo = parts[idx - 1] + base = "/".join(parts[:3]) + api_url = f"{base}/api/v1/repos/{owner}/{repo}/releases" + req = urllib.request.Request(api_url) + token = _auth_token() + if token: + req.add_header("Authorization", f"token {token}") + resp = urllib.request.urlopen(req, timeout=10) + releases = json.loads(resp.read().decode()) + + def pick(predicate): + for r in releases: + if predicate(r): + asset = next((a for a in r.get("assets", []) if a.get("name") == "manifest.json"), None) + if asset and asset.get("browser_download_url"): + return fetch_manifest(asset["browser_download_url"]) + return None + + if channel == "dev": + m = pick(lambda r: r.get("prerelease") is True) + if m: + return m + m = pick(lambda r: r.get("prerelease") is False) + if m: + return m + except Exception: + pass + + if manifest: + return manifest + raise RuntimeError("No manifest found for channel") + + +def download_file(url: str, dest: pathlib.Path): + ensure_dir(dest.parent) + req = urllib.request.Request(url) + token = _auth_token() + if token: + req.add_header("Authorization", f"token {token}") + with urllib.request.urlopen(req, timeout=60) as resp, dest.open("wb") as f: + shutil.copyfileobj(resp, f) + return dest + + +def fetch_text_with_auth(url: str): + req = urllib.request.Request(url) + token = _auth_token() + if token: + req.add_header("Authorization", f"token {token}") + with urllib.request.urlopen(req, timeout=10) as resp: + return resp.read().decode() + + +def acquire_lock(): + try: + ensure_dir(UPDATE_LOCK.parent) + lockfile = UPDATE_LOCK.open("w") + fcntl.flock(lockfile.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + lockfile.write(str(os.getpid())) + lockfile.flush() + return lockfile + except Exception: + return None + + +def release_lock(lockfile): + try: + fcntl.flock(lockfile.fileno(), fcntl.LOCK_UN) + lockfile.close() + UPDATE_LOCK.unlink(missing_ok=True) + except Exception: + pass + + +def list_backups(): + """Return backups sorted by mtime (newest first).""" + ensure_dir(BACKUP_ROOT) + backups = [p for p in BACKUP_ROOT.iterdir() if p.is_dir()] + backups.sort(key=lambda p: p.stat().st_mtime, reverse=True) + return backups + + +def get_backup_version(path: pathlib.Path): + vf = path / "version.txt" + if not vf.exists(): + web_version = path / "pikit-web" / "data" / "version.json" + if not web_version.exists(): + return None + try: + return json.loads(web_version.read_text()).get("version") + except Exception: + return None + try: + return vf.read_text().strip() + except Exception: + return None + + +def choose_rollback_backup(): + """ + Pick the most recent backup whose version differs from the currently + installed version. If none differ, fall back to the newest backup. + """ + backups = list_backups() + if not backups: + return None + current = read_current_version() + for b in backups: + ver = get_backup_version(b) + if ver and ver != current: + return b + return backups[0] + + +def restore_backup(target: pathlib.Path): + if (target / "pikit-web").exists(): + shutil.rmtree(WEB_ROOT, ignore_errors=True) + shutil.copytree(target / "pikit-web", WEB_ROOT, dirs_exist_ok=True) + if (target / "pikit-api.py").exists(): + shutil.copy2(target / "pikit-api.py", API_PATH) + os.chmod(API_PATH, 0o755) + if (target / "pikit_api").exists(): + shutil.rmtree(API_PACKAGE_DIR, ignore_errors=True) + shutil.copytree(target / "pikit_api", API_PACKAGE_DIR, dirs_exist_ok=True) + VERSION_FILE.parent.mkdir(parents=True, exist_ok=True) + if (target / "version.txt").exists(): + shutil.copy2(target / "version.txt", VERSION_FILE) + else: + ver = get_backup_version(target) + if ver: + VERSION_FILE.write_text(str(ver)) + for svc in ("pikit-api.service", "dietpi-dashboard-frontend.service"): + subprocess.run(["systemctl", "restart", svc], check=False) + + +def prune_backups(keep: int = 2): + if keep < 1: + keep = 1 + backups = list_backups() + for old in backups[keep:]: + shutil.rmtree(old, ignore_errors=True) + + +def check_for_update(): + state = load_update_state() + lock = acquire_lock() + if lock is None: + state["status"] = "error" + state["message"] = "Another update is running" + save_update_state(state) + return state + diag_log("info", "Update check started", {"channel": state.get("channel") or "dev"}) + state["in_progress"] = True + state["progress"] = "Checking for updates…" + save_update_state(state) + try: + manifest = fetch_manifest_for_channel(state.get("channel") or "dev") + latest = manifest.get("version") or manifest.get("latest_version") + state["latest_version"] = latest + state["last_check"] = datetime.datetime.utcnow().isoformat() + "Z" + channel = state.get("channel") or "dev" + if channel == "stable" and latest and "dev" in str(latest): + state["status"] = "up_to_date" + state["message"] = "Dev release available; enable dev channel to install." + else: + if latest and latest != state.get("current_version"): + state["status"] = "update_available" + state["message"] = manifest.get("changelog", "Update available") + else: + state["status"] = "up_to_date" + state["message"] = "Up to date" + diag_log("info", "Update check finished", {"status": state["status"], "latest": str(latest)}) + except Exception as e: + state["status"] = "up_to_date" + state["message"] = f"Could not reach update server: {e}" + state["last_check"] = datetime.datetime.utcnow().isoformat() + "Z" + diag_log("error", "Update check failed", {"error": str(e)}) + finally: + state["in_progress"] = False + state["progress"] = None + save_update_state(state) + if lock: + release_lock(lock) + return state + + +def _stage_backup() -> pathlib.Path: + ts = datetime.datetime.utcnow().strftime("%Y%m%d-%H%M%S") + backup_dir = BACKUP_ROOT / ts + ensure_dir(backup_dir) + if WEB_ROOT.exists(): + ensure_dir(backup_dir / "pikit-web") + shutil.copytree(WEB_ROOT, backup_dir / "pikit-web", dirs_exist_ok=True) + if API_PATH.exists(): + shutil.copy2(API_PATH, backup_dir / "pikit-api.py") + if API_PACKAGE_DIR.exists(): + shutil.copytree(API_PACKAGE_DIR, backup_dir / "pikit_api", dirs_exist_ok=True) + if VERSION_FILE.exists(): + shutil.copy2(VERSION_FILE, backup_dir / "version.txt") + return backup_dir + + +def apply_update(): + state = load_update_state() + if state.get("in_progress"): + state["message"] = "Update already in progress" + save_update_state(state) + return state + + lock = acquire_lock() + if lock is None: + state["status"] = "error" + state["message"] = "Another update is running" + save_update_state(state) + return state + + state["in_progress"] = True + state["status"] = "in_progress" + state["progress"] = "Starting update…" + save_update_state(state) + diag_log("info", "Update apply started", {"channel": state.get("channel") or "dev"}) + + try: + channel = state.get("channel") or os.environ.get("PIKIT_CHANNEL", "dev") + manifest = fetch_manifest_for_channel(channel) + latest = manifest.get("version") or manifest.get("latest_version") + if not latest: + raise RuntimeError("Manifest missing version") + + backup_dir = _stage_backup() + prune_backups(keep=1) + + bundle_url = manifest.get("bundle") or manifest.get("url") + if not bundle_url: + raise RuntimeError("Manifest missing bundle url") + stage_dir = TMP_ROOT / str(latest) + bundle_path = stage_dir / "bundle.tar.gz" + ensure_dir(stage_dir) + + state["progress"] = "Downloading release…" + save_update_state(state) + download_file(bundle_url, bundle_path) + diag_log("debug", "Bundle downloaded", {"url": bundle_url, "path": str(bundle_path)}) + + expected_hash = None + for f in manifest.get("files", []): + if f.get("path") == "bundle.tar.gz" and f.get("sha256"): + expected_hash = f["sha256"] + break + if expected_hash: + got = sha256_file(bundle_path) + if got.lower() != expected_hash.lower(): + raise RuntimeError("Bundle hash mismatch") + diag_log("debug", "Bundle hash verified", {"expected": expected_hash}) + + state["progress"] = "Staging files…" + save_update_state(state) + with tarfile.open(bundle_path, "r:gz") as tar: + tar.extractall(stage_dir) + + staged_web = stage_dir / "pikit-web" + if staged_web.exists(): + shutil.rmtree(WEB_ROOT, ignore_errors=True) + shutil.copytree(staged_web, WEB_ROOT) + staged_api = stage_dir / "pikit-api.py" + if staged_api.exists(): + shutil.copy2(staged_api, API_PATH) + os.chmod(API_PATH, 0o755) + staged_pkg = stage_dir / "pikit_api" + if staged_pkg.exists(): + shutil.rmtree(API_PACKAGE_DIR, ignore_errors=True) + shutil.copytree(staged_pkg, API_PACKAGE_DIR, dirs_exist_ok=True) + + for svc in ("pikit-api.service", "dietpi-dashboard-frontend.service"): + subprocess.run(["systemctl", "restart", svc], check=False) + + VERSION_FILE.parent.mkdir(parents=True, exist_ok=True) + VERSION_FILE.write_text(str(latest)) + + state["current_version"] = str(latest) + state["latest_version"] = str(latest) + state["status"] = "up_to_date" + state["message"] = "Update installed" + state["progress"] = None + save_update_state(state) + diag_log("info", "Update applied", {"version": str(latest)}) + except urllib.error.HTTPError as e: + state["status"] = "error" + state["message"] = f"No release available ({e.code})" + diag_log("error", "Update apply HTTP error", {"code": e.code}) + except Exception as e: + state["status"] = "error" + state["message"] = f"Update failed: {e}" + state["progress"] = None + save_update_state(state) + diag_log("error", "Update apply failed", {"error": str(e)}) + backup = choose_rollback_backup() + if backup: + try: + restore_backup(backup) + state["current_version"] = read_current_version() + state["message"] += f" (rolled back to backup {backup.name})" + save_update_state(state) + diag_log("info", "Rollback after failed update", {"backup": backup.name}) + except Exception as re: + state["message"] += f" (rollback failed: {re})" + save_update_state(state) + diag_log("error", "Rollback after failed update failed", {"error": str(re)}) + finally: + state["in_progress"] = False + state["progress"] = None + save_update_state(state) + if lock: + release_lock(lock) + return state + + +def rollback_update(): + state = load_update_state() + lock = acquire_lock() + if lock is None: + state["status"] = "error" + state["message"] = "Another update is running" + save_update_state(state) + return state + state["in_progress"] = True + state["status"] = "in_progress" + state["progress"] = "Rolling back…" + save_update_state(state) + diag_log("info", "Rollback started") + backup = choose_rollback_backup() + if not backup: + state["status"] = "error" + state["message"] = "No backup available to rollback." + state["in_progress"] = False + state["progress"] = None + save_update_state(state) + release_lock(lock) + return state + try: + restore_backup(backup) + state["status"] = "up_to_date" + state["current_version"] = read_current_version() + state["latest_version"] = state.get("latest_version") or state["current_version"] + ver = get_backup_version(backup) + suffix = f" (version {ver})" if ver else "" + state["message"] = f"Rolled back to backup {backup.name}{suffix}" + diag_log("info", "Rollback complete", {"backup": backup.name, "version": ver}) + except Exception as e: + state["status"] = "error" + state["message"] = f"Rollback failed: {e}" + diag_log("error", "Rollback failed", {"error": str(e)}) + state["in_progress"] = False + state["progress"] = None + save_update_state(state) + release_lock(lock) + return state + + +def start_background_task(mode: str): + """ + Kick off a background update/rollback via systemd-run so nginx/API restarts + do not break the caller connection. + mode: "apply" or "rollback" + """ + assert mode in ("apply", "rollback"), "invalid mode" + unit = f"pikit-update-{mode}" + cmd = ["systemd-run", "--unit", unit, "--quiet"] + if DEFAULT_MANIFEST_URL: + cmd += [f"--setenv=PIKIT_MANIFEST_URL={DEFAULT_MANIFEST_URL}"] + token = _auth_token() + if token: + cmd += [f"--setenv=PIKIT_AUTH_TOKEN={token}"] + cmd += ["/usr/bin/env", "python3", str(API_PATH), f"--{mode}-update"] + subprocess.run(cmd, check=False) + + +# Backwards compat aliases +apply_update_stub = apply_update +rollback_update_stub = rollback_update diff --git a/pikit_api/server.py b/pikit_api/server.py new file mode 100644 index 0000000..876732e --- /dev/null +++ b/pikit_api/server.py @@ -0,0 +1,9 @@ +from http.server import HTTPServer + +from .constants import HOST, PORT +from .http_handlers import Handler + + +def run_server(host: str = HOST, port: int = PORT): + server = HTTPServer((host, port), Handler) + server.serve_forever() diff --git a/pikit_api/services.py b/pikit_api/services.py new file mode 100644 index 0000000..3a8e319 --- /dev/null +++ b/pikit_api/services.py @@ -0,0 +1,151 @@ +import json +import pathlib +import shutil +import socket +import subprocess +from typing import Any, Dict, List + +from .constants import ( + CORE_NAME, + CORE_PORTS, + HTTPS_PORTS, + READY_FILE, + RESET_LOG, + SERVICE_JSON, +) +from .diagnostics import dbg +from .helpers import default_host, detect_https, ensure_dir, normalize_path + + +class FirewallToolMissing(Exception): + """Raised when ufw is unavailable but a firewall change was requested.""" + pass + + +def load_services() -> List[Dict[str, Any]]: + """Load service registry and normalize url/scheme/path fields.""" + if SERVICE_JSON.exists(): + try: + data = json.loads(SERVICE_JSON.read_text()) + host = default_host() + 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: List[Dict[str, Any]]) -> None: + ensure_dir(SERVICE_JSON.parent) + SERVICE_JSON.write_text(json.dumps(services, indent=2)) + + +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 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 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) + 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) + + +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 factory_reset(): + # Restore services config + custom = pathlib.Path("/boot/custom-files/pikit-services.json") + if custom.exists(): + shutil.copy(custom, SERVICE_JSON) + else: + SERVICE_JSON.write_text( + json.dumps( + [ + {"name": "Pi-Kit Dashboard", "port": 80}, + {"name": "DietPi Dashboard", "port": 5252}, + ], + indent=2, + ) + ) + reset_firewall() + set_ssh_password_auth(True) + for user in ("root", "dietpi"): + try: + subprocess.run(["chpasswd"], input=f"{user}:pikit".encode(), check=True) + except Exception: + pass + 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) + RESET_LOG.write_text("Factory reset triggered\n") + subprocess.Popen(["/bin/sh", "-c", "sleep 2 && systemctl reboot >/dev/null 2>&1"], close_fds=True) diff --git a/pikit_api/status.py b/pikit_api/status.py new file mode 100644 index 0000000..2f4328c --- /dev/null +++ b/pikit_api/status.py @@ -0,0 +1,103 @@ +import os +import pathlib +import shutil +import socket +import subprocess +from typing import Any, Dict, List + +from .auto_updates import auto_updates_state, read_updates_config +from .constants import CORE_NAME, CORE_PORTS, READY_FILE +from .helpers import default_host, detect_https, normalize_path, port_online, reboot_required +from .services import load_services, ufw_status_allows + + +def _cpu_temp(): + for path in ("/sys/class/thermal/thermal_zone0/temp",): + p = pathlib.Path(path) + if p.exists(): + try: + return float(p.read_text().strip()) / 1000.0 + except Exception: + continue + return None + + +def _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 + return os_ver + + +def _lan_ip(): + try: + out = subprocess.check_output(["hostname", "-I"], text=True).strip() + return out.split()[0] if out else None + except Exception: + return None + + +def list_services_with_health(): + 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) + return services + + +def list_services_for_ui(): + 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) + host = default_host() + path = normalize_path(svc.get("path")) + scheme = svc.get("scheme") or ("https" if detect_https(host, port) else "http") + svc["scheme"] = scheme + svc["url"] = f"{scheme}://{host}:{port}{path}" + services.append(svc) + return services + + +def collect_status() -> Dict[str, Any]: + 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("/") + services = list_services_with_health() + updates_state = auto_updates_state() + updates_config = read_updates_config(updates_state) + 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": _lan_ip(), + "os_version": _os_version(), + "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, + } + return data diff --git a/set_ready.sh b/set_ready.sh deleted file mode 100755 index 996269c..0000000 --- a/set_ready.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/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 deleted file mode 100755 index 87cfaba..0000000 --- a/start-codex.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/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 - diff --git a/tools/release/README.md b/tools/release/README.md index b478e27..7161acc 100644 --- a/tools/release/README.md +++ b/tools/release/README.md @@ -34,7 +34,7 @@ Environment=PIKIT_MANIFEST_URL=https://git.44r0n.cc/44r0n7/pi-kit/releases/downl ## What’s inside the bundle - `pikit-web/` (built static assets) -- `pikit-api.py` +- `pikit-api.py` + `pikit_api/` package - optional helper scripts (e.g., `set_ready.sh`, `start-codex.sh`, `pikit-services.json` if present) ## Notes diff --git a/tools/release/make-release.sh b/tools/release/make-release.sh index bb70934..01dbfda 100755 --- a/tools/release/make-release.sh +++ b/tools/release/make-release.sh @@ -34,9 +34,8 @@ rsync -a --delete \ "$ROOT/pikit-web/" "$STAGE/pikit-web/" cp "$ROOT/pikit-api.py" "$STAGE/" +rsync -a "$ROOT/pikit_api/" "$STAGE/pikit_api/" cp "$ROOT/pikit-services.json" "$STAGE/" 2>/dev/null || true -cp "$ROOT/set_ready.sh" "$STAGE/" 2>/dev/null || true -cp "$ROOT/start-codex.sh" "$STAGE/" 2>/dev/null || true # Include version marker if [[ -f "$ROOT/pikit-web/data/version.json" ]]; then