Compare commits
32 Commits
v0.1.0-dev
...
v0.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b70f4c5f3f | ||
|
|
0c69e9bf90 | ||
|
|
d8673a9fb4 | ||
|
|
1e8e8a5bc2 | ||
|
|
e3c6c3b308 | ||
|
|
a05aa70069 | ||
|
|
06e8e25aad | ||
|
|
4f05f58f45 | ||
|
|
eaf261a6be | ||
|
|
798d78cb13 | ||
|
|
bcb6f3005d | ||
|
|
b911171045 | ||
|
|
2cfb9779d6 | ||
|
|
08cf472bf7 | ||
|
|
471e242427 | ||
|
|
357453eed4 | ||
|
|
25cc888b86 | ||
|
|
17ae87563f | ||
|
|
32503424e8 | ||
|
|
50be46df45 | ||
|
|
8c06962f62 | ||
|
|
2a439321d0 | ||
|
|
e993d19886 | ||
|
|
0e3b144cd7 | ||
|
|
98fbe1b96e | ||
|
|
8864df2b2c | ||
|
|
d49218409d | ||
|
|
35c83a918b | ||
|
|
c182eb179d | ||
|
|
650175913e | ||
|
|
5ee183d607 | ||
|
|
48be7a1c61 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -26,6 +26,3 @@ out/
|
|||||||
|
|
||||||
# Stock images (large)
|
# Stock images (large)
|
||||||
images/stock/
|
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).
|
|
||||||
27
README.md
27
README.md
@@ -1,21 +1,22 @@
|
|||||||
# Pi-Kit Dashboard
|
# 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:
|
## 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-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/`: Vite static site served by nginx at `/var/www/pikit-web/`; source in `pikit-web/assets/`, Playwright E2E in `pikit-web/tests/`.
|
||||||
- `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/`.
|
- Release tooling: `tools/release/make-release.sh` builds a bundle tarball + manifest for OTA; changelogs live in `out/releases/`.
|
||||||
|
|
||||||
## Local development
|
## 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.
|
- 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` to run locally (listens on 127.0.0.1:4000).
|
- API: `python pikit-api.py` runs the same routes locally (no auth) as on-device.
|
||||||
|
|
||||||
## Deploying to a Pi-Kit box
|
## Deploy 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.
|
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/index.html` and `pikit-web/assets/*` (or the built `pikit-web/dist/*`) to `/var/www/pikit-web/`.
|
2) Sync `pikit-web/dist/` (preferred) to `/var/www/pikit-web/`, then restart `dietpi-dashboard-frontend.service`.
|
||||||
3. The API surfaces clear errors if firewall tooling (`ufw`) is missing when ports are opened/closed.
|
3) Using release bundles: `./tools/release/make-release.sh <version> <base_url>`, upload the tarball + manifest, and point `PIKIT_MANIFEST_URL` (systemd drop-in) to that manifest URL for OTA.
|
||||||
4. Factory reset sets `root` and `dietpi` passwords to `pikit`.
|
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
- Service paths are normalized (leading slash) and URLs include optional subpaths.
|
- 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.
|
- Firewall changes surface clear errors when `ufw` is missing so the UI can report failures.
|
||||||
- Access the device UI at `http://pikit.local/` (mDNS).
|
- Factory reset sets `root` and `dietpi` passwords to `pikit`.
|
||||||
|
- Default UI: `http://pikit.local/` (mDNS) unless HTTPS is enabled.
|
||||||
|
|||||||
1315
pikit-api.py
1315
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.
|
|
||||||
|
|
||||||
---
|
|
||||||
@@ -117,3 +117,15 @@ export const removeService = ({ port }) =>
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ port }),
|
body: JSON.stringify({ port }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Diagnostics
|
||||||
|
export const getDiagLog = () => api("/api/diag/log");
|
||||||
|
export const setDiagLevel = ({ enabled, level }) =>
|
||||||
|
api("/api/diag/log/level", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ enabled, level }),
|
||||||
|
});
|
||||||
|
export const clearDiagLog = () =>
|
||||||
|
api("/api/diag/log/clear", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|||||||
140
pikit-web/assets/css/fonts.css
Normal file
140
pikit-web/assets/css/fonts.css
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: "Red Hat Text";
|
||||||
|
src: url("../fonts/RedHatText-Regular.woff2") format("woff2");
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Red Hat Text";
|
||||||
|
src: url("../fonts/RedHatText-Medium.woff2") format("woff2");
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Red Hat Display";
|
||||||
|
src: url("../fonts/RedHatDisplay-SemiBold.woff2") format("woff2");
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Red Hat Display";
|
||||||
|
src: url("../fonts/RedHatDisplay-Bold.woff2") format("woff2");
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Space Grotesk";
|
||||||
|
src: url("../fonts/SpaceGrotesk-Regular.woff2") format("woff2");
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Space Grotesk";
|
||||||
|
src: url("../fonts/SpaceGrotesk-Medium.woff2") format("woff2");
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Space Grotesk";
|
||||||
|
src: url("../fonts/SpaceGrotesk-SemiBold.woff2") format("woff2");
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Manrope";
|
||||||
|
src: url("../fonts/Manrope-Regular.woff2") format("woff2");
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Manrope";
|
||||||
|
src: url("../fonts/Manrope-SemiBold.woff2") format("woff2");
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "DM Sans";
|
||||||
|
src: url("../fonts/DMSans-Regular.woff2") format("woff2");
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "DM Sans";
|
||||||
|
src: url("../fonts/DMSans-Medium.woff2") format("woff2");
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "DM Sans";
|
||||||
|
src: url("../fonts/DMSans-Bold.woff2") format("woff2");
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Sora";
|
||||||
|
src: url("../fonts/Sora-Regular.woff2") format("woff2");
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Sora";
|
||||||
|
src: url("../fonts/Sora-SemiBold.woff2") format("woff2");
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Chivo";
|
||||||
|
src: url("../fonts/Chivo-Regular.woff2") format("woff2");
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Chivo";
|
||||||
|
src: url("../fonts/Chivo-Bold.woff2") format("woff2");
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Atkinson Hyperlegible";
|
||||||
|
src: url("../fonts/Atkinson-Regular.woff2") format("woff2");
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Atkinson Hyperlegible";
|
||||||
|
src: url("../fonts/Atkinson-Bold.woff2") format("woff2");
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "IBM Plex Sans";
|
||||||
|
src: url("../fonts/PlexSans-Regular.woff2") format("woff2");
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "IBM Plex Sans";
|
||||||
|
src: url("../fonts/PlexSans-SemiBold.woff2") format("woff2");
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
335
pikit-web/assets/css/forms.css
Normal file
335
pikit-web/assets/css/forms.css
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
button {
|
||||||
|
background: linear-gradient(135deg, #22c55e, #7dd3fc);
|
||||||
|
color: #041012;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.icon-btn {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] button.icon-btn {
|
||||||
|
box-shadow: inset 0 0 0 1px var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger-btn {
|
||||||
|
background: linear-gradient(135deg, #f87171, #ef4444);
|
||||||
|
color: #0f1117;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-btn {
|
||||||
|
font-size: 1rem;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-menu .ghost {
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border) 60%, var(--text) 20%);
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label.toggle {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 46px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label.toggle input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
label.toggle .slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--toggle-track);
|
||||||
|
border-radius: 24px;
|
||||||
|
transition: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
label.toggle .slider:before {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
left: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
label.toggle input:checked + .slider {
|
||||||
|
background: linear-gradient(135deg, #22c55e, #7dd3fc);
|
||||||
|
}
|
||||||
|
|
||||||
|
label.toggle input:checked + .slider:before {
|
||||||
|
transform: translateX(22px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--panel-overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion + .accordion {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-toggle {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
border: none;
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-toggle.danger-btn {
|
||||||
|
color: #0f1117;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-body {
|
||||||
|
padding: 0 14px 0;
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
transition:
|
||||||
|
max-height 0.24s ease,
|
||||||
|
opacity 0.18s ease,
|
||||||
|
padding-bottom 0.18s ease,
|
||||||
|
padding-top 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion.open .accordion-body {
|
||||||
|
max-height: 1200px;
|
||||||
|
opacity: 1;
|
||||||
|
padding: 8px 12px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-body p {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-actions.split-row {
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-actions.column.tight {
|
||||||
|
gap: 6px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-actions.column {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-actions.column > .checkbox-row.inline {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
select,
|
||||||
|
input {
|
||||||
|
background: var(--input-bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--input-border);
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row.inline {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row.inline.tight {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row.inline.nowrap span {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-row.split {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-row.split > * {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-row .dual-col:last-child {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dual-row .checkbox-row.inline {
|
||||||
|
justify-content: flex-start;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-field .checkbox-row {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-msg {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-msg.error {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-warn {
|
||||||
|
color: #f87171;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-disabled input,
|
||||||
|
.is-disabled select,
|
||||||
|
.is-disabled textarea {
|
||||||
|
filter: grayscale(0.5);
|
||||||
|
background: var(--input-disabled-bg);
|
||||||
|
color: var(--input-disabled-text);
|
||||||
|
border-color: var(--input-disabled-border);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-disabled label {
|
||||||
|
color: var(--disabled-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-disabled .slider {
|
||||||
|
filter: grayscale(0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: none;
|
||||||
|
background: #2f3844;
|
||||||
|
border: 1px solid #3b4756;
|
||||||
|
color: #c9d2dc;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
379
pikit-web/assets/css/layout.css
Normal file
379
pikit-web/assets/css/layout.css
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
.host-chip {
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 18px 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.1fr 0.9fr;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.12rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow.warning {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
line-height: 1.2;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
line-height: 1.2;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lede {
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint.quiet {
|
||||||
|
opacity: 0.8;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stats {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
background: var(--card-overlay);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 9px;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat .label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat .value {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 18px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-actions .icon-btn {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header.small-gap {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid.empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 220px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state button {
|
||||||
|
margin-top: 6px;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--card-overlay);
|
||||||
|
padding: 12px;
|
||||||
|
padding-right: 48px; /* reserve room for stacked action buttons */
|
||||||
|
padding-bottom: 34px; /* reserve room for bottom badges */
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.offline {
|
||||||
|
border-color: rgba(225, 29, 72, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-url {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--accent) 18%, var(--panel) 82%);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border) 65%);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-small {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px dashed #6b7280;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.self-signed-pill {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-btn {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border) 60%, var(--text) 20%);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--card-overlay);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #e11d48;
|
||||||
|
box-shadow: 0 0 8px rgba(225, 29, 72, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.on {
|
||||||
|
background: #22c55e;
|
||||||
|
box-shadow: 0 0 8px rgba(34, 197, 94, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-anim="on"] .status-dot.on {
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.35);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
box-shadow: 0 0 0 10px rgba(34, 197, 94, 0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.off {
|
||||||
|
background: #f87171;
|
||||||
|
box-shadow: 0 0 8px rgba(248, 113, 113, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-header .pill {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-header .status-dot,
|
||||||
|
.service-header .menu-btn,
|
||||||
|
.service-header .notice-pill {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-header .menu-btn {
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
#servicesGrid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-actions .icon-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: var(--card-overlay);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-box {
|
||||||
|
max-height: 140px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: rgba(0, 0, 0, 0.08);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
font-family: "DM Sans", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="light"] .log-box {
|
||||||
|
background: rgba(12, 18, 32, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.codeblock {
|
||||||
|
background: var(--input-bg);
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
max-width: 440px;
|
||||||
|
min-width: 260px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
272
pikit-web/assets/css/modal.css
Normal file
272
pikit-web/assets/css/modal.css
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
4
pikit-web/assets/diaglog.css
Normal file
4
pikit-web/assets/diaglog.css
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.diag-log-modal .log-box {
|
||||||
|
max-height: 60vh;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
225
pikit-web/assets/diaglog.js
Normal file
225
pikit-web/assets/diaglog.js
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
// Diagnostic logging (frontend side)
|
||||||
|
// Maintains a client-side ring buffer, fetches server logs, and wires UI controls.
|
||||||
|
|
||||||
|
import { getDiagLog, setDiagLevel, clearDiagLog } from "./api.js";
|
||||||
|
|
||||||
|
const UI_MAX = 500;
|
||||||
|
const uiBuffer = [];
|
||||||
|
let uiEnabled = false;
|
||||||
|
let uiLevel = "normal";
|
||||||
|
let clickListenerAttached = false;
|
||||||
|
let loading = false;
|
||||||
|
|
||||||
|
function appendUi(level, msg, meta = null) {
|
||||||
|
if (!uiEnabled) return;
|
||||||
|
if (level === "debug" && uiLevel !== "debug") return;
|
||||||
|
const ts = new Date().toISOString();
|
||||||
|
const entry = { ts, level, msg, meta, source: "ui" };
|
||||||
|
uiBuffer.unshift(entry);
|
||||||
|
if (uiBuffer.length > UI_MAX) uiBuffer.length = UI_MAX;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachClickTracker() {
|
||||||
|
if (clickListenerAttached) return;
|
||||||
|
clickListenerAttached = true;
|
||||||
|
document.addEventListener(
|
||||||
|
"click",
|
||||||
|
(e) => {
|
||||||
|
if (!uiEnabled || uiLevel !== "debug") return;
|
||||||
|
const el = e.target.closest("button,input,select,textarea,label");
|
||||||
|
if (!el) return;
|
||||||
|
const label =
|
||||||
|
el.getAttribute("aria-label") ||
|
||||||
|
el.getAttribute("title") ||
|
||||||
|
el.textContent?.trim()?.slice(0, 60) ||
|
||||||
|
el.id ||
|
||||||
|
el.tagName.toLowerCase();
|
||||||
|
appendUi("debug", `UI click: ${label || el.tagName}`, {
|
||||||
|
id: el.id || null,
|
||||||
|
type: el.tagName.toLowerCase(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logUi(msg, level = "info", meta) {
|
||||||
|
appendUi(level, msg, meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initDiagUI({ elements, toast }) {
|
||||||
|
const {
|
||||||
|
enableToggle,
|
||||||
|
debugToggle,
|
||||||
|
refreshBtn,
|
||||||
|
clearBtn,
|
||||||
|
copyBtn,
|
||||||
|
downloadBtn,
|
||||||
|
logBox,
|
||||||
|
statusEl,
|
||||||
|
logButton,
|
||||||
|
modal,
|
||||||
|
modalClose,
|
||||||
|
} = elements;
|
||||||
|
|
||||||
|
const setBusy = (on) => {
|
||||||
|
loading = on;
|
||||||
|
[refreshBtn, clearBtn, copyBtn, downloadBtn, enableToggle, debugToggle].forEach((el) => {
|
||||||
|
if (el) el.disabled = !!on;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
async function syncState() {
|
||||||
|
const data = await getDiagLog();
|
||||||
|
const state = data.state || {};
|
||||||
|
uiEnabled = !!state.enabled;
|
||||||
|
uiLevel = state.level || "normal";
|
||||||
|
if (enableToggle) enableToggle.checked = uiEnabled;
|
||||||
|
if (debugToggle) debugToggle.checked = uiLevel === "debug";
|
||||||
|
if (logButton) logButton.classList.toggle("hidden", !uiEnabled);
|
||||||
|
if (modal && !uiEnabled) modal.classList.add("hidden");
|
||||||
|
return data.entries || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(entries) {
|
||||||
|
if (!logBox) return;
|
||||||
|
const merged = [
|
||||||
|
...(entries || []).map((e) => ({ ...e, source: "api" })),
|
||||||
|
...uiBuffer,
|
||||||
|
].sort((a, b) => (a.ts < b.ts ? 1 : -1));
|
||||||
|
logBox.textContent = merged
|
||||||
|
.map((e) => `${new Date(e.ts).toLocaleTimeString()} [${e.source || "api"} ${e.level}] ${e.msg}`)
|
||||||
|
.join("\n");
|
||||||
|
if (statusEl) statusEl.textContent = `${merged.length} entries`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh({ silent = false } = {}) {
|
||||||
|
if (loading) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const entries = await syncState();
|
||||||
|
render(entries);
|
||||||
|
if (!silent) toast?.("Diagnostics refreshed", "success");
|
||||||
|
} catch (e) {
|
||||||
|
if (!silent) toast?.(e.error || "Failed to load diagnostics", "error");
|
||||||
|
// retry once if failed
|
||||||
|
try {
|
||||||
|
const entries = await syncState();
|
||||||
|
render(entries);
|
||||||
|
if (!silent) toast?.("Diagnostics refreshed (after retry)", "success");
|
||||||
|
} catch (err2) {
|
||||||
|
if (!silent) toast?.(err2.error || "Diagnostics still failing", "error");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enableToggle?.addEventListener("change", async () => {
|
||||||
|
try {
|
||||||
|
setBusy(true);
|
||||||
|
uiEnabled = enableToggle.checked;
|
||||||
|
await setDiagLevel({ enabled: uiEnabled, level: uiLevel });
|
||||||
|
appendUi("info", `Diagnostics ${uiEnabled ? "enabled" : "disabled"}`);
|
||||||
|
if (uiEnabled) attachClickTracker();
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
toast?.(e.error || "Failed to save diagnostics setting", "error");
|
||||||
|
enableToggle.checked = !enableToggle.checked;
|
||||||
|
setBusy(false);
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
if (logButton) logButton.classList.toggle("hidden", !uiEnabled);
|
||||||
|
if (!uiEnabled && modal) modal.classList.add("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
debugToggle?.addEventListener("change", async () => {
|
||||||
|
try {
|
||||||
|
setBusy(true);
|
||||||
|
uiLevel = debugToggle.checked ? "debug" : "normal";
|
||||||
|
await setDiagLevel({ enabled: uiEnabled, level: uiLevel });
|
||||||
|
appendUi("info", `Diagnostics level set to ${uiLevel}`);
|
||||||
|
if (uiEnabled) attachClickTracker();
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
toast?.(e.error || "Failed to save level", "error");
|
||||||
|
debugToggle.checked = uiLevel === "debug";
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
refreshBtn?.addEventListener("click", refresh);
|
||||||
|
|
||||||
|
clearBtn?.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
setBusy(true);
|
||||||
|
await clearDiagLog();
|
||||||
|
uiBuffer.length = 0;
|
||||||
|
appendUi("info", "Cleared diagnostics");
|
||||||
|
// Immediately reflect empty log in UI, then refresh from server
|
||||||
|
if (logBox) logBox.textContent = "";
|
||||||
|
render([]);
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
toast?.(e.error || "Failed to clear log", "error");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
copyBtn?.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const text = logBox?.textContent || "";
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(text || "No log entries.");
|
||||||
|
} else {
|
||||||
|
const ta = document.createElement("textarea");
|
||||||
|
ta.value = text || "No log entries.";
|
||||||
|
ta.style.position = "fixed";
|
||||||
|
ta.style.opacity = "0";
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
}
|
||||||
|
toast?.("Diagnostics copied", "success");
|
||||||
|
} catch (e) {
|
||||||
|
toast?.("Copy failed", "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
downloadBtn?.addEventListener("click", () => {
|
||||||
|
try {
|
||||||
|
const blob = new Blob([logBox?.textContent || "No log entries."], { type: "text/plain" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "pikit-diagnostics.txt";
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (e) {
|
||||||
|
toast?.("Download failed", "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// initial load
|
||||||
|
attachClickTracker();
|
||||||
|
await refresh({ silent: true });
|
||||||
|
|
||||||
|
logButton?.addEventListener("click", () => {
|
||||||
|
if (!uiEnabled) return;
|
||||||
|
modal?.classList.remove("hidden");
|
||||||
|
});
|
||||||
|
modalClose?.addEventListener("click", () => modal?.classList.add("hidden"));
|
||||||
|
modal?.addEventListener("click", (e) => {
|
||||||
|
if (e.target === modal) e.stopPropagation(); // prevent accidental close
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
logUi,
|
||||||
|
refresh,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,11 +1,21 @@
|
|||||||
// Entry point for the dashboard: wires UI events, pulls status, and initializes
|
// Entry point for the dashboard: wires UI events, pulls status, and initializes
|
||||||
// feature modules (services, settings, stats).
|
// feature modules (services, settings, stats).
|
||||||
import { getStatus, triggerReset } from "./api.js";
|
import { getStatus, triggerReset } from "./api.js";
|
||||||
|
import { initServiceControls } from "./services.js";
|
||||||
import { placeholderStatus, renderStats } from "./status.js";
|
import { placeholderStatus, renderStats } from "./status.js";
|
||||||
import { initServiceControls, renderServices } from "./services.js";
|
|
||||||
import { initSettings } from "./settings.js";
|
import { initSettings } from "./settings.js";
|
||||||
import { initUpdateSettings, isUpdatesDirty } from "./update-settings.js";
|
import { initUpdateSettings, isUpdatesDirty } from "./update-settings.js";
|
||||||
import { initReleaseUI } from "./releases.js?v=20251213g";
|
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 servicesGrid = document.getElementById("servicesGrid");
|
||||||
const heroStats = document.getElementById("heroStats");
|
const heroStats = document.getElementById("heroStats");
|
||||||
@@ -31,6 +41,7 @@ const toastPosSelect = document.getElementById("toastPosSelect");
|
|||||||
const toastAnimSelect = document.getElementById("toastAnimSelect");
|
const toastAnimSelect = document.getElementById("toastAnimSelect");
|
||||||
const toastSpeedInput = document.getElementById("toastSpeedInput");
|
const toastSpeedInput = document.getElementById("toastSpeedInput");
|
||||||
const toastDurationInput = document.getElementById("toastDurationInput");
|
const toastDurationInput = document.getElementById("toastDurationInput");
|
||||||
|
const toastTestBtn = document.getElementById("toastTestBtn");
|
||||||
const fontSelect = document.getElementById("fontSelect");
|
const fontSelect = document.getElementById("fontSelect");
|
||||||
const updatesScope = document.getElementById("updatesScope");
|
const updatesScope = document.getElementById("updatesScope");
|
||||||
const updateTimeInput = document.getElementById("updateTimeInput");
|
const updateTimeInput = document.getElementById("updateTimeInput");
|
||||||
@@ -97,147 +108,112 @@ const changelogModal = document.getElementById("changelogModal");
|
|||||||
const changelogTitle = document.getElementById("changelogTitle");
|
const changelogTitle = document.getElementById("changelogTitle");
|
||||||
const changelogBody = document.getElementById("changelogBody");
|
const changelogBody = document.getElementById("changelogBody");
|
||||||
const changelogClose = document.getElementById("changelogClose");
|
const changelogClose = document.getElementById("changelogClose");
|
||||||
|
const diagEnableToggle = document.getElementById("diagEnableToggle");
|
||||||
|
const diagDebugToggle = document.getElementById("diagDebugToggle");
|
||||||
|
const diagRefreshBtn = document.getElementById("diagRefreshBtn");
|
||||||
|
const diagClearBtn = document.getElementById("diagClearBtn");
|
||||||
|
const diagCopyBtn = document.getElementById("diagCopyBtn");
|
||||||
|
const diagDownloadBtn = document.getElementById("diagDownloadBtn");
|
||||||
|
const diagLogBox = document.getElementById("diagLogBox");
|
||||||
|
const diagStatus = document.getElementById("diagStatus");
|
||||||
|
const diagLogBtn = document.getElementById("diagLogBtn");
|
||||||
|
const diagModal = document.getElementById("diagModal");
|
||||||
|
const diagClose = document.getElementById("diagClose");
|
||||||
|
const diagStatusModal = document.getElementById("diagStatusModal");
|
||||||
|
|
||||||
const TOAST_POS_KEY = "pikit-toast-pos";
|
const toastController = createToastManager({
|
||||||
const TOAST_ANIM_KEY = "pikit-toast-anim";
|
container: toastContainer,
|
||||||
const TOAST_SPEED_KEY = "pikit-toast-speed";
|
posSelect: toastPosSelect,
|
||||||
const TOAST_DURATION_KEY = "pikit-toast-duration";
|
animSelect: toastAnimSelect,
|
||||||
const FONT_KEY = "pikit-font";
|
speedInput: toastSpeedInput,
|
||||||
const ALLOWED_TOAST_POS = [
|
durationInput: toastDurationInput,
|
||||||
"bottom-center",
|
fontSelect,
|
||||||
"bottom-right",
|
testBtn: toastTestBtn,
|
||||||
"bottom-left",
|
});
|
||||||
"top-right",
|
const showToast = toastController.showToast;
|
||||||
"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";
|
|
||||||
let releaseUI = null;
|
let releaseUI = null;
|
||||||
|
const { showBusy, hideBusy } = createBusyOverlay({
|
||||||
|
overlay: busyOverlay,
|
||||||
|
titleEl: busyTitle,
|
||||||
|
textEl: busyText,
|
||||||
|
});
|
||||||
|
const confirmAction = createConfirmModal({
|
||||||
|
modal: confirmModal,
|
||||||
|
titleEl: confirmTitle,
|
||||||
|
bodyEl: confirmBody,
|
||||||
|
okBtn: confirmOk,
|
||||||
|
cancelBtn: confirmCancel,
|
||||||
|
});
|
||||||
|
const collapseAccordions = () => document.querySelectorAll(".accordion").forEach((a) => a.classList.remove("open"));
|
||||||
|
|
||||||
function applyToastSettings() {
|
const statusController = createStatusController({
|
||||||
if (!toastContainer) return;
|
heroStats,
|
||||||
toastContainer.className = `toast-container pos-${toastPosition}`;
|
servicesGrid,
|
||||||
document.documentElement.style.setProperty("--toast-speed", `${toastSpeedMs}ms`);
|
updatesFlagTop,
|
||||||
const dir = toastPosition.startsWith("top") ? -1 : 1;
|
updatesNoteTop,
|
||||||
const isLeft = toastPosition.includes("left");
|
tempFlagTop,
|
||||||
const isRight = toastPosition.includes("right");
|
readyOverlay,
|
||||||
const slideX = isLeft ? -26 : isRight ? 26 : 0;
|
logUi,
|
||||||
const slideY = isLeft || isRight ? 0 : dir * 24;
|
getStatus,
|
||||||
document.documentElement.style.setProperty("--toast-slide-offset", `${dir * 24}px`);
|
isUpdatesDirty,
|
||||||
document.documentElement.style.setProperty("--toast-dir", `${dir}`);
|
setUpdatesUI,
|
||||||
document.documentElement.style.setProperty("--toast-slide-x", `${slideX}px`);
|
updatesFlagEl: setUpdatesFlag,
|
||||||
document.documentElement.style.setProperty("--toast-slide-y", `${slideY}px`);
|
releaseUIGetter: () => releaseUI,
|
||||||
if (toastDurationInput) toastDurationInput.value = toastDurationMs;
|
onReadyWait: () => setTimeout(() => statusController.loadStatus(), 3000),
|
||||||
}
|
});
|
||||||
|
const { loadStatus } = statusController;
|
||||||
|
|
||||||
function applyFontSetting() {
|
function wireDialogs() {
|
||||||
document.documentElement.setAttribute("data-font", fontChoice);
|
wireModalPairs([
|
||||||
if (fontSelect) fontSelect.value = fontChoice;
|
{ openBtn: aboutBtn, modal: aboutModal, closeBtn: aboutClose },
|
||||||
}
|
{ openBtn: helpBtn, modal: helpModal, closeBtn: helpClose },
|
||||||
|
]);
|
||||||
function loadToastSettings() {
|
// Settings modal keeps custom accordion collapse on close
|
||||||
try {
|
advBtn?.addEventListener("click", () => {
|
||||||
const posSaved = localStorage.getItem(TOAST_POS_KEY);
|
if (window.__pikitTest?.forceServiceFormVisible) {
|
||||||
if (ALLOWED_TOAST_POS.includes(posSaved)) toastPosition = posSaved;
|
// For tests: avoid opening any modal; just ensure form controls are visible
|
||||||
const animSaved = localStorage.getItem(TOAST_ANIM_KEY);
|
addServiceModal?.classList.add("hidden");
|
||||||
const migrated =
|
addServiceModal?.setAttribute("style", "display:none;");
|
||||||
animSaved === "slide-up" || animSaved === "slide-left" || animSaved === "slide-right"
|
window.__pikitTest.forceServiceFormVisible();
|
||||||
? "slide-in"
|
return;
|
||||||
: animSaved;
|
|
||||||
if (migrated && ALLOWED_TOAST_ANIM.includes(migrated)) toastAnimation = migrated;
|
|
||||||
const savedSpeed = Number(localStorage.getItem(TOAST_SPEED_KEY));
|
|
||||||
if (!Number.isNaN(savedSpeed) && savedSpeed >= 100 && savedSpeed <= 3000) {
|
|
||||||
toastSpeedMs = savedSpeed;
|
|
||||||
}
|
}
|
||||||
const savedDur = Number(localStorage.getItem(TOAST_DURATION_KEY));
|
advModal.classList.remove("hidden");
|
||||||
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 = "";
|
|
||||||
});
|
});
|
||||||
}
|
advClose?.addEventListener("click", () => {
|
||||||
|
advModal.classList.add("hidden");
|
||||||
|
collapseAccordions();
|
||||||
});
|
});
|
||||||
|
menuClose.onclick = () => menuModal.classList.add("hidden");
|
||||||
|
addServiceOpen?.addEventListener("click", openAddService);
|
||||||
|
addSvcClose?.addEventListener("click", () => addServiceModal?.classList.add("hidden"));
|
||||||
|
addServiceModal?.addEventListener("click", (e) => {
|
||||||
|
if (e.target === addServiceModal) addServiceModal.classList.add("hidden");
|
||||||
});
|
});
|
||||||
|
|
||||||
const removeDelay = animOn ? toastSpeedMs : 0;
|
|
||||||
setTimeout(() => {
|
|
||||||
t.classList.remove("show");
|
|
||||||
t.remove();
|
|
||||||
// clear transition styling
|
|
||||||
others.forEach((el) => (el.style.transition = ""));
|
|
||||||
}, removeDelay);
|
|
||||||
}, duration);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTooltips() {
|
// Testing hook
|
||||||
const tips = {
|
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.",
|
updatesFlagTop: "Auto updates status; configure in Settings → Automatic updates.",
|
||||||
refreshFlagTop: "Auto-refresh interval; change in Settings → Refresh interval.",
|
refreshFlagTop: "Auto-refresh interval; change in Settings → Refresh interval.",
|
||||||
tempFlagTop: "CPU temperature status; see details in the hero stats below.",
|
tempFlagTop: "CPU temperature status; see details in the hero stats below.",
|
||||||
@@ -278,12 +254,7 @@ function applyTooltips() {
|
|||||||
menuSaveBtn: "Save service changes",
|
menuSaveBtn: "Save service changes",
|
||||||
menuCancelBtn: "Cancel changes",
|
menuCancelBtn: "Cancel changes",
|
||||||
menuRemoveBtn: "Remove this service",
|
menuRemoveBtn: "Remove this service",
|
||||||
};
|
};
|
||||||
Object.entries(tips).forEach(([id, text]) => {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (el) el.title = text;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clamp name inputs to 30 chars
|
// Clamp name inputs to 30 chars
|
||||||
[svcName, menuRename].forEach((el) => {
|
[svcName, menuRename].forEach((el) => {
|
||||||
@@ -302,74 +273,7 @@ function setUpdatesUI(enabled) {
|
|||||||
updatesStatus.classList.toggle("chip-off", !on);
|
updatesStatus.classList.toggle("chip-off", !on);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadStatus() {
|
function setUpdatesFlag(enabled) {
|
||||||
try {
|
|
||||||
const data = await getStatus();
|
|
||||||
renderStats(heroStats, data);
|
|
||||||
renderServices(servicesGrid, data.services, { openAddService });
|
|
||||||
const updatesEnabled =
|
|
||||||
data?.auto_updates?.enabled ?? data.auto_updates_enabled;
|
|
||||||
if (updatesEnabled !== undefined && !isUpdatesDirty()) {
|
|
||||||
setUpdatesUI(updatesEnabled);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Updates chip + reboot note
|
|
||||||
updatesFlagEl(
|
|
||||||
updatesEnabled === undefined ? null : updatesEnabled === true,
|
|
||||||
);
|
|
||||||
const cfg = data.updates_config || {};
|
|
||||||
const rebootReq = data.reboot_required;
|
|
||||||
setTempFlag(data.cpu_temp_c);
|
|
||||||
if (updatesNoteTop) {
|
|
||||||
updatesNoteTop.textContent = "";
|
|
||||||
updatesNoteTop.classList.remove("note-warn");
|
|
||||||
if (rebootReq) {
|
|
||||||
if (cfg.auto_reboot) {
|
|
||||||
updatesNoteTop.textContent = `Reboot scheduled at ${cfg.reboot_time || "02:00"}.`;
|
|
||||||
} else {
|
|
||||||
updatesNoteTop.textContent = "Reboot required. Please reboot when you can.";
|
|
||||||
updatesNoteTop.classList.add("note-warn");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (readyOverlay) {
|
|
||||||
if (data.ready) {
|
|
||||||
readyOverlay.classList.add("hidden");
|
|
||||||
} else {
|
|
||||||
readyOverlay.classList.remove("hidden");
|
|
||||||
// When not ready, retry periodically until API reports ready
|
|
||||||
setTimeout(loadStatus, 3000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Pull Pi-Kit release status after core status
|
|
||||||
releaseUI?.refreshStatus();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
renderStats(heroStats, placeholderStatus);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setTempFlag(tempC) {
|
|
||||||
if (!tempFlagTop) return;
|
|
||||||
const t = typeof tempC === "number" ? tempC : null;
|
|
||||||
let label = "Temp: n/a";
|
|
||||||
tempFlagTop.classList.remove("chip-on", "chip-warm", "chip-off");
|
|
||||||
if (t !== null) {
|
|
||||||
if (t < 55) {
|
|
||||||
label = "Temp: OK";
|
|
||||||
tempFlagTop.classList.add("chip-on");
|
|
||||||
} else if (t < 70) {
|
|
||||||
label = "Temp: Warm";
|
|
||||||
tempFlagTop.classList.add("chip-warm");
|
|
||||||
} else {
|
|
||||||
label = "Temp: Hot";
|
|
||||||
tempFlagTop.classList.add("chip-off");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tempFlagTop.textContent = label;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatesFlagEl(enabled) {
|
|
||||||
if (!updatesFlagTop) return;
|
if (!updatesFlagTop) return;
|
||||||
const labelOn = "System updates: On";
|
const labelOn = "System updates: On";
|
||||||
const labelOff = "System updates: Off";
|
const labelOff = "System updates: Off";
|
||||||
@@ -379,61 +283,6 @@ function updatesFlagEl(enabled) {
|
|||||||
if (enabled === false) updatesFlagTop.classList.add("chip-off");
|
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() {
|
function wireResetAndUpdates() {
|
||||||
resetBtn.onclick = async () => {
|
resetBtn.onclick = async () => {
|
||||||
resetBtn.disabled = true;
|
resetBtn.disabled = true;
|
||||||
@@ -452,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() {
|
function openAddService() {
|
||||||
if (addServiceModal) addServiceModal.classList.remove("hidden");
|
if (addServiceModal) addServiceModal.classList.remove("hidden");
|
||||||
document.getElementById("svcName")?.focus();
|
document.getElementById("svcName")?.focus();
|
||||||
@@ -486,24 +310,24 @@ if (typeof window !== "undefined") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
applyTooltips();
|
applyTooltips(TOOLTIP_MAP);
|
||||||
wireModals();
|
// Test convenience: ensure service form elements are visible when hook is set
|
||||||
|
if (window.__pikitTest?.forceServiceFormVisible) {
|
||||||
|
window.__pikitTest.forceServiceFormVisible();
|
||||||
|
window.__pikitTest.exposeServiceForm?.();
|
||||||
|
}
|
||||||
|
wireDialogs();
|
||||||
wireResetAndUpdates();
|
wireResetAndUpdates();
|
||||||
wireAccordions();
|
wireAccordions({
|
||||||
|
forceOpen: typeof window !== "undefined" && window.__pikitTest?.forceAccordionsOpen,
|
||||||
|
});
|
||||||
releaseUI = initReleaseUI({
|
releaseUI = initReleaseUI({
|
||||||
showToast,
|
showToast,
|
||||||
showBusy,
|
showBusy,
|
||||||
hideBusy,
|
hideBusy,
|
||||||
confirmAction,
|
confirmAction,
|
||||||
|
logUi,
|
||||||
});
|
});
|
||||||
loadToastSettings();
|
|
||||||
|
|
||||||
if (advClose) {
|
|
||||||
advClose.onclick = () => {
|
|
||||||
advModal.classList.add("hidden");
|
|
||||||
collapseAccordions();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
initServiceControls({
|
initServiceControls({
|
||||||
gridEl: servicesGrid,
|
gridEl: servicesGrid,
|
||||||
@@ -558,97 +382,25 @@ function main() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Toast controls
|
// Diagnostics
|
||||||
toastPosSelect?.addEventListener("change", () => {
|
initDiagUI({
|
||||||
const val = toastPosSelect.value;
|
elements: {
|
||||||
if (ALLOWED_TOAST_POS.includes(val)) {
|
enableToggle: diagEnableToggle,
|
||||||
toastPosition = val;
|
debugToggle: diagDebugToggle,
|
||||||
applyToastSettings();
|
refreshBtn: diagRefreshBtn,
|
||||||
persistToastSettings();
|
clearBtn: diagClearBtn,
|
||||||
} else {
|
copyBtn: diagCopyBtn,
|
||||||
toastPosSelect.value = toastPosition;
|
downloadBtn: diagDownloadBtn,
|
||||||
showToast("Invalid toast position", "error");
|
logBox: diagLogBox,
|
||||||
}
|
statusEl: diagStatusModal || diagStatus,
|
||||||
|
logButton: diagLogBtn,
|
||||||
|
modal: diagModal,
|
||||||
|
modalClose: diagClose,
|
||||||
|
},
|
||||||
|
toast: showToast,
|
||||||
|
}).catch((e) => {
|
||||||
|
console.error("Diag init failed", e);
|
||||||
});
|
});
|
||||||
toastAnimSelect?.addEventListener("change", () => {
|
|
||||||
let val = toastAnimSelect.value;
|
|
||||||
if (val === "slide-up" || val === "slide-left" || val === "slide-right") val = "slide-in"; // migrate old label if cached
|
|
||||||
if (ALLOWED_TOAST_ANIM.includes(val)) {
|
|
||||||
toastAnimation = val;
|
|
||||||
persistToastSettings();
|
|
||||||
} else {
|
|
||||||
toastAnimSelect.value = toastAnimation;
|
|
||||||
showToast("Invalid toast animation", "error");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const clampSpeed = (val) => Math.min(3000, Math.max(100, val));
|
|
||||||
const clampDuration = (val) => Math.min(15000, Math.max(1000, val));
|
|
||||||
|
|
||||||
toastSpeedInput?.addEventListener("input", () => {
|
|
||||||
const raw = toastSpeedInput.value;
|
|
||||||
if (raw === "") return; // allow typing
|
|
||||||
const val = Number(raw);
|
|
||||||
if (Number.isNaN(val) || val < 100 || val > 3000) return; // wait until valid
|
|
||||||
toastSpeedMs = val;
|
|
||||||
applyToastSettings();
|
|
||||||
persistToastSettings();
|
|
||||||
});
|
|
||||||
toastSpeedInput?.addEventListener("blur", () => {
|
|
||||||
const raw = toastSpeedInput.value;
|
|
||||||
if (raw === "") {
|
|
||||||
toastSpeedInput.value = toastSpeedMs;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const val = Number(raw);
|
|
||||||
if (Number.isNaN(val) || val < 100 || val > 3000) {
|
|
||||||
toastSpeedMs = clampSpeed(toastSpeedMs);
|
|
||||||
toastSpeedInput.value = toastSpeedMs;
|
|
||||||
showToast("Toast speed must be 100-3000 ms", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
toastSpeedMs = val;
|
|
||||||
toastSpeedInput.value = toastSpeedMs;
|
|
||||||
applyToastSettings();
|
|
||||||
persistToastSettings();
|
|
||||||
});
|
|
||||||
|
|
||||||
toastDurationInput?.addEventListener("input", () => {
|
|
||||||
const raw = toastDurationInput.value;
|
|
||||||
if (raw === "") return; // allow typing
|
|
||||||
const val = Number(raw);
|
|
||||||
if (Number.isNaN(val) || val < 1000 || val > 15000) return; // wait until valid
|
|
||||||
toastDurationMs = val;
|
|
||||||
persistToastSettings();
|
|
||||||
});
|
|
||||||
toastDurationInput?.addEventListener("blur", () => {
|
|
||||||
const raw = toastDurationInput.value;
|
|
||||||
if (raw === "") {
|
|
||||||
toastDurationInput.value = toastDurationMs;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const val = Number(raw);
|
|
||||||
if (Number.isNaN(val) || val < 1000 || val > 15000) {
|
|
||||||
toastDurationMs = clampDuration(toastDurationMs);
|
|
||||||
toastDurationInput.value = toastDurationMs;
|
|
||||||
showToast("Toast duration must be 1000-15000 ms", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
toastDurationMs = val;
|
|
||||||
toastDurationInput.value = toastDurationMs;
|
|
||||||
persistToastSettings();
|
|
||||||
});
|
|
||||||
fontSelect?.addEventListener("change", () => {
|
|
||||||
const val = fontSelect.value;
|
|
||||||
if (!ALLOWED_FONTS.includes(val)) {
|
|
||||||
fontSelect.value = fontChoice;
|
|
||||||
showToast("Invalid font choice", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fontChoice = val;
|
|
||||||
applyFontSetting();
|
|
||||||
persistToastSettings();
|
|
||||||
});
|
|
||||||
toastTestBtn?.addEventListener("click", () => showToast("This is a test toast", "info"));
|
|
||||||
|
|
||||||
initUpdateSettings({
|
initUpdateSettings({
|
||||||
elements: {
|
elements: {
|
||||||
|
|||||||
38
pikit-web/assets/releases-utils.js
Normal file
38
pikit-web/assets/releases-utils.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
export function shorten(text, max = 90) {
|
||||||
|
if (!text || typeof text !== "string") return text;
|
||||||
|
return text.length > max ? `${text.slice(0, max - 3)}...` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createReleaseLogger(logUi = () => {}) {
|
||||||
|
let lines = [];
|
||||||
|
let lastMessage = null;
|
||||||
|
const state = { el: null };
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (state.el) {
|
||||||
|
state.el.textContent = lines.join("\n");
|
||||||
|
state.el.scrollTop = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(msg) {
|
||||||
|
if (!msg) return;
|
||||||
|
const plain = msg.trim();
|
||||||
|
if (plain === lastMessage) return;
|
||||||
|
lastMessage = plain;
|
||||||
|
const ts = new Date().toLocaleTimeString();
|
||||||
|
const line = `${ts} ${msg}`;
|
||||||
|
lines.unshift(line);
|
||||||
|
lines = lines.slice(0, 120);
|
||||||
|
render();
|
||||||
|
const lvl = msg.toLowerCase().startsWith("error") ? "error" : "info";
|
||||||
|
logUi(`Update: ${msg}`, lvl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function attach(el) {
|
||||||
|
state.el = el;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { log, attach, getLines: () => lines.slice() };
|
||||||
|
}
|
||||||
@@ -9,13 +9,9 @@ import {
|
|||||||
setReleaseAutoCheck,
|
setReleaseAutoCheck,
|
||||||
setReleaseChannel,
|
setReleaseChannel,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
|
import { shorten, createReleaseLogger } from "./releases-utils.js";
|
||||||
|
|
||||||
function shorten(text, max = 90) {
|
export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction, logUi = () => {} }) {
|
||||||
if (!text || typeof text !== "string") return text;
|
|
||||||
return text.length > max ? `${text.slice(0, max - 3)}...` : text;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction }) {
|
|
||||||
const releaseFlagTop = document.getElementById("releaseFlagTop");
|
const releaseFlagTop = document.getElementById("releaseFlagTop");
|
||||||
const releaseBtn = document.getElementById("releaseBtn");
|
const releaseBtn = document.getElementById("releaseBtn");
|
||||||
const releaseModal = document.getElementById("releaseModal");
|
const releaseModal = document.getElementById("releaseModal");
|
||||||
@@ -42,29 +38,14 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction })
|
|||||||
const changelogClose = document.getElementById("changelogClose");
|
const changelogClose = document.getElementById("changelogClose");
|
||||||
|
|
||||||
let releaseBusyActive = false;
|
let releaseBusyActive = false;
|
||||||
let releaseLogLines = [];
|
|
||||||
let releaseLastFetched = 0;
|
let releaseLastFetched = 0;
|
||||||
let lastReleaseLogKey = "";
|
let lastReleaseLogKey = "";
|
||||||
let lastReleaseToastKey = null;
|
let lastReleaseToastKey = null;
|
||||||
let lastLogMessage = null;
|
|
||||||
let changelogCache = { version: null, text: "" };
|
let changelogCache = { version: null, text: "" };
|
||||||
let lastChangelogUrl = null;
|
let lastChangelogUrl = null;
|
||||||
let releaseChannel = "dev";
|
let releaseChannel = "dev";
|
||||||
|
const logger = createReleaseLogger(logUi);
|
||||||
function logRelease(msg) {
|
logger.attach(releaseLog);
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setReleaseChip(state) {
|
function setReleaseChip(state) {
|
||||||
if (!releaseFlagTop) return;
|
if (!releaseFlagTop) return;
|
||||||
@@ -133,6 +114,8 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction })
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const logRelease = logger.log;
|
||||||
|
|
||||||
async function loadReleaseStatus(force = false) {
|
async function loadReleaseStatus(force = false) {
|
||||||
if (!releaseFlagTop) return;
|
if (!releaseFlagTop) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -175,11 +158,16 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction })
|
|||||||
pollReleaseStatus();
|
pollReleaseStatus();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// During an update/rollback the API may restart; retry quietly.
|
||||||
|
if (releaseBusyActive) {
|
||||||
|
setTimeout(() => loadReleaseStatus(true), 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error("Failed to load release status", e);
|
console.error("Failed to load release status", e);
|
||||||
setReleaseChip({ status: "error", message: "Failed to load" });
|
setReleaseChip({ status: "error", message: "Failed to load" });
|
||||||
// surface via toast/log only; avoid inline red flashes
|
// surface via toast/log only once
|
||||||
showToast("Failed to load release status", "error");
|
|
||||||
logRelease("Error: failed to load release status");
|
logRelease("Error: failed to load release status");
|
||||||
|
showToast("Failed to load release status", "error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,6 +224,7 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction })
|
|||||||
releaseCheckBtn?.addEventListener("click", async () => {
|
releaseCheckBtn?.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
logRelease("Checking for updates…");
|
logRelease("Checking for updates…");
|
||||||
|
logUi("Update check requested");
|
||||||
await checkRelease();
|
await checkRelease();
|
||||||
await loadReleaseStatus(true);
|
await loadReleaseStatus(true);
|
||||||
const state = window.__lastReleaseState || {};
|
const state = window.__lastReleaseState || {};
|
||||||
@@ -254,6 +243,7 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction })
|
|||||||
releaseApplyBtn?.addEventListener("click", async () => {
|
releaseApplyBtn?.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
lastReleaseToastKey = null;
|
lastReleaseToastKey = null;
|
||||||
|
logUi("Update apply requested");
|
||||||
const state = window.__lastReleaseState || {};
|
const state = window.__lastReleaseState || {};
|
||||||
const { current_version, latest_version } = state;
|
const { current_version, latest_version } = state;
|
||||||
const sameVersion =
|
const sameVersion =
|
||||||
@@ -287,6 +277,7 @@ export function initReleaseUI({ showToast, showBusy, hideBusy, confirmAction })
|
|||||||
releaseRollbackBtn?.addEventListener("click", async () => {
|
releaseRollbackBtn?.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
lastReleaseToastKey = null;
|
lastReleaseToastKey = null;
|
||||||
|
logUi("Rollback requested");
|
||||||
releaseBusyActive = true;
|
releaseBusyActive = true;
|
||||||
showBusy("Rolling back…", "Restoring previous backup.");
|
showBusy("Rolling back…", "Restoring previous backup.");
|
||||||
logRelease("Starting rollback…");
|
logRelease("Starting rollback…");
|
||||||
|
|||||||
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 { 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.
|
// 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.
|
// All mutations round-trip through the API then invoke onChange to refresh data.
|
||||||
|
|
||||||
let noticeModalRefs = null;
|
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() {
|
function ensureNoticeModal() {
|
||||||
if (noticeModalRefs) return noticeModalRefs;
|
if (noticeModalRefs) return noticeModalRefs;
|
||||||
const modal = document.createElement("div");
|
const modal = document.createElement("div");
|
||||||
@@ -264,6 +232,7 @@ export function initServiceControls({ gridEl, menu, addForm, onChange, overlay,
|
|||||||
async function menuAction(action, body = {}) {
|
async function menuAction(action, body = {}) {
|
||||||
if (!menuContext) return;
|
if (!menuContext) return;
|
||||||
msg.textContent = "";
|
msg.textContent = "";
|
||||||
|
const original = { ...menuContext };
|
||||||
try {
|
try {
|
||||||
const isRemove = action === "remove";
|
const isRemove = action === "remove";
|
||||||
const isSave = action === "save";
|
const isSave = action === "save";
|
||||||
@@ -285,6 +254,17 @@ export function initServiceControls({ gridEl, menu, addForm, onChange, overlay,
|
|||||||
}
|
}
|
||||||
msg.textContent = "";
|
msg.textContent = "";
|
||||||
toast?.(isRemove ? "Service removed" : "Service saved", "success");
|
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");
|
modal?.classList.add("hidden");
|
||||||
menuContext = null;
|
menuContext = null;
|
||||||
await onChange?.();
|
await onChange?.();
|
||||||
@@ -292,6 +272,12 @@ export function initServiceControls({ gridEl, menu, addForm, onChange, overlay,
|
|||||||
const err = e.error || "Action failed.";
|
const err = e.error || "Action failed.";
|
||||||
msg.textContent = "";
|
msg.textContent = "";
|
||||||
toast?.(err, "error");
|
toast?.(err, "error");
|
||||||
|
logUi("Service update failed", "error", {
|
||||||
|
action,
|
||||||
|
name: body.name || original.name,
|
||||||
|
port: original.port,
|
||||||
|
reason: err,
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
hideBusy();
|
hideBusy();
|
||||||
}
|
}
|
||||||
@@ -310,8 +296,7 @@ export function initServiceControls({ gridEl, menu, addForm, onChange, overlay,
|
|||||||
if (
|
if (
|
||||||
!validateServiceFields(
|
!validateServiceFields(
|
||||||
{ name, port: new_port, path, notice, notice_link },
|
{ name, port: new_port, path, notice, notice_link },
|
||||||
() => {},
|
(m) => toast?.(m, "error"),
|
||||||
toast,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
@@ -335,11 +320,7 @@ export function initServiceControls({ gridEl, menu, addForm, onChange, overlay,
|
|||||||
const notice_link = (addNoticeLinkInput?.value || "").trim();
|
const notice_link = (addNoticeLinkInput?.value || "").trim();
|
||||||
const self_signed = !!addSelfSignedInput?.checked;
|
const self_signed = !!addSelfSignedInput?.checked;
|
||||||
if (
|
if (
|
||||||
!validateServiceFields(
|
!validateServiceFields({ name, port, path, notice, notice_link }, (m) => toast?.(m, "error"))
|
||||||
{ name, port, path, notice, notice_link },
|
|
||||||
() => {},
|
|
||||||
toast,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
addBtn.disabled = true;
|
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 });
|
await addService({ name, port, scheme, path, notice, notice_link, self_signed });
|
||||||
addMsg.textContent = "";
|
addMsg.textContent = "";
|
||||||
toast?.("Service added", "success");
|
toast?.("Service added", "success");
|
||||||
|
logUi("Service added", "info", {
|
||||||
|
name,
|
||||||
|
port,
|
||||||
|
scheme,
|
||||||
|
path,
|
||||||
|
notice: !!notice,
|
||||||
|
notice_link: !!notice_link,
|
||||||
|
self_signed,
|
||||||
|
});
|
||||||
await onChange?.();
|
await onChange?.();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e.error || "Failed to add.";
|
const err = e.error || "Failed to add.";
|
||||||
addMsg.textContent = "";
|
addMsg.textContent = "";
|
||||||
toast?.(err, "error");
|
toast?.(err, "error");
|
||||||
|
logUi("Service add failed", "error", { name, port, scheme, reason: err });
|
||||||
} finally {
|
} finally {
|
||||||
addBtn.disabled = false;
|
addBtn.disabled = false;
|
||||||
hideBusy();
|
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.
|
// UI controller for unattended-upgrades settings.
|
||||||
// Fetches current config, mirrors it into the form, and saves changes.
|
// Fetches current config, mirrors it into the form, and saves changes.
|
||||||
import { getUpdateConfig, saveUpdateConfig } from "./api.js";
|
import { getUpdateConfig, saveUpdateConfig } from "./api.js";
|
||||||
|
import { logUi } from "./diaglog.js";
|
||||||
|
|
||||||
const TIME_RE = /^(\d{1,2}):(\d{2})$/;
|
const TIME_RE = /^(\d{1,2}):(\d{2})$/;
|
||||||
const MAX_BANDWIDTH_KBPS = 1_000_000; // 1 GB/s cap to prevent typos
|
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) {
|
function showMessage(text, isError = false) {
|
||||||
if (!msgEl) return;
|
if (!msgEl) return;
|
||||||
// Only surface inline text for errors; successes go to toast only.
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
msgEl.textContent = text || "Something went wrong";
|
msgEl.textContent = text || "Something went wrong";
|
||||||
msgEl.classList.add("error");
|
msgEl.classList.add("error");
|
||||||
|
toast?.(text || "Error", "error");
|
||||||
} else {
|
} else {
|
||||||
msgEl.textContent = "";
|
msgEl.textContent = text || "";
|
||||||
msgEl.classList.remove("error");
|
msgEl.classList.remove("error");
|
||||||
}
|
}
|
||||||
if (toast) toast(text || (isError ? "Error" : ""), isError ? "error" : "success");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentConfigFromForm() {
|
function currentConfigFromForm() {
|
||||||
@@ -246,6 +246,7 @@ export function initUpdateSettings({
|
|||||||
showMessage("");
|
showMessage("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const prev = lastConfig ? { ...lastConfig } : null;
|
||||||
const payload = buildPayload();
|
const payload = buildPayload();
|
||||||
|
|
||||||
if (overrideEnable !== null) payload.enable = !!overrideEnable;
|
if (overrideEnable !== null) payload.enable = !!overrideEnable;
|
||||||
@@ -257,6 +258,7 @@ export function initUpdateSettings({
|
|||||||
|
|
||||||
showMessage("Update settings saved.");
|
showMessage("Update settings saved.");
|
||||||
toast?.("Updates saved", "success");
|
toast?.("Updates saved", "success");
|
||||||
|
logUi("Update settings saved", "info", { from: prev, to: payload });
|
||||||
|
|
||||||
onAfterSave?.();
|
onAfterSave?.();
|
||||||
|
|
||||||
@@ -273,6 +275,16 @@ export function initUpdateSettings({
|
|||||||
|
|
||||||
}
|
}
|
||||||
showMessage(e?.error || e?.message || "Save failed", true);
|
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 {
|
} finally {
|
||||||
saving = false;
|
saving = false;
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"version": "0.1.0-dev"
|
"version": "0.1.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Pi-Kit Dashboard</title>
|
<title>Pi-Kit Dashboard</title>
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<link rel="stylesheet" href="assets/style.css" />
|
<link rel="stylesheet" href="assets/style.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -33,6 +34,9 @@
|
|||||||
>
|
>
|
||||||
<span id="themeToggleIcon" aria-hidden="true">🌙</span>
|
<span id="themeToggleIcon" aria-hidden="true">🌙</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button id="diagLogBtn" class="ghost hidden" title="Diagnostics log (visible when enabled)">
|
||||||
|
Log
|
||||||
|
</button>
|
||||||
<button id="releaseBtn" class="ghost" title="Pi-Kit updates">
|
<button id="releaseBtn" class="ghost" title="Pi-Kit updates">
|
||||||
Update
|
Update
|
||||||
</button>
|
</button>
|
||||||
@@ -102,6 +106,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="diagModal" class="modal hidden diag-log-modal">
|
||||||
|
<div class="modal-card wide">
|
||||||
|
<div class="panel-header sticky">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Diagnostics</p>
|
||||||
|
<h3>Diagnostics log</h3>
|
||||||
|
<p class="hint">RAM-only; cleared on reboot/clear. Use toggles in Settings → Diagnostics to enable.</p>
|
||||||
|
</div>
|
||||||
|
<button id="diagClose" class="ghost icon-btn close-btn" title="Close diagnostics log">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="control-actions wrap gap">
|
||||||
|
<button id="diagRefreshBtn" class="ghost" title="Refresh diagnostics log">Refresh</button>
|
||||||
|
<button id="diagClearBtn" class="ghost" title="Clear diagnostics log">Clear</button>
|
||||||
|
<button id="diagCopyBtn" class="ghost" title="Copy diagnostics to clipboard">Copy</button>
|
||||||
|
<button id="diagDownloadBtn" class="ghost" title="Download diagnostics as text">Download</button>
|
||||||
|
<span id="diagStatusModal" class="hint quiet"></span>
|
||||||
|
</div>
|
||||||
|
<pre id="diagLogBox" class="log-box" aria-live="polite"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="releaseModal" class="modal hidden">
|
<div id="releaseModal" class="modal hidden">
|
||||||
<div class="modal-card wide">
|
<div class="modal-card wide">
|
||||||
<div class="panel-header sticky">
|
<div class="panel-header sticky">
|
||||||
@@ -250,6 +277,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
id="svcName"
|
id="svcName"
|
||||||
placeholder="Service name"
|
placeholder="Service name"
|
||||||
|
title="Service name"
|
||||||
maxlength="32"
|
maxlength="32"
|
||||||
/>
|
/>
|
||||||
<p class="hint quiet">Service name: max 32 characters.</p>
|
<p class="hint quiet">Service name: max 32 characters.</p>
|
||||||
@@ -259,11 +287,13 @@
|
|||||||
placeholder="Port (e.g. 8080)"
|
placeholder="Port (e.g. 8080)"
|
||||||
min="1"
|
min="1"
|
||||||
max="65535"
|
max="65535"
|
||||||
|
title="Service port"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="svcPath"
|
id="svcPath"
|
||||||
placeholder="Optional path (e.g. /admin)"
|
placeholder="Optional path (e.g. /admin)"
|
||||||
|
title="Optional path (e.g. /admin)"
|
||||||
/>
|
/>
|
||||||
<div class="control-row split">
|
<div class="control-row split">
|
||||||
<label class="checkbox-row">
|
<label class="checkbox-row">
|
||||||
@@ -282,11 +312,13 @@
|
|||||||
id="svcNotice"
|
id="svcNotice"
|
||||||
rows="3"
|
rows="3"
|
||||||
placeholder="Optional notice (shown on card)"
|
placeholder="Optional notice (shown on card)"
|
||||||
|
title="Optional notice shown on the service card"
|
||||||
></textarea>
|
></textarea>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="svcNoticeLink"
|
id="svcNoticeLink"
|
||||||
placeholder="Optional link for more info"
|
placeholder="Optional link for more info"
|
||||||
|
title="Optional link for more info"
|
||||||
/>
|
/>
|
||||||
<div class="control-actions">
|
<div class="control-actions">
|
||||||
<button id="svcAddBtn" title="Add service and open port on LAN">
|
<button id="svcAddBtn" title="Add service and open port on LAN">
|
||||||
@@ -522,6 +554,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="accordion">
|
||||||
|
<button class="accordion-toggle" data-target="acc-diag">
|
||||||
|
Diagnostics
|
||||||
|
</button>
|
||||||
|
<div class="accordion-body" id="acc-diag">
|
||||||
|
<p class="hint">
|
||||||
|
Temporary, RAM-only logs for debugging. Toggle on, choose debug for extra detail. Logs reset on reboot or clear. Use the Log button in the top bar (visible when diagnostics is enabled) to view, copy, download, or clear entries.
|
||||||
|
</p>
|
||||||
|
<div class="control-actions split-row">
|
||||||
|
<label class="checkbox-row inline tight">
|
||||||
|
<input type="checkbox" id="diagEnableToggle" />
|
||||||
|
<span>Enable diagnostics</span>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-row inline tight">
|
||||||
|
<input type="checkbox" id="diagDebugToggle" />
|
||||||
|
<span>Debug detail (includes UI clicks)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="control-actions">
|
||||||
|
<span id="diagStatus" class="hint quiet"></span>
|
||||||
|
</div>
|
||||||
|
<p class="hint quiet">Stored in RAM (max ~1MB server, ~500 entries client). No data written to disk.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="accordion">
|
<div class="accordion">
|
||||||
<button class="accordion-toggle danger-btn" data-target="acc-reset">
|
<button class="accordion-toggle danger-btn" data-target="acc-reset">
|
||||||
Factory reset
|
Factory reset
|
||||||
@@ -726,7 +783,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module" src="assets/main.js?v=20251213g"></script>
|
<script type="module" src="assets/main.js?v=20251213j"></script>
|
||||||
<div id="toastContainer" class="toast-container"></div>
|
<div id="toastContainer" class="toast-container"></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
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, rollback_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()
|
||||||
66
pikit_api/constants.py
Normal file
66
pikit_api/constants.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
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",
|
||||||
|
"https://git.44r0n.cc/44r0n7/pi-kit/releases/latest/download/manifest.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
|
||||||
298
pikit_api/http_handlers.py
Normal file
298
pikit_api/http_handlers.py
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
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,
|
||||||
|
)
|
||||||
|
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})
|
||||||
|
|
||||||
|
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"):
|
||||||
|
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/rollback"):
|
||||||
|
start_background_task("rollback")
|
||||||
|
state = load_update_state()
|
||||||
|
state["status"] = "in_progress"
|
||||||
|
state["message"] = "Starting rollback"
|
||||||
|
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"})
|
||||||
529
pikit_api/releases.py
Normal file
529
pikit_api/releases.py
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
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,
|
||||||
|
BACKUP_ROOT,
|
||||||
|
DEFAULT_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)
|
||||||
|
if UPDATE_STATE.exists():
|
||||||
|
try:
|
||||||
|
return json.loads(UPDATE_STATE.read_text())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {
|
||||||
|
"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"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 e.code == 404:
|
||||||
|
alt = _gitea_latest_manifest(target)
|
||||||
|
if alt:
|
||||||
|
return alt
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_manifest_for_channel(channel: str):
|
||||||
|
"""
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
channel = channel or "dev"
|
||||||
|
base_manifest_url = os.environ.get("PIKIT_MANIFEST_URL") or DEFAULT_MANIFEST_URL
|
||||||
|
manifest = None
|
||||||
|
try:
|
||||||
|
manifest = fetch_manifest(base_manifest_url)
|
||||||
|
except Exception:
|
||||||
|
manifest = None
|
||||||
|
|
||||||
|
if manifest and channel == "stable":
|
||||||
|
return manifest
|
||||||
|
if manifest:
|
||||||
|
version = manifest.get("version") or manifest.get("latest_version")
|
||||||
|
if channel == "dev" and version and "dev" in str(version):
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
try:
|
||||||
|
parts = base_manifest_url.split("/")
|
||||||
|
if "releases" not in parts:
|
||||||
|
if manifest:
|
||||||
|
return manifest
|
||||||
|
return fetch_manifest(base_manifest_url)
|
||||||
|
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())
|
||||||
|
|
||||||
|
def pick(predicate):
|
||||||
|
for r in releases:
|
||||||
|
if predicate(r):
|
||||||
|
asset = next((a for a in r.get("assets", []) if a.get("name") == "manifest.json"), None)
|
||||||
|
if asset and asset.get("browser_download_url"):
|
||||||
|
return fetch_manifest(asset["browser_download_url"])
|
||||||
|
return None
|
||||||
|
|
||||||
|
if channel == "dev":
|
||||||
|
m = pick(lambda r: r.get("prerelease") is True)
|
||||||
|
if m:
|
||||||
|
return m
|
||||||
|
m = pick(lambda r: r.get("prerelease") is False)
|
||||||
|
if m:
|
||||||
|
return m
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if manifest:
|
||||||
|
return manifest
|
||||||
|
raise RuntimeError("No manifest found for channel")
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
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 list_backups():
|
||||||
|
"""Return backups sorted by mtime (newest first)."""
|
||||||
|
ensure_dir(BACKUP_ROOT)
|
||||||
|
backups = [p for p in BACKUP_ROOT.iterdir() if p.is_dir()]
|
||||||
|
backups.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||||
|
return backups
|
||||||
|
|
||||||
|
|
||||||
|
def get_backup_version(path: pathlib.Path):
|
||||||
|
vf = path / "version.txt"
|
||||||
|
if not vf.exists():
|
||||||
|
web_version = path / "pikit-web" / "data" / "version.json"
|
||||||
|
if not web_version.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(web_version.read_text()).get("version")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return vf.read_text().strip()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def choose_rollback_backup():
|
||||||
|
"""
|
||||||
|
Pick the most recent backup whose version differs from the currently
|
||||||
|
installed version. If none differ, fall back to the newest backup.
|
||||||
|
"""
|
||||||
|
backups = list_backups()
|
||||||
|
if not backups:
|
||||||
|
return None
|
||||||
|
current = read_current_version()
|
||||||
|
for b in backups:
|
||||||
|
ver = get_backup_version(b)
|
||||||
|
if ver and ver != current:
|
||||||
|
return b
|
||||||
|
return backups[0]
|
||||||
|
|
||||||
|
|
||||||
|
def restore_backup(target: pathlib.Path):
|
||||||
|
if (target / "pikit-web").exists():
|
||||||
|
shutil.rmtree(WEB_ROOT, ignore_errors=True)
|
||||||
|
shutil.copytree(target / "pikit-web", WEB_ROOT, dirs_exist_ok=True)
|
||||||
|
if (target / "pikit-api.py").exists():
|
||||||
|
shutil.copy2(target / "pikit-api.py", API_PATH)
|
||||||
|
os.chmod(API_PATH, 0o755)
|
||||||
|
if (target / "pikit_api").exists():
|
||||||
|
shutil.rmtree(API_PACKAGE_DIR, ignore_errors=True)
|
||||||
|
shutil.copytree(target / "pikit_api", API_PACKAGE_DIR, dirs_exist_ok=True)
|
||||||
|
VERSION_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if (target / "version.txt").exists():
|
||||||
|
shutil.copy2(target / "version.txt", VERSION_FILE)
|
||||||
|
else:
|
||||||
|
ver = get_backup_version(target)
|
||||||
|
if ver:
|
||||||
|
VERSION_FILE.write_text(str(ver))
|
||||||
|
for svc in ("pikit-api.service", "dietpi-dashboard-frontend.service"):
|
||||||
|
subprocess.run(["systemctl", "restart", svc], check=False)
|
||||||
|
|
||||||
|
|
||||||
|
def prune_backups(keep: int = 2):
|
||||||
|
if keep < 1:
|
||||||
|
keep = 1
|
||||||
|
backups = list_backups()
|
||||||
|
for old in backups[keep:]:
|
||||||
|
shutil.rmtree(old, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
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 = fetch_manifest_for_channel(state.get("channel") or "dev")
|
||||||
|
latest = manifest.get("version") or manifest.get("latest_version")
|
||||||
|
state["latest_version"] = latest
|
||||||
|
state["last_check"] = datetime.datetime.utcnow().isoformat() + "Z"
|
||||||
|
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("changelog", "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"
|
||||||
|
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 _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 = fetch_manifest_for_channel(channel)
|
||||||
|
latest = manifest.get("version") or manifest.get("latest_version")
|
||||||
|
if not latest:
|
||||||
|
raise RuntimeError("Manifest missing version")
|
||||||
|
|
||||||
|
backup_dir = _stage_backup()
|
||||||
|
prune_backups(keep=1)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
for svc in ("pikit-api.service", "dietpi-dashboard-frontend.service"):
|
||||||
|
subprocess.run(["systemctl", "restart", svc], 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["status"] = "up_to_date"
|
||||||
|
state["message"] = "Update installed"
|
||||||
|
state["progress"] = None
|
||||||
|
save_update_state(state)
|
||||||
|
diag_log("info", "Update applied", {"version": str(latest)})
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
state["status"] = "error"
|
||||||
|
state["message"] = f"No release available ({e.code})"
|
||||||
|
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
|
||||||
|
save_update_state(state)
|
||||||
|
diag_log("error", "Update apply failed", {"error": str(e)})
|
||||||
|
backup = choose_rollback_backup()
|
||||||
|
if backup:
|
||||||
|
try:
|
||||||
|
restore_backup(backup)
|
||||||
|
state["current_version"] = read_current_version()
|
||||||
|
state["message"] += f" (rolled back to backup {backup.name})"
|
||||||
|
save_update_state(state)
|
||||||
|
diag_log("info", "Rollback after failed update", {"backup": backup.name})
|
||||||
|
except Exception as re:
|
||||||
|
state["message"] += f" (rollback failed: {re})"
|
||||||
|
save_update_state(state)
|
||||||
|
diag_log("error", "Rollback after failed update failed", {"error": str(re)})
|
||||||
|
finally:
|
||||||
|
state["in_progress"] = False
|
||||||
|
state["progress"] = None
|
||||||
|
save_update_state(state)
|
||||||
|
if lock:
|
||||||
|
release_lock(lock)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def rollback_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
|
||||||
|
state["in_progress"] = True
|
||||||
|
state["status"] = "in_progress"
|
||||||
|
state["progress"] = "Rolling back…"
|
||||||
|
save_update_state(state)
|
||||||
|
diag_log("info", "Rollback started")
|
||||||
|
backup = choose_rollback_backup()
|
||||||
|
if not backup:
|
||||||
|
state["status"] = "error"
|
||||||
|
state["message"] = "No backup available to rollback."
|
||||||
|
state["in_progress"] = False
|
||||||
|
state["progress"] = None
|
||||||
|
save_update_state(state)
|
||||||
|
release_lock(lock)
|
||||||
|
return state
|
||||||
|
try:
|
||||||
|
restore_backup(backup)
|
||||||
|
state["status"] = "up_to_date"
|
||||||
|
state["current_version"] = read_current_version()
|
||||||
|
state["latest_version"] = state.get("latest_version") or state["current_version"]
|
||||||
|
ver = get_backup_version(backup)
|
||||||
|
suffix = f" (version {ver})" if ver else ""
|
||||||
|
state["message"] = f"Rolled back to backup {backup.name}{suffix}"
|
||||||
|
diag_log("info", "Rollback complete", {"backup": backup.name, "version": ver})
|
||||||
|
except Exception as e:
|
||||||
|
state["status"] = "error"
|
||||||
|
state["message"] = f"Rollback failed: {e}"
|
||||||
|
diag_log("error", "Rollback failed", {"error": str(e)})
|
||||||
|
state["in_progress"] = False
|
||||||
|
state["progress"] = None
|
||||||
|
save_update_state(state)
|
||||||
|
release_lock(lock)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def start_background_task(mode: str):
|
||||||
|
"""
|
||||||
|
Kick off a background update/rollback via systemd-run so nginx/API restarts
|
||||||
|
do not break the caller connection.
|
||||||
|
mode: "apply" or "rollback"
|
||||||
|
"""
|
||||||
|
assert mode in ("apply", "rollback"), "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
|
||||||
|
rollback_update_stub = rollback_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
|
## What’s inside the bundle
|
||||||
- `pikit-web/` (built static assets)
|
- `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)
|
- optional helper scripts (e.g., `set_ready.sh`, `start-codex.sh`, `pikit-services.json` if present)
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|||||||
@@ -34,9 +34,8 @@ rsync -a --delete \
|
|||||||
"$ROOT/pikit-web/" "$STAGE/pikit-web/"
|
"$ROOT/pikit-web/" "$STAGE/pikit-web/"
|
||||||
|
|
||||||
cp "$ROOT/pikit-api.py" "$STAGE/"
|
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/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
|
# Include version marker
|
||||||
if [[ -f "$ROOT/pikit-web/data/version.json" ]]; then
|
if [[ -f "$ROOT/pikit-web/data/version.json" ]]; then
|
||||||
|
|||||||
Reference in New Issue
Block a user