Add dashboard UI updates and settings modal

This commit is contained in:
Aaron
2025-12-10 18:51:31 -05:00
commit c85df728b7
54 changed files with 7151 additions and 0 deletions

28
.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# Node
node_modules/
pikit-web/node_modules/
pikit-web/dist/
pikit-web/test-results/
pikit-web/.vite/
pikit-web/coverage/
pikit-web/playwright-report/
pikit-web/.cache/
# Python
__pycache__/
*.pyc
# OS/Editor
.DS_Store
Thumbs.db
*.swp
# Build artifacts
*.log
# QEMU images / large artifacts
qemu-dietpi/
# Stock images (large)
images/stock/

21
README.md Normal file
View File

@@ -0,0 +1,21 @@
# Pi-Kit Dashboard
Lightweight dashboard for DietPi-based Pi-Kit images. Two pieces live in this repo:
- `pikit-api.py`: tiny Python HTTP API (status, services, auto updates, factory reset). Runs on localhost:4000 and writes to `/etc/pikit/services.json`.
- `pikit-web/`: static Vite site served by nginx from `/var/www/pikit-web`. Sources live in `pikit-web/assets/`; Playwright E2E tests in `pikit-web/tests/`.
## Local development
- Dashboard: `cd pikit-web && npm install` (first run), then `npm run dev` for Vite, `npm test` for Playwright, `npm run build` for production bundle.
- API: `python pikit-api.py` to run locally (listens on 127.0.0.1:4000).
## Deploying to a Pi-Kit box
1. Copy `pikit-api.py` to the device (e.g., `/usr/local/bin/`) and restart the service unit that wraps it.
2. Sync `pikit-web/index.html` and `pikit-web/assets/*` (or the built `pikit-web/dist/*`) to `/var/www/pikit-web/`.
3. The API surfaces clear errors if firewall tooling (`ufw`) is missing when ports are opened/closed.
4. Factory reset sets `root` and `dietpi` passwords to `pikit`.
## Notes
- Service paths are normalized (leading slash) and URLs include optional subpaths.
- Firewall changes raise explicit errors when `ufw` is unavailable so the UI can surface what failed.
- Access the device UI at `http://pikit.local/` (mDNS).

49
check-pikit-clean.sh Executable file
View File

@@ -0,0 +1,49 @@
#!/bin/bash
set -euo pipefail
echo "== Identity files =="
ls -l /etc/machine-id || true
cat /etc/machine-id || true
[ -e /var/lib/dbus/machine-id ] && echo "dbus machine-id exists" || echo "dbus machine-id missing (expected)"
ls -l /var/lib/systemd/random-seed || true
echo -e "\n== SSH host keys =="
ls /etc/ssh/ssh_host_* 2>/dev/null || echo "no host keys (expected)"
echo -e "\n== SSH client traces =="
for f in /root/.ssh/known_hosts /home/dietpi/.ssh/known_hosts /home/dietpi/.ssh/authorized_keys; do
if [ -e "$f" ]; then
printf "%s: size %s\n" "$f" "$(stat -c%s "$f")"
[ -s "$f" ] && echo " WARNING: not empty"
else
echo "$f: missing"
fi
done
echo -e "\n== Ready flag =="
[ -e /var/run/pikit-ready ] && echo "READY FLAG STILL PRESENT" || echo "ready flag absent (expected)"
echo -e "\n== Logs =="
du -sh /var/log 2>/dev/null
du -sh /var/log/nginx 2>/dev/null
find /var/log -type f -maxdepth 2 -printf "%p %s bytes\n"
echo -e "\n== DietPi RAM logs =="
if [ -d /var/tmp/dietpi/logs ]; then
find /var/tmp/dietpi/logs -type f -printf "%p %s bytes\n"
else
echo "/var/tmp/dietpi/logs missing"
fi
echo -e "\n== Caches =="
du -sh /var/cache/apt /var/lib/apt/lists /var/cache/debconf 2>/dev/null || true
echo -e "\n== Temp dirs =="
du -sh /tmp /var/tmp 2>/dev/null || true
find /tmp /var/tmp -maxdepth 1 -mindepth 1 ! -name 'systemd-private-*' -print
echo -e "\n== DHCP lease =="
ls -l /var/lib/dhcp/dhclient.eth0.leases 2>/dev/null || echo "lease file missing (expected)"
echo -e "\n== Nginx cache dirs =="
[ -d /var/lib/nginx ] && find /var/lib/nginx -maxdepth 2 -type d -print || echo "/var/lib/nginx missing"

43
flash_sd.sh Executable file
View File

@@ -0,0 +1,43 @@
#!/bin/bash
set -euo pipefail
# Pi-Kit flashing helper (small wrapper around xzcat | dd).
# Usage: sudo ./flash_sd.sh /path/to/image.img.xz /dev/sdX
# Example: sudo ./flash_sd.sh images/output/DietPi_RPi5-ARMv8-Trixie-base.img.xz /dev/sdb
# Safety guardrails:
# - Requires root (sudo).
# - WILL wipe the target block device; prompt asks for YES before writing.
# - If a .sha256 sits next to the image, consider verifying it first.
img="${1:-}"
dev="${2:-}"
if [[ -z "$img" || -z "$dev" ]]; then
echo "Usage: sudo $0 image.img.xz /dev/sdX"
exit 1
fi
if [[ $EUID -ne 0 ]]; then
echo "Please run as root (sudo)."
exit 1
fi
if [[ ! -b "$dev" ]]; then
echo "Device $dev not found or not a block device."
exit 1
fi
echo "About to wipe and flash $dev with $img"
read -rp "Type YES to continue: " yn
[[ "$yn" == "YES" ]] || { echo "Aborted."; exit 1; }
echo "Decompressing and writing... this may take several minutes."
xzcat "$img" | pv | dd of="$dev" bs=4M conv=fsync status=none
echo "Syncing..."
sync
echo "Flashed $img to $dev successfully."
echo "Ejecting $dev..."
sudo eject "$dev" || true

716
pikit-api.py Normal file
View File

