45 Commits

Author SHA1 Message Date
Aaron
f4090cbf1d Fix copy log button 2025-12-14 19:23:31 -05:00
Aaron
f4d0765c93 Release 0.1.3-dev5 for updater testing 2025-12-14 19:18:01 -05:00
Aaron
99bd87c7f6 Release 0.1.3-dev4: updater resilience 2025-12-14 19:16:29 -05:00
Aaron
e87b90bf9f Auto-clear stale updater state 2025-12-14 19:07:47 -05:00
Aaron
8557140193 Avoid restarting API during installs 2025-12-14 19:01:46 -05:00
Aaron
86438b11f3 Handle stale updater lockfiles by removing dead PID entries 2025-12-14 18:56:15 -05:00
Aaron
3a785832b1 Bump to 0.1.3-dev3 and update dev manifest 2025-12-14 18:51:03 -05:00
Aaron
a94cd17186 Redesign updater UI with manual version picker and status bar 2025-12-14 18:48:00 -05:00
Aaron
b01bfcd38e Replace rollback with manual version selection and simplify updater 2025-12-14 18:20:11 -05:00
Aaron
831a98c5a1 Document release build and OTA packaging steps 2025-12-14 18:05:55 -05:00
Aaron
daea783d38 Allow dev manifest selection when using non-releases URLs 2025-12-14 18:02:36 -05:00
Aaron
90d3e5676a Import DEFAULT_DEV_MANIFEST_URL for dev channel fetch 2025-12-14 18:01:14 -05:00
Aaron
8a054c5d85 Default manifests to public raw files (no token needed) 2025-12-14 17:59:50 -05:00
Aaron
009ac8cdd0 Retry manifest fetch with access_token on 404 when token present 2025-12-14 17:37:35 -05:00
Aaron
7a9ffb710a Point default manifests to public release assets 2025-12-14 17:36:30 -05:00
Aaron
15da438625 Fix dev channel selection to only use dev manifest when allowed 2025-12-14 17:35:47 -05:00
Aaron
50ddc3e211 Use stable manifest by default; only load dev manifest when dev channel enabled 2025-12-14 17:34:24 -05:00
Aaron
e7a79246b8 Publish dev/stable manifest files with release dates 2025-12-14 17:30:38 -05:00
Aaron
bb2fb2dcf2 Add public dev manifest fallback and bundle manifests 2025-12-14 17:28:57 -05:00
Aaron
222f6f9e77 Bump version to 0.1.3-dev1 2025-12-14 17:23:07 -05:00
Aaron
250ea2e00d Improve updater version selection and surface release dates 2025-12-14 17:16:52 -05:00
Aaron
b70f4c5f3f chore: prep 0.1.2 release, tidy repo 2025-12-13 17:04:32 -05:00
Aaron
0c69e9bf90 Onboarding: escape pi-ip placeholder 2025-12-13 15:29:07 -05:00
Aaron
d8673a9fb4 Onboarding: literal <pi-kit ip> fallback 2025-12-13 15:27:41 -05:00
Aaron
1e8e8a5bc2 Onboarding: clarify IP fallback text 2025-12-13 15:26:02 -05:00
Aaron
e3c6c3b308 Onboarding: remove duplicate CTA button 2025-12-13 15:22:22 -05:00
Aaron
a05aa70069 Onboarding: remove OS auto-open and QR, keep simple CTA 2025-12-13 15:20:33 -05:00
Aaron
06e8e25aad Onboarding: clean QR block, copy button label restore 2025-12-13 15:17:39 -05:00
Aaron
4f05f58f45 Onboarding: fix QR, simplify Linux section 2025-12-13 15:09:48 -05:00
Aaron
eaf261a6be Onboarding: HTTP QR + better Arch/Endeavour detect 2025-12-13 15:06:00 -05:00
Aaron
798d78cb13 Onboarding: better OS auto-open, move QR to its own row 2025-12-13 14:56:48 -05:00
Aaron
bcb6f3005d Onboarding: status chip, QR, copy feedback, platform auto-open 2025-12-13 14:45:46 -05:00
Aaron
b911171045 Onboarding: add spacing under CA download button 2025-12-13 14:38:00 -05:00
Aaron
2cfb9779d6 Onboarding: center CTA, move CA download into install section 2025-12-13 14:36:29 -05:00
Aaron
08cf472bf7 Onboarding: center CTA, remove badges 2025-12-13 14:33:39 -05:00
Aaron
471e242427 Onboarding: rely on https cookie redirect, remove DietPi mentions 2025-12-13 14:13:51 -05:00
Aaron
357453eed4 Onboarding: set https cookie + auto redirect 2025-12-13 14:07:56 -05:00
Aaron
25cc888b86 Onboarding: accordion + distro list updates 2025-12-13 14:01:12 -05:00
Aaron
17ae87563f Onboarding: fix CSS path and keep HTTP CA link 2025-12-13 13:56:21 -05:00
Aaron
32503424e8 Onboarding: explain HTTPS, allow HTTP CA download 2025-12-13 13:54:10 -05:00
Aaron
50be46df45 Revamp onboarding page styling 2025-12-13 13:51:25 -05:00
Aaron
8c06962f62 Add HTTPS onboarding page; prefer .local host for service URLs 2025-12-13 13:44:01 -05:00
Aaron
2a439321d0 Add tooltips to diagnostics controls and service form inputs 2025-12-13 12:27:57 -05:00
Aaron
e993d19886 Clear diagnostics UI immediately, then refresh 2025-12-13 12:24:58 -05:00
Aaron
0e3b144cd7 Hide/disable diagnostics log button when diag is off 2025-12-13 12:20:52 -05:00
49 changed files with 5159 additions and 4185 deletions

3
.gitignore vendored
View File

@@ -26,6 +26,3 @@ out/
# Stock images (large)
images/stock/
# Local helpers
set_ready.sh

81
PLAN.md
View File

@@ -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).

View File

