Compare commits
73 Commits
v0.1.0-dev
...
v0.1.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb2d75387d | ||
|
|
77bc4c1c36 | ||
|
|
13e5788fe1 | ||
|
|
24e89b516f | ||
|
|
9c3156df35 | ||
|
|
b01c2ba737 | ||
|
|
bc97e0374f | ||
|
|
0a23902eb0 | ||
|
|
4632704092 | ||
|
|
36d30da30a | ||
|
|
c62f1f018f | ||
|
|
32a9f42361 | ||
|
|
40b1b43449 | ||
|
|
ccc97f7912 | ||
|
|
f4090cbf1d | ||
|
|
f4d0765c93 | ||
|
|
99bd87c7f6 | ||
|
|
e87b90bf9f | ||
|
|
8557140193 | ||
|
|
86438b11f3 | ||
|
|
3a785832b1 | ||
|
|
a94cd17186 | ||
|
|
b01bfcd38e | ||
|
|
831a98c5a1 | ||
|
|
daea783d38 | ||
|
|
90d3e5676a | ||
|
|
8a054c5d85 | ||
|
|
009ac8cdd0 | ||
|
|
7a9ffb710a | ||
|
|
15da438625 | ||
|
|
50ddc3e211 | ||
|
|
e7a79246b8 | ||
|
|
bb2fb2dcf2 | ||
|
|
222f6f9e77 | ||
|
|
250ea2e00d | ||
|
|
b70f4c5f3f | ||
|
|
0c69e9bf90 | ||
|
|
d8673a9fb4 | ||
|
|
1e8e8a5bc2 | ||
|
|
e3c6c3b308 | ||
|
|
a05aa70069 | ||
|
|
06e8e25aad | ||
|
|
4f05f58f45 | ||
|
|
eaf261a6be | ||
|
|
798d78cb13 | ||
|
|
bcb6f3005d | ||
|
|
b911171045 | ||
|
|
2cfb9779d6 | ||
|
|
08cf472bf7 | ||
|
|
471e242427 | ||
|
|
357453eed4 | ||
|
|
25cc888b86 | ||
|
|
17ae87563f | ||
|
|
32503424e8 | ||
|
|
50be46df45 | ||
|
|
8c06962f62 | ||
|
|
2a439321d0 | ||
|
|
e993d19886 | ||
|
|
0e3b144cd7 | ||
|
|
98fbe1b96e | ||
|
|
8864df2b2c | ||
|
|
d49218409d | ||
|
|
35c83a918b | ||
|
|
c182eb179d | ||
|
|
650175913e | ||
|
|
5ee183d607 | ||
|
|
48be7a1c61 | ||
|
|
28acb94a6f | ||
|
|
2c60ba981b | ||
|
|
92e4ce88df | ||
|
|
c1eb7d0765 | ||
|
|
c66f7d78a0 | ||
|
|
c20ea57da6 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -10,12 +10,14 @@ pikit-web/.cache/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
backups/
|
||||
*.pyc
|
||||
|
||||
# OS/Editor
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.swp
|
||||
AGENTS.md
|
||||
|
||||
# Build artifacts
|
||||
*.log
|
||||
@@ -26,6 +28,3 @@ out/
|
||||
|
||||
# Stock images (large)
|
||||
images/stock/
|
||||
|
||||
# Local helpers
|
||||
set_ready.sh
|
||||
|
||||
81
PLAN.md
81
PLAN.md
@@ -1,81 +0,0 @@
|
||||
# Pi-Kit Updater Plan
|
||||
|
||||
## Goals
|
||||
- Add seamless in-dashboard updates for Pi-Kit assets (web UI, api script, helper scripts).
|
||||
- Allow manual check/apply + rollback; optional daily auto-check with a status chip.
|
||||
- Use release tarballs (not git clones) from Gitea; verify hashes; stage before swap; keep one backup.
|
||||
- Keep UX friendly: clear progress, errors, and “update available” indicator.
|
||||
|
||||
## Release Format (Gitea)
|
||||
- Release asset: `pikit-<version>.tar.gz` containing:
|
||||
- `pikit-web/` (built assets only, no node_modules)
|
||||
- `pikit-api.py`
|
||||
- helper scripts/configs that should overwrite
|
||||
- `manifest.json` (see below)
|
||||
- `manifest.json` fields:
|
||||
- `version` (string)
|
||||
- `changelog` (url)
|
||||
- `files`: array of { `path`, `sha256`, `mode`? }
|
||||
- `post_install` (optional array of commands or service restarts)
|
||||
- Optional: detached signature in future (ed25519), not in v1.
|
||||
|
||||
## On-device Updater (Python helper)
|
||||
- Location: `/usr/local/bin/pikit-updater.py` (invoked by API and timer).
|
||||
- State file: `/var/lib/pikit-update/state.json` (latest, current, last_check, last_result, progress, backup_path).
|
||||
- Steps for apply:
|
||||
1) Fetch manifest (HTTP GET release URL).
|
||||
2) Download tarball to `/var/tmp/pikit-update/<version>/bundle.tar.gz`.
|
||||
3) Verify SHA256 against manifest.
|
||||
4) Unpack to staging dir.
|
||||
5) Backup current managed files to `/var/backups/pikit/<ts>/` (keep 1 latest).
|
||||
6) Rsync staged files to destinations:
|
||||
- `/var/www/pikit-web/` (built assets)
|
||||
- `/usr/local/bin/pikit-api.py`
|
||||
7) Restart services: `dietpi-dashboard-frontend`, api service (if exists), maybe nginx reload.
|
||||
8) Update state; mark current version file `/etc/pikit/version`.
|
||||
- Rollback: restore latest backup and restart services.
|
||||
- Concurrency: use lockfile in `/var/run/pikit-update.lock`.
|
||||
|
||||
## API additions (pikit-api.py)
|
||||
- `GET /api/update/status` -> returns state.json contents + current version.
|
||||
- `POST /api/update/check` -> fetch manifest only, update state.
|
||||
- `POST /api/update/apply` -> spawn updater apply (can run inline for v1) and stream status.
|
||||
- `POST /api/update/rollback` -> restore last backup.
|
||||
- `POST /api/update/auto` -> enable/disable daily timer.
|
||||
|
||||
## Timer
|
||||
- systemd timer: `pikit-update.timer/service` running check daily (e.g., 04:20).
|
||||
- Writes last_check and latest_version to state.
|
||||
|
||||
## UI changes (pikit-web)
|
||||
- Top-bar chip: “Updates: Up to date / Update available / Checking…”.
|
||||
- New “Updates” modal (not the OS auto-updates) for Pi-Kit releases:
|
||||
- Show current vs latest version, changelog link.
|
||||
- Buttons: Check, Download & Install, Rollback, View logs.
|
||||
- Progress states + errors.
|
||||
- Hook into existing status refresh: call `/api/update/status`.
|
||||
|
||||
## Safety / Edge
|
||||
- Preflight disk space check before download.
|
||||
- Handle offline: clear message.
|
||||
- If apply fails, auto rollback.
|
||||
- Skip overwriting user-edited config? For v1 assume assets/api are managed; nginx site left unchanged.
|
||||
|
||||
## Current status (2025-12-11)
|
||||
- Version markers and mock update status in place.
|
||||
- UI has release chip + modal; buttons call real `/api/update/*`.
|
||||
- API implements check/apply/rollback with staging, SHA256 verify, backup, deploy, restart, rollback.
|
||||
- Release tooling: `tools/release/make-release.sh` + README; SHA256 baked into manifest.
|
||||
- Missing: auto-check timer, lockfile, backup pruning, real release publishing.
|
||||
|
||||
## Remaining work
|
||||
- Publish real release bundles + manifest to Gitea.
|
||||
- Add lock around apply/check to prevent overlap.
|
||||
- Add backup pruning (keep last N).
|
||||
- Add systemd timer/service to run check daily and surface status.
|
||||
- Optional: protect nginx/site overrides; add disk-space preflight.
|
||||
|
||||
## Open questions / assumptions
|
||||
- Release hosting: use Gitea releases at `https://git.44r0n.cc/44r0n7/pi-kit/releases/latest` (JSON API? or static URLs). For v1, hardcode base URL env var or config.
|
||||
- Services to restart: assume `dietpi-dashboard-frontend` and `pikit-api` (if we add a service file). Need to verify actual service names on device.
|
||||
- Device perms: updater runs as root (via sudo from API). Ensure API endpoint requires auth (existing login).
|
||||
40
README.md
40
README.md
@@ -1,21 +1,35 @@
|
||||
# Pi-Kit Dashboard
|
||||
Lightweight dashboard for DietPi-based Pi-Kit images.
|
||||
|
||||
Lightweight dashboard for DietPi-based Pi-Kit images. Two pieces live in this repo:
|
||||
|
||||
- `pikit-api.py`: tiny Python HTTP API (status, services, auto updates, factory reset). Runs on localhost:4000 and writes to `/etc/pikit/services.json`.
|
||||
- `pikit-web/`: static Vite site served by nginx from `/var/www/pikit-web`. Sources live in `pikit-web/assets/`; Playwright E2E tests in `pikit-web/tests/`.
|
||||
## What’s here
|
||||
- `pikit_api/` + `pikit-api.py`: Python HTTP API (status, services CRUD, auto-updates, diagnostics, factory reset), served on 127.0.0.1:4000.
|
||||
- `pikit-web/`: Vite static site served by nginx at `/var/www/pikit-web/`; source in `pikit-web/assets/`, Playwright E2E in `pikit-web/tests/`.
|
||||
- Release tooling: `tools/release/make-release.sh` builds a bundle tarball + manifest for OTA; changelogs live in `out/releases/`.
|
||||
|
||||
## Local development
|
||||
- Dashboard: `cd pikit-web && npm install` (first run), then `npm run dev` for Vite, `npm test` for Playwright, `npm run build` for production bundle.
|
||||
- API: `python pikit-api.py` to run locally (listens on 127.0.0.1:4000).
|
||||
- Frontend: `cd pikit-web && npm install` (once), `npm run dev` for live reload, `npm test` for Playwright, `npm run build` for production `dist/`.
|
||||
- API: `python pikit-api.py` runs the same routes locally (no auth) as on-device.
|
||||
|
||||
## Deploying to a Pi-Kit box
|
||||
1. Copy `pikit-api.py` to the device (e.g., `/usr/local/bin/`) and restart the service unit that wraps it.
|
||||
2. Sync `pikit-web/index.html` and `pikit-web/assets/*` (or the built `pikit-web/dist/*`) to `/var/www/pikit-web/`.
|
||||
3. The API surfaces clear errors if firewall tooling (`ufw`) is missing when ports are opened/closed.
|
||||
4. Factory reset sets `root` and `dietpi` passwords to `pikit`.
|
||||
## Build, package, and publish a release
|
||||
1) Bump `pikit-web/data/version.json` to the target version (e.g., `0.1.3-dev1`), then `cd pikit-web && npm run build`.
|
||||
2) Package: `./tools/release/make-release.sh <version> https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v<version>`
|
||||
Outputs in `out/releases/`: `pikit-<version>.tar.gz` and `manifest.json` (with SHA256 + changelog URL).
|
||||
3) Create a Gitea release/tag `v<version>` and upload:
|
||||
- `pikit-<version>.tar.gz`
|
||||
- `manifest.json`
|
||||
- `CHANGELOG-<version>.txt` (add a short changelog in `out/releases/`)
|
||||
4) Update repo manifests (public raw defaults used by devices): edit `manifests/manifest-stable.json` and `manifests/manifest-dev.json` with `version`, `bundle`, `changelog`, `_release_date`, `sha256` from the new bundle, then commit/push.
|
||||
- Default OTA URLs (no token needed):
|
||||
- Stable: `https://git.44r0n.cc/44r0n7/pi-kit/raw/branch/main/manifests/manifest-stable.json`
|
||||
- Dev: `https://git.44r0n.cc/44r0n7/pi-kit/raw/branch/main/manifests/manifest-dev.json`
|
||||
|
||||
## Deploy to a Pi-Kit box
|
||||
1) Copy `pikit-api.py` **and** the `pikit_api/` directory to the device (e.g., `/usr/local/bin/`) and restart `pikit-api.service`.
|
||||
2) Sync `pikit-web/dist/` (preferred) to `/var/www/pikit-web/`, then restart `dietpi-dashboard-frontend.service`.
|
||||
3) OTA defaults (no per-device token required): `PIKIT_MANIFEST_URL` points to the stable manifest above; enabling “Allow dev builds” in the UI makes the updater consult the dev manifest URL. Override via systemd drop-in if you need to pin to a specific manifest.
|
||||
|
||||
## Notes
|
||||
- Service paths are normalized (leading slash) and URLs include optional subpaths.
|
||||
- Firewall changes raise explicit errors when `ufw` is unavailable so the UI can surface what failed.
|
||||
- Access the device UI at `http://pikit.local/` (mDNS).
|
||||
- Firewall changes surface clear errors when `ufw` is missing so the UI can report failures.
|
||||
- Factory reset sets `root` and `dietpi` passwords to `pikit`.
|
||||
- Default UI: `http://pikit.local/` (mDNS) unless HTTPS is enabled.
|
||||
|
||||
@@ -1,49 +1,5 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
#!/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"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec "${SCRIPT_DIR}/pikit-prep.sh" --check-only "$@"
|
||||
|
||||
104
docs/workflow.md
Normal file
104
docs/workflow.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Pi-Kit Image Workflow
|
||||
|
||||
This documents the *current* workflow and the *target* workflow once profiles + first‑boot automation are implemented. It is meant to be a practical, repeatable checklist.
|
||||
|
||||
## 0) Keep a golden base image (do this first)
|
||||
1) Boot the known‑good base Pi.
|
||||
2) Verify core services:
|
||||
- Nginx + Pi‑Kit dashboard
|
||||
- DietPi dashboard
|
||||
3) Update the system if needed.
|
||||
4) Run the prep scrub + verify:
|
||||
- `sudo ./pikit-prep.sh`
|
||||
- `./pikit-smoke-test.sh`
|
||||
- (optional) `sudo ./pikit-prep.sh --check-only`
|
||||
5) Image the SD card with DietPi Imager.
|
||||
6) Store it as the golden base (e.g., `images/base/pikit-base-YYYYMMDD.img.xz`).
|
||||
|
||||
## 1) Build a profile image (current/manual workflow)
|
||||
1) Identify the SD card:
|
||||
- `lsblk`
|
||||
2) Flash the golden base image to SD:
|
||||
- `sudo ./flash_sd.sh qemu-dietpi/shared/base.img.xz /dev/sdX`
|
||||
3) Boot the Pi and install/configure services manually.
|
||||
- Avoid port 80/443 (Pi‑Kit already uses those).
|
||||
4) Add dashboard services using the UI (Add Service modal).
|
||||
5) Open any needed ports in ufw (done as part of testing/config):
|
||||
- `sudo ufw allow from <LAN subnet> to any port <port>`
|
||||
6) Run the prep scrub + verify:
|
||||
- `sudo ./pikit-prep.sh`
|
||||
- `./pikit-smoke-test.sh`
|
||||
- (optional) `sudo ./pikit-prep.sh --check-only`
|
||||
7) Image the SD card via the QEMU DietPi VM:
|
||||
- Insert the SD card into your desktop.
|
||||
- Identify it with `lsblk`.
|
||||
- Start QEMU with passthrough:
|
||||
- `./qemu-dietpi/run-dietpi.sh /dev/sdX`
|
||||
- SSH in:
|
||||
- `ssh -i qemu-dietpi/ssh/id_ed25519 -p 2222 root@localhost`
|
||||
- In the VM, go to the shared mount and run DietPi Imager:
|
||||
- `cd /mnt/images`
|
||||
- `dietpi-imager`
|
||||
- After imaging, shut down the VM:
|
||||
- `shutdown`
|
||||
8) Store the image as the profile name (e.g., `images/profiles/dns-stack.img.xz`).
|
||||
|
||||
## 2) Build a profile image (target workflow with profiles + first‑boot)
|
||||
1) Flash the golden base image to SD.
|
||||
2) Boot the Pi and install/configure services manually.
|
||||
3) Create or export the profile file locally: `profiles/<name>/profile.json`.
|
||||
- Includes *additional* services and firewall ports only.
|
||||
- Planned: export a profile from the running Pi (services + ufw) to avoid manual edits.
|
||||
4) Apply the profile to the Pi (planned script, optional if already configured):
|
||||
- Writes `/etc/pikit/profile.json` (for first‑boot).
|
||||
- Merges services into `/etc/pikit/services.json` (idempotent).
|
||||
5) Run the drift check (planned script):
|
||||
- Confirms services + ports match the profile + base.
|
||||
6) Run the prep scrub + verify:
|
||||
- `sudo ./pikit-prep.sh`
|
||||
- `./pikit-smoke-test.sh`
|
||||
- (optional) `sudo ./pikit-prep.sh --check-only`
|
||||
7) Image the SD card with DietPi Imager.
|
||||
|
||||
First boot on the end‑user device will:
|
||||
- Regenerate unique identity + TLS certs.
|
||||
- Ensure the profile’s firewall ports are open (LAN‑only).
|
||||
- Show a progress overlay until complete.
|
||||
|
||||
Optional: to skip the first‑boot update step for faster startup, create
|
||||
`/etc/pikit/firstboot.conf` with:
|
||||
|
||||
```
|
||||
PIKIT_FIRSTBOOT_UPDATES=0
|
||||
```
|
||||
|
||||
## 3) Flashing an image to SD
|
||||
Use the helper:
|
||||
- `sudo ./flash_sd.sh <image.img.xz> /dev/sdX`
|
||||
|
||||
## 4) Manufacturing / imaging checklist (production)
|
||||
1) Start from the golden base image (stored in `images/base/`).
|
||||
2) Flash it to a known‑good SD card.
|
||||
3) Boot and verify:
|
||||
- `http://pikit.local` and `https://pikit.local`
|
||||
- dashboard loads
|
||||
- first‑boot completes
|
||||
4) Apply any required profile/services.
|
||||
5) Run prep + verify:
|
||||
- `sudo ./pikit-prep.sh`
|
||||
- `./pikit-smoke-test.sh`
|
||||
6) Power down cleanly.
|
||||
7) Image the SD card (DietPi Imager via QEMU or on‑device).
|
||||
8) Name and archive the image:
|
||||
- Base: `images/base/pikit-base-YYYYMMDD-dietpi9.20.1.img.xz`
|
||||
- Profile: `images/profiles/pikit-<profile>-YYYYMMDD.img.xz`
|
||||
- Testing/staging: `images/staging/pikit-<profile>-YYYYMMDD-rcN.img.xz`
|
||||
9) Smoke test the flashed image on a second SD card:
|
||||
- boot → first‑boot → dashboard → services
|
||||
|
||||
## Notes
|
||||
- Profiles are additive to the base image defaults; do not include Pi‑Kit or DietPi dashboard entries in profiles.
|
||||
- Keep `RESCUE.md` in `/root` and `/home/dietpi` only (not in `/var/www`).
|
||||
- Prep enforces a password change for `dietpi` on first login; set `PIKIT_FORCE_PASSWORD_CHANGE=0` to skip.
|
||||
- After the password change, a one‑time SSH hardening tip is shown on login.
|
||||
- End-user defaults: OS security unattended upgrades on; Pi-Kit updater auto-check on stable channel, auto-apply off (user can change in dashboard).
|
||||
9
manifests/manifest-dev.json
Normal file
9
manifests/manifest-dev.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"version": "0.1.3-dev6",
|
||||
"_release_date": "2025-12-15T00:26:36Z",
|
||||
"bundle": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.3-dev6/pikit-0.1.3-dev6.tar.gz",
|
||||
"changelog": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.3-dev6/CHANGELOG-0.1.3-dev6.txt",
|
||||
"files": [
|
||||
{ "path": "bundle.tar.gz", "sha256": "2d53fb5fc1b98193defac8566707ef49dd4e69a3befc31646eee1c972c102c9e" }
|
||||
]
|
||||
}
|
||||
9
manifests/manifest-stable.json
Normal file
9
manifests/manifest-stable.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"version": "0.1.2",
|
||||
"_release_date": "2025-12-10T00:00:00Z",
|
||||
"bundle": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.2/pikit-0.1.2.tar.gz",
|
||||
"changelog": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.2/CHANGELOG-0.1.2.txt",
|
||||
"files": [
|
||||
{ "path": "bundle.tar.gz", "sha256": "8d2e0f8b260063cab0d52e862cb42f10472a643123f984af0248592479dd613d" }
|
||||
]
|
||||
}
|
||||
1317
pikit-api.py
1317
pikit-api.py
File diff suppressed because it is too large
Load Diff
@@ -1,391 +0,0 @@
|
||||
# Pi-Kit DietPi Image Prep Spec
|
||||
|
||||
This file defines how to design a **prep script** for a DietPi-based Pi-Kit image.
|
||||
|
||||
The script’s job:
|
||||
Prepare a running Pi-Kit system to be cloned as a “golden image” **without** removing any intentional software, configs, hostname, or passwords.
|
||||
|
||||
---
|
||||
|
||||
## 0. Context & Goals
|
||||
|
||||
**Starting point**
|
||||
|
||||
- OS: DietPi (Debian-based), already installed.
|
||||
- Extra software: web stack, Pi-Kit dashboard, DNS/ad-blocker, DBs, monitoring, etc.
|
||||
- System has been used for testing (logs, histories, test data, junk).
|
||||
|
||||
**Goal**
|
||||
|
||||
- Prepare system for cloning as a product image.
|
||||
- **KEEP**:
|
||||
- All intentionally installed packages/software.
|
||||
- All custom configs (web, apps, DietPi configs, firewall).
|
||||
- Current hostname.
|
||||
- Existing passwords (system + services) as shipped defaults.
|
||||
- **RESET/CLEAR**:
|
||||
- Host-unique identity data (machine-id, SSH host keys, etc.).
|
||||
- Logs, histories, caches.
|
||||
- Test/personal accounts and data.
|
||||
|
||||
---
|
||||
|
||||
## 1. Discovery Phase (MUST HAPPEN BEFORE SCRIPT DESIGN)
|
||||
|
||||
Before writing any code, inspect the system and external docs.
|
||||
|
||||
The AI MUST:
|
||||
|
||||
1. **Detect installed components**
|
||||
- Determine which key packages/services are present, e.g.:
|
||||
- Web server (nginx, lighttpd, apache2, etc.).
|
||||
- DNS/ad-blocker (Pi-hole or similar).
|
||||
- DB engines (MariaDB, PostgreSQL, SQLite usage).
|
||||
- Monitoring/metrics (Netdata, Uptime Kuma, etc.).
|
||||
- Use this to decide which cleanup sections apply.
|
||||
|
||||
2. **Verify paths/layouts**
|
||||
- For each service or category:
|
||||
- Confirm relevant paths/directories actually exist.
|
||||
- Do not assume standard paths without checking.
|
||||
- Example: Only treat `/var/log/nginx` as Nginx logs if:
|
||||
- Nginx is installed, AND
|
||||
- That directory exists.
|
||||
|
||||
3. **Consult upstream docs (online)**
|
||||
- Check current:
|
||||
- DietPi docs and/or DietPi GitHub.
|
||||
- Docs for major services (e.g. Pi-hole, Nginx, MariaDB, etc.).
|
||||
- Use docs to confirm:
|
||||
- Data vs config locations.
|
||||
- Safe cache/log cleanup methods.
|
||||
- Prefer documented behavior over guesses.
|
||||
|
||||
4. **Classify actions**
|
||||
- For each potential cleanup:
|
||||
- Mark as **safe** if clearly understood and documented.
|
||||
- Mark as **uncertain** if layout deviates or docs are unclear.
|
||||
- Plan to:
|
||||
- Perform safe actions.
|
||||
- Skip uncertain actions and surface them for manual review.
|
||||
|
||||
5. **Fail safe**
|
||||
- If something doesn’t match expectations:
|
||||
- Do NOT plan a destructive operation on it.
|
||||
- Flag it as “needs manual review” in the confirmation phase.
|
||||
|
||||
---
|
||||
|
||||
## 2. Identity & Host-Specific Secrets
|
||||
|
||||
**DO NOT CHANGE:**
|
||||
|
||||
- Hostname (whatever it currently is).
|
||||
- Any existing passwords (system or service-level) that are part of the appliance defaults.
|
||||
|
||||
**RESET/CLEAR:**
|
||||
|
||||
1. **Machine identity**
|
||||
- Clear:
|
||||
- `/etc/machine-id`
|
||||
- `/var/lib/dbus/machine-id` (if present)
|
||||
- Rely on OS to recreate them on next boot.
|
||||
|
||||
2. **Random seed**
|
||||
- Clear persisted random seed (e.g. `/var/lib/systemd/random-seed`) so each clone gets unique entropy.
|
||||
|
||||
3. **SSH host keys**
|
||||
- Remove all SSH **host key** files (server keys only).
|
||||
- Leave user SSH keypairs unless explicitly identified as dev/test and safe to remove.
|
||||
|
||||
4. **SSH known_hosts**
|
||||
- Clear `known_hosts` for:
|
||||
- `root`
|
||||
- `dietpi` (or primary DietPi user)
|
||||
- Any other persistent users
|
||||
|
||||
5. **VPN keys (conditional)**
|
||||
- If keys are meant to be unique per device:
|
||||
- Remove WireGuard/OpenVPN private keys and per-device configs embedding them.
|
||||
- If the design requires fixed server keys:
|
||||
- KEEP server keys.
|
||||
- REMOVE test/client keys/profiles that are tied to dev use.
|
||||
|
||||
6. **TLS certificates (conditional)**
|
||||
- REMOVE:
|
||||
- Let’s Encrypt/ACME certs tied to personal domains.
|
||||
- Per-device self-signed certs that should regenerate.
|
||||
- KEEP:
|
||||
- Shared CAs/certs only if explicitly part of product design.
|
||||
|
||||
---
|
||||
|
||||
## 3. Users & Personal Traces
|
||||
|
||||
1. **Accounts**
|
||||
- KEEP:
|
||||
- Accounts that are part of the product.
|
||||
- REMOVE:
|
||||
- Test-only accounts (users created for dev/debug).
|
||||
|
||||
2. **Shell histories**
|
||||
- Clear shell histories for all remaining users:
|
||||
- `root`, `dietpi`, others that stay.
|
||||
|
||||
3. **Home directories**
|
||||
- For users that remain:
|
||||
- KEEP:
|
||||
- Intentional config/dotfiles (shell rc, app config, etc.).
|
||||
- REMOVE:
|
||||
- Downloads, random files, scratch notes.
|
||||
- Editor backup/swap files, stray temp files.
|
||||
- Debug dumps, one-off scripts not part of product.
|
||||
- For users that are removed:
|
||||
- Delete their home dirs entirely.
|
||||
|
||||
4. **SSH client keys**
|
||||
- REMOVE:
|
||||
- Clearly personal/test keys (e.g. with your email in comments).
|
||||
- KEEP:
|
||||
- Only keys explicitly required by product design.
|
||||
|
||||
---
|
||||
|
||||
## 4. Logs & Telemetry
|
||||
|
||||
1. **System logs**
|
||||
- Clear:
|
||||
- Systemd journal (persistent logs).
|
||||
- `/var/log` files + rotated/compressed variants, where safe.
|
||||
|
||||
2. **Service logs**
|
||||
- For installed services (web servers, DNS/ad-blockers, DBs, etc.):
|
||||
- Clear their log files and rotated versions.
|
||||
|
||||
3. **Monitoring/metrics**
|
||||
- For tools like Netdata, Uptime Kuma, etc.:
|
||||
- KEEP:
|
||||
- Config, target definitions.
|
||||
- CLEAR:
|
||||
- Historical metric/alert data (TSDBs, history files, etc.).
|
||||
|
||||
---
|
||||
|
||||
## 5. Package Manager & Caches
|
||||
|
||||
1. **APT**
|
||||
- Clear:
|
||||
- Downloaded `.deb` archives.
|
||||
- Safe APT caches (as per documentation).
|
||||
|
||||
2. **Other caches**
|
||||
- Under `/var/cache` and `~/.cache`:
|
||||
- CLEAR:
|
||||
- Caches known to be safe and auto-regenerated.
|
||||
- DO NOT CLEAR:
|
||||
- Caches that are required for correct functioning or very expensive to rebuild, unless docs confirm safety.
|
||||
|
||||
3. **Temp directories**
|
||||
- Empty:
|
||||
- `/tmp`
|
||||
- `/var/tmp`
|
||||
|
||||
4. **Crash dumps**
|
||||
- Remove crash dumps and core files (e.g. `/var/crash` and similar locations).
|
||||
|
||||
---
|
||||
|
||||
## 6. Service Data vs Config (Per-App Logic)
|
||||
|
||||
General rule:
|
||||
|
||||
> Keep configuration & structure. Remove dev/test data, history, and personal content.
|
||||
|
||||
The AI must apply this using detected services + docs.
|
||||
|
||||
### 6.1 Web Servers (nginx / lighttpd / apache2)
|
||||
|
||||
- KEEP:
|
||||
- Main config and site configs that define Pi-Kit behavior.
|
||||
- App code in `/var/www/...` (or equivalent Pi-Kit web root).
|
||||
- CLEAR:
|
||||
- Access/error logs.
|
||||
- Non-critical caches if docs confirm they’re safe to recreate.
|
||||
|
||||
### 6.2 DNS / Ad-blockers (Pi-hole or similar)
|
||||
|
||||
- KEEP:
|
||||
- Upstream DNS settings.
|
||||
- Blocklists / adlists / local DNS overrides.
|
||||
- DHCP config if it is part of the product’s behavior.
|
||||
- CLEAR:
|
||||
- Query history / statistics DB.
|
||||
- Log files.
|
||||
- DO NOT:
|
||||
- Change the current admin password (it is the product default).
|
||||
|
||||
### 6.3 Databases (MariaDB, PostgreSQL, SQLite, etc.)
|
||||
|
||||
- KEEP:
|
||||
- DB schema.
|
||||
- Seed/default data required for every user.
|
||||
- REMOVE/RESET:
|
||||
- Dev/test user accounts (with your email, etc.).
|
||||
- Test content/records not meant for production image.
|
||||
- Access tokens, session records, API keys tied to dev use.
|
||||
- For SQLite-based apps:
|
||||
- Decide per app (based on docs) whether to:
|
||||
- Ship a pre-seeded “clean” DB, OR
|
||||
- Let it auto-create DB on first run.
|
||||
|
||||
### 6.4 Other services (Nextcloud, Jellyfin, Gotify, Uptime Kuma, etc.)
|
||||
|
||||
For each detected service:
|
||||
|
||||
- KEEP:
|
||||
- Global config, ports, base URLs, application settings needed for Pi-Kit.
|
||||
- CLEAR:
|
||||
- Personal/dev user accounts.
|
||||
- Your media/content (unless intentionally shipping sample content).
|
||||
- Notification endpoints tied to your own email / Gotify / Telegram, unless explicitly desired.
|
||||
|
||||
If docs or structure are unclear, mark cleanup as **uncertain** and surface in confirmation instead of guessing.
|
||||
|
||||
---
|
||||
|
||||
## 7. Networking & Firewall
|
||||
|
||||
**HARD CONSTRAINTS:**
|
||||
|
||||
- Do NOT modify hostname.
|
||||
- Do NOT weaken/remove the product firewall rules.
|
||||
|
||||
1. **Firewall**
|
||||
- Detect firewall system in use (iptables, nftables, UFW, etc.).
|
||||
- KEEP:
|
||||
- All persistent firewall configs that define Pi-Kit’s security behavior.
|
||||
- DO NOT:
|
||||
- Flush or reset firewall rules unless it’s clearly a dev-only configuration (and that’s confirmed).
|
||||
|
||||
2. **Other networking state**
|
||||
- Safe to CLEAR:
|
||||
- DHCP lease files.
|
||||
- DNS caches.
|
||||
- DO NOT ALTER:
|
||||
- Static IP/bridge/VLAN config that appears to be part of the intended appliance setup.
|
||||
|
||||
---
|
||||
|
||||
## 8. DietPi-Specific State & First-Boot Behavior
|
||||
|
||||
1. **DietPi automation/config**
|
||||
- Identify DietPi automation configuration (e.g. `dietpi.txt`, related files).
|
||||
- KEEP:
|
||||
- The intended defaults (locale, timezone, etc.).
|
||||
- Any automation that is part of Pi-Kit behavior.
|
||||
- AVOID:
|
||||
- Re-triggering DietPi’s generic first-boot flow unless that is intentionally desired.
|
||||
|
||||
2. **DietPi logs/temp**
|
||||
- CLEAR:
|
||||
- DietPi-specific logs and temp files.
|
||||
- KEEP:
|
||||
- All DietPi configuration and automation files.
|
||||
|
||||
3. **Pi-Kit first-boot logic**
|
||||
- Ensure any Pi-Kit specific first-run services/hooks are:
|
||||
- Enabled.
|
||||
- Not dependent on data being cleaned (e.g., they must not require removed dev tokens/paths).
|
||||
|
||||
---
|
||||
|
||||
## 9. Shell & Tooling State
|
||||
|
||||
1. **Tool caches**
|
||||
- For root and main user(s), CLEAR:
|
||||
- Safe caches in `~/.cache` (pip, npm, cargo, etc.), if not needed at runtime.
|
||||
- Avoid clearing caches that are critical or painful to rebuild unless doc-backed.
|
||||
|
||||
2. **Build artifacts**
|
||||
- REMOVE:
|
||||
- Source trees, build directories, and other dev artifacts that are not part of final product.
|
||||
|
||||
3. **Cronjobs / timers**
|
||||
- Audit:
|
||||
- User crontabs.
|
||||
- System crontabs.
|
||||
- Systemd timers.
|
||||
- KEEP:
|
||||
- Jobs that are part of Pi-Kit behavior.
|
||||
- REMOVE:
|
||||
- Jobs/timers clearly used for dev/testing only.
|
||||
|
||||
---
|
||||
|
||||
## 10. Implementation Requirements (For the Future Script)
|
||||
|
||||
When generating the actual script, the AI MUST:
|
||||
|
||||
1. **Error handling**
|
||||
- Check exit statuses where relevant.
|
||||
- Handle missing paths/directories gracefully:
|
||||
- If a path doesn’t exist, skip and log; do not fail hard.
|
||||
- Avoid wide-destructive operations without validation:
|
||||
- No “blind” deletions on unverified globs.
|
||||
|
||||
2. **Idempotency**
|
||||
- Script can run multiple times without progressively breaking the system.
|
||||
- After repeated runs, image should remain valid and “clean”.
|
||||
|
||||
3. **Conservative behavior**
|
||||
- If uncertain about an operation:
|
||||
- Do NOT perform it.
|
||||
- Log a warning and mark for manual review.
|
||||
|
||||
4. **Logging**
|
||||
- For each major category (identity, logs, caches, per-service cleanup, etc.):
|
||||
- Log what was targeted and outcome:
|
||||
- `cleaned`
|
||||
- `skipped (not installed/not found)`
|
||||
- `skipped (uncertain; manual review)`
|
||||
- Provide a summary at the end.
|
||||
|
||||
---
|
||||
|
||||
## 11. Mandatory Pre-Script Confirmation Step
|
||||
|
||||
**Before writing any script, the AI MUST:**
|
||||
|
||||
1. **Present a system-specific plan**
|
||||
- Based on discovery + docs, list:
|
||||
- Exactly which paths, files, DBs, and data types it intends to:
|
||||
- Remove
|
||||
- Reset
|
||||
- Leave untouched
|
||||
- For each item or group: a short explanation of **why**.
|
||||
|
||||
2. **Highlight conflicts / ambiguities**
|
||||
- If any cleanup might:
|
||||
- Affect passwords,
|
||||
- Affect hostname,
|
||||
- Affect firewall rules,
|
||||
- Or contradict this spec in any way,
|
||||
- The AI must:
|
||||
- Call it out explicitly.
|
||||
- Explain tradeoffs and propose a safe option.
|
||||
|
||||
3. **Highlight extra opportunities**
|
||||
- If the AI finds additional cleanup opportunities not explicitly listed here (e.g., new DietPi features, new log paths):
|
||||
- Describe them clearly.
|
||||
- Explain pros/cons of adding them.
|
||||
- Ask whether to include them.
|
||||
|
||||
4. **Wait for explicit approval**
|
||||
- Do NOT generate the script until:
|
||||
- The user (me) has reviewed the plan.
|
||||
- Conflicts and extra opportunities have been discussed.
|
||||
- Explicit approval (with any modifications) has been given.
|
||||
|
||||
Only after that confirmation may the AI produce the actual prep script.
|
||||
|
||||
---
|
||||
713
pikit-prep.sh
Normal file → Executable file
713
pikit-prep.sh
Normal file → Executable file
@@ -1,10 +1,100 @@
|
||||
#!/bin/bash
|
||||
# Pi-Kit DietPi image prep script
|
||||
# Cleans host-unique data, logs, caches, and temp files per pikit-prep-spec.
|
||||
# Pi-Kit DietPi image prep + check script
|
||||
# Cleans host-unique data and optionally verifies the image state.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
status() { printf '[%s] %s\n' "$1" "$2"; }
|
||||
SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")"
|
||||
SCRIPT_NAME="$(basename "$SCRIPT_PATH")"
|
||||
|
||||
PIKIT_HOST="${PIKIT_HOST:-pikit.local}"
|
||||
PIKIT_USER="${PIKIT_USER:-dietpi}"
|
||||
PIKIT_SSH_KEY="${PIKIT_SSH_KEY:-$HOME/.ssh/pikit}"
|
||||
PIKIT_SSH_OPTS="${PIKIT_SSH_OPTS:-}"
|
||||
PIKIT_REMOTE_TMP="${PIKIT_REMOTE_TMP:-/tmp/pikit-prep.sh}"
|
||||
PIKIT_SELF_DELETE="${PIKIT_SELF_DELETE:-0}"
|
||||
PIKIT_FORCE_PASSWORD_CHANGE="${PIKIT_FORCE_PASSWORD_CHANGE:-1}"
|
||||
|
||||
MODE="both"
|
||||
LOCAL_ONLY=0
|
||||
|
||||
ERRORS=0
|
||||
WARNINGS=0
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: pikit-prep.sh [--prep-only|--check-only] [--local]
|
||||
|
||||
Defaults to prep + check (combined). When run on a non-DietPi host, it will
|
||||
copy itself to the Pi (/tmp) and run with sudo, then clean up.
|
||||
|
||||
Options:
|
||||
--prep-only Run prep only (no check)
|
||||
--check-only Run checks only (no prep)
|
||||
--local Force local execution (no SSH copy)
|
||||
--help Show this help
|
||||
|
||||
Env:
|
||||
PIKIT_FORCE_PASSWORD_CHANGE=0 Skip forcing a password change (default is on)
|
||||
USAGE
|
||||
}
|
||||
|
||||
status() {
|
||||
local level="$1"
|
||||
shift
|
||||
printf '[%s] %s\n' "$level" "$*"
|
||||
case "$level" in
|
||||
FAIL) ERRORS=$((ERRORS + 1)) ;;
|
||||
WARN) WARNINGS=$((WARNINGS + 1)) ;;
|
||||
esac
|
||||
}
|
||||
|
||||
section() {
|
||||
printf '\n== %s ==\n' "$1"
|
||||
}
|
||||
|
||||
is_dietpi() {
|
||||
grep -qi "dietpi" /etc/os-release 2>/dev/null
|
||||
}
|
||||
|
||||
require_root() {
|
||||
if [ "${EUID:-$(id -u)}" -ne 0 ]; then
|
||||
echo "[FAIL] This script must run as root (use sudo)." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
parse_args() {
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--prep-only) MODE="prep" ;;
|
||||
--check-only) MODE="check" ;;
|
||||
--local) LOCAL_ONLY=1 ;;
|
||||
--help|-h) usage; exit 0 ;;
|
||||
*)
|
||||
echo "[FAIL] Unknown argument: $arg" >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
run_remote() {
|
||||
local forward=()
|
||||
for arg in "$@"; do
|
||||
[ "$arg" = "--local" ] && continue
|
||||
forward+=("$arg")
|
||||
done
|
||||
if ! command -v scp >/dev/null 2>&1 || ! command -v ssh >/dev/null 2>&1; then
|
||||
echo "[FAIL] ssh/scp not available for remote prep" >&2
|
||||
exit 1
|
||||
fi
|
||||
scp -i "$PIKIT_SSH_KEY" $PIKIT_SSH_OPTS "$SCRIPT_PATH" "${PIKIT_USER}@${PIKIT_HOST}:${PIKIT_REMOTE_TMP}"
|
||||
ssh -i "$PIKIT_SSH_KEY" $PIKIT_SSH_OPTS "${PIKIT_USER}@${PIKIT_HOST}" \
|
||||
"sudo PIKIT_SELF_DELETE=1 bash ${PIKIT_REMOTE_TMP} --local ${forward[*]}; rc=\$?; rm -f ${PIKIT_REMOTE_TMP}; exit \$rc"
|
||||
exit $?
|
||||
}
|
||||
|
||||
clean_logs_dir() {
|
||||
local dir="$1" pattern="${2:-*}"
|
||||
@@ -65,181 +155,484 @@ clean_backups() {
|
||||
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
|
||||
clean_home_dir() {
|
||||
local dir="$1"
|
||||
shift
|
||||
local keep=("$@")
|
||||
if [ -d "$dir" ]; then
|
||||
shopt -s dotglob nullglob
|
||||
for entry in "$dir"/*; do
|
||||
local base
|
||||
base="$(basename "$entry")"
|
||||
case "$base" in
|
||||
.|..) continue ;;
|
||||
esac
|
||||
local keep_it=0
|
||||
for k in "${keep[@]}"; do
|
||||
if [ "$base" = "$k" ]; then
|
||||
keep_it=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ "$keep_it" -eq 0 ]; then
|
||||
rm -rf "$entry" && status CLEANED "$dir/$base" || status FAIL "$dir/$base"
|
||||
fi
|
||||
done
|
||||
shopt -u dotglob nullglob
|
||||
else
|
||||
status SKIP "$dir (missing)"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- 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
|
||||
prep_image() {
|
||||
section "Prep"
|
||||
|
||||
# --- 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"
|
||||
# --- Identity ---
|
||||
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
|
||||
|
||||
# --- 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"
|
||||
# --- 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
|
||||
|
||||
# --- Ready flag ---
|
||||
clean_file /var/run/pikit-ready
|
||||
# --- 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"
|
||||
|
||||
# --- Backup/editor cruft ---
|
||||
clean_backups /var/www/pikit-web
|
||||
clean_backups /usr/local/bin
|
||||
# --- Default login ---
|
||||
if id -u dietpi >/dev/null 2>&1; then
|
||||
echo "dietpi:pikit" | chpasswd && status CLEANED "reset dietpi password" || status FAIL "reset dietpi password"
|
||||
mkdir -p /var/lib/pikit
|
||||
rm -f /var/lib/pikit/first-login.notice
|
||||
case "${PIKIT_FORCE_PASSWORD_CHANGE,,}" in
|
||||
1|true|yes|on)
|
||||
chage -d 0 dietpi && status CLEANED "force dietpi password change on next login" || status FAIL "force dietpi password change"
|
||||
:> /var/lib/pikit/first-login.notice && chmod 644 /var/lib/pikit/first-login.notice \
|
||||
&& status CLEANED "first-login notice armed" || status FAIL "first-login notice"
|
||||
;;
|
||||
*) ;;
|
||||
esac
|
||||
else
|
||||
status SKIP "dietpi user missing"
|
||||
fi
|
||||
|
||||
# --- 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
|
||||
# --- 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"
|
||||
|
||||
# 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
|
||||
# --- Home directories ---
|
||||
clean_home_dir /home/dietpi .bashrc .bash_logout .profile .ssh RESCUE.md
|
||||
clean_home_dir /root .bashrc .bash_logout .profile .ssh RESCUE.md
|
||||
|
||||
if [ -x /opt/AdGuardHome/AdGuardHome ]; then
|
||||
clean_logs_dir /var/opt/AdGuardHome/data/logs '*'
|
||||
clean_file /opt/AdGuardHome/data/querylog.db
|
||||
fi
|
||||
# --- Ready flag ---
|
||||
clean_file /var/run/pikit-ready
|
||||
|
||||
if command -v ufw >/dev/null 2>&1; then
|
||||
truncate_file /var/log/ufw.log
|
||||
fi
|
||||
# --- First-boot state + TLS ---
|
||||
clean_dir_files /var/lib/pikit/firstboot "*"
|
||||
clean_file /var/lib/pikit/firstboot/firstboot.done
|
||||
clean_file /var/lib/pikit/firstboot/state.json
|
||||
clean_file /var/lib/pikit/firstboot/firstboot.log
|
||||
clean_file /var/lib/pikit/firstboot/firstboot.error
|
||||
clean_file /var/lib/pikit/firstboot/firstboot.lock
|
||||
clean_file /etc/pikit/certs/pikit-ca.crt
|
||||
clean_file /etc/pikit/certs/pikit-ca.key
|
||||
clean_file /etc/pikit/certs/pikit-ca.srl
|
||||
clean_file /etc/pikit/certs/pikit.local.crt
|
||||
clean_file /etc/pikit/certs/pikit.local.key
|
||||
clean_file /etc/pikit/certs/pikit.local.csr
|
||||
clean_file /var/www/pikit-web/assets/pikit-ca.crt
|
||||
clean_file /var/www/pikit-web/assets/pikit-ca.sha256
|
||||
clean_file /var/lib/pikit-update/state.json
|
||||
clean_file /var/run/pikit-update.lock
|
||||
|
||||
if command -v fail2ban-client >/dev/null 2>&1; then
|
||||
truncate_file /var/log/fail2ban.log
|
||||
fi
|
||||
# --- Backup/editor cruft ---
|
||||
clean_backups /var/www/pikit-web
|
||||
clean_backups /usr/local/bin
|
||||
|
||||
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 '*'
|
||||
# --- Logs ---
|
||||
clean_dir_files /var/log "*"
|
||||
clean_dir_files /var/log/nginx "*"
|
||||
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
|
||||
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
|
||||
|
||||
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 '*'
|
||||
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
|
||||
fi
|
||||
|
||||
# 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 '*'
|
||||
if [ -x /opt/AdGuardHome/AdGuardHome ]; then
|
||||
clean_logs_dir /var/opt/AdGuardHome/data/logs '*'
|
||||
clean_file /opt/AdGuardHome/data/querylog.db
|
||||
fi
|
||||
|
||||
# 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
|
||||
if command -v ufw >/dev/null 2>&1; then
|
||||
truncate_file /var/log/ufw.log
|
||||
fi
|
||||
|
||||
# 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 "*"
|
||||
if command -v fail2ban-client >/dev/null 2>&1; then
|
||||
truncate_file /var/log/fail2ban.log
|
||||
fi
|
||||
|
||||
# --- 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"
|
||||
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 '*'
|
||||
|
||||
# --- Temp directories ---
|
||||
truncate_dir /tmp
|
||||
truncate_dir /var/tmp
|
||||
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 '*'
|
||||
|
||||
# --- DHCP leases ---
|
||||
clean_file /var/lib/dhcp/dhclient.eth0.leases
|
||||
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 '*'
|
||||
|
||||
# --- 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
|
||||
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
|
||||
|
||||
status DONE "Prep complete"
|
||||
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 "*"
|
||||
|
||||
# Self-delete to avoid leaving the prep tool on the image.
|
||||
rm -- "$0"
|
||||
# --- 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
|
||||
|
||||
# --- DietPi RAMlog store (preserve log dir structure) ---
|
||||
if [ -d /var/log ]; then
|
||||
local log_group="adm"
|
||||
if ! getent group "$log_group" >/dev/null 2>&1; then
|
||||
log_group="root"
|
||||
fi
|
||||
install -d -m 0755 -o root -g "$log_group" /var/log/nginx \
|
||||
&& status CLEANED "/var/log/nginx (ensured)" \
|
||||
|| status FAIL "/var/log/nginx"
|
||||
else
|
||||
status SKIP "/var/log (missing)"
|
||||
fi
|
||||
if [ -x /boot/dietpi/func/dietpi-ramlog ]; then
|
||||
/boot/dietpi/func/dietpi-ramlog 1 >/dev/null 2>&1 \
|
||||
&& status CLEANED "DietPi RAMlog store" \
|
||||
|| status FAIL "DietPi RAMlog store"
|
||||
else
|
||||
status SKIP "DietPi RAMlog (missing)"
|
||||
fi
|
||||
|
||||
# --- 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"
|
||||
}
|
||||
|
||||
check_home_dir() {
|
||||
local dir="$1"
|
||||
shift
|
||||
local keep=("$@")
|
||||
if [ -d "$dir" ]; then
|
||||
local extra=()
|
||||
shopt -s dotglob nullglob
|
||||
for entry in "$dir"/*; do
|
||||
local base
|
||||
base="$(basename "$entry")"
|
||||
case "$base" in
|
||||
.|..) continue ;;
|
||||
esac
|
||||
local keep_it=0
|
||||
for k in "${keep[@]}"; do
|
||||
if [ "$base" = "$k" ]; then
|
||||
keep_it=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ "$keep_it" -eq 0 ]; then
|
||||
extra+=("$base")
|
||||
fi
|
||||
done
|
||||
shopt -u dotglob nullglob
|
||||
if [ "${#extra[@]}" -gt 0 ]; then
|
||||
status WARN "extra files in $dir: ${extra[*]}"
|
||||
else
|
||||
status OK "home clean: $dir"
|
||||
fi
|
||||
else
|
||||
status FAIL "missing home dir: $dir"
|
||||
fi
|
||||
}
|
||||
|
||||
check_file_missing() {
|
||||
local path="$1"
|
||||
if [ -e "$path" ]; then
|
||||
status FAIL "unexpected file exists: $path"
|
||||
else
|
||||
status OK "missing as expected: $path"
|
||||
fi
|
||||
}
|
||||
|
||||
check_file_empty_or_missing() {
|
||||
local path="$1"
|
||||
if [ -e "$path" ]; then
|
||||
if [ -s "$path" ]; then
|
||||
status FAIL "file not empty: $path"
|
||||
else
|
||||
status OK "empty file: $path"
|
||||
fi
|
||||
else
|
||||
status OK "missing (ok): $path"
|
||||
fi
|
||||
}
|
||||
|
||||
check_image() {
|
||||
section "Check"
|
||||
|
||||
section "Identity files"
|
||||
if [ -e /etc/machine-id ]; then
|
||||
if [ -s /etc/machine-id ]; then
|
||||
status FAIL "/etc/machine-id not empty"
|
||||
else
|
||||
status OK "/etc/machine-id empty"
|
||||
fi
|
||||
else
|
||||
status FAIL "/etc/machine-id missing"
|
||||
fi
|
||||
if [ -L /var/lib/dbus/machine-id ]; then
|
||||
status OK "/var/lib/dbus/machine-id symlink present"
|
||||
else
|
||||
status WARN "dbus machine-id missing or not a symlink"
|
||||
fi
|
||||
check_file_missing /var/lib/systemd/random-seed
|
||||
|
||||
section "SSH host keys"
|
||||
if ls /etc/ssh/ssh_host_* >/dev/null 2>&1; then
|
||||
status FAIL "SSH host keys still present"
|
||||
else
|
||||
status OK "no SSH host keys"
|
||||
fi
|
||||
|
||||
section "SSH client traces"
|
||||
check_file_empty_or_missing /root/.ssh/known_hosts
|
||||
check_file_empty_or_missing /home/dietpi/.ssh/known_hosts
|
||||
check_file_empty_or_missing /home/dietpi/.ssh/authorized_keys
|
||||
check_file_empty_or_missing /root/.ssh/authorized_keys
|
||||
|
||||
section "Home directories"
|
||||
check_home_dir /home/dietpi .bashrc .bash_logout .profile .ssh RESCUE.md
|
||||
check_home_dir /root .bashrc .bash_logout .profile .ssh RESCUE.md
|
||||
|
||||
section "Ready flag"
|
||||
check_file_missing /var/run/pikit-ready
|
||||
|
||||
section "Firstboot state"
|
||||
if [ -d /var/lib/pikit/firstboot ]; then
|
||||
local count
|
||||
count="$(find /var/lib/pikit/firstboot -type f 2>/dev/null | wc -l | tr -d ' ')"
|
||||
if [ "$count" -gt 0 ]; then
|
||||
status WARN "firstboot files still present: $count"
|
||||
else
|
||||
status OK "firstboot dir empty"
|
||||
fi
|
||||
else
|
||||
status OK "firstboot dir missing (ok)"
|
||||
fi
|
||||
|
||||
section "TLS certs"
|
||||
check_file_missing /etc/pikit/certs/pikit-ca.crt
|
||||
check_file_missing /etc/pikit/certs/pikit-ca.key
|
||||
check_file_missing /etc/pikit/certs/pikit.local.crt
|
||||
check_file_missing /etc/pikit/certs/pikit.local.key
|
||||
check_file_missing /var/www/pikit-web/assets/pikit-ca.crt
|
||||
check_file_missing /var/www/pikit-web/assets/pikit-ca.sha256
|
||||
check_file_missing /var/lib/pikit-update/state.json
|
||||
check_file_missing /var/run/pikit-update.lock
|
||||
|
||||
section "Logs"
|
||||
if [ -d /var/log ]; then
|
||||
local nonempty
|
||||
nonempty="$(find /var/log -type f -size +0c 2>/dev/null | wc -l | tr -d ' ')"
|
||||
if [ "$nonempty" -gt 0 ]; then
|
||||
status WARN "/var/log has non-empty files: $nonempty"
|
||||
else
|
||||
status OK "/var/log empty"
|
||||
fi
|
||||
else
|
||||
status WARN "/var/log missing"
|
||||
fi
|
||||
if [ -d /var/log/nginx ]; then
|
||||
local nginx_nonempty
|
||||
nginx_nonempty="$(find /var/log/nginx -type f -size +0c 2>/dev/null | wc -l | tr -d ' ')"
|
||||
if [ "$nginx_nonempty" -gt 0 ]; then
|
||||
status WARN "/var/log/nginx has non-empty files: $nginx_nonempty"
|
||||
else
|
||||
status OK "/var/log/nginx empty"
|
||||
fi
|
||||
else
|
||||
status WARN "/var/log/nginx missing"
|
||||
fi
|
||||
|
||||
section "DietPi RAM logs"
|
||||
if [ -d /var/tmp/dietpi/logs/dietpi-ramlog_store ]; then
|
||||
status OK "DietPi RAMlog store present"
|
||||
else
|
||||
status FAIL "DietPi RAMlog store missing"
|
||||
fi
|
||||
|
||||
section "Caches"
|
||||
du -sh /var/cache/apt /var/lib/apt/lists /var/cache/debconf 2>/dev/null || true
|
||||
|
||||
section "Temp dirs"
|
||||
local tmp_extra
|
||||
tmp_extra="$(find /tmp /var/tmp -maxdepth 1 -mindepth 1 ! -name 'systemd-private-*' ! -path '/var/tmp/dietpi' 2>/dev/null | wc -l | tr -d ' ')"
|
||||
if [ "$tmp_extra" -gt 0 ]; then
|
||||
status WARN "temp dirs not empty"
|
||||
else
|
||||
status OK "temp dirs empty"
|
||||
fi
|
||||
|
||||
section "DHCP lease"
|
||||
check_file_missing /var/lib/dhcp/dhclient.eth0.leases
|
||||
|
||||
section "Nginx cache dirs"
|
||||
if [ -d /var/lib/nginx ]; then
|
||||
local nginx_cache
|
||||
nginx_cache="$(find /var/lib/nginx -mindepth 1 -maxdepth 1 -type d 2>/dev/null | wc -l | tr -d ' ')"
|
||||
if [ "$nginx_cache" -gt 0 ]; then
|
||||
status WARN "nginx cache dirs present: $nginx_cache"
|
||||
else
|
||||
status OK "/var/lib/nginx empty"
|
||||
fi
|
||||
else
|
||||
status OK "/var/lib/nginx missing (ok)"
|
||||
fi
|
||||
}
|
||||
|
||||
finalize() {
|
||||
section "Summary"
|
||||
status OK "warnings: $WARNINGS"
|
||||
status OK "errors: $ERRORS"
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo "[FAIL] Prep/check completed with errors."
|
||||
exit 1
|
||||
fi
|
||||
echo "[OK] Prep/check completed."
|
||||
}
|
||||
|
||||
maybe_self_delete() {
|
||||
if [ "$PIKIT_SELF_DELETE" -eq 1 ] && [[ "$SCRIPT_PATH" == /tmp/* ]]; then
|
||||
rm -f "$SCRIPT_PATH" || true
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
parse_args "$@"
|
||||
|
||||
if [ "$LOCAL_ONLY" -eq 0 ] && ! is_dietpi; then
|
||||
run_remote "$@"
|
||||
fi
|
||||
|
||||
require_root
|
||||
|
||||
case "$MODE" in
|
||||
prep) prep_image ;;
|
||||
check) check_image ;;
|
||||
both)
|
||||
prep_image
|
||||
check_image
|
||||
;;
|
||||
esac
|
||||
|
||||
finalize
|
||||
maybe_self_delete
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
252
pikit-smoke-test.sh
Executable file
252
pikit-smoke-test.sh
Executable file
@@ -0,0 +1,252 @@
|
||||
#!/bin/bash
|
||||
# Pi-Kit post-prep smoke test (HTTP/HTTPS/API/firstboot/services)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PIKIT_HOST="${PIKIT_HOST:-pikit.local}"
|
||||
PIKIT_USER="${PIKIT_USER:-dietpi}"
|
||||
PIKIT_SSH_KEY="${PIKIT_SSH_KEY:-$HOME/.ssh/pikit}"
|
||||
PIKIT_SSH_OPTS="${PIKIT_SSH_OPTS:-}"
|
||||
PIKIT_HTTP_URL="${PIKIT_HTTP_URL:-http://$PIKIT_HOST}"
|
||||
PIKIT_HTTPS_URL="${PIKIT_HTTPS_URL:-https://$PIKIT_HOST}"
|
||||
PIKIT_API_URL="${PIKIT_API_URL:-http://127.0.0.1:4000}"
|
||||
|
||||
LOCAL_ONLY=0
|
||||
ERRORS=0
|
||||
WARNINGS=0
|
||||
REMOTE_MODE=0
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: pikit-smoke-test.sh [--local]
|
||||
|
||||
Runs a quick post-prep smoke test:
|
||||
- HTTP/HTTPS reachable
|
||||
- API reachable and returns JSON
|
||||
- firstboot state done
|
||||
- core services active (nginx, pikit-api, dietpi-dashboard-frontend)
|
||||
|
||||
Options:
|
||||
--local Run locally on the Pi (skip SSH)
|
||||
--help Show this help
|
||||
|
||||
Env:
|
||||
PIKIT_HOST, PIKIT_USER, PIKIT_SSH_KEY, PIKIT_SSH_OPTS
|
||||
PIKIT_HTTP_URL, PIKIT_HTTPS_URL
|
||||
USAGE
|
||||
}
|
||||
|
||||
status() {
|
||||
local level="$1"
|
||||
shift
|
||||
printf '[%s] %s\n' "$level" "$*"
|
||||
case "$level" in
|
||||
FAIL) ERRORS=$((ERRORS + 1)) ;;
|
||||
WARN) WARNINGS=$((WARNINGS + 1)) ;;
|
||||
esac
|
||||
}
|
||||
|
||||
section() {
|
||||
printf '\n== %s ==\n' "$1"
|
||||
}
|
||||
|
||||
is_dietpi() {
|
||||
grep -qi "dietpi" /etc/os-release 2>/dev/null
|
||||
}
|
||||
|
||||
parse_args() {
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--local) LOCAL_ONLY=1 ;;
|
||||
--help|-h) usage; exit 0 ;;
|
||||
*)
|
||||
echo "[FAIL] Unknown argument: $arg" >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
remote_cmd() {
|
||||
local cmd="$1"
|
||||
if [ "$LOCAL_ONLY" -eq 1 ] || is_dietpi; then
|
||||
bash -c "$cmd"
|
||||
else
|
||||
ssh -i "$PIKIT_SSH_KEY" $PIKIT_SSH_OPTS -o ConnectTimeout=10 "${PIKIT_USER}@${PIKIT_HOST}" "$cmd"
|
||||
fi
|
||||
}
|
||||
|
||||
extract_json_line() {
|
||||
awk 'BEGIN{found=0} /^[[:space:]]*[{[]/ {print; found=1; exit} END{if(!found) exit 0}'
|
||||
}
|
||||
|
||||
json_get() {
|
||||
local key="$1"
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 -c 'import json,sys
|
||||
key=sys.argv[1]
|
||||
try:
|
||||
data=json.load(sys.stdin)
|
||||
except Exception:
|
||||
print("")
|
||||
sys.exit(1)
|
||||
val=data.get(key, "")
|
||||
if isinstance(val, bool):
|
||||
print("true" if val else "false")
|
||||
else:
|
||||
print(val)
|
||||
' "$key"
|
||||
elif command -v jq >/dev/null 2>&1; then
|
||||
jq -r --arg key "$key" '.[$key] // empty'
|
||||
else
|
||||
cat >/dev/null
|
||||
echo ""
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
check_http() {
|
||||
local url="$1"
|
||||
local label="$2"
|
||||
if curl -fsS --max-time 5 "$url" >/dev/null; then
|
||||
status OK "$label reachable"
|
||||
else
|
||||
status FAIL "$label not reachable"
|
||||
fi
|
||||
}
|
||||
|
||||
check_https() {
|
||||
local url="$1"
|
||||
local label="$2"
|
||||
if curl -kfsS --max-time 5 "$url" >/dev/null; then
|
||||
status OK "$label reachable"
|
||||
else
|
||||
status FAIL "$label not reachable"
|
||||
fi
|
||||
}
|
||||
|
||||
check_api() {
|
||||
local url="$1"
|
||||
local body
|
||||
if ! body="$(remote_cmd "curl -fsS --max-time 5 $url")"; then
|
||||
status FAIL "API not reachable: $url"
|
||||
return
|
||||
fi
|
||||
if [ "$REMOTE_MODE" -eq 1 ]; then
|
||||
body="$(printf "%s\n" "$body" | extract_json_line)"
|
||||
fi
|
||||
if [ -z "$body" ]; then
|
||||
status FAIL "API response empty or not JSON"
|
||||
return
|
||||
fi
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
if printf "%s" "$body" | python3 -c 'import json,sys
|
||||
try:
|
||||
data=json.load(sys.stdin)
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
for key in ("services","hostname","uptime_seconds"):
|
||||
if key in data:
|
||||
sys.exit(0)
|
||||
sys.exit(1)
|
||||
'
|
||||
then
|
||||
status OK "API responds with JSON"
|
||||
else
|
||||
status WARN "API response did not include expected fields"
|
||||
fi
|
||||
else
|
||||
status WARN "python3 missing; API JSON check skipped"
|
||||
fi
|
||||
}
|
||||
|
||||
check_firstboot() {
|
||||
local url="$1"
|
||||
local body state error_present
|
||||
if ! body="$(remote_cmd "curl -fsS --max-time 5 $url")"; then
|
||||
status FAIL "firstboot API not reachable"
|
||||
return
|
||||
fi
|
||||
if [ "$REMOTE_MODE" -eq 1 ]; then
|
||||
body="$(printf "%s\n" "$body" | extract_json_line)"
|
||||
fi
|
||||
if [ -z "$body" ]; then
|
||||
status FAIL "firstboot status invalid or missing"
|
||||
return
|
||||
fi
|
||||
state="$(printf "%s" "$body" | json_get "state" || true)"
|
||||
error_present="$(printf "%s" "$body" | json_get "error_present" || true)"
|
||||
if [ -z "$state" ]; then
|
||||
status FAIL "firstboot status invalid or missing"
|
||||
return
|
||||
fi
|
||||
if [ "$state" = "done" ] && [ "$error_present" != "true" ]; then
|
||||
status OK "firstboot completed"
|
||||
else
|
||||
status FAIL "firstboot not complete (state=$state error=$error_present)"
|
||||
fi
|
||||
}
|
||||
|
||||
check_services() {
|
||||
local services=("nginx" "pikit-api" "dietpi-dashboard-frontend")
|
||||
for svc in "${services[@]}"; do
|
||||
if remote_cmd "systemctl is-active --quiet $svc"; then
|
||||
status OK "$svc active"
|
||||
else
|
||||
status FAIL "$svc not active"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
check_ports() {
|
||||
local cmd="ss -lnt | awk '{print \$4}' | grep -E ':(80|443|5252|5253)\$' | sort -u"
|
||||
local out
|
||||
if out="$(remote_cmd "$cmd" 2>/dev/null)"; then
|
||||
if echo "$out" | grep -q ":80" && echo "$out" | grep -q ":443"; then
|
||||
status OK "ports 80/443 listening"
|
||||
else
|
||||
status WARN "ports 80/443 not both listening"
|
||||
fi
|
||||
else
|
||||
status WARN "unable to check ports"
|
||||
fi
|
||||
}
|
||||
|
||||
finalize() {
|
||||
section "Summary"
|
||||
status OK "warnings: $WARNINGS"
|
||||
status OK "errors: $ERRORS"
|
||||
if [ "$ERRORS" -gt 0 ]; then
|
||||
echo "[FAIL] Smoke test failed."
|
||||
exit 1
|
||||
fi
|
||||
echo "[OK] Smoke test passed."
|
||||
}
|
||||
|
||||
main() {
|
||||
parse_args "$@"
|
||||
if [ "$LOCAL_ONLY" -eq 0 ] && ! is_dietpi; then
|
||||
REMOTE_MODE=1
|
||||
fi
|
||||
|
||||
section "HTTP/HTTPS"
|
||||
check_http "$PIKIT_HTTP_URL" "HTTP"
|
||||
check_https "$PIKIT_HTTPS_URL" "HTTPS"
|
||||
|
||||
section "API"
|
||||
check_api "$PIKIT_API_URL/api/status"
|
||||
|
||||
section "Firstboot"
|
||||
check_firstboot "$PIKIT_API_URL/api/firstboot"
|
||||
|
||||
section "Services"
|
||||
check_services
|
||||
|
||||
section "Ports"
|
||||
check_ports
|
||||
|
||||
finalize
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -31,6 +31,8 @@ export async function api(path, opts = {}) {
|
||||
}
|
||||
|
||||
export const getStatus = () => api("/api/status");
|
||||
export const getFirstbootStatus = () => api("/api/firstboot");
|
||||
export const getFirstbootError = () => api("/api/firstboot/error");
|
||||
export const toggleUpdates = (enable) =>
|
||||
api("/api/updates/auto", {
|
||||
method: "POST",
|
||||
@@ -53,10 +55,12 @@ export const applyRelease = () =>
|
||||
api("/api/update/apply", {
|
||||
method: "POST",
|
||||
});
|
||||
export const rollbackRelease = () =>
|
||||
api("/api/update/rollback", {
|
||||
export const applyReleaseVersion = (version) =>
|
||||
api("/api/update/apply_version", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ version }),
|
||||
});
|
||||
export const listReleases = () => api("/api/update/releases");
|
||||
export const setReleaseAutoCheck = (enable) =>
|
||||
api("/api/update/auto", {
|
||||
method: "POST",
|
||||
@@ -117,3 +121,15 @@ export const removeService = ({ port }) =>
|
||||
method: "POST",
|
||||
body: JSON.stringify({ port }),
|
||||
});
|
||||
|
||||
// Diagnostics
|
||||
export const getDiagLog = () => api("/api/diag/log");
|
||||
export const setDiagLevel = ({ enabled, level }) =>
|
||||
api("/api/diag/log/level", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ enabled, level }),
|
||||
});
|
||||
export const clearDiagLog = () =>
|
||||
api("/api/diag/log/clear", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
140
pikit-web/assets/css/fonts.css
Normal file
140
pikit-web/assets/css/fonts.css
Normal file
@@ -0,0 +1,140 @@
|
||||
@font-face {
|
||||
font-family: "Red Hat Text";
|
||||
src: url("../fonts/RedHatText-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Red Hat Text";
|
||||
src: url("../fonts/RedHatText-Medium.woff2") format("woff2");
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Red Hat Display";
|
||||
src: url("../fonts/RedHatDisplay-SemiBold.woff2") format("woff2");
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Red Hat Display";
|
||||
src: url("../fonts/RedHatDisplay-Bold.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Space Grotesk";
|
||||
src: url("../fonts/SpaceGrotesk-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Space Grotesk";
|
||||
src: url("../fonts/SpaceGrotesk-Medium.woff2") format("woff2");
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Space Grotesk";
|
||||
src: url("../fonts/SpaceGrotesk-SemiBold.woff2") format("woff2");
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Manrope";
|
||||
src: url("../fonts/Manrope-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Manrope";
|
||||
src: url("../fonts/Manrope-SemiBold.woff2") format("woff2");
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "DM Sans";
|
||||
src: url("../fonts/DMSans-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "DM Sans";
|
||||
src: url("../fonts/DMSans-Medium.woff2") format("woff2");
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "DM Sans";
|
||||
src: url("../fonts/DMSans-Bold.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Sora";
|
||||
src: url("../fonts/Sora-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Sora";
|
||||
src: url("../fonts/Sora-SemiBold.woff2") format("woff2");
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Chivo";
|
||||
src: url("../fonts/Chivo-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Chivo";
|
||||
src: url("../fonts/Chivo-Bold.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Atkinson Hyperlegible";
|
||||
src: url("../fonts/Atkinson-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Atkinson Hyperlegible";
|
||||
src: url("../fonts/Atkinson-Bold.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Sans";
|
||||
src: url("../fonts/PlexSans-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Sans";
|
||||
src: url("../fonts/PlexSans-SemiBold.woff2") format("woff2");
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
335
pikit-web/assets/css/forms.css
Normal file
335
pikit-web/assets/css/forms.css
Normal file
@@ -0,0 +1,335 @@
|
||||
button {
|
||||
background: linear-gradient(135deg, #22c55e, #7dd3fc);
|
||||
color: #041012;
|
||||
border: none;
|
||||
padding: 10px 16px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
button.icon-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 1.1rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] button.icon-btn {
|
||||
box-shadow: inset 0 0 0 1px var(--border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
button.danger-btn {
|
||||
background: linear-gradient(135deg, #f87171, #ef4444);
|
||||
color: #0f1117;
|
||||
}
|
||||
|
||||
.menu-btn {
|
||||
font-size: 1rem;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.service-menu {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.service-menu .ghost {
|
||||
border: 1px solid color-mix(in srgb, var(--border) 60%, var(--text) 20%);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
label.toggle {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 46px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
label.toggle input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
label.toggle .slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
background: var(--toggle-track);
|
||||
border-radius: 24px;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
label.toggle .slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
label.toggle input:checked + .slider {
|
||||
background: linear-gradient(135deg, #22c55e, #7dd3fc);
|
||||
}
|
||||
|
||||
label.toggle input:checked + .slider:before {
|
||||
transform: translateX(22px);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.accordion {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: var(--panel-overlay);
|
||||
}
|
||||
|
||||
.accordion + .accordion {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.accordion-toggle {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: none;
|
||||
padding: 12px 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.accordion-toggle.danger-btn {
|
||||
color: #0f1117;
|
||||
}
|
||||
|
||||
.accordion-body {
|
||||
padding: 0 14px 0;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transition:
|
||||
max-height 0.24s ease,
|
||||
opacity 0.18s ease,
|
||||
padding-bottom 0.18s ease,
|
||||
padding-top 0.18s ease;
|
||||
}
|
||||
|
||||
.accordion.open .accordion-body {
|
||||
max-height: 1200px;
|
||||
opacity: 1;
|
||||
padding: 8px 12px 6px;
|
||||
}
|
||||
|
||||
.accordion-body p {
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.control-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-actions.split-row {
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-actions.column.tight {
|
||||
gap: 6px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.control-actions.column {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.control-actions.column > .checkbox-row.inline {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
select,
|
||||
input {
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--input-border);
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.checkbox-row.inline {
|
||||
display: inline-flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.checkbox-row.inline.tight {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.checkbox-row.inline.nowrap span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.control-row.split {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-row.split > * {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dual-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dual-row .dual-col:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dual-row .checkbox-row.inline {
|
||||
justify-content: flex-start;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.checkbox-field .checkbox-row {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.status-msg {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.status-msg.error {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.note-warn {
|
||||
color: #f87171;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.is-disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.is-disabled input,
|
||||
.is-disabled select,
|
||||
.is-disabled textarea {
|
||||
filter: grayscale(0.5);
|
||||
background: var(--input-disabled-bg);
|
||||
color: var(--input-disabled-text);
|
||||
border-color: var(--input-disabled-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.is-disabled label {
|
||||
color: var(--disabled-text);
|
||||
}
|
||||
|
||||
.is-disabled .slider {
|
||||
filter: grayscale(0.7);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
background: #2f3844;
|
||||
border: 1px solid #3b4756;
|
||||
color: #c9d2dc;
|
||||
pointer-events: none;
|
||||
}
|
||||
379
pikit-web/assets/css/layout.css
Normal file
379
pikit-web/assets/css/layout.css
Normal file
@@ -0,0 +1,379 @@
|
||||
.host-chip {
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.layout {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 18px 80px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 0.9fr;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.12rem;
|
||||
color: var(--muted);
|
||||
margin: 0 0 6px;
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
.eyebrow.warning {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 10px;
|
||||
line-height: 1.2;
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 6px;
|
||||
line-height: 1.2;
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lede {
|
||||
color: var(--muted);
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.hint.quiet {
|
||||
opacity: 0.8;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
box-shadow: var(--shadow);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
background: var(--card-overlay);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 9px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.stat .label {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.stat .value {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 18px;
|
||||
margin-bottom: 18px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.panel-actions .icon-btn {
|
||||
font-size: 1.15rem;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.panel-header.small-gap {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.grid.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 220px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.empty-state button {
|
||||
margin-top: 6px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card-overlay);
|
||||
padding: 12px;
|
||||
padding-right: 48px; /* reserve room for stacked action buttons */
|
||||
padding-bottom: 34px; /* reserve room for bottom badges */
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card.offline {
|
||||
border-color: rgba(225, 29, 72, 0.45);
|
||||
}
|
||||
|
||||
.card a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.service-url {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--accent) 18%, var(--panel) 82%);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border) 65%);
|
||||
color: var(--text);
|
||||
font-size: 0.85rem;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pill-small {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.notice-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px dashed #6b7280;
|
||||
color: #6b7280;
|
||||
font-size: 0.78rem;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.self-signed-pill {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.notice-link {
|
||||
display: inline-block;
|
||||
margin-top: 8px;
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.info-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 60%, var(--text) 20%);
|
||||
border-radius: 10px;
|
||||
font-size: 1rem;
|
||||
color: var(--text);
|
||||
background: var(--card-overlay);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #e11d48;
|
||||
box-shadow: 0 0 8px rgba(225, 29, 72, 0.5);
|
||||
}
|
||||
|
||||
.status-dot.on {
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 8px rgba(34, 197, 94, 0.6);
|
||||
}
|
||||
|
||||
html[data-anim="on"] .status-dot.on {
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.35);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px rgba(34, 197, 94, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.status-dot.off {
|
||||
background: #f87171;
|
||||
box-shadow: 0 0 8px rgba(248, 113, 113, 0.5);
|
||||
}
|
||||
|
||||
.service-header {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.service-header .pill {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.service-header .status-dot,
|
||||
.service-header .menu-btn,
|
||||
.service-header .notice-pill {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.service-header .menu-btn {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
#servicesGrid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
}
|
||||
|
||||
.log-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.log-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.log-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.log-actions .icon-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
font-size: 0.9rem;
|
||||
background: var(--card-overlay);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.log-box {
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
font-family: "DM Sans", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.9rem;
|
||||
border: 1px dashed var(--border);
|
||||
color: var(--muted);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .log-box {
|
||||
background: rgba(12, 18, 32, 0.04);
|
||||
}
|
||||
|
||||
.codeblock {
|
||||
background: var(--input-bg);
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
max-width: 440px;
|
||||
min-width: 260px;
|
||||
border: 1px solid var(--border);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
414
pikit-web/assets/css/modal.css
Normal file
414
pikit-web/assets/css/modal.css
Normal file
@@ -0,0 +1,414 @@
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.18s ease;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.modal#changelogModal {
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
.modal.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal:not(.hidden) {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
padding: 18px;
|
||||
border-radius: 14px;
|
||||
min-width: 300px;
|
||||
max-width: 420px;
|
||||
transform: translateY(6px) scale(0.99);
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.modal-card.wide {
|
||||
max-width: 820px;
|
||||
width: 90vw;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.modal-card.wide .panel-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
margin: 0 0 12px;
|
||||
padding: 18px 18px 12px;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.modal-card.wide .help-body,
|
||||
.modal-card.wide .controls {
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
|
||||
.modal-card.wide .control-card {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
/* Extra breathing room for custom add-service modal */
|
||||
#addServiceModal .modal-card {
|
||||
padding: 18px 18px 16px;
|
||||
}
|
||||
|
||||
#addServiceModal .controls {
|
||||
padding: 0 2px 4px;
|
||||
}
|
||||
|
||||
/* Busy overlay already defined; ensure modal width for release modal */
|
||||
#releaseModal .modal-card.wide {
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.release-versions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.release-versions > div {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.release-versions .align-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.release-status-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.release-advanced {
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
margin-top: 8px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.release-advanced-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.release-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.release-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.release-card input[type="radio"] {
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.release-card-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.release-card-tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-card .status-msg {
|
||||
overflow-wrap: anywhere;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.modal:not(.hidden) .modal-card {
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.modal-card .close-btn {
|
||||
min-width: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.config-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.config-row {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.config-row.danger {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.modal-actions .push {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.modal-actions .primary {
|
||||
background: linear-gradient(135deg, #16d0d8, #59e693);
|
||||
color: #0c0f17;
|
||||
border: none;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.config-label h4 {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.config-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.config-controls textarea {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
min-height: 96px;
|
||||
}
|
||||
|
||||
.config-controls input[type="text"],
|
||||
.config-controls input[type="number"],
|
||||
.config-controls select {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.overlay.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.overlay-box {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
padding: 20px;
|
||||
border-radius: 14px;
|
||||
max-width: 420px;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.firstboot-overlay .overlay-box {
|
||||
width: min(92vw, 980px);
|
||||
max-width: 980px;
|
||||
text-align: left;
|
||||
padding: 24px;
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.firstboot-header h3 {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.firstboot-body {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
gap: 18px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.firstboot-steps-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 8px 0 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.firstboot-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 0.95rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.firstboot-step .step-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--border);
|
||||
flex: 0 0 10px;
|
||||
}
|
||||
|
||||
.firstboot-step.current {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.firstboot-step.current .step-dot {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.firstboot-step.done {
|
||||
color: var(--text);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.firstboot-step.done .step-dot {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.firstboot-step.error {
|
||||
color: #ef4444;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.firstboot-step.error .step-dot {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.firstboot-current {
|
||||
margin: 0 0 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.firstboot-log .log-box {
|
||||
max-height: 240px;
|
||||
min-height: 140px;
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
.firstboot-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.spinner {
|
||||
margin: 12px auto 4px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 4px solid rgba(255, 255, 255, 0.2);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-actions button {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.help-body h4 {
|
||||
margin: 10px 0 6px;
|
||||
}
|
||||
|
||||
.help-body ul {
|
||||
margin: 0 0 12px 18px;
|
||||
padding: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.help-body ul a {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.help-body li {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.danger {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.modal-card label {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.modal-card input {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.modal-card.wide pre.log-box {
|
||||
max-height: 60vh;
|
||||
}
|
||||
|
||||
#releaseModal pre.log-box {
|
||||
max-height: 220px !important;
|
||||
min-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#diagModal pre.log-box {
|
||||
max-height: 60vh;
|
||||
min-height: 300px;
|
||||
}
|
||||
57
pikit-web/assets/css/motion.css
Normal file
57
pikit-web/assets/css/motion.css
Normal file
@@ -0,0 +1,57 @@
|
||||
/* Motion (opt-out via data-anim="off") */
|
||||
html[data-anim="on"] .card,
|
||||
html[data-anim="on"] .stat,
|
||||
html[data-anim="on"] button,
|
||||
html[data-anim="on"] .accordion,
|
||||
html[data-anim="on"] .modal-card {
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
background-color 0.18s ease;
|
||||
}
|
||||
|
||||
html[data-anim="on"] .card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 14px 26px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
html[data-anim="on"][data-theme="light"] .card:hover {
|
||||
box-shadow: 0 14px 26px rgba(12, 18, 32, 0.12);
|
||||
}
|
||||
|
||||
html[data-anim="on"] button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
html[data-anim="on"] button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
html[data-anim="off"] .card,
|
||||
html[data-anim="off"] .stat,
|
||||
html[data-anim="off"] button,
|
||||
html[data-anim="off"] .accordion,
|
||||
html[data-anim="off"] .modal-card {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* Hard-stop any remaining motion (spinners, keyframes, incidental transitions) */
|
||||
html[data-anim="off"] *,
|
||||
html[data-anim="off"] *::before,
|
||||
html[data-anim="off"] *::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* Focus states */
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
.card:focus-visible,
|
||||
.status-chip:focus-visible,
|
||||
.accordion-toggle:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
88
pikit-web/assets/css/responsive.css
Normal file
88
pikit-web/assets/css/responsive.css
Normal file
@@ -0,0 +1,88 @@
|
||||
@media (min-width: 1180px) {
|
||||
#servicesGrid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
#servicesGrid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.layout {
|
||||
padding: 24px 14px 60px;
|
||||
}
|
||||
.panel {
|
||||
padding: 16px;
|
||||
}
|
||||
.grid {
|
||||
gap: 10px;
|
||||
}
|
||||
.card {
|
||||
padding: 12px;
|
||||
}
|
||||
.hero-stats {
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.release-versions {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.release-versions .align-right {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.topbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.brand {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.top-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
.top-actions .ghost {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.top-indicators {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.top-indicators .chip-label {
|
||||
grid-column: 1 / -1;
|
||||
margin-right: 0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.top-indicators .status-chip,
|
||||
.top-indicators .hint {
|
||||
width: auto;
|
||||
justify-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.hero-stats {
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
}
|
||||
}
|
||||
117
pikit-web/assets/css/theme.css
Normal file
117
pikit-web/assets/css/theme.css
Normal file
@@ -0,0 +1,117 @@
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--panel: #161a23;
|
||||
--panel-overlay: rgba(255, 255, 255, 0.02);
|
||||
--card-overlay: rgba(255, 255, 255, 0.03);
|
||||
--muted: #9ca3af;
|
||||
--text: #e5e7eb;
|
||||
--accent: #7dd3fc;
|
||||
--accent-2: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
--border: #1f2430;
|
||||
--shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
|
||||
--topbar-bg: rgba(15, 17, 23, 0.8);
|
||||
--toggle-track: #374151;
|
||||
--input-bg: #0c0e14;
|
||||
--input-border: var(--border);
|
||||
--disabled-bg: #141a22;
|
||||
--disabled-border: #2a313c;
|
||||
--disabled-text: #7c8696;
|
||||
--disabled-strong: #0b0f18;
|
||||
--input-disabled-bg: #141a22;
|
||||
--input-disabled-text: #7c8696;
|
||||
--input-disabled-border: #2a313c;
|
||||
--font-body: "Red Hat Text", "Inter", "Segoe UI", system-ui, -apple-system,
|
||||
sans-serif;
|
||||
--font-heading: "Red Hat Display", "Red Hat Text", system-ui, -apple-system,
|
||||
sans-serif;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
:root[data-font="space"] {
|
||||
--font-body: "Space Grotesk", "Inter", "Segoe UI", system-ui, -apple-system,
|
||||
sans-serif;
|
||||
--font-heading: "Space Grotesk", "Red Hat Text", system-ui, -apple-system,
|
||||
sans-serif;
|
||||
}
|
||||
:root[data-font="manrope"] {
|
||||
--font-body: "Manrope", "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
--font-heading: "Manrope", "Manrope", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
:root[data-font="dmsans"] {
|
||||
--font-body: "DM Sans", "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
--font-heading: "DM Sans", "Red Hat Display", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
:root[data-font="sora"] {
|
||||
--font-body: "Sora", "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
--font-heading: "Sora", "Sora", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
:root[data-font="chivo"] {
|
||||
--font-body: "Chivo", "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
--font-heading: "Chivo", "Chivo", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
:root[data-font="atkinson"] {
|
||||
--font-body: "Atkinson Hyperlegible", "Inter", "Segoe UI", system-ui, -apple-system,
|
||||
sans-serif;
|
||||
--font-heading: "Atkinson Hyperlegible", "Atkinson Hyperlegible", system-ui,
|
||||
-apple-system, sans-serif;
|
||||
}
|
||||
:root[data-font="plex"] {
|
||||
--font-body: "IBM Plex Sans", "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
--font-heading: "IBM Plex Sans", "IBM Plex Sans", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
:root[data-theme="light"] {
|
||||
--bg: #dfe4ee;
|
||||
--panel: #f6f8fd;
|
||||
--panel-overlay: rgba(10, 12, 18, 0.06);
|
||||
--card-overlay: rgba(10, 12, 18, 0.11);
|
||||
--muted: #4b5563;
|
||||
--text: #0b1224;
|
||||
--accent: #0077c2;
|
||||
--accent-2: #15803d;
|
||||
--warning: #b45309;
|
||||
--border: #bcc5d6;
|
||||
--shadow: 0 12px 30px rgba(12, 18, 32, 0.12);
|
||||
--topbar-bg: rgba(249, 251, 255, 0.92);
|
||||
--toggle-track: #d1d5db;
|
||||
--input-bg: #f0f2f7;
|
||||
--input-border: #c5ccd9;
|
||||
--disabled-bg: #f4f6fb;
|
||||
--disabled-border: #c8d0df;
|
||||
--disabled-text: #7a8292;
|
||||
--disabled-strong: #eef1f7;
|
||||
--input-disabled-bg: #f8fafc;
|
||||
--input-disabled-text: #6a6f7b;
|
||||
--input-disabled-border: #c9d1df;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 20% 20%,
|
||||
rgba(125, 211, 252, 0.08),
|
||||
transparent 32%
|
||||
),
|
||||
radial-gradient(circle at 80% 0%, rgba(34, 197, 94, 0.06), transparent 28%),
|
||||
linear-gradient(180deg, #0f1117 0%, #0e1119 55%, #0b0f15 100%);
|
||||
background-attachment: fixed;
|
||||
background-repeat: no-repeat;
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
transition: background 240ms ease, color 240ms ease;
|
||||
}
|
||||
:root[data-theme="light"] body {
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 25% 18%,
|
||||
rgba(0, 119, 194, 0.14),
|
||||
transparent 34%
|
||||
),
|
||||
radial-gradient(circle at 78% 8%, rgba(21, 128, 61, 0.12), transparent 30%),
|
||||
linear-gradient(180deg, #f6f8fd 0%, #e8edf7 52%, #d6dde9 100%);
|
||||
background-attachment: fixed;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
204
pikit-web/assets/css/toast.css
Normal file
204
pikit-web/assets/css/toast.css
Normal file
@@ -0,0 +1,204 @@
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
min-width: 200px;
|
||||
max-width: 320px;
|
||||
max-height: 240px;
|
||||
overflow: hidden;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
box-shadow: var(--shadow);
|
||||
font-weight: 600;
|
||||
pointer-events: auto;
|
||||
opacity: 0;
|
||||
transform: translateY(var(--toast-slide-offset, 14px));
|
||||
transition:
|
||||
opacity var(--toast-speed, 0.28s) ease,
|
||||
transform var(--toast-speed, 0.28s) ease,
|
||||
max-height var(--toast-speed, 0.28s) ease,
|
||||
padding var(--toast-speed, 0.28s) ease,
|
||||
margin var(--toast-speed, 0.28s) ease;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
border-color: rgba(34, 197, 94, 0.5);
|
||||
}
|
||||
|
||||
.toast.warn {
|
||||
border-color: rgba(217, 119, 6, 0.5);
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
border-color: rgba(225, 29, 72, 0.6);
|
||||
}
|
||||
|
||||
.toast.info {
|
||||
border-color: rgba(125, 211, 252, 0.4);
|
||||
}
|
||||
|
||||
html[data-anim="off"] .toast {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.toast.anim-slide-in {
|
||||
transform: translate(var(--toast-slide-x, 0px), var(--toast-slide-y, 24px));
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.toast.anim-slide-in.show {
|
||||
transform: translate(0, 0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast.anim-fade {
|
||||
transform: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.toast.anim-fade.show {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast.anim-pop {
|
||||
transform: scale(0.9);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.toast.anim-pop.show {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast.anim-bounce {
|
||||
opacity: 0;
|
||||
transform: translateY(calc(var(--toast-dir, 1) * 20px));
|
||||
}
|
||||
|
||||
.toast.anim-bounce.show {
|
||||
opacity: 1;
|
||||
animation: toast-bounce var(--toast-speed, 0.46s) cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
.toast.anim-drop {
|
||||
opacity: 0;
|
||||
transform: translateY(calc(var(--toast-dir, 1) * -24px)) scale(0.98);
|
||||
}
|
||||
|
||||
.toast.anim-drop.show {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast.anim-grow {
|
||||
transform: scale(0.85);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.toast.anim-grow.show {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast.leaving {
|
||||
opacity: 0 !important;
|
||||
transform: translateY(12px) !important;
|
||||
max-height: 0 !important;
|
||||
margin: 0 !important;
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
@keyframes toast-bounce {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(calc(var(--toast-dir, 1) * 20px)) scale(0.96);
|
||||
}
|
||||
55% {
|
||||
opacity: 1;
|
||||
transform: translateY(calc(var(--toast-dir, 1) * -8px)) scale(1.03);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.toast-container.pos-bottom-center {
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
right: auto;
|
||||
top: auto;
|
||||
transform: translateX(-50%);
|
||||
flex-direction: column-reverse;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toast-container.pos-bottom-right {
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
left: auto;
|
||||
top: auto;
|
||||
transform: none;
|
||||
flex-direction: column-reverse;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.toast-container.pos-bottom-left {
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
right: auto;
|
||||
top: auto;
|
||||
transform: none;
|
||||
flex-direction: column-reverse;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.toast-container.pos-top-right {
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
bottom: auto;
|
||||
left: auto;
|
||||
transform: none;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.toast-container.pos-top-left {
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
bottom: auto;
|
||||
right: auto;
|
||||
transform: none;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.toast-container.pos-top-center {
|
||||
top: 16px;
|
||||
left: 50%;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
transform: translateX(-50%);
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
126
pikit-web/assets/css/topbar.css
Normal file
126
pikit-web/assets/css/topbar.css
Normal file
@@ -0,0 +1,126 @@
|
||||
.topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 22px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
backdrop-filter: blur(10px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--topbar-bg);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.3px;
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
.brand .dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #22c55e, #7dd3fc);
|
||||
box-shadow: 0 0 10px rgba(125, 211, 252, 0.6);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.top-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.top-indicators {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chip-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.status-chip.quiet {
|
||||
opacity: 0.75;
|
||||
font-size: 0.85rem;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.status-chip.chip-on {
|
||||
color: var(--accent-2);
|
||||
border-color: rgba(22, 163, 74, 0.4);
|
||||
background: rgba(22, 163, 74, 0.08);
|
||||
}
|
||||
|
||||
.status-chip.chip-off {
|
||||
color: #e11d48;
|
||||
border-color: rgba(225, 29, 72, 0.4);
|
||||
background: rgba(225, 29, 72, 0.08);
|
||||
}
|
||||
|
||||
.status-chip.chip-system {
|
||||
color: #3b82f6;
|
||||
border-color: rgba(59, 130, 246, 0.45);
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
|
||||
.status-chip.chip-warm {
|
||||
color: #d97706;
|
||||
border-color: rgba(217, 119, 6, 0.35);
|
||||
background: rgba(217, 119, 6, 0.12);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .status-chip {
|
||||
background: rgba(12, 18, 32, 0.06);
|
||||
border-color: rgba(12, 18, 32, 0.14);
|
||||
color: #1f2a3d;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .status-chip.quiet {
|
||||
background: rgba(12, 18, 32, 0.05);
|
||||
color: #243247;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .status-chip.chip-on {
|
||||
background: rgba(34, 197, 94, 0.16);
|
||||
border-color: rgba(34, 197, 94, 0.5);
|
||||
color: #0f5132;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .status-chip.chip-system {
|
||||
background: rgba(59, 130, 246, 0.16);
|
||||
border-color: rgba(59, 130, 246, 0.55);
|
||||
color: #153e9f;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .status-chip.chip-warm {
|
||||
background: rgba(217, 119, 6, 0.16);
|
||||
border-color: rgba(217, 119, 6, 0.5);
|
||||
color: #8a4b08;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .status-chip.chip-off {
|
||||
background: rgba(225, 29, 72, 0.18);
|
||||
border-color: rgba(225, 29, 72, 0.55);
|
||||
color: #7a1028;
|
||||
}
|
||||
152
pikit-web/assets/css/updates.css
Normal file
152
pikit-web/assets/css/updates.css
Normal file
@@ -0,0 +1,152 @@
|
||||
#releaseProgress {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.updates-status {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.updates-status.error {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#acc-updates .accordion-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px 12px 2px !important;
|
||||
}
|
||||
|
||||
#updatesSection {
|
||||
width: 100%;
|
||||
gap: 4px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
#updatesControls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#updatesControls.is-disabled {
|
||||
opacity: 0.6;
|
||||
background: var(--disabled-bg);
|
||||
border: 1px dashed var(--disabled-border);
|
||||
border-radius: 8px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
#updatesControls.is-disabled * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#updatesControls input:disabled,
|
||||
#updatesControls select:disabled,
|
||||
#updatesControls textarea:disabled {
|
||||
background: var(--input-disabled-bg);
|
||||
color: var(--input-disabled-text);
|
||||
border-color: var(--input-disabled-border);
|
||||
}
|
||||
|
||||
#updatesControls .checkbox-row input:disabled + span,
|
||||
#updatesControls label,
|
||||
#updatesControls .field > span,
|
||||
#updatesControls .hint {
|
||||
color: var(--disabled-text);
|
||||
}
|
||||
|
||||
#updatesControls .control-actions,
|
||||
#updatesControls .field {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
#updatesControls .toggle .slider {
|
||||
filter: grayscale(0.9);
|
||||
}
|
||||
|
||||
/* Disabled styling scoped to updates section */
|
||||
#updatesControls.is-disabled input,
|
||||
#updatesControls.is-disabled select,
|
||||
#updatesControls.is-disabled textarea {
|
||||
background: var(--input-disabled-bg);
|
||||
color: var(--input-disabled-text);
|
||||
border-color: var(--input-disabled-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
#updatesControls.is-disabled .checkbox-row span,
|
||||
#updatesControls.is-disabled label,
|
||||
#updatesControls.is-disabled .field > span,
|
||||
#updatesControls.is-disabled .hint {
|
||||
color: var(--disabled-text);
|
||||
}
|
||||
|
||||
#updatesControls.is-disabled .control-actions,
|
||||
#updatesControls.is-disabled .field {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
#updatesControls.is-disabled .toggle .slider {
|
||||
filter: grayscale(0.8);
|
||||
}
|
||||
|
||||
/* Light theme contrast for disabled controls */
|
||||
:root[data-theme="light"] #updatesSection {
|
||||
background: #f7f9fd;
|
||||
border: 1px solid #d9dfeb;
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] #updatesControls {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] #updatesControls.is-disabled {
|
||||
opacity: 1;
|
||||
background: var(--disabled-bg);
|
||||
border: 1px dashed var(--disabled-border);
|
||||
border-radius: 8px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] #updatesControls.is-disabled * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] #updatesControls.is-disabled input,
|
||||
:root[data-theme="light"] #updatesControls.is-disabled select,
|
||||
:root[data-theme="light"] #updatesControls.is-disabled textarea {
|
||||
background: var(--input-disabled-bg) !important;
|
||||
color: var(--input-disabled-text) !important;
|
||||
border: 1px dashed var(--disabled-border) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] #updatesControls.is-disabled .checkbox-row input:disabled + span,
|
||||
:root[data-theme="light"] #updatesControls.is-disabled label,
|
||||
:root[data-theme="light"] #updatesControls.is-disabled .field > span,
|
||||
:root[data-theme="light"] #updatesControls.is-disabled .hint {
|
||||
color: var(--disabled-text) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] #updatesControls.is-disabled .control-actions,
|
||||
:root[data-theme="light"] #updatesControls.is-disabled .field {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] #updatesControls.is-disabled .toggle .slider {
|
||||
filter: grayscale(0.2);
|
||||
}
|
||||
|
||||
#updatesControls .form-grid {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#updatesControls .control-actions.split-row {
|
||||
margin: 0;
|
||||
}
|
||||
4
pikit-web/assets/diaglog.css
Normal file
4
pikit-web/assets/diaglog.css
Normal file
@@ -0,0 +1,4 @@
|
||||
.diag-log-modal .log-box {
|
||||
max-height: 60vh;
|
||||
min-height: 300px;
|
||||
}
|
||||
225
pikit-web/assets/diaglog.js
Normal file
225
pikit-web/assets/diaglog.js
Normal file
@@ -0,0 +1,225 @@
|
||||
// Diagnostic logging (frontend side)
|
||||
// Maintains a client-side ring buffer, fetches server logs, and wires UI controls.
|
||||
|
||||
import { getDiagLog, setDiagLevel, clearDiagLog } from "./api.js";
|
||||
|
||||
const UI_MAX = 500;
|
||||
const uiBuffer = [];
|
||||
let uiEnabled = false;
|
||||
let uiLevel = "normal";
|
||||
let clickListenerAttached = false;
|
||||
let loading = false;
|
||||
|
||||
function appendUi(level, msg, meta = null) {
|
||||
if (!uiEnabled) return;
|
||||
if (level === "debug" && uiLevel !== "debug") return;
|
||||
const ts = new Date().toISOString();
|
||||
const entry = { ts, level, msg, meta, source: "ui" };
|
||||
uiBuffer.unshift(entry);
|
||||
if (uiBuffer.length > UI_MAX) uiBuffer.length = UI_MAX;
|
||||
}
|
||||
|
||||
function attachClickTracker() {
|
||||
if (clickListenerAttached) return;
|
||||
clickListenerAttached = true;
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(e) => {
|
||||
if (!uiEnabled || uiLevel !== "debug") return;
|
||||
const el = e.target.closest("button,input,select,textarea,label");
|
||||
if (!el) return;
|
||||
const label =
|
||||
el.getAttribute("aria-label") ||
|
||||
el.getAttribute("title") ||
|
||||
el.textContent?.trim()?.slice(0, 60) ||
|
||||
el.id ||
|
||||
el.tagName.toLowerCase();
|
||||
appendUi("debug", `UI click: ${label || el.tagName}`, {
|
||||
id: el.id || null,
|
||||
type: el.tagName.toLowerCase(),
|
||||
});
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
export function logUi(msg, level = "info", meta) {
|
||||
appendUi(level, msg, meta);
|
||||
}
|
||||
|
||||
export async function initDiagUI({ elements, toast }) {
|
||||
const {
|
||||
enableToggle,
|
||||
debugToggle,
|
||||
refreshBtn,
|
||||
clearBtn,
|
||||
copyBtn,
|
||||
downloadBtn,
|
||||
logBox,
|
||||
statusEl,
|
||||
logButton,
|
||||
modal,
|
||||
modalClose,
|
||||
} = elements;
|
||||
|
||||
const setBusy = (on) => {
|
||||
loading = on;
|
||||
[refreshBtn, clearBtn, copyBtn, downloadBtn, enableToggle, debugToggle].forEach((el) => {
|
||||
if (el) el.disabled = !!on;
|
||||
});
|
||||
};
|
||||
|
||||
async function syncState() {
|
||||
const data = await getDiagLog();
|
||||
const state = data.state || {};
|
||||
uiEnabled = !!state.enabled;
|
||||
uiLevel = state.level || "normal";
|
||||
if (enableToggle) enableToggle.checked = uiEnabled;
|
||||
if (debugToggle) debugToggle.checked = uiLevel === "debug";
|
||||
if (logButton) logButton.classList.toggle("hidden", !uiEnabled);
|
||||
if (modal && !uiEnabled) modal.classList.add("hidden");
|
||||
return data.entries || [];
|
||||
}
|
||||
|
||||
function render(entries) {
|
||||
if (!logBox) return;
|
||||
const merged = [
|
||||
...(entries || []).map((e) => ({ ...e, source: "api" })),
|
||||
...uiBuffer,
|
||||
].sort((a, b) => (a.ts < b.ts ? 1 : -1));
|
||||
logBox.textContent = merged
|
||||
.map((e) => `${new Date(e.ts).toLocaleTimeString()} [${e.source || "api"} ${e.level}] ${e.msg}`)
|
||||
.join("\n");
|
||||
if (statusEl) statusEl.textContent = `${merged.length} entries`;
|
||||
}
|
||||
|
||||
async function refresh({ silent = false } = {}) {
|
||||
if (loading) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const entries = await syncState();
|
||||
render(entries);
|
||||
if (!silent) toast?.("Diagnostics refreshed", "success");
|
||||
} catch (e) {
|
||||
if (!silent) toast?.(e.error || "Failed to load diagnostics", "error");
|
||||
// retry once if failed
|
||||
try {
|
||||
const entries = await syncState();
|
||||
render(entries);
|
||||
if (!silent) toast?.("Diagnostics refreshed (after retry)", "success");
|
||||
} catch (err2) {
|
||||
if (!silent) toast?.(err2.error || "Diagnostics still failing", "error");
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
enableToggle?.addEventListener("change", async () => {
|
||||
try {
|
||||
setBusy(true);
|
||||
uiEnabled = enableToggle.checked;
|
||||
await setDiagLevel({ enabled: uiEnabled, level: uiLevel });
|
||||
appendUi("info", `Diagnostics ${uiEnabled ? "enabled" : "disabled"}`);
|
||||
if (uiEnabled) attachClickTracker();
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toast?.(e.error || "Failed to save diagnostics setting", "error");
|
||||
enableToggle.checked = !enableToggle.checked;
|
||||
setBusy(false);
|
||||
return;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
if (logButton) logButton.classList.toggle("hidden", !uiEnabled);
|
||||
if (!uiEnabled && modal) modal.classList.add("hidden");
|
||||
});
|
||||
|
||||
debugToggle?.addEventListener("change", async () => {
|
||||
try {
|
||||
setBusy(true);
|
||||
uiLevel = debugToggle.checked ? "debug" : "normal";
|
||||
await setDiagLevel({ enabled: uiEnabled, level: uiLevel });
|
||||
appendUi("info", `Diagnostics level set to ${uiLevel}`);
|
||||
if (uiEnabled) attachClickTracker();
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toast?.(e.error || "Failed to save level", "error");
|
||||
debugToggle.checked = uiLevel === "debug";
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
});
|
||||
|
||||
refreshBtn?.addEventListener("click", refresh);
|
||||
|
||||
clearBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
setBusy(true);
|
||||
await clearDiagLog();
|
||||
uiBuffer.length = 0;
|
||||
appendUi("info", "Cleared diagnostics");
|
||||
// Immediately reflect empty log in UI, then refresh from server
|
||||
if (logBox) logBox.textContent = "";
|
||||
render([]);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toast?.(e.error || "Failed to clear log", "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
});
|
||||
|
||||
copyBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
const text = logBox?.textContent || "";
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text || "No log entries.");
|
||||
} else {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text || "No log entries.";
|
||||
ta.style.position = "fixed";
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
toast?.("Diagnostics copied", "success");
|
||||
} catch (e) {
|
||||
toast?.("Copy failed", "error");
|
||||
}
|
||||
});
|
||||
|
||||
downloadBtn?.addEventListener("click", () => {
|
||||
try {
|
||||
const blob = new Blob([logBox?.textContent || "No log entries."], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "pikit-diagnostics.txt";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
toast?.("Download failed", "error");
|
||||
}
|
||||
});
|
||||
|
||||
// initial load
|
||||
attachClickTracker();
|
||||
await refresh({ silent: true });
|
||||
|
||||
logButton?.addEventListener("click", () => {
|
||||
if (!uiEnabled) return;
|
||||
modal?.classList.remove("hidden");
|
||||
});
|
||||
modalClose?.addEventListener("click", () => modal?.classList.add("hidden"));
|
||||
modal?.addEventListener("click", (e) => {
|
||||
if (e.target === modal) e.stopPropagation(); // prevent accidental close
|
||||
});
|
||||
|
||||
return {
|
||||
logUi,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
142
pikit-web/assets/firstboot-ui.js
Normal file
142
pikit-web/assets/firstboot-ui.js
Normal file
@@ -0,0 +1,142 @@
|
||||
const STATUS_CLASS = {
|
||||
pending: "pending",
|
||||
current: "current",
|
||||
running: "current",
|
||||
done: "done",
|
||||
error: "error",
|
||||
};
|
||||
|
||||
function normalizeStatus(status) {
|
||||
const key = (status || "pending").toString().toLowerCase();
|
||||
return STATUS_CLASS[key] || "pending";
|
||||
}
|
||||
|
||||
function currentStepLabel(steps = [], fallback = "") {
|
||||
const current = steps.find((step) => {
|
||||
const status = typeof step === "string" ? "pending" : step.status;
|
||||
return ["current", "running", "error"].includes(status);
|
||||
});
|
||||
if (current) {
|
||||
return typeof current === "string" ? current : current.label;
|
||||
}
|
||||
const first = steps.find((step) => (typeof step === "string" ? step : step.label));
|
||||
if (first) return typeof first === "string" ? first : first.label;
|
||||
return fallback || "";
|
||||
}
|
||||
|
||||
function renderSteps(stepsEl, steps = []) {
|
||||
if (!stepsEl) return;
|
||||
stepsEl.innerHTML = "";
|
||||
steps.forEach((step) => {
|
||||
const li = document.createElement("li");
|
||||
const status = normalizeStatus(step.status);
|
||||
li.className = `firstboot-step ${status}`;
|
||||
const dot = document.createElement("span");
|
||||
dot.className = "step-dot";
|
||||
dot.setAttribute("aria-hidden", "true");
|
||||
const label = document.createElement("span");
|
||||
label.className = "step-label";
|
||||
label.textContent = step.label || "";
|
||||
li.appendChild(dot);
|
||||
li.appendChild(label);
|
||||
stepsEl.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function setLogText(logEl, text) {
|
||||
if (!logEl) return;
|
||||
const value = text && text.trim().length ? text : "Waiting for setup logs...";
|
||||
logEl.textContent = value;
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
|
||||
function wireCopyButton(btn, getText, showToast) {
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", async () => {
|
||||
const text = getText();
|
||||
if (!text) return;
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
showToast?.("Copied error log", "success");
|
||||
} catch (err) {
|
||||
showToast?.("Copy failed", "error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function createFirstbootUI({
|
||||
overlay,
|
||||
stepsEl,
|
||||
currentStepEl,
|
||||
logEl,
|
||||
logNoteEl,
|
||||
errorModal,
|
||||
errorLogEl,
|
||||
errorCloseBtn,
|
||||
errorCopyBtn,
|
||||
errorShowRecoveryBtn,
|
||||
recoveryEl,
|
||||
showToast,
|
||||
}) {
|
||||
let lastErrorText = "";
|
||||
|
||||
if (errorModal) {
|
||||
errorModal.addEventListener("click", (e) => {
|
||||
if (e.target === errorModal) errorModal.classList.add("hidden");
|
||||
});
|
||||
}
|
||||
errorCloseBtn?.addEventListener("click", () => errorModal?.classList.add("hidden"));
|
||||
errorShowRecoveryBtn?.addEventListener("click", () => recoveryEl?.classList.toggle("hidden"));
|
||||
wireCopyButton(errorCopyBtn, () => lastErrorText, showToast);
|
||||
|
||||
function update(data) {
|
||||
if (!data) return;
|
||||
const steps = Array.isArray(data.steps) ? data.steps : [];
|
||||
const current = data.current_step || currentStepLabel(steps);
|
||||
renderSteps(
|
||||
stepsEl,
|
||||
steps.map((step) => {
|
||||
const label = typeof step === "string" ? step : step.label || "";
|
||||
const status = typeof step === "string" ? "pending" : step.status;
|
||||
return { label, status: normalizeStatus(status) };
|
||||
})
|
||||
);
|
||||
|
||||
if (currentStepEl) {
|
||||
currentStepEl.textContent = current ? `Current step: ${current}` : "Current step: preparing";
|
||||
}
|
||||
setLogText(logEl, data.log_tail || "");
|
||||
if (logNoteEl) logNoteEl.textContent = "If this stalls for more than 10 minutes, refresh the page or check SSH.";
|
||||
|
||||
}
|
||||
|
||||
function showOverlay(show) {
|
||||
if (!overlay) return;
|
||||
overlay.classList.toggle("hidden", !show);
|
||||
}
|
||||
|
||||
function showError(text) {
|
||||
lastErrorText = text || "";
|
||||
if (errorLogEl) {
|
||||
errorLogEl.textContent = lastErrorText || "(no error log found)";
|
||||
}
|
||||
if (errorModal) errorModal.classList.remove("hidden");
|
||||
}
|
||||
|
||||
return {
|
||||
update,
|
||||
showOverlay,
|
||||
showError,
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,22 @@
|
||||
// Entry point for the dashboard: wires UI events, pulls status, and initializes
|
||||
// feature modules (services, settings, stats).
|
||||
import { getStatus, triggerReset } from "./api.js";
|
||||
import { getStatus, getFirstbootError, getFirstbootStatus, triggerReset } from "./api.js";
|
||||
import { createFirstbootUI } from "./firstboot-ui.js";
|
||||
import { initServiceControls } from "./services.js";
|
||||
import { placeholderStatus, renderStats } from "./status.js";
|
||||
import { initServiceControls, renderServices } from "./services.js";
|
||||
import { initSettings } from "./settings.js";
|
||||
import { initUpdateSettings, isUpdatesDirty } from "./update-settings.js";
|
||||
import { initReleaseUI } from "./releases.js?v=20251213f";
|
||||
import { initReleaseUI } from "./releases.js?v=20251213h";
|
||||
import { initDiagUI, logUi } from "./diaglog.js?v=20251213j";
|
||||
import { createToastManager } from "./toast.js?v=20251213a";
|
||||
import {
|
||||
applyTooltips,
|
||||
wireModalPairs,
|
||||
wireAccordions,
|
||||
createBusyOverlay,
|
||||
createConfirmModal,
|
||||
} from "./ui.js";
|
||||
import { createStatusController } from "./status-controller.js";
|
||||
|
||||
const servicesGrid = document.getElementById("servicesGrid");
|
||||
const heroStats = document.getElementById("heroStats");
|
||||
@@ -31,6 +42,7 @@ const toastPosSelect = document.getElementById("toastPosSelect");
|
||||
const toastAnimSelect = document.getElementById("toastAnimSelect");
|
||||
const toastSpeedInput = document.getElementById("toastSpeedInput");
|
||||
const toastDurationInput = document.getElementById("toastDurationInput");
|
||||
const toastTestBtn = document.getElementById("toastTestBtn");
|
||||
const fontSelect = document.getElementById("fontSelect");
|
||||
const updatesScope = document.getElementById("updatesScope");
|
||||
const updateTimeInput = document.getElementById("updateTimeInput");
|
||||
@@ -88,6 +100,16 @@ const busyTitle = document.getElementById("busyTitle");
|
||||
const busyText = document.getElementById("busyText");
|
||||
const toastContainer = document.getElementById("toastContainer");
|
||||
const readyOverlay = document.getElementById("readyOverlay");
|
||||
const firstbootSteps = document.getElementById("firstbootSteps");
|
||||
const firstbootCurrentStep = document.getElementById("firstbootCurrentStep");
|
||||
const firstbootLog = document.getElementById("firstbootLog");
|
||||
const firstbootLogNote = document.getElementById("firstbootLogNote");
|
||||
const firstbootErrorModal = document.getElementById("firstbootErrorModal");
|
||||
const firstbootErrorLog = document.getElementById("firstbootErrorLog");
|
||||
const firstbootErrorClose = document.getElementById("firstbootErrorClose");
|
||||
const firstbootCopyError = document.getElementById("firstbootCopyError");
|
||||
const firstbootShowRecovery = document.getElementById("firstbootShowRecovery");
|
||||
const firstbootRecovery = document.getElementById("firstbootRecovery");
|
||||
const confirmModal = document.getElementById("confirmModal");
|
||||
const confirmTitle = document.getElementById("confirmTitle");
|
||||
const confirmBody = document.getElementById("confirmBody");
|
||||
@@ -97,194 +119,174 @@ const changelogModal = document.getElementById("changelogModal");
|
||||
const changelogTitle = document.getElementById("changelogTitle");
|
||||
const changelogBody = document.getElementById("changelogBody");
|
||||
const changelogClose = document.getElementById("changelogClose");
|
||||
const diagEnableToggle = document.getElementById("diagEnableToggle");
|
||||
const diagDebugToggle = document.getElementById("diagDebugToggle");
|
||||
const diagRefreshBtn = document.getElementById("diagRefreshBtn");
|
||||
const diagClearBtn = document.getElementById("diagClearBtn");
|
||||
const diagCopyBtn = document.getElementById("diagCopyBtn");
|
||||
const diagDownloadBtn = document.getElementById("diagDownloadBtn");
|
||||
const diagLogBox = document.getElementById("diagLogBox");
|
||||
const diagStatus = document.getElementById("diagStatus");
|
||||
const diagLogBtn = document.getElementById("diagLogBtn");
|
||||
const diagModal = document.getElementById("diagModal");
|
||||
const diagClose = document.getElementById("diagClose");
|
||||
const diagStatusModal = document.getElementById("diagStatusModal");
|
||||
|
||||
const TOAST_POS_KEY = "pikit-toast-pos";
|
||||
const TOAST_ANIM_KEY = "pikit-toast-anim";
|
||||
const TOAST_SPEED_KEY = "pikit-toast-speed";
|
||||
const TOAST_DURATION_KEY = "pikit-toast-duration";
|
||||
const FONT_KEY = "pikit-font";
|
||||
const ALLOWED_TOAST_POS = [
|
||||
"bottom-center",
|
||||
"bottom-right",
|
||||
"bottom-left",
|
||||
"top-right",
|
||||
"top-left",
|
||||
"top-center",
|
||||
];
|
||||
const ALLOWED_TOAST_ANIM = ["slide-in", "fade", "pop", "bounce", "drop", "grow"];
|
||||
const ALLOWED_FONTS = ["redhat", "space", "manrope", "dmsans", "sora", "chivo", "atkinson", "plex"];
|
||||
|
||||
let toastPosition = "bottom-center";
|
||||
let toastAnimation = "slide-in";
|
||||
let toastDurationMs = 5000;
|
||||
let toastSpeedMs = 300;
|
||||
let fontChoice = "redhat";
|
||||
const toastController = createToastManager({
|
||||
container: toastContainer,
|
||||
posSelect: toastPosSelect,
|
||||
animSelect: toastAnimSelect,
|
||||
speedInput: toastSpeedInput,
|
||||
durationInput: toastDurationInput,
|
||||
fontSelect,
|
||||
testBtn: toastTestBtn,
|
||||
});
|
||||
const showToast = toastController.showToast;
|
||||
let releaseUI = null;
|
||||
const { showBusy, hideBusy } = createBusyOverlay({
|
||||
overlay: busyOverlay,
|
||||
titleEl: busyTitle,
|
||||
textEl: busyText,
|
||||
});
|
||||
const confirmAction = createConfirmModal({
|
||||
modal: confirmModal,
|
||||
titleEl: confirmTitle,
|
||||
bodyEl: confirmBody,
|
||||
okBtn: confirmOk,
|
||||
cancelBtn: confirmCancel,
|
||||
});
|
||||
const collapseAccordions = () => document.querySelectorAll(".accordion").forEach((a) => a.classList.remove("open"));
|
||||
|
||||
function applyToastSettings() {
|
||||
if (!toastContainer) return;
|
||||
toastContainer.className = `toast-container pos-${toastPosition}`;
|
||||
document.documentElement.style.setProperty("--toast-speed", `${toastSpeedMs}ms`);
|
||||
const dir = toastPosition.startsWith("top") ? -1 : 1;
|
||||
const isLeft = toastPosition.includes("left");
|
||||
const isRight = toastPosition.includes("right");
|
||||
const slideX = isLeft ? -26 : isRight ? 26 : 0;
|
||||
const slideY = isLeft || isRight ? 0 : dir * 24;
|
||||
document.documentElement.style.setProperty("--toast-slide-offset", `${dir * 24}px`);
|
||||
document.documentElement.style.setProperty("--toast-dir", `${dir}`);
|
||||
document.documentElement.style.setProperty("--toast-slide-x", `${slideX}px`);
|
||||
document.documentElement.style.setProperty("--toast-slide-y", `${slideY}px`);
|
||||
if (toastDurationInput) toastDurationInput.value = toastDurationMs;
|
||||
}
|
||||
const firstbootUI = createFirstbootUI({
|
||||
overlay: readyOverlay,
|
||||
stepsEl: firstbootSteps,
|
||||
currentStepEl: firstbootCurrentStep,
|
||||
logEl: firstbootLog,
|
||||
logNoteEl: firstbootLogNote,
|
||||
errorModal: firstbootErrorModal,
|
||||
errorLogEl: firstbootErrorLog,
|
||||
errorCloseBtn: firstbootErrorClose,
|
||||
errorCopyBtn: firstbootCopyError,
|
||||
errorShowRecoveryBtn: firstbootShowRecovery,
|
||||
recoveryEl: firstbootRecovery,
|
||||
showToast,
|
||||
});
|
||||
|
||||
function applyFontSetting() {
|
||||
document.documentElement.setAttribute("data-font", fontChoice);
|
||||
if (fontSelect) fontSelect.value = fontChoice;
|
||||
}
|
||||
const statusController = createStatusController({
|
||||
heroStats,
|
||||
servicesGrid,
|
||||
updatesFlagTop,
|
||||
updatesNoteTop,
|
||||
tempFlagTop,
|
||||
readyOverlay,
|
||||
logUi,
|
||||
getStatus,
|
||||
isUpdatesDirty,
|
||||
setUpdatesUI,
|
||||
updatesFlagEl: setUpdatesFlag,
|
||||
releaseUIGetter: () => releaseUI,
|
||||
onReadyWait: () => setTimeout(() => statusController.loadStatus(), 3000),
|
||||
firstboot: {
|
||||
getStatus: getFirstbootStatus,
|
||||
getError: getFirstbootError,
|
||||
ui: firstbootUI,
|
||||
},
|
||||
});
|
||||
const { loadStatus } = statusController;
|
||||
|
||||
function loadToastSettings() {
|
||||
try {
|
||||
const posSaved = localStorage.getItem(TOAST_POS_KEY);
|
||||
if (ALLOWED_TOAST_POS.includes(posSaved)) toastPosition = posSaved;
|
||||
const animSaved = localStorage.getItem(TOAST_ANIM_KEY);
|
||||
const migrated =
|
||||
animSaved === "slide-up" || animSaved === "slide-left" || animSaved === "slide-right"
|
||||
? "slide-in"
|
||||
: animSaved;
|
||||
if (migrated && ALLOWED_TOAST_ANIM.includes(migrated)) toastAnimation = migrated;
|
||||
const savedSpeed = Number(localStorage.getItem(TOAST_SPEED_KEY));
|
||||
if (!Number.isNaN(savedSpeed) && savedSpeed >= 100 && savedSpeed <= 3000) {
|
||||
toastSpeedMs = savedSpeed;
|
||||
function wireDialogs() {
|
||||
wireModalPairs([
|
||||
{ openBtn: aboutBtn, modal: aboutModal, closeBtn: aboutClose },
|
||||
{ openBtn: helpBtn, modal: helpModal, closeBtn: helpClose },
|
||||
]);
|
||||
// Settings modal keeps custom accordion collapse on close
|
||||
advBtn?.addEventListener("click", () => {
|
||||
if (window.__pikitTest?.forceServiceFormVisible) {
|
||||
// For tests: avoid opening any modal; just ensure form controls are visible
|
||||
addServiceModal?.classList.add("hidden");
|
||||
addServiceModal?.setAttribute("style", "display:none;");
|
||||
window.__pikitTest.forceServiceFormVisible();
|
||||
return;
|
||||
}
|
||||
const savedDur = Number(localStorage.getItem(TOAST_DURATION_KEY));
|
||||
if (!Number.isNaN(savedDur) && savedDur >= 1000 && savedDur <= 15000) {
|
||||
toastDurationMs = savedDur;
|
||||
}
|
||||
const savedFont = localStorage.getItem(FONT_KEY);
|
||||
if (ALLOWED_FONTS.includes(savedFont)) fontChoice = savedFont;
|
||||
} catch (e) {
|
||||
console.warn("Toast settings load failed", e);
|
||||
}
|
||||
if (toastPosSelect) toastPosSelect.value = toastPosition;
|
||||
if (toastAnimSelect) toastAnimSelect.value = toastAnimation;
|
||||
if (toastSpeedInput) toastSpeedInput.value = toastSpeedMs;
|
||||
if (toastDurationInput) toastDurationInput.value = toastDurationMs;
|
||||
if (fontSelect) fontSelect.value = fontChoice;
|
||||
applyToastSettings();
|
||||
applyFontSetting();
|
||||
}
|
||||
|
||||
function persistToastSettings() {
|
||||
try {
|
||||
localStorage.setItem(TOAST_POS_KEY, toastPosition);
|
||||
localStorage.setItem(TOAST_ANIM_KEY, toastAnimation);
|
||||
localStorage.setItem(TOAST_SPEED_KEY, String(toastSpeedMs));
|
||||
localStorage.setItem(TOAST_DURATION_KEY, String(toastDurationMs));
|
||||
localStorage.setItem(FONT_KEY, fontChoice);
|
||||
} catch (e) {
|
||||
console.warn("Toast settings save failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message, type = "info") {
|
||||
if (!toastContainer || !message) return;
|
||||
const t = document.createElement("div");
|
||||
t.className = `toast ${type} anim-${toastAnimation}`;
|
||||
t.textContent = message;
|
||||
toastContainer.appendChild(t);
|
||||
const animOn = document.documentElement.getAttribute("data-anim") !== "off";
|
||||
if (!animOn) {
|
||||
t.classList.add("show");
|
||||
} else {
|
||||
requestAnimationFrame(() => t.classList.add("show"));
|
||||
}
|
||||
const duration = toastDurationMs;
|
||||
setTimeout(() => {
|
||||
const all = Array.from(toastContainer.querySelectorAll(".toast"));
|
||||
const others = all.filter((el) => el !== t && !el.classList.contains("leaving"));
|
||||
const first = new Map(
|
||||
others.map((el) => [el, el.getBoundingClientRect()]),
|
||||
);
|
||||
|
||||
t.classList.add("leaving");
|
||||
// force layout
|
||||
void t.offsetHeight;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const second = new Map(
|
||||
others.map((el) => [el, el.getBoundingClientRect()]),
|
||||
);
|
||||
others.forEach((el) => {
|
||||
const dy = first.get(el).top - second.get(el).top;
|
||||
if (Math.abs(dy) > 0.5) {
|
||||
el.style.transition = "transform var(--toast-speed, 0.28s) ease";
|
||||
el.style.transform = `translateY(${dy}px)`;
|
||||
requestAnimationFrame(() => {
|
||||
el.style.transform = "";
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const removeDelay = animOn ? toastSpeedMs : 0;
|
||||
setTimeout(() => {
|
||||
t.classList.remove("show");
|
||||
t.remove();
|
||||
// clear transition styling
|
||||
others.forEach((el) => (el.style.transition = ""));
|
||||
}, removeDelay);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
function applyTooltips() {
|
||||
const tips = {
|
||||
updatesFlagTop: "Auto updates status; configure in Settings → Automatic updates.",
|
||||
refreshFlagTop: "Auto-refresh interval; change in Settings → Refresh interval.",
|
||||
tempFlagTop: "CPU temperature status; see details in the hero stats below.",
|
||||
releaseFlagTop: "Pi-Kit release status; open Settings → Updates to install.",
|
||||
themeToggle: "Toggle light or dark theme",
|
||||
helpBtn: "Open quick help",
|
||||
advBtn: "Open settings",
|
||||
animToggle: "Enable or disable dashboard animations",
|
||||
refreshIntervalInput: "Seconds between automatic refreshes (5-120)",
|
||||
refreshIntervalSave: "Save refresh interval",
|
||||
svcName: "Display name for the service card",
|
||||
svcPort: "Port number the service listens on",
|
||||
svcPath: "Optional path like /admin",
|
||||
svcScheme: "Choose HTTP or HTTPS link",
|
||||
svcSelfSigned: "Mark service as using a self-signed certificate",
|
||||
svcNotice: "Optional note shown on the service card",
|
||||
svcNoticeLink: "Optional link for more info about the service",
|
||||
svcAddBtn: "Add the service to the dashboard",
|
||||
updatesToggle: "Turn unattended upgrades on or off",
|
||||
updatesScope: "Select security-only or all updates",
|
||||
updateTimeInput: "Time to download updates (24h)",
|
||||
upgradeTimeInput: "Time to install updates (24h)",
|
||||
updatesCleanup: "Remove unused dependencies after upgrades",
|
||||
updatesBandwidth: "Limit download bandwidth in KB/s (0 = unlimited)",
|
||||
updatesRebootToggle: "Auto-reboot if required by updates",
|
||||
updatesRebootTime: "Scheduled reboot time when auto-reboot is on",
|
||||
updatesRebootUsers: "Allow reboot even if users are logged in",
|
||||
updatesSaveBtn: "Save unattended-upgrades settings",
|
||||
resetConfirm: "Type YES to enable factory reset",
|
||||
resetBtn: "Factory reset this Pi-Kit",
|
||||
menuRename: "Change the service display name",
|
||||
menuPort: "Change the service port",
|
||||
menuPath: "Optional service path",
|
||||
menuScheme: "Switch between HTTP and HTTPS",
|
||||
menuSelfSigned: "Mark the service as self-signed",
|
||||
menuNotice: "Edit the notice text shown on the card",
|
||||
menuNoticeLink: "Optional link for the notice",
|
||||
menuSaveBtn: "Save service changes",
|
||||
menuCancelBtn: "Cancel changes",
|
||||
menuRemoveBtn: "Remove this service",
|
||||
};
|
||||
Object.entries(tips).forEach(([id, text]) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.title = text;
|
||||
advModal.classList.remove("hidden");
|
||||
});
|
||||
advClose?.addEventListener("click", () => {
|
||||
advModal.classList.add("hidden");
|
||||
collapseAccordions();
|
||||
});
|
||||
menuClose.onclick = () => menuModal.classList.add("hidden");
|
||||
addServiceOpen?.addEventListener("click", openAddService);
|
||||
addSvcClose?.addEventListener("click", () => addServiceModal?.classList.add("hidden"));
|
||||
addServiceModal?.addEventListener("click", (e) => {
|
||||
if (e.target === addServiceModal) addServiceModal.classList.add("hidden");
|
||||
});
|
||||
}
|
||||
|
||||
// Testing hook
|
||||
if (typeof window !== "undefined") {
|
||||
window.__pikitTest = window.__pikitTest || {};
|
||||
window.__pikitTest.showBusy = showBusy;
|
||||
window.__pikitTest.hideBusy = hideBusy;
|
||||
window.__pikitTest.exposeServiceForm = () => {
|
||||
if (!addServiceModal) return;
|
||||
const card = addServiceModal.querySelector(".modal-card");
|
||||
if (!card) return;
|
||||
addServiceModal.classList.add("hidden"); // keep overlay out of the way
|
||||
card.style.position = "static";
|
||||
card.style.background = "transparent";
|
||||
card.style.boxShadow = "none";
|
||||
card.style.border = "none";
|
||||
card.style.padding = "0";
|
||||
card.style.margin = "12px auto";
|
||||
card.style.maxWidth = "720px";
|
||||
// Move the form inline so Playwright can see it without the overlay
|
||||
document.body.appendChild(card);
|
||||
};
|
||||
}
|
||||
|
||||
const TOOLTIP_MAP = {
|
||||
updatesFlagTop: "Auto updates status; configure in Settings → Automatic updates.",
|
||||
refreshFlagTop: "Auto-refresh interval; change in Settings → Refresh interval.",
|
||||
tempFlagTop: "CPU temperature status; see details in the hero stats below.",
|
||||
releaseFlagTop: "Pi-Kit release status; open Settings → Updates to install.",
|
||||
themeToggle: "Toggle light or dark theme",
|
||||
helpBtn: "Open quick help",
|
||||
advBtn: "Open settings",
|
||||
animToggle: "Enable or disable dashboard animations",
|
||||
refreshIntervalInput: "Seconds between automatic refreshes (5-120)",
|
||||
refreshIntervalSave: "Save refresh interval",
|
||||
svcName: "Display name for the service card",
|
||||
svcPort: "Port number the service listens on",
|
||||
svcPath: "Optional path like /admin",
|
||||
svcScheme: "Choose HTTP or HTTPS link",
|
||||
svcSelfSigned: "Mark service as using a self-signed certificate",
|
||||
svcNotice: "Optional note shown on the service card",
|
||||
svcNoticeLink: "Optional link for more info about the service",
|
||||
svcAddBtn: "Add the service to the dashboard",
|
||||
updatesToggle: "Turn unattended upgrades on or off",
|
||||
updatesScope: "Select security-only or all updates",
|
||||
updateTimeInput: "Time to download updates (24h)",
|
||||
upgradeTimeInput: "Time to install updates (24h)",
|
||||
updatesCleanup: "Remove unused dependencies after upgrades",
|
||||
updatesBandwidth: "Limit download bandwidth in KB/s (0 = unlimited)",
|
||||
updatesRebootToggle: "Auto-reboot if required by updates",
|
||||
updatesRebootTime: "Scheduled reboot time when auto-reboot is on",
|
||||
updatesRebootUsers: "Allow reboot even if users are logged in",
|
||||
updatesSaveBtn: "Save unattended-upgrades settings",
|
||||
resetConfirm: "Type YES to enable factory reset",
|
||||
resetBtn: "Factory reset this Pi-Kit",
|
||||
menuRename: "Change the service display name",
|
||||
menuPort: "Change the service port",
|
||||
menuPath: "Optional service path",
|
||||
menuScheme: "Switch between HTTP and HTTPS",
|
||||
menuSelfSigned: "Mark the service as self-signed",
|
||||
menuNotice: "Edit the notice text shown on the card",
|
||||
menuNoticeLink: "Optional link for the notice",
|
||||
menuSaveBtn: "Save service changes",
|
||||
menuCancelBtn: "Cancel changes",
|
||||
menuRemoveBtn: "Remove this service",
|
||||
};
|
||||
|
||||
// Clamp name inputs to 30 chars
|
||||
[svcName, menuRename].forEach((el) => {
|
||||
if (!el) return;
|
||||
@@ -302,74 +304,7 @@ function setUpdatesUI(enabled) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
// Pull Pi-Kit release status after core status
|
||||
releaseUI?.refreshStatus();
|
||||
} 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) {
|
||||
function setUpdatesFlag(enabled) {
|
||||
if (!updatesFlagTop) return;
|
||||
const labelOn = "System updates: On";
|
||||
const labelOff = "System updates: Off";
|
||||
@@ -379,61 +314,6 @@ function updatesFlagEl(enabled) {
|
||||
if (enabled === false) updatesFlagTop.classList.add("chip-off");
|
||||
}
|
||||
|
||||
function wireModals() {
|
||||
advBtn.onclick = () => advModal.classList.remove("hidden");
|
||||
advClose.onclick = () => advModal.classList.add("hidden");
|
||||
helpBtn.onclick = () => helpModal.classList.remove("hidden");
|
||||
helpClose.onclick = () => helpModal.classList.add("hidden");
|
||||
aboutBtn.onclick = () => aboutModal.classList.remove("hidden");
|
||||
aboutClose.onclick = () => aboutModal.classList.add("hidden");
|
||||
menuClose.onclick = () => menuModal.classList.add("hidden");
|
||||
addServiceOpen?.addEventListener("click", openAddService);
|
||||
addSvcClose?.addEventListener("click", () => addServiceModal?.classList.add("hidden"));
|
||||
addServiceModal?.addEventListener("click", (e) => {
|
||||
if (e.target === addServiceModal) addServiceModal.classList.add("hidden");
|
||||
});
|
||||
}
|
||||
|
||||
function showBusy(title = "Working…", text = "This may take a few seconds.") {
|
||||
if (!busyOverlay) return;
|
||||
busyTitle.textContent = title;
|
||||
busyText.textContent = text || "";
|
||||
busyText.classList.toggle("hidden", !text);
|
||||
busyOverlay.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function hideBusy() {
|
||||
busyOverlay?.classList.add("hidden");
|
||||
}
|
||||
|
||||
function confirmAction(title, body) {
|
||||
return new Promise((resolve) => {
|
||||
if (!confirmModal) {
|
||||
const ok = window.confirm(body || title || "Are you sure?");
|
||||
resolve(ok);
|
||||
return;
|
||||
}
|
||||
confirmTitle.textContent = title || "Are you sure?";
|
||||
confirmBody.textContent = body || "";
|
||||
confirmModal.classList.remove("hidden");
|
||||
const done = (val) => {
|
||||
confirmModal.classList.add("hidden");
|
||||
resolve(val);
|
||||
};
|
||||
const okHandler = () => done(true);
|
||||
const cancelHandler = () => done(false);
|
||||
confirmOk.onclick = okHandler;
|
||||
confirmCancel.onclick = cancelHandler;
|
||||
});
|
||||
}
|
||||
|
||||
// Testing hook
|
||||
if (typeof window !== "undefined") {
|
||||
window.__pikitTest = window.__pikitTest || {};
|
||||
window.__pikitTest.showBusy = showBusy;
|
||||
window.__pikitTest.hideBusy = hideBusy;
|
||||
}
|
||||
|
||||
function wireResetAndUpdates() {
|
||||
resetBtn.onclick = async () => {
|
||||
resetBtn.disabled = true;
|
||||
@@ -452,31 +332,6 @@ function wireResetAndUpdates() {
|
||||
});
|
||||
}
|
||||
|
||||
function wireAccordions() {
|
||||
const forceOpen = typeof window !== "undefined" && window.__pikitTest?.forceAccordionsOpen;
|
||||
const accordions = document.querySelectorAll(".accordion");
|
||||
if (forceOpen) {
|
||||
accordions.forEach((a) => a.classList.add("open"));
|
||||
return;
|
||||
}
|
||||
document.querySelectorAll(".accordion-toggle").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const acc = btn.closest(".accordion");
|
||||
if (acc.classList.contains("open")) {
|
||||
acc.classList.remove("open");
|
||||
} else {
|
||||
// Keep a single accordion expanded at a time for readability
|
||||
accordions.forEach((a) => a.classList.remove("open"));
|
||||
acc.classList.add("open");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function collapseAccordions() {
|
||||
document.querySelectorAll(".accordion").forEach((a) => a.classList.remove("open"));
|
||||
}
|
||||
|
||||
function openAddService() {
|
||||
if (addServiceModal) addServiceModal.classList.remove("hidden");
|
||||
document.getElementById("svcName")?.focus();
|
||||
@@ -486,24 +341,24 @@ if (typeof window !== "undefined") {
|
||||
}
|
||||
|
||||
function main() {
|
||||
applyTooltips();
|
||||
wireModals();
|
||||
applyTooltips(TOOLTIP_MAP);
|
||||
// Test convenience: ensure service form elements are visible when hook is set
|
||||
if (window.__pikitTest?.forceServiceFormVisible) {
|
||||
window.__pikitTest.forceServiceFormVisible();
|
||||
window.__pikitTest.exposeServiceForm?.();
|
||||
}
|
||||
wireDialogs();
|
||||
wireResetAndUpdates();
|
||||
wireAccordions();
|
||||
wireAccordions({
|
||||
forceOpen: typeof window !== "undefined" && window.__pikitTest?.forceAccordionsOpen,
|
||||
});
|
||||
releaseUI = initReleaseUI({
|
||||
showToast,
|
||||
showBusy,
|
||||
hideBusy,
|
||||
confirmAction,
|
||||
logUi,
|
||||
});
|
||||
loadToastSettings();
|
||||
|
||||
if (advClose) {
|
||||
advClose.onclick = () => {
|
||||
advModal.classList.add("hidden");
|
||||
collapseAccordions();
|
||||
};
|
||||
}
|
||||
|
||||
initServiceControls({
|
||||
gridEl: servicesGrid,
|
||||
@@ -558,97 +413,25 @@ function main() {
|
||||
},
|
||||
});
|
||||
|
||||
// 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");
|
||||
}
|
||||
// Diagnostics
|
||||
initDiagUI({
|
||||
elements: {
|
||||
enableToggle: diagEnableToggle,
|
||||
debugToggle: diagDebugToggle,
|
||||
refreshBtn: diagRefreshBtn,
|
||||
clearBtn: diagClearBtn,
|
||||
copyBtn: diagCopyBtn,
|
||||
downloadBtn: diagDownloadBtn,
|
||||
logBox: diagLogBox,
|
||||
statusEl: diagStatusModal || diagStatus,
|
||||
logButton: diagLogBtn,
|
||||
modal: diagModal,
|
||||
modalClose: diagClose,
|
||||
},
|
||||
toast: showToast,
|
||||
}).catch((e) => {
|
||||
console.error("Diag init failed", e);
|
||||
});
|
||||
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: {
|
||||
|
||||
38
pikit-web/assets/releases-utils.js
Normal file
38
pikit-web/assets/releases-utils.js
Normal file
@@ -0,0 +1,38 @@
|
||||
export function shorten(text, max = 90) {
|
||||
if (!text || typeof text !== "string") return text;
|
||||
return text.length > max ? `${text.slice(0, max - 3)}...` : text;
|
||||
}
|
||||
|
||||
export function createReleaseLogger(logUi = () => {}) {
|
||||
let lines = [];
|
||||
let lastMessage = null;
|
||||
const state = { el: null };
|
||||
|
||||
function render() {
|
||||
if (state.el) {
|
||||
state.el.textContent = lines.join("\n");
|
||||
state.el.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function log(msg) {
|
||||
if (!msg) return;
|
||||
const plain = msg.trim();
|
||||
if (plain === lastMessage) return;
|
||||
lastMessage = plain;
|
||||
const ts = new Date().toLocaleTimeString();
|
||||
const line = `${ts} ${msg}`;
|
||||
lines.unshift(line);
|
||||
lines = lines.slice(0, 120);
|
||||
render();
|
||||
const lvl = msg.toLowerCase().startsWith("error") ? "error" : "info";
|
||||
logUi(`Update: ${msg}`, lvl);
|
||||
}
|
||||
|
||||
function attach(el) {
|
||||
state.el = el;
|
||||
render();
|
||||
}
|
||||
|
||||
return { log, attach, getLines: () => lines.slice() };
|
||||
}
|
||||
@@ -5,29 +5,34 @@ import {
|
||||
getReleaseStatus,
|
||||
checkRelease,
|
||||
applyRelease,
|
||||
rollbackRelease,
|
||||
applyReleaseVersion,
|
||||
listReleases,
|
||||
setReleaseAutoCheck,
|
||||
setReleaseChannel,
|
||||
} from "./api.js";
|
||||
import { shorten, createReleaseLogger } from "./releases-utils.js";
|
||||
|
||||
function shorten(text, max = 90) {
|
||||
if (!text || typeof text !== "string") return text;
|
||||
return text.length > max ? `${text.slice(0, max - 3)}...` : text;
|
||||
}
|
||||
|
||||
export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction }) {
|
||||
export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, logUi = () => {} }) {
|
||||
const releaseFlagTop = document.getElementById("releaseFlagTop");
|
||||
const releaseBtn = document.getElementById("releaseBtn");
|
||||
const releaseModal = document.getElementById("releaseModal");
|
||||
const releaseClose = document.getElementById("releaseClose");
|
||||
const releaseCurrent = document.getElementById("releaseCurrent");
|
||||
const releaseLatest = document.getElementById("releaseLatest");
|
||||
const releaseCurrentDate = document.getElementById("releaseCurrentDate");
|
||||
const releaseLatestDate = document.getElementById("releaseLatestDate");
|
||||
const releaseStatusMsg = document.getElementById("releaseStatusMsg");
|
||||
const releaseProgress = document.getElementById("releaseProgress");
|
||||
const releaseCheckBtn = document.getElementById("releaseCheckBtn");
|
||||
const releaseApplyBtn = document.getElementById("releaseApplyBtn");
|
||||
const releaseRollbackBtn = document.getElementById("releaseRollbackBtn");
|
||||
const releaseAdvancedToggle = document.getElementById("releaseAdvancedToggle");
|
||||
const releaseAdvanced = document.getElementById("releaseAdvanced");
|
||||
const releaseList = document.getElementById("releaseList");
|
||||
const releaseApplyVersionBtn = document.getElementById("releaseApplyVersionBtn");
|
||||
const releaseAutoCheck = document.getElementById("releaseAutoCheck");
|
||||
const releaseStatusChip = document.getElementById("releaseStatusChip");
|
||||
const releaseChannelChip = document.getElementById("releaseChannelChip");
|
||||
const releaseLastCheckChip = document.getElementById("releaseLastCheckChip");
|
||||
const releaseLog = document.getElementById("releaseLog");
|
||||
const releaseLogStatus = document.getElementById("releaseLogStatus");
|
||||
const releaseLogCopy = document.getElementById("releaseLogCopy");
|
||||
@@ -42,24 +47,94 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction })
|
||||
const changelogClose = document.getElementById("changelogClose");
|
||||
|
||||
let releaseBusyActive = false;
|
||||
let releaseLogLines = [];
|
||||
let releaseLastFetched = 0;
|
||||
let lastReleaseLogKey = "";
|
||||
let lastReleaseToastKey = null;
|
||||
let changelogCache = { version: null, text: "" };
|
||||
let lastChangelogUrl = null;
|
||||
let releaseChannel = "dev";
|
||||
let releaseOptions = [];
|
||||
const logger = createReleaseLogger(logUi);
|
||||
logger.attach(releaseLog);
|
||||
|
||||
function logRelease(msg) {
|
||||
if (!msg) return;
|
||||
const ts = new Date().toLocaleTimeString();
|
||||
const line = `${ts} ${msg}`;
|
||||
if (releaseLogLines[0] === line) return;
|
||||
releaseLogLines.unshift(line);
|
||||
releaseLogLines = releaseLogLines.slice(0, 120);
|
||||
if (releaseLog) {
|
||||
releaseLog.textContent = releaseLogLines.join("\n");
|
||||
releaseLog.scrollTop = 0; // keep most recent in view
|
||||
const fmtDate = (iso) => {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
return d.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
} catch (e) {
|
||||
return "—";
|
||||
}
|
||||
};
|
||||
|
||||
async function loadReleaseList() {
|
||||
if (!releaseList) return;
|
||||
try {
|
||||
const data = await listReleases();
|
||||
releaseOptions = data.releases || [];
|
||||
renderReleaseList();
|
||||
} catch (e) {
|
||||
renderReleaseList(true);
|
||||
}
|
||||
}
|
||||
|
||||
function renderReleaseList(error = false) {
|
||||
if (!releaseList) return;
|
||||
releaseList.innerHTML = "";
|
||||
if (error) {
|
||||
releaseList.textContent = "Failed to load releases.";
|
||||
return;
|
||||
}
|
||||
if (!releaseOptions.length) {
|
||||
releaseList.textContent = "No releases found.";
|
||||
return;
|
||||
}
|
||||
releaseOptions.forEach((r, idx) => {
|
||||
const card = document.createElement("label");
|
||||
card.className = "release-card";
|
||||
card.setAttribute("role", "option");
|
||||
const input = document.createElement("input");
|
||||
input.type = "radio";
|
||||
input.name = "releaseVersion";
|
||||
input.value = r.version;
|
||||
if (idx === 0) input.checked = true;
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "release-card-meta";
|
||||
const title = document.createElement("div");
|
||||
title.className = "release-card-title";
|
||||
title.textContent = r.version;
|
||||
const tags = document.createElement("div");
|
||||
tags.className = "release-card-tags";
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "status-chip ghost";
|
||||
chip.textContent = r.prerelease ? "Dev" : "Stable";
|
||||
tags.appendChild(chip);
|
||||
if (r.published_at) {
|
||||
const date = document.createElement("span");
|
||||
date.className = "hint quiet";
|
||||
date.textContent = fmtDate(r.published_at);
|
||||
tags.appendChild(date);
|
||||
}
|
||||
meta.appendChild(title);
|
||||
meta.appendChild(tags);
|
||||
if (r.changelog_url) {
|
||||
const link = document.createElement("a");
|
||||
link.href = r.changelog_url;
|
||||
link.target = "_blank";
|
||||
link.className = "hint";
|
||||
link.textContent = "Changelog";
|
||||
meta.appendChild(link);
|
||||
}
|
||||
card.appendChild(input);
|
||||
card.appendChild(meta);
|
||||
releaseList.appendChild(card);
|
||||
});
|
||||
if (releaseApplyVersionBtn) releaseApplyVersionBtn.disabled = false;
|
||||
}
|
||||
|
||||
function setReleaseChip(state) {
|
||||
@@ -84,12 +159,10 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction })
|
||||
const msg = shorten(message, 80) || "";
|
||||
releaseFlagTop.title = msg || "Pi-Kit release status";
|
||||
if (releaseStatusMsg) {
|
||||
releaseStatusMsg.textContent =
|
||||
status === "update_available"
|
||||
? msg || "Update available"
|
||||
: status === "up_to_date"
|
||||
? msg || "Up to date"
|
||||
: msg || status;
|
||||
const isUrlMsg = msg && /^https?:/i.test(msg);
|
||||
const safeMsg = isUrlMsg ? "Update available" : msg;
|
||||
releaseStatusMsg.textContent = status === "update_available" ? safeMsg || "Update available" : "";
|
||||
releaseStatusMsg.classList.remove("error");
|
||||
}
|
||||
if (releaseLogStatus) {
|
||||
releaseLogStatus.textContent =
|
||||
@@ -133,6 +206,8 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction })
|
||||
}
|
||||
}
|
||||
|
||||
const logRelease = logger.log;
|
||||
|
||||
async function loadReleaseStatus(force = false) {
|
||||
if (!releaseFlagTop) return;
|
||||
const now = Date.now();
|
||||
@@ -150,37 +225,55 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction })
|
||||
auto_check = false,
|
||||
progress = null,
|
||||
channel = "dev",
|
||||
current_release_date = null,
|
||||
latest_release_date = null,
|
||||
changelog_url = null,
|
||||
last_check = null,
|
||||
} = data || {};
|
||||
releaseChannel = channel || "dev";
|
||||
if (releaseChannelToggle) releaseChannelToggle.checked = releaseChannel === "dev";
|
||||
window.__lastReleaseState = data;
|
||||
const key = [status, progress, message].join("|");
|
||||
if (key !== lastReleaseLogKey) {
|
||||
logRelease(`Status: ${status}${progress ? " • " + progress : ""}${message ? " • " + message : ""}`);
|
||||
lastReleaseLogKey = key;
|
||||
}
|
||||
releaseLastFetched = now;
|
||||
if (status === "update_available" && message && message.startsWith("http")) {
|
||||
const key = [status, progress, message].join("|");
|
||||
if (key !== lastReleaseLogKey) {
|
||||
logRelease(`Status: ${status}${progress ? " • " + progress : ""}${message ? " • " + message : ""}`);
|
||||
lastReleaseLogKey = key;
|
||||
}
|
||||
releaseLastFetched = now;
|
||||
lastChangelogUrl = changelog_url || null;
|
||||
if (!lastChangelogUrl && status === "update_available" && message && message.startsWith("http")) {
|
||||
lastChangelogUrl = message;
|
||||
} else if (latest_version) {
|
||||
} else if (!lastChangelogUrl && latest_version) {
|
||||
lastChangelogUrl = `https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v${latest_version}/CHANGELOG-${latest_version}.txt`;
|
||||
}
|
||||
setReleaseChip(data);
|
||||
if (releaseCurrent) releaseCurrent.textContent = current_version;
|
||||
if (releaseLatest) releaseLatest.textContent = latest_version;
|
||||
if (releaseCurrentDate) releaseCurrentDate.textContent = fmtDate(current_release_date);
|
||||
if (releaseLatestDate) releaseLatestDate.textContent = fmtDate(latest_release_date);
|
||||
if (releaseStatusChip) {
|
||||
releaseStatusChip.textContent = `Status: ${status.replaceAll("_", " ")}`;
|
||||
releaseStatusChip.classList.toggle("chip-warm", status === "update_available");
|
||||
releaseStatusChip.classList.toggle("chip-off", status === "error");
|
||||
}
|
||||
if (releaseChannelChip) releaseChannelChip.textContent = `Channel: ${releaseChannel}`;
|
||||
if (releaseLastCheckChip) releaseLastCheckChip.textContent = `Last check: ${last_check ? fmtDate(last_check) : "—"}`;
|
||||
if (releaseAutoCheck) releaseAutoCheck.checked = !!auto_check;
|
||||
if (releaseProgress) releaseProgress.textContent = "";
|
||||
if (status === "in_progress" && progress) {
|
||||
showBusy("Working on update…", progress || "This can take up to a minute.");
|
||||
pollReleaseStatus();
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (e) {
|
||||
// During an update/rollback the API may restart; retry quietly.
|
||||
if (releaseBusyActive) {
|
||||
setTimeout(() => loadReleaseStatus(true), 1000);
|
||||
return;
|
||||
}
|
||||
console.error("Failed to load release status", e);
|
||||
setReleaseChip({ status: "error", message: "Failed to load" });
|
||||
if (releaseStatusMsg) {
|
||||
releaseStatusMsg.textContent = "Failed to load release status";
|
||||
releaseStatusMsg.classList.add("error");
|
||||
}
|
||||
// surface via toast/log only once
|
||||
logRelease("Error: failed to load release status");
|
||||
showToast("Failed to load release status", "error");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,11 +293,20 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction })
|
||||
releaseBusyActive = false;
|
||||
hideBusy();
|
||||
if (releaseProgress) releaseProgress.textContent = "";
|
||||
if (state.status === "up_to_date") {
|
||||
showToast(state.message || "Update complete", "success");
|
||||
// Only toast once per apply/rollback cycle
|
||||
if (state.status === "up_to_date" && releaseBusyActive === false) {
|
||||
const key = `ok-${state.current_version || ""}-${state.latest_version || ""}`;
|
||||
if (lastReleaseToastKey !== key) {
|
||||
lastReleaseToastKey = key;
|
||||
showToast(state.message || "Update complete", "success");
|
||||
}
|
||||
logRelease("Update complete");
|
||||
} else if (state.status === "error") {
|
||||
showToast(state.message || "Update failed", "error");
|
||||
const key = `err-${state.message || ""}`;
|
||||
if (lastReleaseToastKey !== key) {
|
||||
lastReleaseToastKey = key;
|
||||
showToast(state.message || "Update failed", "error");
|
||||
}
|
||||
logRelease(`Error: ${state.message || "Update failed"}`);
|
||||
}
|
||||
}
|
||||
@@ -216,15 +318,20 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction })
|
||||
releaseBtn?.addEventListener("click", () => {
|
||||
releaseModal?.classList.remove("hidden");
|
||||
loadReleaseStatus(true);
|
||||
loadReleaseList();
|
||||
});
|
||||
releaseClose?.addEventListener("click", () => releaseModal?.classList.add("hidden"));
|
||||
// Do not allow dismiss by clicking backdrop (consistency with other modals)
|
||||
releaseModal?.addEventListener("click", (e) => {
|
||||
if (e.target === releaseModal) releaseModal.classList.add("hidden");
|
||||
if (e.target === releaseModal) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
});
|
||||
|
||||
releaseCheckBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
logRelease("Checking for updates…");
|
||||
logUi("Update check requested");
|
||||
await checkRelease();
|
||||
await loadReleaseStatus(true);
|
||||
const state = window.__lastReleaseState || {};
|
||||
@@ -242,6 +349,8 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction })
|
||||
|
||||
releaseApplyBtn?.addEventListener("click", async () => {
|
||||
try {
|
||||
lastReleaseToastKey = null;
|
||||
logUi("Update apply requested");
|
||||
const state = window.__lastReleaseState || {};
|
||||
const { current_version, latest_version } = state;
|
||||
const sameVersion =
|
||||
@@ -272,17 +381,32 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction })
|
||||
}
|
||||
});
|
||||
|
||||
releaseRollbackBtn?.addEventListener("click", async () => {
|
||||
releaseAdvancedToggle?.addEventListener("click", async () => {
|
||||
releaseAdvanced?.classList.toggle("hidden");
|
||||
if (!releaseAdvanced?.classList.contains("hidden")) {
|
||||
await loadReleaseList();
|
||||
}
|
||||
});
|
||||
|
||||
releaseApplyVersionBtn?.addEventListener("click", async () => {
|
||||
const selected = releaseList?.querySelector("input[name='releaseVersion']:checked");
|
||||
if (!selected) {
|
||||
showToast("Select a version first", "error");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
lastReleaseToastKey = null;
|
||||
const ver = selected.value;
|
||||
logUi(`Install version ${ver} requested`);
|
||||
releaseBusyActive = true;
|
||||
showBusy("Rolling back…", "Restoring previous backup.");
|
||||
logRelease("Starting rollback…");
|
||||
await rollbackRelease();
|
||||
showBusy(`Installing ${ver}…`, "Applying selected release. This can take up to a minute.");
|
||||
logRelease(`Installing ${ver}…`);
|
||||
await applyReleaseVersion(ver);
|
||||
pollReleaseStatus();
|
||||
showToast("Rollback started", "success");
|
||||
showToast(`Installing ${ver}`, "success");
|
||||
} catch (e) {
|
||||
showToast(e.error || "Rollback failed", "error");
|
||||
logRelease(`Error: ${e.error || "Rollback failed"}`);
|
||||
showToast(e.error || "Install failed", "error");
|
||||
logRelease(`Error: ${e.error || "Install failed"}`);
|
||||
} finally {
|
||||
if (releaseProgress) releaseProgress.textContent = "";
|
||||
}
|
||||
@@ -305,6 +429,7 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction })
|
||||
releaseChannel = chan;
|
||||
logRelease(`Channel set to ${chan}`);
|
||||
await loadReleaseStatus(true);
|
||||
await loadReleaseList();
|
||||
} catch (e) {
|
||||
showToast(e.error || "Failed to save channel", "error");
|
||||
releaseChannelToggle.checked = releaseChannel === "dev";
|
||||
@@ -313,8 +438,11 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction })
|
||||
|
||||
releaseChangelogBtn?.addEventListener("click", async () => {
|
||||
const state = window.__lastReleaseState || {};
|
||||
const { latest_version, message } = state;
|
||||
const url = (message && message.startsWith("http") ? message : null) || lastChangelogUrl;
|
||||
const { latest_version, message, changelog_url } = state;
|
||||
const url =
|
||||
changelog_url ||
|
||||
(message && message.startsWith("http") ? message : null) ||
|
||||
lastChangelogUrl;
|
||||
if (!url) {
|
||||
showToast("No changelog URL available", "error");
|
||||
return;
|
||||
@@ -324,7 +452,8 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction })
|
||||
|
||||
releaseLogCopy?.addEventListener("click", async () => {
|
||||
try {
|
||||
const text = releaseLogLines.join("\n") || "No log entries yet.";
|
||||
const lines = logger.getLines ? logger.getLines() : [];
|
||||
const text = lines.join("\n") || "No log entries yet.";
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
|
||||
37
pikit-web/assets/services-helpers.js
Normal file
37
pikit-web/assets/services-helpers.js
Normal file
@@ -0,0 +1,37 @@
|
||||
export const DEFAULT_SELF_SIGNED_MSG =
|
||||
"This service uses a self-signed certificate. Expect a browser warning; proceed only if you trust this device.";
|
||||
|
||||
export function isValidLink(str) {
|
||||
if (!str) return true; // empty is allowed
|
||||
try {
|
||||
const u = new URL(str);
|
||||
return u.protocol === "http:" || u.protocol === "https:";
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePath(path) {
|
||||
if (!path) return "";
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed) return "";
|
||||
if (/\s/.test(trimmed)) return null; // no spaces or tabs in paths
|
||||
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return null;
|
||||
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
}
|
||||
|
||||
export function validateServiceFields({ name, port, path, notice, notice_link }, fail) {
|
||||
const err = (m) => {
|
||||
fail?.(m);
|
||||
return false;
|
||||
};
|
||||
if (!name || name.trim().length < 2) return err("Name must be at least 2 characters.");
|
||||
if (name.length > 48) return err("Name is too long (max 48 chars).");
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) return err("Port must be 1-65535.");
|
||||
if (path === null) return err("Path must be relative (e.g. /admin) or blank.");
|
||||
if (path.length > 200) return err("Path is too long (max 200 chars).");
|
||||
if (notice && notice.length > 180) return err("Notice text too long (max 180 chars).");
|
||||
if (notice_link && notice_link.length > 200) return err("Notice link too long (max 200 chars).");
|
||||
if (!isValidLink(notice_link)) return err("Enter a valid URL (http/https) or leave blank.");
|
||||
return true;
|
||||
}
|
||||
@@ -1,48 +1,16 @@
|
||||
import { addService, updateService, removeService } from "./api.js";
|
||||
import { logUi } from "./diaglog.js";
|
||||
import {
|
||||
DEFAULT_SELF_SIGNED_MSG,
|
||||
isValidLink,
|
||||
normalizePath,
|
||||
validateServiceFields,
|
||||
} from "./services-helpers.js";
|
||||
|
||||
// Renders service cards and wires UI controls for add/edit/remove operations.
|
||||
// All mutations round-trip through the API then invoke onChange to refresh data.
|
||||
|
||||
let noticeModalRefs = null;
|
||||
const DEFAULT_SELF_SIGNED_MSG =
|
||||
"This service uses a self-signed certificate. Expect a browser warning; proceed only if you trust this device.";
|
||||
|
||||
function isValidLink(str) {
|
||||
if (!str) return true; // empty is allowed
|
||||
try {
|
||||
const u = new URL(str);
|
||||
return u.protocol === "http:" || u.protocol === "https:";
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePath(path) {
|
||||
if (!path) return "";
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed) return "";
|
||||
if (/\s/.test(trimmed)) return null; // no spaces or tabs in paths
|
||||
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return null;
|
||||
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
}
|
||||
|
||||
function validateServiceFields({ name, port, path, notice, notice_link }, setMsg, toast) {
|
||||
const fail = (m) => {
|
||||
setMsg("");
|
||||
toast?.(m, "error");
|
||||
return false;
|
||||
};
|
||||
if (!name || name.trim().length < 2) return fail("Name must be at least 2 characters.");
|
||||
if (name.length > 48) return fail("Name is too long (max 48 chars).");
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) return fail("Port must be 1-65535.");
|
||||
if (path === null) return fail("Path must be relative (e.g. /admin) or blank.");
|
||||
if (path.length > 200) return fail("Path is too long (max 200 chars).");
|
||||
if (notice && notice.length > 180) return fail("Notice text too long (max 180 chars).");
|
||||
if (notice_link && notice_link.length > 200) return fail("Notice link too long (max 200 chars).");
|
||||
if (!isValidLink(notice_link)) return fail("Enter a valid URL (http/https) or leave blank.");
|
||||
return true;
|
||||
}
|
||||
|
||||
function ensureNoticeModal() {
|
||||
if (noticeModalRefs) return noticeModalRefs;
|
||||
const modal = document.createElement("div");
|
||||
@@ -264,6 +232,7 @@ export function initServiceControls({ gridEl, menu, addForm, onChange, overlay,
|
||||
async function menuAction(action, body = {}) {
|
||||
if (!menuContext) return;
|
||||
msg.textContent = "";
|
||||
const original = { ...menuContext };
|
||||
try {
|
||||
const isRemove = action === "remove";
|
||||
const isSave = action === "save";
|
||||
@@ -285,6 +254,17 @@ export function initServiceControls({ gridEl, menu, addForm, onChange, overlay,
|
||||
}
|
||||
msg.textContent = "";
|
||||
toast?.(isRemove ? "Service removed" : "Service saved", "success");
|
||||
logUi(isRemove ? "Service removed" : "Service updated", "info", {
|
||||
name: body.name || original.name,
|
||||
port_from: original.port,
|
||||
port_to: body.new_port || original.port,
|
||||
scheme_from: original.scheme,
|
||||
scheme_to: body.scheme || original.scheme,
|
||||
path_from: original.path,
|
||||
path_to: body.path ?? original.path,
|
||||
notice_changed: body.notice !== undefined,
|
||||
self_signed: body.self_signed,
|
||||
});
|
||||
modal?.classList.add("hidden");
|
||||
menuContext = null;
|
||||
await onChange?.();
|
||||
@@ -292,6 +272,12 @@ export function initServiceControls({ gridEl, menu, addForm, onChange, overlay,
|
||||
const err = e.error || "Action failed.";
|
||||
msg.textContent = "";
|
||||
toast?.(err, "error");
|
||||
logUi("Service update failed", "error", {
|
||||
action,
|
||||
name: body.name || original.name,
|
||||
port: original.port,
|
||||
reason: err,
|
||||
});
|
||||
} finally {
|
||||
hideBusy();
|
||||
}
|
||||
@@ -310,8 +296,7 @@ export function initServiceControls({ gridEl, menu, addForm, onChange, overlay,
|
||||
if (
|
||||
!validateServiceFields(
|
||||
{ name, port: new_port, path, notice, notice_link },
|
||||
() => {},
|
||||
toast,
|
||||
(m) => toast?.(m, "error"),
|
||||
)
|
||||
)
|
||||
return;
|
||||
@@ -335,11 +320,7 @@ export function initServiceControls({ gridEl, menu, addForm, onChange, overlay,
|
||||
const notice_link = (addNoticeLinkInput?.value || "").trim();
|
||||
const self_signed = !!addSelfSignedInput?.checked;
|
||||
if (
|
||||
!validateServiceFields(
|
||||
{ name, port, path, notice, notice_link },
|
||||
() => {},
|
||||
toast,
|
||||
)
|
||||
!validateServiceFields({ name, port, path, notice, notice_link }, (m) => toast?.(m, "error"))
|
||||
)
|
||||
return;
|
||||
addBtn.disabled = true;
|
||||
@@ -348,11 +329,21 @@ export function initServiceControls({ gridEl, menu, addForm, onChange, overlay,
|
||||
await addService({ name, port, scheme, path, notice, notice_link, self_signed });
|
||||
addMsg.textContent = "";
|
||||
toast?.("Service added", "success");
|
||||
logUi("Service added", "info", {
|
||||
name,
|
||||
port,
|
||||
scheme,
|
||||
path,
|
||||
notice: !!notice,
|
||||
notice_link: !!notice_link,
|
||||
self_signed,
|
||||
});
|
||||
await onChange?.();
|
||||
} catch (e) {
|
||||
const err = e.error || "Failed to add.";
|
||||
addMsg.textContent = "";
|
||||
toast?.(err, "error");
|
||||
logUi("Service add failed", "error", { name, port, scheme, reason: err });
|
||||
} finally {
|
||||
addBtn.disabled = false;
|
||||
hideBusy();
|
||||
|
||||
125
pikit-web/assets/status-controller.js
Normal file
125
pikit-web/assets/status-controller.js
Normal file
@@ -0,0 +1,125 @@
|
||||
// Status polling and UI flag helpers
|
||||
import { placeholderStatus, renderStats } from "./status.js";
|
||||
import { renderServices } from "./services.js";
|
||||
|
||||
export function createStatusController({
|
||||
heroStats,
|
||||
servicesGrid,
|
||||
updatesFlagTop,
|
||||
updatesNoteTop,
|
||||
tempFlagTop,
|
||||
readyOverlay,
|
||||
logUi,
|
||||
showToast = () => {},
|
||||
onReadyWait = null,
|
||||
getStatus,
|
||||
isUpdatesDirty,
|
||||
releaseUIGetter = () => null,
|
||||
setUpdatesUI = null,
|
||||
updatesFlagEl = null,
|
||||
firstboot = null,
|
||||
}) {
|
||||
let lastStatusData = null;
|
||||
let lastFirstbootState = null;
|
||||
|
||||
function setTempFlag(tempC) {
|
||||
if (!tempFlagTop) return;
|
||||
const t = typeof tempC === "number" ? tempC : null;
|
||||
let label = "Temp: n/a";
|
||||
tempFlagTop.classList.remove("chip-on", "chip-warm", "chip-off");
|
||||
if (t !== null) {
|
||||
if (t < 55) {
|
||||
label = "Temp: OK";
|
||||
tempFlagTop.classList.add("chip-on");
|
||||
} else if (t < 70) {
|
||||
label = "Temp: Warm";
|
||||
tempFlagTop.classList.add("chip-warm");
|
||||
} else {
|
||||
label = "Temp: Hot";
|
||||
tempFlagTop.classList.add("chip-off");
|
||||
}
|
||||
}
|
||||
tempFlagTop.textContent = label;
|
||||
}
|
||||
|
||||
function updatesFlagEl(enabled) {
|
||||
if (!updatesFlagTop) return;
|
||||
const labelOn = "System updates: On";
|
||||
const labelOff = "System updates: Off";
|
||||
updatesFlagTop.textContent =
|
||||
enabled === true ? labelOn : enabled === false ? labelOff : "System updates";
|
||||
updatesFlagTop.className = "status-chip quiet chip-system";
|
||||
if (enabled === false) updatesFlagTop.classList.add("chip-off");
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
try {
|
||||
const data = await getStatus();
|
||||
lastStatusData = data;
|
||||
renderStats(heroStats, data);
|
||||
renderServices(servicesGrid, data.services, { openAddService: window.__pikitOpenAddService });
|
||||
const updatesEnabled = data?.auto_updates?.enabled ?? data.auto_updates_enabled;
|
||||
if (updatesEnabled !== undefined && !isUpdatesDirty()) {
|
||||
setUpdatesUI?.(updatesEnabled);
|
||||
}
|
||||
updatesFlagEl?.(updatesEnabled === undefined ? null : updatesEnabled === true);
|
||||
|
||||
const cfg = data.updates_config || {};
|
||||
const rebootReq = data.reboot_required;
|
||||
setTempFlag(data.cpu_temp_c);
|
||||
if (updatesNoteTop) {
|
||||
updatesNoteTop.textContent = "";
|
||||
updatesNoteTop.classList.remove("note-warn");
|
||||
if (rebootReq) {
|
||||
if (cfg.auto_reboot) {
|
||||
updatesNoteTop.textContent = `Reboot scheduled at ${cfg.reboot_time || "02:00"}.`;
|
||||
} else {
|
||||
updatesNoteTop.textContent = "Reboot required. Please reboot when you can.";
|
||||
updatesNoteTop.classList.add("note-warn");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (firstboot?.getStatus && firstboot?.ui) {
|
||||
let firstbootData = null;
|
||||
const shouldFetchFirstboot =
|
||||
lastFirstbootState === null || !data.ready || lastFirstbootState === "running" || lastFirstbootState === "error";
|
||||
if (shouldFetchFirstboot) {
|
||||
try {
|
||||
firstbootData = await firstboot.getStatus();
|
||||
lastFirstbootState = firstbootData?.state || lastFirstbootState;
|
||||
firstboot.ui.update(firstbootData);
|
||||
if (firstbootData?.state === "error" && firstboot.getError) {
|
||||
const err = await firstboot.getError();
|
||||
if (err?.present) {
|
||||
firstboot.ui.showError(err.text || "");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logUi?.(`First-boot status failed: ${err?.message || err}`, "error");
|
||||
}
|
||||
}
|
||||
const readyNow = data.ready || firstbootData?.state === "done";
|
||||
const showOverlay = !readyNow || firstbootData?.state === "error";
|
||||
firstboot.ui.showOverlay(showOverlay);
|
||||
if (showOverlay) onReadyWait?.();
|
||||
} else if (readyOverlay) {
|
||||
if (data.ready) {
|
||||
readyOverlay.classList.add("hidden");
|
||||
} else {
|
||||
readyOverlay.classList.remove("hidden");
|
||||
onReadyWait?.();
|
||||
}
|
||||
}
|
||||
releaseUIGetter()?.refreshStatus();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
logUi?.(`Status refresh failed: ${e?.message || e}`, "error");
|
||||
if (!lastStatusData) {
|
||||
renderStats(heroStats, placeholderStatus);
|
||||
}
|
||||
setTimeout(loadStatus, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
return { loadStatus, setTempFlag, updatesFlagEl };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
258
pikit-web/assets/toast.js
Normal file
258
pikit-web/assets/toast.js
Normal file
@@ -0,0 +1,258 @@
|
||||
const TOAST_POS_KEY = "pikit-toast-pos";
|
||||
const TOAST_ANIM_KEY = "pikit-toast-anim";
|
||||
const TOAST_SPEED_KEY = "pikit-toast-speed";
|
||||
const TOAST_DURATION_KEY = "pikit-toast-duration";
|
||||
const FONT_KEY = "pikit-font";
|
||||
|
||||
export const ALLOWED_TOAST_POS = [
|
||||
"bottom-center",
|
||||
"bottom-right",
|
||||
"bottom-left",
|
||||
"top-right",
|
||||
"top-left",
|
||||
"top-center",
|
||||
];
|
||||
export const ALLOWED_TOAST_ANIM = ["slide-in", "fade", "pop", "bounce", "drop", "grow"];
|
||||
export const ALLOWED_FONTS = ["redhat", "space", "manrope", "dmsans", "sora", "chivo", "atkinson", "plex"];
|
||||
|
||||
const clamp = (val, min, max) => Math.min(max, Math.max(min, val));
|
||||
|
||||
export function createToastManager({
|
||||
container,
|
||||
posSelect,
|
||||
animSelect,
|
||||
speedInput,
|
||||
durationInput,
|
||||
fontSelect,
|
||||
testBtn,
|
||||
} = {}) {
|
||||
const state = {
|
||||
position: "bottom-center",
|
||||
animation: "slide-in",
|
||||
durationMs: 5000,
|
||||
speedMs: 300,
|
||||
font: "redhat",
|
||||
};
|
||||
|
||||
function applyToastSettings() {
|
||||
if (!container) return;
|
||||
container.className = `toast-container pos-${state.position}`;
|
||||
document.documentElement.style.setProperty("--toast-speed", `${state.speedMs}ms`);
|
||||
const dir = state.position.startsWith("top") ? -1 : 1;
|
||||
const isLeft = state.position.includes("left");
|
||||
const isRight = state.position.includes("right");
|
||||
const slideX = isLeft ? -26 : isRight ? 26 : 0;
|
||||
const slideY = isLeft || isRight ? 0 : dir * 24;
|
||||
document.documentElement.style.setProperty("--toast-slide-offset", `${dir * 24}px`);
|
||||
document.documentElement.style.setProperty("--toast-dir", `${dir}`);
|
||||
document.documentElement.style.setProperty("--toast-slide-x", `${slideX}px`);
|
||||
document.documentElement.style.setProperty("--toast-slide-y", `${slideY}px`);
|
||||
if (durationInput) durationInput.value = state.durationMs;
|
||||
}
|
||||
|
||||
function applyFontSetting() {
|
||||
document.documentElement.setAttribute("data-font", state.font);
|
||||
if (fontSelect) fontSelect.value = state.font;
|
||||
}
|
||||
|
||||
function persistSettings() {
|
||||
try {
|
||||
localStorage.setItem(TOAST_POS_KEY, state.position);
|
||||
localStorage.setItem(TOAST_ANIM_KEY, state.animation);
|
||||
localStorage.setItem(TOAST_SPEED_KEY, String(state.speedMs));
|
||||
localStorage.setItem(TOAST_DURATION_KEY, String(state.durationMs));
|
||||
localStorage.setItem(FONT_KEY, state.font);
|
||||
} catch (e) {
|
||||
console.warn("Toast settings save failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
function loadFromStorage() {
|
||||
try {
|
||||
const posSaved = localStorage.getItem(TOAST_POS_KEY);
|
||||
if (ALLOWED_TOAST_POS.includes(posSaved)) state.position = posSaved;
|
||||
const animSaved = localStorage.getItem(TOAST_ANIM_KEY);
|
||||
const migrated =
|
||||
animSaved === "slide-up" || animSaved === "slide-left" || animSaved === "slide-right"
|
||||
? "slide-in"
|
||||
: animSaved;
|
||||
if (migrated && ALLOWED_TOAST_ANIM.includes(migrated)) state.animation = migrated;
|
||||
const savedSpeed = Number(localStorage.getItem(TOAST_SPEED_KEY));
|
||||
if (!Number.isNaN(savedSpeed) && savedSpeed >= 100 && savedSpeed <= 3000) {
|
||||
state.speedMs = savedSpeed;
|
||||
}
|
||||
const savedDur = Number(localStorage.getItem(TOAST_DURATION_KEY));
|
||||
if (!Number.isNaN(savedDur) && savedDur >= 1000 && savedDur <= 15000) {
|
||||
state.durationMs = savedDur;
|
||||
}
|
||||
const savedFont = localStorage.getItem(FONT_KEY);
|
||||
if (ALLOWED_FONTS.includes(savedFont)) state.font = savedFont;
|
||||
} catch (e) {
|
||||
console.warn("Toast settings load failed", e);
|
||||
}
|
||||
if (posSelect) posSelect.value = state.position;
|
||||
if (animSelect) animSelect.value = state.animation;
|
||||
if (speedInput) speedInput.value = state.speedMs;
|
||||
if (durationInput) durationInput.value = state.durationMs;
|
||||
if (fontSelect) fontSelect.value = state.font;
|
||||
applyToastSettings();
|
||||
applyFontSetting();
|
||||
}
|
||||
|
||||
function showToast(message, type = "info") {
|
||||
if (!container || !message) return;
|
||||
const t = document.createElement("div");
|
||||
t.className = `toast ${type} anim-${state.animation}`;
|
||||
t.textContent = message;
|
||||
container.appendChild(t);
|
||||
const animOn = document.documentElement.getAttribute("data-anim") !== "off";
|
||||
if (!animOn) {
|
||||
t.classList.add("show");
|
||||
} else {
|
||||
requestAnimationFrame(() => t.classList.add("show"));
|
||||
}
|
||||
const duration = state.durationMs;
|
||||
setTimeout(() => {
|
||||
const all = Array.from(container.querySelectorAll(".toast"));
|
||||
const others = all.filter((el) => el !== t && !el.classList.contains("leaving"));
|
||||
const first = new Map(
|
||||
others.map((el) => [el, el.getBoundingClientRect()]),
|
||||
);
|
||||
|
||||
t.classList.add("leaving");
|
||||
void t.offsetHeight;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const second = new Map(
|
||||
others.map((el) => [el, el.getBoundingClientRect()]),
|
||||
);
|
||||
others.forEach((el) => {
|
||||
const dy = first.get(el).top - second.get(el).top;
|
||||
if (Math.abs(dy) > 0.5) {
|
||||
el.style.transition = "transform var(--toast-speed, 0.28s) ease";
|
||||
el.style.transform = `translateY(${dy}px)`;
|
||||
requestAnimationFrame(() => {
|
||||
el.style.transform = "";
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const removeDelay = animOn ? state.speedMs : 0;
|
||||
setTimeout(() => {
|
||||
t.classList.remove("show");
|
||||
t.remove();
|
||||
others.forEach((el) => (el.style.transition = ""));
|
||||
}, removeDelay);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
function wireControls() {
|
||||
posSelect?.addEventListener("change", () => {
|
||||
const val = posSelect.value;
|
||||
if (ALLOWED_TOAST_POS.includes(val)) {
|
||||
state.position = val;
|
||||
applyToastSettings();
|
||||
persistSettings();
|
||||
} else {
|
||||
posSelect.value = state.position;
|
||||
showToast("Invalid toast position", "error");
|
||||
}
|
||||
});
|
||||
|
||||
animSelect?.addEventListener("change", () => {
|
||||
let val = animSelect.value;
|
||||
if (val === "slide-up" || val === "slide-left" || val === "slide-right") val = "slide-in";
|
||||
if (ALLOWED_TOAST_ANIM.includes(val)) {
|
||||
state.animation = val;
|
||||
persistSettings();
|
||||
} else {
|
||||
animSelect.value = state.animation;
|
||||
showToast("Invalid toast animation", "error");
|
||||
}
|
||||
});
|
||||
|
||||
const clampSpeed = (val) => clamp(val, 100, 3000);
|
||||
const clampDuration = (val) => clamp(val, 1000, 15000);
|
||||
|
||||
speedInput?.addEventListener("input", () => {
|
||||
const raw = speedInput.value;
|
||||
if (raw === "") return;
|
||||
const val = Number(raw);
|
||||
if (Number.isNaN(val) || val < 100 || val > 3000) return;
|
||||
state.speedMs = val;
|
||||
applyToastSettings();
|
||||
persistSettings();
|
||||
});
|
||||
speedInput?.addEventListener("blur", () => {
|
||||
const raw = speedInput.value;
|
||||
if (raw === "") {
|
||||
speedInput.value = state.speedMs;
|
||||
return;
|
||||
}
|
||||
const val = Number(raw);
|
||||
if (Number.isNaN(val) || val < 100 || val > 3000) {
|
||||
state.speedMs = clampSpeed(state.speedMs);
|
||||
speedInput.value = state.speedMs;
|
||||
showToast("Toast speed must be 100-3000 ms", "error");
|
||||
return;
|
||||
}
|
||||
state.speedMs = val;
|
||||
speedInput.value = state.speedMs;
|
||||
applyToastSettings();
|
||||
persistSettings();
|
||||
});
|
||||
|
||||
durationInput?.addEventListener("input", () => {
|
||||
const raw = durationInput.value;
|
||||
if (raw === "") return;
|
||||
const val = Number(raw);
|
||||
if (Number.isNaN(val) || val < 1000 || val > 15000) return;
|
||||
state.durationMs = val;
|
||||
persistSettings();
|
||||
});
|
||||
durationInput?.addEventListener("blur", () => {
|
||||
const raw = durationInput.value;
|
||||
if (raw === "") {
|
||||
durationInput.value = state.durationMs;
|
||||
return;
|
||||
}
|
||||
const val = Number(raw);
|
||||
if (Number.isNaN(val) || val < 1000 || val > 15000) {
|
||||
state.durationMs = clampDuration(state.durationMs);
|
||||
durationInput.value = state.durationMs;
|
||||
showToast("Toast duration must be 1000-15000 ms", "error");
|
||||
return;
|
||||
}
|
||||
state.durationMs = val;
|
||||
durationInput.value = state.durationMs;
|
||||
persistSettings();
|
||||
});
|
||||
|
||||
fontSelect?.addEventListener("change", () => {
|
||||
const val = fontSelect.value;
|
||||
if (!ALLOWED_FONTS.includes(val)) {
|
||||
fontSelect.value = state.font;
|
||||
showToast("Invalid font choice", "error");
|
||||
return;
|
||||
}
|
||||
state.font = val;
|
||||
applyFontSetting();
|
||||
persistSettings();
|
||||
});
|
||||
|
||||
testBtn?.addEventListener("click", () => showToast("This is a test toast", "info"));
|
||||
}
|
||||
|
||||
loadFromStorage();
|
||||
wireControls();
|
||||
|
||||
return {
|
||||
state,
|
||||
showToast,
|
||||
applyToastSettings,
|
||||
applyFontSetting,
|
||||
persistSettings,
|
||||
loadFromStorage,
|
||||
};
|
||||
}
|
||||
80
pikit-web/assets/ui.js
Normal file
80
pikit-web/assets/ui.js
Normal file
@@ -0,0 +1,80 @@
|
||||
// Small UI helpers to keep main.js lean and declarative.
|
||||
|
||||
export function applyTooltips(map = {}) {
|
||||
Object.entries(map).forEach(([id, text]) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el && text) el.title = text;
|
||||
});
|
||||
}
|
||||
|
||||
export function wireModalPairs(pairs = []) {
|
||||
pairs.forEach(({ openBtn, modal, closeBtn }) => {
|
||||
if (!modal) return;
|
||||
openBtn?.addEventListener("click", () => modal.classList.remove("hidden"));
|
||||
closeBtn?.addEventListener("click", () => modal.classList.add("hidden"));
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === modal) modal.classList.add("hidden");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function wireAccordions({
|
||||
toggleSelector = ".accordion-toggle",
|
||||
accordionSelector = ".accordion",
|
||||
forceOpen = false,
|
||||
} = {}) {
|
||||
const accordions = Array.from(document.querySelectorAll(accordionSelector));
|
||||
const toggles = Array.from(document.querySelectorAll(toggleSelector));
|
||||
if (forceOpen) {
|
||||
accordions.forEach((a) => a.classList.add("open"));
|
||||
return;
|
||||
}
|
||||
toggles.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const acc = btn.closest(accordionSelector);
|
||||
if (!acc) return;
|
||||
if (acc.classList.contains("open")) {
|
||||
acc.classList.remove("open");
|
||||
} else {
|
||||
accordions.forEach((a) => a.classList.remove("open"));
|
||||
acc.classList.add("open");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function createBusyOverlay({ overlay, titleEl, textEl }) {
|
||||
const showBusy = (title = "Working…", text = "This may take a few seconds.") => {
|
||||
if (!overlay) return;
|
||||
if (titleEl) titleEl.textContent = title;
|
||||
if (textEl) {
|
||||
textEl.textContent = text || "";
|
||||
textEl.classList.toggle("hidden", !text);
|
||||
}
|
||||
overlay.classList.remove("hidden");
|
||||
};
|
||||
const hideBusy = () => overlay?.classList.add("hidden");
|
||||
return { showBusy, hideBusy };
|
||||
}
|
||||
|
||||
export function createConfirmModal({ modal, titleEl, bodyEl, okBtn, cancelBtn }) {
|
||||
const confirmAction = (title, body) =>
|
||||
new Promise((resolve) => {
|
||||
if (!modal) {
|
||||
resolve(window.confirm(body || title || "Are you sure?"));
|
||||
return;
|
||||
}
|
||||
if (titleEl) titleEl.textContent = title || "Are you sure?";
|
||||
if (bodyEl) bodyEl.textContent = body || "";
|
||||
modal.classList.remove("hidden");
|
||||
const done = (val) => {
|
||||
modal.classList.add("hidden");
|
||||
resolve(val);
|
||||
};
|
||||
const okHandler = () => done(true);
|
||||
const cancelHandler = () => done(false);
|
||||
okBtn?.addEventListener("click", okHandler, { once: true });
|
||||
cancelBtn?.addEventListener("click", cancelHandler, { once: true });
|
||||
});
|
||||
return confirmAction;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// UI controller for unattended-upgrades settings.
|
||||
// Fetches current config, mirrors it into the form, and saves changes.
|
||||
import { getUpdateConfig, saveUpdateConfig } from "./api.js";
|
||||
import { logUi } from "./diaglog.js";
|
||||
|
||||
const TIME_RE = /^(\d{1,2}):(\d{2})$/;
|
||||
const MAX_BANDWIDTH_KBPS = 1_000_000; // 1 GB/s cap to prevent typos
|
||||
@@ -115,14 +116,14 @@ export function initUpdateSettings({
|
||||
|
||||
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);
|
||||
if (isError) {
|
||||
msgEl.textContent = text || "Something went wrong";
|
||||
msgEl.classList.add("error");
|
||||
toast?.(text || "Error", "error");
|
||||
} else {
|
||||
msgEl.textContent = text || "";
|
||||
msgEl.classList.remove("error");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function currentConfigFromForm() {
|
||||
@@ -245,6 +246,7 @@ export function initUpdateSettings({
|
||||
showMessage("");
|
||||
|
||||
try {
|
||||
const prev = lastConfig ? { ...lastConfig } : null;
|
||||
const payload = buildPayload();
|
||||
|
||||
if (overrideEnable !== null) payload.enable = !!overrideEnable;
|
||||
@@ -256,6 +258,7 @@ export function initUpdateSettings({
|
||||
|
||||
showMessage("Update settings saved.");
|
||||
toast?.("Updates saved", "success");
|
||||
logUi("Update settings saved", "info", { from: prev, to: payload });
|
||||
|
||||
onAfterSave?.();
|
||||
|
||||
@@ -272,6 +275,16 @@ export function initUpdateSettings({
|
||||
|
||||
}
|
||||
showMessage(e?.error || e?.message || "Save failed", true);
|
||||
logUi("Update settings save failed", "error", {
|
||||
payload: (() => {
|
||||
try {
|
||||
return buildPayload();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})(),
|
||||
reason: e?.error || e?.message,
|
||||
});
|
||||
|
||||
} finally {
|
||||
saving = false;
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"last_check": "2025-12-10T22:00:00Z",
|
||||
"status": "update_available",
|
||||
"message": "New UI polish and bug fixes.",
|
||||
"changelog_url": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.1-mock/CHANGELOG-0.1.1-mock.txt",
|
||||
"latest_release_date": "2025-12-09T18:00:00Z",
|
||||
"current_release_date": "2025-12-01T17:00:00Z",
|
||||
"auto_check": true,
|
||||
"in_progress": false,
|
||||
"progress": null
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "0.1.0-dev"
|
||||
"version": "0.1.3"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Pi-Kit Dashboard</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="stylesheet" href="assets/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
@@ -33,6 +34,9 @@
|
||||
>
|
||||
<span id="themeToggleIcon" aria-hidden="true">🌙</span>
|
||||
</button>
|
||||
<button id="diagLogBtn" class="ghost hidden" title="Diagnostics log (visible when enabled)">
|
||||
Log
|
||||
</button>
|
||||
<button id="releaseBtn" class="ghost" title="Pi-Kit updates">
|
||||
Update
|
||||
</button>
|
||||
@@ -76,14 +80,55 @@
|
||||
</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 id="readyOverlay" class="overlay hidden firstboot-overlay">
|
||||
<div class="overlay-box firstboot-card" role="status" aria-live="polite">
|
||||
<div class="firstboot-header">
|
||||
<h3>Finishing setup</h3>
|
||||
<p class="hint">This usually takes a few minutes. Please keep this tab open.</p>
|
||||
</div>
|
||||
<div class="firstboot-body">
|
||||
<div class="firstboot-steps">
|
||||
<p class="eyebrow">Steps</p>
|
||||
<ol id="firstbootSteps" class="firstboot-steps-list"></ol>
|
||||
</div>
|
||||
<div class="firstboot-log">
|
||||
<p id="firstbootCurrentStep" class="firstboot-current">Current step: preparing</p>
|
||||
<p class="eyebrow">Live setup log</p>
|
||||
<pre id="firstbootLog" class="log-box"></pre>
|
||||
<p id="firstbootLogNote" class="hint"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="firstbootErrorModal" class="modal hidden">
|
||||
<div class="modal-card wide">
|
||||
<div class="panel-header sticky">
|
||||
<div>
|
||||
<p class="eyebrow">Setup</p>
|
||||
<h3>Setup needs attention</h3>
|
||||
<p class="hint">
|
||||
Pi-Kit couldn’t finish setup automatically. Nothing is broken, but a manual fix is needed.
|
||||
Use SSH and review the error log below, then follow the recovery tips.
|
||||
</p>
|
||||
</div>
|
||||
<button id="firstbootErrorClose" class="ghost icon-btn close-btn" title="Close setup error">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div class="control-actions wrap gap">
|
||||
<button id="firstbootCopyError" class="ghost">Copy error log</button>
|
||||
<button id="firstbootShowRecovery" class="ghost">Show recovery steps</button>
|
||||
</div>
|
||||
<div id="firstbootRecovery" class="help-body hidden">
|
||||
<ul>
|
||||
<li>SSH: <code>ssh dietpi@pikit</code></li>
|
||||
<li>Error log: <code>sudo cat /var/lib/pikit/firstboot/firstboot.error</code></li>
|
||||
<li>Full log: <code>sudo cat /var/lib/pikit/firstboot/firstboot.log</code></li>
|
||||
<li>If needed: <code>sudo systemctl restart nginx pikit-api</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
<pre id="firstbootErrorLog" class="log-box" aria-live="polite"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -102,6 +147,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="diagModal" class="modal hidden diag-log-modal">
|
||||
<div class="modal-card wide">
|
||||
<div class="panel-header sticky">
|
||||
<div>
|
||||
<p class="eyebrow">Diagnostics</p>
|
||||
<h3>Diagnostics log</h3>
|
||||
<p class="hint">RAM-only; cleared on reboot/clear. Use toggles in Settings → Diagnostics to enable.</p>
|
||||
</div>
|
||||
<button id="diagClose" class="ghost icon-btn close-btn" title="Close diagnostics log">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div class="control-actions wrap gap">
|
||||
<button id="diagRefreshBtn" class="ghost" title="Refresh diagnostics log">Refresh</button>
|
||||
<button id="diagClearBtn" class="ghost" title="Clear diagnostics log">Clear</button>
|
||||
<button id="diagCopyBtn" class="ghost" title="Copy diagnostics to clipboard">Copy</button>
|
||||
<button id="diagDownloadBtn" class="ghost" title="Download diagnostics as text">Download</button>
|
||||
<span id="diagStatusModal" class="hint quiet"></span>
|
||||
</div>
|
||||
<pre id="diagLogBox" class="log-box" aria-live="polite"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="releaseModal" class="modal hidden">
|
||||
<div class="modal-card wide">
|
||||
<div class="panel-header sticky">
|
||||
@@ -117,14 +185,21 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="controls column">
|
||||
<div class="release-status-bar">
|
||||
<span id="releaseStatusChip" class="status-chip quiet">Status: n/a</span>
|
||||
<span id="releaseChannelChip" class="status-chip quiet">Channel: n/a</span>
|
||||
<span id="releaseLastCheckChip" class="status-chip quiet">Last check: —</span>
|
||||
</div>
|
||||
<div class="control-card release-versions">
|
||||
<div>
|
||||
<p class="hint quiet">Current version</p>
|
||||
<h3 id="releaseCurrent">n/a</h3>
|
||||
<p class="hint quiet" id="releaseCurrentDate">—</p>
|
||||
</div>
|
||||
<div class="align-right">
|
||||
<p class="hint quiet">Latest available</p>
|
||||
<h3 id="releaseLatest">—</h3>
|
||||
<p class="hint quiet" id="releaseLatestDate">—</p>
|
||||
<p id="releaseStatusMsg" class="hint status-msg"></p>
|
||||
<button id="releaseChangelogBtn" class="ghost small" title="View changelog">Changelog</button>
|
||||
</div>
|
||||
@@ -136,8 +211,8 @@
|
||||
<button id="releaseApplyBtn" title="Download and install the latest release">
|
||||
Upgrade
|
||||
</button>
|
||||
<button id="releaseRollbackBtn" class="ghost" title="Rollback to previous backup">
|
||||
Rollback
|
||||
<button id="releaseAdvancedToggle" class="ghost" title="Open manual version picker">
|
||||
Manual selection
|
||||
</button>
|
||||
<label class="checkbox-row inline">
|
||||
<input type="checkbox" id="releaseAutoCheck" />
|
||||
@@ -148,6 +223,18 @@
|
||||
<span>Allow dev builds</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="releaseAdvanced" class="release-advanced hidden">
|
||||
<div class="release-advanced-head">
|
||||
<div>
|
||||
<p class="hint quiet">Choose a specific release</p>
|
||||
<span class="hint">Dev builds only appear when “Allow dev builds” is on.</span>
|
||||
</div>
|
||||
<button id="releaseApplyVersionBtn" class="ghost" title="Install selected release">
|
||||
Install selected
|
||||
</button>
|
||||
</div>
|
||||
<div id="releaseList" class="release-list" role="listbox" aria-label="Available releases"></div>
|
||||
</div>
|
||||
<div id="releaseProgress" class="hint status-msg"></div>
|
||||
<div class="log-card">
|
||||
<div class="log-header">
|
||||
@@ -250,6 +337,7 @@
|
||||
type="text"
|
||||
id="svcName"
|
||||
placeholder="Service name"
|
||||
title="Service name"
|
||||
maxlength="32"
|
||||
/>
|
||||
<p class="hint quiet">Service name: max 32 characters.</p>
|
||||
@@ -259,11 +347,13 @@
|
||||
placeholder="Port (e.g. 8080)"
|
||||
min="1"
|
||||
max="65535"
|
||||
title="Service port"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
id="svcPath"
|
||||
placeholder="Optional path (e.g. /admin)"
|
||||
title="Optional path (e.g. /admin)"
|
||||
/>
|
||||
<div class="control-row split">
|
||||
<label class="checkbox-row">
|
||||
@@ -282,11 +372,13 @@
|
||||
id="svcNotice"
|
||||
rows="3"
|
||||
placeholder="Optional notice (shown on card)"
|
||||
title="Optional notice shown on the service card"
|
||||
></textarea>
|
||||
<input
|
||||
type="text"
|
||||
id="svcNoticeLink"
|
||||
placeholder="Optional link for more info"
|
||||
title="Optional link for more info"
|
||||
/>
|
||||
<div class="control-actions">
|
||||
<button id="svcAddBtn" title="Add service and open port on LAN">
|
||||
@@ -522,6 +614,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="accordion">
|
||||
<button class="accordion-toggle" data-target="acc-diag">
|
||||
Diagnostics
|
||||
</button>
|
||||
<div class="accordion-body" id="acc-diag">
|
||||
<p class="hint">
|
||||
Temporary, RAM-only logs for debugging. Toggle on, choose debug for extra detail. Logs reset on reboot or clear. Use the Log button in the top bar (visible when diagnostics is enabled) to view, copy, download, or clear entries.
|
||||
</p>
|
||||
<div class="control-actions split-row">
|
||||
<label class="checkbox-row inline tight">
|
||||
<input type="checkbox" id="diagEnableToggle" />
|
||||
<span>Enable diagnostics</span>
|
||||
</label>
|
||||
<label class="checkbox-row inline tight">
|
||||
<input type="checkbox" id="diagDebugToggle" />
|
||||
<span>Debug detail (includes UI clicks)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="control-actions">
|
||||
<span id="diagStatus" class="hint quiet"></span>
|
||||
</div>
|
||||
<p class="hint quiet">Stored in RAM (max ~1MB server, ~500 entries client). No data written to disk.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="accordion">
|
||||
<button class="accordion-toggle danger-btn" data-target="acc-reset">
|
||||
Factory reset
|
||||
@@ -726,7 +843,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="assets/main.js?v=20251213f"></script>
|
||||
<script type="module" src="assets/main.js?v=20251213j"></script>
|
||||
<div id="toastContainer" class="toast-container"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
217
pikit-web/onboarding/index.html
Normal file
217
pikit-web/onboarding/index.html
Normal file
@@ -0,0 +1,217 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Welcome to your Pi-Kit</title>
|
||||
<link rel="stylesheet" href="/style.css?v=2" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="card">
|
||||
<header>
|
||||
<div class="dot"></div>
|
||||
<h1>Welcome to your Pi-Kit</h1>
|
||||
</header>
|
||||
|
||||
<p class="welcome">Great news — you’re already on your Pi-Kit and it’s responding.</p>
|
||||
<div class="status-row" aria-live="polite">
|
||||
<span class="status-chip" id="statusChip">You’re on HTTP — trust the CA or continue to HTTPS.</span>
|
||||
</div>
|
||||
<p class="subtle">
|
||||
Everything stays on your local network. Let’s move you to the secure (HTTPS) dashboard so you
|
||||
can manage Pi-Kit safely.
|
||||
</p>
|
||||
|
||||
<section class="steps">
|
||||
<h3>Why switch to HTTPS?</h3>
|
||||
<ul>
|
||||
<li>Encrypts traffic on your LAN so no one can snoop your Pi-Kit dashboard.</li>
|
||||
<li>Stops mixed-content / “not secure” browser warnings.</li>
|
||||
<li>Needed for some browser features (clipboard, notifications, service workers).</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="steps">
|
||||
<h3>If you see a warning</h3>
|
||||
<ul>
|
||||
<li>Brave/Chrome: click <strong>Advanced</strong> → <strong>Proceed</strong>.</li>
|
||||
<li>Firefox: click <strong>Advanced</strong> → <strong>Accept the Risk & Continue</strong>.</li>
|
||||
</ul>
|
||||
<p>This warning is expected the first time. It’s safe for your own Pi on your own network.</p>
|
||||
</section>
|
||||
|
||||
<section class="steps">
|
||||
<h3>Install the Pi-Kit CA (recommended, one-time)</h3>
|
||||
<div class="ca-download">
|
||||
<p>This removes future warnings for the Pi-Kit dashboard.</p>
|
||||
<a class="ghost download-inline" id="downloadCa" href="http://pikit.local/assets/pikit-ca.crt" download>
|
||||
Download Pi-Kit CA
|
||||
</a>
|
||||
</div>
|
||||
<p class="subtle">After installing the CA, close and reopen your browser so it takes effect.</p>
|
||||
<p class="checksum">SHA256: <code id="caHash" class="inline">Loading...</code></p>
|
||||
<details>
|
||||
<summary id="win">Windows</summary>
|
||||
<p>Run <strong>mmc</strong> → Add/Remove Snap-in → Certificates (Computer) → Trusted Root CAs → Import <em>pikit-ca.crt</em>.</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary id="mac">macOS</summary>
|
||||
<p>Double-click <em>pikit-ca.crt</em> → Always Trust.</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary id="linux">Linux</summary>
|
||||
<p><strong>Arch-based:</strong> <code id="archCmd">curl -s http://pikit.local/assets/pikit-ca.crt -o /tmp/pikit-ca.crt && sudo install -m644 /tmp/pikit-ca.crt /etc/ca-certificates/trust-source/anchors/ && sudo trust extract-compat</code></p>
|
||||
<button class="copy" data-target="archCmd">Copy Arch command</button>
|
||||
<p><strong>Debian/Ubuntu:</strong> <code id="debCmd">curl -s http://pikit.local/assets/pikit-ca.crt -o /tmp/pikit-ca.crt && sudo cp /tmp/pikit-ca.crt /usr/local/share/ca-certificates/pikit-ca.crt && sudo update-ca-certificates</code></p>
|
||||
<button class="copy" data-target="debCmd">Copy Debian/Ubuntu command</button>
|
||||
<p><strong>Fedora/RHEL:</strong> <code id="fedoraCmd">curl -s http://pikit.local/assets/pikit-ca.crt -o /tmp/pikit-ca.crt && sudo cp /tmp/pikit-ca.crt /etc/pki/ca-trust/source/anchors/pikit-ca.crt && sudo update-ca-trust</code></p>
|
||||
<button class="copy" data-target="fedoraCmd">Copy Fedora/RHEL command</button>
|
||||
</details>
|
||||
<details>
|
||||
<summary id="bsd">BSD (FreeBSD / OpenBSD)</summary>
|
||||
<code id="bsdCmd">fetch -o /tmp/pikit-ca.crt http://pikit.local/assets/pikit-ca.crt && sudo install -m644 /tmp/pikit-ca.crt /usr/local/share/certs/pikit-ca.crt && sudo certctl rehash</code>
|
||||
<button class="copy" data-target="bsdCmd">Copy</button>
|
||||
</details>
|
||||
<div id="copyStatus" class="copy-status" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
|
||||
<section class="actions">
|
||||
<button id="continueBtn">Go to secure dashboard</button>
|
||||
</section>
|
||||
|
||||
|
||||
<p class="footnote">Once trusted, this page will auto-forward you to the secure dashboard.</p>
|
||||
<p class="footnote">If the hostname ever fails, try http://<pi-kit ip>/ (or https://<pi-kit ip>/ — your browser will show the same warning to bypass).</p>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const host = location.hostname || "pikit.local";
|
||||
const target = `https://${host}`;
|
||||
const hasCookie = document.cookie.includes("pikit_https_ok=1");
|
||||
const statusChip = document.getElementById("statusChip");
|
||||
const copyStatus = document.getElementById("copyStatus");
|
||||
const downloadCa = document.getElementById("downloadCa");
|
||||
const caHash = document.getElementById("caHash");
|
||||
const caUrl = `http://${host}/assets/pikit-ca.crt`;
|
||||
|
||||
if (downloadCa) downloadCa.href = caUrl;
|
||||
|
||||
const cmdTemplates = {
|
||||
archCmd: `curl -s ${caUrl} -o /tmp/pikit-ca.crt && sudo install -m644 /tmp/pikit-ca.crt /etc/ca-certificates/trust-source/anchors/ && sudo trust extract-compat`,
|
||||
debCmd: `curl -s ${caUrl} -o /tmp/pikit-ca.crt && sudo cp /tmp/pikit-ca.crt /usr/local/share/ca-certificates/pikit-ca.crt && sudo update-ca-certificates`,
|
||||
fedoraCmd: `curl -s ${caUrl} -o /tmp/pikit-ca.crt && sudo cp /tmp/pikit-ca.crt /etc/pki/ca-trust/source/anchors/pikit-ca.crt && sudo update-ca-trust`,
|
||||
bsdCmd: `fetch -o /tmp/pikit-ca.crt ${caUrl} && sudo install -m644 /tmp/pikit-ca.crt /usr/local/share/certs/pikit-ca.crt && sudo certctl rehash`,
|
||||
};
|
||||
|
||||
Object.entries(cmdTemplates).forEach(([id, cmd]) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = cmd;
|
||||
});
|
||||
|
||||
async function fetchText(url) {
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
if (!res.ok) return "";
|
||||
return (await res.text()).trim();
|
||||
}
|
||||
|
||||
async function fetchCaHashFromApi() {
|
||||
const res = await fetch("/api/firstboot", { cache: "no-store" });
|
||||
if (!res.ok) return "";
|
||||
const data = await res.json();
|
||||
return data?.ca_hash || "";
|
||||
}
|
||||
|
||||
async function loadCaHash(retries = 10) {
|
||||
if (!caHash) return;
|
||||
try {
|
||||
const assetHash = await fetchText("/assets/pikit-ca.sha256");
|
||||
if (assetHash) {
|
||||
caHash.textContent = assetHash.split(/\s+/)[0];
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
// ignore and try API
|
||||
}
|
||||
|
||||
try {
|
||||
const apiHash = await fetchCaHashFromApi();
|
||||
if (apiHash) {
|
||||
caHash.textContent = apiHash;
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
// fall through
|
||||
}
|
||||
|
||||
if (retries > 0) {
|
||||
caHash.textContent = "Generating...";
|
||||
setTimeout(() => loadCaHash(retries - 1), 2000);
|
||||
} else {
|
||||
caHash.textContent = "Unavailable";
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("continueBtn").addEventListener("click", () => {
|
||||
window.location = target;
|
||||
});
|
||||
|
||||
document.querySelectorAll(".copy").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const id = btn.dataset.target;
|
||||
const el = document.getElementById(id);
|
||||
const text = el.textContent.trim();
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
const original = btn.dataset.label || btn.textContent;
|
||||
btn.textContent = "Copied";
|
||||
copyStatus.textContent = "Copied to clipboard.";
|
||||
setTimeout(() => {
|
||||
btn.textContent = original;
|
||||
copyStatus.textContent = "";
|
||||
}, 1600);
|
||||
} catch (err) {
|
||||
const original = btn.dataset.label || btn.textContent;
|
||||
btn.textContent = "Failed";
|
||||
copyStatus.textContent = "Copy failed. Try manual copy.";
|
||||
setTimeout(() => (btn.textContent = original), 1500);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Accordion: keep only one platform section open at a time
|
||||
const detailBlocks = Array.from(document.querySelectorAll("details"));
|
||||
detailBlocks.forEach((d) => {
|
||||
d.addEventListener("toggle", () => {
|
||||
if (!d.open) return;
|
||||
detailBlocks.forEach((other) => {
|
||||
if (other !== d) other.removeAttribute("open");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// No auto-open: let users expand the platform they need.
|
||||
|
||||
if (hasCookie) {
|
||||
statusChip.textContent = "HTTPS trusted — redirecting…";
|
||||
window.location = target;
|
||||
} else {
|
||||
statusChip.textContent = "You’re on HTTP — trust the CA or click Go to secure dashboard.";
|
||||
}
|
||||
|
||||
loadCaHash();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
276
pikit-web/onboarding/style.css
Normal file
276
pikit-web/onboarding/style.css
Normal file
@@ -0,0 +1,276 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #070b15;
|
||||
--panel: rgba(13, 18, 28, 0.9);
|
||||
--panel-2: rgba(18, 26, 40, 0.7);
|
||||
--text: #e9f0ff;
|
||||
--muted: #9bb0ca;
|
||||
--accent: #44d392;
|
||||
--accent-2: #6cc9ff;
|
||||
--border: #1b2538;
|
||||
--shadow: 0 20px 60px rgba(0, 0, 0, 0.45);
|
||||
--glow: 0 20px 70px rgba(68, 211, 146, 0.14);
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 32px 22px;
|
||||
background: radial-gradient(140% 140% at 12% 18%, #0f1625, #080c14 58%);
|
||||
color: var(--text);
|
||||
font-family: "DM Sans", "Inter", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.card {
|
||||
max-width: 920px;
|
||||
width: 100%;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
padding: 28px 30px 32px;
|
||||
box-shadow: var(--shadow), var(--glow);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.7rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 10px 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.welcome {
|
||||
font-size: 1.05rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.status-row {
|
||||
margin: 6px 0 10px;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(68, 211, 146, 0.08);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 14px var(--accent);
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin: 10px 0 4px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.badge .dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 18px auto 14px;
|
||||
width: min(360px, 100%);
|
||||
}
|
||||
|
||||
button,
|
||||
a.ghost {
|
||||
border: 1px solid var(--border);
|
||||
background: linear-gradient(135deg, var(--accent), #2dbb7b);
|
||||
color: #041008;
|
||||
padding: 12px 18px;
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 10px 30px rgba(68, 211, 146, 0.22);
|
||||
transition: transform 0.1s ease, box-shadow 0.2s ease, filter 0.2s ease;
|
||||
}
|
||||
|
||||
button:hover,
|
||||
a.ghost:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 12px 36px rgba(68, 211, 146, 0.3);
|
||||
filter: brightness(1.02);
|
||||
}
|
||||
|
||||
button.ghost,
|
||||
a.ghost {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text);
|
||||
box-shadow: none;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ca-download {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.download-inline {
|
||||
width: auto;
|
||||
padding: 8px 12px;
|
||||
font-size: 0.95rem;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.checksum {
|
||||
margin: 4px 0 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
code.inline {
|
||||
display: inline;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.qr-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-top: 8px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
width: min(360px, 100%);
|
||||
}
|
||||
|
||||
.qr-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
|
||||
.qr-title {
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tiny {
|
||||
margin: 2px 0 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.copy-status {
|
||||
min-height: 18px;
|
||||
color: var(--text);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
button.copy {
|
||||
margin-left: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
padding: 7px 11px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.steps {
|
||||
padding: 14px 15px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.steps h3 {
|
||||
margin: 0 0 8px;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 6px 0 6px 18px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
code {
|
||||
display: block;
|
||||
background: #0b111c;
|
||||
border: 1px solid var(--border);
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
margin-top: 6px;
|
||||
color: var(--text);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.footnote {
|
||||
font-size: 0.93rem;
|
||||
color: var(--muted);
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.subtle {
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
0
pikit-web/public/favicon.ico
Normal file
0
pikit-web/public/favicon.ico
Normal file
11
pikit_api/__init__.py
Normal file
11
pikit_api/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Pi-Kit API package
|
||||
|
||||
This package splits the monolithic `pikit-api.py` script into small, testable
|
||||
modules while keeping the on-device entry point compatible.
|
||||
"""
|
||||
|
||||
# Re-export commonly used helpers for convenience
|
||||
from .constants import HOST, PORT # noqa: F401
|
||||
from .server import run_server # noqa: F401
|
||||
from .releases import apply_update, check_for_update # noqa: F401
|
||||
239
pikit_api/auto_updates.py
Normal file
239
pikit_api/auto_updates.py
Normal file
@@ -0,0 +1,239 @@
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .constants import (
|
||||
ALL_PATTERNS,
|
||||
APT_AUTO_CFG,
|
||||
APT_UA_BASE,
|
||||
APT_UA_OVERRIDE,
|
||||
DEFAULT_UPDATE_TIME,
|
||||
DEFAULT_UPGRADE_TIME,
|
||||
SECURITY_PATTERNS,
|
||||
)
|
||||
from .helpers import strip_comments, validate_time
|
||||
|
||||
|
||||
def auto_updates_enabled() -> bool:
|
||||
if not APT_AUTO_CFG.exists():
|
||||
return False
|
||||
text = APT_AUTO_CFG.read_text()
|
||||
return 'APT::Periodic::Unattended-Upgrade "1";' in text
|
||||
|
||||
|
||||
def set_auto_updates(enable: bool) -> None:
|
||||
"""
|
||||
Toggle unattended upgrades in a way that matches systemd state, not just the
|
||||
apt config file. Assumes unattended-upgrades is already installed.
|
||||
"""
|
||||
units_maskable = [
|
||||
"apt-daily.service",
|
||||
"apt-daily-upgrade.service",
|
||||
"apt-daily.timer",
|
||||
"apt-daily-upgrade.timer",
|
||||
"unattended-upgrades.service",
|
||||
]
|
||||
timers = ["apt-daily.timer", "apt-daily-upgrade.timer"]
|
||||
service = "unattended-upgrades.service"
|
||||
|
||||
APT_AUTO_CFG.parent.mkdir(parents=True, exist_ok=True)
|
||||
if enable:
|
||||
APT_AUTO_CFG.write_text(
|
||||
'APT::Periodic::Update-Package-Lists "1";\n'
|
||||
'APT::Periodic::Unattended-Upgrade "1";\n'
|
||||
)
|
||||
for unit in units_maskable:
|
||||
subprocess.run(["systemctl", "unmask", unit], check=False)
|
||||
for unit in timers + [service]:
|
||||
subprocess.run(["systemctl", "enable", unit], check=False)
|
||||
for unit in timers:
|
||||
subprocess.run(["systemctl", "start", unit], check=False)
|
||||
subprocess.run(["systemctl", "start", service], check=False)
|
||||
else:
|
||||
APT_AUTO_CFG.write_text(
|
||||
'APT::Periodic::Update-Package-Lists "0";\n'
|
||||
'APT::Periodic::Unattended-Upgrade "0";\n'
|
||||
)
|
||||
for unit in timers + [service]:
|
||||
subprocess.run(["systemctl", "stop", unit], check=False)
|
||||
subprocess.run(["systemctl", "disable", unit], check=False)
|
||||
for unit in units_maskable:
|
||||
subprocess.run(["systemctl", "mask", unit], check=False)
|
||||
|
||||
|
||||
def _systemctl_is(unit: str, verb: str) -> bool:
|
||||
try:
|
||||
out = subprocess.check_output(["systemctl", verb, unit], text=True).strip()
|
||||
return out == "enabled" if verb == "is-enabled" else out == "active"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def auto_updates_state() -> Dict[str, Any]:
|
||||
config_on = auto_updates_enabled()
|
||||
service = "unattended-upgrades.service"
|
||||
timers = ["apt-daily.timer", "apt-daily-upgrade.timer"]
|
||||
state: Dict[str, Any] = {
|
||||
"config_enabled": config_on,
|
||||
"service_enabled": _systemctl_is(service, "is-enabled"),
|
||||
"service_active": _systemctl_is(service, "is-active"),
|
||||
"timers_enabled": {},
|
||||
"timers_active": {},
|
||||
}
|
||||
for t in timers:
|
||||
state["timers_enabled"][t] = _systemctl_is(t, "is-enabled")
|
||||
state["timers_active"][t] = _systemctl_is(t, "is-active")
|
||||
state["enabled"] = (
|
||||
config_on
|
||||
and state["service_enabled"]
|
||||
and all(state["timers_enabled"].values())
|
||||
)
|
||||
return state
|
||||
|
||||
|
||||
def _parse_directive(text: str, key: str, default=None, as_bool=False, as_int=False):
|
||||
text = strip_comments(text)
|
||||
pattern = rf'{re.escape(key)}\s+"?([^";\n]+)"?;'
|
||||
m = re.search(pattern, text)
|
||||
if not m:
|
||||
return default
|
||||
val = m.group(1).strip()
|
||||
if as_bool:
|
||||
return val.lower() in ("1", "true", "yes", "on")
|
||||
if as_int:
|
||||
try:
|
||||
return int(val)
|
||||
except ValueError:
|
||||
return default
|
||||
return val
|
||||
|
||||
|
||||
def _parse_origins_patterns(text: str):
|
||||
text = strip_comments(text)
|
||||
m = re.search(r"Unattended-Upgrade::Origins-Pattern\s*{([^}]*)}", text, re.S)
|
||||
patterns = []
|
||||
if not m:
|
||||
return patterns
|
||||
body = m.group(1)
|
||||
for line in body.splitlines():
|
||||
ln = line.strip().strip('";')
|
||||
if ln:
|
||||
patterns.append(ln)
|
||||
return patterns
|
||||
|
||||
|
||||
def _read_timer_time(timer: str):
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["systemctl", "show", "--property=TimersCalendar", timer], text=True
|
||||
)
|
||||
m = re.search(r"OnCalendar=[^0-9]*([0-9]{1,2}):([0-9]{2})", out)
|
||||
if m:
|
||||
return f"{int(m.group(1)):02d}:{m.group(2)}"
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def read_updates_config(state=None) -> Dict[str, Any]:
|
||||
"""
|
||||
Return a normalized unattended-upgrades configuration snapshot.
|
||||
Values are sourced from the Pi-Kit override file when present, else the base file.
|
||||
"""
|
||||
text = ""
|
||||
for path in (APT_UA_OVERRIDE, APT_UA_BASE):
|
||||
if path.exists():
|
||||
try:
|
||||
text += path.read_text() + "\n"
|
||||
except Exception:
|
||||
pass
|
||||
scope_hint = None
|
||||
m_scope = re.search(r"PIKIT_SCOPE:\s*(\w+)", text)
|
||||
if m_scope:
|
||||
scope_hint = m_scope.group(1).lower()
|
||||
cleaned = strip_comments(text)
|
||||
patterns = _parse_origins_patterns(cleaned)
|
||||
scope = (
|
||||
scope_hint
|
||||
or ("all" if any("label=Debian" in p and "-security" not in p for p in patterns) else "security")
|
||||
)
|
||||
cleanup = _parse_directive(text, "Unattended-Upgrade::Remove-Unused-Dependencies", False, as_bool=True)
|
||||
auto_reboot = _parse_directive(text, "Unattended-Upgrade::Automatic-Reboot", False, as_bool=True)
|
||||
reboot_time = validate_time(_parse_directive(text, "Unattended-Upgrade::Automatic-Reboot-Time", DEFAULT_UPGRADE_TIME), DEFAULT_UPGRADE_TIME)
|
||||
reboot_with_users = _parse_directive(text, "Unattended-Upgrade::Automatic-Reboot-WithUsers", False, as_bool=True)
|
||||
bandwidth = _parse_directive(text, "Acquire::http::Dl-Limit", None, as_int=True)
|
||||
|
||||
update_time = _read_timer_time("apt-daily.timer") or DEFAULT_UPDATE_TIME
|
||||
upgrade_time = _read_timer_time("apt-daily-upgrade.timer") or DEFAULT_UPGRADE_TIME
|
||||
|
||||
state = state or auto_updates_state()
|
||||
return {
|
||||
"enabled": bool(state.get("enabled", False)),
|
||||
"scope": scope,
|
||||
"cleanup": bool(cleanup),
|
||||
"bandwidth_limit_kbps": bandwidth,
|
||||
"auto_reboot": bool(auto_reboot),
|
||||
"reboot_time": reboot_time,
|
||||
"reboot_with_users": bool(reboot_with_users),
|
||||
"update_time": update_time,
|
||||
"upgrade_time": upgrade_time,
|
||||
"state": state,
|
||||
}
|
||||
|
||||
|
||||
def _write_timer_override(timer: str, time_str: str):
|
||||
time_norm = validate_time(time_str, DEFAULT_UPDATE_TIME)
|
||||
override_dir = pathlib.Path(f"/etc/systemd/system/{timer}.d")
|
||||
override_dir.mkdir(parents=True, exist_ok=True)
|
||||
override_file = override_dir / "pikit.conf"
|
||||
override_file.write_text(
|
||||
"[Timer]\n"
|
||||
f"OnCalendar=*-*-* {time_norm}\n"
|
||||
"Persistent=true\n"
|
||||
"RandomizedDelaySec=30min\n"
|
||||
)
|
||||
subprocess.run(["systemctl", "daemon-reload"], check=False)
|
||||
subprocess.run(["systemctl", "restart", timer], check=False)
|
||||
|
||||
|
||||
def set_updates_config(opts: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Apply unattended-upgrades configuration from dashboard inputs.
|
||||
"""
|
||||
enable = bool(opts.get("enable", True))
|
||||
scope = opts.get("scope") or "all"
|
||||
patterns = ALL_PATTERNS if scope == "all" else SECURITY_PATTERNS
|
||||
cleanup = bool(opts.get("cleanup", False))
|
||||
bandwidth = opts.get("bandwidth_limit_kbps")
|
||||
auto_reboot = bool(opts.get("auto_reboot", False))
|
||||
reboot_time = validate_time(opts.get("reboot_time"), DEFAULT_UPGRADE_TIME)
|
||||
reboot_with_users = bool(opts.get("reboot_with_users", False))
|
||||
update_time = validate_time(opts.get("update_time"), DEFAULT_UPDATE_TIME)
|
||||
upgrade_time = validate_time(opts.get("upgrade_time") or opts.get("update_time"), DEFAULT_UPGRADE_TIME)
|
||||
|
||||
APT_AUTO_CFG.parent.mkdir(parents=True, exist_ok=True)
|
||||
set_auto_updates(enable)
|
||||
|
||||
lines = [
|
||||
"// Managed by Pi-Kit dashboard",
|
||||
f"// PIKIT_SCOPE: {scope}",
|
||||
"Unattended-Upgrade::Origins-Pattern {",
|
||||
]
|
||||
for p in patterns:
|
||||
lines.append(f' "{p}";')
|
||||
lines.append("};")
|
||||
lines.append(f'Unattended-Upgrade::Remove-Unused-Dependencies {"true" if cleanup else "false"};')
|
||||
lines.append(f'Unattended-Upgrade::Automatic-Reboot {"true" if auto_reboot else "false"};')
|
||||
lines.append(f'Unattended-Upgrade::Automatic-Reboot-Time "{reboot_time}";')
|
||||
lines.append(
|
||||
f'Unattended-Upgrade::Automatic-Reboot-WithUsers {"true" if reboot_with_users else "false"};'
|
||||
)
|
||||
if bandwidth is not None:
|
||||
lines.append(f'Acquire::http::Dl-Limit "{int(bandwidth)}";')
|
||||
APT_UA_OVERRIDE.parent.mkdir(parents=True, exist_ok=True)
|
||||
APT_UA_OVERRIDE.write_text("\n".join(lines) + "\n")
|
||||
|
||||
_write_timer_override("apt-daily.timer", update_time)
|
||||
_write_timer_override("apt-daily-upgrade.timer", upgrade_time)
|
||||
return read_updates_config()
|
||||
79
pikit_api/constants.py
Normal file
79
pikit_api/constants.py
Normal file
@@ -0,0 +1,79 @@
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
# Network
|
||||
HOST = "127.0.0.1"
|
||||
PORT = 4000
|
||||
|
||||
# Paths / files
|
||||
SERVICE_JSON = pathlib.Path("/etc/pikit/services.json")
|
||||
RESET_LOG = pathlib.Path("/var/log/pikit-reset.log")
|
||||
API_LOG = pathlib.Path("/var/log/pikit-api.log")
|
||||
READY_FILE = pathlib.Path("/var/run/pikit-ready")
|
||||
VERSION_FILE = pathlib.Path("/etc/pikit/version")
|
||||
WEB_VERSION_FILE = pathlib.Path("/var/www/pikit-web/data/version.json")
|
||||
UPDATE_STATE_DIR = pathlib.Path("/var/lib/pikit-update")
|
||||
UPDATE_STATE = UPDATE_STATE_DIR / "state.json"
|
||||
UPDATE_LOCK = pathlib.Path("/var/run/pikit-update.lock")
|
||||
WEB_ROOT = pathlib.Path("/var/www/pikit-web")
|
||||
API_PATH = pathlib.Path("/usr/local/bin/pikit-api.py")
|
||||
API_DIR = API_PATH.parent
|
||||
API_PACKAGE_DIR = API_DIR / "pikit_api"
|
||||
BACKUP_ROOT = pathlib.Path("/var/backups/pikit")
|
||||
TMP_ROOT = pathlib.Path("/var/tmp/pikit-update")
|
||||
|
||||
# First-boot state
|
||||
FIRSTBOOT_DIR = pathlib.Path("/var/lib/pikit/firstboot")
|
||||
FIRSTBOOT_STATE = FIRSTBOOT_DIR / "state.json"
|
||||
FIRSTBOOT_LOG = FIRSTBOOT_DIR / "firstboot.log"
|
||||
FIRSTBOOT_ERROR = FIRSTBOOT_DIR / "firstboot.error"
|
||||
FIRSTBOOT_DONE = FIRSTBOOT_DIR / "firstboot.done"
|
||||
|
||||
# Apt / unattended-upgrades
|
||||
APT_AUTO_CFG = pathlib.Path("/etc/apt/apt.conf.d/20auto-upgrades")
|
||||
APT_UA_BASE = pathlib.Path("/etc/apt/apt.conf.d/50unattended-upgrades")
|
||||
APT_UA_OVERRIDE = pathlib.Path("/etc/apt/apt.conf.d/51pikit-unattended.conf")
|
||||
DEFAULT_UPDATE_TIME = "04:00"
|
||||
DEFAULT_UPGRADE_TIME = "04:30"
|
||||
SECURITY_PATTERNS = [
|
||||
"origin=Debian,codename=${distro_codename},label=Debian-Security",
|
||||
"origin=Debian,codename=${distro_codename}-security,label=Debian-Security",
|
||||
]
|
||||
ALL_PATTERNS = [
|
||||
"origin=Debian,codename=${distro_codename},label=Debian",
|
||||
*SECURITY_PATTERNS,
|
||||
]
|
||||
|
||||
# Release updater
|
||||
DEFAULT_MANIFEST_URL = os.environ.get(
|
||||
"PIKIT_MANIFEST_URL",
|
||||
# Stable manifest (raw in repo, public)
|
||||
"https://git.44r0n.cc/44r0n7/pi-kit/raw/branch/main/manifests/manifest-stable.json",
|
||||
)
|
||||
DEFAULT_DEV_MANIFEST_URL = os.environ.get(
|
||||
"PIKIT_DEV_MANIFEST_URL",
|
||||
# Dev manifest (raw in repo, public)
|
||||
"https://git.44r0n.cc/44r0n7/pi-kit/raw/branch/main/manifests/manifest-dev.json",
|
||||
)
|
||||
AUTH_TOKEN = os.environ.get("PIKIT_AUTH_TOKEN")
|
||||
|
||||
# Flags / ports
|
||||
DEBUG_FLAG = pathlib.Path("/boot/pikit-debug").exists()
|
||||
HTTPS_PORTS = {443, 5252}
|
||||
CORE_PORTS = {80}
|
||||
CORE_NAME = "Pi-Kit Dashboard"
|
||||
|
||||
# Diagnostics (RAM backed where available)
|
||||
DIAG_STATE_FILE = (
|
||||
pathlib.Path("/dev/shm/pikit-diag.state")
|
||||
if pathlib.Path("/dev/shm").exists()
|
||||
else pathlib.Path("/tmp/pikit-diag.state")
|
||||
)
|
||||
DIAG_LOG_FILE = (
|
||||
pathlib.Path("/dev/shm/pikit-diag.log")
|
||||
if pathlib.Path("/dev/shm").exists()
|
||||
else pathlib.Path("/tmp/pikit-diag.log")
|
||||
)
|
||||
DIAG_MAX_BYTES = 1_048_576 # 1 MiB cap in RAM
|
||||
DIAG_MAX_ENTRY_CHARS = 2048
|
||||
DIAG_DEFAULT_STATE = {"enabled": False, "level": "normal"} # level: normal|debug
|
||||
117
pikit_api/diagnostics.py
Normal file
117
pikit_api/diagnostics.py
Normal file
@@ -0,0 +1,117 @@
|
||||
import datetime
|
||||
import io
|
||||
import json
|
||||
import pathlib
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .constants import (
|
||||
API_LOG,
|
||||
DEBUG_FLAG,
|
||||
DIAG_DEFAULT_STATE,
|
||||
DIAG_LOG_FILE,
|
||||
DIAG_MAX_BYTES,
|
||||
DIAG_MAX_ENTRY_CHARS,
|
||||
DIAG_STATE_FILE,
|
||||
)
|
||||
|
||||
_diag_state: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
def _load_diag_state() -> Dict[str, Any]:
|
||||
"""Load diagnostics state from RAM-backed storage when available."""
|
||||
global _diag_state
|
||||
if _diag_state is not None:
|
||||
return _diag_state
|
||||
try:
|
||||
if DIAG_STATE_FILE.exists():
|
||||
_diag_state = json.loads(DIAG_STATE_FILE.read_text())
|
||||
return _diag_state
|
||||
except Exception:
|
||||
pass
|
||||
_diag_state = DIAG_DEFAULT_STATE.copy()
|
||||
return _diag_state
|
||||
|
||||
|
||||
def _save_diag_state(enabled=None, level=None) -> Dict[str, Any]:
|
||||
"""Persist diagnostics state; tolerate failures silently."""
|
||||
state = _load_diag_state()
|
||||
if enabled is not None:
|
||||
state["enabled"] = bool(enabled)
|
||||
if level in ("normal", "debug"):
|
||||
state["level"] = level
|
||||
try:
|
||||
DIAG_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
DIAG_STATE_FILE.write_text(json.dumps(state))
|
||||
except Exception:
|
||||
pass
|
||||
return state
|
||||
|
||||
|
||||
def diag_log(level: str, message: str, meta: Optional[dict] = None) -> None:
|
||||
"""
|
||||
Append a diagnostic log line to RAM-backed file.
|
||||
Skips when disabled or when debug level is off.
|
||||
"""
|
||||
state = _load_diag_state()
|
||||
if not state.get("enabled"):
|
||||
return
|
||||
if level == "debug" and state.get("level") != "debug":
|
||||
return
|
||||
try:
|
||||
ts = datetime.datetime.utcnow().isoformat() + "Z"
|
||||
entry = {"ts": ts, "level": level, "msg": message}
|
||||
if meta:
|
||||
entry["meta"] = meta
|
||||
line = json.dumps(entry, separators=(",", ":"))
|
||||
if len(line) > DIAG_MAX_ENTRY_CHARS:
|
||||
entry.pop("meta", None)
|
||||
entry["msg"] = (message or "")[: DIAG_MAX_ENTRY_CHARS - 40] + "…"
|
||||
line = json.dumps(entry, separators=(",", ":"))
|
||||
line_bytes = (line + "\n").encode()
|
||||
DIAG_LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with DIAG_LOG_FILE.open("ab") as f:
|
||||
f.write(line_bytes)
|
||||
if DIAG_LOG_FILE.stat().st_size > DIAG_MAX_BYTES:
|
||||
with DIAG_LOG_FILE.open("rb") as f:
|
||||
f.seek(-DIAG_MAX_BYTES, io.SEEK_END)
|
||||
tail = f.read()
|
||||
if b"\n" in tail:
|
||||
tail = tail.split(b"\n", 1)[1]
|
||||
with DIAG_LOG_FILE.open("wb") as f:
|
||||
f.write(tail)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def diag_read(limit: int = 500) -> List[dict]:
|
||||
"""Return latest log entries (dicts), newest first."""
|
||||
if not DIAG_LOG_FILE.exists():
|
||||
return []
|
||||
try:
|
||||
data = DIAG_LOG_FILE.read_bytes()
|
||||
except Exception:
|
||||
return []
|
||||
lines = data.splitlines()[-limit:]
|
||||
out: List[dict] = []
|
||||
for line in lines:
|
||||
try:
|
||||
out.append(json.loads(line.decode("utf-8", errors="ignore")))
|
||||
except Exception:
|
||||
continue
|
||||
return out[::-1]
|
||||
|
||||
|
||||
def dbg(msg: str) -> None:
|
||||
"""
|
||||
Lightweight debug logger for legacy /boot/pikit-debug flag.
|
||||
Mirrors into diagnostics log when enabled.
|
||||
"""
|
||||
if DEBUG_FLAG:
|
||||
API_LOG.parent.mkdir(parents=True, exist_ok=True)
|
||||
ts = datetime.datetime.utcnow().isoformat()
|
||||
with API_LOG.open("a") as f:
|
||||
f.write(f"[{ts}] {msg}\n")
|
||||
try:
|
||||
diag_log("debug", msg)
|
||||
except Exception:
|
||||
pass
|
||||
100
pikit_api/firstboot.py
Normal file
100
pikit_api/firstboot.py
Normal file
@@ -0,0 +1,100 @@
|
||||
import json
|
||||
import pathlib
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .constants import FIRSTBOOT_DIR, FIRSTBOOT_DONE, FIRSTBOOT_ERROR, FIRSTBOOT_LOG, FIRSTBOOT_STATE, WEB_ROOT
|
||||
from .helpers import ensure_dir, sha256_file
|
||||
|
||||
DEFAULT_STEPS = [
|
||||
"Preparing system",
|
||||
"Generating security keys",
|
||||
"Securing the dashboard",
|
||||
"Updating software (this can take a while)",
|
||||
"Final checks",
|
||||
"Starting Pi-Kit",
|
||||
]
|
||||
|
||||
|
||||
def _tail_text(path: pathlib.Path, max_lines: int = 200) -> str:
|
||||
if not path.exists():
|
||||
return ""
|
||||
try:
|
||||
text = path.read_text(errors="ignore")
|
||||
except Exception:
|
||||
return ""
|
||||
lines = text.splitlines()
|
||||
if len(lines) > max_lines:
|
||||
lines = lines[-max_lines:]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _normalize_steps(raw_steps: Optional[List[Dict[str, Any]]], state: str) -> List[Dict[str, Any]]:
|
||||
steps: List[Dict[str, Any]] = []
|
||||
if raw_steps:
|
||||
for entry in raw_steps:
|
||||
label = (entry or {}).get("label") or (entry or {}).get("name")
|
||||
if not label:
|
||||
continue
|
||||
status = (entry or {}).get("status") or "pending"
|
||||
steps.append({"label": str(label), "status": str(status)})
|
||||
|
||||
if not steps:
|
||||
steps = [{"label": label, "status": "pending"} for label in DEFAULT_STEPS]
|
||||
|
||||
if state == "done":
|
||||
for step in steps:
|
||||
step["status"] = "done"
|
||||
return steps
|
||||
|
||||
|
||||
def _current_step(steps: List[Dict[str, Any]]) -> Optional[str]:
|
||||
for step in steps:
|
||||
if step.get("status") in ("current", "running", "error"):
|
||||
return step.get("label")
|
||||
return None
|
||||
|
||||
|
||||
def _load_state_file() -> Dict[str, Any]:
|
||||
if FIRSTBOOT_STATE.exists():
|
||||
try:
|
||||
return json.loads(FIRSTBOOT_STATE.read_text())
|
||||
except Exception:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def read_firstboot_status() -> Dict[str, Any]:
|
||||
ensure_dir(FIRSTBOOT_DIR)
|
||||
state_file = _load_state_file()
|
||||
|
||||
if FIRSTBOOT_ERROR.exists():
|
||||
state = "error"
|
||||
elif FIRSTBOOT_DONE.exists():
|
||||
state = "done"
|
||||
else:
|
||||
state = state_file.get("state") or "running"
|
||||
if state not in ("running", "done", "error"):
|
||||
state = "running"
|
||||
|
||||
steps = _normalize_steps(state_file.get("steps"), state)
|
||||
current_step = state_file.get("current_step") or _current_step(steps)
|
||||
|
||||
ca_path = WEB_ROOT / "assets" / "pikit-ca.crt"
|
||||
ca_hash = sha256_file(ca_path) if ca_path.exists() else None
|
||||
|
||||
return {
|
||||
"state": state,
|
||||
"steps": steps,
|
||||
"current_step": current_step,
|
||||
"log_tail": _tail_text(FIRSTBOOT_LOG, 200),
|
||||
"error_present": FIRSTBOOT_ERROR.exists(),
|
||||
"error_path": "/api/firstboot/error",
|
||||
"ca_hash": ca_hash,
|
||||
"ca_url": "/assets/pikit-ca.crt",
|
||||
}
|
||||
|
||||
|
||||
def read_firstboot_error(max_lines: int = 200) -> Dict[str, Any]:
|
||||
if not FIRSTBOOT_ERROR.exists():
|
||||
return {"present": False, "text": ""}
|
||||
return {"present": True, "text": _tail_text(FIRSTBOOT_ERROR, max_lines)}
|
||||
80
pikit_api/helpers.py
Normal file
80
pikit_api/helpers.py
Normal file
@@ -0,0 +1,80 @@
|
||||
import hashlib
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import socket
|
||||
from typing import Optional
|
||||
|
||||
from .constants import HTTPS_PORTS
|
||||
|
||||
|
||||
def ensure_dir(path: pathlib.Path) -> None:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def sha256_file(path: pathlib.Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def normalize_path(path: Optional[str]) -> str:
|
||||
"""Normalize optional service path. Empty -> ''. Ensure leading slash."""
|
||||
if not path:
|
||||
return ""
|
||||
p = str(path).strip()
|
||||
if not p:
|
||||
return ""
|
||||
if not p.startswith("/"):
|
||||
p = "/" + p
|
||||
return p
|
||||
|
||||
|
||||
def default_host() -> str:
|
||||
"""Return preferred hostname (append .local if bare)."""
|
||||
host = socket.gethostname()
|
||||
if "." not in host:
|
||||
host = f"{host}.local"
|
||||
return host
|
||||
|
||||
|
||||
def detect_https(host: str, port: int) -> bool:
|
||||
"""Heuristic: known HTTPS ports or .local certs."""
|
||||
return int(port) in HTTPS_PORTS or host.lower().endswith(".local") or host.lower() == "pikit"
|
||||
|
||||
|
||||
def port_online(host: str, port: int) -> bool:
|
||||
try:
|
||||
with socket.create_connection((host, int(port)), timeout=1.5):
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def reboot_required() -> bool:
|
||||
return pathlib.Path("/run/reboot-required").exists()
|
||||
|
||||
|
||||
def strip_comments(text: str) -> str:
|
||||
"""Remove // and # line comments for safer parsing."""
|
||||
lines = []
|
||||
for ln in text.splitlines():
|
||||
l = ln.strip()
|
||||
if l.startswith("//") or l.startswith("#"):
|
||||
continue
|
||||
lines.append(ln)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def validate_time(val: str, default: str) -> str:
|
||||
if not val:
|
||||
return default
|
||||
m = re.match(r"^(\d{1,2}):(\d{2})$", val.strip())
|
||||
if not m:
|
||||
return default
|
||||
h, mi = int(m.group(1)), int(m.group(2))
|
||||
if 0 <= h < 24 and 0 <= mi < 60:
|
||||
return f"{h:02d}:{mi:02d}"
|
||||
return default
|
||||
313
pikit_api/http_handlers.py
Normal file
313
pikit_api/http_handlers.py
Normal file
@@ -0,0 +1,313 @@
|
||||
import json
|
||||
import urllib.parse
|
||||
from http.server import BaseHTTPRequestHandler
|
||||
|
||||
from .auto_updates import auto_updates_state, read_updates_config, set_auto_updates, set_updates_config
|
||||
from .constants import CORE_NAME, CORE_PORTS, DIAG_LOG_FILE
|
||||
from .diagnostics import _load_diag_state, _save_diag_state, dbg, diag_log, diag_read
|
||||
from .firstboot import read_firstboot_error, read_firstboot_status
|
||||
from .helpers import default_host, detect_https, normalize_path
|
||||
from .releases import (
|
||||
check_for_update,
|
||||
fetch_manifest,
|
||||
fetch_text_with_auth,
|
||||
load_update_state,
|
||||
read_current_version,
|
||||
save_update_state,
|
||||
start_background_task,
|
||||
list_available_releases,
|
||||
apply_update_version,
|
||||
)
|
||||
from .services import (
|
||||
FirewallToolMissing,
|
||||
allow_port_lan,
|
||||
factory_reset,
|
||||
load_services,
|
||||
remove_port_lan,
|
||||
save_services,
|
||||
ufw_status_allows,
|
||||
)
|
||||
from .status import collect_status, list_services_for_ui
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
"""JSON API for the dashboard (status, services, updates, reset)."""
|
||||
|
||||
def _send(self, code, data):
|
||||
body = json.dumps(data).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
return
|
||||
|
||||
# GET endpoints
|
||||
def do_GET(self):
|
||||
if self.path.startswith("/api/status"):
|
||||
return self._send(200, collect_status())
|
||||
|
||||
if self.path.startswith("/api/firstboot/error"):
|
||||
return self._send(200, read_firstboot_error())
|
||||
|
||||
if self.path.startswith("/api/firstboot"):
|
||||
return self._send(200, read_firstboot_status())
|
||||
|
||||
if self.path.startswith("/api/services"):
|
||||
return self._send(200, {"services": list_services_for_ui()})
|
||||
|
||||
if self.path.startswith("/api/updates/auto"):
|
||||
state = auto_updates_state()
|
||||
return self._send(200, {"enabled": state.get("enabled", False), "details": state})
|
||||
|
||||
if self.path.startswith("/api/updates/config"):
|
||||
return self._send(200, read_updates_config())
|
||||
|
||||
if self.path.startswith("/api/update/status"):
|
||||
state = load_update_state()
|
||||
state["current_version"] = read_current_version()
|
||||
state["channel"] = state.get("channel", "dev")
|
||||
return self._send(200, state)
|
||||
|
||||
if self.path.startswith("/api/update/changelog"):
|
||||
try:
|
||||
qs = urllib.parse.urlparse(self.path).query
|
||||
params = urllib.parse.parse_qs(qs)
|
||||
url = params.get("url", [None])[0]
|
||||
if not url:
|
||||
manifest = fetch_manifest()
|
||||
url = manifest.get("changelog")
|
||||
if not url:
|
||||
return self._send(404, {"error": "no changelog url"})
|
||||
text = fetch_text_with_auth(url)
|
||||
return self._send(200, {"text": text})
|
||||
except Exception as e:
|
||||
return self._send(500, {"error": str(e)})
|
||||
|
||||
if self.path.startswith("/api/diag/log"):
|
||||
entries = diag_read()
|
||||
state = _load_diag_state()
|
||||
return self._send(200, {"entries": entries, "state": state})
|
||||
|
||||
if self.path.startswith("/api/update/releases"):
|
||||
state = load_update_state()
|
||||
channel = state.get("channel") or "stable"
|
||||
return self._send(200, {"releases": list_available_releases(channel)})
|
||||
|
||||
return self._send(404, {"error": "not found"})
|
||||
|
||||
# POST endpoints
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("Content-Length", 0))
|
||||
payload = json.loads(self.rfile.read(length) or "{}")
|
||||
|
||||
if self.path.startswith("/api/reset"):
|
||||
if payload.get("confirm") == "YES":
|
||||
self._send(200, {"message": "Resetting and rebooting..."})
|
||||
dbg("Factory reset triggered via API")
|
||||
diag_log("info", "Factory reset requested")
|
||||
factory_reset()
|
||||
else:
|
||||
self._send(400, {"error": "type YES to confirm"})
|
||||
return
|
||||
|
||||
if self.path.startswith("/api/updates/auto"):
|
||||
enable = bool(payload.get("enable"))
|
||||
set_auto_updates(enable)
|
||||
dbg(f"Auto updates set to {enable}")
|
||||
state = auto_updates_state()
|
||||
diag_log("info", "Auto updates toggled", {"enabled": enable})
|
||||
return self._send(200, {"enabled": state.get("enabled", False), "details": state})
|
||||
|
||||
if self.path.startswith("/api/updates/config"):
|
||||
try:
|
||||
cfg = set_updates_config(payload or {})
|
||||
dbg(f"Update settings applied: {cfg}")
|
||||
diag_log("info", "Update settings saved", cfg)
|
||||
return self._send(200, cfg)
|
||||
except Exception as e:
|
||||
dbg(f"Failed to apply updates config: {e}")
|
||||
diag_log("error", "Update settings save failed", {"error": str(e)})
|
||||
return self._send(500, {"error": str(e)})
|
||||
|
||||
if self.path.startswith("/api/update/check"):
|
||||
state = check_for_update()
|
||||
return self._send(200, state)
|
||||
|
||||
if self.path.startswith("/api/update/apply_version"):
|
||||
version = payload.get("version")
|
||||
if not version:
|
||||
return self._send(400, {"error": "version required"})
|
||||
state = load_update_state()
|
||||
chan = payload.get("channel") or state.get("channel") or "stable"
|
||||
result = apply_update_version(version, chan)
|
||||
return self._send(200, result)
|
||||
|
||||
if self.path.startswith("/api/update/apply"):
|
||||
start_background_task("apply")
|
||||
state = load_update_state()
|
||||
state["status"] = "in_progress"
|
||||
state["message"] = "Starting background apply"
|
||||
save_update_state(state)
|
||||
return self._send(202, state)
|
||||
|
||||
if self.path.startswith("/api/update/auto"):
|
||||
state = load_update_state()
|
||||
state["auto_check"] = bool(payload.get("enable"))
|
||||
save_update_state(state)
|
||||
diag_log("info", "Release auto-check toggled", {"enabled": state["auto_check"]})
|
||||
return self._send(200, state)
|
||||
|
||||
if self.path.startswith("/api/update/channel"):
|
||||
chan = payload.get("channel", "dev")
|
||||
if chan not in ("dev", "stable"):
|
||||
return self._send(400, {"error": "channel must be dev or stable"})
|
||||
state = load_update_state()
|
||||
state["channel"] = chan
|
||||
save_update_state(state)
|
||||
diag_log("info", "Release channel set", {"channel": chan})
|
||||
return self._send(200, state)
|
||||
|
||||
if self.path.startswith("/api/diag/log/level"):
|
||||
state = _save_diag_state(payload.get("enabled"), payload.get("level"))
|
||||
diag_log("info", "Diag level updated", state)
|
||||
return self._send(200, {"state": state})
|
||||
|
||||
if self.path.startswith("/api/diag/log/clear"):
|
||||
try:
|
||||
DIAG_LOG_FILE.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
diag_log("info", "Diag log cleared")
|
||||
return self._send(200, {"cleared": True, "state": _load_diag_state()})
|
||||
|
||||
if self.path.startswith("/api/services/add"):
|
||||
name = payload.get("name")
|
||||
port = int(payload.get("port", 0))
|
||||
if not name or not port:
|
||||
return self._send(400, {"error": "name and port required"})
|
||||
if port in CORE_PORTS and name != CORE_NAME:
|
||||
return self._send(400, {"error": f"Port {port} is reserved for {CORE_NAME}"})
|
||||
services = load_services()
|
||||
if any(s.get("port") == port for s in services):
|
||||
return self._send(400, {"error": "port already exists"})
|
||||
host = default_host()
|
||||
scheme = payload.get("scheme")
|
||||
if scheme not in ("http", "https"):
|
||||
scheme = "https" if detect_https(host, port) else "http"
|
||||
notice = (payload.get("notice") or "").strip()
|
||||
notice_link = (payload.get("notice_link") or "").strip()
|
||||
self_signed = bool(payload.get("self_signed"))
|
||||
path = normalize_path(payload.get("path"))
|
||||
svc = {"name": name, "port": port, "scheme": scheme, "url": f"{scheme}://{host}:{port}{path}"}
|
||||
if notice:
|
||||
svc["notice"] = notice
|
||||
if notice_link:
|
||||
svc["notice_link"] = notice_link
|
||||
if self_signed:
|
||||
svc["self_signed"] = True
|
||||
if path:
|
||||
svc["path"] = path
|
||||
services.append(svc)
|
||||
save_services(services)
|
||||
try:
|
||||
allow_port_lan(port)
|
||||
except FirewallToolMissing as e:
|
||||
return self._send(500, {"error": str(e)})
|
||||
diag_log("info", "Service added", {"name": name, "port": port, "scheme": scheme})
|
||||
return self._send(200, {"services": services, "message": f"Added {name} on port {port} and opened LAN firewall"})
|
||||
|
||||
if self.path.startswith("/api/services/remove"):
|
||||
port = int(payload.get("port", 0))
|
||||
if not port:
|
||||
return self._send(400, {"error": "port required"})
|
||||
if port in CORE_PORTS:
|
||||
return self._send(400, {"error": f"Cannot remove core service on port {port}"})
|
||||
services = [s for s in load_services() if s.get("port") != port]
|
||||
try:
|
||||
remove_port_lan(port)
|
||||
except FirewallToolMissing as e:
|
||||
return self._send(500, {"error": str(e)})
|
||||
save_services(services)
|
||||
diag_log("info", "Service removed", {"port": port})
|
||||
return self._send(200, {"services": services, "message": f"Removed service on port {port}"})
|
||||
|
||||
if self.path.startswith("/api/services/update"):
|
||||
port = int(payload.get("port", 0))
|
||||
new_name = payload.get("name")
|
||||
new_port = payload.get("new_port")
|
||||
new_scheme = payload.get("scheme")
|
||||
notice = payload.get("notice")
|
||||
notice_link = payload.get("notice_link")
|
||||
new_path = payload.get("path")
|
||||
self_signed = payload.get("self_signed")
|
||||
services = load_services()
|
||||
updated = False
|
||||
for svc in services:
|
||||
if svc.get("port") == port:
|
||||
if new_name:
|
||||
if port in CORE_PORTS and new_name != CORE_NAME:
|
||||
return self._send(400, {"error": f"Core service on port {port} must stay named {CORE_NAME}"})
|
||||
svc["name"] = new_name
|
||||
target_port = svc.get("port")
|
||||
if new_port is not None:
|
||||
new_port_int = int(new_port)
|
||||
if new_port_int != port:
|
||||
if new_port_int in CORE_PORTS and svc.get("name") != CORE_NAME:
|
||||
return self._send(400, {"error": f"Port {new_port_int} is reserved for {CORE_NAME}"})
|
||||
if any(s.get("port") == new_port_int and s is not svc for s in services):
|
||||
return self._send(400, {"error": "new port already in use"})
|
||||
try:
|
||||
remove_port_lan(port)
|
||||
allow_port_lan(new_port_int)
|
||||
except FirewallToolMissing as e:
|
||||
return self._send(500, {"error": str(e)})
|
||||
svc["port"] = new_port_int
|
||||
target_port = new_port_int
|
||||
host = default_host()
|
||||
if new_path is not None:
|
||||
path = normalize_path(new_path)
|
||||
if path:
|
||||
svc["path"] = path
|
||||
elif "path" in svc:
|
||||
svc.pop("path", None)
|
||||
else:
|
||||
path = normalize_path(svc.get("path"))
|
||||
if path:
|
||||
svc["path"] = path
|
||||
if new_scheme:
|
||||
scheme = new_scheme if new_scheme in ("http", "https") else None
|
||||
else:
|
||||
scheme = svc.get("scheme")
|
||||
if not scheme or scheme == "auto":
|
||||
scheme = "https" if detect_https(host, target_port) else "http"
|
||||
svc["scheme"] = scheme
|
||||
svc["url"] = f"{scheme}://{host}:{target_port}{path}"
|
||||
if notice is not None:
|
||||
text = (notice or "").strip()
|
||||
if text:
|
||||
svc["notice"] = text
|
||||
elif "notice" in svc:
|
||||
svc.pop("notice", None)
|
||||
if notice_link is not None:
|
||||
link = (notice_link or "").strip()
|
||||
if link:
|
||||
svc["notice_link"] = link
|
||||
elif "notice_link" in svc:
|
||||
svc.pop("notice_link", None)
|
||||
if self_signed is not None:
|
||||
if bool(self_signed):
|
||||
svc["self_signed"] = True
|
||||
else:
|
||||
svc.pop("self_signed", None)
|
||||
updated = True
|
||||
break
|
||||
if not updated:
|
||||
return self._send(404, {"error": "service not found"})
|
||||
save_services(services)
|
||||
diag_log("info", "Service updated", {"port": svc.get("port"), "name": new_name or None, "scheme": svc.get("scheme")})
|
||||
return self._send(200, {"services": services, "message": "Service updated"})
|
||||
|
||||
return self._send(404, {"error": "not found"})
|
||||
722
pikit_api/releases.py
Normal file
722
pikit_api/releases.py
Normal file
@@ -0,0 +1,722 @@
|
||||
import datetime
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .constants import (
|
||||
API_DIR,
|
||||
API_PACKAGE_DIR,
|
||||
API_PATH,
|
||||
AUTH_TOKEN,
|
||||
DEFAULT_MANIFEST_URL,
|
||||
DEFAULT_DEV_MANIFEST_URL,
|
||||
TMP_ROOT,
|
||||
UPDATE_LOCK,
|
||||
UPDATE_STATE,
|
||||
UPDATE_STATE_DIR,
|
||||
VERSION_FILE,
|
||||
WEB_ROOT,
|
||||
WEB_VERSION_FILE,
|
||||
)
|
||||
from .diagnostics import diag_log
|
||||
from .helpers import default_host, ensure_dir, sha256_file
|
||||
|
||||
|
||||
def read_current_version() -> str:
|
||||
if VERSION_FILE.exists():
|
||||
return VERSION_FILE.read_text().strip()
|
||||
if WEB_VERSION_FILE.exists():
|
||||
try:
|
||||
return json.loads(WEB_VERSION_FILE.read_text()).get("version", "unknown")
|
||||
except Exception:
|
||||
return "unknown"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def load_update_state() -> Dict[str, Any]:
|
||||
UPDATE_STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
def _reset_if_stale(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
If state thinks an update is running but the lock holder is gone,
|
||||
clear it so the UI can recover instead of getting stuck forever.
|
||||
"""
|
||||
lock_alive = False
|
||||
if UPDATE_LOCK.exists():
|
||||
try:
|
||||
pid = int(UPDATE_LOCK.read_text().strip() or "0")
|
||||
if pid > 0:
|
||||
os.kill(pid, 0)
|
||||
lock_alive = True
|
||||
else:
|
||||
UPDATE_LOCK.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
UPDATE_LOCK.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
UPDATE_LOCK.unlink(missing_ok=True)
|
||||
|
||||
if state.get("in_progress") and not lock_alive:
|
||||
state["in_progress"] = False
|
||||
state["progress"] = None
|
||||
if state.get("status") == "in_progress":
|
||||
state["status"] = "up_to_date"
|
||||
state["message"] = state.get("message") or "Recovered from interrupted update"
|
||||
try:
|
||||
save_update_state(state)
|
||||
except Exception:
|
||||
pass
|
||||
return state
|
||||
|
||||
if UPDATE_STATE.exists():
|
||||
try:
|
||||
state = json.loads(UPDATE_STATE.read_text())
|
||||
state.setdefault("changelog_url", None)
|
||||
state.setdefault("latest_release_date", None)
|
||||
state.setdefault("current_release_date", None)
|
||||
return _reset_if_stale(state)
|
||||
except Exception:
|
||||
pass
|
||||
return _reset_if_stale(
|
||||
{
|
||||
"current_version": read_current_version(),
|
||||
"latest_version": None,
|
||||
"last_check": None,
|
||||
"status": "unknown",
|
||||
"message": "",
|
||||
"auto_check": True,
|
||||
"in_progress": False,
|
||||
"progress": None,
|
||||
"channel": os.environ.get("PIKIT_CHANNEL", "stable"),
|
||||
"changelog_url": None,
|
||||
"latest_release_date": None,
|
||||
"current_release_date": None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def save_update_state(state: Dict[str, Any]) -> None:
|
||||
UPDATE_STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
UPDATE_STATE.write_text(json.dumps(state, indent=2))
|
||||
|
||||
|
||||
def _auth_token():
|
||||
return os.environ.get("PIKIT_AUTH_TOKEN") or AUTH_TOKEN
|
||||
|
||||
|
||||
def _gitea_latest_manifest(target: str):
|
||||
"""
|
||||
Fallback: when a manifest URL 404s, try hitting the Gitea API to grab the
|
||||
latest release asset named manifest.json.
|
||||
"""
|
||||
try:
|
||||
parts = target.split("/")
|
||||
if "releases" not in parts:
|
||||
return None
|
||||
idx = parts.index("releases")
|
||||
if idx < 2:
|
||||
return None
|
||||
base = "/".join(parts[:3])
|
||||
owner = parts[idx - 2]
|
||||
repo = parts[idx - 1]
|
||||
api_url = f"{base}/api/v1/repos/{owner}/{repo}/releases/latest"
|
||||
req = urllib.request.Request(api_url)
|
||||
token = _auth_token()
|
||||
if token:
|
||||
req.add_header("Authorization", f"token {token}")
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
rel = json.loads(resp.read().decode())
|
||||
assets = rel.get("assets") or []
|
||||
manifest_asset = next((a for a in assets if a.get("name") == "manifest.json"), None)
|
||||
if manifest_asset and manifest_asset.get("browser_download_url"):
|
||||
return fetch_manifest(manifest_asset["browser_download_url"])
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def fetch_manifest(url: str | None = None):
|
||||
target = url or os.environ.get("PIKIT_MANIFEST_URL") or DEFAULT_MANIFEST_URL
|
||||
req = urllib.request.Request(target)
|
||||
token = _auth_token()
|
||||
if token:
|
||||
req.add_header("Authorization", f"token {token}")
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
data = resp.read()
|
||||
return json.loads(data.decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
# If raw URL is protected, retry with access_token query param
|
||||
if e.code == 404 and token and "access_token=" not in target:
|
||||
try:
|
||||
sep = "&" if "?" in target else "?"
|
||||
retry_url = f"{target}{sep}access_token={token}"
|
||||
req = urllib.request.Request(retry_url)
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
data = resp.read()
|
||||
return json.loads(data.decode())
|
||||
except Exception:
|
||||
pass
|
||||
if e.code == 404:
|
||||
alt = _gitea_latest_manifest(target)
|
||||
if alt:
|
||||
return alt
|
||||
raise
|
||||
|
||||
|
||||
def _try_fetch(url: Optional[str]):
|
||||
if not url:
|
||||
return None
|
||||
try:
|
||||
return fetch_manifest(url)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _derive_releases_api_url(manifest_url: str) -> Optional[str]:
|
||||
"""
|
||||
Best-effort: derive Gitea releases API endpoint from a manifest URL.
|
||||
Supports raw URLs (/owner/repo/raw/...) or release asset URLs.
|
||||
"""
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(manifest_url)
|
||||
parts = parsed.path.strip("/").split("/")
|
||||
owner = repo = None
|
||||
if "releases" in parts:
|
||||
if len(parts) >= 2:
|
||||
owner, repo = parts[0], parts[1]
|
||||
elif "raw" in parts:
|
||||
if len(parts) >= 2:
|
||||
owner, repo = parts[0], parts[1]
|
||||
elif len(parts) >= 2:
|
||||
owner, repo = parts[0], parts[1]
|
||||
if owner and repo:
|
||||
base = f"{parsed.scheme}://{parsed.netloc}"
|
||||
return f"{base}/api/v1/repos/{owner}/{repo}/releases"
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def fetch_manifest_for_channel(channel: str, with_meta: bool = False):
|
||||
"""
|
||||
For stable: use normal manifest (latest non-prerelease).
|
||||
For dev: try normal manifest; if it points to stable, fetch latest prerelease manifest via Gitea API.
|
||||
If a stable build is newer than the latest dev build, prefer the newer stable even on dev channel.
|
||||
"""
|
||||
channel = channel or "dev"
|
||||
base_manifest_url = os.environ.get("PIKIT_MANIFEST_URL") or DEFAULT_MANIFEST_URL
|
||||
dev_manifest_url = os.environ.get("PIKIT_DEV_MANIFEST_URL") or DEFAULT_DEV_MANIFEST_URL
|
||||
stable_manifest_url = os.environ.get("PIKIT_STABLE_MANIFEST_URL") or DEFAULT_MANIFEST_URL
|
||||
manifest = None
|
||||
manual_dev_manifest = None
|
||||
version_dates: Dict[str, Optional[str]] = {}
|
||||
# Explicit dev manifest (raw file) – only used for dev channel
|
||||
if channel == "dev":
|
||||
manual_dev_manifest = _try_fetch(dev_manifest_url)
|
||||
try:
|
||||
manifest = fetch_manifest(stable_manifest_url)
|
||||
except Exception:
|
||||
manifest = None
|
||||
|
||||
def _norm_ver(ver):
|
||||
if ver is None:
|
||||
return None
|
||||
s = str(ver).strip()
|
||||
if s.lower().startswith("v"):
|
||||
s = s[1:]
|
||||
return s
|
||||
|
||||
def _newer(a, b):
|
||||
try:
|
||||
from distutils.version import LooseVersion
|
||||
|
||||
return LooseVersion(a) > LooseVersion(b)
|
||||
except Exception:
|
||||
return a > b
|
||||
|
||||
def _release_version(rel: Dict[str, Any]):
|
||||
for key in ("tag_name", "name"):
|
||||
val = rel.get(key)
|
||||
if val:
|
||||
v = _norm_ver(val)
|
||||
if v:
|
||||
return v
|
||||
return None
|
||||
|
||||
def _manifest_from_release(rel: Dict[str, Any]):
|
||||
asset = next((a for a in rel.get("assets", []) if a.get("name") == "manifest.json"), None)
|
||||
if not asset or not asset.get("browser_download_url"):
|
||||
return None
|
||||
mf = fetch_manifest(asset["browser_download_url"])
|
||||
if mf:
|
||||
dt = rel.get("published_at") or rel.get("created_at")
|
||||
if dt:
|
||||
mf["_release_date"] = dt
|
||||
tag = rel.get("tag_name")
|
||||
if tag:
|
||||
mf["_release_tag"] = tag
|
||||
return mf
|
||||
|
||||
try:
|
||||
parts = base_manifest_url.split("/")
|
||||
if "releases" not in parts:
|
||||
# No releases API for this URL; keep any fetched manifest and skip API discovery.
|
||||
releases = []
|
||||
if not manifest:
|
||||
manifest = fetch_manifest(base_manifest_url)
|
||||
else:
|
||||
idx = parts.index("releases")
|
||||
owner = parts[idx - 2]
|
||||
repo = parts[idx - 1]
|
||||
base = "/".join(parts[:3])
|
||||
api_url = f"{base}/api/v1/repos/{owner}/{repo}/releases"
|
||||
req = urllib.request.Request(api_url)
|
||||
token = _auth_token()
|
||||
if token:
|
||||
req.add_header("Authorization", f"token {token}")
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
releases = json.loads(resp.read().decode())
|
||||
|
||||
# Map release versions to published dates so we can surface them later
|
||||
for rel in releases:
|
||||
v = _release_version(rel)
|
||||
if v and v not in version_dates:
|
||||
version_dates[v] = rel.get("published_at") or rel.get("created_at")
|
||||
|
||||
dev_rel = None
|
||||
stable_rel = None
|
||||
dev_ver = None
|
||||
stable_ver = None
|
||||
for rel in releases:
|
||||
ver_str = _release_version(rel)
|
||||
parsed = _norm_ver(ver_str) if ver_str else None
|
||||
if parsed is None:
|
||||
continue
|
||||
if rel.get("prerelease") is True:
|
||||
if dev_ver is None or _newer(parsed.replace("-", "."), dev_ver):
|
||||
dev_rel = rel
|
||||
dev_ver = parsed.replace("-", ".")
|
||||
elif rel.get("prerelease") is False:
|
||||
if stable_ver is None or _newer(parsed.replace("-", "."), stable_ver):
|
||||
stable_rel = rel
|
||||
stable_ver = parsed.replace("-", ".")
|
||||
|
||||
latest_dev = _manifest_from_release(dev_rel) if dev_rel else None
|
||||
latest_stable = _manifest_from_release(stable_rel) if stable_rel else None
|
||||
|
||||
# If API didn't give us a dev manifest, try explicitly configured dev URL
|
||||
if dev_manifest_url and latest_dev is None:
|
||||
latest_dev = _try_fetch(dev_manifest_url)
|
||||
if latest_dev and "_release_date" not in latest_dev:
|
||||
latest_dev["_release_date"] = version_dates.get(
|
||||
_norm_ver(latest_dev.get("version") or latest_dev.get("latest_version")), None
|
||||
)
|
||||
|
||||
# Attach publish date to the base manifest when possible
|
||||
if manifest:
|
||||
mver = _norm_ver(manifest.get("version") or manifest.get("latest_version"))
|
||||
if mver and mver in version_dates and "_release_date" not in manifest:
|
||||
manifest["_release_date"] = version_dates[mver]
|
||||
|
||||
if channel == "dev":
|
||||
# Choose the newest by version comparison across stable/dev/base/manual-dev candidates
|
||||
candidates = [c for c in (latest_dev, manual_dev_manifest, latest_stable, manifest) if c]
|
||||
best = None
|
||||
best_ver = None
|
||||
for c in candidates:
|
||||
ver = _norm_ver(c.get("version") or c.get("latest_version"))
|
||||
if not ver:
|
||||
continue
|
||||
ver_cmp = ver.replace("-", ".")
|
||||
if best_ver is None or _newer(ver_cmp, best_ver):
|
||||
best = c
|
||||
best_ver = ver_cmp
|
||||
manifest = best
|
||||
else:
|
||||
# stable channel
|
||||
manifest = latest_stable or manifest
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# As a last resort for dev channel, consider explicitly configured dev manifest even without API data
|
||||
if channel == "dev" and manifest is None and dev_manifest_url:
|
||||
manifest = _try_fetch(dev_manifest_url)
|
||||
|
||||
# If still nothing and stable manifest URL is set, try that once more
|
||||
if manifest is None and stable_manifest_url and stable_manifest_url != base_manifest_url:
|
||||
manifest = _try_fetch(stable_manifest_url)
|
||||
|
||||
if manifest:
|
||||
if with_meta:
|
||||
return manifest, {"version_dates": version_dates}
|
||||
return manifest
|
||||
raise RuntimeError("No manifest found for channel")
|
||||
|
||||
|
||||
def list_available_releases(channel: str = "stable", limit: int = 20) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Return a list of releases with manifest URLs. Respects channel:
|
||||
- stable: non-prerelease only
|
||||
- dev: includes prereleases
|
||||
"""
|
||||
channel = channel or "stable"
|
||||
api_url = os.environ.get("PIKIT_RELEASES_API") or _derive_releases_api_url(DEFAULT_MANIFEST_URL)
|
||||
if not api_url:
|
||||
return []
|
||||
try:
|
||||
req = urllib.request.Request(api_url)
|
||||
token = _auth_token()
|
||||
if token:
|
||||
req.add_header("Authorization", f"token {token}")
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
releases = json.loads(resp.read().decode())
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
items = []
|
||||
for rel in releases:
|
||||
prerelease = bool(rel.get("prerelease"))
|
||||
if channel == "stable" and prerelease:
|
||||
continue
|
||||
version = rel.get("tag_name") or rel.get("name")
|
||||
if not version:
|
||||
continue
|
||||
version = str(version).lstrip("vV")
|
||||
manifest_asset = next((a for a in rel.get("assets", []) if a.get("name") == "manifest.json"), None)
|
||||
if not manifest_asset or not manifest_asset.get("browser_download_url"):
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
"version": version,
|
||||
"prerelease": prerelease,
|
||||
"published_at": rel.get("published_at") or rel.get("created_at"),
|
||||
"manifest_url": manifest_asset["browser_download_url"],
|
||||
"changelog_url": next(
|
||||
(a.get("browser_download_url") for a in rel.get("assets", []) if a.get("name", "").startswith("CHANGELOG-")),
|
||||
None,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
# Sort newest first by published_at if present
|
||||
items.sort(key=lambda x: x.get("published_at") or "", reverse=True)
|
||||
return items[:limit]
|
||||
|
||||
|
||||
def download_file(url: str, dest: pathlib.Path):
|
||||
ensure_dir(dest.parent)
|
||||
req = urllib.request.Request(url)
|
||||
token = _auth_token()
|
||||
if token:
|
||||
req.add_header("Authorization", f"token {token}")
|
||||
with urllib.request.urlopen(req, timeout=60) as resp, dest.open("wb") as f:
|
||||
shutil.copyfileobj(resp, f)
|
||||
return dest
|
||||
|
||||
|
||||
def fetch_text_with_auth(url: str):
|
||||
req = urllib.request.Request(url)
|
||||
token = _auth_token()
|
||||
if token:
|
||||
req.add_header("Authorization", f"token {token}")
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return resp.read().decode()
|
||||
|
||||
|
||||
def acquire_lock():
|
||||
try:
|
||||
ensure_dir(UPDATE_LOCK.parent)
|
||||
# Clear stale lock if the recorded PID is not running
|
||||
if UPDATE_LOCK.exists():
|
||||
try:
|
||||
pid = int(UPDATE_LOCK.read_text().strip() or "0")
|
||||
if pid > 0:
|
||||
os.kill(pid, 0)
|
||||
else:
|
||||
UPDATE_LOCK.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
# Process not running
|
||||
UPDATE_LOCK.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
UPDATE_LOCK.unlink(missing_ok=True)
|
||||
lockfile = UPDATE_LOCK.open("w")
|
||||
fcntl.flock(lockfile.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
lockfile.write(str(os.getpid()))
|
||||
lockfile.flush()
|
||||
return lockfile
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def release_lock(lockfile):
|
||||
try:
|
||||
fcntl.flock(lockfile.fileno(), fcntl.LOCK_UN)
|
||||
lockfile.close()
|
||||
UPDATE_LOCK.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def check_for_update():
|
||||
state = load_update_state()
|
||||
lock = acquire_lock()
|
||||
if lock is None:
|
||||
state["status"] = "error"
|
||||
state["message"] = "Another update is running"
|
||||
save_update_state(state)
|
||||
return state
|
||||
diag_log("info", "Update check started", {"channel": state.get("channel") or "dev"})
|
||||
state["in_progress"] = True
|
||||
state["progress"] = "Checking for updates…"
|
||||
save_update_state(state)
|
||||
try:
|
||||
manifest, meta = fetch_manifest_for_channel(state.get("channel") or "dev", with_meta=True)
|
||||
latest = manifest.get("version") or manifest.get("latest_version")
|
||||
state["latest_version"] = latest
|
||||
state["changelog_url"] = manifest.get("changelog")
|
||||
state["last_check"] = datetime.datetime.utcnow().isoformat() + "Z"
|
||||
version_dates = (meta or {}).get("version_dates") or {}
|
||||
if manifest.get("_release_date"):
|
||||
state["latest_release_date"] = manifest.get("_release_date")
|
||||
elif latest and latest in version_dates:
|
||||
state["latest_release_date"] = version_dates.get(str(latest))
|
||||
else:
|
||||
state["latest_release_date"] = None
|
||||
state["current_release_date"] = None
|
||||
current_ver = state.get("current_version")
|
||||
if current_ver and current_ver in version_dates:
|
||||
state["current_release_date"] = version_dates.get(str(current_ver))
|
||||
elif current_ver and current_ver == latest and state["latest_release_date"]:
|
||||
# If current matches latest and we have a date for latest, reuse it
|
||||
state["current_release_date"] = state["latest_release_date"]
|
||||
channel = state.get("channel") or "dev"
|
||||
if channel == "stable" and latest and "dev" in str(latest):
|
||||
state["status"] = "up_to_date"
|
||||
state["message"] = "Dev release available; enable dev channel to install."
|
||||
else:
|
||||
if latest and latest != state.get("current_version"):
|
||||
state["status"] = "update_available"
|
||||
state["message"] = manifest.get("notes") or manifest.get("message") or "Update available"
|
||||
else:
|
||||
state["status"] = "up_to_date"
|
||||
state["message"] = "Up to date"
|
||||
diag_log("info", "Update check finished", {"status": state["status"], "latest": str(latest)})
|
||||
except Exception as e:
|
||||
state["status"] = "up_to_date"
|
||||
state["message"] = f"Could not reach update server: {e}"
|
||||
state["last_check"] = datetime.datetime.utcnow().isoformat() + "Z"
|
||||
state["latest_release_date"] = None
|
||||
diag_log("error", "Update check failed", {"error": str(e)})
|
||||
finally:
|
||||
state["in_progress"] = False
|
||||
state["progress"] = None
|
||||
save_update_state(state)
|
||||
if lock:
|
||||
release_lock(lock)
|
||||
return state
|
||||
|
||||
|
||||
def _install_manifest(manifest: Dict[str, Any], meta: Optional[Dict[str, Any]], state: Dict[str, Any]):
|
||||
latest = manifest.get("version") or manifest.get("latest_version")
|
||||
if not latest:
|
||||
raise RuntimeError("Manifest missing version")
|
||||
|
||||
bundle_url = manifest.get("bundle") or manifest.get("url")
|
||||
if not bundle_url:
|
||||
raise RuntimeError("Manifest missing bundle url")
|
||||
stage_dir = TMP_ROOT / str(latest)
|
||||
bundle_path = stage_dir / "bundle.tar.gz"
|
||||
ensure_dir(stage_dir)
|
||||
|
||||
state["progress"] = "Downloading release…"
|
||||
save_update_state(state)
|
||||
download_file(bundle_url, bundle_path)
|
||||
diag_log("debug", "Bundle downloaded", {"url": bundle_url, "path": str(bundle_path)})
|
||||
|
||||
expected_hash = None
|
||||
for f in manifest.get("files", []):
|
||||
if f.get("path") == "bundle.tar.gz" and f.get("sha256"):
|
||||
expected_hash = f["sha256"]
|
||||
break
|
||||
if expected_hash:
|
||||
got = sha256_file(bundle_path)
|
||||
if got.lower() != expected_hash.lower():
|
||||
raise RuntimeError("Bundle hash mismatch")
|
||||
diag_log("debug", "Bundle hash verified", {"expected": expected_hash})
|
||||
|
||||
state["progress"] = "Staging files…"
|
||||
save_update_state(state)
|
||||
with tarfile.open(bundle_path, "r:gz") as tar:
|
||||
tar.extractall(stage_dir)
|
||||
|
||||
staged_web = stage_dir / "pikit-web"
|
||||
if staged_web.exists():
|
||||
shutil.rmtree(WEB_ROOT, ignore_errors=True)
|
||||
shutil.copytree(staged_web, WEB_ROOT)
|
||||
staged_api = stage_dir / "pikit-api.py"
|
||||
if staged_api.exists():
|
||||
shutil.copy2(staged_api, API_PATH)
|
||||
os.chmod(API_PATH, 0o755)
|
||||
staged_pkg = stage_dir / "pikit_api"
|
||||
if staged_pkg.exists():
|
||||
shutil.rmtree(API_PACKAGE_DIR, ignore_errors=True)
|
||||
shutil.copytree(staged_pkg, API_PACKAGE_DIR, dirs_exist_ok=True)
|
||||
|
||||
# Restart frontend to pick up new assets; avoid restarting this API process
|
||||
# mid-apply to prevent leaving state in_progress.
|
||||
subprocess.run(["systemctl", "restart", "dietpi-dashboard-frontend.service"], check=False)
|
||||
|
||||
VERSION_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
VERSION_FILE.write_text(str(latest))
|
||||
|
||||
state["current_version"] = str(latest)
|
||||
state["latest_version"] = str(latest)
|
||||
state["changelog_url"] = manifest.get("changelog")
|
||||
state["latest_release_date"] = manifest.get("_release_date") or (meta or {}).get("version_dates", {}).get(str(latest))
|
||||
state["current_release_date"] = state.get("latest_release_date")
|
||||
state["status"] = "up_to_date"
|
||||
state["message"] = "Update installed"
|
||||
state["progress"] = None
|
||||
save_update_state(state)
|
||||
diag_log("info", "Update applied", {"version": str(latest)})
|
||||
|
||||
|
||||
def _stage_backup() -> pathlib.Path:
|
||||
ts = datetime.datetime.utcnow().strftime("%Y%m%d-%H%M%S")
|
||||
backup_dir = BACKUP_ROOT / ts
|
||||
ensure_dir(backup_dir)
|
||||
if WEB_ROOT.exists():
|
||||
ensure_dir(backup_dir / "pikit-web")
|
||||
shutil.copytree(WEB_ROOT, backup_dir / "pikit-web", dirs_exist_ok=True)
|
||||
if API_PATH.exists():
|
||||
shutil.copy2(API_PATH, backup_dir / "pikit-api.py")
|
||||
if API_PACKAGE_DIR.exists():
|
||||
shutil.copytree(API_PACKAGE_DIR, backup_dir / "pikit_api", dirs_exist_ok=True)
|
||||
if VERSION_FILE.exists():
|
||||
shutil.copy2(VERSION_FILE, backup_dir / "version.txt")
|
||||
return backup_dir
|
||||
|
||||
|
||||
def apply_update():
|
||||
state = load_update_state()
|
||||
if state.get("in_progress"):
|
||||
state["message"] = "Update already in progress"
|
||||
save_update_state(state)
|
||||
return state
|
||||
|
||||
lock = acquire_lock()
|
||||
if lock is None:
|
||||
state["status"] = "error"
|
||||
state["message"] = "Another update is running"
|
||||
save_update_state(state)
|
||||
return state
|
||||
|
||||
state["in_progress"] = True
|
||||
state["status"] = "in_progress"
|
||||
state["progress"] = "Starting update…"
|
||||
save_update_state(state)
|
||||
diag_log("info", "Update apply started", {"channel": state.get("channel") or "dev"})
|
||||
|
||||
try:
|
||||
channel = state.get("channel") or os.environ.get("PIKIT_CHANNEL", "dev")
|
||||
manifest, meta = fetch_manifest_for_channel(channel, with_meta=True)
|
||||
_install_manifest(manifest, meta, state)
|
||||
except urllib.error.HTTPError as e:
|
||||
state["status"] = "error"
|
||||
state["message"] = f"No release available ({e.code})"
|
||||
state["latest_release_date"] = None
|
||||
diag_log("error", "Update apply HTTP error", {"code": e.code})
|
||||
except Exception as e:
|
||||
state["status"] = "error"
|
||||
state["message"] = f"Update failed: {e}"
|
||||
state["progress"] = None
|
||||
state["latest_release_date"] = None
|
||||
save_update_state(state)
|
||||
diag_log("error", "Update apply failed", {"error": str(e)})
|
||||
finally:
|
||||
state["in_progress"] = False
|
||||
state["progress"] = None
|
||||
save_update_state(state)
|
||||
if lock:
|
||||
release_lock(lock)
|
||||
return state
|
||||
|
||||
|
||||
def apply_update_version(version: str, channel: Optional[str] = None):
|
||||
"""
|
||||
Install a specific version chosen by the user. Uses the releases list to
|
||||
resolve the manifest URL.
|
||||
"""
|
||||
version = str(version).lstrip("vV")
|
||||
state = load_update_state()
|
||||
channel = channel or state.get("channel") or "stable"
|
||||
if state.get("in_progress"):
|
||||
state["message"] = "Update already in progress"
|
||||
save_update_state(state)
|
||||
return state
|
||||
|
||||
lock = acquire_lock()
|
||||
if lock is None:
|
||||
state["status"] = "error"
|
||||
state["message"] = "Another update is running"
|
||||
save_update_state(state)
|
||||
return state
|
||||
|
||||
state["in_progress"] = True
|
||||
state["status"] = "in_progress"
|
||||
state["progress"] = f"Preparing {version}…"
|
||||
save_update_state(state)
|
||||
diag_log("info", "Manual update started", {"version": version, "channel": channel})
|
||||
|
||||
try:
|
||||
releases = list_available_releases("dev" if channel == "dev" else "stable", limit=40)
|
||||
entry = next((r for r in releases if str(r.get("version")) == version), None)
|
||||
if not entry:
|
||||
raise RuntimeError(f"Version {version} not found")
|
||||
manifest_url = entry.get("manifest_url")
|
||||
manifest = fetch_manifest(manifest_url)
|
||||
meta = {"version_dates": {version: entry.get("published_at")}}
|
||||
_install_manifest(manifest, meta, state)
|
||||
except Exception as e:
|
||||
state["status"] = "error"
|
||||
state["message"] = f"Update failed: {e}"
|
||||
state["progress"] = None
|
||||
state["latest_release_date"] = None
|
||||
save_update_state(state)
|
||||
diag_log("error", "Manual update failed", {"error": str(e), "version": version})
|
||||
finally:
|
||||
state["in_progress"] = False
|
||||
state["progress"] = None
|
||||
save_update_state(state)
|
||||
if lock:
|
||||
release_lock(lock)
|
||||
return state
|
||||
|
||||
|
||||
def start_background_task(mode: str):
|
||||
"""
|
||||
Kick off a background update via systemd-run so nginx/API restarts
|
||||
do not break the caller connection.
|
||||
mode: "apply"
|
||||
"""
|
||||
assert mode in ("apply",), "invalid mode"
|
||||
unit = f"pikit-update-{mode}"
|
||||
cmd = ["systemd-run", "--unit", unit, "--quiet"]
|
||||
if DEFAULT_MANIFEST_URL:
|
||||
cmd += [f"--setenv=PIKIT_MANIFEST_URL={DEFAULT_MANIFEST_URL}"]
|
||||
token = _auth_token()
|
||||
if token:
|
||||
cmd += [f"--setenv=PIKIT_AUTH_TOKEN={token}"]
|
||||
cmd += ["/usr/bin/env", "python3", str(API_PATH), f"--{mode}-update"]
|
||||
subprocess.run(cmd, check=False)
|
||||
|
||||
|
||||
# Backwards compat aliases
|
||||
apply_update_stub = apply_update
|
||||
9
pikit_api/server.py
Normal file
9
pikit_api/server.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from http.server import HTTPServer
|
||||
|
||||
from .constants import HOST, PORT
|
||||
from .http_handlers import Handler
|
||||
|
||||
|
||||
def run_server(host: str = HOST, port: int = PORT):
|
||||
server = HTTPServer((host, port), Handler)
|
||||
server.serve_forever()
|
||||
151
pikit_api/services.py
Normal file
151
pikit_api/services.py
Normal file
@@ -0,0 +1,151 @@
|
||||
import json
|
||||
import pathlib
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from .constants import (
|
||||
CORE_NAME,
|
||||
CORE_PORTS,
|
||||
HTTPS_PORTS,
|
||||
READY_FILE,
|
||||
RESET_LOG,
|
||||
SERVICE_JSON,
|
||||
)
|
||||
from .diagnostics import dbg
|
||||
from .helpers import default_host, detect_https, ensure_dir, normalize_path
|
||||
|
||||
|
||||
class FirewallToolMissing(Exception):
|
||||
"""Raised when ufw is unavailable but a firewall change was requested."""
|
||||
pass
|
||||
|
||||
|
||||
def load_services() -> List[Dict[str, Any]]:
|
||||
"""Load service registry and normalize url/scheme/path fields."""
|
||||
if SERVICE_JSON.exists():
|
||||
try:
|
||||
data = json.loads(SERVICE_JSON.read_text())
|
||||
host = default_host()
|
||||
for svc in data:
|
||||
svc_path = normalize_path(svc.get("path"))
|
||||
if svc_path:
|
||||
svc["path"] = svc_path
|
||||
if svc.get("port"):
|
||||
scheme = svc.get("scheme")
|
||||
if not scheme:
|
||||
scheme = "https" if int(svc["port"]) in HTTPS_PORTS else "http"
|
||||
svc["scheme"] = scheme
|
||||
svc["url"] = f"{scheme}://{host}:{svc['port']}{svc_path}"
|
||||
return data
|
||||
except Exception:
|
||||
dbg("Failed to read services.json")
|
||||
return []
|
||||
return []
|
||||
|
||||
|
||||
def save_services(services: List[Dict[str, Any]]) -> None:
|
||||
ensure_dir(SERVICE_JSON.parent)
|
||||
SERVICE_JSON.write_text(json.dumps(services, indent=2))
|
||||
|
||||
|
||||
def allow_port_lan(port: int):
|
||||
"""Open a port to RFC1918 subnets; raise if ufw is missing so callers can surface the error."""
|
||||
if not shutil.which("ufw"):
|
||||
raise FirewallToolMissing("Cannot update firewall: ufw is not installed on this system.")
|
||||
for subnet in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "169.254.0.0/16"):
|
||||
subprocess.run(["ufw", "allow", "from", subnet, "to", "any", "port", str(port)], check=False)
|
||||
|
||||
|
||||
def remove_port_lan(port: int):
|
||||
"""Close a LAN rule for a port; raise if ufw is missing so callers can surface the error."""
|
||||
if not shutil.which("ufw"):
|
||||
raise FirewallToolMissing("Cannot update firewall: ufw is not installed on this system.")
|
||||
for subnet in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "169.254.0.0/16"):
|
||||
subprocess.run(["ufw", "delete", "allow", "from", subnet, "to", "any", "port", str(port)], check=False)
|
||||
|
||||
|
||||
def ufw_status_allows(port: int) -> bool:
|
||||
try:
|
||||
out = subprocess.check_output(["ufw", "status"], text=True)
|
||||
return f"{port}" in out and "ALLOW" in out
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def reset_firewall():
|
||||
subprocess.run(["ufw", "--force", "reset"], check=False)
|
||||
subprocess.run(["ufw", "default", "deny", "incoming"], check=False)
|
||||
subprocess.run(["ufw", "default", "deny", "outgoing"], check=False)
|
||||
for port in ("53", "80", "443", "123", "67", "68"):
|
||||
subprocess.run(["ufw", "allow", "out", port], check=False)
|
||||
subprocess.run(["ufw", "allow", "out", "on", "lo"], check=False)
|
||||
for subnet in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "169.254.0.0/16"):
|
||||
subprocess.run(["ufw", "allow", "out", "to", subnet], check=False)
|
||||
for subnet in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "169.254.0.0/16"):
|
||||
for port in ("22", "80", "443", "5252", "5253"):
|
||||
subprocess.run(["ufw", "allow", "from", subnet, "to", "any", "port", port], check=False)
|
||||
subprocess.run(["ufw", "--force", "enable"], check=False)
|
||||
|
||||
|
||||
def set_ssh_password_auth(allow: bool):
|
||||
"""
|
||||
Enable/disable SSH password authentication without requiring the current password.
|
||||
Used during factory reset to restore a predictable state.
|
||||
"""
|
||||
cfg = pathlib.Path("/etc/ssh/sshd_config")
|
||||
text = cfg.read_text() if cfg.exists() else ""
|
||||
|
||||
def set_opt(key, value):
|
||||
nonlocal text
|
||||
pattern = f"{key} "
|
||||
lines = text.splitlines()
|
||||
replaced = False
|
||||
for idx, line in enumerate(lines):
|
||||
if line.strip().startswith(pattern):
|
||||
lines[idx] = f"{key} {value}"
|
||||
replaced = True
|
||||
break
|
||||
if not replaced:
|
||||
lines.append(f"{key} {value}")
|
||||
text_new = "\n".join(lines) + "\n"
|
||||
return text_new
|
||||
|
||||
text = set_opt("PasswordAuthentication", "yes" if allow else "no")
|
||||
text = set_opt("KbdInteractiveAuthentication", "no")
|
||||
text = set_opt("ChallengeResponseAuthentication", "no")
|
||||
text = set_opt("PubkeyAuthentication", "yes")
|
||||
text = set_opt("PermitRootLogin", "yes" if allow else "prohibit-password")
|
||||
cfg.write_text(text)
|
||||
subprocess.run(["systemctl", "restart", "ssh"], check=False)
|
||||
return True, f"SSH password auth {'enabled' if allow else 'disabled'}"
|
||||
|
||||
|
||||
def factory_reset():
|
||||
# Restore services config
|
||||
custom = pathlib.Path("/boot/custom-files/pikit-services.json")
|
||||
if custom.exists():
|
||||
shutil.copy(custom, SERVICE_JSON)
|
||||
else:
|
||||
SERVICE_JSON.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{"name": "Pi-Kit Dashboard", "port": 80},
|
||||
{"name": "DietPi Dashboard", "port": 5252},
|
||||
],
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
reset_firewall()
|
||||
set_ssh_password_auth(True)
|
||||
for user in ("root", "dietpi"):
|
||||
try:
|
||||
subprocess.run(["chpasswd"], input=f"{user}:pikit".encode(), check=True)
|
||||
except Exception:
|
||||
pass
|
||||
if not pathlib.Path("/home/dietpi").exists():
|
||||
subprocess.run(["useradd", "-m", "-s", "/bin/bash", "dietpi"], check=False)
|
||||
subprocess.run(["chpasswd"], input=b"dietpi:pikit", check=False)
|
||||
RESET_LOG.write_text("Factory reset triggered\n")
|
||||
subprocess.Popen(["/bin/sh", "-c", "sleep 2 && systemctl reboot >/dev/null 2>&1"], close_fds=True)
|
||||
103
pikit_api/status.py
Normal file
103
pikit_api/status.py
Normal file
@@ -0,0 +1,103 @@
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from .auto_updates import auto_updates_state, read_updates_config
|
||||
from .constants import CORE_NAME, CORE_PORTS, READY_FILE
|
||||
from .helpers import default_host, detect_https, normalize_path, port_online, reboot_required
|
||||
from .services import load_services, ufw_status_allows
|
||||
|
||||
|
||||
def _cpu_temp():
|
||||
for path in ("/sys/class/thermal/thermal_zone0/temp",):
|
||||
p = pathlib.Path(path)
|
||||
if p.exists():
|
||||
try:
|
||||
return float(p.read_text().strip()) / 1000.0
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _os_version():
|
||||
os_ver = "DietPi"
|
||||
try:
|
||||
for line in pathlib.Path("/etc/os-release").read_text().splitlines():
|
||||
if line.startswith("PRETTY_NAME="):
|
||||
os_ver = line.split("=", 1)[1].strip().strip('"')
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
return os_ver
|
||||
|
||||
|
||||
def _lan_ip():
|
||||
try:
|
||||
out = subprocess.check_output(["hostname", "-I"], text=True).strip()
|
||||
return out.split()[0] if out else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def list_services_with_health():
|
||||
services = []
|
||||
for svc in load_services():
|
||||
svc = dict(svc)
|
||||
port = svc.get("port")
|
||||
if port:
|
||||
svc["online"] = port_online("127.0.0.1", port)
|
||||
svc["firewall_open"] = ufw_status_allows(port)
|
||||
services.append(svc)
|
||||
return services
|
||||
|
||||
|
||||
def list_services_for_ui():
|
||||
services = []
|
||||
for svc in load_services():
|
||||
svc = dict(svc)
|
||||
port = svc.get("port")
|
||||
if port:
|
||||
svc["online"] = port_online("127.0.0.1", port)
|
||||
svc["firewall_open"] = ufw_status_allows(port)
|
||||
host = default_host()
|
||||
path = normalize_path(svc.get("path"))
|
||||
scheme = svc.get("scheme") or ("https" if detect_https(host, port) else "http")
|
||||
svc["scheme"] = scheme
|
||||
svc["url"] = f"{scheme}://{host}:{port}{path}"
|
||||
services.append(svc)
|
||||
return services
|
||||
|
||||
|
||||
def collect_status() -> Dict[str, Any]:
|
||||
uptime = float(open("/proc/uptime").read().split()[0])
|
||||
load1, load5, load15 = os.getloadavg()
|
||||
meminfo = {}
|
||||
for ln in open("/proc/meminfo"):
|
||||
k, v = ln.split(":", 1)
|
||||
meminfo[k] = int(v.strip().split()[0])
|
||||
total = meminfo.get("MemTotal", 0) // 1024
|
||||
free = meminfo.get("MemAvailable", 0) // 1024
|
||||
disk = shutil.disk_usage("/")
|
||||
services = list_services_with_health()
|
||||
updates_state = auto_updates_state()
|
||||
updates_config = read_updates_config(updates_state)
|
||||
data = {
|
||||
"hostname": socket.gethostname(),
|
||||
"uptime_seconds": uptime,
|
||||
"load": [load1, load5, load15],
|
||||
"memory_mb": {"total": total, "free": free},
|
||||
"disk_mb": {"total": disk.total // 1024 // 1024, "free": disk.free // 1024 // 1024},
|
||||
"cpu_temp_c": _cpu_temp(),
|
||||
"lan_ip": _lan_ip(),
|
||||
"os_version": _os_version(),
|
||||
"auto_updates_enabled": updates_state.get("enabled", False),
|
||||
"auto_updates": updates_state,
|
||||
"updates_config": updates_config,
|
||||
"reboot_required": reboot_required(),
|
||||
"ready": READY_FILE.exists(),
|
||||
"services": services,
|
||||
}
|
||||
return data
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/bin/bash
|
||||
echo "Waiting for pikit to resolve..."
|
||||
while ! getent hosts pikit >/dev/null;
|
||||
do sleep 1
|
||||
done
|
||||
ssh -i ~/.ssh/pikit dietpi@pikit sudo touch /var/run/pikit-ready
|
||||
echo "Done."
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
# 1) Refresh sudo once in the foreground so you enter your password cleanly
|
||||
echo "Refreshing sudo credentials..."
|
||||
sudo -v || { echo "sudo authentication failed"; exit 1; }
|
||||
|
||||
# 2) Start non-interactive sudo keepalive loop in the background
|
||||
# -n = never prompt; if the timestamp ever expires, this just exits
|
||||
( while true; do sudo -n true 2>/dev/null || exit 0; sleep 180; done ) &
|
||||
KEEPALIVE_PID=$!
|
||||
|
||||
# 3) Ensure cleanup on exit (normal, error, or Ctrl+C)
|
||||
cleanup() {
|
||||
kill "$KEEPALIVE_PID" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# 4) Run Codex
|
||||
codex resume --search
|
||||
|
||||
10
systemd/pikit-certgen.service
Normal file
10
systemd/pikit-certgen.service
Normal file
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Generate Pi-Kit TLS certs if missing
|
||||
Before=nginx.service dietpi-dashboard-frontend.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/pikit-certgen.sh
|
||||
|
||||
[Install]
|
||||
WantedBy=nginx.service dietpi-dashboard-frontend.service
|
||||
116
systemd/pikit-certgen.sh
Executable file
116
systemd/pikit-certgen.sh
Executable file
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generate Pi-Kit TLS CA + server cert if missing (idempotent).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
CERT_DIR="/etc/pikit/certs"
|
||||
WEB_ASSETS="/var/www/pikit-web/assets"
|
||||
CA_CRT="$CERT_DIR/pikit-ca.crt"
|
||||
CA_KEY="$CERT_DIR/pikit-ca.key"
|
||||
CA_SRL="$CERT_DIR/pikit-ca.srl"
|
||||
SRV_KEY="$CERT_DIR/pikit.local.key"
|
||||
SRV_CRT="$CERT_DIR/pikit.local.crt"
|
||||
SRV_CSR="$CERT_DIR/pikit.local.csr"
|
||||
CERT_GROUP="pikit-cert"
|
||||
|
||||
log() {
|
||||
printf '[pikit-certgen] %s\n' "$*"
|
||||
}
|
||||
|
||||
write_ca_hash() {
|
||||
if [ -s "$WEB_ASSETS/pikit-ca.crt" ]; then
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$WEB_ASSETS/pikit-ca.crt" | awk '{print $1}' > "$WEB_ASSETS/pikit-ca.sha256"
|
||||
elif command -v openssl >/dev/null 2>&1; then
|
||||
openssl dgst -sha256 "$WEB_ASSETS/pikit-ca.crt" | awk '{print $2}' > "$WEB_ASSETS/pikit-ca.sha256"
|
||||
fi
|
||||
if [ -s "$WEB_ASSETS/pikit-ca.sha256" ]; then
|
||||
chmod 644 "$WEB_ASSETS/pikit-ca.sha256"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_group() {
|
||||
if ! getent group "$CERT_GROUP" >/dev/null 2>&1; then
|
||||
groupadd "$CERT_GROUP" || true
|
||||
fi
|
||||
for u in www-data dietpi-dashboard-frontend; do
|
||||
if id -u "$u" >/dev/null 2>&1; then
|
||||
usermod -a -G "$CERT_GROUP" "$u" || true
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
fix_perms() {
|
||||
ensure_group
|
||||
if [ -d "$CERT_DIR" ]; then
|
||||
chgrp "$CERT_GROUP" "$CERT_DIR" || true
|
||||
chmod 750 "$CERT_DIR" || true
|
||||
fi
|
||||
for f in "$CA_CRT" "$CA_KEY" "$SRV_CRT" "$SRV_KEY"; do
|
||||
if [ -e "$f" ]; then
|
||||
chgrp "$CERT_GROUP" "$f" || true
|
||||
fi
|
||||
done
|
||||
[ -e "$CA_KEY" ] && chmod 640 "$CA_KEY"
|
||||
[ -e "$SRV_KEY" ] && chmod 640 "$SRV_KEY"
|
||||
[ -e "$CA_CRT" ] && chmod 644 "$CA_CRT"
|
||||
[ -e "$SRV_CRT" ] && chmod 644 "$SRV_CRT"
|
||||
}
|
||||
|
||||
if [ -s "$CA_CRT" ] && [ -s "$CA_KEY" ] && [ -s "$SRV_KEY" ] && [ -s "$SRV_CRT" ]; then
|
||||
mkdir -p "$WEB_ASSETS"
|
||||
if [ ! -s "$WEB_ASSETS/pikit-ca.crt" ]; then
|
||||
cp "$CA_CRT" "$WEB_ASSETS/pikit-ca.crt"
|
||||
chmod 644 "$WEB_ASSETS/pikit-ca.crt"
|
||||
log "Copied CA to web assets."
|
||||
fi
|
||||
write_ca_hash
|
||||
fix_perms
|
||||
log "TLS certs already present; skipping generation."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! command -v openssl >/dev/null 2>&1; then
|
||||
log "openssl not installed; cannot generate certs."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Generating TLS certs..."
|
||||
mkdir -p "$CERT_DIR" "$WEB_ASSETS"
|
||||
ensure_group
|
||||
chgrp "$CERT_GROUP" "$CERT_DIR" || true
|
||||
chmod 750 "$CERT_DIR"
|
||||
|
||||
rm -f "$CA_KEY" "$CA_CRT" "$CA_SRL" "$SRV_KEY" "$SRV_CRT" "$SRV_CSR" || true
|
||||
|
||||
openssl genrsa -out "$CA_KEY" 2048
|
||||
openssl req -x509 -new -nodes -key "$CA_KEY" -sha256 -days 3650 \
|
||||
-out "$CA_CRT" -subj "/CN=Pi-Kit CA"
|
||||
|
||||
openssl genrsa -out "$SRV_KEY" 2048
|
||||
openssl req -new -key "$SRV_KEY" -out "$SRV_CSR" -subj "/CN=pikit.local"
|
||||
|
||||
SAN_CFG=$(mktemp)
|
||||
cat > "$SAN_CFG" <<'CFG'
|
||||
authorityKeyIdentifier=keyid,issuer
|
||||
basicConstraints=CA:FALSE
|
||||
keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
|
||||
subjectAltName = @alt_names
|
||||
|
||||
[alt_names]
|
||||
DNS.1 = pikit.local
|
||||
DNS.2 = pikit
|
||||
CFG
|
||||
|
||||
openssl x509 -req -in "$SRV_CSR" -CA "$CA_CRT" -CAkey "$CA_KEY" \
|
||||
-CAcreateserial -out "$SRV_CRT" -days 825 -sha256 -extfile "$SAN_CFG"
|
||||
|
||||
rm -f "$SAN_CFG" "$SRV_CSR"
|
||||
fix_perms
|
||||
|
||||
cp "$CA_CRT" "$WEB_ASSETS/pikit-ca.crt"
|
||||
chmod 644 "$WEB_ASSETS/pikit-ca.crt"
|
||||
write_ca_hash
|
||||
|
||||
log "TLS certs generated."
|
||||
19
systemd/pikit-first-login.sh
Normal file
19
systemd/pikit-first-login.sh
Normal file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env sh
|
||||
# Install as /etc/profile.d/pikit-first-login.sh
|
||||
# Prints a one-time SSH hardening tip after the forced password change.
|
||||
|
||||
FLAG="/var/lib/pikit/first-login.notice"
|
||||
|
||||
case "$-" in
|
||||
*i*) interactive=1 ;;
|
||||
*) interactive=0 ;;
|
||||
esac
|
||||
|
||||
if [ "$interactive" -eq 1 ] && [ -f "$FLAG" ]; then
|
||||
echo ""
|
||||
echo "Pi-Kit: For better security, set up an SSH key and disable password auth once working."
|
||||
echo " Example: ssh-keygen -t ed25519"
|
||||
echo " ssh-copy-id dietpi@pikit.local"
|
||||
echo ""
|
||||
rm -f "$FLAG" 2>/dev/null || true
|
||||
fi
|
||||
304
systemd/pikit-firstboot.sh
Executable file
304
systemd/pikit-firstboot.sh
Executable file
@@ -0,0 +1,304 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install as /var/lib/dietpi/postboot.d/10-pikit-firstboot and chmod +x.
|
||||
# Runs once on first boot to finalize device-unique setup.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
FIRSTBOOT_DIR="/var/lib/pikit/firstboot"
|
||||
STATE_FILE="$FIRSTBOOT_DIR/state.json"
|
||||
LOG_FILE="$FIRSTBOOT_DIR/firstboot.log"
|
||||
ERROR_FILE="$FIRSTBOOT_DIR/firstboot.error"
|
||||
DONE_FILE="$FIRSTBOOT_DIR/firstboot.done"
|
||||
LOCK_FILE="$FIRSTBOOT_DIR/firstboot.lock"
|
||||
CERT_DIR="/etc/pikit/certs"
|
||||
WEB_ASSETS="/var/www/pikit-web/assets"
|
||||
PROFILE_FILE="/etc/pikit/profile.json"
|
||||
MOTD_FILE="/etc/motd"
|
||||
FIRSTBOOT_CONF="/etc/pikit/firstboot.conf"
|
||||
APT_UA_OVERRIDE="/etc/apt/apt.conf.d/51pikit-unattended.conf"
|
||||
|
||||
STEPS=(
|
||||
"Preparing system"
|
||||
"Generating security keys"
|
||||
"Securing the dashboard"
|
||||
"Updating software (this can take a while)"
|
||||
"Final checks"
|
||||
"Starting Pi-Kit"
|
||||
)
|
||||
STEP_STATUS=(pending pending pending pending pending pending)
|
||||
CURRENT_STEP=""
|
||||
CURRENT_INDEX=-1
|
||||
PIKIT_FIRSTBOOT_UPDATES="${PIKIT_FIRSTBOOT_UPDATES:-1}"
|
||||
|
||||
log() {
|
||||
printf '[%s] %s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')" "$*"
|
||||
}
|
||||
|
||||
load_config() {
|
||||
if [ -f "$FIRSTBOOT_CONF" ]; then
|
||||
# shellcheck disable=SC1090
|
||||
. "$FIRSTBOOT_CONF"
|
||||
fi
|
||||
PIKIT_FIRSTBOOT_UPDATES="${PIKIT_FIRSTBOOT_UPDATES:-1}"
|
||||
}
|
||||
|
||||
skip_updates() {
|
||||
case "${PIKIT_FIRSTBOOT_UPDATES,,}" in
|
||||
0|false|no|off) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
configure_unattended_defaults() {
|
||||
if [ -f "$APT_UA_OVERRIDE" ]; then
|
||||
log "Unattended-upgrades config already present; skipping defaults."
|
||||
return
|
||||
fi
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
log "python3 missing; skipping unattended-upgrades defaults."
|
||||
return
|
||||
fi
|
||||
PYTHONPATH=/usr/local/bin python3 - <<'PY'
|
||||
import sys
|
||||
try:
|
||||
from pikit_api.auto_updates import set_updates_config
|
||||
except Exception as e:
|
||||
print(f"pikit_api unavailable: {e}")
|
||||
sys.exit(0)
|
||||
|
||||
set_updates_config({"enable": True, "scope": "security"})
|
||||
PY
|
||||
log "Unattended-upgrades defaults applied (security-only)."
|
||||
}
|
||||
|
||||
write_state() {
|
||||
local state="$1"
|
||||
local current="$2"
|
||||
local steps_joined
|
||||
local status_joined
|
||||
steps_joined=$(IFS='|'; echo "${STEPS[*]}")
|
||||
status_joined=$(IFS='|'; echo "${STEP_STATUS[*]}")
|
||||
PIKIT_STATE_FILE="$STATE_FILE" PIKIT_STATE="$state" PIKIT_CURRENT_STEP="$current" PIKIT_STEPS="$steps_joined" PIKIT_STEP_STATUSES="$status_joined" \
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
from datetime import datetime, timezone
|
||||
|
||||
state_path = pathlib.Path(os.environ["PIKIT_STATE_FILE"]) if "PIKIT_STATE_FILE" in os.environ else pathlib.Path("/var/lib/pikit/firstboot/state.json")
|
||||
state = os.environ.get("PIKIT_STATE", "running")
|
||||
current = os.environ.get("PIKIT_CURRENT_STEP") or None
|
||||
steps = (os.environ.get("PIKIT_STEPS") or "").split("|")
|
||||
statuses = (os.environ.get("PIKIT_STEP_STATUSES") or "").split("|")
|
||||
if len(statuses) < len(steps):
|
||||
statuses += ["pending"] * (len(steps) - len(statuses))
|
||||
updated_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
state_path.write_text(json.dumps({
|
||||
"state": state,
|
||||
"current_step": current,
|
||||
"steps": [{"label": label, "status": status} for label, status in zip(steps, statuses)],
|
||||
"updated_at": updated_at,
|
||||
}, indent=2))
|
||||
PY
|
||||
}
|
||||
|
||||
begin_step() {
|
||||
local idx="$1"
|
||||
if [ "$CURRENT_INDEX" -ge 0 ]; then
|
||||
STEP_STATUS[$CURRENT_INDEX]="done"
|
||||
fi
|
||||
CURRENT_INDEX="$idx"
|
||||
CURRENT_STEP="${STEPS[$idx]}"
|
||||
STEP_STATUS[$idx]="current"
|
||||
write_state "running" "$CURRENT_STEP"
|
||||
log "--- $CURRENT_STEP ---"
|
||||
}
|
||||
|
||||
finish_step() {
|
||||
local idx="$1"
|
||||
local state="${2:-running}"
|
||||
local current="${3:-$CURRENT_STEP}"
|
||||
STEP_STATUS[$idx]="done"
|
||||
write_state "$state" "$current"
|
||||
}
|
||||
|
||||
clear_motd_block() {
|
||||
if [ -f "$MOTD_FILE" ]; then
|
||||
sed -i '/^\[Pi-Kit firstboot\]/,/^\[\/Pi-Kit firstboot\]/d' "$MOTD_FILE" || true
|
||||
fi
|
||||
}
|
||||
|
||||
write_motd_error() {
|
||||
clear_motd_block
|
||||
cat >> "$MOTD_FILE" <<'TXT'
|
||||
[Pi-Kit firstboot]
|
||||
Pi-Kit setup needs attention.
|
||||
Error log: sudo cat /var/lib/pikit/firstboot/firstboot.error
|
||||
Full log: sudo cat /var/lib/pikit/firstboot/firstboot.log
|
||||
If needed: sudo systemctl restart nginx pikit-api
|
||||
[/Pi-Kit firstboot]
|
||||
TXT
|
||||
}
|
||||
|
||||
handle_error() {
|
||||
local line="$1"
|
||||
local msg="Firstboot failed at step: ${CURRENT_STEP:-unknown} (line $line)"
|
||||
log "$msg"
|
||||
STEP_STATUS[$CURRENT_INDEX]="error"
|
||||
write_state "error" "${CURRENT_STEP:-}" || true
|
||||
echo "$msg" > "$ERROR_FILE"
|
||||
if [ -f "$LOG_FILE" ]; then
|
||||
echo "--- recent log ---" >> "$ERROR_FILE"
|
||||
tail -n 120 "$LOG_FILE" >> "$ERROR_FILE"
|
||||
fi
|
||||
write_motd_error
|
||||
exit 1
|
||||
}
|
||||
|
||||
mkdir -p "$FIRSTBOOT_DIR"
|
||||
:> "$LOG_FILE"
|
||||
exec >>"$LOG_FILE" 2>&1
|
||||
|
||||
log "Pi-Kit firstboot starting"
|
||||
load_config
|
||||
|
||||
if [ -f "$DONE_FILE" ]; then
|
||||
log "Firstboot already completed; exiting."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
rm -f "$ERROR_FILE"
|
||||
clear_motd_block
|
||||
|
||||
exec 9>"$LOCK_FILE"
|
||||
if ! flock -n 9; then
|
||||
log "Another firstboot run is in progress; exiting."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
trap 'handle_error $LINENO' ERR
|
||||
|
||||
begin_step 0
|
||||
mkdir -p "$CERT_DIR" "$WEB_ASSETS"
|
||||
if getent group pikit-cert >/dev/null 2>&1; then
|
||||
chgrp pikit-cert "$CERT_DIR" || true
|
||||
fi
|
||||
chmod 750 "$CERT_DIR"
|
||||
finish_step 0
|
||||
|
||||
begin_step 1
|
||||
if [ -x /usr/local/bin/pikit-certgen.sh ]; then
|
||||
/usr/local/bin/pikit-certgen.sh
|
||||
else
|
||||
if [ -s "$CERT_DIR/pikit-ca.crt" ] && [ -s "$CERT_DIR/pikit-ca.key" ] && [ -s "$CERT_DIR/pikit.local.crt" ] && [ -s "$CERT_DIR/pikit.local.key" ]; then
|
||||
log "TLS certs already present; skipping generation."
|
||||
else
|
||||
if ! command -v openssl >/dev/null 2>&1; then
|
||||
echo "openssl not installed" >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$CERT_DIR/pikit-ca.key" "$CERT_DIR/pikit-ca.crt" "$CERT_DIR/pikit-ca.srl" || true
|
||||
rm -f "$CERT_DIR/pikit.local.key" "$CERT_DIR/pikit.local.crt" "$CERT_DIR/pikit.local.csr" || true
|
||||
|
||||
openssl genrsa -out "$CERT_DIR/pikit-ca.key" 2048
|
||||
openssl req -x509 -new -nodes -key "$CERT_DIR/pikit-ca.key" -sha256 -days 3650 \
|
||||
-out "$CERT_DIR/pikit-ca.crt" -subj "/CN=Pi-Kit CA"
|
||||
|
||||
openssl genrsa -out "$CERT_DIR/pikit.local.key" 2048
|
||||
openssl req -new -key "$CERT_DIR/pikit.local.key" -out "$CERT_DIR/pikit.local.csr" -subj "/CN=pikit.local"
|
||||
|
||||
SAN_CFG=$(mktemp)
|
||||
cat > "$SAN_CFG" <<'CFG'
|
||||
authorityKeyIdentifier=keyid,issuer
|
||||
basicConstraints=CA:FALSE
|
||||
keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
|
||||
subjectAltName = @alt_names
|
||||
|
||||
[alt_names]
|
||||
DNS.1 = pikit.local
|
||||
DNS.2 = pikit
|
||||
CFG
|
||||
|
||||
openssl x509 -req -in "$CERT_DIR/pikit.local.csr" -CA "$CERT_DIR/pikit-ca.crt" -CAkey "$CERT_DIR/pikit-ca.key" \
|
||||
-CAcreateserial -out "$CERT_DIR/pikit.local.crt" -days 825 -sha256 -extfile "$SAN_CFG"
|
||||
rm -f "$SAN_CFG" "$CERT_DIR/pikit.local.csr"
|
||||
|
||||
chmod 600 "$CERT_DIR/pikit-ca.key" "$CERT_DIR/pikit.local.key"
|
||||
chmod 644 "$CERT_DIR/pikit-ca.crt" "$CERT_DIR/pikit.local.crt"
|
||||
fi
|
||||
fi
|
||||
finish_step 1
|
||||
|
||||
begin_step 2
|
||||
cp "$CERT_DIR/pikit-ca.crt" "$WEB_ASSETS/pikit-ca.crt"
|
||||
chmod 644 "$WEB_ASSETS/pikit-ca.crt"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$WEB_ASSETS/pikit-ca.crt" | awk '{print $1}' > "$WEB_ASSETS/pikit-ca.sha256"
|
||||
elif command -v openssl >/dev/null 2>&1; then
|
||||
openssl dgst -sha256 "$WEB_ASSETS/pikit-ca.crt" | awk '{print $2}' > "$WEB_ASSETS/pikit-ca.sha256"
|
||||
fi
|
||||
if [ -s "$WEB_ASSETS/pikit-ca.sha256" ]; then
|
||||
chmod 644 "$WEB_ASSETS/pikit-ca.sha256"
|
||||
fi
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl reload nginx || systemctl restart nginx
|
||||
fi
|
||||
finish_step 2
|
||||
|
||||
begin_step 3
|
||||
if skip_updates; then
|
||||
log "Skipping software updates (PIKIT_FIRSTBOOT_UPDATES=$PIKIT_FIRSTBOOT_UPDATES)."
|
||||
else
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
mkdir -p /var/cache/apt/archives/partial /var/lib/apt/lists/partial
|
||||
chmod 755 /var/cache/apt/archives /var/cache/apt/archives/partial /var/lib/apt/lists /var/lib/apt/lists/partial
|
||||
apt-get update
|
||||
apt-get -y -o Dpkg::Options::=--force-confold full-upgrade
|
||||
fi
|
||||
finish_step 3
|
||||
|
||||
begin_step 4
|
||||
configure_unattended_defaults
|
||||
if [ -f "$PROFILE_FILE" ] && command -v ufw >/dev/null 2>&1; then
|
||||
python3 - <<'PY' > /tmp/pikit-profile-ports.txt
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
profile = Path("/etc/pikit/profile.json")
|
||||
try:
|
||||
data = json.loads(profile.read_text())
|
||||
except Exception:
|
||||
data = {}
|
||||
ports = data.get("firewall_ports") or []
|
||||
for port in ports:
|
||||
try:
|
||||
port_int = int(port)
|
||||
except Exception:
|
||||
continue
|
||||
print(port_int)
|
||||
PY
|
||||
while read -r port; do
|
||||
[ -z "$port" ] && continue
|
||||
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; do
|
||||
ufw allow from "$subnet" to any port "$port" || true
|
||||
done
|
||||
done < /tmp/pikit-profile-ports.txt
|
||||
rm -f /tmp/pikit-profile-ports.txt
|
||||
else
|
||||
log "Profile firewall step skipped (no profile.json or ufw missing)"
|
||||
fi
|
||||
|
||||
if [ ! -f "$WEB_ASSETS/pikit-ca.crt" ]; then
|
||||
echo "CA bundle missing in web assets" >&2
|
||||
exit 1
|
||||
fi
|
||||
finish_step 4
|
||||
|
||||
begin_step 5
|
||||
touch "$DONE_FILE"
|
||||
touch /var/run/pikit-ready
|
||||
finish_step 5 "done" "${STEPS[5]}"
|
||||
|
||||
log "Pi-Kit firstboot complete"
|
||||
exit 0
|
||||
10
systemd/pikit-ready.service
Normal file
10
systemd/pikit-ready.service
Normal file
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Pi-Kit ready flag helper
|
||||
ConditionPathExists=/var/lib/pikit/firstboot/firstboot.done
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/pikit-ready.sh
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
11
systemd/pikit-ready.sh
Executable file
11
systemd/pikit-ready.sh
Executable file
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Touches /var/run/pikit-ready on boot when firstboot is complete.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DONE_FILE="/var/lib/pikit/firstboot/firstboot.done"
|
||||
READY_FILE="/var/run/pikit-ready"
|
||||
|
||||
if [ -f "$DONE_FILE" ]; then
|
||||
touch "$READY_FILE"
|
||||
fi
|
||||
10
systemd/pikit-ssh-keygen.service
Normal file
10
systemd/pikit-ssh-keygen.service
Normal file
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=Generate SSH host keys if missing
|
||||
Before=ssh.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/ssh-keygen -A
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -34,7 +34,7 @@ Environment=PIKIT_MANIFEST_URL=https://git.44r0n.cc/44r0n7/pi-kit/releases/downl
|
||||
|
||||
## What’s inside the bundle
|
||||
- `pikit-web/` (built static assets)
|
||||
- `pikit-api.py`
|
||||
- `pikit-api.py` + `pikit_api/` package
|
||||
- optional helper scripts (e.g., `set_ready.sh`, `start-codex.sh`, `pikit-services.json` if present)
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -34,9 +34,8 @@ rsync -a --delete \
|
||||
"$ROOT/pikit-web/" "$STAGE/pikit-web/"
|
||||
|
||||
cp "$ROOT/pikit-api.py" "$STAGE/"
|
||||
rsync -a "$ROOT/pikit_api/" "$STAGE/pikit_api/"
|
||||
cp "$ROOT/pikit-services.json" "$STAGE/" 2>/dev/null || true
|
||||
cp "$ROOT/set_ready.sh" "$STAGE/" 2>/dev/null || true
|
||||
cp "$ROOT/start-codex.sh" "$STAGE/" 2>/dev/null || true
|
||||
|
||||
# Include version marker
|
||||
if [[ -f "$ROOT/pikit-web/data/version.json" ]]; then
|
||||
|
||||
Reference in New Issue
Block a user