@@ -0,0 +1,716 @@
#!/usr/bin/env python3
import json, os, subprocess, socket, shutil, pathlib, datetime
from http.server import BaseHTTPRequestHandler, HTTPServer
import re
HOST = "127.0.0.1"
PORT = 4000
SERVICE_JSON = pathlib.Path("/etc/pikit/services.json")
RESET_LOG = pathlib.Path("/var/log/pikit-reset.log")
API_LOG = pathlib.Path("/var/log/pikit-api.log")
DEBUG_FLAG = pathlib.Path("/boot/pikit-debug").exists()
HTTPS_PORTS = {443, 5252}
CORE_PORTS = {80}
CORE_NAME = "Pi-Kit Dashboard"
READY_FILE = pathlib.Path("/var/run/pikit-ready")
APT_AUTO_CFG = pathlib.Path("/etc/apt/apt.conf.d/20auto-upgrades")
APT_UA_BASE = pathlib.Path("/etc/apt/apt.conf.d/50unattended-upgrades")
APT_UA_OVERRIDE = pathlib.Path("/etc/apt/apt.conf.d/51pikit-unattended.conf")
DEFAULT_UPDATE_TIME = "04:00"
DEFAULT_UPGRADE_TIME = "04:30"
SECURITY_PATTERNS = [
'origin=Debian,codename=${distro_codename},label=Debian-Security',
'origin=Debian,codename=${distro_codename}-security,label=Debian-Security',
]
ALL_PATTERNS = [
'origin=Debian,codename=${distro_codename},label=Debian',
*SECURITY_PATTERNS,
]
class FirewallToolMissing(Exception):
"""Raised when ufw is unavailable but a firewall change was requested."""
pass
def normalize_path(path: str | None) -> str:
"""Normalize optional service path. Empty -> "". Ensure leading slash."""
if not path:
return ""
p = str(path).strip()
if not p:
return ""
if not p.startswith("/"):
p = "/" + p
return p
def dbg(msg):
if not DEBUG_FLAG:
return
API_LOG.parent.mkdir(parents=True, exist_ok=True)
ts = datetime.datetime.utcnow().isoformat()
with API_LOG.open("a") as f:
f.write(f"[{ts}] {msg}\n")
def set_ssh_password_auth(allow: bool):
"""
Enable/disable SSH password authentication without requiring the current password.
Used during factory reset to restore a predictable state.
"""
cfg = pathlib.Path("/etc/ssh/sshd_config")
text = cfg.read_text() if cfg.exists() else ""
def set_opt(key, value):
nonlocal text
pattern = f"{key} "
lines = text.splitlines()
replaced = False
for idx, line in enumerate(lines):
if line.strip().startswith(pattern):
lines[idx] = f"{key} {value}"
replaced = True
break
if not replaced:
lines.append(f"{key} {value}")
text_new = "\n".join(lines) + "\n"
return text_new
text = set_opt("PasswordAuthentication", "yes" if allow else "no")
text = set_opt("KbdInteractiveAuthentication", "no")
text = set_opt("ChallengeResponseAuthentication", "no")
text = set_opt("PubkeyAuthentication", "yes")
text = set_opt("PermitRootLogin", "yes" if allow else "prohibit-password")
cfg.write_text(text)
subprocess.run(["systemctl", "restart", "ssh"], check=False)
return True, f"SSH password auth {'enabled' if allow else 'disabled'}"
def load_services():
if SERVICE_JSON.exists():
try:
data = json.loads(SERVICE_JSON.read_text())
# Normalize entries: ensure url built from port if missing
host = socket.gethostname()
for svc in data:
svc_path = normalize_path(svc.get("path"))
if svc_path:
svc["path"] = svc_path
if svc.get("port"):
scheme = svc.get("scheme")
if not scheme:
scheme = "https" if int(svc["port"]) in HTTPS_PORTS else "http"
svc["scheme"] = scheme
svc["url"] = f"{scheme}://{host}:{svc['port']}{svc_path}"
return data
except Exception:
dbg("Failed to read services.json")
return []
return []
def save_services(services):
SERVICE_JSON.parent.mkdir(parents=True, exist_ok=True)
SERVICE_JSON.write_text(json.dumps(services, indent=2))
def auto_updates_enabled():
if not APT_AUTO_CFG.exists():
return False
text = APT_AUTO_CFG.read_text()
return 'APT::Periodic::Unattended-Upgrade "1";' in text
def set_auto_updates(enable: bool):
"""
Toggle unattended upgrades in a way that matches systemd state, not just the
apt config file. Assumes unattended-upgrades is already installed.
"""
units_maskable = [
"apt-daily.service",
"apt-daily-upgrade.service",
"apt-daily.timer",
"apt-daily-upgrade.timer",
"unattended-upgrades.service",
]
timers = ["apt-daily.timer", "apt-daily-upgrade.timer"]
service = "unattended-upgrades.service"
APT_AUTO_CFG.parent.mkdir(parents=True, exist_ok=True)
if enable:
APT_AUTO_CFG.write_text(
'APT::Periodic::Update-Package-Lists "1";\n'
'APT::Periodic::Unattended-Upgrade "1";\n'
)
for unit in units_maskable:
subprocess.run(["systemctl", "unmask", unit], check=False)
# Enable timers and the service; start them so state matches immediately
for unit in timers + [service]:
subprocess.run(["systemctl", "enable", unit], check=False)
for unit in timers:
subprocess.run(["systemctl", "start", unit], check=False)
subprocess.run(["systemctl", "start", service], check=False)
else:
APT_AUTO_CFG.write_text(
'APT::Periodic::Update-Package-Lists "0";\n'
'APT::Periodic::Unattended-Upgrade "0";\n'
)
# Stop/disable and mask to mirror DietPi defaults
for unit in timers + [service]:
subprocess.run(["systemctl", "stop", unit], check=False)
subprocess.run(["systemctl", "disable", unit], check=False)
for unit in units_maskable:
subprocess.run(["systemctl", "mask", unit], check=False)
def _systemctl_is(unit: str, verb: str) -> bool:
try:
out = subprocess.check_output(["systemctl", verb, unit], text=True).strip()
return out == "enabled" if verb == "is-enabled" else out == "active"
except Exception:
return False
def auto_updates_state():
config_on = auto_updates_enabled()
service = "unattended-upgrades.service"
timers = ["apt-daily.timer", "apt-daily-upgrade.timer"]
state = {
"config_enabled": config_on,
"service_enabled": _systemctl_is(service, "is-enabled"),
"service_active": _systemctl_is(service, "is-active"),
"timers_enabled": {},
"timers_active": {},
}
for t in timers:
state["timers_enabled"][t] = _systemctl_is(t, "is-enabled")
state["timers_active"][t] = _systemctl_is(t, "is-active")
# Consider overall enabled only if config is on and both timers & service are enabled
state["enabled"] = (
config_on
and state["service_enabled"]
and all(state["timers_enabled"].values())
)
return state
def reboot_required():
return pathlib.Path("/run/reboot-required").exists()
def _parse_directive(text: str, key: str, default=None, as_bool=False, as_int=False):
text = _strip_comments(text)
pattern = rf'{re.escape(key)}\s+"?([^";\n]+)"?;'
m = re.search(pattern, text)
if not m:
return default
val = m.group(1).strip()
if as_bool:
return val.lower() in ("1", "true", "yes", "on")
if as_int:
try:
return int(val)
except ValueError:
return default
return val
def _parse_origins_patterns(text: str):
text = _strip_comments(text)
m = re.search(r"Unattended-Upgrade::Origins-Pattern\s*{([^}]*)}", text, re.S)
patterns = []
if not m:
return patterns
body = m.group(1)
for line in body.splitlines():
ln = line.strip().strip('";')
if ln:
patterns.append(ln)
return patterns
def _read_timer_time(timer: str):
try:
out = subprocess.check_output(
["systemctl", "show", "--property=TimersCalendar", timer], text=True
)
# Example: TimersCalendar={ OnCalendar=*-*-* 03:10:00 ; next_elapse=... }
m = re.search(r"OnCalendar=[^0-9]*([0-9]{1,2}):([0-9]{2})", out)
if m:
return f"{int(m.group(1)):02d}:{m.group(2)}"
except Exception:
pass
return None
def _strip_comments(text: str):
"""Remove // and # line comments for safer parsing."""
lines = []
for ln in text.splitlines():
l = ln.strip()
if l.startswith("//") or l.startswith("#"):
continue
lines.append(ln)
return "\n".join(lines)
def _validate_time(val: str, default: str):
if not val:
return default
m = re.match(r"^(\d{1,2}):(\d{2})$", val.strip())
if not m:
return default
h, mi = int(m.group(1)), int(m.group(2))
if 0 <= h < 24 and 0 <= mi < 60:
return f"{h:02d}:{mi:02d}"
return default
def read_updates_config(state=None):
"""
Return a normalized unattended-upgrades configuration snapshot.
Values are sourced from the Pi-Kit override file when present, else the base file.
"""
text = ""
for path in (APT_UA_OVERRIDE, APT_UA_BASE):
if path.exists():
try:
text += path.read_text() + "\n"
except Exception:
pass
scope_hint = None
m_scope = re.search(r"PIKIT_SCOPE:\s*(\w+)", text)
if m_scope:
scope_hint = m_scope.group(1).lower()
cleaned = _strip_comments(text)
patterns = _parse_origins_patterns(cleaned)
scope = (
scope_hint
or ("all" if any("label=Debian" in p and "-security" not in p for p in patterns) else "security")
)
cleanup = _parse_directive(text, "Unattended-Upgrade::Remove-Unused-Dependencies", False, as_bool=True)
auto_reboot = _parse_directive(text, "Unattended-Upgrade::Automatic-Reboot", False, as_bool=True)
reboot_time = _validate_time(_parse_directive(text, "Unattended-Upgrade::Automatic-Reboot-Time", DEFAULT_UPGRADE_TIME), DEFAULT_UPGRADE_TIME)
reboot_with_users = _parse_directive(text, "Unattended-Upgrade::Automatic-Reboot-WithUsers", False, as_bool=True)
bandwidth = _parse_directive(text, "Acquire::http::Dl-Limit", None, as_int=True)
update_time = _read_timer_time("apt-daily.timer") or DEFAULT_UPDATE_TIME
upgrade_time = _read_timer_time("apt-daily-upgrade.timer") or DEFAULT_UPGRADE_TIME
state = state or auto_updates_state()
return {
"enabled": bool(state.get("enabled", False)),
"scope": scope,
"cleanup": bool(cleanup),
"bandwidth_limit_kbps": bandwidth,
"auto_reboot": bool(auto_reboot),
"reboot_time": reboot_time,
"reboot_with_users": bool(reboot_with_users),
"update_time": update_time,
"upgrade_time": upgrade_time,
"state": state,
}
def _write_timer_override(timer: str, time_str: str):
time_norm = _validate_time(time_str, DEFAULT_UPDATE_TIME)
override_dir = pathlib.Path(f"/etc/systemd/system/{timer}.d")
override_dir.mkdir(parents=True, exist_ok=True)
override_file = override_dir / "pikit.conf"
override_file.write_text(
"[Timer]\n"
f"OnCalendar=*-*-* {time_norm}\n"
"Persistent=true\n"
"RandomizedDelaySec=30min\n"
)
subprocess.run(["systemctl", "daemon-reload"], check=False)
subprocess.run(["systemctl", "restart", timer], check=False)
def set_updates_config(opts: dict):
"""
Apply unattended-upgrades configuration from dashboard inputs.
"""
enable = bool(opts.get("enable", True))
scope = opts.get("scope") or "all"
patterns = ALL_PATTERNS if scope == "all" else SECURITY_PATTERNS
cleanup = bool(opts.get("cleanup", False))
bandwidth = opts.get("bandwidth_limit_kbps")
auto_reboot = bool(opts.get("auto_reboot", False))
reboot_time = _validate_time(opts.get("reboot_time"), DEFAULT_UPGRADE_TIME)
reboot_with_users = bool(opts.get("reboot_with_users", False))
update_time = _validate_time(opts.get("update_time"), DEFAULT_UPDATE_TIME)
upgrade_time = _validate_time(opts.get("upgrade_time") or opts.get("update_time"), DEFAULT_UPGRADE_TIME)
APT_AUTO_CFG.parent.mkdir(parents=True, exist_ok=True)
set_auto_updates(enable)
lines = [
"// Managed by Pi-Kit dashboard",
f"// PIKIT_SCOPE: {scope}",
"Unattended-Upgrade::Origins-Pattern {",
]
for p in patterns:
lines.append(f' "{p}";')
lines.append("};")
lines.append(f'Unattended-Upgrade::Remove-Unused-Dependencies {"true" if cleanup else "false"};')
lines.append(f'Unattended-Upgrade::Automatic-Reboot {"true" if auto_reboot else "false"};')
lines.append(f'Unattended-Upgrade::Automatic-Reboot-Time "{reboot_time}";')
lines.append(
f'Unattended-Upgrade::Automatic-Reboot-WithUsers {"true" if reboot_with_users else "false"};'
)
if bandwidth is not None:
lines.append(f'Acquire::http::Dl-Limit "{int(bandwidth)}";')
APT_UA_OVERRIDE.parent.mkdir(parents=True, exist_ok=True)
APT_UA_OVERRIDE.write_text("\n".join(lines) + "\n")
# Timer overrides for when upgrades run
_write_timer_override("apt-daily.timer", update_time)
_write_timer_override("apt-daily-upgrade.timer", upgrade_time)
return read_updates_config()
def detect_https(host, port):
try:
import ssl
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
with socket.create_connection((host, int(port)), timeout=1.5) as sock:
with ctx.wrap_socket(sock, server_hostname=host):
return True
except Exception:
return False
def factory_reset():
# Restore services config
if pathlib.Path("/boot/custom-files/pikit-services.json").exists():
shutil.copy("/boot/custom-files/pikit-services.json", SERVICE_JSON)
else:
SERVICE_JSON.write_text(json.dumps([
{"name": "Pi-Kit Dashboard", "port": 80},
{"name": "DietPi Dashboard", "port": 5252},
], indent=2))
# Reset firewall
reset_firewall()
# Reset SSH auth to password and set defaults
set_ssh_password_auth(True)
for user in ("root", "dietpi"):
try:
subprocess.run(["chpasswd"], input=f"{user}:pikit".encode(), check=True)
except Exception:
pass
# Ensure dietpi exists
if not pathlib.Path("/home/dietpi").exists():
subprocess.run(["useradd", "-m", "-s", "/bin/bash", "dietpi"], check=False)
subprocess.run(["chpasswd"], input=b"dietpi:pikit", check=False)
# Log and reboot
pathlib.Path("/var/log/pikit-reset.log").write_text("Factory reset triggered\n")
subprocess.Popen(["/bin/sh", "-c", "sleep 2 && systemctl reboot >/dev/null 2>&1"], close_fds=True)
def port_online(host, port):
try:
with socket.create_connection((host, int(port)), timeout=1.5):
return True
except Exception:
return False
def ufw_status_allows(port: int) -> bool:
try:
out = subprocess.check_output(["ufw", "status"], text=True)
return f"{port}" in out and "ALLOW" in out
except Exception:
return False
def allow_port_lan(port: int):
"""Open a port to RFC1918 subnets; raise if ufw is missing so callers can surface the error."""
if not shutil.which("ufw"):
raise FirewallToolMissing("Cannot update firewall: ufw is not installed on this system.")
for subnet in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "169.254.0.0/16"):
subprocess.run(["ufw", "allow", "from", subnet, "to", "any", "port", str(port)], check=False)
def remove_port_lan(port: int):
"""Close a LAN rule for a port; raise if ufw is missing so callers can surface the error."""
if not shutil.which("ufw"):
raise FirewallToolMissing("Cannot update firewall: ufw is not installed on this system.")
for subnet in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "169.254.0.0/16"):
subprocess.run(["ufw", "delete", "allow", "from", subnet, "to", "any", "port", str(port)], check=False)
def reset_firewall():
subprocess.run(["ufw", "--force", "reset"], check=False)
subprocess.run(["ufw", "default", "deny", "incoming"], check=False)
subprocess.run(["ufw", "default", "deny", "outgoing"], check=False)
# Outbound essentials + LAN
for port in ("53", "80", "443", "123", "67", "68"):
subprocess.run(["ufw", "allow", "out", port], check=False)
subprocess.run(["ufw", "allow", "out", "on", "lo"], check=False)
for subnet in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "169.254.0.0/16"):
subprocess.run(["ufw", "allow", "out", "to", subnet], check=False)
for subnet in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "169.254.0.0/16"):
for port in ("22", "80", "443", "5252", "5253"):
subprocess.run(["ufw", "allow", "from", subnet, "to", "any", "port", port], check=False)
subprocess.run(["ufw", "--force", "enable"], check=False)
class Handler(BaseHTTPRequestHandler):
"""Minimal JSON API for the dashboard (status, services, updates, reset)."""
def _send(self, code, data):
body = json.dumps(data).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, fmt, *args):
return
def do_GET(self):
if self.path.startswith("/api/status"):
uptime = float(open("/proc/uptime").read().split()[0])
load1, load5, load15 = os.getloadavg()
meminfo = {}
for ln in open("/proc/meminfo"):
k, v = ln.split(":", 1)
meminfo[k] = int(v.strip().split()[0])
total = meminfo.get("MemTotal", 0)//1024
free = meminfo.get("MemAvailable", 0)//1024
disk = shutil.disk_usage("/")
# CPU temperature (best-effort)
cpu_temp = None
for path in ("/sys/class/thermal/thermal_zone0/temp",):
if pathlib.Path(path).exists():
try:
cpu_temp = float(pathlib.Path(path).read_text().strip())/1000.0
break
except Exception:
pass
# LAN IP (first non-loopback)
ip_addr = None
try:
out = subprocess.check_output(["hostname", "-I"], text=True).strip()
ip_addr = out.split()[0] if out else None
except Exception:
pass
# OS version
os_ver = "DietPi"
try:
for line in pathlib.Path("/etc/os-release").read_text().splitlines():
if line.startswith("PRETTY_NAME="):
os_ver = line.split("=",1)[1].strip().strip('"')
break
except Exception:
pass
updates_state = auto_updates_state()
updates_config = read_updates_config(updates_state)
services = []
for svc in load_services():
svc = dict(svc)
port = svc.get("port")
if port:
svc["online"] = port_online("127.0.0.1", port)
svc["firewall_open"] = ufw_status_allows(port)
services.append(svc)
data = {
"hostname": socket.gethostname(),
"uptime_seconds": uptime,
"load": [load1, load5, load15],
"memory_mb": {"total": total, "free": free},
"disk_mb": {"total": disk.total//1024//1024, "free": disk.free//1024//1024},
"cpu_temp_c": cpu_temp,
"lan_ip": ip_addr,
"os_version": os_ver,
"auto_updates_enabled": updates_state.get("enabled", False),
"auto_updates": updates_state,
"updates_config": updates_config,
"reboot_required": reboot_required(),
"ready": READY_FILE.exists(),
"services": services
}
self._send(200, data)
elif self.path.startswith("/api/services"):
services = []
for svc in load_services():
svc = dict(svc)
port = svc.get("port")
if port:
svc["online"] = port_online("127.0.0.1", port)
svc["firewall_open"] = ufw_status_allows(port)
services.append(svc)
self._send(200, {"services": services})
elif self.path.startswith("/api/updates/auto"):
state = auto_updates_state()
self._send(200, {"enabled": state.get("enabled", False), "details": state})
elif self.path.startswith("/api/updates/config"):
cfg = read_updates_config()
self._send(200, cfg)
else:
self._send(404, {"error": "not found"})
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
payload = json.loads(self.rfile.read(length) or "{}")
if self.path.startswith("/api/reset"):
if payload.get("confirm") == "YES":
self._send(200, {"message": "Resetting and rebooting..."})
dbg("Factory reset triggered via API")
factory_reset()
else:
self._send(400, {"error": "type YES to confirm"})
return
if self.path.startswith("/api/updates/auto"):
enable = bool(payload.get("enable"))
set_auto_updates(enable)
dbg(f"Auto updates set to {enable}")
state = auto_updates_state()
return self._send(200, {"enabled": state.get("enabled", False), "details": state})
if self.path.startswith("/api/updates/config"):
try:
cfg = set_updates_config(payload or {})
dbg(f"Update settings applied: {cfg}")
return self._send(200, cfg)
except Exception as e:
dbg(f"Failed to apply updates config: {e}")
return self._send(500, {"error": str(e)})
if self.path.startswith("/api/services/add"):
name = payload.get("name")
port = int(payload.get("port", 0))
if not name or not port:
return self._send(400, {"error": "name and port required"})
if port in CORE_PORTS and name != CORE_NAME:
return self._send(400, {"error": f"Port {port} is reserved for {CORE_NAME}"})
services = load_services()
if any(s.get("port") == port for s in services):
return self._send(400, {"error": "port already exists"})
host = socket.gethostname()
scheme = payload.get("scheme")
if scheme not in ("http", "https"):
scheme = "https" if detect_https(host, port) else "http"
notice = (payload.get("notice") or "").strip()
notice_link = (payload.get("notice_link") or "").strip()
self_signed = bool(payload.get("self_signed"))
path = normalize_path(payload.get("path"))
svc = {"name": name, "port": port, "scheme": scheme, "url": f"{scheme}://{host}:{port}{path}"}
if notice:
svc["notice"] = notice
if notice_link:
svc["notice_link"] = notice_link
if self_signed:
svc["self_signed"] = True
if path:
svc["path"] = path
services.append(svc)
save_services(services)
try:
allow_port_lan(port)
except FirewallToolMissing as e:
return self._send(500, {"error": str(e)})
return self._send(200, {"services": services, "message": f"Added {name} on port {port} and opened LAN firewall"})
if self.path.startswith("/api/services/remove"):
port = int(payload.get("port", 0))
if not port:
return self._send(400, {"error": "port required"})
if port in CORE_PORTS:
return self._send(400, {"error": f"Cannot remove core service on port {port}"})
services = [s for s in load_services() if s.get("port") != port]
try:
remove_port_lan(port)
except FirewallToolMissing as e:
return self._send(500, {"error": str(e)})
save_services(services)
return self._send(200, {"services": services, "message": f"Removed service on port {port}"})
if self.path.startswith("/api/services/update"):
port = int(payload.get("port", 0))
new_name = payload.get("name")
new_port = payload.get("new_port")
new_scheme = payload.get("scheme")
notice = payload.get("notice")
notice_link = payload.get("notice_link")
new_path = payload.get("path")
self_signed = payload.get("self_signed")
services = load_services()
updated = False
for svc in services:
if svc.get("port") == port:
if new_name:
# Prevent renaming core service to something else if still on core port
if port in CORE_PORTS and new_name != CORE_NAME:
return self._send(400, {"error": f"Core service on port {port} must stay named {CORE_NAME}"})
svc["name"] = new_name
target_port = svc.get("port")
port_changed = False
if new_port is not None:
new_port_int = int(new_port)
if new_port_int != port:
if new_port_int in CORE_PORTS and svc.get("name") != CORE_NAME:
return self._send(400, {"error": f"Port {new_port_int} is reserved for {CORE_NAME}"})
if any(s.get("port") == new_port_int and s is not svc for s in services):
return self._send(400, {"error": "new port already in use"})
try:
remove_port_lan(port)
allow_port_lan(new_port_int)
except FirewallToolMissing as e:
return self._send(500, {"error": str(e)})
svc["port"] = new_port_int
target_port = new_port_int
port_changed = True
host = socket.gethostname()
if new_path is not None:
path = normalize_path(new_path)
if path:
svc["path"] = path
elif "path" in svc:
svc.pop("path", None)
else:
path = normalize_path(svc.get("path"))
if path:
svc["path"] = path
if new_scheme:
scheme = new_scheme if new_scheme in ("http", "https") else None
else:
scheme = svc.get("scheme")
if not scheme or scheme == "auto":
scheme = "https" if detect_https(host, target_port) else "http"
svc["scheme"] = scheme
svc["url"] = f"{scheme}://{host}:{target_port}{path}"
if notice is not None:
text = (notice or "").strip()
if text:
svc["notice"] = text
elif "notice" in svc:
svc.pop("notice", None)
if notice_link is not None:
link = (notice_link or "").strip()
if link:
svc["notice_link"] = link
elif "notice_link" in svc:
svc.pop("notice_link", None)
if self_signed is not None:
if bool(self_signed):
svc["self_signed"] = True
else:
svc.pop("self_signed", None)
updated = True
break
if not updated:
return self._send(404, {"error": "service not found"})
save_services(services)
return self._send(200, {"services": services, "message": "Service updated"})
self._send(404, {"error": "not found"})
def main():
load_services()
server = HTTPServer((HOST, PORT), Handler)
server.serve_forever()
if __name__ == "__main__":
main()

391
pikit-prep-spec.md Normal file
View File

@@ -0,0 +1,391 @@
# Pi-Kit DietPi Image Prep Spec
This file defines how to design a **prep script** for a DietPi-based Pi-Kit image.
The scripts 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 doesnt 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:
- Lets 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 theyre 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 products 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-Kits security behavior.
- DO NOT:
- Flush or reset firewall rules unless its clearly a dev-only configuration (and thats 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 DietPis 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 doesnt 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.
---

245
pikit-prep.sh Normal file
View File

@@ -0,0 +1,245 @@
#!/bin/bash
# Pi-Kit DietPi image prep script
# Cleans host-unique data, logs, caches, and temp files per pikit-prep-spec.
set -euo pipefail
status() { printf '[%s] %s\n' "$1" "$2"; }
clean_logs_dir() {
local dir="$1" pattern="${2:-*}"
if [ -d "$dir" ]; then
find "$dir" -type f -name "$pattern" -print -delete 2>/dev/null | sed 's/^/[CLEANED] /' || true
status CLEANED "logs $pattern in $dir"
else
status SKIP "$dir (missing)"
fi
}
truncate_file() {
local file="$1"
if [ -e "$file" ]; then
:> "$file" && status CLEANED "truncated $file" || status FAIL "truncate $file"
else
status SKIP "$file (missing)"
fi
}
clean_file() {
local path="$1"
if [ -e "$path" ]; then
rm -f "$path" && status CLEANED "$path" || status FAIL "$path"
else
status SKIP "$path (missing)"
fi
}
clean_dir_files() {
local dir="$1" pattern="$2"
if [ -d "$dir" ]; then
find "$dir" -type f -name "$pattern" -print -delete 2>/dev/null | sed 's/^/[CLEANED] /' || true
status CLEANED "files $pattern in $dir"
else
status SKIP "$dir (missing)"
fi
}
truncate_dir() {
local dir="$1"
if [ -d "$dir" ]; then
# keep systemd-private dirs intact while services run
find "$dir" -mindepth 1 -maxdepth 1 ! -path "$dir/systemd-private-*" -exec rm -rf {} + 2>/dev/null
status CLEANED "$dir"
else
status SKIP "$dir (missing)"
fi
}
clean_backups() {
local dir="$1"
if [ -d "$dir" ]; then
find "$dir" -type f \( -name '*~' -o -name '*.bak*' -o -name '*.orig*' \) -print -delete 2>/dev/null | sed 's/^/[CLEANED] /' || true
status CLEANED "backup/editor files in $dir"
else
status SKIP "$dir (missing)"
fi
}
# --- Identity ---
# Keep machine-id file present but empty so systemd regenerates cleanly on next boot.
truncate -s 0 /etc/machine-id && status CLEANED /etc/machine-id || status FAIL /etc/machine-id
mkdir -p /var/lib/dbus || true
rm -f /var/lib/dbus/machine-id
ln -s /etc/machine-id /var/lib/dbus/machine-id && status CLEANED "/var/lib/dbus/machine-id -> /etc/machine-id" || status FAIL "/var/lib/dbus/machine-id"
clean_file /var/lib/systemd/random-seed
# --- SSH host keys ---
if ls /etc/ssh/ssh_host_* >/dev/null 2>&1; then
rm -f /etc/ssh/ssh_host_* && status CLEANED "SSH host keys" || status FAIL "SSH host keys"
else
status SKIP "SSH host keys (none)"
fi
# --- SSH client traces ---
:> /root/.ssh/known_hosts 2>/dev/null && status CLEANED "/root/.ssh/known_hosts" || status SKIP "/root/.ssh/known_hosts"
:> /home/dietpi/.ssh/known_hosts 2>/dev/null && status CLEANED "/home/dietpi/.ssh/known_hosts" || status SKIP "/home/dietpi/.ssh/known_hosts"
:> /home/dietpi/.ssh/authorized_keys 2>/dev/null && status CLEANED "/home/dietpi/.ssh/authorized_keys" || status SKIP "/home/dietpi/.ssh/authorized_keys"
:> /root/.ssh/authorized_keys 2>/dev/null && status CLEANED "/root/.ssh/authorized_keys" || status SKIP "/root/.ssh/authorized_keys"
# --- Shell history ---
:> /root/.bash_history 2>/dev/null && status CLEANED "/root/.bash_history" || status SKIP "/root/.bash_history"
:> /home/dietpi/.bash_history 2>/dev/null && status CLEANED "/home/dietpi/.bash_history" || status SKIP "/home/dietpi/.bash_history"
# --- Ready flag ---
clean_file /var/run/pikit-ready
# --- Backup/editor cruft ---
clean_backups /var/www/pikit-web
clean_backups /usr/local/bin
# --- Logs ---
clean_dir_files /var/log "*"
clean_dir_files /var/log/nginx "*"
# systemd journal (persistent) if present
if [ -d /var/log/journal ]; then
find /var/log/journal -type f -print -delete 2>/dev/null | sed 's/^/[CLEANED] /' || true
status CLEANED "/var/log/journal"
else
status SKIP "/var/log/journal (missing)"
fi
# crash dumps
if [ -d /var/crash ]; then
find /var/crash -type f -print -delete 2>/dev/null | sed 's/^/[CLEANED] /' || true
status CLEANED "/var/crash"
else
status SKIP "/var/crash (missing)"
fi
# Service-specific logs (best-effort, skip if absent)
if command -v pihole >/dev/null 2>&1; then
pihole -f >/dev/null 2>&1 && status CLEANED "pihole logs via pihole -f" || status FAIL "pihole -f"
clean_logs_dir /var/log/pihole '*'
clean_file /etc/pihole/pihole-FTL.db # resets long-term query history; leave gravity.db untouched
fi
if [ -x /opt/AdGuardHome/AdGuardHome ]; then
clean_logs_dir /var/opt/AdGuardHome/data/logs '*'
clean_file /opt/AdGuardHome/data/querylog.db
fi
if command -v ufw >/dev/null 2>&1; then
truncate_file /var/log/ufw.log
fi
if command -v fail2ban-client >/dev/null 2>&1; then
truncate_file /var/log/fail2ban.log
fi
clean_logs_dir /var/log/unbound '*'
clean_logs_dir /var/log/dnsmasq '*'
clean_logs_dir /var/log/powerdns '*'
clean_logs_dir /var/lib/technitium-dns/Logs '*'
clean_logs_dir /var/log/jellyfin '*'
clean_logs_dir /var/lib/jellyfin/log '*'
clean_logs_dir /var/log/jellyseerr '*'
clean_logs_dir /opt/jellyseerr/logs '*'
clean_logs_dir /var/log/ustreamer '*'
clean_logs_dir /var/log/gitea '*'
clean_logs_dir /var/lib/gitea/log '*'
clean_logs_dir /var/log/fmd '*'
clean_logs_dir /var/log/uptime-kuma '*'
clean_logs_dir /opt/uptime-kuma/data/logs '*'
clean_logs_dir /var/log/romm '*'
clean_logs_dir /var/log/privatebin '*'
clean_logs_dir /var/log/crafty '*'
clean_logs_dir /var/log/rustdesk '*'
clean_logs_dir /var/log/memos '*'
clean_logs_dir /var/lib/memos/logs '*'
clean_logs_dir /var/log/traccar '*'
clean_logs_dir /var/log/webmin '*'
clean_logs_dir /var/log/homarr '*'
clean_logs_dir /var/log/termix '*'
clean_logs_dir /var/log/syncthing '*'
clean_logs_dir /var/log/netdata '*'
clean_logs_dir /var/lib/netdata/dbengine '*'
clean_logs_dir /var/log/AdGuardHome '*'
# DB / metrics / web stacks
clean_logs_dir /var/log/mysql '*'
clean_logs_dir /var/log/mariadb '*'
clean_logs_dir /var/log/postgresql '*'
truncate_file /var/log/redis/redis-server.log
clean_logs_dir /var/log/influxdb '*'
clean_logs_dir /var/log/prometheus '*'
clean_logs_dir /var/log/grafana '*'
clean_logs_dir /var/log/loki '*'
clean_logs_dir /var/log/caddy '*'
clean_logs_dir /var/log/apache2 '*'
clean_logs_dir /var/log/lighttpd '*'
clean_logs_dir /var/log/samba '*'
clean_logs_dir /var/log/mosquitto '*'
clean_logs_dir /var/log/openvpn '*'
clean_logs_dir /var/log/wireguard '*'
clean_logs_dir /var/log/node-red '*'
truncate_file /var/log/nodered-install.log
clean_logs_dir /var/log/transmission-daemon '*'
clean_logs_dir /var/log/deluge '*'
clean_logs_dir /var/log/qbittorrent '*'
clean_logs_dir /var/log/paperless-ngx '*'
clean_logs_dir /var/log/photoprism '*'
clean_logs_dir /var/log/navidrome '*'
clean_logs_dir /var/log/minio '*'
clean_logs_dir /var/log/nzbget '*'
clean_logs_dir /var/log/sabnzbd '*'
clean_logs_dir /var/log/jackett '*'
clean_logs_dir /var/log/radarr '*'
clean_logs_dir /var/log/sonarr '*'
clean_logs_dir /var/log/lidarr '*'
clean_logs_dir /var/log/prowlarr '*'
clean_logs_dir /var/log/bazarr '*'
clean_logs_dir /var/log/overseerr '*'
clean_logs_dir /var/log/emby-server '*'
# App-specific logs stored with app data (truncate, keep structure)
truncate_file /home/homeassistant/.homeassistant/home-assistant.log
clean_logs_dir /home/homeassistant/.homeassistant/logs '*'
truncate_file /var/www/nextcloud/data/nextcloud.log
truncate_file /var/www/owncloud/data/owncloud.log
# Docker container JSON logs
if [ -d /var/lib/docker/containers ]; then
find /var/lib/docker/containers -type f -name '*-json.log' -print0 2>/dev/null | while IFS= read -r -d '' f; do
:> "$f" && status CLEANED "truncated $f" || status FAIL "truncate $f"
done
else
status SKIP "/var/lib/docker/containers (missing)"
fi
clean_file /var/log/wtmp.db
clean_dir_files /var/tmp/dietpi/logs "*"
# --- Caches ---
apt-get clean >/dev/null 2>&1 && status CLEANED "apt cache" || status FAIL "apt cache"
rm -rf /var/lib/apt/lists/* /var/cache/apt/* 2>/dev/null && status CLEANED "apt lists/cache" || status FAIL "apt lists/cache"
find /var/cache/debconf -type f -print -delete 2>/dev/null | sed 's/^/[CLEANED] /' || true
status CLEANED "/var/cache/debconf files"
# --- Temp directories ---
truncate_dir /tmp
truncate_dir /var/tmp
# --- DHCP leases ---
clean_file /var/lib/dhcp/dhclient.eth0.leases
# --- Nginx caches ---
if [ -d /var/lib/nginx ]; then
find /var/lib/nginx -mindepth 1 -maxdepth 1 -type d -exec rm -rf {} + 2>/dev/null
status CLEANED "/var/lib/nginx/*"
else
status SKIP "/var/lib/nginx"
fi
status DONE "Prep complete"
# Self-delete to avoid leaving the prep tool on the image.
rm -- "$0"

3
pikit-services.json Normal file
View File

@@ -0,0 +1,3 @@
[
{ "name": "DietPi Dashboard", "port": 5252, "scheme": "http" }
]

21
pikit-web/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Pi-Kit contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

21
pikit-web/LICENSE.txt Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Pi-Kit contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

41
pikit-web/RESCUE.md Normal file
View File

@@ -0,0 +1,41 @@
# Pi-Kit quick rescue (offline note)
Keep this handy if the web dashboard is down. Youre already in via SSH, so heres what to check next.
## Where to find this note
- `/root/RESCUE.md`
- `/home/dietpi/RESCUE.md`
- `/var/www/pikit-web/RESCUE.md`
## Fast service reset
```bash
sudo systemctl status nginx pikit-api # check
sudo systemctl restart nginx pikit-api # restart both
```
## Logs to inspect
```bash
sudo tail -n 100 /var/log/nginx/error.log
sudo tail -n 100 /var/log/pikit-api.log
```
## Check system health
```bash
df -h # disk space
free -h # memory
sudo systemctl status unattended-upgrades # auto-update service
sudo systemctl status apt-daily.timer apt-daily-upgrade.timer
```
## If services wont start
```bash
sudo nginx -t # validate nginx config
sudo journalctl -u nginx -u pikit-api -n 100 # detailed service logs
```
## Licenses (for distribution)
- `/var/www/pikit-web/LICENSE` (MIT for Pi-Kit)
- `/var/www/pikit-web/THIRD-PARTY-LICENSES.md`
- `/var/www/pikit-web/assets/fonts/OFL.txt`
Tip: after any change, `sudo systemctl restart nginx pikit-api` then re-check logs above.

View File

@@ -0,0 +1,21 @@
# Third-Party Licenses
## Fonts (SIL Open Font License 1.1)
- Red Hat Display
- Red Hat Text
- Space Grotesk
- Manrope
- DM Sans
- Sora
- Chivo
- Atkinson Hyperlegible
- IBM Plex Sans
Full text: assets/fonts/OFL.txt
## Frontend tooling
- Vite — MIT License
- @fontsource packages — MIT License
## Test/dev
- Playwright — Apache License 2.0

View File

@@ -0,0 +1,21 @@
# Third-Party Licenses
## Fonts (SIL Open Font License 1.1)
- Red Hat Display
- Red Hat Text
- Space Grotesk
- Manrope
- DM Sans
- Sora
- Chivo
- Atkinson Hyperlegible
- IBM Plex Sans
Full text: assets/fonts/OFL.txt
## Frontend tooling
- Vite — MIT License
- @fontsource packages — MIT License
## Test/dev
- Playwright — Apache License 2.0

89
pikit-web/assets/api.js Normal file
View File

@@ -0,0 +1,89 @@
// Lightweight fetch wrapper for the Pi-Kit API endpoints exposed by the mock server
// and on-device Python API. All helpers below return parsed JSON or throw the
// JSON error body when the response is not 2xx.
const headers = { "Content-Type": "application/json" };
export async function api(path, opts = {}) {
// When running `npm run dev` without the backend, allow mock JSON from /data/
const isMock = import.meta?.env?.MODE === 'development' && path.startsWith('/api');
const target = isMock
? path.replace('/api/status', '/data/mock-status.json').replace('/api/updates/config', '/data/mock-updates.json')
: path;
const res = await fetch(target, { headers, ...opts });
// If mock files are missing, surface a clear error instead of JSON parse of HTML
const text = await res.text();
let data;
try {
data = JSON.parse(text);
} catch (e) {
throw new Error(`Expected JSON from ${target}, got: ${text.slice(0, 120)}...`);
}
if (!res.ok) throw data;
return data;
}
export const getStatus = () => api("/api/status");
export const toggleUpdates = (enable) =>
api("/api/updates/auto", {
method: "POST",
body: JSON.stringify({ enable }),
});
export const getUpdateConfig = () => api("/api/updates/config");
export const saveUpdateConfig = (config) =>
api("/api/updates/config", {
method: "POST",
body: JSON.stringify(config),
});
export const triggerReset = (confirm) =>
api("/api/reset", {
method: "POST",
body: JSON.stringify({ confirm }),
});
export const addService = ({
name,
port,
scheme,
path,
notice,
notice_link,
self_signed,
}) =>
api("/api/services/add", {
method: "POST",
body: JSON.stringify({ name, port, scheme, path, notice, notice_link, self_signed }),
});
export const updateService = ({
port,
name,
new_port,
scheme,
path,
notice,
notice_link,
self_signed,
}) =>
api("/api/services/update", {
method: "POST",
body: JSON.stringify({
port,
name,
new_port,
scheme,
path,
notice,
notice_link,
self_signed,
}),
});
export const removeService = ({ port }) =>
api("/api/services/remove", {
method: "POST",
body: JSON.stringify({ port }),
});

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,101 @@
This package includes open fonts licensed under the SIL Open Font License, Version 1.1.
Fonts covered (used by Pi-Kit):
- Red Hat Display
- Red Hat Text
- Space Grotesk
- Manrope
- DM Sans
- Sora
- Chivo
- Atkinson Hyperlegible
- IBM Plex Sans
Full license text:
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

635
pikit-web/assets/main.js Normal file
View File

@@ -0,0 +1,635 @@
// Entry point for the dashboard: wires UI events, pulls status, and initializes
// feature modules (services, settings, stats).
import { getStatus, triggerReset } from "./api.js";
import { placeholderStatus, renderStats } from "./status.js";
import { initServiceControls, renderServices } from "./services.js";
import { initSettings } from "./settings.js";
import { initUpdateSettings, isUpdatesDirty } from "./update-settings.js";
const servicesGrid = document.getElementById("servicesGrid");
const heroStats = document.getElementById("heroStats");
const refreshHintMain = document.getElementById("refreshHintMain");
const refreshHintServices = document.getElementById("refreshHintServices");
const refreshFlagTop = document.getElementById("refreshFlagTop");
const themeToggle = document.getElementById("themeToggle");
const themeToggleIcon = document.getElementById("themeToggleIcon");
const animToggle = document.getElementById("animToggle");
const resetConfirm = document.getElementById("resetConfirm");
const resetBtn = document.getElementById("resetBtn");
const updatesToggle = document.getElementById("updatesToggle");
const updatesStatus = document.getElementById("updatesStatus");
const updatesFlagTop = document.getElementById("updatesFlagTop");
const updatesNoteTop = document.getElementById("updatesNoteTop");
const tempFlagTop = document.getElementById("tempFlagTop");
const refreshIntervalInput = document.getElementById("refreshIntervalInput");
const refreshIntervalSave = document.getElementById("refreshIntervalSave");
const refreshIntervalMsg = document.getElementById("refreshIntervalMsg");
const toastPosSelect = document.getElementById("toastPosSelect");
const toastAnimSelect = document.getElementById("toastAnimSelect");
const toastSpeedInput = document.getElementById("toastSpeedInput");
const toastDurationInput = document.getElementById("toastDurationInput");
const fontSelect = document.getElementById("fontSelect");
const updatesScope = document.getElementById("updatesScope");
const updateTimeInput = document.getElementById("updateTimeInput");
const upgradeTimeInput = document.getElementById("upgradeTimeInput");
const updatesCleanup = document.getElementById("updatesCleanup");
const updatesBandwidth = document.getElementById("updatesBandwidth");
const updatesRebootToggle = document.getElementById("updatesRebootToggle");
const updatesRebootTime = document.getElementById("updatesRebootTime");
const updatesRebootUsers = document.getElementById("updatesRebootUsers");
const updatesSaveBtn = document.getElementById("updatesSaveBtn");
const updatesMsg = document.getElementById("updatesMsg");
const updatesUnsavedNote = document.getElementById("updatesUnsavedNote");
const updatesSection = document.getElementById("updatesSection");
const svcName = document.getElementById("svcName");
const svcPort = document.getElementById("svcPort");
const svcPath = document.getElementById("svcPath");
const svcAddBtn = document.getElementById("svcAddBtn");
const svcMsg = document.getElementById("svcMsg");
const svcScheme = document.getElementById("svcScheme");
const svcNotice = document.getElementById("svcNotice");
const svcNoticeLink = document.getElementById("svcNoticeLink");
const svcSelfSigned = document.getElementById("svcSelfSigned");
const svcSelfSignedLabel = document.querySelector("label[for='svcSelfSigned']") || null;
const addServiceModal = document.getElementById("addServiceModal");
const addSvcClose = document.getElementById("addSvcClose");
const addServiceOpen = document.getElementById("addServiceOpen");
const menuModal = document.getElementById("menuModal");
const menuTitle = document.getElementById("menuTitle");
const menuSubtitle = document.getElementById("menuSubtitle");
const menuRename = document.getElementById("menuRename");
const menuPort = document.getElementById("menuPort");
const menuPath = document.getElementById("menuPath");
const menuScheme = document.getElementById("menuScheme");
const menuNotice = document.getElementById("menuNotice");
const menuNoticeLink = document.getElementById("menuNoticeLink");
const menuSelfSigned = document.getElementById("menuSelfSigned");
const menuSaveBtn = document.getElementById("menuSaveBtn");
const menuCancelBtn = document.getElementById("menuCancelBtn");
const menuRemoveBtn = document.getElementById("menuRemoveBtn");
const menuMsg = document.getElementById("menuMsg");
const menuClose = document.getElementById("menuClose");
const advBtn = document.getElementById("advBtn");
const advModal = document.getElementById("advModal");
const advClose = document.getElementById("advClose");
const helpBtn = document.getElementById("helpBtn");
const helpModal = document.getElementById("helpModal");
const helpClose = document.getElementById("helpClose");
const aboutBtn = document.getElementById("aboutBtn");
const aboutModal = document.getElementById("aboutModal");
const aboutClose = document.getElementById("aboutClose");
const readyOverlay = document.getElementById("readyOverlay");
const busyOverlay = document.getElementById("busyOverlay");
const busyTitle = document.getElementById("busyTitle");
const busyText = document.getElementById("busyText");
const toastContainer = document.getElementById("toastContainer");
const TOAST_POS_KEY = "pikit-toast-pos";
const TOAST_ANIM_KEY = "pikit-toast-anim";
const TOAST_SPEED_KEY = "pikit-toast-speed";
const TOAST_DURATION_KEY = "pikit-toast-duration";
const FONT_KEY = "pikit-font";
const ALLOWED_TOAST_POS = [
"bottom-center",
"bottom-right",
"bottom-left",
"top-right",
"top-left",
"top-center",
];
const ALLOWED_TOAST_ANIM = ["slide-in", "fade", "pop", "bounce", "drop", "grow"];
const ALLOWED_FONTS = ["redhat", "space", "manrope", "dmsans", "sora", "chivo", "atkinson", "plex"];
let toastPosition = "bottom-center";
let toastAnimation = "slide-in";
let toastDurationMs = 5000;
let toastSpeedMs = 300;
let fontChoice = "redhat";
function applyToastSettings() {
if (!toastContainer) return;
toastContainer.className = `toast-container pos-${toastPosition}`;
document.documentElement.style.setProperty("--toast-speed", `${toastSpeedMs}ms`);
const dir = toastPosition.startsWith("top") ? -1 : 1;
const isLeft = toastPosition.includes("left");
const isRight = toastPosition.includes("right");
const slideX = isLeft ? -26 : isRight ? 26 : 0;
const slideY = isLeft || isRight ? 0 : dir * 24;
document.documentElement.style.setProperty("--toast-slide-offset", `${dir * 24}px`);
document.documentElement.style.setProperty("--toast-dir", `${dir}`);
document.documentElement.style.setProperty("--toast-slide-x", `${slideX}px`);
document.documentElement.style.setProperty("--toast-slide-y", `${slideY}px`);
if (toastDurationInput) toastDurationInput.value = toastDurationMs;
}
function applyFontSetting() {
document.documentElement.setAttribute("data-font", fontChoice);
if (fontSelect) fontSelect.value = fontChoice;
}
function loadToastSettings() {
try {
const posSaved = localStorage.getItem(TOAST_POS_KEY);
if (ALLOWED_TOAST_POS.includes(posSaved)) toastPosition = posSaved;
const animSaved = localStorage.getItem(TOAST_ANIM_KEY);
const migrated =
animSaved === "slide-up" || animSaved === "slide-left" || animSaved === "slide-right"
? "slide-in"
: animSaved;
if (migrated && ALLOWED_TOAST_ANIM.includes(migrated)) toastAnimation = migrated;
const savedSpeed = Number(localStorage.getItem(TOAST_SPEED_KEY));
if (!Number.isNaN(savedSpeed) && savedSpeed >= 100 && savedSpeed <= 3000) {
toastSpeedMs = savedSpeed;
}
const savedDur = Number(localStorage.getItem(TOAST_DURATION_KEY));
if (!Number.isNaN(savedDur) && savedDur >= 1000 && savedDur <= 15000) {
toastDurationMs = savedDur;
}
const savedFont = localStorage.getItem(FONT_KEY);
if (ALLOWED_FONTS.includes(savedFont)) fontChoice = savedFont;
} catch (e) {
console.warn("Toast settings load failed", e);
}
if (toastPosSelect) toastPosSelect.value = toastPosition;
if (toastAnimSelect) toastAnimSelect.value = toastAnimation;
if (toastSpeedInput) toastSpeedInput.value = toastSpeedMs;
if (toastDurationInput) toastDurationInput.value = toastDurationMs;
if (fontSelect) fontSelect.value = fontChoice;
applyToastSettings();
applyFontSetting();
}
function persistToastSettings() {
try {
localStorage.setItem(TOAST_POS_KEY, toastPosition);
localStorage.setItem(TOAST_ANIM_KEY, toastAnimation);
localStorage.setItem(TOAST_SPEED_KEY, String(toastSpeedMs));
localStorage.setItem(TOAST_DURATION_KEY, String(toastDurationMs));
localStorage.setItem(FONT_KEY, fontChoice);
} catch (e) {
console.warn("Toast settings save failed", e);
}
}
function showToast(message, type = "info") {
if (!toastContainer || !message) return;
const t = document.createElement("div");
t.className = `toast ${type} anim-${toastAnimation}`;
t.textContent = message;
toastContainer.appendChild(t);
const animOn = document.documentElement.getAttribute("data-anim") !== "off";
if (!animOn) {
t.classList.add("show");
} else {
requestAnimationFrame(() => t.classList.add("show"));
}
const duration = toastDurationMs;
setTimeout(() => {
const all = Array.from(toastContainer.querySelectorAll(".toast"));
const others = all.filter((el) => el !== t && !el.classList.contains("leaving"));
const first = new Map(
others.map((el) => [el, el.getBoundingClientRect()]),
);
t.classList.add("leaving");
// force layout
void t.offsetHeight;
requestAnimationFrame(() => {
const second = new Map(
others.map((el) => [el, el.getBoundingClientRect()]),
);
others.forEach((el) => {
const dy = first.get(el).top - second.get(el).top;
if (Math.abs(dy) > 0.5) {
el.style.transition = "transform var(--toast-speed, 0.28s) ease";
el.style.transform = `translateY(${dy}px)`;
requestAnimationFrame(() => {
el.style.transform = "";
});
}
});
});
const removeDelay = animOn ? toastSpeedMs : 0;
setTimeout(() => {
t.classList.remove("show");
t.remove();
// clear transition styling
others.forEach((el) => (el.style.transition = ""));
}, removeDelay);
}, duration);
}
function applyTooltips() {
const tips = {
updatesFlagTop: "Auto updates status; configure in Settings → Automatic updates.",
refreshFlagTop: "Auto-refresh interval; change in Settings → Refresh interval.",
tempFlagTop: "CPU temperature status; see details in the hero stats below.",
themeToggle: "Toggle light or dark theme",
helpBtn: "Open quick help",
advBtn: "Open settings",
animToggle: "Enable or disable dashboard animations",
refreshIntervalInput: "Seconds between automatic refreshes (5-120)",
refreshIntervalSave: "Save refresh interval",
svcName: "Display name for the service card",
svcPort: "Port number the service listens on",
svcPath: "Optional path like /admin",
svcScheme: "Choose HTTP or HTTPS link",
svcSelfSigned: "Mark service as using a self-signed certificate",
svcNotice: "Optional note shown on the service card",
svcNoticeLink: "Optional link for more info about the service",
svcAddBtn: "Add the service to the dashboard",
updatesToggle: "Turn unattended upgrades on or off",
updatesScope: "Select security-only or all updates",
updateTimeInput: "Time to download updates (24h)",
upgradeTimeInput: "Time to install updates (24h)",
updatesCleanup: "Remove unused dependencies after upgrades",
updatesBandwidth: "Limit download bandwidth in KB/s (0 = unlimited)",
updatesRebootToggle: "Auto-reboot if required by updates",
updatesRebootTime: "Scheduled reboot time when auto-reboot is on",
updatesRebootUsers: "Allow reboot even if users are logged in",
updatesSaveBtn: "Save unattended-upgrades settings",
resetConfirm: "Type YES to enable factory reset",
resetBtn: "Factory reset this Pi-Kit",
menuRename: "Change the service display name",
menuPort: "Change the service port",
menuPath: "Optional service path",
menuScheme: "Switch between HTTP and HTTPS",
menuSelfSigned: "Mark the service as self-signed",
menuNotice: "Edit the notice text shown on the card",
menuNoticeLink: "Optional link for the notice",
menuSaveBtn: "Save service changes",
menuCancelBtn: "Cancel changes",
menuRemoveBtn: "Remove this service",
};
Object.entries(tips).forEach(([id, text]) => {
const el = document.getElementById(id);
if (el) el.title = text;
});
}
// Clamp name inputs to 30 chars
[svcName, menuRename].forEach((el) => {
if (!el) return;
el.setAttribute("maxlength", "32");
el.addEventListener("input", () => {
if (el.value.length > 32) el.value = el.value.slice(0, 32);
});
});
function setUpdatesUI(enabled) {
const on = !!enabled;
updatesToggle.checked = on;
updatesStatus.textContent = on ? "On" : "Off";
updatesStatus.classList.toggle("chip-on", on);
updatesStatus.classList.toggle("chip-off", !on);
}
async function loadStatus() {
try {
const data = await getStatus();
renderStats(heroStats, data);
renderServices(servicesGrid, data.services, { openAddService });
const updatesEnabled =
data?.auto_updates?.enabled ?? data.auto_updates_enabled;
if (updatesEnabled !== undefined && !isUpdatesDirty()) {
setUpdatesUI(updatesEnabled);
}
// Updates chip + reboot note
updatesFlagEl(
updatesEnabled === undefined ? null : updatesEnabled === true,
);
const cfg = data.updates_config || {};
const rebootReq = data.reboot_required;
setTempFlag(data.cpu_temp_c);
if (updatesNoteTop) {
updatesNoteTop.textContent = "";
updatesNoteTop.classList.remove("note-warn");
if (rebootReq) {
if (cfg.auto_reboot) {
updatesNoteTop.textContent = `Reboot scheduled at ${cfg.reboot_time || "02:00"}.`;
} else {
updatesNoteTop.textContent = "Reboot required. Please reboot when you can.";
updatesNoteTop.classList.add("note-warn");
}
}
}
if (readyOverlay) {
if (data.ready) {
readyOverlay.classList.add("hidden");
} else {
readyOverlay.classList.remove("hidden");
// When not ready, retry periodically until API reports ready
setTimeout(loadStatus, 3000);
}
}
} catch (e) {
console.error(e);
renderStats(heroStats, placeholderStatus);
}
}
function setTempFlag(tempC) {
if (!tempFlagTop) return;
const t = typeof tempC === "number" ? tempC : null;
let label = "Temp: n/a";
tempFlagTop.classList.remove("chip-on", "chip-warm", "chip-off");
if (t !== null) {
if (t < 55) {
label = "Temp: OK";
tempFlagTop.classList.add("chip-on");
} else if (t < 70) {
label = "Temp: Warm";
tempFlagTop.classList.add("chip-warm");
} else {
label = "Temp: Hot";
tempFlagTop.classList.add("chip-off");
}
}
tempFlagTop.textContent = label;
}
function updatesFlagEl(enabled) {
if (!updatesFlagTop) return;
updatesFlagTop.textContent = "Auto updates";
updatesFlagTop.classList.remove("chip-on", "chip-off");
if (enabled === true) updatesFlagTop.classList.add("chip-on");
else if (enabled === false) updatesFlagTop.classList.add("chip-off");
}
function wireModals() {
advBtn.onclick = () => advModal.classList.remove("hidden");
advClose.onclick = () => advModal.classList.add("hidden");
helpBtn.onclick = () => helpModal.classList.remove("hidden");
helpClose.onclick = () => helpModal.classList.add("hidden");
aboutBtn.onclick = () => aboutModal.classList.remove("hidden");
aboutClose.onclick = () => aboutModal.classList.add("hidden");
menuClose.onclick = () => menuModal.classList.add("hidden");
addServiceOpen?.addEventListener("click", openAddService);
addSvcClose?.addEventListener("click", () => addServiceModal?.classList.add("hidden"));
addServiceModal?.addEventListener("click", (e) => {
if (e.target === addServiceModal) addServiceModal.classList.add("hidden");
});
}
function showBusy(title = "Working…", text = "This may take a few seconds.") {
if (!busyOverlay) return;
busyTitle.textContent = title;
busyText.textContent = text || "";
busyText.classList.toggle("hidden", !text);
busyOverlay.classList.remove("hidden");
}
function hideBusy() {
busyOverlay?.classList.add("hidden");
}
// Testing hook
if (typeof window !== "undefined") {
window.__pikitTest = window.__pikitTest || {};
window.__pikitTest.showBusy = showBusy;
window.__pikitTest.hideBusy = hideBusy;
}
function wireResetAndUpdates() {
resetBtn.onclick = async () => {
resetBtn.disabled = true;
try {
await triggerReset(resetConfirm.value.trim());
alert("Resetting now. The device will reboot.");
} catch (e) {
alert(e.error || "Reset failed");
} finally {
resetBtn.disabled = false;
}
};
resetConfirm.addEventListener("input", () => {
resetBtn.disabled = resetConfirm.value.trim() !== "YES";
});
}
function wireAccordions() {
const forceOpen = typeof window !== "undefined" && window.__pikitTest?.forceAccordionsOpen;
const accordions = document.querySelectorAll(".accordion");
if (forceOpen) {
accordions.forEach((a) => a.classList.add("open"));
return;
}
document.querySelectorAll(".accordion-toggle").forEach((btn) => {
btn.addEventListener("click", () => {
const acc = btn.closest(".accordion");
if (acc.classList.contains("open")) {
acc.classList.remove("open");
} else {
// Keep a single accordion expanded at a time for readability
accordions.forEach((a) => a.classList.remove("open"));
acc.classList.add("open");
}
});
});
}
function collapseAccordions() {
document.querySelectorAll(".accordion").forEach((a) => a.classList.remove("open"));
}
function openAddService() {
if (addServiceModal) addServiceModal.classList.remove("hidden");
document.getElementById("svcName")?.focus();
}
if (typeof window !== "undefined") {
window.__pikitOpenAddService = openAddService;
}
function main() {
applyTooltips();
wireModals();
wireResetAndUpdates();
wireAccordions();
loadToastSettings();
if (advClose) {
advClose.onclick = () => {
advModal.classList.add("hidden");
collapseAccordions();
};
}
initServiceControls({
gridEl: servicesGrid,
onChange: loadStatus,
overlay: { show: showBusy, hide: hideBusy },
toast: showToast,
openAddService,
menu: {
modal: menuModal,
title: menuTitle,
subtitle: menuSubtitle,
renameInput: menuRename,
portInput: menuPort,
pathInput: menuPath,
schemeSelect: menuScheme,
saveBtn: menuSaveBtn,
cancelBtn: menuCancelBtn,
removeBtn: menuRemoveBtn,
msg: menuMsg,
noticeInput: menuNotice,
noticeLinkInput: menuNoticeLink,
selfSignedInput: menuSelfSigned,
},
addForm: {
nameInput: svcName,
portInput: svcPort,
pathInput: svcPath,
schemeSelect: svcScheme,
addBtn: svcAddBtn,
msg: svcMsg,
noticeInput: svcNotice,
noticeLinkInput: svcNoticeLink,
selfSignedInput: svcSelfSigned,
},
});
initSettings({
refreshHintMain,
refreshHintServices,
refreshFlagTop,
refreshIntervalInput,
refreshIntervalSave,
refreshIntervalMsg,
themeToggle,
themeToggleIcon,
animToggle,
onTick: loadStatus,
toast: showToast,
onThemeToggle: () => {
document.body.classList.add("theming");
setTimeout(() => document.body.classList.remove("theming"), 300);
},
});
// Toast controls
toastPosSelect?.addEventListener("change", () => {
const val = toastPosSelect.value;
if (ALLOWED_TOAST_POS.includes(val)) {
toastPosition = val;
applyToastSettings();
persistToastSettings();
} else {
toastPosSelect.value = toastPosition;
showToast("Invalid toast position", "error");
}
});
toastAnimSelect?.addEventListener("change", () => {
let val = toastAnimSelect.value;
if (val === "slide-up" || val === "slide-left" || val === "slide-right") val = "slide-in"; // migrate old label if cached
if (ALLOWED_TOAST_ANIM.includes(val)) {
toastAnimation = val;
persistToastSettings();
} else {
toastAnimSelect.value = toastAnimation;
showToast("Invalid toast animation", "error");
}
});
const clampSpeed = (val) => Math.min(3000, Math.max(100, val));
const clampDuration = (val) => Math.min(15000, Math.max(1000, val));
toastSpeedInput?.addEventListener("input", () => {
const raw = toastSpeedInput.value;
if (raw === "") return; // allow typing
const val = Number(raw);
if (Number.isNaN(val) || val < 100 || val > 3000) return; // wait until valid
toastSpeedMs = val;
applyToastSettings();
persistToastSettings();
});
toastSpeedInput?.addEventListener("blur", () => {
const raw = toastSpeedInput.value;
if (raw === "") {
toastSpeedInput.value = toastSpeedMs;
return;
}
const val = Number(raw);
if (Number.isNaN(val) || val < 100 || val > 3000) {
toastSpeedMs = clampSpeed(toastSpeedMs);
toastSpeedInput.value = toastSpeedMs;
showToast("Toast speed must be 100-3000 ms", "error");
return;
}
toastSpeedMs = val;
toastSpeedInput.value = toastSpeedMs;
applyToastSettings();
persistToastSettings();
});
toastDurationInput?.addEventListener("input", () => {
const raw = toastDurationInput.value;
if (raw === "") return; // allow typing
const val = Number(raw);
if (Number.isNaN(val) || val < 1000 || val > 15000) return; // wait until valid
toastDurationMs = val;
persistToastSettings();
});
toastDurationInput?.addEventListener("blur", () => {
const raw = toastDurationInput.value;
if (raw === "") {
toastDurationInput.value = toastDurationMs;
return;
}
const val = Number(raw);
if (Number.isNaN(val) || val < 1000 || val > 15000) {
toastDurationMs = clampDuration(toastDurationMs);
toastDurationInput.value = toastDurationMs;
showToast("Toast duration must be 1000-15000 ms", "error");
return;
}
toastDurationMs = val;
toastDurationInput.value = toastDurationMs;
persistToastSettings();
});
fontSelect?.addEventListener("change", () => {
const val = fontSelect.value;
if (!ALLOWED_FONTS.includes(val)) {
fontSelect.value = fontChoice;
showToast("Invalid font choice", "error");
return;
}
fontChoice = val;
applyFontSetting();
persistToastSettings();
});
toastTestBtn?.addEventListener("click", () => showToast("This is a test toast", "info"));
initUpdateSettings({
elements: {
updatesStatus,
updatesToggle,
scopeSelect: updatesScope,
updateTimeInput,
upgradeTimeInput,
cleanupToggle: updatesCleanup,
bandwidthInput: updatesBandwidth,
rebootToggle: updatesRebootToggle,
rebootTimeInput: updatesRebootTime,
rebootWithUsersToggle: updatesRebootUsers,
saveBtn: updatesSaveBtn,
msgEl: updatesMsg,
updatesUnsavedNote,
updatesSection,
},
onAfterSave: loadStatus,
overlay: { show: showBusy, hide: hideBusy },
toast: showToast,
});
// initial paint
renderStats(heroStats, placeholderStatus);
loadStatus();
}
main();

View File

@@ -0,0 +1,361 @@
import { addService, updateService, removeService } from "./api.js";
// Renders service cards and wires UI controls for add/edit/remove operations.
// All mutations round-trip through the API then invoke onChange to refresh data.
let noticeModalRefs = null;
const DEFAULT_SELF_SIGNED_MSG =
"This service uses a self-signed certificate. Expect a browser warning; proceed only if you trust this device.";
function isValidLink(str) {
if (!str) return true; // empty is allowed
try {
const u = new URL(str);
return u.protocol === "http:" || u.protocol === "https:";
} catch (e) {
return false;
}
}
function normalizePath(path) {
if (!path) return "";
const trimmed = path.trim();
if (!trimmed) return "";
if (/\s/.test(trimmed)) return null; // no spaces or tabs in paths
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return null;
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
}
function validateServiceFields({ name, port, path, notice, notice_link }, setMsg, toast) {
const fail = (m) => {
setMsg("");
toast?.(m, "error");
return false;
};
if (!name || name.trim().length < 2) return fail("Name must be at least 2 characters.");
if (name.length > 48) return fail("Name is too long (max 48 chars).");
if (!Number.isInteger(port) || port < 1 || port > 65535) return fail("Port must be 1-65535.");
if (path === null) return fail("Path must be relative (e.g. /admin) or blank.");
if (path.length > 200) return fail("Path is too long (max 200 chars).");
if (notice && notice.length > 180) return fail("Notice text too long (max 180 chars).");
if (notice_link && notice_link.length > 200) return fail("Notice link too long (max 200 chars).");
if (!isValidLink(notice_link)) return fail("Enter a valid URL (http/https) or leave blank.");
return true;
}
function ensureNoticeModal() {
if (noticeModalRefs) return noticeModalRefs;
const modal = document.createElement("div");
modal.className = "modal hidden";
modal.id = "noticeModal";
modal.innerHTML = `
<div class="modal-card">
<div class="panel-header">
<div>
<p class="eyebrow">Service notice</p>
<h3 id="noticeTitle"></h3>
</div>
<button class="ghost icon-btn close-btn" id="noticeClose" title="Close">&times;</button>
</div>
<div class="modal-body">
<p id="noticeText" class="hint"></p>
<a id="noticeLink" class="notice-link" target="_blank" rel="noopener"></a>
</div>
</div>
`;
document.body.appendChild(modal);
const title = modal.querySelector("#noticeTitle");
const text = modal.querySelector("#noticeText");
const link = modal.querySelector("#noticeLink");
const closeBtn = modal.querySelector("#noticeClose");
const close = () => modal.classList.add("hidden");
closeBtn.addEventListener("click", close);
modal.addEventListener("click", (e) => {
if (e.target === modal) close();
});
noticeModalRefs = { modal, title, text, link, close };
return noticeModalRefs;
}
export function renderServices(gridEl, services = [], { openAddService } = {}) {
if (!gridEl) return;
gridEl.innerHTML = "";
if (!services.length) {
gridEl.classList.add("empty");
gridEl.innerHTML = `
<div class="empty-state">
<p>No web services detected yet.</p>
<button id="addSvcCta">Add a service</button>
</div>`;
const cta = gridEl.querySelector("#addSvcCta");
cta?.addEventListener("click", () => {
if (typeof window !== "undefined" && window.__pikitOpenAddService) {
window.__pikitOpenAddService();
} else if (typeof openAddService === "function") {
openAddService();
}
});
return;
}
gridEl.classList.remove("empty");
services.forEach((svc) => {
const card = document.createElement("div");
const scheme = svc.scheme === "https" ? "https" : "http";
const path = normalizePath(svc.path) || "";
const url = svc.url || `${scheme}://pikit:${svc.port}${path}`;
const nameRaw = svc.name || svc.process || "service";
const name = nameRaw.slice(0, 32);
const isSelfSigned = !!svc.self_signed;
const hasCustomNotice = !!(svc.notice && svc.notice.trim());
const noticeText = hasCustomNotice ? svc.notice.trim() : "";
const noticeLink = hasCustomNotice ? svc.notice_link || "" : "";
card.className = `card clickable ${svc.online ? "" : "offline"}`.trim();
card.dataset.url = url;
card.dataset.path = path;
card.tabIndex = 0;
card.innerHTML = `
<div class="service-header">
<div class="status-dot ${svc.online ? "on" : "off"}"></div>
<div class="pill" title="${nameRaw}">${name}</div>
</div>
<div class="service-url">${url}</div>
<p class="hint">Port ${svc.port}</p>
${
isSelfSigned
? `<span class="notice-pill self-signed-pill" title="${DEFAULT_SELF_SIGNED_MSG}">Self-signed</span>`
: ""
}
<div class="service-menu">
${
hasCustomNotice
? `<button class="ghost info-btn" title="Notice" data-notice="${encodeURIComponent(
noticeText,
)}" data-link="${encodeURIComponent(noticeLink)}"></button>`
: ""
}
<button class="ghost menu-btn" title="Service actions" data-port="${svc.port}" data-name="${svc.name || ""}" data-scheme="${scheme}" data-path="${encodeURIComponent(
path,
)}" data-notice="${encodeURIComponent(svc.notice || "")}" data-notice-link="${encodeURIComponent(
svc.notice_link || "",
)}" data-self-signed="${svc.self_signed ? "1" : "0"}">⋮</button>
</div>
`;
gridEl.appendChild(card);
});
}
export function initServiceControls({ gridEl, menu, addForm, onChange, overlay, toast, openAddService }) {
if (!gridEl) return;
// Tracks which card was opened in the context menu
let menuContext = null;
const showBusy = overlay?.show || (() => {});
const hideBusy = overlay?.hide || (() => {});
const {
modal,
title,
subtitle,
renameInput,
portInput,
pathInput,
schemeSelect,
saveBtn,
cancelBtn,
removeBtn,
msg,
noticeInput,
noticeLinkInput,
selfSignedInput,
} = menu;
const {
nameInput,
portInput: addPortInput,
pathInput: addPathInput,
schemeSelect: addSchemeSelect,
addBtn,
msg: addMsg,
noticeInput: addNoticeInput,
noticeLinkInput: addNoticeLinkInput,
selfSignedInput: addSelfSignedInput,
} = addForm;
function enforceTlsCheckbox(selectEl, checkbox) {
if (!selectEl || !checkbox) return;
const update = () => {
const isHttps = selectEl.value === "https";
checkbox.disabled = !isHttps;
if (!isHttps) checkbox.checked = false;
};
selectEl.addEventListener("change", update);
update();
}
gridEl.addEventListener("click", (e) => {
const btn = e.target.closest(".menu-btn");
if (!btn) return;
const port = Number(btn.dataset.port);
const name = btn.dataset.name || "";
const schemeRaw = btn.dataset.scheme || "http";
const scheme = schemeRaw === "https" ? "https" : "http";
const path = decodeURIComponent(btn.dataset.path || "").trim();
const notice = decodeURIComponent(btn.dataset.notice || "").trim();
const notice_link = decodeURIComponent(btn.dataset.noticeLink || "").trim();
const self_signed = btn.dataset.selfSigned === "1";
menuContext = { port, name, scheme, path };
if (title) title.textContent = `${name || "Service"} (${scheme}://${port})`;
if (subtitle) {
const p = normalizePath(path) || "";
subtitle.textContent = `Current link: ${scheme}://pikit:${port}${p}`;
}
if (renameInput) renameInput.value = name;
if (portInput) portInput.value = port;
if (pathInput) pathInput.value = path;
if (schemeSelect) schemeSelect.value = scheme;
if (noticeInput) noticeInput.value = notice || "";
if (noticeLinkInput) noticeLinkInput.value = notice_link || "";
if (selfSignedInput) selfSignedInput.checked = !!self_signed;
if (msg) msg.textContent = "";
modal?.classList.remove("hidden");
});
gridEl.addEventListener("click", (e) => {
if (e.target.closest(".menu-btn") || e.target.closest(".info-btn") || e.target.closest("a")) return;
const card = e.target.closest(".card.clickable");
if (!card) return;
const url = card.dataset.url;
if (url) window.open(url, "_blank", "noopener");
});
gridEl.addEventListener("keydown", (e) => {
if (e.key !== "Enter" && e.key !== " ") return;
const card = e.target.closest(".card.clickable");
if (!card || e.target.closest(".menu-btn")) return;
e.preventDefault();
const url = card.dataset.url;
if (url) window.open(url, "_blank", "noopener");
});
gridEl.addEventListener("click", (e) => {
const infoBtn = e.target.closest(".info-btn");
if (!infoBtn) return;
e.stopPropagation();
const text = decodeURIComponent(infoBtn.dataset.notice || "").trim();
const link = decodeURIComponent(infoBtn.dataset.link || "").trim();
const { modal, title, text: textEl, link: linkEl } = ensureNoticeModal();
title.textContent = infoBtn.getAttribute("title") || "Notice";
textEl.textContent = text || "No additional info.";
if (link) {
linkEl.textContent = "More info";
linkEl.href = link;
linkEl.classList.remove("hidden");
} else {
linkEl.textContent = "";
linkEl.href = "";
linkEl.classList.add("hidden");
}
modal.classList.remove("hidden");
});
enforceTlsCheckbox(schemeSelect, selfSignedInput);
enforceTlsCheckbox(addSchemeSelect, addSelfSignedInput);
async function menuAction(action, body = {}) {
if (!menuContext) return;
msg.textContent = "";
try {
const isRemove = action === "remove";
const isSave = action === "save";
if (isRemove) showBusy("Removing service", "Updating firewall rules…");
if (isSave) showBusy("Saving service", "Opening/closing firewall rules as needed…");
if (action === "remove") {
await removeService({ port: menuContext.port });
} else {
await updateService({
port: menuContext.port,
name: body.name,
new_port: body.new_port,
scheme: body.scheme,
path: body.path,
notice: body.notice,
notice_link: body.notice_link,
self_signed: body.self_signed,
});
}
msg.textContent = "";
toast?.(isRemove ? "Service removed" : "Service saved", "success");
modal?.classList.add("hidden");
menuContext = null;
await onChange?.();
} catch (e) {
const err = e.error || "Action failed.";
msg.textContent = "";
toast?.(err, "error");
} finally {
hideBusy();
}
}
saveBtn?.addEventListener("click", () => {
if (!menuContext) return;
const name = (renameInput?.value || "").trim();
const new_port = Number(portInput?.value);
const scheme = schemeSelect?.value === "https" ? "https" : "http";
const pathRaw = pathInput?.value ?? "";
const path = normalizePath(pathRaw);
const notice = (noticeInput?.value || "").trim();
const notice_link = (noticeLinkInput?.value || "").trim();
const self_signed = !!selfSignedInput?.checked;
if (
!validateServiceFields(
{ name, port: new_port, path, notice, notice_link },
() => {},
toast,
)
)
return;
menuAction("save", { name, new_port, scheme, path, notice, notice_link, self_signed });
});
cancelBtn?.addEventListener("click", () => {
modal?.classList.add("hidden");
msg.textContent = "";
menuContext = null;
});
removeBtn?.addEventListener("click", () => menuAction("remove"));
addBtn?.addEventListener("click", async () => {
addMsg.textContent = "";
const name = (nameInput?.value || "").trim();
const port = Number(addPortInput?.value);
const scheme = addSchemeSelect?.value === "https" ? "https" : "http";
const pathRaw = addPathInput?.value ?? "";
const path = normalizePath(pathRaw);
const notice = (addNoticeInput?.value || "").trim();
const notice_link = (addNoticeLinkInput?.value || "").trim();
const self_signed = !!addSelfSignedInput?.checked;
if (
!validateServiceFields(
{ name, port, path, notice, notice_link },
() => {},
toast,
)
)
return;
addBtn.disabled = true;
try {
showBusy("Adding service", "Opening firewall rules…");
await addService({ name, port, scheme, path, notice, notice_link, self_signed });
addMsg.textContent = "";
toast?.("Service added", "success");
await onChange?.();
} catch (e) {
const err = e.error || "Failed to add.";
addMsg.textContent = "";
toast?.(err, "error");
} finally {
addBtn.disabled = false;
hideBusy();
}
});
}

View File

@@ -0,0 +1,130 @@
// Handles user-facing settings (theme, motion, refresh cadence) and persistence
// across reloads. Keeps side effects isolated so main.js simply wires callbacks.
const DEFAULT_REFRESH_SEC = 10;
const MIN_REFRESH_SEC = 5;
const MAX_REFRESH_SEC = 120;
const THEME_KEY = "pikit-theme";
const ANIM_KEY = "pikit-anim";
const REFRESH_KEY = "pikit-refresh-sec";
export function initSettings({
refreshHintMain,
refreshHintServices,
refreshFlagTop,
refreshIntervalInput,
refreshIntervalSave,
refreshIntervalMsg,
themeToggle,
themeToggleIcon,
animToggle,
onTick,
toast = null,
defaultIntervalSec = DEFAULT_REFRESH_SEC,
}) {
let refreshIntervalMs = defaultIntervalSec * 1000;
let refreshTimer = null;
const prefersReduce =
typeof window !== "undefined" &&
window.matchMedia &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function updateRefreshHints(seconds) {
const text = `${seconds} second${seconds === 1 ? "" : "s"}`;
if (refreshHintMain) refreshHintMain.textContent = text;
if (refreshHintServices) refreshHintServices.textContent = text;
if (refreshFlagTop) refreshFlagTop.textContent = `Refresh: ${seconds}s`;
if (refreshIntervalInput) refreshIntervalInput.value = seconds;
}
function setRefreshInterval(seconds, { silent = false } = {}) {
const sec = Math.max(
MIN_REFRESH_SEC,
Math.min(MAX_REFRESH_SEC, Math.floor(seconds)),
);
// Clamp to safe bounds; store milliseconds for setInterval
refreshIntervalMs = sec * 1000;
if (refreshTimer) clearInterval(refreshTimer);
if (onTick) {
refreshTimer = setInterval(onTick, refreshIntervalMs);
}
updateRefreshHints(sec);
try {
localStorage.setItem(REFRESH_KEY, String(sec));
} catch (e) {
console.warn("Refresh persistence unavailable", e);
}
if (!silent && refreshIntervalMsg) {
refreshIntervalMsg.textContent = "";
}
}
refreshIntervalSave?.addEventListener("click", () => {
if (!refreshIntervalInput) return;
const sec = Number(refreshIntervalInput.value);
if (Number.isNaN(sec)) {
if (refreshIntervalMsg) refreshIntervalMsg.textContent = "";
toast?.("Enter seconds.", "error");
return;
}
setRefreshInterval(sec);
onTick?.(); // immediate refresh on change
if (refreshIntervalMsg) refreshIntervalMsg.textContent = "";
toast?.("Refresh interval updated", "success");
});
function applyTheme(mode) {
const theme = mode === "light" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", theme);
if (themeToggleIcon)
themeToggleIcon.textContent = theme === "light" ? "☀️" : "🌙";
try {
localStorage.setItem(THEME_KEY, theme);
} catch (e) {
console.warn("Theme persistence unavailable", e);
}
}
themeToggle?.addEventListener("click", () => {
const current =
document.documentElement.getAttribute("data-theme") || "dark";
applyTheme(current === "dark" ? "light" : "dark");
});
function applyAnim(enabled) {
const on = enabled !== false; // default true
// Using a data attribute lets CSS fully disable motion when off
document.documentElement.setAttribute("data-anim", on ? "on" : "off");
if (animToggle) animToggle.checked = on;
try {
localStorage.setItem(ANIM_KEY, on ? "on" : "off");
} catch (e) {
console.warn("Anim persistence unavailable", e);
}
}
animToggle?.addEventListener("change", () => {
applyAnim(animToggle.checked);
});
// Initialize defaults
let storedRefresh = defaultIntervalSec;
try {
const saved = localStorage.getItem(REFRESH_KEY);
if (saved) {
const n = Number(saved);
if (!Number.isNaN(n)) storedRefresh = n;
}
} catch (e) {
console.warn("Refresh persistence unavailable", e);
}
updateRefreshHints(storedRefresh);
setRefreshInterval(storedRefresh, { silent: true });
applyTheme(localStorage.getItem(THEME_KEY) || "dark");
const storedAnim = localStorage.getItem(ANIM_KEY);
const animDefault =
storedAnim === "on" || storedAnim === "off"
? storedAnim === "on"
: !prefersReduce; // respect system reduce-motion if no user choice
applyAnim(animDefault);
return { setRefreshInterval, applyTheme };
}

View File

@@ -0,0 +1,77 @@
// Small helpers for rendering the status summary cards on the dashboard.
export const placeholderStatus = {
hostname: "Pi-Kit",
uptime_seconds: 0,
os_version: "Pi-Kit",
cpu_temp_c: null,
memory_mb: { total: 0, free: 0 },
disk_mb: { total: 0, free: 0 },
lan_ip: null,
};
function fmtUptime(sec) {
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
return `${h}h ${m}m`;
}
function fmtOs(text) {
const raw = text || "DietPi";
// If PRETTY_NAME looks like "Debian GNU/Linux 13 (trixie)"
const m = /Debian[^0-9]*?(\d+)(?:\s*\(([^)]+)\))?/i.exec(raw);
if (m) {
const version = m[1];
return `DietPi · Debian ${version}`;
}
// If already contains DietPi, keep a concise form
if (/dietpi/i.test(raw)) return raw.replace(/GNU\/Linux\s*/i, "").trim();
// Fallback: truncate to keep cards tidy
return raw.length > 30 ? `${raw.slice(0, 27)}` : raw;
}
function fmtSizeMb(mb) {
if (mb >= 1000) {
const gb = mb / 1024;
return `${gb.toFixed(gb >= 10 ? 0 : 1)} GB`;
}
return `${mb} MB`;
}
export function renderStats(container, data) {
if (!container) return;
container.innerHTML = "";
// Flatten the incoming status into label/value pairs before rendering cards
const stats = [
["Uptime", fmtUptime(data.uptime_seconds)],
["OS", fmtOs(data.os_version)],
["CPU Temp", data.cpu_temp_c ? `${data.cpu_temp_c.toFixed(1)} °C` : "n/a"],
[
"Memory",
`${fmtSizeMb(data.memory_mb.total - data.memory_mb.free)} / ${fmtSizeMb(data.memory_mb.total)}`,
],
[
"Disk",
`${fmtSizeMb(data.disk_mb.total - data.disk_mb.free)} / ${fmtSizeMb(data.disk_mb.total)}`,
],
[
"LAN / Host",
data.lan_ip
? `${data.lan_ip} (${data.hostname || "n/a"})`
: `n/a (${data.hostname || "n/a"})`,
],
];
stats.forEach(([label, value]) => {
const div = document.createElement("div");
div.className = "stat";
div.innerHTML = `<div class="label">${label}</div><div class="value">${value}</div>`;
if (label === "OS") {
const valEl = div.querySelector(".value");
if (valEl) {
valEl.style.whiteSpace = "normal";
valEl.style.wordBreak = "break-word";
valEl.style.lineHeight = "1.2";
}
}
container.appendChild(div);
});
}