@@ -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/`.
## Whats 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.

View File

@@ -0,0 +1,9 @@
{
"version": "0.1.3-dev5",
"_release_date": "2025-12-15T00:17:16Z",
"bundle": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.3-dev5/pikit-0.1.3-dev5.tar.gz",
"changelog": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.3-dev5/CHANGELOG-0.1.3-dev5.txt",
"files": [
{ "path": "bundle.tar.gz", "sha256": "28816637f1ead0275c53cdc8a13e97aeecf023e7e0ba78331ce8ac139882ffd5" }
]
}

View 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" }
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -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 scripts job:
Prepare a running Pi-Kit system to be cloned as a “golden image” **without** removing any intentional software, configs, hostname, or passwords.
---
## 0. Context & Goals
**Starting point**
- OS: DietPi (Debian-based), already installed.
- Extra software: web stack, Pi-Kit dashboard, DNS/ad-blocker, DBs, monitoring, etc.
- System has been used for testing (logs, histories, test data, junk).
**Goal**
- Prepare system for cloning as a product image.
- **KEEP**:
- All intentionally installed packages/software.
- All custom configs (web, apps, DietPi configs, firewall).
- Current hostname.
- Existing passwords (system + services) as shipped defaults.
- **RESET/CLEAR**:
- Host-unique identity data (machine-id, SSH host keys, etc.).
- Logs, histories, caches.
- Test/personal accounts and data.
---
## 1. Discovery Phase (MUST HAPPEN BEFORE SCRIPT DESIGN)
Before writing any code, inspect the system and external docs.
The AI MUST:
1. **Detect installed components**
- Determine which key packages/services are present, e.g.:
- Web server (nginx, lighttpd, apache2, etc.).
- DNS/ad-blocker (Pi-hole or similar).
- DB engines (MariaDB, PostgreSQL, SQLite usage).
- Monitoring/metrics (Netdata, Uptime Kuma, etc.).
- Use this to decide which cleanup sections apply.
2. **Verify paths/layouts**
- For each service or category:
- Confirm relevant paths/directories actually exist.
- Do not assume standard paths without checking.
- Example: Only treat `/var/log/nginx` as Nginx logs if:
- Nginx is installed, AND
- That directory exists.
3. **Consult upstream docs (online)**
- Check current:
- DietPi docs and/or DietPi GitHub.
- Docs for major services (e.g. Pi-hole, Nginx, MariaDB, etc.).
- Use docs to confirm:
- Data vs config locations.
- Safe cache/log cleanup methods.
- Prefer documented behavior over guesses.
4. **Classify actions**
- For each potential cleanup:
- Mark as **safe** if clearly understood and documented.
- Mark as **uncertain** if layout deviates or docs are unclear.
- Plan to:
- Perform safe actions.
- Skip uncertain actions and surface them for manual review.
5. **Fail safe**
- If something doesnt match expectations:
- Do NOT plan a destructive operation on it.
- Flag it as “needs manual review” in the confirmation phase.
---
## 2. Identity & Host-Specific Secrets
**DO NOT CHANGE:**
- Hostname (whatever it currently is).
- Any existing passwords (system or service-level) that are part of the appliance defaults.
**RESET/CLEAR:**
1. **Machine identity**
- Clear:
- `/etc/machine-id`
- `/var/lib/dbus/machine-id` (if present)
- Rely on OS to recreate them on next boot.
2. **Random seed**
- Clear persisted random seed (e.g. `/var/lib/systemd/random-seed`) so each clone gets unique entropy.
3. **SSH host keys**
- Remove all SSH **host key** files (server keys only).
- Leave user SSH keypairs unless explicitly identified as dev/test and safe to remove.
4. **SSH known_hosts**
- Clear `known_hosts` for:
- `root`
- `dietpi` (or primary DietPi user)
- Any other persistent users
5. **VPN keys (conditional)**
- If keys are meant to be unique per device:
- Remove WireGuard/OpenVPN private keys and per-device configs embedding them.
- If the design requires fixed server keys:
- KEEP server keys.
- REMOVE test/client keys/profiles that are tied to dev use.
6. **TLS certificates (conditional)**
- REMOVE:
- Lets Encrypt/ACME certs tied to personal domains.
- Per-device self-signed certs that should regenerate.
- KEEP:
- Shared CAs/certs only if explicitly part of product design.
---
## 3. Users & Personal Traces
1. **Accounts**
- KEEP:
- Accounts that are part of the product.
- REMOVE:
- Test-only accounts (users created for dev/debug).
2. **Shell histories**
- Clear shell histories for all remaining users:
- `root`, `dietpi`, others that stay.
3. **Home directories**
- For users that remain:
- KEEP:
- Intentional config/dotfiles (shell rc, app config, etc.).
- REMOVE:
- Downloads, random files, scratch notes.
- Editor backup/swap files, stray temp files.
- Debug dumps, one-off scripts not part of product.
- For users that are removed:
- Delete their home dirs entirely.
4. **SSH client keys**
- REMOVE:
- Clearly personal/test keys (e.g. with your email in comments).
- KEEP:
- Only keys explicitly required by product design.
---
## 4. Logs & Telemetry
1. **System logs**
- Clear:
- Systemd journal (persistent logs).
- `/var/log` files + rotated/compressed variants, where safe.
2. **Service logs**
- For installed services (web servers, DNS/ad-blockers, DBs, etc.):
- Clear their log files and rotated versions.
3. **Monitoring/metrics**
- For tools like Netdata, Uptime Kuma, etc.:
- KEEP:
- Config, target definitions.
- CLEAR:
- Historical metric/alert data (TSDBs, history files, etc.).
---
## 5. Package Manager & Caches
1. **APT**
- Clear:
- Downloaded `.deb` archives.
- Safe APT caches (as per documentation).
2. **Other caches**
- Under `/var/cache` and `~/.cache`:
- CLEAR:
- Caches known to be safe and auto-regenerated.
- DO NOT CLEAR:
- Caches that are required for correct functioning or very expensive to rebuild, unless docs confirm safety.
3. **Temp directories**
- Empty:
- `/tmp`
- `/var/tmp`
4. **Crash dumps**
- Remove crash dumps and core files (e.g. `/var/crash` and similar locations).
---
## 6. Service Data vs Config (Per-App Logic)
General rule:
> Keep configuration & structure. Remove dev/test data, history, and personal content.
The AI must apply this using detected services + docs.
### 6.1 Web Servers (nginx / lighttpd / apache2)
- KEEP:
- Main config and site configs that define Pi-Kit behavior.
- App code in `/var/www/...` (or equivalent Pi-Kit web root).
- CLEAR:
- Access/error logs.
- Non-critical caches if docs confirm theyre safe to recreate.
### 6.2 DNS / Ad-blockers (Pi-hole or similar)
- KEEP:
- Upstream DNS settings.
- Blocklists / adlists / local DNS overrides.
- DHCP config if it is part of the products behavior.
- CLEAR:
- Query history / statistics DB.
- Log files.
- DO NOT:
- Change the current admin password (it is the product default).
### 6.3 Databases (MariaDB, PostgreSQL, SQLite, etc.)
- KEEP:
- DB schema.
- Seed/default data required for every user.
- REMOVE/RESET:
- Dev/test user accounts (with your email, etc.).
- Test content/records not meant for production image.
- Access tokens, session records, API keys tied to dev use.
- For SQLite-based apps:
- Decide per app (based on docs) whether to:
- Ship a pre-seeded “clean” DB, OR
- Let it auto-create DB on first run.
### 6.4 Other services (Nextcloud, Jellyfin, Gotify, Uptime Kuma, etc.)
For each detected service:
- KEEP:
- Global config, ports, base URLs, application settings needed for Pi-Kit.
- CLEAR:
- Personal/dev user accounts.
- Your media/content (unless intentionally shipping sample content).
- Notification endpoints tied to your own email / Gotify / Telegram, unless explicitly desired.
If docs or structure are unclear, mark cleanup as **uncertain** and surface in confirmation instead of guessing.
---
## 7. Networking & Firewall
**HARD CONSTRAINTS:**
- Do NOT modify hostname.
- Do NOT weaken/remove the product firewall rules.
1. **Firewall**
- Detect firewall system in use (iptables, nftables, UFW, etc.).
- KEEP:
- All persistent firewall configs that define Pi-Kits security behavior.
- DO NOT:
- Flush or reset firewall rules unless its clearly a dev-only configuration (and thats confirmed).
2. **Other networking state**
- Safe to CLEAR:
- DHCP lease files.
- DNS caches.
- DO NOT ALTER:
- Static IP/bridge/VLAN config that appears to be part of the intended appliance setup.
---
## 8. DietPi-Specific State & First-Boot Behavior
1. **DietPi automation/config**
- Identify DietPi automation configuration (e.g. `dietpi.txt`, related files).
- KEEP:
- The intended defaults (locale, timezone, etc.).
- Any automation that is part of Pi-Kit behavior.
- AVOID:
- Re-triggering DietPis generic first-boot flow unless that is intentionally desired.
2. **DietPi logs/temp**
- CLEAR:
- DietPi-specific logs and temp files.
- KEEP:
- All DietPi configuration and automation files.
3. **Pi-Kit first-boot logic**
- Ensure any Pi-Kit specific first-run services/hooks are:
- Enabled.
- Not dependent on data being cleaned (e.g., they must not require removed dev tokens/paths).
---
## 9. Shell & Tooling State
1. **Tool caches**
- For root and main user(s), CLEAR:
- Safe caches in `~/.cache` (pip, npm, cargo, etc.), if not needed at runtime.
- Avoid clearing caches that are critical or painful to rebuild unless doc-backed.
2. **Build artifacts**
- REMOVE:
- Source trees, build directories, and other dev artifacts that are not part of final product.
3. **Cronjobs / timers**
- Audit:
- User crontabs.
- System crontabs.
- Systemd timers.
- KEEP:
- Jobs that are part of Pi-Kit behavior.
- REMOVE:
- Jobs/timers clearly used for dev/testing only.
---
## 10. Implementation Requirements (For the Future Script)
When generating the actual script, the AI MUST:
1. **Error handling**
- Check exit statuses where relevant.
- Handle missing paths/directories gracefully:
- If a path doesnt exist, skip and log; do not fail hard.
- Avoid wide-destructive operations without validation:
- No “blind” deletions on unverified globs.
2. **Idempotency**
- Script can run multiple times without progressively breaking the system.
- After repeated runs, image should remain valid and “clean”.
3. **Conservative behavior**
- If uncertain about an operation:
- Do NOT perform it.
- Log a warning and mark for manual review.
4. **Logging**
- For each major category (identity, logs, caches, per-service cleanup, etc.):
- Log what was targeted and outcome:
- `cleaned`
- `skipped (not installed/not found)`
- `skipped (uncertain; manual review)`
- Provide a summary at the end.
---
## 11. Mandatory Pre-Script Confirmation Step
**Before writing any script, the AI MUST:**
1. **Present a system-specific plan**
- Based on discovery + docs, list:
- Exactly which paths, files, DBs, and data types it intends to:
- Remove
- Reset
- Leave untouched
- For each item or group: a short explanation of **why**.
2. **Highlight conflicts / ambiguities**
- If any cleanup might:
- Affect passwords,
- Affect hostname,
- Affect firewall rules,
- Or contradict this spec in any way,
- The AI must:
- Call it out explicitly.
- Explain tradeoffs and propose a safe option.
3. **Highlight extra opportunities**
- If the AI finds additional cleanup opportunities not explicitly listed here (e.g., new DietPi features, new log paths):
- Describe them clearly.
- Explain pros/cons of adding them.
- Ask whether to include them.
4. **Wait for explicit approval**
- Do NOT generate the script until:
- The user (me) has reviewed the plan.
- Conflicts and extra opportunities have been discussed.
- Explicit approval (with any modifications) has been given.
Only after that confirmation may the AI produce the actual prep script.
---

View File

@@ -53,10 +53,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",

View 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;
}

View 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;
}

View 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;
}

View File

@@ -0,0 +1,326 @@
.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);
}
.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;
}

View 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;
}

View 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));
}
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View File

@@ -93,22 +93,22 @@ export async function initDiagUI({ elements, toast }) {
if (statusEl) statusEl.textContent = `${merged.length} entries`;
}
async function refresh() {
async function refresh({ silent = false } = {}) {
if (loading) return;
setBusy(true);
try {
const entries = await syncState();
render(entries);
toast?.("Diagnostics refreshed", "success");
if (!silent) toast?.("Diagnostics refreshed", "success");
} catch (e) {
toast?.(e.error || "Failed to load diagnostics", "error");
if (!silent) toast?.(e.error || "Failed to load diagnostics", "error");
// retry once if failed
try {
const entries = await syncState();
render(entries);
toast?.("Diagnostics refreshed (after retry)", "success");
if (!silent) toast?.("Diagnostics refreshed (after retry)", "success");
} catch (err2) {
toast?.(err2.error || "Diagnostics still failing", "error");
if (!silent) toast?.(err2.error || "Diagnostics still failing", "error");
}
} finally {
setBusy(false);
@@ -131,6 +131,8 @@ export async function initDiagUI({ elements, toast }) {
} finally {
setBusy(false);
}
if (logButton) logButton.classList.toggle("hidden", !uiEnabled);
if (!uiEnabled && modal) modal.classList.add("hidden");
});
debugToggle?.addEventListener("change", async () => {
@@ -157,6 +159,9 @@ export async function initDiagUI({ elements, toast }) {
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");
@@ -202,7 +207,7 @@ export async function initDiagUI({ elements, toast }) {
// initial load
attachClickTracker();
await refresh();
await refresh({ silent: true });
logButton?.addEventListener("click", () => {
if (!uiEnabled) return;

View File

@@ -1,12 +1,21 @@
// Entry point for the dashboard: wires UI events, pulls status, and initializes
// feature modules (services, settings, stats).
import { getStatus, triggerReset } from "./api.js";
import { initServiceControls } from "./services.js";
import { placeholderStatus, renderStats } from "./status.js";
import { initServiceControls, renderServices } from "./services.js";
import { initSettings } from "./settings.js";
import { initUpdateSettings, isUpdatesDirty } from "./update-settings.js";
import { initReleaseUI } from "./releases.js?v=20251213h";
import { initDiagUI, logUi } from "./diaglog.js?v=20251213j";
import { createToastManager } from "./toast.js?v=20251213a";
import {
applyTooltips,
wireModalPairs,
wireAccordions,
createBusyOverlay,
createConfirmModal,
} from "./ui.js";
import { createStatusController } from "./status-controller.js";
const servicesGrid = document.getElementById("servicesGrid");
const heroStats = document.getElementById("heroStats");
@@ -32,6 +41,7 @@ const toastPosSelect = document.getElementById("toastPosSelect");
const toastAnimSelect = document.getElementById("toastAnimSelect");
const toastSpeedInput = document.getElementById("toastSpeedInput");
const toastDurationInput = document.getElementById("toastDurationInput");
const toastTestBtn = document.getElementById("toastTestBtn");
const fontSelect = document.getElementById("fontSelect");
const updatesScope = document.getElementById("updatesScope");
const updateTimeInput = document.getElementById("updateTimeInput");
@@ -111,147 +121,99 @@ const diagModal = document.getElementById("diagModal");
const diagClose = document.getElementById("diagClose");
const diagStatusModal = document.getElementById("diagStatusModal");
const TOAST_POS_KEY = "pikit-toast-pos";
const TOAST_ANIM_KEY = "pikit-toast-anim";
const TOAST_SPEED_KEY = "pikit-toast-speed";
const TOAST_DURATION_KEY = "pikit-toast-duration";
const FONT_KEY = "pikit-font";
const ALLOWED_TOAST_POS = [
"bottom-center",
"bottom-right",
"bottom-left",
"top-right",
"top-left",
"top-center",
];
const ALLOWED_TOAST_ANIM = ["slide-in", "fade", "pop", "bounce", "drop", "grow"];
const ALLOWED_FONTS = ["redhat", "space", "manrope", "dmsans", "sora", "chivo", "atkinson", "plex"];
let toastPosition = "bottom-center";
let toastAnimation = "slide-in";
let toastDurationMs = 5000;
let toastSpeedMs = 300;
let fontChoice = "redhat";
const toastController = createToastManager({
container: toastContainer,
posSelect: toastPosSelect,
animSelect: toastAnimSelect,
speedInput: toastSpeedInput,
durationInput: toastDurationInput,
fontSelect,
testBtn: toastTestBtn,
});
const showToast = toastController.showToast;
let releaseUI = null;
let lastStatusData = null;
const { showBusy, hideBusy } = createBusyOverlay({
overlay: busyOverlay,
titleEl: busyTitle,
textEl: busyText,
});
const confirmAction = createConfirmModal({
modal: confirmModal,
titleEl: confirmTitle,
bodyEl: confirmBody,
okBtn: confirmOk,
cancelBtn: confirmCancel,
});
const collapseAccordions = () => document.querySelectorAll(".accordion").forEach((a) => a.classList.remove("open"));
function applyToastSettings() {
if (!toastContainer) return;
toastContainer.className = `toast-container pos-${toastPosition}`;
document.documentElement.style.setProperty("--toast-speed", `${toastSpeedMs}ms`);
const dir = toastPosition.startsWith("top") ? -1 : 1;
const isLeft = toastPosition.includes("left");
const isRight = toastPosition.includes("right");
const slideX = isLeft ? -26 : isRight ? 26 : 0;
const slideY = isLeft || isRight ? 0 : dir * 24;
document.documentElement.style.setProperty("--toast-slide-offset", `${dir * 24}px`);
document.documentElement.style.setProperty("--toast-dir", `${dir}`);
document.documentElement.style.setProperty("--toast-slide-x", `${slideX}px`);
document.documentElement.style.setProperty("--toast-slide-y", `${slideY}px`);
if (toastDurationInput) toastDurationInput.value = toastDurationMs;
}
const statusController = createStatusController({
heroStats,
servicesGrid,
updatesFlagTop,
updatesNoteTop,
tempFlagTop,
readyOverlay,
logUi,
getStatus,
isUpdatesDirty,
setUpdatesUI,
updatesFlagEl: setUpdatesFlag,
releaseUIGetter: () => releaseUI,
onReadyWait: () => setTimeout(() => statusController.loadStatus(), 3000),
});
const { loadStatus } = statusController;
function applyFontSetting() {
document.documentElement.setAttribute("data-font", fontChoice);
if (fontSelect) fontSelect.value = fontChoice;
function 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;
}
function loadToastSettings() {
try {
const posSaved = localStorage.getItem(TOAST_POS_KEY);
if (ALLOWED_TOAST_POS.includes(posSaved)) toastPosition = posSaved;
const animSaved = localStorage.getItem(TOAST_ANIM_KEY);
const migrated =
animSaved === "slide-up" || animSaved === "slide-left" || animSaved === "slide-right"
? "slide-in"
: animSaved;
if (migrated && ALLOWED_TOAST_ANIM.includes(migrated)) toastAnimation = migrated;
const savedSpeed = Number(localStorage.getItem(TOAST_SPEED_KEY));
if (!Number.isNaN(savedSpeed) && savedSpeed >= 100 && savedSpeed <= 3000) {
toastSpeedMs = savedSpeed;
}
const savedDur = Number(localStorage.getItem(TOAST_DURATION_KEY));
if (!Number.isNaN(savedDur) && savedDur >= 1000 && savedDur <= 15000) {
toastDurationMs = savedDur;
}
const savedFont = localStorage.getItem(FONT_KEY);
if (ALLOWED_FONTS.includes(savedFont)) fontChoice = savedFont;
} catch (e) {
console.warn("Toast settings load failed", e);
}
if (toastPosSelect) toastPosSelect.value = toastPosition;
if (toastAnimSelect) toastAnimSelect.value = toastAnimation;
if (toastSpeedInput) toastSpeedInput.value = toastSpeedMs;
if (toastDurationInput) toastDurationInput.value = toastDurationMs;
if (fontSelect) fontSelect.value = fontChoice;
applyToastSettings();
applyFontSetting();
}
function persistToastSettings() {
try {
localStorage.setItem(TOAST_POS_KEY, toastPosition);
localStorage.setItem(TOAST_ANIM_KEY, toastAnimation);
localStorage.setItem(TOAST_SPEED_KEY, String(toastSpeedMs));
localStorage.setItem(TOAST_DURATION_KEY, String(toastDurationMs));
localStorage.setItem(FONT_KEY, fontChoice);
} catch (e) {
console.warn("Toast settings save failed", e);
}
}
function showToast(message, type = "info") {
if (!toastContainer || !message) return;
const t = document.createElement("div");
t.className = `toast ${type} anim-${toastAnimation}`;
t.textContent = message;
toastContainer.appendChild(t);
const animOn = document.documentElement.getAttribute("data-anim") !== "off";
if (!animOn) {
t.classList.add("show");
} else {
requestAnimationFrame(() => t.classList.add("show"));
}
const duration = toastDurationMs;
setTimeout(() => {
const all = Array.from(toastContainer.querySelectorAll(".toast"));
const others = all.filter((el) => el !== t && !el.classList.contains("leaving"));
const first = new Map(
others.map((el) => [el, el.getBoundingClientRect()]),
);
t.classList.add("leaving");
// force layout
void t.offsetHeight;
requestAnimationFrame(() => {
const second = new Map(
others.map((el) => [el, el.getBoundingClientRect()]),
);
others.forEach((el) => {
const dy = first.get(el).top - second.get(el).top;
if (Math.abs(dy) > 0.5) {
el.style.transition = "transform var(--toast-speed, 0.28s) ease";
el.style.transform = `translateY(${dy}px)`;
requestAnimationFrame(() => {
el.style.transform = "";
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");
});
}
});
});
const removeDelay = animOn ? toastSpeedMs : 0;
setTimeout(() => {
t.classList.remove("show");
t.remove();
// clear transition styling
others.forEach((el) => (el.style.transition = ""));
}, removeDelay);
}, duration);
// 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);
};
}
function applyTooltips() {
const tips = {
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.",
@@ -293,11 +255,6 @@ function applyTooltips() {
menuCancelBtn: "Cancel changes",
menuRemoveBtn: "Remove this service",
};
Object.entries(tips).forEach(([id, text]) => {
const el = document.getElementById(id);
if (el) el.title = text;
});
}
// Clamp name inputs to 30 chars
[svcName, menuRename].forEach((el) => {
@@ -316,79 +273,7 @@ function setUpdatesUI(enabled) {
updatesStatus.classList.toggle("chip-off", !on);
}
async function loadStatus() {
try {
const data = await getStatus();
lastStatusData = data;
renderStats(heroStats, data);
renderServices(servicesGrid, data.services, { openAddService });
const updatesEnabled =
data?.auto_updates?.enabled ?? data.auto_updates_enabled;
if (updatesEnabled !== undefined && !isUpdatesDirty()) {
setUpdatesUI(updatesEnabled);
}
// Updates chip + reboot note
updatesFlagEl(
updatesEnabled === undefined ? null : updatesEnabled === true,
);
const cfg = data.updates_config || {};
const rebootReq = data.reboot_required;
setTempFlag(data.cpu_temp_c);
if (updatesNoteTop) {
updatesNoteTop.textContent = "";
updatesNoteTop.classList.remove("note-warn");
if (rebootReq) {
if (cfg.auto_reboot) {
updatesNoteTop.textContent = `Reboot scheduled at ${cfg.reboot_time || "02:00"}.`;
} else {
updatesNoteTop.textContent = "Reboot required. Please reboot when you can.";
updatesNoteTop.classList.add("note-warn");
}
}
}
if (readyOverlay) {
if (data.ready) {
readyOverlay.classList.add("hidden");
} else {
readyOverlay.classList.remove("hidden");
// When not ready, retry periodically until API reports ready
setTimeout(loadStatus, 3000);
}
}
// Pull Pi-Kit release status after core status
releaseUI?.refreshStatus();
} catch (e) {
console.error(e);
logUi(`Status refresh failed: ${e?.message || e}`, "error");
if (!lastStatusData) {
renderStats(heroStats, placeholderStatus);
}
setTimeout(loadStatus, 2000);
}
}
function setTempFlag(tempC) {
if (!tempFlagTop) return;
const t = typeof tempC === "number" ? tempC : null;
let label = "Temp: n/a";
tempFlagTop.classList.remove("chip-on", "chip-warm", "chip-off");
if (t !== null) {
if (t < 55) {
label = "Temp: OK";
tempFlagTop.classList.add("chip-on");
} else if (t < 70) {
label = "Temp: Warm";
tempFlagTop.classList.add("chip-warm");
} else {
label = "Temp: Hot";
tempFlagTop.classList.add("chip-off");
}
}
tempFlagTop.textContent = label;
}
function updatesFlagEl(enabled) {
function setUpdatesFlag(enabled) {
if (!updatesFlagTop) return;
const labelOn = "System updates: On";
const labelOff = "System updates: Off";
@@ -398,61 +283,6 @@ function updatesFlagEl(enabled) {
if (enabled === false) updatesFlagTop.classList.add("chip-off");
}
function wireModals() {
advBtn.onclick = () => advModal.classList.remove("hidden");
advClose.onclick = () => advModal.classList.add("hidden");
helpBtn.onclick = () => helpModal.classList.remove("hidden");
helpClose.onclick = () => helpModal.classList.add("hidden");
aboutBtn.onclick = () => aboutModal.classList.remove("hidden");
aboutClose.onclick = () => aboutModal.classList.add("hidden");
menuClose.onclick = () => menuModal.classList.add("hidden");
addServiceOpen?.addEventListener("click", openAddService);
addSvcClose?.addEventListener("click", () => addServiceModal?.classList.add("hidden"));
addServiceModal?.addEventListener("click", (e) => {
if (e.target === addServiceModal) addServiceModal.classList.add("hidden");
});
}
function showBusy(title = "Working…", text = "This may take a few seconds.") {
if (!busyOverlay) return;
busyTitle.textContent = title;
busyText.textContent = text || "";
busyText.classList.toggle("hidden", !text);
busyOverlay.classList.remove("hidden");
}
function hideBusy() {
busyOverlay?.classList.add("hidden");
}
function confirmAction(title, body) {
return new Promise((resolve) => {
if (!confirmModal) {
const ok = window.confirm(body || title || "Are you sure?");
resolve(ok);
return;
}
confirmTitle.textContent = title || "Are you sure?";
confirmBody.textContent = body || "";
confirmModal.classList.remove("hidden");
const done = (val) => {
confirmModal.classList.add("hidden");
resolve(val);
};
const okHandler = () => done(true);
const cancelHandler = () => done(false);
confirmOk.onclick = okHandler;
confirmCancel.onclick = cancelHandler;
});
}
// Testing hook
if (typeof window !== "undefined") {
window.__pikitTest = window.__pikitTest || {};
window.__pikitTest.showBusy = showBusy;
window.__pikitTest.hideBusy = hideBusy;
}
function wireResetAndUpdates() {
resetBtn.onclick = async () => {
resetBtn.disabled = true;
@@ -471,31 +301,6 @@ function wireResetAndUpdates() {
});
}
function wireAccordions() {
const forceOpen = typeof window !== "undefined" && window.__pikitTest?.forceAccordionsOpen;
const accordions = document.querySelectorAll(".accordion");
if (forceOpen) {
accordions.forEach((a) => a.classList.add("open"));
return;
}
document.querySelectorAll(".accordion-toggle").forEach((btn) => {
btn.addEventListener("click", () => {
const acc = btn.closest(".accordion");
if (acc.classList.contains("open")) {
acc.classList.remove("open");
} else {
// Keep a single accordion expanded at a time for readability
accordions.forEach((a) => a.classList.remove("open"));
acc.classList.add("open");
}
});
});
}
function collapseAccordions() {
document.querySelectorAll(".accordion").forEach((a) => a.classList.remove("open"));
}
function openAddService() {
if (addServiceModal) addServiceModal.classList.remove("hidden");
document.getElementById("svcName")?.focus();
@@ -505,10 +310,17 @@ if (typeof window !== "undefined") {
}
function main() {
applyTooltips();
wireModals();
applyTooltips(TOOLTIP_MAP);
// Test convenience: ensure service form elements are visible when hook is set
if (window.__pikitTest?.forceServiceFormVisible) {
window.__pikitTest.forceServiceFormVisible();
window.__pikitTest.exposeServiceForm?.();
}
wireDialogs();
wireResetAndUpdates();
wireAccordions();
wireAccordions({
forceOpen: typeof window !== "undefined" && window.__pikitTest?.forceAccordionsOpen,
});
releaseUI = initReleaseUI({
showToast,
showBusy,
@@ -516,14 +328,6 @@ function main() {
confirmAction,
logUi,
});
loadToastSettings();
if (advClose) {
advClose.onclick = () => {
advModal.classList.add("hidden");
collapseAccordions();
};
}
initServiceControls({
gridEl: servicesGrid,
@@ -598,98 +402,6 @@ function main() {
console.error("Diag init failed", e);
});
// Toast controls
toastPosSelect?.addEventListener("change", () => {
const val = toastPosSelect.value;
if (ALLOWED_TOAST_POS.includes(val)) {
toastPosition = val;
applyToastSettings();
persistToastSettings();
} else {
toastPosSelect.value = toastPosition;
showToast("Invalid toast position", "error");
}
});
toastAnimSelect?.addEventListener("change", () => {
let val = toastAnimSelect.value;
if (val === "slide-up" || val === "slide-left" || val === "slide-right") val = "slide-in"; // migrate old label if cached
if (ALLOWED_TOAST_ANIM.includes(val)) {
toastAnimation = val;
persistToastSettings();
} else {
toastAnimSelect.value = toastAnimation;
showToast("Invalid toast animation", "error");
}
});
const clampSpeed = (val) => Math.min(3000, Math.max(100, val));
const clampDuration = (val) => Math.min(15000, Math.max(1000, val));
toastSpeedInput?.addEventListener("input", () => {
const raw = toastSpeedInput.value;
if (raw === "") return; // allow typing
const val = Number(raw);
if (Number.isNaN(val) || val < 100 || val > 3000) return; // wait until valid
toastSpeedMs = val;
applyToastSettings();
persistToastSettings();
});
toastSpeedInput?.addEventListener("blur", () => {
const raw = toastSpeedInput.value;
if (raw === "") {
toastSpeedInput.value = toastSpeedMs;
return;
}
const val = Number(raw);
if (Number.isNaN(val) || val < 100 || val > 3000) {
toastSpeedMs = clampSpeed(toastSpeedMs);
toastSpeedInput.value = toastSpeedMs;
showToast("Toast speed must be 100-3000 ms", "error");
return;
}
toastSpeedMs = val;
toastSpeedInput.value = toastSpeedMs;
applyToastSettings();
persistToastSettings();
});
toastDurationInput?.addEventListener("input", () => {
const raw = toastDurationInput.value;
if (raw === "") return; // allow typing
const val = Number(raw);
if (Number.isNaN(val) || val < 1000 || val > 15000) return; // wait until valid
toastDurationMs = val;
persistToastSettings();
});
toastDurationInput?.addEventListener("blur", () => {
const raw = toastDurationInput.value;
if (raw === "") {
toastDurationInput.value = toastDurationMs;
return;
}
const val = Number(raw);
if (Number.isNaN(val) || val < 1000 || val > 15000) {
toastDurationMs = clampDuration(toastDurationMs);
toastDurationInput.value = toastDurationMs;
showToast("Toast duration must be 1000-15000 ms", "error");
return;
}
toastDurationMs = val;
toastDurationInput.value = toastDurationMs;
persistToastSettings();
});
fontSelect?.addEventListener("change", () => {
const val = fontSelect.value;
if (!ALLOWED_FONTS.includes(val)) {
fontSelect.value = fontChoice;
showToast("Invalid font choice", "error");
return;
}
fontChoice = val;
applyFontSetting();
persistToastSettings();
});
toastTestBtn?.addEventListener("click", () => showToast("This is a test toast", "info"));
initUpdateSettings({
elements: {
updatesStatus,

View 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() };
}

View File

@@ -5,15 +5,12 @@ import {
getReleaseStatus,
checkRelease,
applyRelease,
rollbackRelease,
applyReleaseVersion,
listReleases,
setReleaseAutoCheck,
setReleaseChannel,
} from "./api.js";
function shorten(text, max = 90) {
if (!text || typeof text !== "string") return text;
return text.length > max ? `${text.slice(0, max - 3)}...` : text;
}
import { shorten, createReleaseLogger } from "./releases-utils.js";
export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, logUi = () => {} }) {
const releaseFlagTop = document.getElementById("releaseFlagTop");
@@ -22,12 +19,20 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
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,31 +47,94 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
const changelogClose = document.getElementById("changelogClose");
let releaseBusyActive = false;
let releaseLogLines = [];
let releaseLastFetched = 0;
let lastReleaseLogKey = "";
let lastReleaseToastKey = null;
let lastLogMessage = null;
let changelogCache = { version: null, text: "" };
let lastChangelogUrl = null;
let releaseChannel = "dev";
let releaseOptions = [];
const logger = createReleaseLogger(logUi);
logger.attach(releaseLog);
function logRelease(msg) {
if (!msg) return;
const plain = msg.trim();
if (plain === lastLogMessage) return;
lastLogMessage = plain;
const ts = new Date().toLocaleTimeString();
const line = `${ts} ${msg}`;
releaseLogLines.unshift(line);
releaseLogLines = releaseLogLines.slice(0, 120);
if (releaseLog) {
releaseLog.textContent = releaseLogLines.join("\n");
releaseLog.scrollTop = 0; // keep most recent in view
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 "—";
}
// Mirror into global diagnostics log (frontend side)
const lvl = msg.toLowerCase().startsWith("error") ? "error" : "info";
logUi(`Update: ${msg}`, lvl);
};
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) {
@@ -91,7 +159,9 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
const msg = shorten(message, 80) || "";
releaseFlagTop.title = msg || "Pi-Kit release status";
if (releaseStatusMsg) {
releaseStatusMsg.textContent = status === "update_available" ? msg || "Update available" : "";
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) {
@@ -136,6 +206,8 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
}
}
const logRelease = logger.log;
async function loadReleaseStatus(force = false) {
if (!releaseFlagTop) return;
const now = Date.now();
@@ -153,6 +225,10 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
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";
@@ -163,14 +239,24 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
lastReleaseLogKey = key;
}
releaseLastFetched = now;
if (status === "update_available" && message && message.startsWith("http")) {
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) {
@@ -232,6 +318,7 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
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)
@@ -294,19 +381,32 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
}
});
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;
logUi("Rollback requested");
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 = "";
}
@@ -329,6 +429,7 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
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";
@@ -337,8 +438,11 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
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;
@@ -348,7 +452,8 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, lo
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 {

View 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;
}

View File

@@ -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();

View File

@@ -0,0 +1,100 @@
// Status polling and UI flag helpers
import { placeholderStatus, renderStats } from "./status.js";
import { renderServices } from "./services.js";
export function createStatusController({
heroStats,
servicesGrid,
updatesFlagTop,
updatesNoteTop,
tempFlagTop,
readyOverlay,
logUi,
showToast = () => {},
onReadyWait = null,
getStatus,
isUpdatesDirty,
releaseUIGetter = () => null,
setUpdatesUI = null,
updatesFlagEl = null,
}) {
let lastStatusData = null;
function setTempFlag(tempC) {
if (!tempFlagTop) return;
const t = typeof tempC === "number" ? tempC : null;
let label = "Temp: n/a";
tempFlagTop.classList.remove("chip-on", "chip-warm", "chip-off");
if (t !== null) {
if (t < 55) {
label = "Temp: OK";
tempFlagTop.classList.add("chip-on");
} else if (t < 70) {
label = "Temp: Warm";
tempFlagTop.classList.add("chip-warm");
} else {
label = "Temp: Hot";
tempFlagTop.classList.add("chip-off");
}
}
tempFlagTop.textContent = label;
}
function updatesFlagEl(enabled) {
if (!updatesFlagTop) return;
const labelOn = "System updates: On";
const labelOff = "System updates: Off";
updatesFlagTop.textContent =
enabled === true ? labelOn : enabled === false ? labelOff : "System updates";
updatesFlagTop.className = "status-chip quiet chip-system";
if (enabled === false) updatesFlagTop.classList.add("chip-off");
}
async function loadStatus() {
try {
const data = await getStatus();
lastStatusData = data;
renderStats(heroStats, data);
renderServices(servicesGrid, data.services, { openAddService: window.__pikitOpenAddService });
const updatesEnabled = data?.auto_updates?.enabled ?? data.auto_updates_enabled;
if (updatesEnabled !== undefined && !isUpdatesDirty()) {
setUpdatesUI?.(updatesEnabled);
}
updatesFlagEl?.(updatesEnabled === undefined ? null : updatesEnabled === true);
const cfg = data.updates_config || {};
const rebootReq = data.reboot_required;
setTempFlag(data.cpu_temp_c);
if (updatesNoteTop) {
updatesNoteTop.textContent = "";
updatesNoteTop.classList.remove("note-warn");
if (rebootReq) {
if (cfg.auto_reboot) {
updatesNoteTop.textContent = `Reboot scheduled at ${cfg.reboot_time || "02:00"}.`;
} else {
updatesNoteTop.textContent = "Reboot required. Please reboot when you can.";
updatesNoteTop.classList.add("note-warn");
}
}
}
if (readyOverlay) {
if (data.ready) {
readyOverlay.classList.add("hidden");
} else {
readyOverlay.classList.remove("hidden");
onReadyWait?.();
}
}
releaseUIGetter()?.refreshStatus();
} catch (e) {
console.error(e);
logUi?.(`Status refresh failed: ${e?.message || e}`, "error");
if (!lastStatusData) {
renderStats(heroStats, placeholderStatus);
}
setTimeout(loadStatus, 2000);
}
}
return { loadStatus, setTempFlag, updatesFlagEl };
}

File diff suppressed because it is too large Load Diff

258
pikit-web/assets/toast.js Normal file
View 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
View 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;
}

View File

@@ -1,6 +1,7 @@
// UI controller for unattended-upgrades settings.
// Fetches current config, mirrors it into the form, and saves changes.
import { getUpdateConfig, saveUpdateConfig } from "./api.js";
import { logUi } from "./diaglog.js";
const TIME_RE = /^(\d{1,2}):(\d{2})$/;
const MAX_BANDWIDTH_KBPS = 1_000_000; // 1 GB/s cap to prevent typos
@@ -115,15 +116,14 @@ export function initUpdateSettings({
function showMessage(text, isError = false) {
if (!msgEl) return;
// Only surface inline text for errors; successes go to toast only.
if (isError) {
msgEl.textContent = text || "Something went wrong";
msgEl.classList.add("error");
toast?.(text || "Error", "error");
} else {
msgEl.textContent = "";
msgEl.textContent = text || "";
msgEl.classList.remove("error");
}
if (toast) toast(text || (isError ? "Error" : ""), isError ? "error" : "success");
}
function currentConfigFromForm() {
@@ -246,6 +246,7 @@ export function initUpdateSettings({
showMessage("");
try {
const prev = lastConfig ? { ...lastConfig } : null;
const payload = buildPayload();
if (overrideEnable !== null) payload.enable = !!overrideEnable;
@@ -257,6 +258,7 @@ export function initUpdateSettings({
showMessage("Update settings saved.");
toast?.("Updates saved", "success");
logUi("Update settings saved", "info", { from: prev, to: payload });
onAfterSave?.();
@@ -273,6 +275,16 @@ export function initUpdateSettings({
}
showMessage(e?.error || e?.message || "Save failed", true);
logUi("Update settings save failed", "error", {
payload: (() => {
try {
return buildPayload();
} catch {
return null;
}
})(),
reason: e?.error || e?.message,
});
} finally {
saving = false;

View File

@@ -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

View File

@@ -1,3 +1,3 @@
{
"version": "0.1.0-dev"
"version": "0.1.3-dev5"
}

View File

@@ -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>
@@ -118,10 +119,10 @@
</button>
</div>
<div class="control-actions wrap gap">
<button id="diagRefreshBtn" class="ghost">Refresh</button>
<button id="diagClearBtn" class="ghost">Clear</button>
<button id="diagCopyBtn" class="ghost">Copy</button>
<button id="diagDownloadBtn" class="ghost">Download</button>
<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>
@@ -143,14 +144,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>
@@ -162,8 +170,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" />
@@ -174,6 +182,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">
@@ -276,6 +296,7 @@
type="text"
id="svcName"
placeholder="Service name"
title="Service name"
maxlength="32"
/>
<p class="hint quiet">Service name: max 32 characters.</p>
@@ -285,11 +306,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">
@@ -308,11 +331,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">

View File

@@ -0,0 +1,153 @@
<!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 — youre already on your Pi-Kit and its responding.</p>
<div class="status-row" aria-live="polite">
<span class="status-chip" id="statusChip">Youre on HTTP — trust the CA or continue to HTTPS.</span>
</div>
<p class="subtle">
Everything stays on your local network. Lets 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. Its 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="checksum">SHA256: <code class="inline">6bc217c340e502ef20117bd4dc35e05f9f16c562cc3a236d3831a9947caddb97</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://&lt;pi-kit ip&gt;/ (or https://&lt;pi-kit ip&gt;/ — your browser will show the same warning to bypass).</p>
<script>
(function () {
const target = `https://${location.hostname}`;
const hasCookie = document.cookie.includes("pikit_https_ok=1");
const statusChip = document.getElementById("statusChip");
const copyStatus = document.getElementById("copyStatus");
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 = "Youre on HTTP — trust the CA or click Go to secure dashboard.";
}
})();
</script>
</body>
</html>

