Add dashboard UI updates and settings modal
This commit is contained in:
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal 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
21
README.md
Normal 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
49
check-pikit-clean.sh
Executable 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
43
flash_sd.sh
Executable 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
716
pikit-api.py
Normal 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
391
pikit-prep-spec.md
Normal 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 script’s job:
|
||||
Prepare a running Pi-Kit system to be cloned as a “golden image” **without** removing any intentional software, configs, hostname, or passwords.
|
||||
|
||||
---
|
||||
|
||||
## 0. Context & Goals
|
||||
|
||||
**Starting point**
|
||||
|
||||
- OS: DietPi (Debian-based), already installed.
|
||||
- Extra software: web stack, Pi-Kit dashboard, DNS/ad-blocker, DBs, monitoring, etc.
|
||||
- System has been used for testing (logs, histories, test data, junk).
|
||||
|
||||
**Goal**
|
||||
|
||||
- Prepare system for cloning as a product image.
|
||||
- **KEEP**:
|
||||
- All intentionally installed packages/software.
|
||||
- All custom configs (web, apps, DietPi configs, firewall).
|
||||
- Current hostname.
|
||||
- Existing passwords (system + services) as shipped defaults.
|
||||
- **RESET/CLEAR**:
|
||||
- Host-unique identity data (machine-id, SSH host keys, etc.).
|
||||
- Logs, histories, caches.
|
||||
- Test/personal accounts and data.
|
||||
|
||||
---
|
||||
|
||||
## 1. Discovery Phase (MUST HAPPEN BEFORE SCRIPT DESIGN)
|
||||
|
||||
Before writing any code, inspect the system and external docs.
|
||||
|
||||
The AI MUST:
|
||||
|
||||
1. **Detect installed components**
|
||||
- Determine which key packages/services are present, e.g.:
|
||||
- Web server (nginx, lighttpd, apache2, etc.).
|
||||
- DNS/ad-blocker (Pi-hole or similar).
|
||||
- DB engines (MariaDB, PostgreSQL, SQLite usage).
|
||||
- Monitoring/metrics (Netdata, Uptime Kuma, etc.).
|
||||
- Use this to decide which cleanup sections apply.
|
||||
|
||||
2. **Verify paths/layouts**
|
||||
- For each service or category:
|
||||
- Confirm relevant paths/directories actually exist.
|
||||
- Do not assume standard paths without checking.
|
||||
- Example: Only treat `/var/log/nginx` as Nginx logs if:
|
||||
- Nginx is installed, AND
|
||||
- That directory exists.
|
||||
|
||||
3. **Consult upstream docs (online)**
|
||||
- Check current:
|
||||
- DietPi docs and/or DietPi GitHub.
|
||||
- Docs for major services (e.g. Pi-hole, Nginx, MariaDB, etc.).
|
||||
- Use docs to confirm:
|
||||
- Data vs config locations.
|
||||
- Safe cache/log cleanup methods.
|
||||
- Prefer documented behavior over guesses.
|
||||
|
||||
4. **Classify actions**
|
||||
- For each potential cleanup:
|
||||
- Mark as **safe** if clearly understood and documented.
|
||||
- Mark as **uncertain** if layout deviates or docs are unclear.
|
||||
- Plan to:
|
||||
- Perform safe actions.
|
||||
- Skip uncertain actions and surface them for manual review.
|
||||
|
||||
5. **Fail safe**
|
||||
- If something doesn’t match expectations:
|
||||
- Do NOT plan a destructive operation on it.
|
||||
- Flag it as “needs manual review” in the confirmation phase.
|
||||
|
||||
---
|
||||
|
||||
## 2. Identity & Host-Specific Secrets
|
||||
|
||||
**DO NOT CHANGE:**
|
||||
|
||||
- Hostname (whatever it currently is).
|
||||
- Any existing passwords (system or service-level) that are part of the appliance defaults.
|
||||
|
||||
**RESET/CLEAR:**
|
||||
|
||||
1. **Machine identity**
|
||||
- Clear:
|
||||
- `/etc/machine-id`
|
||||
- `/var/lib/dbus/machine-id` (if present)
|
||||
- Rely on OS to recreate them on next boot.
|
||||
|
||||
2. **Random seed**
|
||||
- Clear persisted random seed (e.g. `/var/lib/systemd/random-seed`) so each clone gets unique entropy.
|
||||
|
||||
3. **SSH host keys**
|
||||
- Remove all SSH **host key** files (server keys only).
|
||||
- Leave user SSH keypairs unless explicitly identified as dev/test and safe to remove.
|
||||
|
||||
4. **SSH known_hosts**
|
||||
- Clear `known_hosts` for:
|
||||
- `root`
|
||||
- `dietpi` (or primary DietPi user)
|
||||
- Any other persistent users
|
||||
|
||||
5. **VPN keys (conditional)**
|
||||
- If keys are meant to be unique per device:
|
||||
- Remove WireGuard/OpenVPN private keys and per-device configs embedding them.
|
||||
- If the design requires fixed server keys:
|
||||
- KEEP server keys.
|
||||
- REMOVE test/client keys/profiles that are tied to dev use.
|
||||
|
||||
6. **TLS certificates (conditional)**
|
||||
- REMOVE:
|
||||
- Let’s Encrypt/ACME certs tied to personal domains.
|
||||
- Per-device self-signed certs that should regenerate.
|
||||
- KEEP:
|
||||
- Shared CAs/certs only if explicitly part of product design.
|
||||
|
||||
---
|
||||
|
||||
## 3. Users & Personal Traces
|
||||
|
||||
1. **Accounts**
|
||||
- KEEP:
|
||||
- Accounts that are part of the product.
|
||||
- REMOVE:
|
||||
- Test-only accounts (users created for dev/debug).
|
||||
|
||||
2. **Shell histories**
|
||||
- Clear shell histories for all remaining users:
|
||||
- `root`, `dietpi`, others that stay.
|
||||
|
||||
3. **Home directories**
|
||||
- For users that remain:
|
||||
- KEEP:
|
||||
- Intentional config/dotfiles (shell rc, app config, etc.).
|
||||
- REMOVE:
|
||||
- Downloads, random files, scratch notes.
|
||||
- Editor backup/swap files, stray temp files.
|
||||
- Debug dumps, one-off scripts not part of product.
|
||||
- For users that are removed:
|
||||
- Delete their home dirs entirely.
|
||||
|
||||
4. **SSH client keys**
|
||||
- REMOVE:
|
||||
- Clearly personal/test keys (e.g. with your email in comments).
|
||||
- KEEP:
|
||||
- Only keys explicitly required by product design.
|
||||
|
||||
---
|
||||
|
||||
## 4. Logs & Telemetry
|
||||
|
||||
1. **System logs**
|
||||
- Clear:
|
||||
- Systemd journal (persistent logs).
|
||||
- `/var/log` files + rotated/compressed variants, where safe.
|
||||
|
||||
2. **Service logs**
|
||||
- For installed services (web servers, DNS/ad-blockers, DBs, etc.):
|
||||
- Clear their log files and rotated versions.
|
||||
|
||||
3. **Monitoring/metrics**
|
||||
- For tools like Netdata, Uptime Kuma, etc.:
|
||||
- KEEP:
|
||||
- Config, target definitions.
|
||||
- CLEAR:
|
||||
- Historical metric/alert data (TSDBs, history files, etc.).
|
||||
|
||||
---
|
||||
|
||||
## 5. Package Manager & Caches
|
||||
|
||||
1. **APT**
|
||||
- Clear:
|
||||
- Downloaded `.deb` archives.
|
||||
- Safe APT caches (as per documentation).
|
||||
|
||||
2. **Other caches**
|
||||
- Under `/var/cache` and `~/.cache`:
|
||||
- CLEAR:
|
||||
- Caches known to be safe and auto-regenerated.
|
||||
- DO NOT CLEAR:
|
||||
- Caches that are required for correct functioning or very expensive to rebuild, unless docs confirm safety.
|
||||
|
||||
3. **Temp directories**
|
||||
- Empty:
|
||||
- `/tmp`
|
||||
- `/var/tmp`
|
||||
|
||||
4. **Crash dumps**
|
||||
- Remove crash dumps and core files (e.g. `/var/crash` and similar locations).
|
||||
|
||||
---
|
||||
|
||||
## 6. Service Data vs Config (Per-App Logic)
|
||||
|
||||
General rule:
|
||||
|
||||
> Keep configuration & structure. Remove dev/test data, history, and personal content.
|
||||
|
||||
The AI must apply this using detected services + docs.
|
||||
|
||||
### 6.1 Web Servers (nginx / lighttpd / apache2)
|
||||
|
||||
- KEEP:
|
||||
- Main config and site configs that define Pi-Kit behavior.
|
||||
- App code in `/var/www/...` (or equivalent Pi-Kit web root).
|
||||
- CLEAR:
|
||||
- Access/error logs.
|
||||
- Non-critical caches if docs confirm they’re safe to recreate.
|
||||
|
||||
### 6.2 DNS / Ad-blockers (Pi-hole or similar)
|
||||
|
||||
- KEEP:
|
||||
- Upstream DNS settings.
|
||||
- Blocklists / adlists / local DNS overrides.
|
||||
- DHCP config if it is part of the product’s behavior.
|
||||
- CLEAR:
|
||||
- Query history / statistics DB.
|
||||
- Log files.
|
||||
- DO NOT:
|
||||
- Change the current admin password (it is the product default).
|
||||
|
||||
### 6.3 Databases (MariaDB, PostgreSQL, SQLite, etc.)
|
||||
|
||||
- KEEP:
|
||||
- DB schema.
|
||||
- Seed/default data required for every user.
|
||||
- REMOVE/RESET:
|
||||
- Dev/test user accounts (with your email, etc.).
|
||||
- Test content/records not meant for production image.
|
||||
- Access tokens, session records, API keys tied to dev use.
|
||||
- For SQLite-based apps:
|
||||
- Decide per app (based on docs) whether to:
|
||||
- Ship a pre-seeded “clean” DB, OR
|
||||
- Let it auto-create DB on first run.
|
||||
|
||||
### 6.4 Other services (Nextcloud, Jellyfin, Gotify, Uptime Kuma, etc.)
|
||||
|
||||
For each detected service:
|
||||
|
||||
- KEEP:
|
||||
- Global config, ports, base URLs, application settings needed for Pi-Kit.
|
||||
- CLEAR:
|
||||
- Personal/dev user accounts.
|
||||
- Your media/content (unless intentionally shipping sample content).
|
||||
- Notification endpoints tied to your own email / Gotify / Telegram, unless explicitly desired.
|
||||
|
||||
If docs or structure are unclear, mark cleanup as **uncertain** and surface in confirmation instead of guessing.
|
||||
|
||||
---
|
||||
|
||||
## 7. Networking & Firewall
|
||||
|
||||
**HARD CONSTRAINTS:**
|
||||
|
||||
- Do NOT modify hostname.
|
||||
- Do NOT weaken/remove the product firewall rules.
|
||||
|
||||
1. **Firewall**
|
||||
- Detect firewall system in use (iptables, nftables, UFW, etc.).
|
||||
- KEEP:
|
||||
- All persistent firewall configs that define Pi-Kit’s security behavior.
|
||||
- DO NOT:
|
||||
- Flush or reset firewall rules unless it’s clearly a dev-only configuration (and that’s confirmed).
|
||||
|
||||
2. **Other networking state**
|
||||
- Safe to CLEAR:
|
||||
- DHCP lease files.
|
||||
- DNS caches.
|
||||
- DO NOT ALTER:
|
||||
- Static IP/bridge/VLAN config that appears to be part of the intended appliance setup.
|
||||
|
||||
---
|
||||
|
||||
## 8. DietPi-Specific State & First-Boot Behavior
|
||||
|
||||
1. **DietPi automation/config**
|
||||
- Identify DietPi automation configuration (e.g. `dietpi.txt`, related files).
|
||||
- KEEP:
|
||||
- The intended defaults (locale, timezone, etc.).
|
||||
- Any automation that is part of Pi-Kit behavior.
|
||||
- AVOID:
|
||||
- Re-triggering DietPi’s generic first-boot flow unless that is intentionally desired.
|
||||
|
||||
2. **DietPi logs/temp**
|
||||
- CLEAR:
|
||||
- DietPi-specific logs and temp files.
|
||||
- KEEP:
|
||||
- All DietPi configuration and automation files.
|
||||
|
||||
3. **Pi-Kit first-boot logic**
|
||||
- Ensure any Pi-Kit specific first-run services/hooks are:
|
||||
- Enabled.
|
||||
- Not dependent on data being cleaned (e.g., they must not require removed dev tokens/paths).
|
||||
|
||||
---
|
||||
|
||||
## 9. Shell & Tooling State
|
||||
|
||||
1. **Tool caches**
|
||||
- For root and main user(s), CLEAR:
|
||||
- Safe caches in `~/.cache` (pip, npm, cargo, etc.), if not needed at runtime.
|
||||
- Avoid clearing caches that are critical or painful to rebuild unless doc-backed.
|
||||
|
||||
2. **Build artifacts**
|
||||
- REMOVE:
|
||||
- Source trees, build directories, and other dev artifacts that are not part of final product.
|
||||
|
||||
3. **Cronjobs / timers**
|
||||
- Audit:
|
||||
- User crontabs.
|
||||
- System crontabs.
|
||||
- Systemd timers.
|
||||
- KEEP:
|
||||
- Jobs that are part of Pi-Kit behavior.
|
||||
- REMOVE:
|
||||
- Jobs/timers clearly used for dev/testing only.
|
||||
|
||||
---
|
||||
|
||||
## 10. Implementation Requirements (For the Future Script)
|
||||
|
||||
When generating the actual script, the AI MUST:
|
||||
|
||||
1. **Error handling**
|
||||
- Check exit statuses where relevant.
|
||||
- Handle missing paths/directories gracefully:
|
||||
- If a path doesn’t exist, skip and log; do not fail hard.
|
||||
- Avoid wide-destructive operations without validation:
|
||||
- No “blind” deletions on unverified globs.
|
||||
|
||||
2. **Idempotency**
|
||||
- Script can run multiple times without progressively breaking the system.
|
||||
- After repeated runs, image should remain valid and “clean”.
|
||||
|
||||
3. **Conservative behavior**
|
||||
- If uncertain about an operation:
|
||||
- Do NOT perform it.
|
||||
- Log a warning and mark for manual review.
|
||||
|
||||
4. **Logging**
|
||||
- For each major category (identity, logs, caches, per-service cleanup, etc.):
|
||||
- Log what was targeted and outcome:
|
||||
- `cleaned`
|
||||
- `skipped (not installed/not found)`
|
||||
- `skipped (uncertain; manual review)`
|
||||
- Provide a summary at the end.
|
||||
|
||||
---
|
||||
|
||||
## 11. Mandatory Pre-Script Confirmation Step
|
||||
|
||||
**Before writing any script, the AI MUST:**
|
||||
|
||||
1. **Present a system-specific plan**
|
||||
- Based on discovery + docs, list:
|
||||
- Exactly which paths, files, DBs, and data types it intends to:
|
||||
- Remove
|
||||
- Reset
|
||||
- Leave untouched
|
||||
- For each item or group: a short explanation of **why**.
|
||||
|
||||
2. **Highlight conflicts / ambiguities**
|
||||
- If any cleanup might:
|
||||
- Affect passwords,
|
||||
- Affect hostname,
|
||||
- Affect firewall rules,
|
||||
- Or contradict this spec in any way,
|
||||
- The AI must:
|
||||
- Call it out explicitly.
|
||||
- Explain tradeoffs and propose a safe option.
|
||||
|
||||
3. **Highlight extra opportunities**
|
||||
- If the AI finds additional cleanup opportunities not explicitly listed here (e.g., new DietPi features, new log paths):
|
||||
- Describe them clearly.
|
||||
- Explain pros/cons of adding them.
|
||||
- Ask whether to include them.
|
||||
|
||||
4. **Wait for explicit approval**
|
||||
- Do NOT generate the script until:
|
||||
- The user (me) has reviewed the plan.
|
||||
- Conflicts and extra opportunities have been discussed.
|
||||
- Explicit approval (with any modifications) has been given.
|
||||
|
||||
Only after that confirmation may the AI produce the actual prep script.
|
||||
|
||||
---
|
||||
245
pikit-prep.sh
Normal file
245
pikit-prep.sh
Normal 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
3
pikit-services.json
Normal file
@@ -0,0 +1,3 @@
|
||||
[
|
||||
{ "name": "DietPi Dashboard", "port": 5252, "scheme": "http" }
|
||||
]
|
||||
21
pikit-web/LICENSE
Normal file
21
pikit-web/LICENSE
Normal 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
21
pikit-web/LICENSE.txt
Normal 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
41
pikit-web/RESCUE.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Pi-Kit quick rescue (offline note)
|
||||
|
||||
Keep this handy if the web dashboard is down. You’re already in via SSH, so here’s what to check next.
|
||||
|
||||
## Where to find this note
|
||||
- `/root/RESCUE.md`
|
||||
- `/home/dietpi/RESCUE.md`
|
||||
- `/var/www/pikit-web/RESCUE.md`
|
||||
|
||||
## Fast service reset
|
||||
```bash
|
||||
sudo systemctl status nginx pikit-api # check
|
||||
sudo systemctl restart nginx pikit-api # restart both
|
||||
```
|
||||
|
||||
## Logs to inspect
|
||||
```bash
|
||||
sudo tail -n 100 /var/log/nginx/error.log
|
||||
sudo tail -n 100 /var/log/pikit-api.log
|
||||
```
|
||||
|
||||
## Check system health
|
||||
```bash
|
||||
df -h # disk space
|
||||
free -h # memory
|
||||
sudo systemctl status unattended-upgrades # auto-update service
|
||||
sudo systemctl status apt-daily.timer apt-daily-upgrade.timer
|
||||
```
|
||||
|
||||
## If services won’t start
|
||||
```bash
|
||||
sudo nginx -t # validate nginx config
|
||||
sudo journalctl -u nginx -u pikit-api -n 100 # detailed service logs
|
||||
```
|
||||
|
||||
## Licenses (for distribution)
|
||||
- `/var/www/pikit-web/LICENSE` (MIT for Pi-Kit)
|
||||
- `/var/www/pikit-web/THIRD-PARTY-LICENSES.md`
|
||||
- `/var/www/pikit-web/assets/fonts/OFL.txt`
|
||||
|
||||
Tip: after any change, `sudo systemctl restart nginx pikit-api` then re-check logs above.
|
||||
21
pikit-web/THIRD-PARTY-LICENSES.md
Normal file
21
pikit-web/THIRD-PARTY-LICENSES.md
Normal 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
|
||||
21
pikit-web/THIRD-PARTY-LICENSES.txt
Normal file
21
pikit-web/THIRD-PARTY-LICENSES.txt
Normal 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
89
pikit-web/assets/api.js
Normal 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 }),
|
||||
});
|
||||
BIN
pikit-web/assets/fonts/Atkinson-Bold.woff2
Normal file
BIN
pikit-web/assets/fonts/Atkinson-Bold.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/Atkinson-Regular.woff2
Normal file
BIN
pikit-web/assets/fonts/Atkinson-Regular.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/Chivo-Bold.woff2
Normal file
BIN
pikit-web/assets/fonts/Chivo-Bold.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/Chivo-Regular.woff2
Normal file
BIN
pikit-web/assets/fonts/Chivo-Regular.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/DMSans-Bold.woff2
Normal file
BIN
pikit-web/assets/fonts/DMSans-Bold.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/DMSans-Medium.woff2
Normal file
BIN
pikit-web/assets/fonts/DMSans-Medium.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/DMSans-Regular.woff2
Normal file
BIN
pikit-web/assets/fonts/DMSans-Regular.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/Manrope-Regular.woff2
Normal file
BIN
pikit-web/assets/fonts/Manrope-Regular.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/Manrope-SemiBold.woff2
Normal file
BIN
pikit-web/assets/fonts/Manrope-SemiBold.woff2
Normal file
Binary file not shown.
101
pikit-web/assets/fonts/OFL.txt
Normal file
101
pikit-web/assets/fonts/OFL.txt
Normal 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.
|
||||
BIN
pikit-web/assets/fonts/PlexSans-Regular.woff2
Normal file
BIN
pikit-web/assets/fonts/PlexSans-Regular.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/PlexSans-SemiBold.woff2
Normal file
BIN
pikit-web/assets/fonts/PlexSans-SemiBold.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/RedHatDisplay-Bold.woff2
Normal file
BIN
pikit-web/assets/fonts/RedHatDisplay-Bold.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/RedHatDisplay-SemiBold.woff2
Normal file
BIN
pikit-web/assets/fonts/RedHatDisplay-SemiBold.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/RedHatText-Medium.woff2
Normal file
BIN
pikit-web/assets/fonts/RedHatText-Medium.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/RedHatText-Regular.woff2
Normal file
BIN
pikit-web/assets/fonts/RedHatText-Regular.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/Sora-Regular.woff2
Normal file
BIN
pikit-web/assets/fonts/Sora-Regular.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/Sora-SemiBold.woff2
Normal file
BIN
pikit-web/assets/fonts/Sora-SemiBold.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/SpaceGrotesk-Medium.woff2
Normal file
BIN
pikit-web/assets/fonts/SpaceGrotesk-Medium.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/SpaceGrotesk-Regular.woff2
Normal file
BIN
pikit-web/assets/fonts/SpaceGrotesk-Regular.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/SpaceGrotesk-SemiBold.woff2
Normal file
BIN
pikit-web/assets/fonts/SpaceGrotesk-SemiBold.woff2
Normal file
Binary file not shown.
635
pikit-web/assets/main.js
Normal file
635
pikit-web/assets/main.js
Normal 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();
|
||||
361
pikit-web/assets/services.js
Normal file
361
pikit-web/assets/services.js
Normal 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">×</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();
|
||||
}
|
||||
});
|
||||
}
|
||||
130
pikit-web/assets/settings.js
Normal file
130
pikit-web/assets/settings.js
Normal 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 };
|
||||
}
|
||||
77
pikit-web/assets/status.js
Normal file
77
pikit-web/assets/status.js
Normal 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
1574
pikit-web/assets/style.css
Normal file
File diff suppressed because it is too large
Load Diff
344
pikit-web/assets/update-settings.js
Normal file
344
pikit-web/assets/update-settings.js
Normal 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;
|
||||
11
pikit-web/data/mock-updates.json
Normal file
11
pikit-web/data/mock-updates.json
Normal 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
0
pikit-web/favicon.ico
Normal file
634
pikit-web/index.html
Normal file
634
pikit-web/index.html
Normal 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">🌙</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">
|
||||
×
|
||||
</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">
|
||||
×
|
||||
</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"
|
||||
>
|
||||
×
|
||||
</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 → 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"
|
||||
>
|
||||
×
|
||||
</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 “Self‑signed TLS” if the cert isn’t trusted to avoid surprise warnings.</li>
|
||||
<li>Paths should start with “/” (e.g., <code>/admin</code>); ports must be 1–65535.</li>
|
||||
</ul>
|
||||
|
||||
<h4>Automatic updates</h4>
|
||||
<ul>
|
||||
<li>Turn unattended upgrades on/off, choose security‑only 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 Pi‑Kit.</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">
|
||||
×
|
||||
</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
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
27
pikit-web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
32
pikit-web/playwright.config.js
Normal file
32
pikit-web/playwright.config.js
Normal 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'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
49
pikit-web/tests/busy-overlay.spec.js
Normal file
49
pikit-web/tests/busy-overlay.spec.js
Normal 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 });
|
||||
});
|
||||
18
pikit-web/tests/service-path.spec.js
Normal file
18
pikit-web/tests/service-path.spec.js
Normal 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();
|
||||
});
|
||||
216
pikit-web/tests/services-flow.spec.js
Normal file
216
pikit-web/tests/services-flow.spec.js
Normal 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 });
|
||||
});
|
||||
114
pikit-web/tests/update-settings.spec.js
Normal file
114
pikit-web/tests/update-settings.spec.js
Normal 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
18
pikit-web/vite.config.js
Normal 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
7
set_ready.sh
Executable 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
22
start-codex.sh
Executable 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
|
||||
|
||||
Reference in New Issue
Block a user