1574
pikit-web/assets/style.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,344 @@
// UI controller for unattended-upgrades settings.
// Fetches current config, mirrors it into the form, and saves changes.
import { getUpdateConfig, saveUpdateConfig } from "./api.js";
const TIME_RE = /^(\d{1,2}):(\d{2})$/;
const MAX_BANDWIDTH_KBPS = 1_000_000; // 1 GB/s cap to prevent typos
const fallback = (val, def) => (val === undefined || val === null ? def : val);
let updatesDirty = false;
function isValidTime(value) {
if (!value) return false;
const m = TIME_RE.exec(value.trim());
if (!m) return false;
const h = Number(m[1]);
const mi = Number(m[2]);
return h >= 0 && h < 24 && mi >= 0 && mi < 60;
}
function normalizeTime(value, def) {
return isValidTime(value) ? value.padStart(5, "0") : def;
}
export function initUpdateSettings({
elements,
onAfterSave,
overlay = { show: () => {}, hide: () => {} },
toast = null,
}) {
const {
updatesStatus,
updatesToggle,
scopeSelect,
updateTimeInput,
upgradeTimeInput,
cleanupToggle,
bandwidthInput,
rebootToggle,
rebootTimeInput,
rebootWithUsersToggle,
saveBtn,
msgEl,
updatesUnsavedNote,
updatesSection,
updatesControls,
} = elements;
let lastConfig = null;
let saving = false;
let dirty = false;
function normalizeConfig(cfg) {
if (!cfg) return null;
return {
enable:
cfg.enable !== undefined ? !!cfg.enable : cfg.enabled !== undefined ? !!cfg.enabled : false,
scope: cfg.scope || "all",
update_time: normalizeTime(cfg.update_time, "04:00"),
upgrade_time: normalizeTime(cfg.upgrade_time, "04:30"),
cleanup: !!cfg.cleanup,
bandwidth_limit_kbps:
cfg.bandwidth_limit_kbps === null || cfg.bandwidth_limit_kbps === undefined
? null
: Number(cfg.bandwidth_limit_kbps),
auto_reboot: !!cfg.auto_reboot,
reboot_time: normalizeTime(cfg.reboot_time, "04:30"),
reboot_with_users: !!cfg.reboot_with_users,
};
}
function setStatusChip(enabled) {
const on = !!enabled;
if (updatesToggle) updatesToggle.checked = on;
if (updatesStatus) {
updatesStatus.textContent = on ? "On" : "Off";
updatesStatus.classList.toggle("chip-on", on);
updatesStatus.classList.toggle("chip-off", !on);
}
}
function setControlsEnabled(on) {
const controls = [
scopeSelect,
updateTimeInput,
upgradeTimeInput,
cleanupToggle,
bandwidthInput,
rebootToggle,
rebootTimeInput,
rebootWithUsersToggle,
];
controls.forEach((el) => {
if (el) el.disabled = !on;
});
if (updatesControls) {
updatesControls.classList.toggle("is-disabled", !on);
}
// Reboot sub-controls follow their own toggle
if (rebootToggle) {
const allowReboot = on && rebootToggle.checked;
if (rebootTimeInput) rebootTimeInput.disabled = !allowReboot;
if (rebootWithUsersToggle) rebootWithUsersToggle.disabled = !allowReboot;
}
}
function setRebootControlsState(on) {
if (rebootTimeInput) rebootTimeInput.disabled = !on;
if (rebootWithUsersToggle) rebootWithUsersToggle.disabled = !on;
}
function showMessage(text, isError = false) {
if (!msgEl) return;
msgEl.textContent = text || "";
msgEl.classList.toggle("error", isError);
if (text) {
if (toast && msgEl.id !== "updatesMsg") toast(text, isError ? "error" : "success");
setTimeout(() => (msgEl.textContent = ""), 2500);
}
}
function currentConfigFromForm() {
try {
return normalizeConfig(buildPayload());
} catch (e) {
return null;
}
}
function setDirty(on) {
dirty = !!on;
updatesDirty = dirty;
if (saveBtn) saveBtn.disabled = !dirty;
if (updatesUnsavedNote) updatesUnsavedNote.classList.toggle("hidden", !dirty);
}
function populateForm(cfg) {
lastConfig = normalizeConfig(cfg);
setStatusChip(cfg?.enabled);
setControlsEnabled(cfg?.enabled);
if (scopeSelect) scopeSelect.value = cfg.scope || "all";
if (updateTimeInput)
updateTimeInput.value = normalizeTime(cfg.update_time, "04:00");
if (upgradeTimeInput)
upgradeTimeInput.value = normalizeTime(cfg.upgrade_time, "04:30");
if (cleanupToggle) cleanupToggle.checked = !!cfg.cleanup;
if (bandwidthInput) {
bandwidthInput.value =
cfg.bandwidth_limit_kbps === null || cfg.bandwidth_limit_kbps === undefined
? ""
: cfg.bandwidth_limit_kbps;
}
if (rebootToggle) rebootToggle.checked = !!cfg.auto_reboot;
if (rebootTimeInput)
rebootTimeInput.value = normalizeTime(cfg.reboot_time, "04:30");
if (rebootWithUsersToggle)
rebootWithUsersToggle.checked = !!cfg.reboot_with_users;
setRebootControlsState(rebootToggle?.checked);
setDirty(false);
}
function buildPayload() {
const enable = updatesToggle?.checked !== false;
const scope = scopeSelect?.value === "security" ? "security" : "all";
const updateTime = normalizeTime(updateTimeInput?.value, "04:00");
const upgradeTime = normalizeTime(
upgradeTimeInput?.value || updateTime,
"04:30",
);
if (!isValidTime(updateTime) || !isValidTime(upgradeTime)) {
throw new Error("Time must be HH:MM (24h).");
}
const bwRaw = bandwidthInput?.value?.trim();
let bw = null;
if (bwRaw) {
const n = Number(bwRaw);
if (Number.isNaN(n) || n < 0) throw new Error("Bandwidth must be >= 0.");
if (n > MAX_BANDWIDTH_KBPS) {
throw new Error(`Bandwidth too high (max ${MAX_BANDWIDTH_KBPS.toLocaleString()} KB/s).`);
}
bw = n === 0 ? null : n;
}
const autoReboot = !!rebootToggle?.checked;
const rebootTime = normalizeTime(
rebootTimeInput?.value || upgradeTime,
"04:30",
);
if (autoReboot && !isValidTime(rebootTime)) {
throw new Error("Reboot time must be HH:MM (24h).");
}
return {
enable: enable !== false,
scope,
update_time: updateTime,
upgrade_time: upgradeTime,
cleanup: !!cleanupToggle?.checked,
bandwidth_limit_kbps: bw,
auto_reboot: autoReboot,
reboot_time: rebootTime,
reboot_with_users: !!rebootWithUsersToggle?.checked,
};
}
async function loadConfig() {
try {
const cfg = await getUpdateConfig();
populateForm(cfg);
} catch (e) {
console.error("Failed to load update config", e);
showMessage("Could not load update settings", true);
}
}
async function persistConfig({ overrideEnable = null } = {}) {
if (saving) return;
saving = true;
showMessage("");
try {
const payload = buildPayload();
if (overrideEnable !== null) payload.enable = !!overrideEnable;
overlay.show?.("Saving updates", "Applying unattended-upgrades settings…");
const cfg = await saveUpdateConfig(payload);
populateForm(cfg);
showMessage("Update settings saved.");
toast?.("Updates saved", "success");
onAfterSave?.();
setDirty(false);
} catch (e) {
console.error(e);
if (overrideEnable !== null && lastConfig) {
// revert toggle on failure
setStatusChip(lastConfig.enabled);
setControlsEnabled(lastConfig.enabled);
}
showMessage(e?.error || e?.message || "Save failed", true);
} finally {
saving = false;
overlay.hide?.();
}
}
updatesToggle?.addEventListener("change", () => {
setStatusChip(updatesToggle.checked);
setControlsEnabled(updatesToggle.checked);
const cfgNow = currentConfigFromForm();
setDirty(!lastConfig || (cfgNow && JSON.stringify(cfgNow) !== JSON.stringify(lastConfig)));
});
saveBtn?.addEventListener("click", async () => {
await persistConfig();
});
rebootToggle?.addEventListener("change", () => {
setRebootControlsState(rebootToggle.checked);
const cfgNow = currentConfigFromForm();
setDirty(!lastConfig || (cfgNow && JSON.stringify(cfgNow) !== JSON.stringify(lastConfig)));
});
[
scopeSelect,
updateTimeInput,
upgradeTimeInput,
cleanupToggle,
bandwidthInput,
rebootTimeInput,
rebootWithUsersToggle,
]
.filter(Boolean)
.forEach((el) => {
el.addEventListener("input", () => {
const cfgNow = currentConfigFromForm();
setDirty(!lastConfig || (cfgNow && JSON.stringify(cfgNow) !== JSON.stringify(lastConfig)));
});
el.addEventListener("change", () => {
const cfgNow = currentConfigFromForm();
setDirty(!lastConfig || (cfgNow && JSON.stringify(cfgNow) !== JSON.stringify(lastConfig)));
});
});
loadConfig();
return { reload: loadConfig, isDirty: () => dirty };
}
export const isUpdatesDirty = () => updatesDirty;