View 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;
}

View File

11
pikit_api/__init__.py Normal file
View 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
View 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()

72
pikit_api/constants.py Normal file
View File

@@ -0,0 +1,72 @@
import os
import pathlib
# Network
HOST = "127.0.0.1"
PORT = 4000
# Paths / files
SERVICE_JSON = pathlib.Path("/etc/pikit/services.json")
RESET_LOG = pathlib.Path("/var/log/pikit-reset.log")
API_LOG = pathlib.Path("/var/log/pikit-api.log")
READY_FILE = pathlib.Path("/var/run/pikit-ready")
VERSION_FILE = pathlib.Path("/etc/pikit/version")
WEB_VERSION_FILE = pathlib.Path("/var/www/pikit-web/data/version.json")
UPDATE_STATE_DIR = pathlib.Path("/var/lib/pikit-update")
UPDATE_STATE = UPDATE_STATE_DIR / "state.json"
UPDATE_LOCK = pathlib.Path("/var/run/pikit-update.lock")
WEB_ROOT = pathlib.Path("/var/www/pikit-web")
API_PATH = pathlib.Path("/usr/local/bin/pikit-api.py")
API_DIR = API_PATH.parent
API_PACKAGE_DIR = API_DIR / "pikit_api"
BACKUP_ROOT = pathlib.Path("/var/backups/pikit")
TMP_ROOT = pathlib.Path("/var/tmp/pikit-update")
# Apt / unattended-upgrades
APT_AUTO_CFG = pathlib.Path("/etc/apt/apt.conf.d/20auto-upgrades")
APT_UA_BASE = pathlib.Path("/etc/apt/apt.conf.d/50unattended-upgrades")
APT_UA_OVERRIDE = pathlib.Path("/etc/apt/apt.conf.d/51pikit-unattended.conf")
DEFAULT_UPDATE_TIME = "04:00"
DEFAULT_UPGRADE_TIME = "04:30"
SECURITY_PATTERNS = [
"origin=Debian,codename=${distro_codename},label=Debian-Security",
"origin=Debian,codename=${distro_codename}-security,label=Debian-Security",
]
ALL_PATTERNS = [
"origin=Debian,codename=${distro_codename},label=Debian",
*SECURITY_PATTERNS,
]
# Release updater
DEFAULT_MANIFEST_URL = os.environ.get(
"PIKIT_MANIFEST_URL",
# 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
View 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

80
pikit_api/helpers.py Normal file
View 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

306
pikit_api/http_handlers.py Normal file
View File

@@ -0,0 +1,306 @@
import json
import urllib.parse
from http.server import BaseHTTPRequestHandler
from .auto_updates import auto_updates_state, read_updates_config, set_auto_updates, set_updates_config
from .constants import CORE_NAME, CORE_PORTS, DIAG_LOG_FILE
from .diagnostics import _load_diag_state, _save_diag_state, dbg, diag_log, diag_read
from .helpers import default_host, detect_https, normalize_path
from .releases import (
check_for_update,
fetch_manifest,
fetch_text_with_auth,
load_update_state,
read_current_version,
save_update_state,
start_background_task,
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/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
View 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": False,
"in_progress": False,
"progress": None,
"channel": os.environ.get("PIKIT_CHANNEL", "dev"),
"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
View 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
View 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
View 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

View File

@@ -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."

View File

@@ -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

View File

@@ -34,7 +34,7 @@ Environment=PIKIT_MANIFEST_URL=https://git.44r0n.cc/44r0n7/pi-kit/releases/downl
## Whats 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

View File

@@ -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