Compare commits
42 Commits
v0.1.0-dev
...
v0.1.3-dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e87b90bf9f | ||
|
|
8557140193 | ||
|
|
86438b11f3 | ||
|
|
3a785832b1 | ||
|
|
a94cd17186 | ||
|
|
b01bfcd38e | ||
|
|
831a98c5a1 | ||
|
|
daea783d38 | ||
|
|
90d3e5676a | ||
|
|
8a054c5d85 | ||
|
|
009ac8cdd0 | ||
|
|
7a9ffb710a | ||
|
|
15da438625 | ||
|
|
50ddc3e211 | ||
|
|
e7a79246b8 | ||
|
|
bb2fb2dcf2 | ||
|
|
222f6f9e77 | ||
|
|
250ea2e00d | ||
|
|
b70f4c5f3f | ||
|
|
0c69e9bf90 | ||
|
|
d8673a9fb4 | ||
|
|
1e8e8a5bc2 | ||
|
|
e3c6c3b308 | ||
|
|
a05aa70069 | ||
|
|
06e8e25aad | ||
|
|
4f05f58f45 | ||
|
|
eaf261a6be | ||
|
|
798d78cb13 | ||
|
|
bcb6f3005d | ||
|
|
b911171045 | ||
|
|
2cfb9779d6 | ||
|
|
08cf472bf7 | ||
|
|
471e242427 | ||
|
|
357453eed4 | ||
|
|
25cc888b86 | ||
|
|
17ae87563f | ||
|
|
32503424e8 | ||
|
|
50be46df45 | ||
|
|
8c06962f62 | ||
|
|
2a439321d0 | ||
|
|
e993d19886 | ||
|
|
0e3b144cd7 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -26,6 +26,3 @@ out/
|
||||
|
||||
# Stock images (large)
|
||||
images/stock/
|
||||
|
||||
# Local helpers
|
||||
set_ready.sh
|
||||
|
||||
81
PLAN.md
81
PLAN.md
@@ -1,81 +0,0 @@
|
||||
# Pi-Kit Updater Plan
|
||||
|
||||
## Goals
|
||||
- Add seamless in-dashboard updates for Pi-Kit assets (web UI, api script, helper scripts).
|
||||
- Allow manual check/apply + rollback; optional daily auto-check with a status chip.
|
||||
- Use release tarballs (not git clones) from Gitea; verify hashes; stage before swap; keep one backup.
|
||||
- Keep UX friendly: clear progress, errors, and “update available” indicator.
|
||||
|
||||
## Release Format (Gitea)
|
||||
- Release asset: `pikit-<version>.tar.gz` containing:
|
||||
- `pikit-web/` (built assets only, no node_modules)
|
||||
- `pikit-api.py`
|
||||
- helper scripts/configs that should overwrite
|
||||
- `manifest.json` (see below)
|
||||
- `manifest.json` fields:
|
||||
- `version` (string)
|
||||
- `changelog` (url)
|
||||
- `files`: array of { `path`, `sha256`, `mode`? }
|
||||
- `post_install` (optional array of commands or service restarts)
|
||||
- Optional: detached signature in future (ed25519), not in v1.
|
||||
|
||||
## On-device Updater (Python helper)
|
||||
- Location: `/usr/local/bin/pikit-updater.py` (invoked by API and timer).
|
||||
- State file: `/var/lib/pikit-update/state.json` (latest, current, last_check, last_result, progress, backup_path).
|
||||
- Steps for apply:
|
||||
1) Fetch manifest (HTTP GET release URL).
|
||||
2) Download tarball to `/var/tmp/pikit-update/<version>/bundle.tar.gz`.
|
||||
3) Verify SHA256 against manifest.
|
||||
4) Unpack to staging dir.
|
||||
5) Backup current managed files to `/var/backups/pikit/<ts>/` (keep 1 latest).
|
||||
6) Rsync staged files to destinations:
|
||||
- `/var/www/pikit-web/` (built assets)
|
||||
- `/usr/local/bin/pikit-api.py`
|
||||
7) Restart services: `dietpi-dashboard-frontend`, api service (if exists), maybe nginx reload.
|
||||
8) Update state; mark current version file `/etc/pikit/version`.
|
||||
- Rollback: restore latest backup and restart services.
|
||||
- Concurrency: use lockfile in `/var/run/pikit-update.lock`.
|
||||
|
||||
## API additions (pikit-api.py)
|
||||
- `GET /api/update/status` -> returns state.json contents + current version.
|
||||
- `POST /api/update/check` -> fetch manifest only, update state.
|
||||
- `POST /api/update/apply` -> spawn updater apply (can run inline for v1) and stream status.
|
||||
- `POST /api/update/rollback` -> restore last backup.
|
||||
- `POST /api/update/auto` -> enable/disable daily timer.
|
||||
|
||||
## Timer
|
||||
- systemd timer: `pikit-update.timer/service` running check daily (e.g., 04:20).
|
||||
- Writes last_check and latest_version to state.
|
||||
|
||||
## UI changes (pikit-web)
|
||||
- Top-bar chip: “Updates: Up to date / Update available / Checking…”.
|
||||
- New “Updates” modal (not the OS auto-updates) for Pi-Kit releases:
|
||||
- Show current vs latest version, changelog link.
|
||||
- Buttons: Check, Download & Install, Rollback, View logs.
|
||||
- Progress states + errors.
|
||||
- Hook into existing status refresh: call `/api/update/status`.
|
||||
|
||||
## Safety / Edge
|
||||
- Preflight disk space check before download.
|
||||
- Handle offline: clear message.
|
||||
- If apply fails, auto rollback.
|
||||
- Skip overwriting user-edited config? For v1 assume assets/api are managed; nginx site left unchanged.
|
||||
|
||||
## Current status (2025-12-11)
|
||||
- Version markers and mock update status in place.
|
||||
- UI has release chip + modal; buttons call real `/api/update/*`.
|
||||
- API implements check/apply/rollback with staging, SHA256 verify, backup, deploy, restart, rollback.
|
||||
- Release tooling: `tools/release/make-release.sh` + README; SHA256 baked into manifest.
|
||||
- Missing: auto-check timer, lockfile, backup pruning, real release publishing.
|
||||
|
||||
## Remaining work
|
||||
- Publish real release bundles + manifest to Gitea.
|
||||
- Add lock around apply/check to prevent overlap.
|
||||
- Add backup pruning (keep last N).
|
||||
- Add systemd timer/service to run check daily and surface status.
|
||||
- Optional: protect nginx/site overrides; add disk-space preflight.
|
||||
|
||||
## Open questions / assumptions
|
||||
- Release hosting: use Gitea releases at `https://git.44r0n.cc/44r0n7/pi-kit/releases/latest` (JSON API? or static URLs). For v1, hardcode base URL env var or config.
|
||||
- Services to restart: assume `dietpi-dashboard-frontend` and `pikit-api` (if we add a service file). Need to verify actual service names on device.
|
||||
- Device perms: updater runs as root (via sudo from API). Ensure API endpoint requires auth (existing login).
|
||||
40
README.md
40
README.md
@@ -1,21 +1,35 @@
|
||||
# Pi-Kit Dashboard
|
||||
Lightweight dashboard for DietPi-based Pi-Kit images.
|
||||
|
||||
Lightweight dashboard for DietPi-based Pi-Kit images. Two pieces live in this repo:
|
||||
|
||||
- `pikit-api.py`: tiny Python HTTP API (status, services, auto updates, factory reset). Runs on localhost:4000 and writes to `/etc/pikit/services.json`.
|
||||
- `pikit-web/`: static Vite site served by nginx from `/var/www/pikit-web`. Sources live in `pikit-web/assets/`; Playwright E2E tests in `pikit-web/tests/`.
|
||||
## What’s here
|
||||
- `pikit_api/` + `pikit-api.py`: Python HTTP API (status, services CRUD, auto-updates, diagnostics, factory reset), served on 127.0.0.1:4000.
|
||||
- `pikit-web/`: Vite static site served by nginx at `/var/www/pikit-web/`; source in `pikit-web/assets/`, Playwright E2E in `pikit-web/tests/`.
|
||||
- Release tooling: `tools/release/make-release.sh` builds a bundle tarball + manifest for OTA; changelogs live in `out/releases/`.
|
||||
|
||||
## Local development
|
||||
- Dashboard: `cd pikit-web && npm install` (first run), then `npm run dev` for Vite, `npm test` for Playwright, `npm run build` for production bundle.
|
||||
- API: `python pikit-api.py` to run locally (listens on 127.0.0.1:4000).
|
||||
- Frontend: `cd pikit-web && npm install` (once), `npm run dev` for live reload, `npm test` for Playwright, `npm run build` for production `dist/`.
|
||||
- API: `python pikit-api.py` runs the same routes locally (no auth) as on-device.
|
||||
|
||||
## Deploying to a Pi-Kit box
|
||||
1. Copy `pikit-api.py` to the device (e.g., `/usr/local/bin/`) and restart the service unit that wraps it.
|
||||
2. Sync `pikit-web/index.html` and `pikit-web/assets/*` (or the built `pikit-web/dist/*`) to `/var/www/pikit-web/`.
|
||||
3. The API surfaces clear errors if firewall tooling (`ufw`) is missing when ports are opened/closed.
|
||||
4. Factory reset sets `root` and `dietpi` passwords to `pikit`.
|
||||
## Build, package, and publish a release
|
||||
1) Bump `pikit-web/data/version.json` to the target version (e.g., `0.1.3-dev1`), then `cd pikit-web && npm run build`.
|
||||
2) Package: `./tools/release/make-release.sh <version> https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v<version>`
|
||||
Outputs in `out/releases/`: `pikit-<version>.tar.gz` and `manifest.json` (with SHA256 + changelog URL).
|
||||
3) Create a Gitea release/tag `v<version>` and upload:
|
||||
- `pikit-<version>.tar.gz`
|
||||
- `manifest.json`
|
||||
- `CHANGELOG-<version>.txt` (add a short changelog in `out/releases/`)
|
||||
4) Update repo manifests (public raw defaults used by devices): edit `manifests/manifest-stable.json` and `manifests/manifest-dev.json` with `version`, `bundle`, `changelog`, `_release_date`, `sha256` from the new bundle, then commit/push.
|
||||
- Default OTA URLs (no token needed):
|
||||
- Stable: `https://git.44r0n.cc/44r0n7/pi-kit/raw/branch/main/manifests/manifest-stable.json`
|
||||
- Dev: `https://git.44r0n.cc/44r0n7/pi-kit/raw/branch/main/manifests/manifest-dev.json`
|
||||
|
||||
## Deploy to a Pi-Kit box
|
||||
1) Copy `pikit-api.py` **and** the `pikit_api/` directory to the device (e.g., `/usr/local/bin/`) and restart `pikit-api.service`.
|
||||
2) Sync `pikit-web/dist/` (preferred) to `/var/www/pikit-web/`, then restart `dietpi-dashboard-frontend.service`.
|
||||
3) OTA defaults (no per-device token required): `PIKIT_MANIFEST_URL` points to the stable manifest above; enabling “Allow dev builds” in the UI makes the updater consult the dev manifest URL. Override via systemd drop-in if you need to pin to a specific manifest.
|
||||
|
||||
## Notes
|
||||
- Service paths are normalized (leading slash) and URLs include optional subpaths.
|
||||
- Firewall changes raise explicit errors when `ufw` is unavailable so the UI can surface what failed.
|
||||
- Access the device UI at `http://pikit.local/` (mDNS).
|
||||
- Firewall changes surface clear errors when `ufw` is missing so the UI can report failures.
|
||||
- Factory reset sets `root` and `dietpi` passwords to `pikit`.
|
||||
- Default UI: `http://pikit.local/` (mDNS) unless HTTPS is enabled.
|
||||
|
||||
9
manifests/manifest-dev.json
Normal file
9
manifests/manifest-dev.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"version": "0.1.3-dev3",
|
||||
"_release_date": "2025-12-14T23:50:00Z",
|
||||
"bundle": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.3-dev3/pikit-0.1.3-dev3.tar.gz",
|
||||
"changelog": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.3-dev3/CHANGELOG-0.1.3-dev3.txt",
|
||||
"files": [
|
||||
{ "path": "bundle.tar.gz", "sha256": "1352f6a90d91e21871684e9a2c175c8cb48c53aa0b121fcdc94a3004c3d4d3f2" }
|
||||
]
|
||||
}
|
||||
9
manifests/manifest-stable.json
Normal file
9
manifests/manifest-stable.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"version": "0.1.2",
|
||||
"_release_date": "2025-12-10T00:00:00Z",
|
||||
"bundle": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.2/pikit-0.1.2.tar.gz",
|
||||
"changelog": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.2/CHANGELOG-0.1.2.txt",
|
||||
"files": [
|
||||
{ "path": "bundle.tar.gz", "sha256": "8d2e0f8b260063cab0d52e862cb42f10472a643123f984af0248592479dd613d" }
|
||||
]
|
||||
}
|
||||
1455
pikit-api.py
1455
pikit-api.py
File diff suppressed because it is too large
Load Diff
@@ -1,391 +0,0 @@
|
||||
# Pi-Kit DietPi Image Prep Spec
|
||||
|
||||
This file defines how to design a **prep script** for a DietPi-based Pi-Kit image.
|
||||
|
||||
The script’s job:
|
||||
Prepare a running Pi-Kit system to be cloned as a “golden image” **without** removing any intentional software, configs, hostname, or passwords.
|
||||
|
||||
---
|
||||
|
||||
## 0. Context & Goals
|
||||
|
||||
**Starting point**
|
||||
|
||||
- OS: DietPi (Debian-based), already installed.
|
||||
- Extra software: web stack, Pi-Kit dashboard, DNS/ad-blocker, DBs, monitoring, etc.
|
||||
- System has been used for testing (logs, histories, test data, junk).
|
||||
|
||||
**Goal**
|
||||
|
||||
- Prepare system for cloning as a product image.
|
||||
- **KEEP**:
|
||||
- All intentionally installed packages/software.
|
||||
- All custom configs (web, apps, DietPi configs, firewall).
|
||||
- Current hostname.
|
||||
- Existing passwords (system + services) as shipped defaults.
|
||||
- **RESET/CLEAR**:
|
||||
- Host-unique identity data (machine-id, SSH host keys, etc.).
|
||||
- Logs, histories, caches.
|
||||
- Test/personal accounts and data.
|
||||
|
||||
---
|
||||
|
||||
## 1. Discovery Phase (MUST HAPPEN BEFORE SCRIPT DESIGN)
|
||||
|
||||
Before writing any code, inspect the system and external docs.
|
||||
|
||||
The AI MUST:
|
||||
|
||||
1. **Detect installed components**
|
||||
- Determine which key packages/services are present, e.g.:
|
||||
- Web server (nginx, lighttpd, apache2, etc.).
|
||||
- DNS/ad-blocker (Pi-hole or similar).
|
||||
- DB engines (MariaDB, PostgreSQL, SQLite usage).
|
||||
- Monitoring/metrics (Netdata, Uptime Kuma, etc.).
|
||||
- Use this to decide which cleanup sections apply.
|
||||
|
||||
2. **Verify paths/layouts**
|
||||
- For each service or category:
|
||||
- Confirm relevant paths/directories actually exist.
|
||||
- Do not assume standard paths without checking.
|
||||
- Example: Only treat `/var/log/nginx` as Nginx logs if:
|
||||
- Nginx is installed, AND
|
||||
- That directory exists.
|
||||
|
||||
3. **Consult upstream docs (online)**
|
||||
- Check current:
|
||||
- DietPi docs and/or DietPi GitHub.
|
||||
- Docs for major services (e.g. Pi-hole, Nginx, MariaDB, etc.).
|
||||
- Use docs to confirm:
|
||||
- Data vs config locations.
|
||||
- Safe cache/log cleanup methods.
|
||||
- Prefer documented behavior over guesses.
|
||||
|
||||
4. **Classify actions**
|
||||
- For each potential cleanup:
|
||||
- Mark as **safe** if clearly understood and documented.
|
||||
- Mark as **uncertain** if layout deviates or docs are unclear.
|
||||
- Plan to:
|
||||
- Perform safe actions.
|
||||
- Skip uncertain actions and surface them for manual review.
|
||||
|
||||
5. **Fail safe**
|
||||
- If something doesn’t match expectations:
|
||||
- Do NOT plan a destructive operation on it.
|
||||
- Flag it as “needs manual review” in the confirmation phase.
|
||||
|
||||
---
|
||||
|
||||
## 2. Identity & Host-Specific Secrets
|
||||
|
||||
**DO NOT CHANGE:**
|
||||
|
||||
- Hostname (whatever it currently is).
|
||||
- Any existing passwords (system or service-level) that are part of the appliance defaults.
|
||||
|
||||
**RESET/CLEAR:**
|
||||
|
||||
1. **Machine identity**
|
||||
- Clear:
|
||||
- `/etc/machine-id`
|
||||
- `/var/lib/dbus/machine-id` (if present)
|
||||
- Rely on OS to recreate them on next boot.
|
||||
|
||||
2. **Random seed**
|
||||
- Clear persisted random seed (e.g. `/var/lib/systemd/random-seed`) so each clone gets unique entropy.
|
||||
|
||||
3. **SSH host keys**
|
||||
- Remove all SSH **host key** files (server keys only).
|
||||
- Leave user SSH keypairs unless explicitly identified as dev/test and safe to remove.
|
||||
|
||||
4. **SSH known_hosts**
|
||||
- Clear `known_hosts` for:
|
||||
- `root`
|
||||
- `dietpi` (or primary DietPi user)
|
||||
- Any other persistent users
|
||||
|
||||
5. **VPN keys (conditional)**
|
||||
- If keys are meant to be unique per device:
|
||||
- Remove WireGuard/OpenVPN private keys and per-device configs embedding them.
|
||||
- If the design requires fixed server keys:
|
||||
- KEEP server keys.
|
||||
- REMOVE test/client keys/profiles that are tied to dev use.
|
||||
|
||||
6. **TLS certificates (conditional)**
|
||||
- REMOVE:
|
||||
- Let’s Encrypt/ACME certs tied to personal domains.
|
||||
- Per-device self-signed certs that should regenerate.
|
||||
- KEEP:
|
||||
- Shared CAs/certs only if explicitly part of product design.
|
||||
|
||||
---
|
||||
|
||||
## 3. Users & Personal Traces
|
||||
|
||||
1. **Accounts**
|
||||
- KEEP:
|
||||
- Accounts that are part of the product.
|
||||
- REMOVE:
|
||||
- Test-only accounts (users created for dev/debug).
|
||||
|
||||
2. **Shell histories**
|
||||
- Clear shell histories for all remaining users:
|
||||
- `root`, `dietpi`, others that stay.
|
||||
|
||||
3. **Home directories**
|
||||
- For users that remain:
|
||||
- KEEP:
|
||||
- Intentional config/dotfiles (shell rc, app config, etc.).
|
||||
- REMOVE:
|
||||
- Downloads, random files, scratch notes.
|
||||
- Editor backup/swap files, stray temp files.
|
||||
- Debug dumps, one-off scripts not part of product.
|
||||
- For users that are removed:
|
||||
- Delete their home dirs entirely.
|
||||
|
||||
4. **SSH client keys**
|
||||
- REMOVE:
|
||||
- Clearly personal/test keys (e.g. with your email in comments).
|
||||
- KEEP:
|
||||
- Only keys explicitly required by product design.
|
||||
|
||||
---
|
||||
|
||||
## 4. Logs & Telemetry
|
||||
|
||||
1. **System logs**
|
||||
- Clear:
|
||||
- Systemd journal (persistent logs).
|
||||
- `/var/log` files + rotated/compressed variants, where safe.
|
||||
|
||||
2. **Service logs**
|
||||
- For installed services (web servers, DNS/ad-blockers, DBs, etc.):
|
||||
- Clear their log files and rotated versions.
|
||||
|
||||
3. **Monitoring/metrics**
|
||||
- For tools like Netdata, Uptime Kuma, etc.:
|
||||
- KEEP:
|
||||
- Config, target definitions.
|
||||
- CLEAR:
|
||||
- Historical metric/alert data (TSDBs, history files, etc.).
|
||||
|
||||
---
|
||||
|
||||
## 5. Package Manager & Caches
|
||||
|
||||
1. **APT**
|
||||
- Clear:
|
||||
- Downloaded `.deb` archives.
|
||||
- Safe APT caches (as per documentation).
|
||||
|
||||
2. **Other caches**
|
||||
- Under `/var/cache` and `~/.cache`:
|
||||
- CLEAR:
|
||||
- Caches known to be safe and auto-regenerated.
|
||||
- DO NOT CLEAR:
|
||||
- Caches that are required for correct functioning or very expensive to rebuild, unless docs confirm safety.
|
||||
|
||||
3. **Temp directories**
|
||||
- Empty:
|
||||
- `/tmp`
|
||||
- `/var/tmp`
|
||||
|
||||
4. **Crash dumps**
|
||||
- Remove crash dumps and core files (e.g. `/var/crash` and similar locations).
|
||||
|
||||
---
|
||||
|
||||
## 6. Service Data vs Config (Per-App Logic)
|
||||
|
||||
General rule:
|
||||
|
||||
> Keep configuration & structure. Remove dev/test data, history, and personal content.
|
||||
|
||||
The AI must apply this using detected services + docs.
|
||||
|
||||
### 6.1 Web Servers (nginx / lighttpd / apache2)
|
||||
|
||||
- KEEP:
|
||||
- Main config and site configs that define Pi-Kit behavior.
|
||||
- App code in `/var/www/...` (or equivalent Pi-Kit web root).
|
||||
- CLEAR:
|
||||
- Access/error logs.
|
||||
- Non-critical caches if docs confirm they’re safe to recreate.
|
||||
|
||||
### 6.2 DNS / Ad-blockers (Pi-hole or similar)
|
||||
|
||||
- KEEP:
|
||||
- Upstream DNS settings.
|
||||
- Blocklists / adlists / local DNS overrides.
|
||||
- DHCP config if it is part of the product’s behavior.
|
||||
- CLEAR:
|
||||
- Query history / statistics DB.
|
||||
- Log files.
|
||||
- DO NOT:
|
||||
- Change the current admin password (it is the product default).
|
||||
|
||||
### 6.3 Databases (MariaDB, PostgreSQL, SQLite, etc.)
|
||||
|
||||
- KEEP:
|
||||
- DB schema.
|
||||
- Seed/default data required for every user.
|
||||
- REMOVE/RESET:
|
||||
- Dev/test user accounts (with your email, etc.).
|
||||
- Test content/records not meant for production image.
|
||||
- Access tokens, session records, API keys tied to dev use.
|
||||
- For SQLite-based apps:
|
||||
- Decide per app (based on docs) whether to:
|
||||
- Ship a pre-seeded “clean” DB, OR
|
||||
- Let it auto-create DB on first run.
|
||||
|
||||
### 6.4 Other services (Nextcloud, Jellyfin, Gotify, Uptime Kuma, etc.)
|
||||
|
||||
For each detected service:
|
||||
|
||||
- KEEP:
|
||||
- Global config, ports, base URLs, application settings needed for Pi-Kit.
|
||||
- CLEAR:
|
||||
- Personal/dev user accounts.
|
||||
- Your media/content (unless intentionally shipping sample content).
|
||||
- Notification endpoints tied to your own email / Gotify / Telegram, unless explicitly desired.
|
||||
|
||||
If docs or structure are unclear, mark cleanup as **uncertain** and surface in confirmation instead of guessing.
|
||||
|
||||
---
|
||||
|
||||
## 7. Networking & Firewall
|
||||
|
||||
**HARD CONSTRAINTS:**
|
||||
|
||||
- Do NOT modify hostname.
|
||||
- Do NOT weaken/remove the product firewall rules.
|
||||
|
||||
1. **Firewall**
|
||||
- Detect firewall system in use (iptables, nftables, UFW, etc.).
|
||||
- KEEP:
|
||||
- All persistent firewall configs that define Pi-Kit’s security behavior.
|
||||
- DO NOT:
|
||||
- Flush or reset firewall rules unless it’s clearly a dev-only configuration (and that’s confirmed).
|
||||
|
||||
2. **Other networking state**
|
||||
- Safe to CLEAR:
|
||||
- DHCP lease files.
|
||||
- DNS caches.
|
||||
- DO NOT ALTER:
|
||||
- Static IP/bridge/VLAN config that appears to be part of the intended appliance setup.
|
||||
|
||||
---
|
||||
|
||||
## 8. DietPi-Specific State & First-Boot Behavior
|
||||
|
||||
1. **DietPi automation/config**
|
||||
- Identify DietPi automation configuration (e.g. `dietpi.txt`, related files).
|
||||
- KEEP:
|
||||
- The intended defaults (locale, timezone, etc.).
|
||||
- Any automation that is part of Pi-Kit behavior.
|
||||
- AVOID:
|
||||
- Re-triggering DietPi’s generic first-boot flow unless that is intentionally desired.
|
||||
|
||||
2. **DietPi logs/temp**
|
||||
- CLEAR:
|
||||
- DietPi-specific logs and temp files.
|
||||
- KEEP:
|
||||
- All DietPi configuration and automation files.
|
||||
|
||||
3. **Pi-Kit first-boot logic**
|
||||
- Ensure any Pi-Kit specific first-run services/hooks are:
|
||||
- Enabled.
|
||||
- Not dependent on data being cleaned (e.g., they must not require removed dev tokens/paths).
|
||||
|
||||
---
|
||||
|
||||
## 9. Shell & Tooling State
|
||||
|
||||
1. **Tool caches**
|
||||
- For root and main user(s), CLEAR:
|
||||
- Safe caches in `~/.cache` (pip, npm, cargo, etc.), if not needed at runtime.
|
||||
- Avoid clearing caches that are critical or painful to rebuild unless doc-backed.
|
||||
|
||||
2. **Build artifacts**
|
||||
- REMOVE:
|
||||
- Source trees, build directories, and other dev artifacts that are not part of final product.
|
||||
|
||||
3. **Cronjobs / timers**
|
||||
- Audit:
|
||||
- User crontabs.
|
||||
- System crontabs.
|
||||
- Systemd timers.
|
||||
- KEEP:
|
||||
- Jobs that are part of Pi-Kit behavior.
|
||||
- REMOVE:
|
||||
- Jobs/timers clearly used for dev/testing only.
|
||||
|
||||
---
|
||||
|
||||
## 10. Implementation Requirements (For the Future Script)
|
||||
|
||||
When generating the actual script, the AI MUST:
|
||||
|
||||
1. **Error handling**
|
||||
- Check exit statuses where relevant.
|
||||
- Handle missing paths/directories gracefully:
|
||||
- If a path doesn’t exist, skip and log; do not fail hard.
|
||||
- Avoid wide-destructive operations without validation:
|
||||
- No “blind” deletions on unverified globs.
|
||||
|
||||
2. **Idempotency**
|
||||
- Script can run multiple times without progressively breaking the system.
|
||||
- After repeated runs, image should remain valid and “clean”.
|
||||
|
||||
3. **Conservative behavior**
|
||||
- If uncertain about an operation:
|
||||
- Do NOT perform it.
|
||||
- Log a warning and mark for manual review.
|
||||
|
||||
4. **Logging**
|
||||
- For each major category (identity, logs, caches, per-service cleanup, etc.):
|
||||
- Log what was targeted and outcome:
|
||||
- `cleaned`
|
||||
- `skipped (not installed/not found)`
|
||||
- `skipped (uncertain; manual review)`
|
||||
- Provide a summary at the end.
|
||||
|
||||
---
|
||||
|
||||
## 11. Mandatory Pre-Script Confirmation Step
|
||||
|
||||
**Before writing any script, the AI MUST:**
|
||||
|
||||
1. **Present a system-specific plan**
|
||||
- Based on discovery + docs, list:
|
||||
- Exactly which paths, files, DBs, and data types it intends to:
|
||||
- Remove
|
||||
- Reset
|
||||
- Leave untouched
|
||||
- For each item or group: a short explanation of **why**.
|
||||
|
||||
2. **Highlight conflicts / ambiguities**
|
||||
- If any cleanup might:
|
||||
- Affect passwords,
|
||||
- Affect hostname,
|
||||
- Affect firewall rules,
|
||||
- Or contradict this spec in any way,
|
||||
- The AI must:
|
||||
- Call it out explicitly.
|
||||
- Explain tradeoffs and propose a safe option.
|
||||
|
||||
3. **Highlight extra opportunities**
|
||||
- If the AI finds additional cleanup opportunities not explicitly listed here (e.g., new DietPi features, new log paths):
|
||||
- Describe them clearly.
|
||||
- Explain pros/cons of adding them.
|
||||
- Ask whether to include them.
|
||||
|
||||
4. **Wait for explicit approval**
|
||||
- Do NOT generate the script until:
|
||||
- The user (me) has reviewed the plan.
|
||||
- Conflicts and extra opportunities have been discussed.
|
||||
- Explicit approval (with any modifications) has been given.
|
||||
|
||||
Only after that confirmation may the AI produce the actual prep script.
|
||||
|
||||
---
|
||||
@@ -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",
|
||||
|
||||
140
pikit-web/assets/css/fonts.css
Normal file
140
pikit-web/assets/css/fonts.css
Normal file
@@ -0,0 +1,140 @@
|
||||
@font-face {
|
||||
font-family: "Red Hat Text";
|
||||
src: url("../fonts/RedHatText-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Red Hat Text";
|
||||
src: url("../fonts/RedHatText-Medium.woff2") format("woff2");
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Red Hat Display";
|
||||
src: url("../fonts/RedHatDisplay-SemiBold.woff2") format("woff2");
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Red Hat Display";
|
||||
src: url("../fonts/RedHatDisplay-Bold.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Space Grotesk";
|
||||
src: url("../fonts/SpaceGrotesk-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Space Grotesk";
|
||||
src: url("../fonts/SpaceGrotesk-Medium.woff2") format("woff2");
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Space Grotesk";
|
||||
src: url("../fonts/SpaceGrotesk-SemiBold.woff2") format("woff2");
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Manrope";
|
||||
src: url("../fonts/Manrope-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Manrope";
|
||||
src: url("../fonts/Manrope-SemiBold.woff2") format("woff2");
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "DM Sans";
|
||||
src: url("../fonts/DMSans-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "DM Sans";
|
||||
src: url("../fonts/DMSans-Medium.woff2") format("woff2");
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "DM Sans";
|
||||
src: url("../fonts/DMSans-Bold.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Sora";
|
||||
src: url("../fonts/Sora-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Sora";
|
||||
src: url("../fonts/Sora-SemiBold.woff2") format("woff2");
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Chivo";
|
||||
src: url("../fonts/Chivo-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Chivo";
|
||||
src: url("../fonts/Chivo-Bold.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Atkinson Hyperlegible";
|
||||
src: url("../fonts/Atkinson-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Atkinson Hyperlegible";
|
||||
src: url("../fonts/Atkinson-Bold.woff2") format("woff2");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Sans";
|
||||
src: url("../fonts/PlexSans-Regular.woff2") format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Sans";
|
||||
src: url("../fonts/PlexSans-SemiBold.woff2") format("woff2");
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
335
pikit-web/assets/css/forms.css
Normal file
335
pikit-web/assets/css/forms.css
Normal file
@@ -0,0 +1,335 @@
|
||||
button {
|
||||
background: linear-gradient(135deg, #22c55e, #7dd3fc);
|
||||
color: #041012;
|
||||
border: none;
|
||||
padding: 10px 16px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
button.icon-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 1.1rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] button.icon-btn {
|
||||
box-shadow: inset 0 0 0 1px var(--border);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
button.danger-btn {
|
||||
background: linear-gradient(135deg, #f87171, #ef4444);
|
||||
color: #0f1117;
|
||||
}
|
||||
|
||||
.menu-btn {
|
||||
font-size: 1rem;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.service-menu {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.service-menu .ghost {
|
||||
border: 1px solid color-mix(in srgb, var(--border) 60%, var(--text) 20%);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
label.toggle {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 46px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
label.toggle input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
label.toggle .slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
background: var(--toggle-track);
|
||||
border-radius: 24px;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
label.toggle .slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
label.toggle input:checked + .slider {
|
||||
background: linear-gradient(135deg, #22c55e, #7dd3fc);
|
||||
}
|
||||
|
||||
label.toggle input:checked + .slider:before {
|
||||
transform: translateX(22px);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.accordion {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: var(--panel-overlay);
|
||||
}
|
||||
|
||||
.accordion + .accordion {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.accordion-toggle {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: none;
|
||||
padding: 12px 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.accordion-toggle.danger-btn {
|
||||
color: #0f1117;
|
||||
}
|
||||
|
||||
.accordion-body {
|
||||
padding: 0 14px 0;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transition:
|
||||
max-height 0.24s ease,
|
||||
opacity 0.18s ease,
|
||||
padding-bottom 0.18s ease,
|
||||
padding-top 0.18s ease;
|
||||
}
|
||||
|
||||
.accordion.open .accordion-body {
|
||||
max-height: 1200px;
|
||||
opacity: 1;
|
||||
padding: 8px 12px 6px;
|
||||
}
|
||||
|
||||
.accordion-body p {
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.control-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-actions.split-row {
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-actions.column.tight {
|
||||
gap: 6px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.control-actions.column {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.control-actions.column > .checkbox-row.inline {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
select,
|
||||
input {
|
||||
background: var(--input-bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--input-border);
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.checkbox-row.inline {
|
||||
display: inline-flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.checkbox-row.inline.tight {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.checkbox-row.inline.nowrap span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.control-row.split {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.control-row.split > * {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dual-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dual-row .dual-col:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dual-row .checkbox-row.inline {
|
||||
justify-content: flex-start;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.checkbox-field .checkbox-row {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.status-msg {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.status-msg.error {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.note-warn {
|
||||
color: #f87171;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.is-disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.is-disabled input,
|
||||
.is-disabled select,
|
||||
.is-disabled textarea {
|
||||
filter: grayscale(0.5);
|
||||
background: var(--input-disabled-bg);
|
||||
color: var(--input-disabled-text);
|
||||
border-color: var(--input-disabled-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.is-disabled label {
|
||||
color: var(--disabled-text);
|
||||
}
|
||||
|
||||
.is-disabled .slider {
|
||||
filter: grayscale(0.7);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
background: #2f3844;
|
||||
border: 1px solid #3b4756;
|
||||
color: #c9d2dc;
|
||||
pointer-events: none;
|
||||
}
|
||||
379
pikit-web/assets/css/layout.css
Normal file
379
pikit-web/assets/css/layout.css
Normal file
@@ -0,0 +1,379 @@
|
||||
.host-chip {
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.layout {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 18px 80px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 0.9fr;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.12rem;
|
||||
color: var(--muted);
|
||||
margin: 0 0 6px;
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
.eyebrow.warning {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 10px;
|
||||
line-height: 1.2;
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 6px;
|
||||
line-height: 1.2;
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lede {
|
||||
color: var(--muted);
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.hint.quiet {
|
||||
opacity: 0.8;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
box-shadow: var(--shadow);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
background: var(--card-overlay);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 9px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.stat .label {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.stat .value {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 18px;
|
||||
margin-bottom: 18px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.panel-actions .icon-btn {
|
||||
font-size: 1.15rem;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.panel-header.small-gap {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.grid.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 220px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.empty-state button {
|
||||
margin-top: 6px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--card-overlay);
|
||||
padding: 12px;
|
||||
padding-right: 48px; /* reserve room for stacked action buttons */
|
||||
padding-bottom: 34px; /* reserve room for bottom badges */
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card.offline {
|
||||
border-color: rgba(225, 29, 72, 0.45);
|
||||
}
|
||||
|
||||
.card a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.service-url {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--accent) 18%, var(--panel) 82%);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border) 65%);
|
||||
color: var(--text);
|
||||
font-size: 0.85rem;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pill-small {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.notice-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px dashed #6b7280;
|
||||
color: #6b7280;
|
||||
font-size: 0.78rem;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.self-signed-pill {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.notice-link {
|
||||
display: inline-block;
|
||||
margin-top: 8px;
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.info-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 60%, var(--text) 20%);
|
||||
border-radius: 10px;
|
||||
font-size: 1rem;
|
||||
color: var(--text);
|
||||
background: var(--card-overlay);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #e11d48;
|
||||
box-shadow: 0 0 8px rgba(225, 29, 72, 0.5);
|
||||
}
|
||||
|
||||
.status-dot.on {
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 8px rgba(34, 197, 94, 0.6);
|
||||
}
|
||||
|
||||
html[data-anim="on"] .status-dot.on {
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.35);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px rgba(34, 197, 94, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.status-dot.off {
|
||||
background: #f87171;
|
||||
box-shadow: 0 0 8px rgba(248, 113, 113, 0.5);
|
||||
}
|
||||
|
||||
.service-header {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.service-header .pill {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.service-header .status-dot,
|
||||
.service-header .menu-btn,
|
||||
.service-header .notice-pill {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.service-header .menu-btn {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
#servicesGrid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
}
|
||||
|
||||
.log-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.log-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.log-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.log-actions .icon-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
font-size: 0.9rem;
|
||||
background: var(--card-overlay);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.log-box {
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
font-family: "DM Sans", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.9rem;
|
||||
border: 1px dashed var(--border);
|
||||
color: var(--muted);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .log-box {
|
||||
background: rgba(12, 18, 32, 0.04);
|
||||
}
|
||||
|
||||
.codeblock {
|
||||
background: var(--input-bg);
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
max-width: 440px;
|
||||
min-width: 260px;
|
||||
border: 1px solid var(--border);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
326
pikit-web/assets/css/modal.css
Normal file
326
pikit-web/assets/css/modal.css
Normal 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;
|
||||
}
|
||||
57
pikit-web/assets/css/motion.css
Normal file
57
pikit-web/assets/css/motion.css
Normal file
@@ -0,0 +1,57 @@
|
||||
/* Motion (opt-out via data-anim="off") */
|
||||
html[data-anim="on"] .card,
|
||||
html[data-anim="on"] .stat,
|
||||
html[data-anim="on"] button,
|
||||
html[data-anim="on"] .accordion,
|
||||
html[data-anim="on"] .modal-card {
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
background-color 0.18s ease;
|
||||
}
|
||||
|
||||
html[data-anim="on"] .card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 14px 26px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
html[data-anim="on"][data-theme="light"] .card:hover {
|
||||
box-shadow: 0 14px 26px rgba(12, 18, 32, 0.12);
|
||||
}
|
||||
|
||||
html[data-anim="on"] button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
html[data-anim="on"] button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
html[data-anim="off"] .card,
|
||||
html[data-anim="off"] .stat,
|
||||
html[data-anim="off"] button,
|
||||
html[data-anim="off"] .accordion,
|
||||
html[data-anim="off"] .modal-card {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* Hard-stop any remaining motion (spinners, keyframes, incidental transitions) */
|
||||
html[data-anim="off"] *,
|
||||
html[data-anim="off"] *::before,
|
||||
html[data-anim="off"] *::after {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* Focus states */
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
.card:focus-visible,
|
||||
.status-chip:focus-visible,
|
||||
.accordion-toggle:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
88
pikit-web/assets/css/responsive.css
Normal file
88
pikit-web/assets/css/responsive.css
Normal file
@@ -0,0 +1,88 @@
|
||||
@media (min-width: 1180px) {
|
||||
#servicesGrid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
#servicesGrid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.layout {
|
||||
padding: 24px 14px 60px;
|
||||
}
|
||||
.panel {
|
||||
padding: 16px;
|
||||
}
|
||||
.grid {
|
||||
gap: 10px;
|
||||
}
|
||||
.card {
|
||||
padding: 12px;
|
||||
}
|
||||
.hero-stats {
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.release-versions {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.release-versions .align-right {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.topbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.brand {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.top-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
.top-actions .ghost {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.top-indicators {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.top-indicators .chip-label {
|
||||
grid-column: 1 / -1;
|
||||
margin-right: 0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.top-indicators .status-chip,
|
||||
.top-indicators .hint {
|
||||
width: auto;
|
||||
justify-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.hero-stats {
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
}
|
||||
}
|
||||
117
pikit-web/assets/css/theme.css
Normal file
117
pikit-web/assets/css/theme.css
Normal file
@@ -0,0 +1,117 @@
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--panel: #161a23;
|
||||
--panel-overlay: rgba(255, 255, 255, 0.02);
|
||||
--card-overlay: rgba(255, 255, 255, 0.03);
|
||||
--muted: #9ca3af;
|
||||
--text: #e5e7eb;
|
||||
--accent: #7dd3fc;
|
||||
--accent-2: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
--border: #1f2430;
|
||||
--shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
|
||||
--topbar-bg: rgba(15, 17, 23, 0.8);
|
||||
--toggle-track: #374151;
|
||||
--input-bg: #0c0e14;
|
||||
--input-border: var(--border);
|
||||
--disabled-bg: #141a22;
|
||||
--disabled-border: #2a313c;
|
||||
--disabled-text: #7c8696;
|
||||
--disabled-strong: #0b0f18;
|
||||
--input-disabled-bg: #141a22;
|
||||
--input-disabled-text: #7c8696;
|
||||
--input-disabled-border: #2a313c;
|
||||
--font-body: "Red Hat Text", "Inter", "Segoe UI", system-ui, -apple-system,
|
||||
sans-serif;
|
||||
--font-heading: "Red Hat Display", "Red Hat Text", system-ui, -apple-system,
|
||||
sans-serif;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
:root[data-font="space"] {
|
||||
--font-body: "Space Grotesk", "Inter", "Segoe UI", system-ui, -apple-system,
|
||||
sans-serif;
|
||||
--font-heading: "Space Grotesk", "Red Hat Text", system-ui, -apple-system,
|
||||
sans-serif;
|
||||
}
|
||||
:root[data-font="manrope"] {
|
||||
--font-body: "Manrope", "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
--font-heading: "Manrope", "Manrope", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
:root[data-font="dmsans"] {
|
||||
--font-body: "DM Sans", "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
--font-heading: "DM Sans", "Red Hat Display", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
:root[data-font="sora"] {
|
||||
--font-body: "Sora", "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
--font-heading: "Sora", "Sora", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
:root[data-font="chivo"] {
|
||||
--font-body: "Chivo", "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
--font-heading: "Chivo", "Chivo", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
:root[data-font="atkinson"] {
|
||||
--font-body: "Atkinson Hyperlegible", "Inter", "Segoe UI", system-ui, -apple-system,
|
||||
sans-serif;
|
||||
--font-heading: "Atkinson Hyperlegible", "Atkinson Hyperlegible", system-ui,
|
||||
-apple-system, sans-serif;
|
||||
}
|
||||
:root[data-font="plex"] {
|
||||
--font-body: "IBM Plex Sans", "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
--font-heading: "IBM Plex Sans", "IBM Plex Sans", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
:root[data-theme="light"] {
|
||||
--bg: #dfe4ee;
|
||||
--panel: #f6f8fd;
|
||||
--panel-overlay: rgba(10, 12, 18, 0.06);
|
||||
--card-overlay: rgba(10, 12, 18, 0.11);
|
||||
--muted: #4b5563;
|
||||
--text: #0b1224;
|
||||
--accent: #0077c2;
|
||||
--accent-2: #15803d;
|
||||
--warning: #b45309;
|
||||
--border: #bcc5d6;
|
||||
--shadow: 0 12px 30px rgba(12, 18, 32, 0.12);
|
||||
--topbar-bg: rgba(249, 251, 255, 0.92);
|
||||
--toggle-track: #d1d5db;
|
||||
--input-bg: #f0f2f7;
|
||||
--input-border: #c5ccd9;
|
||||
--disabled-bg: #f4f6fb;
|
||||
--disabled-border: #c8d0df;
|
||||
--disabled-text: #7a8292;
|
||||
--disabled-strong: #eef1f7;
|
||||
--input-disabled-bg: #f8fafc;
|
||||
--input-disabled-text: #6a6f7b;
|
||||
--input-disabled-border: #c9d1df;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 20% 20%,
|
||||
rgba(125, 211, 252, 0.08),
|
||||
transparent 32%
|
||||
),
|
||||
radial-gradient(circle at 80% 0%, rgba(34, 197, 94, 0.06), transparent 28%),
|
||||
linear-gradient(180deg, #0f1117 0%, #0e1119 55%, #0b0f15 100%);
|
||||
background-attachment: fixed;
|
||||
background-repeat: no-repeat;
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
transition: background 240ms ease, color 240ms ease;
|
||||
}
|
||||
:root[data-theme="light"] body {
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 25% 18%,
|
||||
rgba(0, 119, 194, 0.14),
|
||||
transparent 34%
|
||||
),
|
||||
radial-gradient(circle at 78% 8%, rgba(21, 128, 61, 0.12), transparent 30%),
|
||||
linear-gradient(180deg, #f6f8fd 0%, #e8edf7 52%, #d6dde9 100%);
|
||||
background-attachment: fixed;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
204
pikit-web/assets/css/toast.css
Normal file
204
pikit-web/assets/css/toast.css
Normal file
@@ -0,0 +1,204 @@
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
min-width: 200px;
|
||||
max-width: 320px;
|
||||
max-height: 240px;
|
||||
overflow: hidden;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
box-shadow: var(--shadow);
|
||||
font-weight: 600;
|
||||
pointer-events: auto;
|
||||
opacity: 0;
|
||||
transform: translateY(var(--toast-slide-offset, 14px));
|
||||
transition:
|
||||
opacity var(--toast-speed, 0.28s) ease,
|
||||
transform var(--toast-speed, 0.28s) ease,
|
||||
max-height var(--toast-speed, 0.28s) ease,
|
||||
padding var(--toast-speed, 0.28s) ease,
|
||||
margin var(--toast-speed, 0.28s) ease;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.toast.success {
|
||||
border-color: rgba(34, 197, 94, 0.5);
|
||||
}
|
||||
|
||||
.toast.warn {
|
||||
border-color: rgba(217, 119, 6, 0.5);
|
||||
}
|
||||
|
||||
.toast.error {
|
||||
border-color: rgba(225, 29, 72, 0.6);
|
||||
}
|
||||
|
||||
.toast.info {
|
||||
border-color: rgba(125, 211, 252, 0.4);
|
||||
}
|
||||
|
||||
html[data-anim="off"] .toast {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.toast.anim-slide-in {
|
||||
transform: translate(var(--toast-slide-x, 0px), var(--toast-slide-y, 24px));
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.toast.anim-slide-in.show {
|
||||
transform: translate(0, 0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast.anim-fade {
|
||||
transform: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.toast.anim-fade.show {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast.anim-pop {
|
||||
transform: scale(0.9);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.toast.anim-pop.show {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast.anim-bounce {
|
||||
opacity: 0;
|
||||
transform: translateY(calc(var(--toast-dir, 1) * 20px));
|
||||
}
|
||||
|
||||
.toast.anim-bounce.show {
|
||||
opacity: 1;
|
||||
animation: toast-bounce var(--toast-speed, 0.46s) cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
.toast.anim-drop {
|
||||
opacity: 0;
|
||||
transform: translateY(calc(var(--toast-dir, 1) * -24px)) scale(0.98);
|
||||
}
|
||||
|
||||
.toast.anim-drop.show {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast.anim-grow {
|
||||
transform: scale(0.85);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.toast.anim-grow.show {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast.leaving {
|
||||
opacity: 0 !important;
|
||||
transform: translateY(12px) !important;
|
||||
max-height: 0 !important;
|
||||
margin: 0 !important;
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
@keyframes toast-bounce {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(calc(var(--toast-dir, 1) * 20px)) scale(0.96);
|
||||
}
|
||||
55% {
|
||||
opacity: 1;
|
||||
transform: translateY(calc(var(--toast-dir, 1) * -8px)) scale(1.03);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.toast-container.pos-bottom-center {
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
right: auto;
|
||||
top: auto;
|
||||
transform: translateX(-50%);
|
||||
flex-direction: column-reverse;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toast-container.pos-bottom-right {
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
left: auto;
|
||||
top: auto;
|
||||
transform: none;
|
||||
flex-direction: column-reverse;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.toast-container.pos-bottom-left {
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
right: auto;
|
||||
top: auto;
|
||||
transform: none;
|
||||
flex-direction: column-reverse;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.toast-container.pos-top-right {
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
bottom: auto;
|
||||
left: auto;
|
||||
transform: none;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.toast-container.pos-top-left {
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
bottom: auto;
|
||||
right: auto;
|
||||
transform: none;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.toast-container.pos-top-center {
|
||||
top: 16px;
|
||||
left: 50%;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
transform: translateX(-50%);
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
126
pikit-web/assets/css/topbar.css
Normal file
126
pikit-web/assets/css/topbar.css
Normal file
@@ -0,0 +1,126 @@
|
||||
.topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 22px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
backdrop-filter: blur(10px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--topbar-bg);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.3px;
|
||||
font-family: var(--font-heading);
|
||||
}
|
||||
|
||||
.brand .dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #22c55e, #7dd3fc);
|
||||
box-shadow: 0 0 10px rgba(125, 211, 252, 0.6);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.top-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.top-indicators {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chip-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.status-chip.quiet {
|
||||
opacity: 0.75;
|
||||
font-size: 0.85rem;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.status-chip.chip-on {
|
||||
color: var(--accent-2);
|
||||
border-color: rgba(22, 163, 74, 0.4);
|
||||
background: rgba(22, 163, 74, 0.08);
|
||||
}
|
||||
|
||||
.status-chip.chip-off {
|
||||
color: #e11d48;
|
||||
border-color: rgba(225, 29, 72, 0.4);
|
||||
background: rgba(225, 29, 72, 0.08);
|
||||
}
|
||||
|
||||
.status-chip.chip-system {
|
||||
color: #3b82f6;
|
||||
border-color: rgba(59, 130, 246, 0.45);
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
|
||||
.status-chip.chip-warm {
|
||||
color: #d97706;
|
||||
border-color: rgba(217, 119, 6, 0.35);
|
||||
background: rgba(217, 119, 6, 0.12);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .status-chip {
|
||||
background: rgba(12, 18, 32, 0.06);
|
||||
border-color: rgba(12, 18, 32, 0.14);
|
||||
color: #1f2a3d;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .status-chip.quiet {
|
||||
background: rgba(12, 18, 32, 0.05);
|
||||
color: #243247;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .status-chip.chip-on {
|
||||
background: rgba(34, 197, 94, 0.16);
|
||||
border-color: rgba(34, 197, 94, 0.5);
|
||||
color: #0f5132;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .status-chip.chip-system {
|
||||
background: rgba(59, 130, 246, 0.16);
|
||||
border-color: rgba(59, 130, 246, 0.55);
|
||||
color: #153e9f;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .status-chip.chip-warm {
|
||||
background: rgba(217, 119, 6, 0.16);
|
||||
border-color: rgba(217, 119, 6, 0.5);
|
||||
color: #8a4b08;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .status-chip.chip-off {
|
||||
background: rgba(225, 29, 72, 0.18);
|
||||
border-color: rgba(225, 29, 72, 0.55);
|
||||
color: #7a1028;
|
||||
}
|
||||
152
pikit-web/assets/css/updates.css
Normal file
152
pikit-web/assets/css/updates.css
Normal file
@@ -0,0 +1,152 @@
|
||||
#releaseProgress {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.updates-status {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.updates-status.error {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#acc-updates .accordion-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px 12px 2px !important;
|
||||
}
|
||||
|
||||
#updatesSection {
|
||||
width: 100%;
|
||||
gap: 4px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
#updatesControls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#updatesControls.is-disabled {
|
||||
opacity: 0.6;
|
||||
background: var(--disabled-bg);
|
||||
border: 1px dashed var(--disabled-border);
|
||||
border-radius: 8px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
#updatesControls.is-disabled * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#updatesControls input:disabled,
|
||||
#updatesControls select:disabled,
|
||||
#updatesControls textarea:disabled {
|
||||
background: var(--input-disabled-bg);
|
||||
color: var(--input-disabled-text);
|
||||
border-color: var(--input-disabled-border);
|
||||
}
|
||||
|
||||
#updatesControls .checkbox-row input:disabled + span,
|
||||
#updatesControls label,
|
||||
#updatesControls .field > span,
|
||||
#updatesControls .hint {
|
||||
color: var(--disabled-text);
|
||||
}
|
||||
|
||||
#updatesControls .control-actions,
|
||||
#updatesControls .field {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
#updatesControls .toggle .slider {
|
||||
filter: grayscale(0.9);
|
||||
}
|
||||
|
||||
/* Disabled styling scoped to updates section */
|
||||
#updatesControls.is-disabled input,
|
||||
#updatesControls.is-disabled select,
|
||||
#updatesControls.is-disabled textarea {
|
||||
background: var(--input-disabled-bg);
|
||||
color: var(--input-disabled-text);
|
||||
border-color: var(--input-disabled-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
#updatesControls.is-disabled .checkbox-row span,
|
||||
#updatesControls.is-disabled label,
|
||||
#updatesControls.is-disabled .field > span,
|
||||
#updatesControls.is-disabled .hint {
|
||||
color: var(--disabled-text);
|
||||
}
|
||||
|
||||
#updatesControls.is-disabled .control-actions,
|
||||
#updatesControls.is-disabled .field {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
#updatesControls.is-disabled .toggle .slider {
|
||||
filter: grayscale(0.8);
|
||||
}
|
||||
|
||||
/* Light theme contrast for disabled controls */
|
||||
:root[data-theme="light"] #updatesSection {
|
||||
background: #f7f9fd;
|
||||
border: 1px solid #d9dfeb;
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] #updatesControls {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] #updatesControls.is-disabled {
|
||||
opacity: 1;
|
||||
background: var(--disabled-bg);
|
||||
border: 1px dashed var(--disabled-border);
|
||||
border-radius: 8px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] #updatesControls.is-disabled * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] #updatesControls.is-disabled input,
|
||||
:root[data-theme="light"] #updatesControls.is-disabled select,
|
||||
:root[data-theme="light"] #updatesControls.is-disabled textarea {
|
||||
background: var(--input-disabled-bg) !important;
|
||||
color: var(--input-disabled-text) !important;
|
||||
border: 1px dashed var(--disabled-border) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] #updatesControls.is-disabled .checkbox-row input:disabled + span,
|
||||
:root[data-theme="light"] #updatesControls.is-disabled label,
|
||||
:root[data-theme="light"] #updatesControls.is-disabled .field > span,
|
||||
:root[data-theme="light"] #updatesControls.is-disabled .hint {
|
||||
color: var(--disabled-text) !important;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] #updatesControls.is-disabled .control-actions,
|
||||
:root[data-theme="light"] #updatesControls.is-disabled .field {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] #updatesControls.is-disabled .toggle .slider {
|
||||
filter: grayscale(0.2);
|
||||
}
|
||||
|
||||
#updatesControls .form-grid {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#updatesControls .control-actions.split-row {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
// Entry point for the dashboard: wires UI events, pulls status, and initializes
|
||||
// feature modules (services, settings, stats).
|
||||
import { getStatus, triggerReset } from "./api.js";
|
||||
import { initServiceControls } from "./services.js";
|
||||
import { placeholderStatus, renderStats } from "./status.js";
|
||||
import { initServiceControls, renderServices } from "./services.js";
|
||||
import { initSettings } from "./settings.js";
|
||||
import { initUpdateSettings, isUpdatesDirty } from "./update-settings.js";
|
||||
import { initReleaseUI } from "./releases.js?v=20251213h";
|
||||
import { initDiagUI, logUi } from "./diaglog.js?v=20251213j";
|
||||
import { createToastManager } from "./toast.js?v=20251213a";
|
||||
import {
|
||||
applyTooltips,
|
||||
wireModalPairs,
|
||||
wireAccordions,
|
||||
createBusyOverlay,
|
||||
createConfirmModal,
|
||||
} from "./ui.js";
|
||||
import { createStatusController } from "./status-controller.js";
|
||||
|
||||
const servicesGrid = document.getElementById("servicesGrid");
|
||||
const heroStats = document.getElementById("heroStats");
|
||||
@@ -32,6 +41,7 @@ const toastPosSelect = document.getElementById("toastPosSelect");
|
||||
const toastAnimSelect = document.getElementById("toastAnimSelect");
|
||||
const toastSpeedInput = document.getElementById("toastSpeedInput");
|
||||
const toastDurationInput = document.getElementById("toastDurationInput");
|
||||
const toastTestBtn = document.getElementById("toastTestBtn");
|
||||
const fontSelect = document.getElementById("fontSelect");
|
||||
const updatesScope = document.getElementById("updatesScope");
|
||||
const updateTimeInput = document.getElementById("updateTimeInput");
|
||||
@@ -111,194 +121,141 @@ const diagModal = document.getElementById("diagModal");
|
||||
const diagClose = document.getElementById("diagClose");
|
||||
const diagStatusModal = document.getElementById("diagStatusModal");
|
||||
|
||||
const TOAST_POS_KEY = "pikit-toast-pos";
|
||||
const TOAST_ANIM_KEY = "pikit-toast-anim";
|
||||
const TOAST_SPEED_KEY = "pikit-toast-speed";
|
||||
const TOAST_DURATION_KEY = "pikit-toast-duration";
|
||||
const FONT_KEY = "pikit-font";
|
||||
const ALLOWED_TOAST_POS = [
|
||||
"bottom-center",
|
||||
"bottom-right",
|
||||
"bottom-left",
|
||||
"top-right",
|
||||
"top-left",
|
||||
"top-center",
|
||||
];
|
||||
const ALLOWED_TOAST_ANIM = ["slide-in", "fade", "pop", "bounce", "drop", "grow"];
|
||||
const ALLOWED_FONTS = ["redhat", "space", "manrope", "dmsans", "sora", "chivo", "atkinson", "plex"];
|
||||
|
||||
let toastPosition = "bottom-center";
|
||||
let toastAnimation = "slide-in";
|
||||
let toastDurationMs = 5000;
|
||||
let toastSpeedMs = 300;
|
||||
let fontChoice = "redhat";
|
||||
const toastController = createToastManager({
|
||||
container: toastContainer,
|
||||
posSelect: toastPosSelect,
|
||||
animSelect: toastAnimSelect,
|
||||
speedInput: toastSpeedInput,
|
||||
durationInput: toastDurationInput,
|
||||
fontSelect,
|
||||
testBtn: toastTestBtn,
|
||||
});
|
||||
const showToast = toastController.showToast;
|
||||
let releaseUI = null;
|
||||
let lastStatusData = null;
|
||||
const { showBusy, hideBusy } = createBusyOverlay({
|
||||
overlay: busyOverlay,
|
||||
titleEl: busyTitle,
|
||||
textEl: busyText,
|
||||
});
|
||||
const confirmAction = createConfirmModal({
|
||||
modal: confirmModal,
|
||||
titleEl: confirmTitle,
|
||||
bodyEl: confirmBody,
|
||||
okBtn: confirmOk,
|
||||
cancelBtn: confirmCancel,
|
||||
});
|
||||
const collapseAccordions = () => document.querySelectorAll(".accordion").forEach((a) => a.classList.remove("open"));
|
||||
|
||||
function applyToastSettings() {
|
||||
if (!toastContainer) return;
|
||||
toastContainer.className = `toast-container pos-${toastPosition}`;
|
||||
document.documentElement.style.setProperty("--toast-speed", `${toastSpeedMs}ms`);
|
||||
const dir = toastPosition.startsWith("top") ? -1 : 1;
|
||||
const isLeft = toastPosition.includes("left");
|
||||
const isRight = toastPosition.includes("right");
|
||||
const slideX = isLeft ? -26 : isRight ? 26 : 0;
|
||||
const slideY = isLeft || isRight ? 0 : dir * 24;
|
||||
document.documentElement.style.setProperty("--toast-slide-offset", `${dir * 24}px`);
|
||||
document.documentElement.style.setProperty("--toast-dir", `${dir}`);
|
||||
document.documentElement.style.setProperty("--toast-slide-x", `${slideX}px`);
|
||||
document.documentElement.style.setProperty("--toast-slide-y", `${slideY}px`);
|
||||
if (toastDurationInput) toastDurationInput.value = toastDurationMs;
|
||||
}
|
||||
const statusController = createStatusController({
|
||||
heroStats,
|
||||
servicesGrid,
|
||||
updatesFlagTop,
|
||||
updatesNoteTop,
|
||||
tempFlagTop,
|
||||
readyOverlay,
|
||||
logUi,
|
||||
getStatus,
|
||||
isUpdatesDirty,
|
||||
setUpdatesUI,
|
||||
updatesFlagEl: setUpdatesFlag,
|
||||
releaseUIGetter: () => releaseUI,
|
||||
onReadyWait: () => setTimeout(() => statusController.loadStatus(), 3000),
|
||||
});
|
||||
const { loadStatus } = statusController;
|
||||
|
||||
function applyFontSetting() {
|
||||
document.documentElement.setAttribute("data-font", fontChoice);
|
||||
if (fontSelect) fontSelect.value = fontChoice;
|
||||
}
|
||||
|
||||
function loadToastSettings() {
|
||||
try {
|
||||
const posSaved = localStorage.getItem(TOAST_POS_KEY);
|
||||
if (ALLOWED_TOAST_POS.includes(posSaved)) toastPosition = posSaved;
|
||||
const animSaved = localStorage.getItem(TOAST_ANIM_KEY);
|
||||
const migrated =
|
||||
animSaved === "slide-up" || animSaved === "slide-left" || animSaved === "slide-right"
|
||||
? "slide-in"
|
||||
: animSaved;
|
||||
if (migrated && ALLOWED_TOAST_ANIM.includes(migrated)) toastAnimation = migrated;
|
||||
const savedSpeed = Number(localStorage.getItem(TOAST_SPEED_KEY));
|
||||
if (!Number.isNaN(savedSpeed) && savedSpeed >= 100 && savedSpeed <= 3000) {
|
||||
toastSpeedMs = savedSpeed;
|
||||
function wireDialogs() {
|
||||
wireModalPairs([
|
||||
{ openBtn: aboutBtn, modal: aboutModal, closeBtn: aboutClose },
|
||||
{ openBtn: helpBtn, modal: helpModal, closeBtn: helpClose },
|
||||
]);
|
||||
// Settings modal keeps custom accordion collapse on close
|
||||
advBtn?.addEventListener("click", () => {
|
||||
if (window.__pikitTest?.forceServiceFormVisible) {
|
||||
// For tests: avoid opening any modal; just ensure form controls are visible
|
||||
addServiceModal?.classList.add("hidden");
|
||||
addServiceModal?.setAttribute("style", "display:none;");
|
||||
window.__pikitTest.forceServiceFormVisible();
|
||||
return;
|
||||
}
|
||||
const savedDur = Number(localStorage.getItem(TOAST_DURATION_KEY));
|
||||
if (!Number.isNaN(savedDur) && savedDur >= 1000 && savedDur <= 15000) {
|
||||
toastDurationMs = savedDur;
|
||||
}
|
||||
const savedFont = localStorage.getItem(FONT_KEY);
|
||||
if (ALLOWED_FONTS.includes(savedFont)) fontChoice = savedFont;
|
||||
} catch (e) {
|
||||
console.warn("Toast settings load failed", e);
|
||||
}
|
||||
if (toastPosSelect) toastPosSelect.value = toastPosition;
|
||||
if (toastAnimSelect) toastAnimSelect.value = toastAnimation;
|
||||
if (toastSpeedInput) toastSpeedInput.value = toastSpeedMs;
|
||||
if (toastDurationInput) toastDurationInput.value = toastDurationMs;
|
||||
if (fontSelect) fontSelect.value = fontChoice;
|
||||
applyToastSettings();
|
||||
applyFontSetting();
|
||||
}
|
||||
|
||||
function persistToastSettings() {
|
||||
try {
|
||||
localStorage.setItem(TOAST_POS_KEY, toastPosition);
|
||||
localStorage.setItem(TOAST_ANIM_KEY, toastAnimation);
|
||||
localStorage.setItem(TOAST_SPEED_KEY, String(toastSpeedMs));
|
||||
localStorage.setItem(TOAST_DURATION_KEY, String(toastDurationMs));
|
||||
localStorage.setItem(FONT_KEY, fontChoice);
|
||||
} catch (e) {
|
||||
console.warn("Toast settings save failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message, type = "info") {
|
||||
if (!toastContainer || !message) return;
|
||||
const t = document.createElement("div");
|
||||
t.className = `toast ${type} anim-${toastAnimation}`;
|
||||
t.textContent = message;
|
||||
toastContainer.appendChild(t);
|
||||
const animOn = document.documentElement.getAttribute("data-anim") !== "off";
|
||||
if (!animOn) {
|
||||
t.classList.add("show");
|
||||
} else {
|
||||
requestAnimationFrame(() => t.classList.add("show"));
|
||||
}
|
||||
const duration = toastDurationMs;
|
||||
setTimeout(() => {
|
||||
const all = Array.from(toastContainer.querySelectorAll(".toast"));
|
||||
const others = all.filter((el) => el !== t && !el.classList.contains("leaving"));
|
||||
const first = new Map(
|
||||
others.map((el) => [el, el.getBoundingClientRect()]),
|
||||
);
|
||||
|
||||
t.classList.add("leaving");
|
||||
// force layout
|
||||
void t.offsetHeight;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const second = new Map(
|
||||
others.map((el) => [el, el.getBoundingClientRect()]),
|
||||
);
|
||||
others.forEach((el) => {
|
||||
const dy = first.get(el).top - second.get(el).top;
|
||||
if (Math.abs(dy) > 0.5) {
|
||||
el.style.transition = "transform var(--toast-speed, 0.28s) ease";
|
||||
el.style.transform = `translateY(${dy}px)`;
|
||||
requestAnimationFrame(() => {
|
||||
el.style.transform = "";
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const removeDelay = animOn ? toastSpeedMs : 0;
|
||||
setTimeout(() => {
|
||||
t.classList.remove("show");
|
||||
t.remove();
|
||||
// clear transition styling
|
||||
others.forEach((el) => (el.style.transition = ""));
|
||||
}, removeDelay);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
function applyTooltips() {
|
||||
const tips = {
|
||||
updatesFlagTop: "Auto updates status; configure in Settings → Automatic updates.",
|
||||
refreshFlagTop: "Auto-refresh interval; change in Settings → Refresh interval.",
|
||||
tempFlagTop: "CPU temperature status; see details in the hero stats below.",
|
||||
releaseFlagTop: "Pi-Kit release status; open Settings → Updates to install.",
|
||||
themeToggle: "Toggle light or dark theme",
|
||||
helpBtn: "Open quick help",
|
||||
advBtn: "Open settings",
|
||||
animToggle: "Enable or disable dashboard animations",
|
||||
refreshIntervalInput: "Seconds between automatic refreshes (5-120)",
|
||||
refreshIntervalSave: "Save refresh interval",
|
||||
svcName: "Display name for the service card",
|
||||
svcPort: "Port number the service listens on",
|
||||
svcPath: "Optional path like /admin",
|
||||
svcScheme: "Choose HTTP or HTTPS link",
|
||||
svcSelfSigned: "Mark service as using a self-signed certificate",
|
||||
svcNotice: "Optional note shown on the service card",
|
||||
svcNoticeLink: "Optional link for more info about the service",
|
||||
svcAddBtn: "Add the service to the dashboard",
|
||||
updatesToggle: "Turn unattended upgrades on or off",
|
||||
updatesScope: "Select security-only or all updates",
|
||||
updateTimeInput: "Time to download updates (24h)",
|
||||
upgradeTimeInput: "Time to install updates (24h)",
|
||||
updatesCleanup: "Remove unused dependencies after upgrades",
|
||||
updatesBandwidth: "Limit download bandwidth in KB/s (0 = unlimited)",
|
||||
updatesRebootToggle: "Auto-reboot if required by updates",
|
||||
updatesRebootTime: "Scheduled reboot time when auto-reboot is on",
|
||||
updatesRebootUsers: "Allow reboot even if users are logged in",
|
||||
updatesSaveBtn: "Save unattended-upgrades settings",
|
||||
resetConfirm: "Type YES to enable factory reset",
|
||||
resetBtn: "Factory reset this Pi-Kit",
|
||||
menuRename: "Change the service display name",
|
||||
menuPort: "Change the service port",
|
||||
menuPath: "Optional service path",
|
||||
menuScheme: "Switch between HTTP and HTTPS",
|
||||
menuSelfSigned: "Mark the service as self-signed",
|
||||
menuNotice: "Edit the notice text shown on the card",
|
||||
menuNoticeLink: "Optional link for the notice",
|
||||
menuSaveBtn: "Save service changes",
|
||||
menuCancelBtn: "Cancel changes",
|
||||
menuRemoveBtn: "Remove this service",
|
||||
};
|
||||
Object.entries(tips).forEach(([id, text]) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.title = text;
|
||||
advModal.classList.remove("hidden");
|
||||
});
|
||||
advClose?.addEventListener("click", () => {
|
||||
advModal.classList.add("hidden");
|
||||
collapseAccordions();
|
||||
});
|
||||
menuClose.onclick = () => menuModal.classList.add("hidden");
|
||||
addServiceOpen?.addEventListener("click", openAddService);
|
||||
addSvcClose?.addEventListener("click", () => addServiceModal?.classList.add("hidden"));
|
||||
addServiceModal?.addEventListener("click", (e) => {
|
||||
if (e.target === addServiceModal) addServiceModal.classList.add("hidden");
|
||||
});
|
||||
}
|
||||
|
||||
// Testing hook
|
||||
if (typeof window !== "undefined") {
|
||||
window.__pikitTest = window.__pikitTest || {};
|
||||
window.__pikitTest.showBusy = showBusy;
|
||||
window.__pikitTest.hideBusy = hideBusy;
|
||||
window.__pikitTest.exposeServiceForm = () => {
|
||||
if (!addServiceModal) return;
|
||||
const card = addServiceModal.querySelector(".modal-card");
|
||||
if (!card) return;
|
||||
addServiceModal.classList.add("hidden"); // keep overlay out of the way
|
||||
card.style.position = "static";
|
||||
card.style.background = "transparent";
|
||||
card.style.boxShadow = "none";
|
||||
card.style.border = "none";
|
||||
card.style.padding = "0";
|
||||
card.style.margin = "12px auto";
|
||||
card.style.maxWidth = "720px";
|
||||
// Move the form inline so Playwright can see it without the overlay
|
||||
document.body.appendChild(card);
|
||||
};
|
||||
}
|
||||
|
||||
const TOOLTIP_MAP = {
|
||||
updatesFlagTop: "Auto updates status; configure in Settings → Automatic updates.",
|
||||
refreshFlagTop: "Auto-refresh interval; change in Settings → Refresh interval.",
|
||||
tempFlagTop: "CPU temperature status; see details in the hero stats below.",
|
||||
releaseFlagTop: "Pi-Kit release status; open Settings → Updates to install.",
|
||||
themeToggle: "Toggle light or dark theme",
|
||||
helpBtn: "Open quick help",
|
||||
advBtn: "Open settings",
|
||||
animToggle: "Enable or disable dashboard animations",
|
||||
refreshIntervalInput: "Seconds between automatic refreshes (5-120)",
|
||||
refreshIntervalSave: "Save refresh interval",
|
||||
svcName: "Display name for the service card",
|
||||
svcPort: "Port number the service listens on",
|
||||
svcPath: "Optional path like /admin",
|
||||
svcScheme: "Choose HTTP or HTTPS link",
|
||||
svcSelfSigned: "Mark service as using a self-signed certificate",
|
||||
svcNotice: "Optional note shown on the service card",
|
||||
svcNoticeLink: "Optional link for more info about the service",
|
||||
svcAddBtn: "Add the service to the dashboard",
|
||||
updatesToggle: "Turn unattended upgrades on or off",
|
||||
updatesScope: "Select security-only or all updates",
|
||||
updateTimeInput: "Time to download updates (24h)",
|
||||
upgradeTimeInput: "Time to install updates (24h)",
|
||||
updatesCleanup: "Remove unused dependencies after upgrades",
|
||||
updatesBandwidth: "Limit download bandwidth in KB/s (0 = unlimited)",
|
||||
updatesRebootToggle: "Auto-reboot if required by updates",
|
||||
updatesRebootTime: "Scheduled reboot time when auto-reboot is on",
|
||||
updatesRebootUsers: "Allow reboot even if users are logged in",
|
||||
updatesSaveBtn: "Save unattended-upgrades settings",
|
||||
resetConfirm: "Type YES to enable factory reset",
|
||||
resetBtn: "Factory reset this Pi-Kit",
|
||||
menuRename: "Change the service display name",
|
||||
menuPort: "Change the service port",
|
||||
menuPath: "Optional service path",
|
||||
menuScheme: "Switch between HTTP and HTTPS",
|
||||
menuSelfSigned: "Mark the service as self-signed",
|
||||
menuNotice: "Edit the notice text shown on the card",
|
||||
menuNoticeLink: "Optional link for the notice",
|
||||
menuSaveBtn: "Save service changes",
|
||||
menuCancelBtn: "Cancel changes",
|
||||
menuRemoveBtn: "Remove this service",
|
||||
};
|
||||
|
||||
// Clamp name inputs to 30 chars
|
||||
[svcName, menuRename].forEach((el) => {
|
||||
if (!el) return;
|
||||
@@ -316,79 +273,7 @@ function setUpdatesUI(enabled) {
|
||||
updatesStatus.classList.toggle("chip-off", !on);
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
try {
|
||||
const data = await getStatus();
|
||||
lastStatusData = data;
|
||||
renderStats(heroStats, data);
|
||||
renderServices(servicesGrid, data.services, { openAddService });
|
||||
const updatesEnabled =
|
||||
data?.auto_updates?.enabled ?? data.auto_updates_enabled;
|
||||
if (updatesEnabled !== undefined && !isUpdatesDirty()) {
|
||||
setUpdatesUI(updatesEnabled);
|
||||
}
|
||||
|
||||
// Updates chip + reboot note
|
||||
updatesFlagEl(
|
||||
updatesEnabled === undefined ? null : updatesEnabled === true,
|
||||
);
|
||||
const cfg = data.updates_config || {};
|
||||
const rebootReq = data.reboot_required;
|
||||
setTempFlag(data.cpu_temp_c);
|
||||
if (updatesNoteTop) {
|
||||
updatesNoteTop.textContent = "";
|
||||
updatesNoteTop.classList.remove("note-warn");
|
||||
if (rebootReq) {
|
||||
if (cfg.auto_reboot) {
|
||||
updatesNoteTop.textContent = `Reboot scheduled at ${cfg.reboot_time || "02:00"}.`;
|
||||
} else {
|
||||
updatesNoteTop.textContent = "Reboot required. Please reboot when you can.";
|
||||
updatesNoteTop.classList.add("note-warn");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (readyOverlay) {
|
||||
if (data.ready) {
|
||||
readyOverlay.classList.add("hidden");
|
||||
} else {
|
||||
readyOverlay.classList.remove("hidden");
|
||||
// When not ready, retry periodically until API reports ready
|
||||
setTimeout(loadStatus, 3000);
|
||||
}
|
||||
}
|
||||
// Pull Pi-Kit release status after core status
|
||||
releaseUI?.refreshStatus();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
logUi(`Status refresh failed: ${e?.message || e}`, "error");
|
||||
if (!lastStatusData) {
|
||||
renderStats(heroStats, placeholderStatus);
|
||||
}
|
||||
setTimeout(loadStatus, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function setTempFlag(tempC) {
|
||||
if (!tempFlagTop) return;
|
||||
const t = typeof tempC === "number" ? tempC : null;
|
||||
let label = "Temp: n/a";
|
||||
tempFlagTop.classList.remove("chip-on", "chip-warm", "chip-off");
|
||||
if (t !== null) {
|
||||
if (t < 55) {
|
||||
label = "Temp: OK";
|
||||
tempFlagTop.classList.add("chip-on");
|
||||
} else if (t < 70) {
|
||||
label = "Temp: Warm";
|
||||
tempFlagTop.classList.add("chip-warm");
|
||||
} else {
|
||||
label = "Temp: Hot";
|
||||
tempFlagTop.classList.add("chip-off");
|
||||
}
|
||||
}
|
||||
tempFlagTop.textContent = label;
|
||||
}
|
||||
|
||||
function updatesFlagEl(enabled) {
|
||||
function setUpdatesFlag(enabled) {
|
||||
if (!updatesFlagTop) return;
|
||||
const labelOn = "System updates: On";
|
||||
const labelOff = "System updates: Off";
|
||||
@@ -398,61 +283,6 @@ function updatesFlagEl(enabled) {
|
||||
if (enabled === false) updatesFlagTop.classList.add("chip-off");
|
||||
}
|
||||
|
||||
function wireModals() {
|
||||
advBtn.onclick = () => advModal.classList.remove("hidden");
|
||||
advClose.onclick = () => advModal.classList.add("hidden");
|
||||
helpBtn.onclick = () => helpModal.classList.remove("hidden");
|
||||
helpClose.onclick = () => helpModal.classList.add("hidden");
|
||||
aboutBtn.onclick = () => aboutModal.classList.remove("hidden");
|
||||
aboutClose.onclick = () => aboutModal.classList.add("hidden");
|
||||
menuClose.onclick = () => menuModal.classList.add("hidden");
|
||||
addServiceOpen?.addEventListener("click", openAddService);
|
||||
addSvcClose?.addEventListener("click", () => addServiceModal?.classList.add("hidden"));
|
||||
addServiceModal?.addEventListener("click", (e) => {
|
||||
if (e.target === addServiceModal) addServiceModal.classList.add("hidden");
|
||||
});
|
||||
}
|
||||
|
||||
function showBusy(title = "Working…", text = "This may take a few seconds.") {
|
||||
if (!busyOverlay) return;
|
||||
busyTitle.textContent = title;
|
||||
busyText.textContent = text || "";
|
||||
busyText.classList.toggle("hidden", !text);
|
||||
busyOverlay.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function hideBusy() {
|
||||
busyOverlay?.classList.add("hidden");
|
||||
}
|
||||
|
||||
function confirmAction(title, body) {
|
||||
return new Promise((resolve) => {
|
||||
if (!confirmModal) {
|
||||
const ok = window.confirm(body || title || "Are you sure?");
|
||||
resolve(ok);
|
||||
return;
|
||||
}
|
||||
confirmTitle.textContent = title || "Are you sure?";
|
||||
confirmBody.textContent = body || "";
|
||||
confirmModal.classList.remove("hidden");
|
||||
const done = (val) => {
|
||||
confirmModal.classList.add("hidden");
|
||||
resolve(val);
|
||||
};
|
||||
const okHandler = () => done(true);
|
||||
const cancelHandler = () => done(false);
|
||||
confirmOk.onclick = okHandler;
|
||||
confirmCancel.onclick = cancelHandler;
|
||||
});
|
||||
}
|
||||
|
||||
// Testing hook
|
||||
if (typeof window !== "undefined") {
|
||||
window.__pikitTest = window.__pikitTest || {};
|
||||
window.__pikitTest.showBusy = showBusy;
|
||||
window.__pikitTest.hideBusy = hideBusy;
|
||||
}
|
||||
|
||||
function wireResetAndUpdates() {
|
||||
resetBtn.onclick = async () => {
|
||||
resetBtn.disabled = true;
|
||||
@@ -471,31 +301,6 @@ function wireResetAndUpdates() {
|
||||
});
|
||||
}
|
||||
|
||||
function wireAccordions() {
|
||||
const forceOpen = typeof window !== "undefined" && window.__pikitTest?.forceAccordionsOpen;
|
||||
const accordions = document.querySelectorAll(".accordion");
|
||||
if (forceOpen) {
|
||||
accordions.forEach((a) => a.classList.add("open"));
|
||||
return;
|
||||
}
|
||||
document.querySelectorAll(".accordion-toggle").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const acc = btn.closest(".accordion");
|
||||
if (acc.classList.contains("open")) {
|
||||
acc.classList.remove("open");
|
||||
} else {
|
||||
// Keep a single accordion expanded at a time for readability
|
||||
accordions.forEach((a) => a.classList.remove("open"));
|
||||
acc.classList.add("open");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function collapseAccordions() {
|
||||
document.querySelectorAll(".accordion").forEach((a) => a.classList.remove("open"));
|
||||
}
|
||||
|
||||
function openAddService() {
|
||||
if (addServiceModal) addServiceModal.classList.remove("hidden");
|
||||
document.getElementById("svcName")?.focus();
|
||||
@@ -505,10 +310,17 @@ if (typeof window !== "undefined") {
|
||||
}
|
||||
|
||||
function main() {
|
||||
applyTooltips();
|
||||
wireModals();
|
||||
applyTooltips(TOOLTIP_MAP);
|
||||
// Test convenience: ensure service form elements are visible when hook is set
|
||||
if (window.__pikitTest?.forceServiceFormVisible) {
|
||||
window.__pikitTest.forceServiceFormVisible();
|
||||
window.__pikitTest.exposeServiceForm?.();
|
||||
}
|
||||
wireDialogs();
|
||||
wireResetAndUpdates();
|
||||
wireAccordions();
|
||||
wireAccordions({
|
||||
forceOpen: typeof window !== "undefined" && window.__pikitTest?.forceAccordionsOpen,
|
||||
});
|
||||
releaseUI = initReleaseUI({
|
||||
showToast,
|
||||
showBusy,
|
||||
@@ -516,14 +328,6 @@ function main() {
|
||||
confirmAction,
|
||||
logUi,
|
||||
});
|
||||
loadToastSettings();
|
||||
|
||||
if (advClose) {
|
||||
advClose.onclick = () => {
|
||||
advModal.classList.add("hidden");
|
||||
collapseAccordions();
|
||||
};
|
||||
}
|
||||
|
||||
initServiceControls({
|
||||
gridEl: servicesGrid,
|
||||
@@ -598,98 +402,6 @@ function main() {
|
||||
console.error("Diag init failed", e);
|
||||
});
|
||||
|
||||
// Toast controls
|
||||
toastPosSelect?.addEventListener("change", () => {
|
||||
const val = toastPosSelect.value;
|
||||
if (ALLOWED_TOAST_POS.includes(val)) {
|
||||
toastPosition = val;
|
||||
applyToastSettings();
|
||||
persistToastSettings();
|
||||
} else {
|
||||
toastPosSelect.value = toastPosition;
|
||||
showToast("Invalid toast position", "error");
|
||||
}
|
||||
});
|
||||
toastAnimSelect?.addEventListener("change", () => {
|
||||
let val = toastAnimSelect.value;
|
||||
if (val === "slide-up" || val === "slide-left" || val === "slide-right") val = "slide-in"; // migrate old label if cached
|
||||
if (ALLOWED_TOAST_ANIM.includes(val)) {
|
||||
toastAnimation = val;
|
||||
persistToastSettings();
|
||||
} else {
|
||||
toastAnimSelect.value = toastAnimation;
|
||||
showToast("Invalid toast animation", "error");
|
||||
}
|
||||
});
|
||||
const clampSpeed = (val) => Math.min(3000, Math.max(100, val));
|
||||
const clampDuration = (val) => Math.min(15000, Math.max(1000, val));
|
||||
|
||||
toastSpeedInput?.addEventListener("input", () => {
|
||||
const raw = toastSpeedInput.value;
|
||||
if (raw === "") return; // allow typing
|
||||
const val = Number(raw);
|
||||
if (Number.isNaN(val) || val < 100 || val > 3000) return; // wait until valid
|
||||
toastSpeedMs = val;
|
||||
applyToastSettings();
|
||||
persistToastSettings();
|
||||
});
|
||||
toastSpeedInput?.addEventListener("blur", () => {
|
||||
const raw = toastSpeedInput.value;
|
||||
if (raw === "") {
|
||||
toastSpeedInput.value = toastSpeedMs;
|
||||
return;
|
||||
}
|
||||
const val = Number(raw);
|
||||
if (Number.isNaN(val) || val < 100 || val > 3000) {
|
||||
toastSpeedMs = clampSpeed(toastSpeedMs);
|
||||
toastSpeedInput.value = toastSpeedMs;
|
||||
showToast("Toast speed must be 100-3000 ms", "error");
|
||||
return;
|
||||
}
|
||||
toastSpeedMs = val;
|
||||
toastSpeedInput.value = toastSpeedMs;
|
||||
applyToastSettings();
|
||||
persistToastSettings();
|
||||
});
|
||||
|
||||
toastDurationInput?.addEventListener("input", () => {
|
||||
const raw = toastDurationInput.value;
|
||||
if (raw === "") return; // allow typing
|
||||
const val = Number(raw);
|
||||
if (Number.isNaN(val) || val < 1000 || val > 15000) return; // wait until valid
|
||||
toastDurationMs = val;
|
||||
persistToastSettings();
|
||||
});
|
||||
toastDurationInput?.addEventListener("blur", () => {
|
||||
const raw = toastDurationInput.value;
|
||||
if (raw === "") {
|
||||
toastDurationInput.value = toastDurationMs;
|
||||
return;
|
||||
}
|
||||
const val = Number(raw);
|
||||
if (Number.isNaN(val) || val < 1000 || val > 15000) {
|
||||
toastDurationMs = clampDuration(toastDurationMs);
|
||||
toastDurationInput.value = toastDurationMs;
|
||||
showToast("Toast duration must be 1000-15000 ms", "error");
|
||||
return;
|
||||
}
|
||||
toastDurationMs = val;
|
||||
toastDurationInput.value = toastDurationMs;
|
||||
persistToastSettings();
|
||||
});
|
||||
fontSelect?.addEventListener("change", () => {
|
||||
const val = fontSelect.value;
|
||||
if (!ALLOWED_FONTS.includes(val)) {
|
||||
fontSelect.value = fontChoice;
|
||||
showToast("Invalid font choice", "error");
|
||||
return;
|
||||
}
|
||||
fontChoice = val;
|
||||
applyFontSetting();
|
||||
persistToastSettings();
|
||||
});
|
||||
toastTestBtn?.addEventListener("click", () => showToast("This is a test toast", "info"));
|
||||
|
||||
initUpdateSettings({
|
||||
elements: {
|
||||
updatesStatus,
|
||||
|
||||
38
pikit-web/assets/releases-utils.js
Normal file
38
pikit-web/assets/releases-utils.js
Normal file
@@ -0,0 +1,38 @@
|
||||
export function shorten(text, max = 90) {
|
||||
if (!text || typeof text !== "string") return text;
|
||||
return text.length > max ? `${text.slice(0, max - 3)}...` : text;
|
||||
}
|
||||
|
||||
export function createReleaseLogger(logUi = () => {}) {
|
||||
let lines = [];
|
||||
let lastMessage = null;
|
||||
const state = { el: null };
|
||||
|
||||
function render() {
|
||||
if (state.el) {
|
||||
state.el.textContent = lines.join("\n");
|
||||
state.el.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function log(msg) {
|
||||
if (!msg) return;
|
||||
const plain = msg.trim();
|
||||
if (plain === lastMessage) return;
|
||||
lastMessage = plain;
|
||||
const ts = new Date().toLocaleTimeString();
|
||||
const line = `${ts} ${msg}`;
|
||||
lines.unshift(line);
|
||||
lines = lines.slice(0, 120);
|
||||
render();
|
||||
const lvl = msg.toLowerCase().startsWith("error") ? "error" : "info";
|
||||
logUi(`Update: ${msg}`, lvl);
|
||||
}
|
||||
|
||||
function attach(el) {
|
||||
state.el = el;
|
||||
render();
|
||||
}
|
||||
|
||||
return { log, attach, getLines: () => lines.slice() };
|
||||
}
|
||||
@@ -5,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 = message;
|
||||
} else if (latest_version) {
|
||||
lastChangelogUrl = changelog_url || null;
|
||||
if (!lastChangelogUrl && status === "update_available" && message && message.startsWith("http")) {
|
||||
lastChangelogUrl = message;
|
||||
} 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;
|
||||
|
||||
37
pikit-web/assets/services-helpers.js
Normal file
37
pikit-web/assets/services-helpers.js
Normal file
@@ -0,0 +1,37 @@
|
||||
export const DEFAULT_SELF_SIGNED_MSG =
|
||||
"This service uses a self-signed certificate. Expect a browser warning; proceed only if you trust this device.";
|
||||
|
||||
export function isValidLink(str) {
|
||||
if (!str) return true; // empty is allowed
|
||||
try {
|
||||
const u = new URL(str);
|
||||
return u.protocol === "http:" || u.protocol === "https:";
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePath(path) {
|
||||
if (!path) return "";
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed) return "";
|
||||
if (/\s/.test(trimmed)) return null; // no spaces or tabs in paths
|
||||
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return null;
|
||||
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
}
|
||||
|
||||
export function validateServiceFields({ name, port, path, notice, notice_link }, fail) {
|
||||
const err = (m) => {
|
||||
fail?.(m);
|
||||
return false;
|
||||
};
|
||||
if (!name || name.trim().length < 2) return err("Name must be at least 2 characters.");
|
||||
if (name.length > 48) return err("Name is too long (max 48 chars).");
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) return err("Port must be 1-65535.");
|
||||
if (path === null) return err("Path must be relative (e.g. /admin) or blank.");
|
||||
if (path.length > 200) return err("Path is too long (max 200 chars).");
|
||||
if (notice && notice.length > 180) return err("Notice text too long (max 180 chars).");
|
||||
if (notice_link && notice_link.length > 200) return err("Notice link too long (max 200 chars).");
|
||||
if (!isValidLink(notice_link)) return err("Enter a valid URL (http/https) or leave blank.");
|
||||
return true;
|
||||
}
|
||||
@@ -1,48 +1,16 @@
|
||||
import { addService, updateService, removeService } from "./api.js";
|
||||
import { logUi } from "./diaglog.js";
|
||||
import {
|
||||
DEFAULT_SELF_SIGNED_MSG,
|
||||
isValidLink,
|
||||
normalizePath,
|
||||
validateServiceFields,
|
||||
} from "./services-helpers.js";
|
||||
|
||||
// Renders service cards and wires UI controls for add/edit/remove operations.
|
||||
// All mutations round-trip through the API then invoke onChange to refresh data.
|
||||
|
||||
let noticeModalRefs = null;
|
||||
const DEFAULT_SELF_SIGNED_MSG =
|
||||
"This service uses a self-signed certificate. Expect a browser warning; proceed only if you trust this device.";
|
||||
|
||||
function isValidLink(str) {
|
||||
if (!str) return true; // empty is allowed
|
||||
try {
|
||||
const u = new URL(str);
|
||||
return u.protocol === "http:" || u.protocol === "https:";
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePath(path) {
|
||||
if (!path) return "";
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed) return "";
|
||||
if (/\s/.test(trimmed)) return null; // no spaces or tabs in paths
|
||||
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return null;
|
||||
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
}
|
||||
|
||||
function validateServiceFields({ name, port, path, notice, notice_link }, setMsg, toast) {
|
||||
const fail = (m) => {
|
||||
setMsg("");
|
||||
toast?.(m, "error");
|
||||
return false;
|
||||
};
|
||||
if (!name || name.trim().length < 2) return fail("Name must be at least 2 characters.");
|
||||
if (name.length > 48) return fail("Name is too long (max 48 chars).");
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) return fail("Port must be 1-65535.");
|
||||
if (path === null) return fail("Path must be relative (e.g. /admin) or blank.");
|
||||
if (path.length > 200) return fail("Path is too long (max 200 chars).");
|
||||
if (notice && notice.length > 180) return fail("Notice text too long (max 180 chars).");
|
||||
if (notice_link && notice_link.length > 200) return fail("Notice link too long (max 200 chars).");
|
||||
if (!isValidLink(notice_link)) return fail("Enter a valid URL (http/https) or leave blank.");
|
||||
return true;
|
||||
}
|
||||
|
||||
function ensureNoticeModal() {
|
||||
if (noticeModalRefs) return noticeModalRefs;
|
||||
const modal = document.createElement("div");
|
||||
@@ -264,6 +232,7 @@ export function initServiceControls({ gridEl, menu, addForm, onChange, overlay,
|
||||
async function menuAction(action, body = {}) {
|
||||
if (!menuContext) return;
|
||||
msg.textContent = "";
|
||||
const original = { ...menuContext };
|
||||
try {
|
||||
const isRemove = action === "remove";
|
||||
const isSave = action === "save";
|
||||
@@ -285,6 +254,17 @@ export function initServiceControls({ gridEl, menu, addForm, onChange, overlay,
|
||||
}
|
||||
msg.textContent = "";
|
||||
toast?.(isRemove ? "Service removed" : "Service saved", "success");
|
||||
logUi(isRemove ? "Service removed" : "Service updated", "info", {
|
||||
name: body.name || original.name,
|
||||
port_from: original.port,
|
||||
port_to: body.new_port || original.port,
|
||||
scheme_from: original.scheme,
|
||||
scheme_to: body.scheme || original.scheme,
|
||||
path_from: original.path,
|
||||
path_to: body.path ?? original.path,
|
||||
notice_changed: body.notice !== undefined,
|
||||
self_signed: body.self_signed,
|
||||
});
|
||||
modal?.classList.add("hidden");
|
||||
menuContext = null;
|
||||
await onChange?.();
|
||||
@@ -292,6 +272,12 @@ export function initServiceControls({ gridEl, menu, addForm, onChange, overlay,
|
||||
const err = e.error || "Action failed.";
|
||||
msg.textContent = "";
|
||||
toast?.(err, "error");
|
||||
logUi("Service update failed", "error", {
|
||||
action,
|
||||
name: body.name || original.name,
|
||||
port: original.port,
|
||||
reason: err,
|
||||
});
|
||||
} finally {
|
||||
hideBusy();
|
||||
}
|
||||
@@ -310,8 +296,7 @@ export function initServiceControls({ gridEl, menu, addForm, onChange, overlay,
|
||||
if (
|
||||
!validateServiceFields(
|
||||
{ name, port: new_port, path, notice, notice_link },
|
||||
() => {},
|
||||
toast,
|
||||
(m) => toast?.(m, "error"),
|
||||
)
|
||||
)
|
||||
return;
|
||||
@@ -335,11 +320,7 @@ export function initServiceControls({ gridEl, menu, addForm, onChange, overlay,
|
||||
const notice_link = (addNoticeLinkInput?.value || "").trim();
|
||||
const self_signed = !!addSelfSignedInput?.checked;
|
||||
if (
|
||||
!validateServiceFields(
|
||||
{ name, port, path, notice, notice_link },
|
||||
() => {},
|
||||
toast,
|
||||
)
|
||||
!validateServiceFields({ name, port, path, notice, notice_link }, (m) => toast?.(m, "error"))
|
||||
)
|
||||
return;
|
||||
addBtn.disabled = true;
|
||||
@@ -348,11 +329,21 @@ export function initServiceControls({ gridEl, menu, addForm, onChange, overlay,
|
||||
await addService({ name, port, scheme, path, notice, notice_link, self_signed });
|
||||
addMsg.textContent = "";
|
||||
toast?.("Service added", "success");
|
||||
logUi("Service added", "info", {
|
||||
name,
|
||||
port,
|
||||
scheme,
|
||||
path,
|
||||
notice: !!notice,
|
||||
notice_link: !!notice_link,
|
||||
self_signed,
|
||||
});
|
||||
await onChange?.();
|
||||
} catch (e) {
|
||||
const err = e.error || "Failed to add.";
|
||||
addMsg.textContent = "";
|
||||
toast?.(err, "error");
|
||||
logUi("Service add failed", "error", { name, port, scheme, reason: err });
|
||||
} finally {
|
||||
addBtn.disabled = false;
|
||||
hideBusy();
|
||||
|
||||
100
pikit-web/assets/status-controller.js
Normal file
100
pikit-web/assets/status-controller.js
Normal 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
258
pikit-web/assets/toast.js
Normal file
@@ -0,0 +1,258 @@
|
||||
const TOAST_POS_KEY = "pikit-toast-pos";
|
||||
const TOAST_ANIM_KEY = "pikit-toast-anim";
|
||||
const TOAST_SPEED_KEY = "pikit-toast-speed";
|
||||
const TOAST_DURATION_KEY = "pikit-toast-duration";
|
||||
const FONT_KEY = "pikit-font";
|
||||
|
||||
export const ALLOWED_TOAST_POS = [
|
||||
"bottom-center",
|
||||
"bottom-right",
|
||||
"bottom-left",
|
||||
"top-right",
|
||||
"top-left",
|
||||
"top-center",
|
||||
];
|
||||
export const ALLOWED_TOAST_ANIM = ["slide-in", "fade", "pop", "bounce", "drop", "grow"];
|
||||
export const ALLOWED_FONTS = ["redhat", "space", "manrope", "dmsans", "sora", "chivo", "atkinson", "plex"];
|
||||
|
||||
const clamp = (val, min, max) => Math.min(max, Math.max(min, val));
|
||||
|
||||
export function createToastManager({
|
||||
container,
|
||||
posSelect,
|
||||
animSelect,
|
||||
speedInput,
|
||||
durationInput,
|
||||
fontSelect,
|
||||
testBtn,
|
||||
} = {}) {
|
||||
const state = {
|
||||
position: "bottom-center",
|
||||
animation: "slide-in",
|
||||
durationMs: 5000,
|
||||
speedMs: 300,
|
||||
font: "redhat",
|
||||
};
|
||||
|
||||
function applyToastSettings() {
|
||||
if (!container) return;
|
||||
container.className = `toast-container pos-${state.position}`;
|
||||
document.documentElement.style.setProperty("--toast-speed", `${state.speedMs}ms`);
|
||||
const dir = state.position.startsWith("top") ? -1 : 1;
|
||||
const isLeft = state.position.includes("left");
|
||||
const isRight = state.position.includes("right");
|
||||
const slideX = isLeft ? -26 : isRight ? 26 : 0;
|
||||
const slideY = isLeft || isRight ? 0 : dir * 24;
|
||||
document.documentElement.style.setProperty("--toast-slide-offset", `${dir * 24}px`);
|
||||
document.documentElement.style.setProperty("--toast-dir", `${dir}`);
|
||||
document.documentElement.style.setProperty("--toast-slide-x", `${slideX}px`);
|
||||
document.documentElement.style.setProperty("--toast-slide-y", `${slideY}px`);
|
||||
if (durationInput) durationInput.value = state.durationMs;
|
||||
}
|
||||
|
||||
function applyFontSetting() {
|
||||
document.documentElement.setAttribute("data-font", state.font);
|
||||
if (fontSelect) fontSelect.value = state.font;
|
||||
}
|
||||
|
||||
function persistSettings() {
|
||||
try {
|
||||
localStorage.setItem(TOAST_POS_KEY, state.position);
|
||||
localStorage.setItem(TOAST_ANIM_KEY, state.animation);
|
||||
localStorage.setItem(TOAST_SPEED_KEY, String(state.speedMs));
|
||||
localStorage.setItem(TOAST_DURATION_KEY, String(state.durationMs));
|
||||
localStorage.setItem(FONT_KEY, state.font);
|
||||
} catch (e) {
|
||||
console.warn("Toast settings save failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
function loadFromStorage() {
|
||||
try {
|
||||
const posSaved = localStorage.getItem(TOAST_POS_KEY);
|
||||
if (ALLOWED_TOAST_POS.includes(posSaved)) state.position = posSaved;
|
||||
const animSaved = localStorage.getItem(TOAST_ANIM_KEY);
|
||||
const migrated =
|
||||
animSaved === "slide-up" || animSaved === "slide-left" || animSaved === "slide-right"
|
||||
? "slide-in"
|
||||
: animSaved;
|
||||
if (migrated && ALLOWED_TOAST_ANIM.includes(migrated)) state.animation = migrated;
|
||||
const savedSpeed = Number(localStorage.getItem(TOAST_SPEED_KEY));
|
||||
if (!Number.isNaN(savedSpeed) && savedSpeed >= 100 && savedSpeed <= 3000) {
|
||||
state.speedMs = savedSpeed;
|
||||
}
|
||||
const savedDur = Number(localStorage.getItem(TOAST_DURATION_KEY));
|
||||
if (!Number.isNaN(savedDur) && savedDur >= 1000 && savedDur <= 15000) {
|
||||
state.durationMs = savedDur;
|
||||
}
|
||||
const savedFont = localStorage.getItem(FONT_KEY);
|
||||
if (ALLOWED_FONTS.includes(savedFont)) state.font = savedFont;
|
||||
} catch (e) {
|
||||
console.warn("Toast settings load failed", e);
|
||||
}
|
||||
if (posSelect) posSelect.value = state.position;
|
||||
if (animSelect) animSelect.value = state.animation;
|
||||
if (speedInput) speedInput.value = state.speedMs;
|
||||
if (durationInput) durationInput.value = state.durationMs;
|
||||
if (fontSelect) fontSelect.value = state.font;
|
||||
applyToastSettings();
|
||||
applyFontSetting();
|
||||
}
|
||||
|
||||
function showToast(message, type = "info") {
|
||||
if (!container || !message) return;
|
||||
const t = document.createElement("div");
|
||||
t.className = `toast ${type} anim-${state.animation}`;
|
||||
t.textContent = message;
|
||||
container.appendChild(t);
|
||||
const animOn = document.documentElement.getAttribute("data-anim") !== "off";
|
||||
if (!animOn) {
|
||||
t.classList.add("show");
|
||||
} else {
|
||||
requestAnimationFrame(() => t.classList.add("show"));
|
||||
}
|
||||
const duration = state.durationMs;
|
||||
setTimeout(() => {
|
||||
const all = Array.from(container.querySelectorAll(".toast"));
|
||||
const others = all.filter((el) => el !== t && !el.classList.contains("leaving"));
|
||||
const first = new Map(
|
||||
others.map((el) => [el, el.getBoundingClientRect()]),
|
||||
);
|
||||
|
||||
t.classList.add("leaving");
|
||||
void t.offsetHeight;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const second = new Map(
|
||||
others.map((el) => [el, el.getBoundingClientRect()]),
|
||||
);
|
||||
others.forEach((el) => {
|
||||
const dy = first.get(el).top - second.get(el).top;
|
||||
if (Math.abs(dy) > 0.5) {
|
||||
el.style.transition = "transform var(--toast-speed, 0.28s) ease";
|
||||
el.style.transform = `translateY(${dy}px)`;
|
||||
requestAnimationFrame(() => {
|
||||
el.style.transform = "";
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const removeDelay = animOn ? state.speedMs : 0;
|
||||
setTimeout(() => {
|
||||
t.classList.remove("show");
|
||||
t.remove();
|
||||
others.forEach((el) => (el.style.transition = ""));
|
||||
}, removeDelay);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
function wireControls() {
|
||||
posSelect?.addEventListener("change", () => {
|
||||
const val = posSelect.value;
|
||||
if (ALLOWED_TOAST_POS.includes(val)) {
|
||||
state.position = val;
|
||||
applyToastSettings();
|
||||
persistSettings();
|
||||
} else {
|
||||
posSelect.value = state.position;
|
||||
showToast("Invalid toast position", "error");
|
||||
}
|
||||
});
|
||||
|
||||
animSelect?.addEventListener("change", () => {
|
||||
let val = animSelect.value;
|
||||
if (val === "slide-up" || val === "slide-left" || val === "slide-right") val = "slide-in";
|
||||
if (ALLOWED_TOAST_ANIM.includes(val)) {
|
||||
state.animation = val;
|
||||
persistSettings();
|
||||
} else {
|
||||
animSelect.value = state.animation;
|
||||
showToast("Invalid toast animation", "error");
|
||||
}
|
||||
});
|
||||
|
||||
const clampSpeed = (val) => clamp(val, 100, 3000);
|
||||
const clampDuration = (val) => clamp(val, 1000, 15000);
|
||||
|
||||
speedInput?.addEventListener("input", () => {
|
||||
const raw = speedInput.value;
|
||||
if (raw === "") return;
|
||||
const val = Number(raw);
|
||||
if (Number.isNaN(val) || val < 100 || val > 3000) return;
|
||||
state.speedMs = val;
|
||||
applyToastSettings();
|
||||
persistSettings();
|
||||
});
|
||||
speedInput?.addEventListener("blur", () => {
|
||||
const raw = speedInput.value;
|
||||
if (raw === "") {
|
||||
speedInput.value = state.speedMs;
|
||||
return;
|
||||
}
|
||||
const val = Number(raw);
|
||||
if (Number.isNaN(val) || val < 100 || val > 3000) {
|
||||
state.speedMs = clampSpeed(state.speedMs);
|
||||
speedInput.value = state.speedMs;
|
||||
showToast("Toast speed must be 100-3000 ms", "error");
|
||||
return;
|
||||
}
|
||||
state.speedMs = val;
|
||||
speedInput.value = state.speedMs;
|
||||
applyToastSettings();
|
||||
persistSettings();
|
||||
});
|
||||
|
||||
durationInput?.addEventListener("input", () => {
|
||||
const raw = durationInput.value;
|
||||
if (raw === "") return;
|
||||
const val = Number(raw);
|
||||
if (Number.isNaN(val) || val < 1000 || val > 15000) return;
|
||||
state.durationMs = val;
|
||||
persistSettings();
|
||||
});
|
||||
durationInput?.addEventListener("blur", () => {
|
||||
const raw = durationInput.value;
|
||||
if (raw === "") {
|
||||
durationInput.value = state.durationMs;
|
||||
return;
|
||||
}
|
||||
const val = Number(raw);
|
||||
if (Number.isNaN(val) || val < 1000 || val > 15000) {
|
||||
state.durationMs = clampDuration(state.durationMs);
|
||||
durationInput.value = state.durationMs;
|
||||
showToast("Toast duration must be 1000-15000 ms", "error");
|
||||
return;
|
||||
}
|
||||
state.durationMs = val;
|
||||
durationInput.value = state.durationMs;
|
||||
persistSettings();
|
||||
});
|
||||
|
||||
fontSelect?.addEventListener("change", () => {
|
||||
const val = fontSelect.value;
|
||||
if (!ALLOWED_FONTS.includes(val)) {
|
||||
fontSelect.value = state.font;
|
||||
showToast("Invalid font choice", "error");
|
||||
return;
|
||||
}
|
||||
state.font = val;
|
||||
applyFontSetting();
|
||||
persistSettings();
|
||||
});
|
||||
|
||||
testBtn?.addEventListener("click", () => showToast("This is a test toast", "info"));
|
||||
}
|
||||
|
||||
loadFromStorage();
|
||||
wireControls();
|
||||
|
||||
return {
|
||||
state,
|
||||
showToast,
|
||||
applyToastSettings,
|
||||
applyFontSetting,
|
||||
persistSettings,
|
||||
loadFromStorage,
|
||||
};
|
||||
}
|
||||
80
pikit-web/assets/ui.js
Normal file
80
pikit-web/assets/ui.js
Normal file
@@ -0,0 +1,80 @@
|
||||
// Small UI helpers to keep main.js lean and declarative.
|
||||
|
||||
export function applyTooltips(map = {}) {
|
||||
Object.entries(map).forEach(([id, text]) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el && text) el.title = text;
|
||||
});
|
||||
}
|
||||
|
||||
export function wireModalPairs(pairs = []) {
|
||||
pairs.forEach(({ openBtn, modal, closeBtn }) => {
|
||||
if (!modal) return;
|
||||
openBtn?.addEventListener("click", () => modal.classList.remove("hidden"));
|
||||
closeBtn?.addEventListener("click", () => modal.classList.add("hidden"));
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === modal) modal.classList.add("hidden");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function wireAccordions({
|
||||
toggleSelector = ".accordion-toggle",
|
||||
accordionSelector = ".accordion",
|
||||
forceOpen = false,
|
||||
} = {}) {
|
||||
const accordions = Array.from(document.querySelectorAll(accordionSelector));
|
||||
const toggles = Array.from(document.querySelectorAll(toggleSelector));
|
||||
if (forceOpen) {
|
||||
accordions.forEach((a) => a.classList.add("open"));
|
||||
return;
|
||||
}
|
||||
toggles.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const acc = btn.closest(accordionSelector);
|
||||
if (!acc) return;
|
||||
if (acc.classList.contains("open")) {
|
||||
acc.classList.remove("open");
|
||||
} else {
|
||||
accordions.forEach((a) => a.classList.remove("open"));
|
||||
acc.classList.add("open");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function createBusyOverlay({ overlay, titleEl, textEl }) {
|
||||
const showBusy = (title = "Working…", text = "This may take a few seconds.") => {
|
||||
if (!overlay) return;
|
||||
if (titleEl) titleEl.textContent = title;
|
||||
if (textEl) {
|
||||
textEl.textContent = text || "";
|
||||
textEl.classList.toggle("hidden", !text);
|
||||
}
|
||||
overlay.classList.remove("hidden");
|
||||
};
|
||||
const hideBusy = () => overlay?.classList.add("hidden");
|
||||
return { showBusy, hideBusy };
|
||||
}
|
||||
|
||||
export function createConfirmModal({ modal, titleEl, bodyEl, okBtn, cancelBtn }) {
|
||||
const confirmAction = (title, body) =>
|
||||
new Promise((resolve) => {
|
||||
if (!modal) {
|
||||
resolve(window.confirm(body || title || "Are you sure?"));
|
||||
return;
|
||||
}
|
||||
if (titleEl) titleEl.textContent = title || "Are you sure?";
|
||||
if (bodyEl) bodyEl.textContent = body || "";
|
||||
modal.classList.remove("hidden");
|
||||
const done = (val) => {
|
||||
modal.classList.add("hidden");
|
||||
resolve(val);
|
||||
};
|
||||
const okHandler = () => done(true);
|
||||
const cancelHandler = () => done(false);
|
||||
okBtn?.addEventListener("click", okHandler, { once: true });
|
||||
cancelBtn?.addEventListener("click", cancelHandler, { once: true });
|
||||
});
|
||||
return confirmAction;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// UI controller for unattended-upgrades settings.
|
||||
// Fetches current config, mirrors it into the form, and saves changes.
|
||||
import { getUpdateConfig, saveUpdateConfig } from "./api.js";
|
||||
import { logUi } from "./diaglog.js";
|
||||
|
||||
const TIME_RE = /^(\d{1,2}):(\d{2})$/;
|
||||
const MAX_BANDWIDTH_KBPS = 1_000_000; // 1 GB/s cap to prevent typos
|
||||
@@ -115,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;
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"last_check": "2025-12-10T22:00:00Z",
|
||||
"status": "update_available",
|
||||
"message": "New UI polish and bug fixes.",
|
||||
"changelog_url": "https://git.44r0n.cc/44r0n7/pi-kit/releases/download/v0.1.1-mock/CHANGELOG-0.1.1-mock.txt",
|
||||
"latest_release_date": "2025-12-09T18:00:00Z",
|
||||
"current_release_date": "2025-12-01T17:00:00Z",
|
||||
"auto_check": true,
|
||||
"in_progress": false,
|
||||
"progress": null
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": "0.1.0-dev"
|
||||
"version": "0.1.3-dev3"
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
153
pikit-web/onboarding/index.html
Normal file
153
pikit-web/onboarding/index.html
Normal 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 — you’re already on your Pi-Kit and it’s responding.</p>
|
||||
<div class="status-row" aria-live="polite">
|
||||
<span class="status-chip" id="statusChip">You’re on HTTP — trust the CA or continue to HTTPS.</span>
|
||||
</div>
|
||||
<p class="subtle">
|
||||
Everything stays on your local network. Let’s move you to the secure (HTTPS) dashboard so you
|
||||
can manage Pi-Kit safely.
|
||||
</p>
|
||||
|
||||
<section class="steps">
|
||||
<h3>Why switch to HTTPS?</h3>
|
||||
<ul>
|
||||
<li>Encrypts traffic on your LAN so no one can snoop your Pi-Kit dashboard.</li>
|
||||
<li>Stops mixed-content / “not secure” browser warnings.</li>
|
||||
<li>Needed for some browser features (clipboard, notifications, service workers).</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="steps">
|
||||
<h3>If you see a warning</h3>
|
||||
<ul>
|
||||
<li>Brave/Chrome: click <strong>Advanced</strong> → <strong>Proceed</strong>.</li>
|
||||
<li>Firefox: click <strong>Advanced</strong> → <strong>Accept the Risk & Continue</strong>.</li>
|
||||
</ul>
|
||||
<p>This warning is expected the first time. It’s safe for your own Pi on your own network.</p>
|
||||
</section>
|
||||
|
||||
<section class="steps">
|
||||
<h3>Install the Pi-Kit CA (recommended, one-time)</h3>
|
||||
<div class="ca-download">
|
||||
<p>This removes future warnings for the Pi-Kit dashboard.</p>
|
||||
<a class="ghost download-inline" id="downloadCa" href="http://pikit.local/assets/pikit-ca.crt" download>
|
||||
Download Pi-Kit CA
|
||||
</a>
|
||||
</div>
|
||||
<p class="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://<pi-kit ip>/ (or https://<pi-kit ip>/ — 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 = "You’re on HTTP — trust the CA or click Go to secure dashboard.";
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
276
pikit-web/onboarding/style.css
Normal file
276
pikit-web/onboarding/style.css
Normal file
@@ -0,0 +1,276 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #070b15;
|
||||
--panel: rgba(13, 18, 28, 0.9);
|
||||
--panel-2: rgba(18, 26, 40, 0.7);
|
||||
--text: #e9f0ff;
|
||||
--muted: #9bb0ca;
|
||||
--accent: #44d392;
|
||||
--accent-2: #6cc9ff;
|
||||
--border: #1b2538;
|
||||
--shadow: 0 20px 60px rgba(0, 0, 0, 0.45);
|
||||
--glow: 0 20px 70px rgba(68, 211, 146, 0.14);
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 32px 22px;
|
||||
background: radial-gradient(140% 140% at 12% 18%, #0f1625, #080c14 58%);
|
||||
color: var(--text);
|
||||
font-family: "DM Sans", "Inter", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.card {
|
||||
max-width: 920px;
|
||||
width: 100%;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
padding: 28px 30px 32px;
|
||||
box-shadow: var(--shadow), var(--glow);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.7rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 10px 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.welcome {
|
||||
font-size: 1.05rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.status-row {
|
||||
margin: 6px 0 10px;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 9px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(68, 211, 146, 0.08);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 14px var(--accent);
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin: 10px 0 4px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.badge .dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 18px auto 14px;
|
||||
width: min(360px, 100%);
|
||||
}
|
||||
|
||||
button,
|
||||
a.ghost {
|
||||
border: 1px solid var(--border);
|
||||
background: linear-gradient(135deg, var(--accent), #2dbb7b);
|
||||
color: #041008;
|
||||
padding: 12px 18px;
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 10px 30px rgba(68, 211, 146, 0.22);
|
||||
transition: transform 0.1s ease, box-shadow 0.2s ease, filter 0.2s ease;
|
||||
}
|
||||
|
||||
button:hover,
|
||||
a.ghost:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 12px 36px rgba(68, 211, 146, 0.3);
|
||||
filter: brightness(1.02);
|
||||
}
|
||||
|
||||
button.ghost,
|
||||
a.ghost {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text);
|
||||
box-shadow: none;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ca-download {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.download-inline {
|
||||
width: auto;
|
||||
padding: 8px 12px;
|
||||
font-size: 0.95rem;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.checksum {
|
||||
margin: 4px 0 10px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
code.inline {
|
||||
display: inline;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.qr-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-top: 8px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
width: min(360px, 100%);
|
||||
}
|
||||
|
||||
.qr-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
|
||||
.qr-title {
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tiny {
|
||||
margin: 2px 0 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.copy-status {
|
||||
min-height: 18px;
|
||||
color: var(--text);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
button.copy {
|
||||
margin-left: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
padding: 7px 11px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.steps {
|
||||
padding: 14px 15px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.steps h3 {
|
||||
margin: 0 0 8px;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 6px 0 6px 18px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
code {
|
||||
display: block;
|
||||
background: #0b111c;
|
||||
border: 1px solid var(--border);
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
margin-top: 6px;
|
||||
color: var(--text);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.footnote {
|
||||
font-size: 0.93rem;
|
||||
color: var(--muted);
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.subtle {
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
0
pikit-web/public/favicon.ico
Normal file
0
pikit-web/public/favicon.ico
Normal file
11
pikit_api/__init__.py
Normal file
11
pikit_api/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Pi-Kit API package
|
||||
|
||||
This package splits the monolithic `pikit-api.py` script into small, testable
|
||||
modules while keeping the on-device entry point compatible.
|
||||
"""
|
||||
|
||||
# Re-export commonly used helpers for convenience
|
||||
from .constants import HOST, PORT # noqa: F401
|
||||
from .server import run_server # noqa: F401
|
||||
from .releases import apply_update, check_for_update # noqa: F401
|
||||
239
pikit_api/auto_updates.py
Normal file
239
pikit_api/auto_updates.py
Normal file
@@ -0,0 +1,239 @@
|
||||
import pathlib
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .constants import (
|
||||
ALL_PATTERNS,
|
||||
APT_AUTO_CFG,
|
||||
APT_UA_BASE,
|
||||
APT_UA_OVERRIDE,
|
||||
DEFAULT_UPDATE_TIME,
|
||||
DEFAULT_UPGRADE_TIME,
|
||||
SECURITY_PATTERNS,
|
||||
)
|
||||
from .helpers import strip_comments, validate_time
|
||||
|
||||
|
||||
def auto_updates_enabled() -> bool:
|
||||
if not APT_AUTO_CFG.exists():
|
||||
return False
|
||||
text = APT_AUTO_CFG.read_text()
|
||||
return 'APT::Periodic::Unattended-Upgrade "1";' in text
|
||||
|
||||
|
||||
def set_auto_updates(enable: bool) -> None:
|
||||
"""
|
||||
Toggle unattended upgrades in a way that matches systemd state, not just the
|
||||
apt config file. Assumes unattended-upgrades is already installed.
|
||||
"""
|
||||
units_maskable = [
|
||||
"apt-daily.service",
|
||||
"apt-daily-upgrade.service",
|
||||
"apt-daily.timer",
|
||||
"apt-daily-upgrade.timer",
|
||||
"unattended-upgrades.service",
|
||||
]
|
||||
timers = ["apt-daily.timer", "apt-daily-upgrade.timer"]
|
||||
service = "unattended-upgrades.service"
|
||||
|
||||
APT_AUTO_CFG.parent.mkdir(parents=True, exist_ok=True)
|
||||
if enable:
|
||||
APT_AUTO_CFG.write_text(
|
||||
'APT::Periodic::Update-Package-Lists "1";\n'
|
||||
'APT::Periodic::Unattended-Upgrade "1";\n'
|
||||
)
|
||||
for unit in units_maskable:
|
||||
subprocess.run(["systemctl", "unmask", unit], check=False)
|
||||
for unit in timers + [service]:
|
||||
subprocess.run(["systemctl", "enable", unit], check=False)
|
||||
for unit in timers:
|
||||
subprocess.run(["systemctl", "start", unit], check=False)
|
||||
subprocess.run(["systemctl", "start", service], check=False)
|
||||
else:
|
||||
APT_AUTO_CFG.write_text(
|
||||
'APT::Periodic::Update-Package-Lists "0";\n'
|
||||
'APT::Periodic::Unattended-Upgrade "0";\n'
|
||||
)
|
||||
for unit in timers + [service]:
|
||||
subprocess.run(["systemctl", "stop", unit], check=False)
|
||||
subprocess.run(["systemctl", "disable", unit], check=False)
|
||||
for unit in units_maskable:
|
||||
subprocess.run(["systemctl", "mask", unit], check=False)
|
||||
|
||||
|
||||
def _systemctl_is(unit: str, verb: str) -> bool:
|
||||
try:
|
||||
out = subprocess.check_output(["systemctl", verb, unit], text=True).strip()
|
||||
return out == "enabled" if verb == "is-enabled" else out == "active"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def auto_updates_state() -> Dict[str, Any]:
|
||||
config_on = auto_updates_enabled()
|
||||
service = "unattended-upgrades.service"
|
||||
timers = ["apt-daily.timer", "apt-daily-upgrade.timer"]
|
||||
state: Dict[str, Any] = {
|
||||
"config_enabled": config_on,
|
||||
"service_enabled": _systemctl_is(service, "is-enabled"),
|
||||
"service_active": _systemctl_is(service, "is-active"),
|
||||
"timers_enabled": {},
|
||||
"timers_active": {},
|
||||
}
|
||||
for t in timers:
|
||||
state["timers_enabled"][t] = _systemctl_is(t, "is-enabled")
|
||||
state["timers_active"][t] = _systemctl_is(t, "is-active")
|
||||
state["enabled"] = (
|
||||
config_on
|
||||
and state["service_enabled"]
|
||||
and all(state["timers_enabled"].values())
|
||||
)
|
||||
return state
|
||||
|
||||
|
||||
def _parse_directive(text: str, key: str, default=None, as_bool=False, as_int=False):
|
||||
text = strip_comments(text)
|
||||
pattern = rf'{re.escape(key)}\s+"?([^";\n]+)"?;'
|
||||
m = re.search(pattern, text)
|
||||
if not m:
|
||||
return default
|
||||
val = m.group(1).strip()
|
||||
if as_bool:
|
||||
return val.lower() in ("1", "true", "yes", "on")
|
||||
if as_int:
|
||||
try:
|
||||
return int(val)
|
||||
except ValueError:
|
||||
return default
|
||||
return val
|
||||
|
||||
|
||||
def _parse_origins_patterns(text: str):
|
||||
text = strip_comments(text)
|
||||
m = re.search(r"Unattended-Upgrade::Origins-Pattern\s*{([^}]*)}", text, re.S)
|
||||
patterns = []
|
||||
if not m:
|
||||
return patterns
|
||||
body = m.group(1)
|
||||
for line in body.splitlines():
|
||||
ln = line.strip().strip('";')
|
||||
if ln:
|
||||
patterns.append(ln)
|
||||
return patterns
|
||||
|
||||
|
||||
def _read_timer_time(timer: str):
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["systemctl", "show", "--property=TimersCalendar", timer], text=True
|
||||
)
|
||||
m = re.search(r"OnCalendar=[^0-9]*([0-9]{1,2}):([0-9]{2})", out)
|
||||
if m:
|
||||
return f"{int(m.group(1)):02d}:{m.group(2)}"
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def read_updates_config(state=None) -> Dict[str, Any]:
|
||||
"""
|
||||
Return a normalized unattended-upgrades configuration snapshot.
|
||||
Values are sourced from the Pi-Kit override file when present, else the base file.
|
||||
"""
|
||||
text = ""
|
||||
for path in (APT_UA_OVERRIDE, APT_UA_BASE):
|
||||
if path.exists():
|
||||
try:
|
||||
text += path.read_text() + "\n"
|
||||
except Exception:
|
||||
pass
|
||||
scope_hint = None
|
||||
m_scope = re.search(r"PIKIT_SCOPE:\s*(\w+)", text)
|
||||
if m_scope:
|
||||
scope_hint = m_scope.group(1).lower()
|
||||
cleaned = strip_comments(text)
|
||||
patterns = _parse_origins_patterns(cleaned)
|
||||
scope = (
|
||||
scope_hint
|
||||
or ("all" if any("label=Debian" in p and "-security" not in p for p in patterns) else "security")
|
||||
)
|
||||
cleanup = _parse_directive(text, "Unattended-Upgrade::Remove-Unused-Dependencies", False, as_bool=True)
|
||||
auto_reboot = _parse_directive(text, "Unattended-Upgrade::Automatic-Reboot", False, as_bool=True)
|
||||
reboot_time = validate_time(_parse_directive(text, "Unattended-Upgrade::Automatic-Reboot-Time", DEFAULT_UPGRADE_TIME), DEFAULT_UPGRADE_TIME)
|
||||
reboot_with_users = _parse_directive(text, "Unattended-Upgrade::Automatic-Reboot-WithUsers", False, as_bool=True)
|
||||
bandwidth = _parse_directive(text, "Acquire::http::Dl-Limit", None, as_int=True)
|
||||
|
||||
update_time = _read_timer_time("apt-daily.timer") or DEFAULT_UPDATE_TIME
|
||||
upgrade_time = _read_timer_time("apt-daily-upgrade.timer") or DEFAULT_UPGRADE_TIME
|
||||
|
||||
state = state or auto_updates_state()
|
||||
return {
|
||||
"enabled": bool(state.get("enabled", False)),
|
||||
"scope": scope,
|
||||
"cleanup": bool(cleanup),
|
||||
"bandwidth_limit_kbps": bandwidth,
|
||||
"auto_reboot": bool(auto_reboot),
|
||||
"reboot_time": reboot_time,
|
||||
"reboot_with_users": bool(reboot_with_users),
|
||||
"update_time": update_time,
|
||||
"upgrade_time": upgrade_time,
|
||||
"state": state,
|
||||
}
|
||||
|
||||
|
||||
def _write_timer_override(timer: str, time_str: str):
|
||||
time_norm = validate_time(time_str, DEFAULT_UPDATE_TIME)
|
||||
override_dir = pathlib.Path(f"/etc/systemd/system/{timer}.d")
|
||||
override_dir.mkdir(parents=True, exist_ok=True)
|
||||
override_file = override_dir / "pikit.conf"
|
||||
override_file.write_text(
|
||||
"[Timer]\n"
|
||||
f"OnCalendar=*-*-* {time_norm}\n"
|
||||
"Persistent=true\n"
|
||||
"RandomizedDelaySec=30min\n"
|
||||
)
|
||||
subprocess.run(["systemctl", "daemon-reload"], check=False)
|
||||
subprocess.run(["systemctl", "restart", timer], check=False)
|
||||
|
||||
|
||||
def set_updates_config(opts: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Apply unattended-upgrades configuration from dashboard inputs.
|
||||
"""
|
||||
enable = bool(opts.get("enable", True))
|
||||
scope = opts.get("scope") or "all"
|
||||
patterns = ALL_PATTERNS if scope == "all" else SECURITY_PATTERNS
|
||||
cleanup = bool(opts.get("cleanup", False))
|
||||
bandwidth = opts.get("bandwidth_limit_kbps")
|
||||
auto_reboot = bool(opts.get("auto_reboot", False))
|
||||
reboot_time = validate_time(opts.get("reboot_time"), DEFAULT_UPGRADE_TIME)
|
||||
reboot_with_users = bool(opts.get("reboot_with_users", False))
|
||||
update_time = validate_time(opts.get("update_time"), DEFAULT_UPDATE_TIME)
|
||||
upgrade_time = validate_time(opts.get("upgrade_time") or opts.get("update_time"), DEFAULT_UPGRADE_TIME)
|
||||
|
||||
APT_AUTO_CFG.parent.mkdir(parents=True, exist_ok=True)
|
||||
set_auto_updates(enable)
|
||||
|
||||
lines = [
|
||||
"// Managed by Pi-Kit dashboard",
|
||||
f"// PIKIT_SCOPE: {scope}",
|
||||
"Unattended-Upgrade::Origins-Pattern {",
|
||||
]
|
||||
for p in patterns:
|
||||
lines.append(f' "{p}";')
|
||||
lines.append("};")
|
||||
lines.append(f'Unattended-Upgrade::Remove-Unused-Dependencies {"true" if cleanup else "false"};')
|
||||
lines.append(f'Unattended-Upgrade::Automatic-Reboot {"true" if auto_reboot else "false"};')
|
||||
lines.append(f'Unattended-Upgrade::Automatic-Reboot-Time "{reboot_time}";')
|
||||
lines.append(
|
||||
f'Unattended-Upgrade::Automatic-Reboot-WithUsers {"true" if reboot_with_users else "false"};'
|
||||
)
|
||||
if bandwidth is not None:
|
||||
lines.append(f'Acquire::http::Dl-Limit "{int(bandwidth)}";')
|
||||
APT_UA_OVERRIDE.parent.mkdir(parents=True, exist_ok=True)
|
||||
APT_UA_OVERRIDE.write_text("\n".join(lines) + "\n")
|
||||
|
||||
_write_timer_override("apt-daily.timer", update_time)
|
||||
_write_timer_override("apt-daily-upgrade.timer", upgrade_time)
|
||||
return read_updates_config()
|
||||
72
pikit_api/constants.py
Normal file
72
pikit_api/constants.py
Normal 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
117
pikit_api/diagnostics.py
Normal file
@@ -0,0 +1,117 @@
|
||||
import datetime
|
||||
import io
|
||||
import json
|
||||
import pathlib
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .constants import (
|
||||
API_LOG,
|
||||
DEBUG_FLAG,
|
||||
DIAG_DEFAULT_STATE,
|
||||
DIAG_LOG_FILE,
|
||||
DIAG_MAX_BYTES,
|
||||
DIAG_MAX_ENTRY_CHARS,
|
||||
DIAG_STATE_FILE,
|
||||
)
|
||||
|
||||
_diag_state: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
def _load_diag_state() -> Dict[str, Any]:
|
||||
"""Load diagnostics state from RAM-backed storage when available."""
|
||||
global _diag_state
|
||||
if _diag_state is not None:
|
||||
return _diag_state
|
||||
try:
|
||||
if DIAG_STATE_FILE.exists():
|
||||
_diag_state = json.loads(DIAG_STATE_FILE.read_text())
|
||||
return _diag_state
|
||||
except Exception:
|
||||
pass
|
||||
_diag_state = DIAG_DEFAULT_STATE.copy()
|
||||
return _diag_state
|
||||
|
||||
|
||||
def _save_diag_state(enabled=None, level=None) -> Dict[str, Any]:
|
||||
"""Persist diagnostics state; tolerate failures silently."""
|
||||
state = _load_diag_state()
|
||||
if enabled is not None:
|
||||
state["enabled"] = bool(enabled)
|
||||
if level in ("normal", "debug"):
|
||||
state["level"] = level
|
||||
try:
|
||||
DIAG_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
DIAG_STATE_FILE.write_text(json.dumps(state))
|
||||
except Exception:
|
||||
pass
|
||||
return state
|
||||
|
||||
|
||||
def diag_log(level: str, message: str, meta: Optional[dict] = None) -> None:
|
||||
"""
|
||||
Append a diagnostic log line to RAM-backed file.
|
||||
Skips when disabled or when debug level is off.
|
||||
"""
|
||||
state = _load_diag_state()
|
||||
if not state.get("enabled"):
|
||||
return
|
||||
if level == "debug" and state.get("level") != "debug":
|
||||
return
|
||||
try:
|
||||
ts = datetime.datetime.utcnow().isoformat() + "Z"
|
||||
entry = {"ts": ts, "level": level, "msg": message}
|
||||
if meta:
|
||||
entry["meta"] = meta
|
||||
line = json.dumps(entry, separators=(",", ":"))
|
||||
if len(line) > DIAG_MAX_ENTRY_CHARS:
|
||||
entry.pop("meta", None)
|
||||
entry["msg"] = (message or "")[: DIAG_MAX_ENTRY_CHARS - 40] + "…"
|
||||
line = json.dumps(entry, separators=(",", ":"))
|
||||
line_bytes = (line + "\n").encode()
|
||||
DIAG_LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with DIAG_LOG_FILE.open("ab") as f:
|
||||
f.write(line_bytes)
|
||||
if DIAG_LOG_FILE.stat().st_size > DIAG_MAX_BYTES:
|
||||
with DIAG_LOG_FILE.open("rb") as f:
|
||||
f.seek(-DIAG_MAX_BYTES, io.SEEK_END)
|
||||
tail = f.read()
|
||||
if b"\n" in tail:
|
||||
tail = tail.split(b"\n", 1)[1]
|
||||
with DIAG_LOG_FILE.open("wb") as f:
|
||||
f.write(tail)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def diag_read(limit: int = 500) -> List[dict]:
|
||||
"""Return latest log entries (dicts), newest first."""
|
||||
if not DIAG_LOG_FILE.exists():
|
||||
return []
|
||||
try:
|
||||
data = DIAG_LOG_FILE.read_bytes()
|
||||
except Exception:
|
||||
return []
|
||||
lines = data.splitlines()[-limit:]
|
||||
out: List[dict] = []
|
||||
for line in lines:
|
||||
try:
|
||||
out.append(json.loads(line.decode("utf-8", errors="ignore")))
|
||||
except Exception:
|
||||
continue
|
||||
return out[::-1]
|
||||
|
||||
|
||||
def dbg(msg: str) -> None:
|
||||
"""
|
||||
Lightweight debug logger for legacy /boot/pikit-debug flag.
|
||||
Mirrors into diagnostics log when enabled.
|
||||
"""
|
||||
if DEBUG_FLAG:
|
||||
API_LOG.parent.mkdir(parents=True, exist_ok=True)
|
||||
ts = datetime.datetime.utcnow().isoformat()
|
||||
with API_LOG.open("a") as f:
|
||||
f.write(f"[{ts}] {msg}\n")
|
||||
try:
|
||||
diag_log("debug", msg)
|
||||
except Exception:
|
||||
pass
|
||||
80
pikit_api/helpers.py
Normal file
80
pikit_api/helpers.py
Normal file
@@ -0,0 +1,80 @@
|
||||
import hashlib
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import socket
|
||||
from typing import Optional
|
||||
|
||||
from .constants import HTTPS_PORTS
|
||||
|
||||
|
||||
def ensure_dir(path: pathlib.Path) -> None:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def sha256_file(path: pathlib.Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def normalize_path(path: Optional[str]) -> str:
|
||||
"""Normalize optional service path. Empty -> ''. Ensure leading slash."""
|
||||
if not path:
|
||||
return ""
|
||||
p = str(path).strip()
|
||||
if not p:
|
||||
return ""
|
||||
if not p.startswith("/"):
|
||||
p = "/" + p
|
||||
return p
|
||||
|
||||
|
||||
def default_host() -> str:
|
||||
"""Return preferred hostname (append .local if bare)."""
|
||||
host = socket.gethostname()
|
||||
if "." not in host:
|
||||
host = f"{host}.local"
|
||||
return host
|
||||
|
||||
|
||||
def detect_https(host: str, port: int) -> bool:
|
||||
"""Heuristic: known HTTPS ports or .local certs."""
|
||||
return int(port) in HTTPS_PORTS or host.lower().endswith(".local") or host.lower() == "pikit"
|
||||
|
||||
|
||||
def port_online(host: str, port: int) -> bool:
|
||||
try:
|
||||
with socket.create_connection((host, int(port)), timeout=1.5):
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def reboot_required() -> bool:
|
||||
return pathlib.Path("/run/reboot-required").exists()
|
||||
|
||||
|
||||
def strip_comments(text: str) -> str:
|
||||
"""Remove // and # line comments for safer parsing."""
|
||||
lines = []
|
||||
for ln in text.splitlines():
|
||||
l = ln.strip()
|
||||
if l.startswith("//") or l.startswith("#"):
|
||||
continue
|
||||
lines.append(ln)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def validate_time(val: str, default: str) -> str:
|
||||
if not val:
|
||||
return default
|
||||
m = re.match(r"^(\d{1,2}):(\d{2})$", val.strip())
|
||||
if not m:
|
||||
return default
|
||||
h, mi = int(m.group(1)), int(m.group(2))
|
||||
if 0 <= h < 24 and 0 <= mi < 60:
|
||||
return f"{h:02d}:{mi:02d}"
|
||||
return default
|
||||
306
pikit_api/http_handlers.py
Normal file
306
pikit_api/http_handlers.py
Normal 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
722
pikit_api/releases.py
Normal file
@@ -0,0 +1,722 @@
|
||||
import datetime
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .constants import (
|
||||
API_DIR,
|
||||
API_PACKAGE_DIR,
|
||||
API_PATH,
|
||||
AUTH_TOKEN,
|
||||
DEFAULT_MANIFEST_URL,
|
||||
DEFAULT_DEV_MANIFEST_URL,
|
||||
TMP_ROOT,
|
||||
UPDATE_LOCK,
|
||||
UPDATE_STATE,
|
||||
UPDATE_STATE_DIR,
|
||||
VERSION_FILE,
|
||||
WEB_ROOT,
|
||||
WEB_VERSION_FILE,
|
||||
)
|
||||
from .diagnostics import diag_log
|
||||
from .helpers import default_host, ensure_dir, sha256_file
|
||||
|
||||
|
||||
def read_current_version() -> str:
|
||||
if VERSION_FILE.exists():
|
||||
return VERSION_FILE.read_text().strip()
|
||||
if WEB_VERSION_FILE.exists():
|
||||
try:
|
||||
return json.loads(WEB_VERSION_FILE.read_text()).get("version", "unknown")
|
||||
except Exception:
|
||||
return "unknown"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def load_update_state() -> Dict[str, Any]:
|
||||
UPDATE_STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
def _reset_if_stale(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
If state thinks an update is running but the lock holder is gone,
|
||||
clear it so the UI can recover instead of getting stuck forever.
|
||||
"""
|
||||
lock_alive = False
|
||||
if UPDATE_LOCK.exists():
|
||||
try:
|
||||
pid = int(UPDATE_LOCK.read_text().strip() or "0")
|
||||
if pid > 0:
|
||||
os.kill(pid, 0)
|
||||
lock_alive = True
|
||||
else:
|
||||
UPDATE_LOCK.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
UPDATE_LOCK.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
UPDATE_LOCK.unlink(missing_ok=True)
|
||||
|
||||
if state.get("in_progress") and not lock_alive:
|
||||
state["in_progress"] = False
|
||||
state["progress"] = None
|
||||
if state.get("status") == "in_progress":
|
||||
state["status"] = "up_to_date"
|
||||
state["message"] = state.get("message") or "Recovered from interrupted update"
|
||||
try:
|
||||
save_update_state(state)
|
||||
except Exception:
|
||||
pass
|
||||
return state
|
||||
|
||||
if UPDATE_STATE.exists():
|
||||
try:
|
||||
state = json.loads(UPDATE_STATE.read_text())
|
||||
state.setdefault("changelog_url", None)
|
||||
state.setdefault("latest_release_date", None)
|
||||
state.setdefault("current_release_date", None)
|
||||
return _reset_if_stale(state)
|
||||
except Exception:
|
||||
pass
|
||||
return _reset_if_stale(
|
||||
{
|
||||
"current_version": read_current_version(),
|
||||
"latest_version": None,
|
||||
"last_check": None,
|
||||
"status": "unknown",
|
||||
"message": "",
|
||||
"auto_check": 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
9
pikit_api/server.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from http.server import HTTPServer
|
||||
|
||||
from .constants import HOST, PORT
|
||||
from .http_handlers import Handler
|
||||
|
||||
|
||||
def run_server(host: str = HOST, port: int = PORT):
|
||||
server = HTTPServer((host, port), Handler)
|
||||
server.serve_forever()
|
||||
151
pikit_api/services.py
Normal file
151
pikit_api/services.py
Normal file
@@ -0,0 +1,151 @@
|
||||
import json
|
||||
import pathlib
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from .constants import (
|
||||
CORE_NAME,
|
||||
CORE_PORTS,
|
||||
HTTPS_PORTS,
|
||||
READY_FILE,
|
||||
RESET_LOG,
|
||||
SERVICE_JSON,
|
||||
)
|
||||
from .diagnostics import dbg
|
||||
from .helpers import default_host, detect_https, ensure_dir, normalize_path
|
||||
|
||||
|
||||
class FirewallToolMissing(Exception):
|
||||
"""Raised when ufw is unavailable but a firewall change was requested."""
|
||||
pass
|
||||
|
||||
|
||||
def load_services() -> List[Dict[str, Any]]:
|
||||
"""Load service registry and normalize url/scheme/path fields."""
|
||||
if SERVICE_JSON.exists():
|
||||
try:
|
||||
data = json.loads(SERVICE_JSON.read_text())
|
||||
host = default_host()
|
||||
for svc in data:
|
||||
svc_path = normalize_path(svc.get("path"))
|
||||
if svc_path:
|
||||
svc["path"] = svc_path
|
||||
if svc.get("port"):
|
||||
scheme = svc.get("scheme")
|
||||
if not scheme:
|
||||
scheme = "https" if int(svc["port"]) in HTTPS_PORTS else "http"
|
||||
svc["scheme"] = scheme
|
||||
svc["url"] = f"{scheme}://{host}:{svc['port']}{svc_path}"
|
||||
return data
|
||||
except Exception:
|
||||
dbg("Failed to read services.json")
|
||||
return []
|
||||
return []
|
||||
|
||||
|
||||
def save_services(services: List[Dict[str, Any]]) -> None:
|
||||
ensure_dir(SERVICE_JSON.parent)
|
||||
SERVICE_JSON.write_text(json.dumps(services, indent=2))
|
||||
|
||||
|
||||
def allow_port_lan(port: int):
|
||||
"""Open a port to RFC1918 subnets; raise if ufw is missing so callers can surface the error."""
|
||||
if not shutil.which("ufw"):
|
||||
raise FirewallToolMissing("Cannot update firewall: ufw is not installed on this system.")
|
||||
for subnet in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "169.254.0.0/16"):
|
||||
subprocess.run(["ufw", "allow", "from", subnet, "to", "any", "port", str(port)], check=False)
|
||||
|
||||
|
||||
def remove_port_lan(port: int):
|
||||
"""Close a LAN rule for a port; raise if ufw is missing so callers can surface the error."""
|
||||
if not shutil.which("ufw"):
|
||||
raise FirewallToolMissing("Cannot update firewall: ufw is not installed on this system.")
|
||||
for subnet in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "169.254.0.0/16"):
|
||||
subprocess.run(["ufw", "delete", "allow", "from", subnet, "to", "any", "port", str(port)], check=False)
|
||||
|
||||
|
||||
def ufw_status_allows(port: int) -> bool:
|
||||
try:
|
||||
out = subprocess.check_output(["ufw", "status"], text=True)
|
||||
return f"{port}" in out and "ALLOW" in out
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def reset_firewall():
|
||||
subprocess.run(["ufw", "--force", "reset"], check=False)
|
||||
subprocess.run(["ufw", "default", "deny", "incoming"], check=False)
|
||||
subprocess.run(["ufw", "default", "deny", "outgoing"], check=False)
|
||||
for port in ("53", "80", "443", "123", "67", "68"):
|
||||
subprocess.run(["ufw", "allow", "out", port], check=False)
|
||||
subprocess.run(["ufw", "allow", "out", "on", "lo"], check=False)
|
||||
for subnet in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "169.254.0.0/16"):
|
||||
subprocess.run(["ufw", "allow", "out", "to", subnet], check=False)
|
||||
for subnet in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10", "169.254.0.0/16"):
|
||||
for port in ("22", "80", "443", "5252", "5253"):
|
||||
subprocess.run(["ufw", "allow", "from", subnet, "to", "any", "port", port], check=False)
|
||||
subprocess.run(["ufw", "--force", "enable"], check=False)
|
||||
|
||||
|
||||
def set_ssh_password_auth(allow: bool):
|
||||
"""
|
||||
Enable/disable SSH password authentication without requiring the current password.
|
||||
Used during factory reset to restore a predictable state.
|
||||
"""
|
||||
cfg = pathlib.Path("/etc/ssh/sshd_config")
|
||||
text = cfg.read_text() if cfg.exists() else ""
|
||||
|
||||
def set_opt(key, value):
|
||||
nonlocal text
|
||||
pattern = f"{key} "
|
||||
lines = text.splitlines()
|
||||
replaced = False
|
||||
for idx, line in enumerate(lines):
|
||||
if line.strip().startswith(pattern):
|
||||
lines[idx] = f"{key} {value}"
|
||||
replaced = True
|
||||
break
|
||||
if not replaced:
|
||||
lines.append(f"{key} {value}")
|
||||
text_new = "\n".join(lines) + "\n"
|
||||
return text_new
|
||||
|
||||
text = set_opt("PasswordAuthentication", "yes" if allow else "no")
|
||||
text = set_opt("KbdInteractiveAuthentication", "no")
|
||||
text = set_opt("ChallengeResponseAuthentication", "no")
|
||||
text = set_opt("PubkeyAuthentication", "yes")
|
||||
text = set_opt("PermitRootLogin", "yes" if allow else "prohibit-password")
|
||||
cfg.write_text(text)
|
||||
subprocess.run(["systemctl", "restart", "ssh"], check=False)
|
||||
return True, f"SSH password auth {'enabled' if allow else 'disabled'}"
|
||||
|
||||
|
||||
def factory_reset():
|
||||
# Restore services config
|
||||
custom = pathlib.Path("/boot/custom-files/pikit-services.json")
|
||||
if custom.exists():
|
||||
shutil.copy(custom, SERVICE_JSON)
|
||||
else:
|
||||
SERVICE_JSON.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{"name": "Pi-Kit Dashboard", "port": 80},
|
||||
{"name": "DietPi Dashboard", "port": 5252},
|
||||
],
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
reset_firewall()
|
||||
set_ssh_password_auth(True)
|
||||
for user in ("root", "dietpi"):
|
||||
try:
|
||||
subprocess.run(["chpasswd"], input=f"{user}:pikit".encode(), check=True)
|
||||
except Exception:
|
||||
pass
|
||||
if not pathlib.Path("/home/dietpi").exists():
|
||||
subprocess.run(["useradd", "-m", "-s", "/bin/bash", "dietpi"], check=False)
|
||||
subprocess.run(["chpasswd"], input=b"dietpi:pikit", check=False)
|
||||
RESET_LOG.write_text("Factory reset triggered\n")
|
||||
subprocess.Popen(["/bin/sh", "-c", "sleep 2 && systemctl reboot >/dev/null 2>&1"], close_fds=True)
|
||||
103
pikit_api/status.py
Normal file
103
pikit_api/status.py
Normal file
@@ -0,0 +1,103 @@
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from .auto_updates import auto_updates_state, read_updates_config
|
||||
from .constants import CORE_NAME, CORE_PORTS, READY_FILE
|
||||
from .helpers import default_host, detect_https, normalize_path, port_online, reboot_required
|
||||
from .services import load_services, ufw_status_allows
|
||||
|
||||
|
||||
def _cpu_temp():
|
||||
for path in ("/sys/class/thermal/thermal_zone0/temp",):
|
||||
p = pathlib.Path(path)
|
||||
if p.exists():
|
||||
try:
|
||||
return float(p.read_text().strip()) / 1000.0
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _os_version():
|
||||
os_ver = "DietPi"
|
||||
try:
|
||||
for line in pathlib.Path("/etc/os-release").read_text().splitlines():
|
||||
if line.startswith("PRETTY_NAME="):
|
||||
os_ver = line.split("=", 1)[1].strip().strip('"')
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
return os_ver
|
||||
|
||||
|
||||
def _lan_ip():
|
||||
try:
|
||||
out = subprocess.check_output(["hostname", "-I"], text=True).strip()
|
||||
return out.split()[0] if out else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def list_services_with_health():
|
||||
services = []
|
||||
for svc in load_services():
|
||||
svc = dict(svc)
|
||||
port = svc.get("port")
|
||||
if port:
|
||||
svc["online"] = port_online("127.0.0.1", port)
|
||||
svc["firewall_open"] = ufw_status_allows(port)
|
||||
services.append(svc)
|
||||
return services
|
||||
|
||||
|
||||
def list_services_for_ui():
|
||||
services = []
|
||||
for svc in load_services():
|
||||
svc = dict(svc)
|
||||
port = svc.get("port")
|
||||
if port:
|
||||
svc["online"] = port_online("127.0.0.1", port)
|
||||
svc["firewall_open"] = ufw_status_allows(port)
|
||||
host = default_host()
|
||||
path = normalize_path(svc.get("path"))
|
||||
scheme = svc.get("scheme") or ("https" if detect_https(host, port) else "http")
|
||||
svc["scheme"] = scheme
|
||||
svc["url"] = f"{scheme}://{host}:{port}{path}"
|
||||
services.append(svc)
|
||||
return services
|
||||
|
||||
|
||||
def collect_status() -> Dict[str, Any]:
|
||||
uptime = float(open("/proc/uptime").read().split()[0])
|
||||
load1, load5, load15 = os.getloadavg()
|
||||
meminfo = {}
|
||||
for ln in open("/proc/meminfo"):
|
||||
k, v = ln.split(":", 1)
|
||||
meminfo[k] = int(v.strip().split()[0])
|
||||
total = meminfo.get("MemTotal", 0) // 1024
|
||||
free = meminfo.get("MemAvailable", 0) // 1024
|
||||
disk = shutil.disk_usage("/")
|
||||
services = list_services_with_health()
|
||||
updates_state = auto_updates_state()
|
||||
updates_config = read_updates_config(updates_state)
|
||||
data = {
|
||||
"hostname": socket.gethostname(),
|
||||
"uptime_seconds": uptime,
|
||||
"load": [load1, load5, load15],
|
||||
"memory_mb": {"total": total, "free": free},
|
||||
"disk_mb": {"total": disk.total // 1024 // 1024, "free": disk.free // 1024 // 1024},
|
||||
"cpu_temp_c": _cpu_temp(),
|
||||
"lan_ip": _lan_ip(),
|
||||
"os_version": _os_version(),
|
||||
"auto_updates_enabled": updates_state.get("enabled", False),
|
||||
"auto_updates": updates_state,
|
||||
"updates_config": updates_config,
|
||||
"reboot_required": reboot_required(),
|
||||
"ready": READY_FILE.exists(),
|
||||
"services": services,
|
||||
}
|
||||
return data
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/bin/bash
|
||||
echo "Waiting for pikit to resolve..."
|
||||
while ! getent hosts pikit >/dev/null;
|
||||
do sleep 1
|
||||
done
|
||||
ssh -i ~/.ssh/pikit dietpi@pikit sudo touch /var/run/pikit-ready
|
||||
echo "Done."
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
# 1) Refresh sudo once in the foreground so you enter your password cleanly
|
||||
echo "Refreshing sudo credentials..."
|
||||
sudo -v || { echo "sudo authentication failed"; exit 1; }
|
||||
|
||||
# 2) Start non-interactive sudo keepalive loop in the background
|
||||
# -n = never prompt; if the timestamp ever expires, this just exits
|
||||
( while true; do sudo -n true 2>/dev/null || exit 0; sleep 180; done ) &
|
||||
KEEPALIVE_PID=$!
|
||||
|
||||
# 3) Ensure cleanup on exit (normal, error, or Ctrl+C)
|
||||
cleanup() {
|
||||
kill "$KEEPALIVE_PID" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# 4) Run Codex
|
||||
codex resume --search
|
||||
|
||||
@@ -34,7 +34,7 @@ Environment=PIKIT_MANIFEST_URL=https://git.44r0n.cc/44r0n7/pi-kit/releases/downl
|
||||
|
||||
## What’s inside the bundle
|
||||
- `pikit-web/` (built static assets)
|
||||
- `pikit-api.py`
|
||||
- `pikit-api.py` + `pikit_api/` package
|
||||
- optional helper scripts (e.g., `set_ready.sh`, `start-codex.sh`, `pikit-services.json` if present)
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -34,9 +34,8 @@ rsync -a --delete \
|
||||
"$ROOT/pikit-web/" "$STAGE/pikit-web/"
|
||||
|
||||
cp "$ROOT/pikit-api.py" "$STAGE/"
|
||||
rsync -a "$ROOT/pikit_api/" "$STAGE/pikit_api/"
|
||||
cp "$ROOT/pikit-services.json" "$STAGE/" 2>/dev/null || true
|
||||
cp "$ROOT/set_ready.sh" "$STAGE/" 2>/dev/null || true
|
||||
cp "$ROOT/start-codex.sh" "$STAGE/" 2>/dev/null || true
|
||||
|
||||
# Include version marker
|
||||
if [[ -f "$ROOT/pikit-web/data/version.json" ]]; then
|
||||
|
||||
Reference in New Issue
Block a user