View File

@@ -0,0 +1,11 @@
{
"enabled": true,
"scope": "all",
"cleanup": true,
"bandwidth_limit_kbps": 0,
"auto_reboot": false,
"reboot_time": "05:30",
"reboot_with_users": false,
"update_time": "04:00",
"upgrade_time": "04:30"
}

0
pikit-web/favicon.ico Normal file
View File

634
pikit-web/index.html Normal file
View File

@@ -0,0 +1,634 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pi-Kit Dashboard</title>
<link rel="stylesheet" href="assets/style.css" />
</head>
<body>
<header class="topbar">
<div class="brand">
<div
class="dot"
title="Dashboard status indicator (shows when the UI is loaded)"
aria-label="Dashboard status indicator"
></div>
<span>Pi-Kit</span>
</div>
<div class="top-indicators">
<span class="chip-label">Status</span>
<span id="updatesFlagTop" class="status-chip quiet">Auto updates</span>
<span id="updatesNoteTop" class="hint quiet"></span>
<span id="refreshFlagTop" class="status-chip quiet">Refresh: 10s</span>
<span id="tempFlagTop" class="status-chip quiet">Temp: OK</span>
</div>
<div class="top-actions">
<button
id="themeToggle"
class="ghost icon-btn"
title="Toggle theme"
aria-label="Toggle theme"
>
<span id="themeToggleIcon" aria-hidden="true">&#127769;</span>
</button>
<button id="aboutBtn" class="ghost" title="About Pi-Kit">About</button>
<button id="helpBtn" class="ghost" title="Open help">Help</button>
<button id="advBtn" class="ghost" title="Open settings">
Settings
</button>
</div>
</header>
<main class="layout">
<section class="hero">
<div>
<p class="eyebrow">All-in-one launcher</p>
<h1>Welcome to your Pi-Kit homebase</h1>
<p class="lede">
Launch services, view quick health, and handle essentials without
cracking open SSH.
</p>
</div>
<div class="hero-stats" id="heroStats"></div>
</section>
<section class="panel">
<div class="panel-header">
<div>
<p class="eyebrow">Configured services</p>
<h2>Web interfaces</h2>
<p class="hint">
Shortcuts to the web UIs running on your Pi. Click a card to open it. Use the + button to add another service; use the ⋮ menu on a card to edit or remove it.
</p>
</div>
<div class="panel-actions">
<button id="addServiceOpen" class="ghost icon-btn" title="Add a service" aria-label="Add service">
+
</button>
</div>
</div>
<div id="servicesGrid" class="grid"></div>
</section>
</main>
<div id="readyOverlay" class="overlay hidden">
<div class="overlay-box">
<h3>Finishing setup</h3>
<p>
This only takes a couple of minutes. You'll see the dashboard once
Pi-Kit setup completes.
</p>
<div class="spinner"></div>
</div>
</div>
<div id="busyOverlay" class="overlay hidden">
<div class="overlay-box">
<h3 id="busyTitle">Working…</h3>
<p id="busyText" class="hint">This may take a few seconds.</p>
<div class="spinner"></div>
</div>
</div>
<div id="aboutModal" class="modal hidden">
<div class="modal-card wide">
<div class="panel-header sticky">
<div>
<p class="eyebrow">About</p>
<h3>About Pi-Kit</h3>
</div>
<button id="aboutClose" class="ghost icon-btn close-btn" title="Close about">
&times;
</button>
</div>
<div class="help-body">
<h4>What is this?</h4>
<p>
Pi-Kit is a self-hosted dashboard for managing services, monitoring your Pi,
and handling unattended updates, all through a single UI.
</p>
<h4>Font licensing</h4>
<p>
The following fonts are included under the SIL Open Font License 1.1 (no attribution required):
</p>
<ul>
<li>Red Hat Display / Red Hat Text</li>
<li>Space Grotesk</li>
<li>Manrope</li>
<li>DM Sans</li>
<li>Sora</li>
<li>Chivo</li>
<li>Atkinson Hyperlegible</li>
<li>IBM Plex Sans</li>
</ul>
<p class="hint">OFL license text is bundled at assets/fonts/OFL.txt.</p>
<h4>Other licenses</h4>
<p>
Pi-Kit code is MIT licensed. Third-party tooling (Vite, @fontsource) is MIT; Playwright (dev/test) is Apache 2.0.
See <a href="/THIRD-PARTY-LICENSES.txt" target="_blank">THIRD-PARTY-LICENSES.txt</a> and <a href="/LICENSE.txt" target="_blank">LICENSE.txt</a> for details, and <a href="/assets/fonts/OFL.txt" target="_blank">OFL.txt</a> for font licensing.
</p>
</div>
</div>
</div>
<div id="addServiceModal" class="modal hidden">
<div class="modal-card wide">
<div class="panel-header sticky">
<div>
<p class="eyebrow">Add service</p>
<h3>Register a web interface</h3>
<p class="hint">
Adds a local service by name and port. Choose HTTP or HTTPS to match how it serves traffic.
</p>
</div>
<button id="addSvcClose" class="ghost icon-btn close-btn" title="Close add service">
&times;
</button>
</div>
<div class="controls column">
<div class="control-actions column">
<input
type="text"
id="svcName"
placeholder="Service name"
maxlength="32"
/>
<p class="hint quiet">Service name: max 32 characters.</p>
<input
type="number"
id="svcPort"
placeholder="Port (e.g. 8080)"
min="1"
max="65535"
/>
<input
type="text"
id="svcPath"
placeholder="Optional path (e.g. /admin)"
/>
<div class="control-row split">
<label class="checkbox-row">
<span>Protocol</span>
<select id="svcScheme">
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
</select>
</label>
<label class="checkbox-row inline tight nowrap">
<input type="checkbox" id="svcSelfSigned" />
<span>Self-signed TLS</span>
</label>
</div>
<textarea
id="svcNotice"
rows="3"
placeholder="Optional notice (shown on card)"
></textarea>
<input
type="text"
id="svcNoticeLink"
placeholder="Optional link for more info"
/>
<div class="control-actions">
<button id="svcAddBtn" title="Add service and open port on LAN">
Add
</button>
</div>
<div id="svcMsg" class="hint status-msg"></div>
</div>
</div>
</div>
</div>
<div id="advModal" class="modal hidden">
<div class="modal-card wide">
<div class="panel-header sticky">
<div>
<p class="eyebrow warning">Settings</p>
<h3>System controls</h3>
</div>
<button
id="advClose"
class="ghost icon-btn close-btn"
title="Close settings panel"
>
&times;
</button>
</div>
<div class="controls accordion-list">
<div class="accordion">
<button class="accordion-toggle" data-target="acc-updates">
Automatic updates
</button>
<div class="accordion-body" id="acc-updates">
<p class="hint">
Daily unattended-upgrades: choose scope, schedule, cleanup, bandwidth, and reboot policy.
</p>
<div class="control-actions column" id="updatesSection">
<div class="control-actions">
<span id="updatesStatus" class="status-chip">Unknown</span>
<label class="toggle" title="Toggle unattended updates">
<input type="checkbox" id="updatesToggle" />
<span class="slider"></span>
</label>
<span class="hint" id="updatesToggleLabel">Enable/disable unattended upgrades</span>
</div>
<div id="updatesControls">
<div class="form-grid">
<label class="field">
<span>What to update</span>
<select id="updatesScope">
<option value="all">Security + regular updates</option>
<option value="security">Security only</option>
</select>
</label>
<label class="field">
<span>Download updates at</span>
<input type="time" id="updateTimeInput" value="04:00" title="Time of day to download updates" />
</label>
<label class="field">
<span>Install updates at</span>
<input type="time" id="upgradeTimeInput" value="04:30" title="Time of day to install updates" />
</label>
<div class="field checkbox-field">
<span>Cleanup unused packages</span>
<label class="checkbox-row inline tight">
<input type="checkbox" id="updatesCleanup" title="Automatically remove unused dependencies after upgrades" />
<span>Auto-remove dependencies after upgrades</span>
</label>
</div>
<label class="field">
<span>Bandwidth limit (KB/s, 0 = unlimited)</span>
<input
type="number"
id="updatesBandwidth"
min="0"
placeholder="0"
/>
</label>
<div class="field checkbox-field">
<span>Reboot options</span>
<div class="control-actions column tight">
<label class="checkbox-row inline tight">
<input type="checkbox" id="updatesRebootToggle" title="Auto-reboot when updates require it" />
<span>Auto-reboot if required</span>
</label>
<label class="checkbox-row inline tight">
<span>Reboot time</span>
<input type="time" id="updatesRebootTime" value="04:30" title="Scheduled reboot time when auto-reboot is enabled" />
</label>
<label class="checkbox-row inline tight nowrap">
<input type="checkbox" id="updatesRebootUsers" title="Allow reboot even if users are logged in" />
<span>Allow reboot with active users</span>
</label>
</div>
</div>
</div>
<div class="control-actions split-row">
<button id="updatesSaveBtn" title="Save unattended-upgrades settings" disabled>
Save settings
</button>
<span id="updatesUnsavedNote" class="note-warn hidden">Please save changes or they will not apply.</span>
</div>
<div id="updatesMsg" class="hint status-msg"></div>
</div>
</div>
</div>
</div>
<div class="accordion">
<button class="accordion-toggle" data-target="acc-anim">
Animations
</button>
<div class="accordion-body" id="acc-anim">
<p class="hint">
Adds smooth hover and click motion cues across the dashboard.
Default is on.
</p>
<div class="control-actions">
<span class="status-chip">Enable animations</span>
<label class="toggle" title="Toggle UI animations">
<input type="checkbox" id="animToggle" checked />
<span class="slider"></span>
</label>
</div>
<div class="form-grid">
<label class="field">
<span>Toast position</span>
<select id="toastPosSelect" title="Choose where toast notifications appear">
<option value="bottom-center">Bottom center</option>
<option value="bottom-right">Bottom right</option>
<option value="bottom-left">Bottom left</option>
<option value="top-right">Top right</option>
<option value="top-left">Top left</option>
<option value="top-center">Top center</option>
</select>
</label>
<label class="field">
<span>Toast animation</span>
<select id="toastAnimSelect" title="Toast entry animation style">
<option value="slide-in">Slide in</option>
<option value="fade">Fade</option>
<option value="pop">Pop</option>
<option value="bounce">Bounce</option>
<option value="drop">Drop</option>
<option value="grow">Grow</option>
</select>
</label>
<label class="field">
<span>Toast speed (ms)</span>
<input
type="number"
id="toastSpeedInput"
min="100"
max="3000"
step="50"
value="280"
title="Animation duration for toasts"
/>
</label>
<label class="field">
<span>Toast duration (ms)</span>
<input
type="number"
id="toastDurationInput"
min="1000"
max="15000"
step="250"
value="5000"
title="How long to keep toasts visible"
/>
</label>
</div>
<div class="control-actions">
<button id="toastTestBtn" title="Show a sample toast">Test toast</button>
</div>
</div>
</div>
<div class="accordion">
<button class="accordion-toggle" data-target="acc-appearance">
Appearance
</button>
<div class="accordion-body" id="acc-appearance">
<p class="hint">Choose the dashboard typeface.</p>
<div class="form-grid">
<label class="field">
<span>Dashboard font</span>
<select id="fontSelect" title="Choose dashboard typeface">
<option value="redhat">Red Hat (default)</option>
<option value="space">Space Grotesk</option>
<option value="manrope">Manrope</option>
<option value="dmsans">DM Sans</option>
<option value="sora">Sora</option>
<option value="chivo">Chivo</option>
<option value="atkinson">Atkinson Hyperlegible</option>
<option value="plex">IBM Plex Sans</option>
</select>
</label>
</div>
</div>
</div>
<div class="accordion">
<button class="accordion-toggle" data-target="acc-refresh">
Refresh interval
</button>
<div class="accordion-body" id="acc-refresh">
<p class="hint">
Sets how often status and services auto-refresh. Minimum 5
seconds; default 10.
</p>
<div class="control-actions column">
<label class="checkbox-row">
<span>Seconds</span>
<input
type="number"
id="refreshIntervalInput"
min="5"
max="120"
value="10"
/>
</label>
<div class="control-actions">
<button
id="refreshIntervalSave"
title="Update auto refresh rate"
>
Save
</button>
</div>
<div id="refreshIntervalMsg" class="hint status-msg"></div>
</div>
</div>
</div>
<div class="accordion">
<button class="accordion-toggle danger-btn" data-target="acc-reset">
Factory reset
</button>
<div class="accordion-body" id="acc-reset">
<p class="hint">
Restores Pi-Kit defaults, resets firewall and passwords
(root/dietpi &rarr; pikit), then reboots. Type YES to confirm.
</p>
<div class="control-actions column">
<input
type="text"
id="resetConfirm"
placeholder="Type YES to confirm"
/>
<button
id="resetBtn"
class="danger-btn"
disabled
title="Type YES above to enable"
>
Factory reset
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="helpModal" class="modal hidden">
<div class="modal-card wide">
<div class="panel-header">
<div>
<p class="eyebrow">Help</p>
<h3>How Pi-Kit dashboard works</h3>
<p class="hint">Quick, friendly guidance for common tasks.</p>
</div>
<button
id="helpClose"
class="ghost icon-btn close-btn"
title="Close help"
>
&times;
</button>
</div>
<div class="help-body">
<h4>Quick tour</h4>
<ul>
<li><strong>Status chips</strong> show uptime, OS, CPU temp, memory/disk, LAN IP, and auto-update/reboot flags.</li>
<li><strong>Service cards</strong> open your web UIs; the “…” menu lets you rename, change port/protocol/path, or remove.</li>
<li><strong>Hero stats</strong> up top give you a fast health snapshot.</li>
</ul>
<h4>Adding and editing services</h4>
<ul>
<li>Use <strong>Web interfaces → Add service</strong> to register a local UI. Pick HTTP/HTTPS to match the service.</li>
<li>Mark “Selfsigned TLS” if the cert isnt trusted to avoid surprise warnings.</li>
<li>Paths should start with “/” (e.g., <code>/admin</code>); ports must be 165535.</li>
</ul>
<h4>Automatic updates</h4>
<ul>
<li>Turn unattended upgrades on/off, choose securityonly or all updates.</li>
<li>Set download/install times, bandwidth limit, cleanup, and reboot policy.</li>
<li>Save to apply; the top chip shows if a reboot is required/scheduled.</li>
</ul>
<h4>Appearance & toasts</h4>
<ul>
<li>Pick a font in <strong>Settings → Appearance</strong> (Red Hat, Space Grotesk, Manrope, etc.).</li>
<li>Customize toast position, animation, speed, and duration under <strong>Animations</strong>.</li>
</ul>
<h4>Passwords & SSH (friendly defaults)</h4>
<ul>
<li>Default password for <strong>root</strong> and <strong>dietpi</strong>: <code>pikit</code>. Please change it ASAP.</li>
<li>To use keys: <code>ssh-copy-id -i ~/.ssh/yourkey.pub user@pikit</code>, then test with <code>ssh -i ~/.ssh/yourkey user@pikit</code>.</li>
<li>Once keys work, consider disabling password auth in <code>/etc/ssh/sshd_config</code> for extra safety.</li>
</ul>
<h4>Safety</h4>
<ul>
<li>Firewall allows LAN by default; nothing is exposed to the internet by PiKit.</li>
<li>Factory reset reverts passwords and firewall rules and reboots. Use only when you need a clean slate.</li>
</ul>
<h4>Troubleshooting</h4>
<ul>
<li>Service link fails? Make sure the service runs, port/path are right, and edit via the “…” menu.</li>
<li>Key login fails? Re-run <code>ssh-copy-id</code> for the correct user and retry.</li>
</ul>
</div>
</div>
</div>
<div id="menuModal" class="modal hidden">
<div class="modal-card wide">
<div class="panel-header">
<div>
<p class="eyebrow">Service actions</p>
<h3 id="menuTitle"></h3>
<p id="menuSubtitle" class="hint"></p>
</div>
<button class="ghost icon-btn close-btn" id="menuClose" title="Close">
&times;
</button>
</div>
<div class="config-list">
<div class="config-row">
<div class="config-label">
<h4>Rename</h4>
<p class="hint">Update the display name (max 32 characters).</p>
</div>
<div class="config-controls">
<input type="text" id="menuRename" maxlength="32" />
</div>
</div>
<div class="config-row">
<div class="config-label">
<h4>Change port</h4>
<p class="hint">
Moves the service to a new port and opens it on LAN.
</p>
</div>
<div class="config-controls">
<input type="number" id="menuPort" min="1" max="65535" />
</div>
</div>
<div class="config-row">
<div class="config-label">
<h4>Path</h4>
<p class="hint">Optional subpath (e.g. /admin or /ui/).</p>
</div>
<div class="config-controls">
<input type="text" id="menuPath" placeholder="/admin" />
</div>
</div>
<div class="config-row">
<div class="config-label">
<h4>Protocol</h4>
<p class="hint">Choose HTTP or HTTPS link.</p>
</div>
<div class="config-controls">
<select id="menuScheme">
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
</select>
</div>
</div>
<div class="config-row">
<div class="config-label">
<h4>Self-signed TLS</h4>
<p class="hint">Show a self-signed badge.</p>
</div>
<div class="config-controls">
<label class="checkbox-row">
<input type="checkbox" id="menuSelfSigned" />
<span>Mark as self-signed</span>
</label>
</div>
</div>
<div class="config-row">
<div class="config-label">
<h4>Notice</h4>
<p class="hint">Optional badge + info text on the card.</p>
</div>
<div class="config-controls">
<textarea id="menuNotice" rows="4" placeholder="e.g., Uses a self-signed certificate."></textarea>
<input
type="text"
id="menuNoticeLink"
placeholder="Optional link for more info"
/>
</div>
</div>
<div id="menuMsg" class="hint status-msg"></div>
</div>
<div class="modal-actions">
<button
id="menuRemoveBtn"
class="danger-btn"
title="Remove this service"
>
Remove
</button>
<div class="push"></div>
<button id="menuCancelBtn" class="ghost" title="Cancel changes">
Cancel
</button>
<button id="menuSaveBtn" class="primary" title="Save changes">
Save
</button>
</div>
</div>
</div>
<script type="module" src="assets/main.js"></script>
<div id="toastContainer" class="toast-container"></div>
</body>
</html>

1071
pikit-web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
pikit-web/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "pikit-web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite --host --port 4173",
"build": "vite build --outDir dist --emptyOutDir",
"preview": "vite preview --host --port 4173",
"test:e2e": "playwright test",
"test": "npm run test:e2e"
},
"devDependencies": {
"@playwright/test": "^1.45.0",
"vite": "^5.3.0"
},
"dependencies": {
"@fontsource/atkinson-hyperlegible": "^5.0.8",
"@fontsource/chivo": "^5.0.8",
"@fontsource/dm-sans": "^5.0.8",
"@fontsource/ibm-plex-sans": "^5.0.8",
"@fontsource/manrope": "^5.0.8",
"@fontsource/red-hat-display": "^5.0.12",
"@fontsource/red-hat-text": "^5.0.12",
"@fontsource/sora": "^5.0.8",
"@fontsource/space-grotesk": "^5.0.8"
}
}

View File

@@ -0,0 +1,32 @@
// @ts-check
import { defineConfig, devices } from '@playwright/test';
const PORT = 4173;
const HOST = 'localhost';
const BASE_URL = `http://${HOST}:${PORT}`;
export default defineConfig({
testDir: './tests',
timeout: 30_000,
expect: {
timeout: 5_000,
},
reporter: [['list']],
use: {
baseURL: BASE_URL,
trace: 'retain-on-failure',
},
webServer: {
command: 'npm run dev',
url: BASE_URL,
reuseExistingServer: !process.env.CI,
stdout: 'pipe',
stderr: 'pipe',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

View File

@@ -0,0 +1,49 @@
import { test, expect } from '@playwright/test';
const baseStatus = {
hostname: 'pikit',
ready: true,
uptime_seconds: 100,
load: [0, 0, 0],
memory_mb: { total: 1024, free: 512 },
disk_mb: { total: 10240, free: 9000 },
cpu_temp_c: 40,
lan_ip: '10.0.0.10',
os_version: 'DietPi',
auto_updates_enabled: true,
auto_updates: { enabled: true },
services: [],
};
const defaultUpdatesConfig = {
enabled: true,
scope: 'all',
update_time: '04:00',
upgrade_time: '04:30',
cleanup: true,
bandwidth_limit_kbps: null,
auto_reboot: false,
reboot_time: '04:30',
reboot_with_users: false,
};
test('busy overlay appears while adding a service', async ({ page }) => {
let services = [];
await page.route('**/api/updates/config', async (route) => {
await route.fulfill({ json: defaultUpdatesConfig });
});
await page.route('**/api/status', async (route) => {
await route.fulfill({ json: { ...baseStatus, services } });
});
await page.goto('/');
// Trigger busy overlay via test hook
await page.evaluate(() => {
window.__pikitTest?.showBusy('Adding service', 'Opening firewall rules…');
setTimeout(() => window.__pikitTest?.hideBusy(), 300);
});
const busy = page.locator('#busyOverlay');
await expect(busy).toBeVisible();
await expect(busy).toBeHidden({ timeout: 2000 });
});

View File

@@ -0,0 +1,18 @@
import { test, expect } from '@playwright/test';
const services = [
{ name: 'Pi-hole', port: 8089, scheme: 'http', path: '/admin', url: 'http://pikit:8089/admin', online: true, firewall_open: true },
];
test('service cards show path in URL and preserve click target', async ({ page }) => {
await page.goto('/');
await page.evaluate(async (svcList) => {
const mod = await import('/assets/services.js');
const grid = document.getElementById('servicesGrid');
mod.renderServices(grid, svcList);
}, services);
await expect(page.getByText('Pi-hole')).toBeVisible();
await expect(page.getByText('http://pikit:8089/admin')).toBeVisible();
});

View File

@@ -0,0 +1,216 @@
import { test, expect } from '@playwright/test';
const baseStatus = {
hostname: 'pikit',
ready: true,
uptime_seconds: 100,
load: [0, 0, 0],
memory_mb: { total: 1024, free: 512 },
disk_mb: { total: 10240, free: 9000 },
cpu_temp_c: 40,
lan_ip: '10.0.0.10',
os_version: 'DietPi',
auto_updates_enabled: true,
auto_updates: { enabled: true },
};
const defaultUpdatesConfig = {
enabled: true,
scope: 'all',
update_time: '04:00',
upgrade_time: '04:30',
cleanup: true,
bandwidth_limit_kbps: null,
auto_reboot: false,
reboot_time: '04:30',
reboot_with_users: false,
};
async function primeStatus(page, statusData) {
await page.route('**/api/status', async (route) => {
await route.fulfill({ json: statusData });
});
}
async function stubUpdatesConfig(page, cfg = defaultUpdatesConfig) {
await page.route('**/api/updates/config', async (route) => {
if (route.request().method() === 'POST') {
const body = await route.request().postDataJSON();
await route.fulfill({ json: { ...cfg, ...body } });
return;
}
await route.fulfill({ json: cfg });
});
}
test('renders services from status payload', async ({ page }) => {
await stubUpdatesConfig(page);
const statusData = {
...baseStatus,
services: [
{ name: 'Pi-hole', port: 8089, scheme: 'http', path: '/admin', url: 'http://pikit:8089/admin', online: true },
{ name: 'Netdata', port: 19999, scheme: 'http', url: 'http://pikit:19999', online: false },
],
};
await primeStatus(page, statusData);
await page.goto('/');
await expect(page.getByText('Pi-hole')).toBeVisible();
await expect(page.getByText('http://pikit:8089/admin')).toBeVisible();
await expect(page.getByText('Netdata')).toBeVisible();
});
test('add service shows busy overlay and new card', async ({ page }) => {
await stubUpdatesConfig(page);
await page.addInitScript(() => {
window.__pikitTest = window.__pikitTest || {};
window.__pikitTest.forceAccordionsOpen = true;
window.__pikitTest.forceServiceFormVisible = () => {
const ids = ['acc-services', 'svcName', 'svcPort', 'svcPath', 'svcScheme', 'svcAddBtn'];
ids.forEach((id) => {
const el = document.getElementById(id);
if (el) {
el.style.display = 'block';
el.style.opacity = '1';
el.style.visibility = 'visible';
el.style.maxHeight = '2000px';
}
});
};
});
const statusData = { ...baseStatus, services: [] };
await page.route('**/api/status', async (route) => {
await route.fulfill({ json: statusData });
});
await page.route('**/api/services/add', async (route) => {
const body = await route.request().postDataJSON();
statusData.services.push({
name: body.name,
port: body.port,
scheme: body.scheme,
path: body.path,
url: `${body.scheme}://pikit:${body.port}${body.path || ''}`,
online: true,
});
await route.fulfill({ status: 200, json: { services: statusData.services, message: 'Added' } });
});
await page.goto('/');
await page.click('#advBtn');
await page.evaluate(() => {
const acc = document.querySelector('button[data-target="acc-services"]')?.closest('.accordion');
acc?.classList.add('open');
window.__pikitTest?.forceServiceFormVisible?.();
});
await page.fill('#svcName', 'Grafana', { force: true });
await page.fill('#svcPort', '3000', { force: true });
await page.fill('#svcPath', '/dashboards', { force: true });
await page.evaluate(() => {
const sel = document.getElementById('svcScheme');
if (sel) {
sel.value = 'http';
sel.dispatchEvent(new Event('change', { bubbles: true }));
}
});
await page.click('#svcAddBtn', { force: true });
await expect(page.getByText('Grafana')).toBeVisible();
await expect(page.getByText('http://pikit:3000/dashboards')).toBeVisible();
});
test('path validation rejects absolute URLs', async ({ page }) => {
await stubUpdatesConfig(page);
await page.addInitScript(() => {
window.__pikitTest = window.__pikitTest || {};
window.__pikitTest.forceAccordionsOpen = true;
window.__pikitTest.forceServiceFormVisible = () => {
const ids = ['acc-services', 'svcName', 'svcPort', 'svcPath', 'svcScheme', 'svcAddBtn'];
ids.forEach((id) => {
const el = document.getElementById(id);
if (el) {
el.style.display = 'block';
el.style.opacity = '1';
el.style.visibility = 'visible';
el.style.maxHeight = '2000px';
}
});
};
});
const statusData = { ...baseStatus, services: [] };
await primeStatus(page, statusData);
await page.goto('/');
await page.click('#advBtn');
await page.evaluate(() => {
const acc = document.querySelector('button[data-target="acc-services"]')?.closest('.accordion');
acc?.classList.add('open');
window.__pikitTest?.forceServiceFormVisible?.();
});
await page.fill('#svcName', 'BadPath', { force: true });
await page.fill('#svcPort', '8080', { force: true });
await page.fill('#svcPath', 'http://example.com', { force: true });
await page.click('#svcAddBtn', { force: true });
await expect(page.getByText('Path must be relative (e.g. /admin) or blank.')).toBeVisible();
});
test('edit service updates path and scheme', async ({ page }) => {
await stubUpdatesConfig(page);
await page.addInitScript(() => {
window.__pikitTest = window.__pikitTest || {};
window.__pikitTest.forceAccordionsOpen = true;
});
const statusData = {
...baseStatus,
services: [
{ name: 'Uptime Kuma', port: 3001, scheme: 'http', path: '', url: 'http://pikit:3001', online: true },
],
};
await page.route('**/api/status', async (route) => {
await route.fulfill({ json: statusData });
});
await page.route('**/api/services/update', async (route) => {
const body = await route.request().postDataJSON();
statusData.services = statusData.services.map((s) =>
s.port === body.port ? { ...s, scheme: body.scheme, path: body.path, url: `${body.scheme}://pikit:${s.port}${body.path || ''}` } : s
);
await route.fulfill({ status: 200, json: { services: statusData.services, message: 'Service updated' } });
});
await page.goto('/');
await page.click('.menu-btn');
await page.fill('#menuPath', '/status');
await page.selectOption('#menuScheme', 'https');
const updateResp = await Promise.all([
page.waitForResponse((r) => r.url().includes('/api/services/update') && r.status() === 200),
page.click('#menuSaveBtn'),
]).then((res) => res[0]);
expect(updateResp.ok()).toBeTruthy();
const statusAfter = await page.evaluate(async () => {
const res = await fetch('/api/status');
return res.json();
});
const svc = statusAfter.services.find((s) => s.port === 3001);
expect(svc).toBeTruthy();
expect(svc.url).toContain('https://pikit:3001/status');
await page.reload();
await expect(page.locator('.service-url')).toContainText('https://pikit:3001/status', { timeout: 8000 });
});
test('remove service updates list', async ({ page }) => {
await stubUpdatesConfig(page);
let services = [
{ name: 'RemoveMe', port: 9000, scheme: 'http', url: 'http://pikit:9000', online: true },
];
const statusData = { ...baseStatus, services };
await page.route('**/api/status', async (route) => {
await route.fulfill({ json: { ...statusData, services } });
});
await page.route('**/api/services/remove', async (route) => {
const body = await route.request().postDataJSON();
services = services.filter((s) => s.port !== body.port);
await route.fulfill({ status: 200, json: { services, message: 'Removed' } });
});
await page.goto('/');
await page.click('.menu-btn');
await page.click('#menuRemoveBtn');
await expect(page.locator('.pill', { hasText: 'RemoveMe' })).toHaveCount(0, { timeout: 2000 });
});

View File

@@ -0,0 +1,114 @@
import { test, expect } from '@playwright/test';
const baseStatus = {
hostname: 'pikit',
ready: true,
uptime_seconds: 100,
load: [0, 0, 0],
memory_mb: { total: 1024, free: 512 },
disk_mb: { total: 10240, free: 9000 },
cpu_temp_c: 40,
lan_ip: '10.0.0.10',
os_version: 'DietPi',
auto_updates_enabled: true,
auto_updates: { enabled: true },
};
const defaultUpdatesConfig = {
enabled: true,
scope: 'all',
update_time: '04:00',
upgrade_time: '04:30',
cleanup: false,
bandwidth_limit_kbps: null,
auto_reboot: false,
reboot_time: '04:30',
reboot_with_users: false,
};
test('update settings form loads and saves config', async ({ page }) => {
let posted = null;
await page.addInitScript(() => {
window.__pikitTest = window.__pikitTest || {};
window.__pikitTest.forceAccordionsOpen = true;
});
await page.route('**/api/status', async (route) => {
await route.fulfill({ json: { ...baseStatus, services: [] } });
});
await page.route('**/api/updates/config', async (route) => {
if (route.request().method() === 'POST') {
posted = await route.request().postDataJSON();
await route.fulfill({ json: { ...defaultUpdatesConfig, ...posted } });
return;
}
await route.fulfill({ json: defaultUpdatesConfig });
});
await page.goto('/');
await page.click('#advBtn');
await expect(page.locator('#acc-updates')).toBeVisible();
await page.selectOption('#updatesScope', 'security');
await page.fill('#updateTimeInput', '03:00');
await page.fill('#upgradeTimeInput', '03:30');
await page.click('#updatesCleanup');
await page.fill('#updatesBandwidth', '500');
await page.click('#updatesRebootToggle');
await page.fill('#updatesRebootTime', '03:45');
await page.click('#updatesRebootUsers');
const resp = await Promise.all([
page.waitForResponse((r) => r.url().includes('/api/updates/config') && r.request().method() === 'POST'),
page.click('#updatesSaveBtn'),
]);
expect(resp[0].ok()).toBeTruthy();
expect(posted).toMatchObject({
enable: true,
scope: 'security',
update_time: '03:00',
upgrade_time: '03:30',
cleanup: true,
bandwidth_limit_kbps: 500,
auto_reboot: true,
reboot_time: '03:45',
reboot_with_users: true,
});
await expect(page.getByText('Update settings saved.')).toBeVisible({ timeout: 2000 });
});
test('disabling updates disables controls and saves enable=false', async ({ page }) => {
let posted = null;
await page.addInitScript(() => {
window.__pikitTest = window.__pikitTest || {};
window.__pikitTest.forceAccordionsOpen = true;
});
await page.route('**/api/status', async (route) => {
await route.fulfill({ json: { ...baseStatus, services: [] } });
});
await page.route('**/api/updates/config', async (route) => {
if (route.request().method() === 'POST') {
posted = await route.request().postDataJSON();
await route.fulfill({ json: { ...defaultUpdatesConfig, ...posted } });
return;
}
await route.fulfill({ json: defaultUpdatesConfig });
});
await page.goto('/');
await page.click('#advBtn');
await page.click('#updatesToggle + .slider', { force: true }); // disable via slider
await expect(page.locator('#updatesScope')).toBeDisabled();
await expect(page.locator('#updateTimeInput')).toBeDisabled();
await Promise.all([
page.waitForResponse((r) => r.url().includes('/api/updates/config') && r.request().method() === 'POST'),
page.click('#updatesSaveBtn'),
]);
expect(posted).toMatchObject({ enable: false });
});

18
pikit-web/vite.config.js Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'vite';
export default defineConfig({
root: '.',
server: {
host: true,
port: 4173,
proxy: {
// Forward API calls to the local Python API during dev so fetches
// return JSON instead of the Vite index.html shell.
'/api': 'http://127.0.0.1:4000',
},
},
preview: {
host: true,
port: 4173,
},
});

7
set_ready.sh Executable file
View File

@@ -0,0 +1,7 @@
#!/bin/bash
echo "Waiting for pikit to resolve..."
while ! getent hosts pikit >/dev/null;
do sleep 1
done
ssh -i ~/.ssh/pikit dietpi@pikit sudo touch /var/run/pikit-ready
echo "Done."

22
start-codex.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/bin/bash
set -e
# 1) Refresh sudo once in the foreground so you enter your password cleanly
echo "Refreshing sudo credentials..."
sudo -v || { echo "sudo authentication failed"; exit 1; }
# 2) Start non-interactive sudo keepalive loop in the background
# -n = never prompt; if the timestamp ever expires, this just exits
( while true; do sudo -n true 2>/dev/null || exit 0; sleep 180; done ) &
KEEPALIVE_PID=$!
# 3) Ensure cleanup on exit (normal, error, or Ctrl+C)
cleanup() {
kill "$KEEPALIVE_PID" 2>/dev/null || true
}
trap cleanup EXIT
# 4) Run Codex
codex resume --search