Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99085adc76 | |||
| 06b077db40 | |||
| ba12a418ff | |||
| 2554696cfa |
@@ -1,6 +1,7 @@
|
||||
|
||||
# Python (if src/ or pyinstaller is ever used)
|
||||
__pycache__/
|
||||
dashboard/__pycache__/
|
||||
*.pyc
|
||||
dist/
|
||||
build/
|
||||
@@ -30,3 +31,6 @@ session
|
||||
# Scratch/plan notes — never committed
|
||||
TEMP_PLAN.md
|
||||
dream.md
|
||||
|
||||
# opencode local config (project-specific agents)
|
||||
.opencode/
|
||||
|
||||
@@ -16,11 +16,16 @@ CRITICAL: real guidance lives in DOC/. When you encounter a reference below, use
|
||||
|
||||
- **Tool model:** `bin/pos-<category>-<command>`, or **category-less** `bin/pos-<cmd>` for dispatcher/dev-level commands that fit no category (`pos-config`, `pos-tree`) — they dispatch like any tool and show with an empty category in the generated tables. `bin/pos` dispatches by longest-prefix arg matching. New tools are auto-discovered but must be executable (`100755`) and carry a `# POS: <cat> <cmd> — <desc>` header right after the shebang; `make gen` only uses the text after the first `— ` (the leading words are convention-only), so keep the one-line description concise. `# POS_FLAGS:` / `# POS_SUBCMDS:` / `# POS_CONFIG:` headers feed tab-completion and the `pos config` scope registry. A missing `# POS:` header hard-fails `make gen`. Legacy `bin/wr-*`, `mp3`, `mp4`, `vbox`, `ssh-load-all` are thin forwarders to `pos` — keep them that way.
|
||||
- **Categories:** `ai`, `communication`, `docker`, `entertainment`, `media`, `network`, `share` (usb, nfs, smb), `ssh`, `system`, plus category-less `config`/`tree`. `pos tree` (bin/pos-tree) is the authoritative structure — it derives the hierarchy from `bin/pos-*` filenames + `# POS:`/`# POS_SUBCMDS:` headers.
|
||||
- **Generated code:** blocks between `GEN:START`/`GEN:END` markers in `DOC/AGENT_Context_Project.md` (tree, dispatch, selfcontained, filetable, docmap) and `completions/pos.bash` (flags, subcmds, config scopes) are `make gen` output — never hand-edit them. Generators must be **byte-order deterministic** (sort with `LC_ALL=C`, as `scripts/gen-docs.sh` does) or CI's `git diff --exit-code` trips on a locale that collates differently. After touching `bin/pos-*`, run `make gen`, then `make check`, then `make lint` (definition of done: check green + lint ends `0 FAIL, 0 WARN`). `make check` (`scripts/check-sync.sh`) is the self-consistency gate — bash -n + exec-bit check + doc-sync + dispatch smoke; `make lint` (`scripts/lint-conventions.sh`) is the convention gate — it enforces every rule in this file (shebang/strict-mode, exec bits, `# POS:` headers, `-h|--help` present and after deps guards, stdin-readers in `INTERACTIVE_CMDS`, POS.md coverage, plugin/app/unit/wrapper/secrets/env-seam classes — see `DOC/DEV.md → Convention Lint Gate`). Hand-maintained, not gen-checked: `DOC/POS.md`, the line-count rows above the filetable marker in `DOC/AGENT_Context_Project.md` (the non-`pos-*` files — `install.sh`, `preinstall.sh`, `postinstall.sh`, `lib/*`, `features/*`; bump a row's count only when that file's length changes), `bin/pos` usage() EXAMPLES, root README. CI: `.gitea/workflows/lint.yml` runs `make gen` + `git diff --exit-code` + `make check` + `make lint` on every push/PR via the **live** Gitea act_runner (`linux-post-install`, registered on the Gitea host) — a red run means gen drift or a gate failure and is a merge-blocker; still run the gates locally too (lint isn't in the pre-commit hook).
|
||||
- **Generated code:** blocks between `GEN:START`/`GEN:END` markers in `DOC/AGENT_Context_Project.md` (tree, dispatch, selfcontained, filetable, docmap) and `completions/pos.bash` (flags, subcmds, config scopes) are `make gen` output — never hand-edit them. Generators must be **byte-order deterministic** (sort with `LC_ALL=C`, as `scripts/gen-docs.sh` does) or CI's `git diff --exit-code` trips on a locale that collates differently.
|
||||
- **Verification gate:** after touching `bin/pos-*`, run `make gen` → `make check` → `make lint` (definition of done: check green + lint ends `0 FAIL, 0 WARN`). `make check` (`scripts/check-sync.sh`) is the self-consistency gate — bash -n + exec-bit check + doc-sync + dispatch smoke; `make lint` (`scripts/lint-conventions.sh`) is the convention gate — it enforces every rule in this file (shebang/strict-mode, exec bits, `# POS:` headers, `-h|--help` present and after deps guards, stdin-readers in `INTERACTIVE_CMDS`, POS.md coverage, plugin/app/unit/wrapper/secrets/env-seam classes — see `DOC/DEV.md → Convention Lint Gate`).
|
||||
- **What's hand-maintained** (not gen-checked): `DOC/POS.md`, the line-count rows above the filetable marker in `DOC/AGENT_Context_Project.md` (the non-`pos-*` files — `install.sh`, `preinstall.sh`, `postinstall.sh`, `lib/*`, `features/*`; bump a row's count only when that file's length changes), `bin/pos` usage() EXAMPLES, root README.
|
||||
- **CI:** `.gitea/workflows/lint.yml` runs `make gen` + `git diff --exit-code` + `make check` + `make lint` on push to `main` and on PRs — a red run means gen drift or a gate failure and is a merge-blocker; still run the gates locally too (lint isn't in the pre-commit hook). Check CI status without SSH: `scripts/ci-status.sh [--wait] [<sha>]` (exit 0/1/2 = green/red/pending).
|
||||
- **Stdin gotcha:** any tool that reads stdin must be added to `INTERACTIVE_CMDS` in `bin/pos` — otherwise the logging `tee` pipe hangs on (or swallows) the prompt.
|
||||
- **Deps:** apt packages → `PACKAGES` array in `preinstall.sh`; non-apt/manual installers (e.g. `usbsrv`) → `command -v <bin> || err "…"` guard inside the tool, never in PACKAGES.
|
||||
- **Secrets:** never commit keys/tokens. `config/authorized_keys` and `config/rclone.conf` are gitignored; runtime tool config is `~/.config/linux_post_install/<tool>.env` (chmod 600, env-var precedence). Mask tokens in `config` output.
|
||||
- **entertainment plugins:** standalone scripts in `entertainment/` that must NOT source `lib/common.sh` — stdout is the message that gets sent to Telegram (helper chatter would leak into it). Markers: `# POS_PLUGIN: <name>` + `# POS_KEYS:` declarations. They aren't `pos-*` tools, so `make gen` skips them (no headers/doc tables) — verify with `bash -n` + a live `pos entertainment send <name> --print`.
|
||||
- **ScaleTail templates** are a git submodule (`compose/scale-tail`), absent on fresh clones — run `git submodule update --init` first (only needed for `pos docker compose *`).
|
||||
- **Testing:** there is **no `tests/` dir** — behavior tests are throwaway stub harnesses built outside the repo (`/tmp/opencode/<tool>-test/`: `stubs/` + `run-tests.sh` with a `check "desc" "expected" "$actual"` helper), run locally, then left in `/tmp`; never commit them. CI runs only the static gates (gen drift/check/lint), never behavior suites. For tools needing root/systemd/absent deps, use env-override seams + a stubbed `PATH` (see DEV.md → "Testing tools that need root / systemd / missing deps").
|
||||
- **Conventions:** `set -euo pipefail`, `-h|--help` via case, idempotent writes, use `run`/`spawn` helpers (respect `$DRY_RUN`), `make hook` installs the opt-in pre-commit gate. `command -v` deps guards sit **before** the `-h|--help` dispatch — help also errors on a box missing the dependency (matches all existing deps-gated tools). Tools must run standalone from `/usr/local/bin` after install (source `lib/common.sh` via the `$(dirname "$0")/../lib/common.sh` fallback chain). Commits use conventional prefixes (`feat:`/`fix:`/`docs:`/`chore:`/`refactor:`).
|
||||
- **Doc authority order** (when docs disagree): templates (`templates/pos-tool.sh`, `feature.sh`, `app.sh`) > `DOC/DEV.md` > this file > code (`# POS:` headers, runtime behavior) > `POS.md`/`HOWTO`/`README`/`SCRIPTS`/etc. > generated blocks in `AGENT_Context_Project.md`.
|
||||
- Maintain `AGENT_TODO.md` (Now / Next / Later / Done): when you finish a task, move it to **Done** (dated) in the same commit.
|
||||
|
||||
@@ -42,6 +42,8 @@ summary (newest last).
|
||||
|
||||
## Done
|
||||
|
||||
- **2026-08-16** — `pos media sync` review follow-up (on ba12a41): (1) **strip ALL trailing slashes** — `SRC="${SRC%/}"` removed only ONE slash, and GNU find preserves a doubled one on the starting point (`find -H /x// -type f` emits `/x//Album/a.mp3`), so `--source /x//` / `MEDIA_SYNC_SOURCE=…//` still hit the original nesting bug (prefix `/x//` never matched); now `while [[ "$SRC" == */ ]]; do SRC="${SRC%/}"; done`, which also collapses a lone `/` or `//` to empty → guard errs instead of mirroring the filesystem root. (2) **partial-sync marker** — the "find reported problems" condition is computed once (`find_ok=1/0` at the warn block) and reused: when set, the final `ok "Sync complete: …"` and `notify_send "Music sync completed: …"` both append ` (partial — find reported problems)` so the success signal can't contradict the warning (dry-run line untouched). (3) **clear empty-source error** — the post-normalization guard now says `Source path is empty` instead of `Source not found: ` with a blank value; the `-d` guard keeps `Source not found: $SRC` for non-empty missing paths. Docs untouched (ba12a41 wording kept). Verified: harness extended (`NOTIFY_LOG` seam: telegram-sender stub appends its argv, run_sync passes `NOTIFY_LOG`; new §7 `--source …//`/`…///` + env `…//` → correct rel placement, no `Music/tmp` nesting; §8 `--source /`, `//`, `''` → exit 1 + "Source path is empty"; §3 now asserts the partial marker on both the ok line and the notify log; §1 negative: no marker on clean runs) — full suite **51/51 green** on the fixed tool, and against a ba12a41 snapshot it fails ONLY the follow-up assertions (double/triple-slash nesting ×5, partial markers ×2, empty-source message ×2; 8b `--source //` excluded from the pre-run because the old code would have mirrored `/`). `make gen && make check` green, `make lint` 0 FAIL / 0 WARN.
|
||||
- **2026-08-16** — `pos media sync` hardening (3 approved fixes): (1) **trailing-slash source** — with `--source /x/` (or `MEDIA_SYNC_SOURCE=…/`) GNU find normalizes the slash on the starting point, so the rel prefix `"${f#"$SRC/"}"` became `/x//` which never matched and every file silently nested under `<stick>/Music//data/Music/...` each run; `SRC` is now `%/`-normalized with a non-empty guard right after option parsing, before the `-d` check (covers flag + env forms). (2) **silent find failure → false "Sync complete"** — both `find -H` invocations ran through process substitution, which hides find's exit code and stderr from `set -e`/`pipefail`: an unreadable subdir made find exit 1 with "Permission denied" yet the tool announced a full success on a partial tree. find now runs ONCE into a temp list + temp stderr (`|| find_rc=$?`, sorted in place with `sort -o`, temp files removed via `trap EXIT`); rc != 0 or non-empty stderr prints a visible `warn` ("results may be incomplete") with the find stderr lines indented (spawn-style) instead of continuing silently; both the space scan and the copy loop read the same captured list, so the double find scan is gone and the file set is identical. (3) **inner symlinks silently skipped** — `find -H` follows only the command-line source symlink, so symlinks inside the tree never synced; an inner-symlink count (`find -H "$SRC" -mindepth 1 -type l`, root symlink excluded) now warns "N symlink(s) inside the source are not followed (find -H) — their targets will not be synced". `find -H` kept (no `-L`); `needs_copy` mtime logic untouched (FAT32 granularity deferred). Docs: howto/media.md symlink paragraph notes inner symlinks are skipped with a count warning; AGENT_Context filetable regenerated (pos-media-sync 164→202). Verified: stub harness `/tmp/opencode/media-sync-fix-test/` (lsblk JSON fixture with rm=true/type=part/mountpoint/TRAN=usb, `y` confirm, `MEDIA_SYNC_SOURCE`/`MEDIA_SYNC_DEST`/`USB_BYID` seams, HOME isolation, telegram-sender stub) 32/32 green — trailing-slash `--source` and env forms land under `<stick>/Music/<rel>` with no absolute-path nesting, unreadable subdir → visible "results may be incomplete" warn + indented find stderr + exit 0 + accessible files still copied, inner symlinks → count warning, symlink root still works with NO inner-symlink warning (23d69b7 regression incl. trailing-slash combo), re-run after success → `0 added, 0 updated, 2 unchanged`; the same suite against the pre-fix script fails exactly those assertions (24/32). `make gen && make check` green, `make lint` 0 FAIL / 0 WARN.
|
||||
- **2026-08-15** — `pos docker stack` (`bin/pos-docker-stack`) — containers grouped by their Docker Compose project. Each stack is a section (project name, sorted) with lines `container-name status ports`; containers with no compose project land in a `Standalone` section at the end; ends with `Stacks: N containers: N standalone: N`. Running only by default, `-a|--all` includes stopped/exited (like `docker ps -a`). Status colored on a terminal (`Up*` green, `Exited*`/`Dead*`/`Created*` red, `Paused*`/`Restarting*` yellow); exit 0 also when no containers. Data via `docker ps` with `--format '{{.Names}}{{"\u001f"}}{{.Label "com.docker.compose.project"}}{{"\u001f"}}{{.Status}}{{"\u001f"}}{{.Ports}}'` (compose v2 sets the project label; `{{"\u001f"}}` escapes in the Go template), parsed with `awk -F'\x1f'` + `IFS=$'\x1f' read` everywhere — tab/pipe delimiters are IFS whitespace or inside values, so `\x1f` (DEV.md:213 gotcha); dash padding via `sed` not `tr` (tr corrupts multi-byte `─`). Deps guard (`docker`) before `--help`; no stdin → not in `INTERACTIVE_CMDS`; `# POS_FLAGS: -a --all`. Docs: POS.md docker row + detail, howto/docker.md table + section, `bin/pos` usage EXAMPLES, AGENT_Context §14 row. Verified: stub-PATH suite `/tmp/opencode/docker-stack-test/run-tests.sh` 23/23 (grouping, sorted stacks, `-a` shows exited, standalone, empty daemon rc=0, colored status, missing docker rc=1, `--help` after deps guard); live runs against the real daemon (affine/audiobookshelf/convertx/gitea stacks, `affine_migration_job Exited (0)` + `lab1 Exited (137)` under `-a`); dispatch via `pos docker stack`; `make gen && make check`, `make lint` 0 FAIL / 0 WARN.
|
||||
- **2026-08-15** — Fix `pos media sync` offering a Ventoy stick's **EFI partition** as the sync target: with the data partition unmounted, the 32 MB `VTOYEFI` ESP was the only mounted USB partition, `usb_detect` offered it with no context, and `cp` died mid-copy with `No space left on device` (live-box report). `usb_detect` now fetches `FSTYPE`/`PARTTYPENAME` and excludes EFI system partitions (Ventoy `VTOYEFI`, `/boot/efi`) from **both** the mounted list and the mount-offer list; `USB_MOUNTED` entries carry `mp|label|size|model|fs` and `usb_pick_root` shows that in the single-stick confirm and the multi-stick/partition picker (`1) /media/Ventoy (1.1T, Ventoy, exfat)`), while `USB_ROOT` stays a bare mountpoint (`${root%|*}`) so `pos system backup` (`${root%/}/backups`) is unaffected. `pos-media-sync` gained a pre-flight space check (measures exactly what `needs_copy` would copy vs `df -Pk`, `err`/`warn` before any copy) — no more mid-copy ENOSPC. Docs: howto/media.md target-picking note, SCRIPTS.md usb-lib paragraph, AGENT_Context hand-maintained lib row (194→205). Verified: new stub harness `/tmp/opencode/vtoyefi-run.sh` (ESP filtered from mounted + mount-offer, multi-pick shows only the data partition, space fit/too-small/dry-run-warn) green; `/tmp/opencode/backup-test` still green; live check `printf 'n\ns\n' | bash bin/pos-media-sync --mp3` no longer offers VTOYEFI (offers unmounted `sda1` Ventoy instead); `make gen && make check`, `make lint` 0 FAIL / 0 WARN.
|
||||
- **2026-08-15** — Fix `pos media sync` reporting success with 0 files when the source is a symlink: it enumerated with plain `find "$SRC"`, and GNU find (default `-P`) does not descend a command-line symlink to a directory — `~/Music -> /mnt/hdd/…/music` therefore yielded zero matches, the loop never ran, and the tool printed `0 added, 0 updated, 0 unchanged` without creating the target dir (live-box report). Switched to `find -H "$SRC"` (follows only command-line symlinks; inner-symlink semantics unchanged). howto/media.md sync section notes symlinked sources are followed. Caught live, not by the 46-case stub suite (which used a real temp dir source — lesson: add a symlink-root fixture). Verified: `printf 'y\n' | bash bin/pos-media-sync --mp3 --dry-run` now lists all 31 mp3s as "would copy"; `make gen && make check` green.
|
||||
|
||||
@@ -10,19 +10,19 @@
|
||||
|
||||
<!-- GEN:START docmap -->
|
||||
| ## 1. Project Overview | 28–43 |
|
||||
| ## 2. Directory Structure | 44–195 |
|
||||
| ## 3. Installation Flow | 196–247 |
|
||||
| ## 4. The `pos` CLI System | 248–320 |
|
||||
| ## 5. Shared Library — `lib/common.sh` | 321–352 |
|
||||
| ## 6. Docker Compose / ScaleTail | 353–395 |
|
||||
| ## 7. Optional Apps (`apps/`) | 396–425 |
|
||||
| ## 8. Entertainment Module | 426–439 |
|
||||
| ## 9. Systemd Services | 440–451 |
|
||||
| ## 10. Configuration Files | 452–478 |
|
||||
| ## 11. Coding Conventions | 479–511 |
|
||||
| ## 12. Development Workflow | 512–564 |
|
||||
| ## 13. Key File Quick Reference | 565–628 |
|
||||
| ## 14. Common Tasks for Agents | 629–661 |
|
||||
| ## 2. Directory Structure | 44–196 |
|
||||
| ## 3. Installation Flow | 197–248 |
|
||||
| ## 4. The `pos` CLI System | 249–322 |
|
||||
| ## 5. Shared Library — `lib/common.sh` | 323–354 |
|
||||
| ## 6. Docker Compose / ScaleTail | 355–397 |
|
||||
| ## 7. Optional Apps (`apps/`) | 398–427 |
|
||||
| ## 8. Entertainment Module | 428–441 |
|
||||
| ## 9. Systemd Services | 442–453 |
|
||||
| ## 10. Configuration Files | 454–480 |
|
||||
| ## 11. Coding Conventions | 481–513 |
|
||||
| ## 12. Development Workflow | 514–566 |
|
||||
| ## 13. Key File Quick Reference | 567–631 |
|
||||
| ## 14. Common Tasks for Agents | 632–664 |
|
||||
<!-- GEN:END docmap -->
|
||||
|
||||
## 1. Project Overview
|
||||
@@ -96,6 +96,7 @@ Linux_post_install/
|
||||
│ ├── pos-system-health # Host health dashboard (disk, RAM, services, backup age, fail2ban, docker); exit 1 if any FAIL
|
||||
│ ├── pos-system-schedule # Scheduled jobs: run a command on a timer; notify on threshold/change/error/always or silently
|
||||
│ ├── pos-config # Interactive editor for the tools' runtime config (reads # POS_CONFIG: registry)
|
||||
│ ├── pos-dashboard # Web dashboard: start/stop/status/config/url
|
||||
│ ├── pos-tree # Show the pos CLI command tree: categories, commands, and subcommands
|
||||
<!-- GEN:END tree -->
|
||||
│ ├── flag-reader # Inspect feature flags (list/status/--raw)
|
||||
@@ -299,6 +300,7 @@ All non-interactive `pos` commands log output to `~/.local/share/linux_post_inst
|
||||
| system | health | `pos-system-health` | Host health dashboard (disk, RAM, services, backup age, fail2ban, docker); exit 1 if any FAIL |
|
||||
| system | schedule | `pos-system-schedule` | Scheduled jobs: run a command on a timer; notify on threshold/change/error/always or silently |
|
||||
| | config | `pos-config` | Interactive editor for the tools' runtime config (reads # POS_CONFIG: registry) |
|
||||
| | dashboard | `pos-dashboard` | Web dashboard: start/stop/status/config/url |
|
||||
| | tree | `pos-tree` | Show the pos CLI command tree: categories, commands, and subcommands |
|
||||
<!-- GEN:END dispatch -->
|
||||
|
||||
@@ -345,7 +347,7 @@ source "$(dirname "$0")/../lib/common.sh"
|
||||
|
||||
**Scripts that do NOT source common.sh** (self-contained):
|
||||
<!-- GEN:START selfcontained -->
|
||||
`pos`, `pos-communication-matrix-listener`, `pos-communication-matrix-sender`, `pos-communication-telegram-listener`, `pos-communication-telegram-sender`, `pos-network-checkport`, `pos-network-hotspot`, `pos-network-ip`, `pos-network-scan`, `pos-ssh-load-keys`, `pos-system-firewall`.
|
||||
`pos`.
|
||||
<!-- GEN:END selfcontained -->
|
||||
|
||||
---
|
||||
@@ -584,43 +586,44 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`
|
||||
| `features/usb-automount.sh` | 138 | USB automount feature (udev rule + flag-gated service) |
|
||||
<!-- GEN:START filetable -->
|
||||
| `bin/pos` | 294 | CLI dispatcher with smart arg matching + logging + category help |
|
||||
| `bin/pos-ai-gemini` | 311 | Chat with Google Gemini (ask, chat, models, sessions) |
|
||||
| `bin/pos-communication-matrix-listener` | 568 | Matrix listener: map /command → bash, run them on room messages |
|
||||
| `bin/pos-communication-matrix-sender` | 224 | Send messages to a Matrix room via the client-server API (send, test, login) |
|
||||
| `bin/pos-communication-scrcpy` | 254 | Mirror/control an Android device via scrcpy+adb (mirror, devices, record, tcpip, connect, push, pull, screenshot, info) |
|
||||
| `bin/pos-communication-telegram-listener` | 566 | Telegram bot listener: map /command → bash, run them on chat messages |
|
||||
| `bin/pos-ai-gemini` | 297 | Chat with Google Gemini (ask, chat, models, sessions) |
|
||||
| `bin/pos-communication-matrix-listener` | 566 | Matrix listener: map /command → bash, run them on room messages |
|
||||
| `bin/pos-communication-matrix-sender` | 223 | Send messages to a Matrix room via the client-server API (send, test, login) |
|
||||
| `bin/pos-communication-scrcpy` | 240 | Mirror/control an Android device via scrcpy+adb (mirror, devices, record, tcpip, connect, push, pull, screenshot, info) |
|
||||
| `bin/pos-communication-telegram-listener` | 564 | Telegram bot listener: map /command → bash, run them on chat messages |
|
||||
| `bin/pos-communication-telegram-sender` | 221 | Send Telegram messages/files/links/stickers via Bot API (send, test) |
|
||||
| `bin/pos-docker-compose` | 366 | Docker Compose service manager (ls/up/down/restart/logs/update/config) |
|
||||
| `bin/pos-docker-health` | 107 | One-glance container health dashboard (exits 1 if unhealthy) |
|
||||
| `bin/pos-docker-compose` | 369 | Docker Compose service manager (ls/up/down/restart/logs/update/config) |
|
||||
| `bin/pos-docker-health` | 108 | One-glance container health dashboard (exits 1 if unhealthy) |
|
||||
| `bin/pos-docker-ps` | 125 | Enhanced container overview (health, IPs, ports, uptime) |
|
||||
| `bin/pos-docker-stack` | 101 | Containers grouped by compose stack (project); standalone group; -a/--all includes stopped |
|
||||
| `bin/pos-docker-vbox` | 158 | Disposable Docker-based VMs (create/enter/start/stop/rm/ls) |
|
||||
| `bin/pos-entertainment-config` | 143 | Show or edit the entertainment config (ENABLED auto-trigger list, weather location) |
|
||||
| `bin/pos-entertainment-disable` | 32 | Disable a plugin's auto-trigger (remove it from ENABLED) |
|
||||
| `bin/pos-entertainment-config` | 145 | Show or edit the entertainment config (ENABLED auto-trigger list, weather location) |
|
||||
| `bin/pos-entertainment-disable` | 36 | Disable a plugin's auto-trigger (remove it from ENABLED) |
|
||||
| `bin/pos-entertainment-enable` | 49 | Enable an auto-trigger for a plugin on a schedule |
|
||||
| `bin/pos-entertainment-send` | 95 | Run a public-API plugin and send its output via the configured notify platforms |
|
||||
| `bin/pos-entertainment-status` | 62 | Show enabled plugins and scheduler state |
|
||||
| `bin/pos-entertainment-status` | 67 | Show enabled plugins and scheduler state |
|
||||
| `bin/pos-media-mp3` | 86 | Download audio as MP3 (yt-dlp) |
|
||||
| `bin/pos-media-mp4` | 132 | Download video as MP4 (smart/interactive format select) |
|
||||
| `bin/pos-media-sync` | 164 | Incremental Music → USB sync (mp3/mp4, add/update only) |
|
||||
| `bin/pos-media-sync` | 213 | Incremental Music → USB sync (mp3/mp4, add/update only) |
|
||||
| `bin/pos-network-checkport` | 496 | Check TCP/UDP port reachability (nmap, or bash/nc fallback) + local interface view |
|
||||
| `bin/pos-network-download` | 951 | aria2 RPC daemon + queue control (add/torrent/metalink, watch, limits) |
|
||||
| `bin/pos-network-download` | 948 | aria2 RPC daemon + queue control (add/torrent/metalink, watch, limits) |
|
||||
| `bin/pos-network-hotspot` | 93 | Wi-Fi hotspot via create_ap + wihotspot-gui |
|
||||
| `bin/pos-network-ip` | 69 | Show interfaces, routes, public IP + location |
|
||||
| `bin/pos-network-scan` | 272 | Parallel ping sweep of CIDR |
|
||||
| `bin/pos-network-ip` | 63 | Show interfaces, routes, public IP + location |
|
||||
| `bin/pos-network-scan` | 268 | Parallel ping sweep of CIDR |
|
||||
| `bin/pos-share-nfs-client` | 138 | Mount NFS shares (ephemeral or persistent systemd mount units) |
|
||||
| `bin/pos-share-nfs-server` | 134 | Manage the NFS kernel server (status, share/unshare exports, enable/disable) |
|
||||
| `bin/pos-share-smb-client` | 217 | Mount SMB/CIFS shares (ephemeral or persistent systemd mount units) |
|
||||
| `bin/pos-share-smb-server` | 253 | Manage the Samba server (status, share/unshare exports, users, enable/disable) |
|
||||
| `bin/pos-share-usb-server` | 218 | USB Redirector server control (--ls, --share; prompts when args omitted) |
|
||||
| `bin/pos-ssh-load-keys` | 31 | Load all SSH keys into the agent |
|
||||
| `bin/pos-system-backup` | 216 | Encrypted (AES-256) folder snapshots (tar + gpg) |
|
||||
| `bin/pos-system-firewall` | 308 | Interactive UFW management |
|
||||
| `bin/pos-ssh-load-keys` | 42 | Load all SSH keys into the agent |
|
||||
| `bin/pos-system-backup` | 225 | Encrypted (AES-256) folder snapshots (tar + gpg) |
|
||||
| `bin/pos-system-firewall` | 304 | Interactive UFW management |
|
||||
| `bin/pos-system-health` | 209 | Host health dashboard (disk, RAM, services, backup age, fail2ban, docker); exit 1 if any FAIL |
|
||||
| `bin/pos-system-schedule` | 81 | Scheduled jobs: run a command on a timer; notify on threshold/change/error/always or silently |
|
||||
| `bin/pos-config` | 80 | Interactive editor for the tools' runtime config (reads # POS_CONFIG: registry) |
|
||||
| `bin/pos-dashboard` | 275 | Web dashboard: start/stop/status/config/url |
|
||||
| `bin/pos-tree` | 112 | Show the pos CLI command tree: categories, commands, and subcommands |
|
||||
| `completions/pos.bash` | 294 | Dynamic bash completion |
|
||||
| `completions/pos.bash` | 306 | Dynamic bash completion |
|
||||
<!-- GEN:END filetable -->
|
||||
| `apps/install.sh` | 171 | App install/uninstall picker/orchestrator |
|
||||
|
||||
|
||||
+1108
File diff suppressed because it is too large
Load Diff
+21
-1
@@ -83,7 +83,7 @@ Precedence: `--model` flag > `AI_GEMINI_MODEL` env > config file > `gemini-2.5-f
|
||||
| Command | File | Purpose | Configuration |
|
||||
|---------|------|---------|---------------|
|
||||
| `pos network ip` | `bin/pos-network-ip` | Show interfaces, default route, public IP + location | None. Public IP via `https://ifconfig.me`; location via `ip-api.com` (5s timeouts) |
|
||||
| `pos network checkport <ip:port>` | `bin/pos-network-checkport` | Check if a TCP port is open | None. Uses `/dev/tcp` with a 2s timeout; exit 0/1 via OPEN/CLOSED |
|
||||
| `pos network checkport <ip:port>` | `bin/pos-network-checkport` | Check TCP/UDP port reachability (nmap engine with bash/nc fallback) + local interface view | None |
|
||||
| `pos network scan <cidr> [--full] [--retries N]` | `bin/pos-network-scan` | Two-phase nmap scan | See below |
|
||||
| `pos network hotspot [cmd]` | `bin/pos-network-hotspot` | Wi-Fi hotspot via `create_ap` (CLI) or `wihotspot-gui` (GUI) | Uses the precompiled binaries from `x64_bin/`; see below |
|
||||
| `pos network download <cmd>` | `bin/pos-network-download` | aria2 RPC daemon + queue control (add/torrent/metalink, watch, limits) | `aria2c`/`jq`/`curl`; daemon = systemd user service; secret in `~/.config/linux_post_install/download.env`; see below |
|
||||
@@ -220,9 +220,29 @@ The standalone `vbox` command still works and forwards to `pos docker vbox` (see
|
||||
| `pos system backup --service` | `bin/pos-system-backup` | Lists folders under `/srv` and `~/srv`, lets you pick one, then runs the same backup | Roots via `BACKUP_SERVICE_ROOTS` (space-separated, default `/srv $HOME/srv`) or `~/.config/linux_post_install/system.env` |
|
||||
| `pos system health` | `bin/pos-system-health` | Host health dashboard: disk per mount, RAM/swap, failed systemd units, backup age, fail2ban, docker containers. Exits 1 if any check FAILs | Console-only reporter — health itself never sends notifications; forward the output with a wrapper (e.g. the Telegram/Matrix listener map `/status=pos system health`) or schedule it via `pos system schedule` with a `NOTIFY` policy. `HEALTH_BACKUP_MAX_AGE_DAYS` (default 2) and `BACKUP_SERVICE_ROOTS` come from `~/.config/linux_post_install/system.env`; `--help` shows the effective values |
|
||||
| `pos system schedule <cmd>` | `bin/pos-system-schedule` | Scheduled jobs — run a command on a timer, notify (or stay silent): `run [name\|all]`, `list`, `config`, `enable [name\|all]`, `disable [name\|all]`, `status`, `migrate`. Each job is a file in `~/.config/linux_post_install/schedule.d/<name>.env` with `INTERVAL` (`5m…59m`, `1h…23h`, `hourly`, `daily`, `weekly`, `OnCalendar=…`), `NOTIFY` policy, optional `MSG`, `RULE` (threshold only), and `COMMAND` = the literal rest of the line (pipes/quotes/`sudo` fine). Policies: `always` (full output every run), `onchange` (send when output differs from the last run; first run always sends), `onerror` (non-zero exit or empty output), `threshold` (first numeric output vs `RULE`, alert on false→true + one recovery — the old event-trigger behavior), `never` (side-effect jobs, no notify) | One systemd **user** timer pair per job (`pos-schedule-<name>.timer` + oneshot `.service`, `Persistent=true`), reconciled on `enable`/`disable`; the legacy single `pos-event-trigger` timer is auto-removed. `migrate` converts a pre-existing `event.env` rule set into `schedule.d/rule-N.env` threshold jobs. `config` is an interactive editor (add/edit/remove/enable/disable, validates interval + threshold); alerts via `lib/notify.sh`; `--dry-run` previews runs/writes/sends; jobs are arbitrary shell commands (chmod 600, same trust model as the Telegram map); starter jobs in `config/schedule.d/` auto-installed no-clobber by postinstall |
|
||||
| `pos dashboard <cmd>` | `bin/pos-dashboard` | Web dashboard: start/stop/status/config/url — browser-based UI for managing entertainment, Telegram, Docker, and system health | `~/.config/linux_post_install/dashboard.env` (`DASHBOARD_PORT`, `DASHBOARD_HOST`, chmod 600). systemd user service (`pos-dashboard.service`) runs `python3 /usr/local/share/linux_post_install/dashboard/app.py` |
|
||||
|
||||
A scheduled job is the recommended way to run the health dashboard on a timer: a `daily` job with `COMMAND=pos system health` and `NOTIFY=always` sends the dashboard output as the alert — no separate systemd unit needed (the old `pos-health.{service,timer}` units are gone; a legacy install may still have them failed/leftover — disable and remove them).
|
||||
|
||||
`pos dashboard` in detail:
|
||||
|
||||
| Command | Behavior |
|
||||
|---------|----------|
|
||||
| `pos dashboard start` | Installs a systemd **user** service (`pos-dashboard.service`) and enables+starts it. Requires `python3-flask` (in `preinstall.sh` PACKAGES) and the dashboard app at `/usr/local/share/linux_post_install/dashboard/app.py`. Creates the config file from the template if missing. Prints the dashboard URL. Warns if linger is off |
|
||||
| `pos dashboard stop` | Stops, disables, and removes the service unit |
|
||||
| `pos dashboard status` | Shows service state (running/autostart), config path, and the dashboard URL |
|
||||
| `pos dashboard config` | Shows current `DASHBOARD_PORT` and `DASHBOARD_HOST`; delegates to `pos config dashboard` interactive editor when available |
|
||||
| `pos dashboard url` | Prints `http://<tailscale-ip>:<port>/` (auto-detects Tailscale IP, falls back to `hostname -I`) |
|
||||
|
||||
**Configuration** (`~/.config/linux_post_install/dashboard.env`, edit with `pos config dashboard`):
|
||||
|
||||
| Key | Required | Default | Purpose |
|
||||
|-----|----------|---------|---------|
|
||||
| `DASHBOARD_PORT` | no | `8080` | Port the dashboard listens on |
|
||||
| `DASHBOARD_HOST` | no | `0.0.0.0` | Bind address (`0.0.0.0` for Tailscale, `127.0.0.1` for local only) |
|
||||
|
||||
The dashboard runs as a systemd user service that loads config via `EnvironmentFile=%h/.config/linux_post_install/dashboard.env`. The Flask app calls `pos-*` tools via `subprocess.run([...])` and returns JSON. Security perimeter is Tailscale — no auth middleware.
|
||||
|
||||
### ssh
|
||||
|
||||
| Command | File | Purpose | Configuration |
|
||||
|
||||
+2
-1
@@ -114,7 +114,8 @@ pos media sync --mp4 --dry-run # shows "would copy" list + counts, copies noth
|
||||
|
||||
The source folder may be a symlink to a library elsewhere
|
||||
(`~/Music -> /mnt/data/music`) — it is followed, the artist/album tree is
|
||||
mirrored under the symlink's target.
|
||||
mirrored under the symlink's target. Symlinks *inside* the tree are not
|
||||
followed (`find -H`), and a warning reports how many were skipped.
|
||||
|
||||
**Target picking.** Mounted USB partitions are listed with size, label and
|
||||
filesystem (single candidate → confirm prompt; several → numbered picker, one
|
||||
|
||||
@@ -255,7 +255,7 @@ mkdir -p "$LOG_DIR" 2>/dev/null || true
|
||||
CMD_SAFE=$(echo "${args[*]}" | tr ' /' '__')
|
||||
LOG_FILE="$LOG_DIR/$(date +%Y%m%d_%H%M%S)_pos_${CMD_SAFE}.log"
|
||||
MAIN_LOG="$LOG_DIR/pos.log"
|
||||
log_cmd() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $* → exit $2" >> "$MAIN_LOG"; }
|
||||
log_cmd() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1 → exit $3" >> "$MAIN_LOG"; }
|
||||
|
||||
# Commands that read from stdin interactively — only log invocation
|
||||
INTERACTIVE_CMDS="docker-compose docker-vbox network-hotspot system-firewall media-mp4 media-sync system-backup share-usb-server share-smb-server share-smb-client communication-telegram-listener communication-matrix-listener ai-gemini system-schedule entertainment-config config"
|
||||
|
||||
+3
-17
@@ -7,7 +7,7 @@ set -euo pipefail
|
||||
|
||||
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
||||
|
||||
CONFIG_FILE="$HOME/.config/linux_post_install/ai.env"
|
||||
CONFIG_FILE="$CONFIG_DIR/ai.env"
|
||||
API="https://generativelanguage.googleapis.com/v1beta"
|
||||
DEFAULT_MODEL="gemini-2.5-flash"
|
||||
SESSION_DIR="$HOME/.local/share/linux_post_install/ai"
|
||||
@@ -59,22 +59,8 @@ EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ── ai.env loader (same pattern as telegram.env) ────────────────
|
||||
load_config() {
|
||||
[ -f "$CONFIG_FILE" ] || return 0
|
||||
local k v
|
||||
while IFS='=' read -r k v; do
|
||||
[ -n "$k" ] || continue
|
||||
case "$k" in
|
||||
\#*) continue ;;
|
||||
esac
|
||||
v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}"
|
||||
v="${v//$'\r'/}"
|
||||
if [ -z "${!k:-}" ]; then
|
||||
export "$k"="$v"
|
||||
fi
|
||||
done < <(grep -E '^[A-Z_]+=' "$CONFIG_FILE" || true)
|
||||
}
|
||||
# ── ai.env loader ──────────────────────────────────────────────
|
||||
load_config() { load_tool_config "$CONFIG_FILE"; }
|
||||
|
||||
require_key() {
|
||||
load_config
|
||||
|
||||
@@ -3,20 +3,18 @@ set -euo pipefail
|
||||
# POS: communication matrix-listener — Matrix listener: map /command → bash, run them on room messages
|
||||
# POS_FLAGS: --enable --disable --status --run
|
||||
|
||||
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
||||
|
||||
CONFIG_DIR="${CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/linux_post_install}"
|
||||
CONFIG_FILE="$CONFIG_DIR/matrix.env"
|
||||
MAP_FILE="$CONFIG_DIR/matrix_commands.env"
|
||||
SERVICE="pos-matrix-listener.service"
|
||||
USER_SYSTEMD_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
||||
USER_SYSTEMD_DIR="${USER_SYSTEMD_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user}"
|
||||
|
||||
# System prompt for the "ai " bridge: replies are posted straight into the
|
||||
# room, so ask for concise, emoji-friendly Matrix-style answers.
|
||||
AI_SYSTEM="You are a friendly assistant chatting in a Matrix room. Keep replies concise, use emojis and light formatting to make them lively, and never claim to send messages yourself."
|
||||
|
||||
err() { echo "ERROR: $*" >&2; exit 1; }
|
||||
log() { echo "[+] $*"; }
|
||||
warn() { echo "[!] $*" >&2; }
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: pos communication matrix listener [command]
|
||||
|
||||
@@ -4,6 +4,8 @@ set -euo pipefail
|
||||
# POS_SUBCMDS: send test login
|
||||
# POS_CONFIG: matrix | matrix.env | MATRIX_HOMESERVER=:Homeserver URL (https://matrix.example.org)::https://matrix.example.org | MATRIX_ACCESS_TOKEN=secret:Access token (from 'pos communication matrix sender login' or a Matrix client) | MATRIX_USER_ID=:Your Matrix user id (set by login)::@you:example.org | MATRIX_ROOM_ID=:Room id or alias::#pos:example.org
|
||||
|
||||
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
||||
|
||||
CONFIG_DIR="${CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/linux_post_install}"
|
||||
CONFIG_FILE="$CONFIG_DIR/matrix.env"
|
||||
|
||||
@@ -38,9 +40,6 @@ EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
err() { echo "ERROR: $*" >&2; exit 1; }
|
||||
log() { echo "[+] $*"; }
|
||||
|
||||
load_config() {
|
||||
[ -f "$CONFIG_FILE" ] || return 0
|
||||
local k v
|
||||
|
||||
@@ -6,26 +6,12 @@ set -euo pipefail
|
||||
|
||||
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
||||
|
||||
CONFIG_FILE="$HOME/.config/linux_post_install/scrcpy.env"
|
||||
CONFIG_FILE="$CONFIG_DIR/scrcpy.env"
|
||||
|
||||
command -v scrcpy &>/dev/null || err "scrcpy not found — install the latest release with the app installer: 'apps/media/scrcpy.sh' (or 'sudo apt install scrcpy' if your distro ships it; it bundles adb), see 'pos help communication scrcpy'"
|
||||
command -v adb &>/dev/null || err "adb not found — install it: 'sudo apt install adb'"
|
||||
|
||||
load_config() {
|
||||
[ -f "$CONFIG_FILE" ] || return 0
|
||||
local k v
|
||||
while IFS='=' read -r k v; do
|
||||
[ -n "$k" ] || continue
|
||||
case "$k" in
|
||||
\#*) continue ;;
|
||||
esac
|
||||
v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}"
|
||||
v="${v//$'\r'/}"
|
||||
if [ -z "${!k:-}" ]; then
|
||||
export "$k"="$v"
|
||||
fi
|
||||
done < <(grep -E '^[A-Z_]+=' "$CONFIG_FILE" || true)
|
||||
}
|
||||
load_config() { load_tool_config "$CONFIG_FILE"; }
|
||||
|
||||
load_config
|
||||
SCRCPY_SERIAL="${SCRCPY_SERIAL:-}"
|
||||
|
||||
@@ -3,21 +3,19 @@ set -euo pipefail
|
||||
# POS: communication telegram-listener — Telegram bot listener: map /command → bash, run them on chat messages
|
||||
# POS_FLAGS: --enable --disable --status --sync-commands --run
|
||||
|
||||
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
||||
|
||||
CONFIG_DIR="${CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/linux_post_install}"
|
||||
CONFIG_FILE="$CONFIG_DIR/telegram.env"
|
||||
MAP_FILE="$CONFIG_DIR/telegram_commands.env"
|
||||
API="https://api.telegram.org"
|
||||
SERVICE="pos-telegram-listener.service"
|
||||
USER_SYSTEMD_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
||||
USER_SYSTEMD_DIR="${USER_SYSTEMD_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user}"
|
||||
|
||||
# System prompt for the "ai " bridge: replies are posted straight into the
|
||||
# chat, so ask for concise, emoji-friendly Telegram-style answers.
|
||||
AI_SYSTEM="You are a friendly assistant chatting in a Telegram chat. Keep replies concise, use emojis and light formatting to make them lively, and never claim to send messages yourself."
|
||||
|
||||
err() { echo "ERROR: $*" >&2; exit 1; }
|
||||
log() { echo "[+] $*"; }
|
||||
warn() { echo "[!] $*" >&2; }
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: pos communication telegram listener [command]
|
||||
|
||||
@@ -5,6 +5,8 @@ set -euo pipefail
|
||||
# POS_SUBCMDS: send test
|
||||
# POS_CONFIG: telegram | telegram.env | TELEGRAM_BOT_TOKEN=secret:Bot token from @BotFather | TELEGRAM_CHAT_ID=digits:Numeric chat id from @userinfobot
|
||||
|
||||
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
||||
|
||||
CONFIG_DIR="${CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/linux_post_install}"
|
||||
CONFIG_FILE="$CONFIG_DIR/telegram.env"
|
||||
API="https://api.telegram.org"
|
||||
@@ -56,8 +58,6 @@ EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
err() { echo "ERROR: $*" >&2; exit 1; }
|
||||
|
||||
load_config() {
|
||||
[ -f "$CONFIG_FILE" ] || return 0
|
||||
local k v
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ pick_scope() {
|
||||
fi
|
||||
echo
|
||||
echo "pos config — pick a scope"
|
||||
echo "--------------------------"
|
||||
echo "------------------------------------"
|
||||
for ((i = 1; i <= ${#scopes[@]}; i++)); do
|
||||
env="$(cfg_scope_envfile "${scopes[$((i - 1))]}" || true)"
|
||||
printf ' %2d) %-16s %s\n' "$i" "${scopes[$((i - 1))]}" "${env:-}"
|
||||
|
||||
Executable
+275
@@ -0,0 +1,275 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
# POS: system dashboard — Web dashboard: start/stop/status/config/url
|
||||
# POS_FLAGS: --help
|
||||
# POS_SUBCMDS: start stop status port config url
|
||||
# POS_CONFIG: dashboard | dashboard.env | DASHBOARD_PORT=num:Port the dashboard listens on (default 8080) | DASHBOARD_HOST=:Bind address (default 0.0.0.0)
|
||||
|
||||
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
||||
|
||||
# ── Config & constants ──────────────────────────────────────────
|
||||
CONFIG_DIR="${CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/linux_post_install}"
|
||||
CONFIG_FILE="$CONFIG_DIR/dashboard.env"
|
||||
SERVICE="pos-dashboard.service"
|
||||
USER_SYSTEMD_DIR="${USER_SYSTEMD_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user}"
|
||||
DASHBOARD_DIR="/usr/local/share/linux_post_install/dashboard"
|
||||
DASHBOARD_SCRIPT="$DASHBOARD_DIR/app.py"
|
||||
|
||||
# ── load config ─────────────────────────────────────────────────
|
||||
load_config() {
|
||||
[ -f "$CONFIG_FILE" ] || return 0
|
||||
local k v
|
||||
while IFS='=' read -r k v; do
|
||||
[ -n "$k" ] || continue
|
||||
case "$k" in
|
||||
\#*) continue ;;
|
||||
esac
|
||||
v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}"
|
||||
if [ -z "${!k:-}" ]; then
|
||||
export "$k"="$v"
|
||||
fi
|
||||
done < <(grep -E '^[A-Z_]+=' "$CONFIG_FILE" || true)
|
||||
}
|
||||
|
||||
# ── defaults ────────────────────────────────────────────────────
|
||||
load_config
|
||||
DASHBOARD_PORT="${DASHBOARD_PORT:-8080}"
|
||||
DASHBOARD_HOST="${DASHBOARD_HOST:-0.0.0.0}"
|
||||
|
||||
# ── usage ───────────────────────────────────────────────────────
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: pos dashboard <command>
|
||||
|
||||
Web dashboard for the pos toolkit — browser-based UI for managing
|
||||
entertainment, Telegram, Docker, and system health.
|
||||
|
||||
Commands:
|
||||
start Install and start the systemd user service
|
||||
stop Stop, disable, and remove the service
|
||||
status Show service state and the dashboard URL
|
||||
port <num> Change the dashboard port (restarts the service)
|
||||
config Edit DASHBOARD_PORT, DASHBOARD_HOST
|
||||
url Print the dashboard URL
|
||||
|
||||
Config: $CONFIG_FILE (DASHBOARD_PORT, DASHBOARD_HOST)
|
||||
Service: $SERVICE (systemd user unit)
|
||||
Dir: $DASHBOARD_DIR
|
||||
|
||||
The dashboard binds to DASHBOARD_HOST:DASHBOARD_PORT (default 0.0.0.0:8080).
|
||||
Access it over Tailscale at: http://<tailscale-ip>:<port>/
|
||||
|
||||
Examples:
|
||||
pos dashboard start
|
||||
pos dashboard port 9090
|
||||
pos dashboard status
|
||||
pos dashboard url
|
||||
pos dashboard config
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
# ── deps guard (before --help, per convention) ──────────────────
|
||||
command -v python3 &>/dev/null || err "python3 not found — install with: sudo apt install python3"
|
||||
|
||||
# ── help ────────────────────────────────────────────────────────
|
||||
case "${1:-}" in
|
||||
-h|--help) usage ;;
|
||||
esac
|
||||
|
||||
# ── start ───────────────────────────────────────────────────────
|
||||
cmd_start() {
|
||||
# Check flask is available
|
||||
if ! python3 -c "import flask" 2>/dev/null; then
|
||||
err "python3-flask not installed — install with: sudo apt install python3-flask"
|
||||
fi
|
||||
|
||||
# Check dashboard directory exists
|
||||
if [ ! -d "$DASHBOARD_DIR" ]; then
|
||||
err "Dashboard directory not found: $DASHBOARD_DIR — run install.sh first"
|
||||
fi
|
||||
if [ ! -f "$DASHBOARD_SCRIPT" ]; then
|
||||
err "Dashboard app not found: $DASHBOARD_SCRIPT — run install.sh first"
|
||||
fi
|
||||
|
||||
# Ensure config exists
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
cp "${BASH_SOURCE[0]%/bin/pos-dashboard}/../config/dashboard.env" "$CONFIG_FILE" 2>/dev/null \
|
||||
|| warn "No config template found — create $CONFIG_FILE manually"
|
||||
chmod 600 "$CONFIG_FILE" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Install systemd user service
|
||||
mkdir -p "$USER_SYSTEMD_DIR"
|
||||
|
||||
local runner
|
||||
if [ -x /usr/local/bin/pos-dashboard ]; then
|
||||
runner=/usr/local/bin/pos-dashboard
|
||||
else
|
||||
runner="$(cd "$(dirname "$0")/.." && pwd)/bin/pos-dashboard"
|
||||
warn "using repo path $runner — re-run 'install.sh' so the service survives a deleted repo"
|
||||
fi
|
||||
|
||||
cat >"$USER_SYSTEMD_DIR/$SERVICE" <<EOF
|
||||
[Unit]
|
||||
Description=pos Web Dashboard
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/python3 $DASHBOARD_SCRIPT
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
EnvironmentFile=%h/.config/linux_post_install/dashboard.env
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
EOF
|
||||
chmod 644 "$USER_SYSTEMD_DIR/$SERVICE"
|
||||
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now "$SERVICE"
|
||||
log "Dashboard service enabled: $SERVICE"
|
||||
|
||||
cmd_url
|
||||
|
||||
if command -v loginctl >/dev/null 2>&1; then
|
||||
if ! loginctl show-user "$(id -un)" 2>/dev/null | grep -q '^Linger=yes'; then
|
||||
warn "enable linger so the dashboard survives logout: sudo loginctl enable-linger $(id -un)"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# ── stop ────────────────────────────────────────────────────────
|
||||
cmd_stop() {
|
||||
if [ ! -f "$USER_SYSTEMD_DIR/$SERVICE" ]; then
|
||||
warn "no dashboard service installed ($SERVICE)"
|
||||
return 0
|
||||
fi
|
||||
systemctl --user disable --now "$SERVICE" 2>/dev/null || true
|
||||
rm -f "$USER_SYSTEMD_DIR/$SERVICE"
|
||||
systemctl --user daemon-reload
|
||||
log "Dashboard service disabled"
|
||||
}
|
||||
|
||||
# ── status ──────────────────────────────────────────────────────
|
||||
cmd_status() {
|
||||
load_config
|
||||
DASHBOARD_PORT="${DASHBOARD_PORT:-8080}"
|
||||
DASHBOARD_HOST="${DASHBOARD_HOST:-0.0.0.0}"
|
||||
if systemctl --user is-active --quiet "$SERVICE" 2>/dev/null; then
|
||||
echo "service: running"
|
||||
else
|
||||
echo "service: not running"
|
||||
fi
|
||||
if systemctl --user is-enabled "$SERVICE" >/dev/null 2>&1; then
|
||||
echo "autostart: enabled (starts on login)"
|
||||
else
|
||||
echo "autostart: disabled"
|
||||
fi
|
||||
echo "config: $CONFIG_FILE"
|
||||
echo "dir: $DASHBOARD_DIR"
|
||||
cmd_url
|
||||
}
|
||||
|
||||
# ── port ────────────────────────────────────────────────────────
|
||||
cmd_port() {
|
||||
local new_port="${1:-}"
|
||||
if [ -z "$new_port" ]; then
|
||||
err "Usage: pos dashboard port <number>"
|
||||
fi
|
||||
# Validate port is a number between 1 and 65535
|
||||
if ! [[ "$new_port" =~ ^[0-9]+$ ]] || [ "$new_port" -lt 1 ] || [ "$new_port" -gt 65535 ]; then
|
||||
err "Invalid port: $new_port — must be a number between 1 and 65535"
|
||||
fi
|
||||
|
||||
# Ensure config file exists
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
cp "${BASH_SOURCE[0]%/bin/pos-dashboard}/../config/dashboard.env" "$CONFIG_FILE" 2>/dev/null \
|
||||
|| echo -e "DASHBOARD_PORT=8080\nDASHBOARD_HOST=0.0.0.0" > "$CONFIG_FILE"
|
||||
chmod 600 "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
# Update port in config file
|
||||
if grep -q '^DASHBOARD_PORT=' "$CONFIG_FILE"; then
|
||||
sed -i "s/^DASHBOARD_PORT=.*/DASHBOARD_PORT=$new_port/" "$CONFIG_FILE"
|
||||
else
|
||||
echo "DASHBOARD_PORT=$new_port" >> "$CONFIG_FILE"
|
||||
fi
|
||||
log "Port changed to $new_port in $CONFIG_FILE"
|
||||
|
||||
# Update the running variable so cmd_url shows the new port
|
||||
DASHBOARD_PORT="$new_port"
|
||||
|
||||
# Restart service if running
|
||||
if systemctl --user is-active --quiet "$SERVICE" 2>/dev/null; then
|
||||
systemctl --user restart "$SERVICE"
|
||||
log "Dashboard service restarted on port $new_port"
|
||||
else
|
||||
warn "Dashboard is not running — start it with: pos dashboard start"
|
||||
fi
|
||||
|
||||
cmd_url
|
||||
}
|
||||
|
||||
# ── config ──────────────────────────────────────────────────────
|
||||
cmd_config() {
|
||||
# If pos config-ui.sh is available, use the interactive editor
|
||||
local cfg_ui_script
|
||||
for p in \
|
||||
"$(dirname "$0")/../lib/config-ui.sh" \
|
||||
"$(dirname "$0")/config-ui.sh" \
|
||||
"/usr/local/bin/config-ui.sh"; do
|
||||
if [ -f "$p" ]; then
|
||||
cfg_ui_script="$p"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -n "${cfg_ui_script:-}" ]; then
|
||||
source "$cfg_ui_script" 2>/dev/null
|
||||
cfg_ui "dashboard"
|
||||
return
|
||||
fi
|
||||
|
||||
# Fallback: reload and show current values
|
||||
load_config
|
||||
DASHBOARD_PORT="${DASHBOARD_PORT:-8080}"
|
||||
DASHBOARD_HOST="${DASHBOARD_HOST:-0.0.0.0}"
|
||||
echo "Dashboard config: $CONFIG_FILE"
|
||||
echo
|
||||
printf ' DASHBOARD_PORT=%s\n' "$DASHBOARD_PORT"
|
||||
printf ' DASHBOARD_HOST=%s\n' "$DASHBOARD_HOST"
|
||||
echo
|
||||
echo "Edit with: pos config dashboard"
|
||||
}
|
||||
|
||||
# ── url ─────────────────────────────────────────────────────────
|
||||
cmd_url() {
|
||||
local ip=""
|
||||
# Prefer Tailscale IP
|
||||
if command -v tailscale &>/dev/null; then
|
||||
ip="$(tailscale ip -4 2>/dev/null || true)"
|
||||
fi
|
||||
# Fallback to first non-loopback IP
|
||||
if [ -z "$ip" ]; then
|
||||
ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
|
||||
fi
|
||||
if [ -z "$ip" ]; then
|
||||
ip="localhost"
|
||||
fi
|
||||
echo "http://${ip}:${DASHBOARD_PORT}/"
|
||||
}
|
||||
|
||||
# ── dispatch ────────────────────────────────────────────────────
|
||||
case "${1:-}" in
|
||||
start) cmd_start ;;
|
||||
stop) cmd_stop ;;
|
||||
status) cmd_status ;;
|
||||
port) cmd_port "${2:-}" ;;
|
||||
config) cmd_config ;;
|
||||
url) cmd_url ;;
|
||||
"") usage ;;
|
||||
*) err "Unknown subcommand '$1' (see --help)" ;;
|
||||
esac
|
||||
+13
-10
@@ -69,7 +69,7 @@ EOF
|
||||
}
|
||||
|
||||
check_deps() {
|
||||
command -v docker &>/dev/null || err "docker not found — run 'install.sh --apps' and install Docker first"
|
||||
command -v docker &>/dev/null || err "docker not found — install with: sudo apt install docker.io"
|
||||
}
|
||||
|
||||
check_templates() {
|
||||
@@ -105,12 +105,12 @@ cmd_ls() {
|
||||
IFS=$'\n' names=($(sort <<<"${names[*]}")); unset IFS
|
||||
|
||||
echo "${CYAN}Available ScaleTail services:${RESET}"
|
||||
echo "${BLUE}────────────────────────────────────────${RESET}"
|
||||
echo "${CYAN}────────────────────────────────────────${RESET}"
|
||||
local name
|
||||
for name in "${names[@]}"; do
|
||||
printf " ${GREEN}%s${RESET}\n" "$name"
|
||||
done
|
||||
echo "${BLUE}────────────────────────────────────────${RESET}"
|
||||
echo "${CYAN}────────────────────────────────────────${RESET}"
|
||||
echo " ${#names[@]} services total"
|
||||
}
|
||||
|
||||
@@ -119,9 +119,9 @@ cmd_installed() {
|
||||
local count=0
|
||||
|
||||
echo "${CYAN}Deployed services:${RESET}"
|
||||
echo "${BLUE}────────────────────────────────────────${RESET}"
|
||||
echo "${CYAN}────────────────────────────────────────${RESET}"
|
||||
|
||||
[ -d "$SERVICES_BASE" ] || { echo " (none — $SERVICES_BASE does not exist)"; echo "${BLUE}────────────────────────────────────────${RESET}"; echo " 0 services deployed"; return; }
|
||||
[ -d "$SERVICES_BASE" ] || { echo " (none — $SERVICES_BASE does not exist)"; echo "${CYAN}────────────────────────────────────────${RESET}"; echo " 0 services deployed"; return; }
|
||||
|
||||
for svc in "$SERVICES_BASE"/*/; do
|
||||
[ -d "$svc" ] || continue
|
||||
@@ -131,21 +131,22 @@ cmd_installed() {
|
||||
if [ -f "$svc/compose.yaml" ] || [ -f "$svc/compose.yml" ]; then
|
||||
status=$(docker compose ls --format json 2>/dev/null | python3 -c "
|
||||
import sys, json
|
||||
name = sys.argv[1]
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
if not isinstance(data, list):
|
||||
data = [data]
|
||||
for e in data:
|
||||
if e.get('Name') == '$name':
|
||||
if e.get('Name') == name:
|
||||
print(e.get('Status', 'unknown'))
|
||||
break
|
||||
except: pass
|
||||
" 2>/dev/null || echo "unknown")
|
||||
" "$name" 2>/dev/null || echo "unknown")
|
||||
fi
|
||||
printf " ${GREEN}%-28s${RESET} %s\n" "$name" "${status:-unknown}"
|
||||
count=$((count + 1))
|
||||
done
|
||||
echo "${BLUE}────────────────────────────────────────${RESET}"
|
||||
echo "${CYAN}────────────────────────────────────────${RESET}"
|
||||
echo " $count services deployed"
|
||||
}
|
||||
|
||||
@@ -294,13 +295,13 @@ cmd_config() {
|
||||
load_global_config
|
||||
echo "${CYAN}Global compose config${RESET}"
|
||||
echo "${DIM}File: $CONFIG_ENV${RESET}"
|
||||
echo "${BLUE}────────────────────────────────────────${RESET}"
|
||||
echo "${CYAN}────────────────────────────────────────${RESET}"
|
||||
if [ -f "$CONFIG_ENV" ]; then
|
||||
cat "$CONFIG_ENV"
|
||||
else
|
||||
echo "(no global config set — using defaults)"
|
||||
fi
|
||||
echo "${BLUE}────────────────────────────────────────${RESET}"
|
||||
echo "${CYAN}────────────────────────────────────────${RESET}"
|
||||
echo "SERVICES_BASE=$SERVICES_BASE (deployment directory)"
|
||||
echo "${DIM}Set with: pos docker compose config set SERVICES_BASE=/srv${RESET}"
|
||||
;;
|
||||
@@ -346,6 +347,8 @@ EOF
|
||||
|
||||
[ $# -eq 0 ] && usage
|
||||
|
||||
command -v docker &>/dev/null || command -v docker-compose &>/dev/null || err "docker not found — install with: sudo apt install docker.io"
|
||||
|
||||
case "${1:-}" in
|
||||
-h|--help) usage ;;
|
||||
esac
|
||||
|
||||
@@ -17,7 +17,7 @@ EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
command -v docker &>/dev/null || err "docker not found — install it with: sudo apt install docker.io"
|
||||
command -v docker &>/dev/null || err "docker not found — install with: sudo apt install docker.io"
|
||||
|
||||
case "${1:-}" in
|
||||
-h|--help) usage ;;
|
||||
@@ -34,6 +34,7 @@ no_check=0
|
||||
other=0
|
||||
|
||||
echo "── Docker Health ─────────────────────────────────"
|
||||
printf ' %b%s%b\n' "${BOLD}" "CONTAINER STATUS UPTIME" "${RESET}"
|
||||
|
||||
for cid in $container_ids; do
|
||||
info=$(docker inspect "$cid" 2>/dev/null) || continue
|
||||
|
||||
+6
-6
@@ -13,7 +13,7 @@ EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
command -v docker &>/dev/null || err "docker not found — install it with: sudo apt install docker.io"
|
||||
command -v docker &>/dev/null || err "docker not found — install with: sudo apt install docker.io"
|
||||
|
||||
case "${1:-}" in
|
||||
-h|--help) usage ;;
|
||||
@@ -29,7 +29,7 @@ unhealthy_count=0
|
||||
running_count=0
|
||||
total_count=0
|
||||
|
||||
printf "%-28s %-35s %-22s %-10s %-35s %-20s %s\n" \
|
||||
printf "${BOLD}%-28s %-35s %-22s %-10s %-35s %-20s %s${RESET}\n" \
|
||||
"NAME" "IMAGE" "STATUS(HEALTH)" "UPTIME" "IPS" "PORTS" "CONTAINER ID"
|
||||
|
||||
for cid in $container_ids; do
|
||||
@@ -114,10 +114,10 @@ done
|
||||
echo
|
||||
printf '%*s\n' 120 '' | tr ' ' '-'
|
||||
|
||||
echo "Containers : $total_count"
|
||||
echo "Healthy : $healthy_count"
|
||||
echo "Unhealthy : $unhealthy_count"
|
||||
echo "Running : $running_count"
|
||||
printf ' %-14s %s\n' "Containers:" "$total_count"
|
||||
printf ' %-14s %s\n' "Healthy:" "$healthy_count"
|
||||
printf ' %-14s %s\n' "Unhealthy:" "$unhealthy_count"
|
||||
printf ' %-14s %s\n' "Running:" "$running_count"
|
||||
|
||||
if [ "$unhealthy_count" -gt 0 ]; then
|
||||
echo
|
||||
|
||||
@@ -26,7 +26,7 @@ EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
command -v docker &>/dev/null || err "docker not found — run 'install.sh --apps' and install Docker first"
|
||||
command -v docker &>/dev/null || err "docker not found — install with: sudo apt install docker.io"
|
||||
|
||||
ALL=0
|
||||
case "${1:-}" in
|
||||
@@ -74,7 +74,7 @@ show_rows() {
|
||||
|
||||
stack_header() {
|
||||
local label="$1"
|
||||
local pad=$((46 - ${#label}))
|
||||
local pad=$((39 - ${#label}))
|
||||
[ "$pad" -lt 1 ] && pad=1
|
||||
printf -- "── %s%s%s %s\n" "$CYAN" "$label" "$RESET" "$(printf '%*s' "$pad" '' | sed 's/ /─/g')"
|
||||
}
|
||||
|
||||
@@ -74,6 +74,8 @@ case "${1:-}" in
|
||||
unset)
|
||||
shift
|
||||
[ $# -eq 1 ] || usage
|
||||
[[ "$1" =~ ^[A-Z][A-Z0-9_]*$ ]] || \
|
||||
err "Invalid key '$1' (expected UPPER_SNAKE, e.g. WEATHER_LAT)"
|
||||
write_config_key "$1" "-"
|
||||
ok "$1 removed from $CONFIG_FILE"
|
||||
sync_timers
|
||||
|
||||
@@ -27,6 +27,10 @@ esac
|
||||
[ $# -eq 1 ] || usage
|
||||
plugin="$1"
|
||||
|
||||
if ! ent_plugin_exists "$(ent_plugin_dir)" "$plugin"; then
|
||||
warn "'$plugin' is not an installed plugin — removing from ENABLED anyway"
|
||||
fi
|
||||
|
||||
enabled_remove "$plugin"
|
||||
sync_timers
|
||||
ok "'$plugin' disabled — schedule synced"
|
||||
|
||||
@@ -73,7 +73,7 @@ script="$(resolve_plugin "$dir" "$plugin")"
|
||||
rc=0
|
||||
output="$( "$script" "${plugin_args[@]}" )" || rc=$?
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
[ -n "${INVOCATION_ID:-}" ] && notify_send "⚠️ entertainment '$plugin' failed (exit $rc)"
|
||||
[ -n "${INVOCATION_ID:-}" ] && notify_send "entertainment '$plugin' failed (exit $rc)"
|
||||
save_last_run "$plugin" "$rc" "$(printf '%s' "$output" | head -1)"
|
||||
err "Plugin '$plugin' failed (exit $rc)"
|
||||
fi
|
||||
|
||||
@@ -21,19 +21,24 @@ case "${1:-}" in
|
||||
-h|--help) usage ;;
|
||||
esac
|
||||
|
||||
raw="$(config_value ENABLED)"
|
||||
echo
|
||||
echo "pos entertainment status"
|
||||
echo "------------------------------------"
|
||||
echo "Config: $CONFIG_FILE"
|
||||
|
||||
raw="$(config_value ENABLED)"
|
||||
parse_enabled "$raw"
|
||||
echo "Enabled plugins:"
|
||||
if [ ${#ENABLED_ENTRIES[@]} -eq 0 ]; then
|
||||
echo " (none — enable one with: pos entertainment enable <plugin> [interval])"
|
||||
else
|
||||
printf ' %b%-12s %-16s %s%b\n' "${BOLD}" "PLUGIN" "INTERVAL" "LAST RUN" "${RESET}"
|
||||
for entry in "${ENABLED_ENTRIES[@]}"; do
|
||||
plugin="${entry%%,*}"; interval="${entry##*,}"
|
||||
[ "$interval" = "$plugin" ] && interval="$DEFAULT_INTERVAL"
|
||||
printf ' %-12s %-16s %s\n' "$plugin" "$(ut_interval_label "$interval")" "$(last_run_str "$plugin")"
|
||||
done
|
||||
fi
|
||||
for entry in "${ENABLED_ENTRIES[@]}"; do
|
||||
plugin="${entry%%,*}"; interval="${entry##*,}"
|
||||
[ "$interval" = "$plugin" ] && interval="$DEFAULT_INTERVAL"
|
||||
printf ' %-12s %-16s last run: %s\n' "$plugin" "$(ut_interval_label "$interval")" "$(last_run_str "$plugin")"
|
||||
done
|
||||
|
||||
not_enabled=()
|
||||
for name in $(list_plugins "$(ent_plugin_dir)"); do
|
||||
|
||||
+2
-2
@@ -14,8 +14,8 @@ done
|
||||
|
||||
# Deps guards sit before -h|--help (help also errors on a box missing the deps).
|
||||
if [ "$DRY_RUN" -eq 0 ]; then
|
||||
command -v yt-dlp &>/dev/null || err "yt-dlp not found — install it with: sudo apt install yt-dlp"
|
||||
command -v ffmpeg &>/dev/null || err "ffmpeg not found (needed for MP3 conversion) — install it with: sudo apt install ffmpeg"
|
||||
command -v yt-dlp &>/dev/null || err "yt-dlp not found — install with: sudo apt install yt-dlp"
|
||||
command -v ffmpeg &>/dev/null || err "ffmpeg not found — install with: sudo apt install ffmpeg"
|
||||
fi
|
||||
|
||||
OUT_DIR="$HOME/Music"
|
||||
|
||||
+2
-2
@@ -14,8 +14,8 @@ done
|
||||
|
||||
# Deps guards sit before -h|--help (help also errors on a box missing the deps).
|
||||
if [ "$DRY_RUN" -eq 0 ]; then
|
||||
command -v yt-dlp &>/dev/null || err "yt-dlp not found — install it with: sudo apt install yt-dlp"
|
||||
command -v ffmpeg &>/dev/null || err "ffmpeg not found (needed for MP4 merge) — install it with: sudo apt install ffmpeg"
|
||||
command -v yt-dlp &>/dev/null || err "yt-dlp not found — install with: sudo apt install yt-dlp"
|
||||
command -v ffmpeg &>/dev/null || err "ffmpeg not found — install with: sudo apt install ffmpeg"
|
||||
fi
|
||||
|
||||
OUT_DIR="$HOME/Videos"
|
||||
|
||||
+55
-6
@@ -69,8 +69,28 @@ while [[ $# -gt 0 ]]; do
|
||||
done
|
||||
[ "$MP3" -eq 1 ] || [ "$MP4" -eq 1 ] || { MP3=1; MP4=1; }
|
||||
|
||||
# Normalize trailing slashes on the source: GNU find normalizes ONE trailing
|
||||
# slash on the starting point (find -H /x/ -type f emits /x/a.mp3) but keeps
|
||||
# a doubled one (find -H /x// -type f emits /x//a.mp3), so with SRC=/x// the
|
||||
# rel prefix "${f#"$SRC/"}" never matches and files would nest under
|
||||
# <stick>/Music//x/... instead of mirroring the tree. Strip ALL trailing
|
||||
# slashes — a lone "/" or "//" ends up empty and hits the guard below.
|
||||
# Covers both --source /x// and MEDIA_SYNC_SOURCE=...//.
|
||||
while [[ "$SRC" == */ ]]; do SRC="${SRC%/}"; done
|
||||
[ -n "$SRC" ] || err "Source path is empty"
|
||||
|
||||
[ -d "$SRC" ] || err "Source not found: $SRC"
|
||||
|
||||
# find -H follows only the command-line source symlink; symlinks INSIDE the
|
||||
# tree are never followed, so their targets silently never sync. Count them
|
||||
# (mindepth 1: a symlink source itself IS followed and must not count) and
|
||||
# surface the skip instead of leaving a partial mirror unexplained.
|
||||
inner_links="$(find -H "$SRC" -mindepth 1 -type l 2>/dev/null | wc -l)" || true
|
||||
inner_links="${inner_links// }"
|
||||
if [ "$inner_links" -gt 0 ]; then
|
||||
warn "${inner_links} symlink(s) inside the source are not followed (find -H) — their targets will not be synced"
|
||||
fi
|
||||
|
||||
trap 'notify_send "Music sync FAILED"' ERR
|
||||
|
||||
section "Music sync"
|
||||
@@ -97,9 +117,9 @@ echo "Target : $dest_root"
|
||||
needs_copy() {
|
||||
local src="$1" dst="$2" ss="" ds="" sm="" dm=""
|
||||
[ -f "$dst" ] || return 0
|
||||
ss="$(stat -c %s "$src" 2>/dev/null || printf 0)"
|
||||
ss="$(stat -c %s "$src" 2>/dev/null)" || { return 0; }
|
||||
ds="$(stat -c %s "$dst" 2>/dev/null || printf 0)"
|
||||
sm="$(stat -c %Y "$src" 2>/dev/null || printf 0)"
|
||||
sm="$(stat -c %Y "$src" 2>/dev/null)" || { return 0; }
|
||||
dm="$(stat -c %Y "$dst" 2>/dev/null || printf 0)"
|
||||
[ "$ss" = "$ds" ] && [ "$sm" -le "$dm" ] && return 1
|
||||
return 0
|
||||
@@ -112,6 +132,31 @@ if [ "$DRY_RUN" -eq 0 ]; then
|
||||
mkdir -p "$dest_root"
|
||||
space_path="$dest_root"
|
||||
fi
|
||||
# One find pass into a temp list, sorted once, shared by the space scan and
|
||||
# the copy loop so both see the identical file set (the old double find is
|
||||
# gone). The old process substitution swallowed find's exit code and stderr
|
||||
# (invisible to set -e / pipefail): an unreadable subdir made find exit 1
|
||||
# with "Permission denied" yet the tool still announced a false "Sync
|
||||
# complete" on a partial tree. Capture both and surface them explicitly
|
||||
# instead of continuing silently.
|
||||
find_list="$(mktemp)"
|
||||
find_err="$(mktemp)"
|
||||
trap 'rm -f "$find_list" "$find_err"' EXIT
|
||||
find_rc=0
|
||||
find -H "$SRC" "${find_expr[@]}" >"$find_list" 2>"$find_err" || find_rc=$?
|
||||
sort -o "$find_list" "$find_list"
|
||||
# One "find reported problems" condition, computed once here and reused at the
|
||||
# final success messages (below) so a partial tree is never announced as fully
|
||||
# synced.
|
||||
find_ok=1
|
||||
if [ "$find_rc" -ne 0 ] || [ -s "$find_err" ]; then
|
||||
find_ok=0
|
||||
warn "find of the source reported problems — results may be incomplete:"
|
||||
if [ -s "$find_err" ]; then
|
||||
sed 's/^/ /' "$find_err"
|
||||
fi
|
||||
fi
|
||||
|
||||
need_kb=0
|
||||
while IFS= read -r f; do
|
||||
rel="${f#"$SRC/"}"
|
||||
@@ -119,7 +164,7 @@ while IFS= read -r f; do
|
||||
sz="$(stat -c %s "$f" 2>/dev/null || printf 0)"
|
||||
need_kb=$((need_kb + (sz + 1023) / 1024))
|
||||
fi
|
||||
done < <(find -H "$SRC" "${find_expr[@]}")
|
||||
done < "$find_list"
|
||||
have_kb="$(df -Pk "$space_path" 2>/dev/null | awk 'NR==2 {print $4}')"
|
||||
have_kb="${have_kb:-0}"
|
||||
if [ "$need_kb" -gt "$have_kb" ]; then
|
||||
@@ -154,11 +199,15 @@ while IFS= read -r f; do
|
||||
else
|
||||
unchanged=$((unchanged + 1))
|
||||
fi
|
||||
done < <(find -H "$SRC" "${find_expr[@]}" | sort)
|
||||
done < "$find_list"
|
||||
|
||||
# Mark the success line + notification as partial when find reported
|
||||
# problems, so the success signal can't contradict the warning above.
|
||||
partial_suffix=""
|
||||
[ "$find_ok" -eq 0 ] && partial_suffix=" (partial — find reported problems)"
|
||||
if [ "$DRY_RUN" -eq 1 ]; then
|
||||
echo "DRY RUN — nothing copied. Would sync: ${added} new, ${updated} updated, ${unchanged} unchanged → $dest_root"
|
||||
else
|
||||
ok "Sync complete: ${added} added, ${updated} updated, ${unchanged} unchanged → $dest_root"
|
||||
notify_send "Music sync completed: ${added} added, ${updated} updated → $dest_root"
|
||||
ok "Sync complete: ${added} added, ${updated} updated, ${unchanged} unchanged → $dest_root$partial_suffix"
|
||||
notify_send "Music sync completed: ${added} added, ${updated} updated → $dest_root$partial_suffix"
|
||||
fi
|
||||
|
||||
@@ -3,6 +3,8 @@ set -euo pipefail
|
||||
# POS: network checkport — Check TCP/UDP port reachability (nmap, or bash/nc fallback) + local interface view
|
||||
# POS_FLAGS: --tcp --udp --ping --no-banner --versions --timeout
|
||||
|
||||
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: pos network checkport <target> [target...] [options]
|
||||
@@ -40,8 +42,6 @@ EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
err() { echo "ERROR: $*" >&2; exit 1; }
|
||||
|
||||
# --- port metadata (fallback when nmap reports no service) --------------------
|
||||
meta_name=""; meta_desc=""
|
||||
port_meta() {
|
||||
@@ -227,9 +227,9 @@ local_check() {
|
||||
banner_txt="$(format_banner "$(probe "$ip" "$port" "$tmo")")"; banner_done=1
|
||||
fi
|
||||
case "$state" in
|
||||
open) label="OPEN" ;;
|
||||
bound) label="bound" ;;
|
||||
closed) label="CLOSED" ;;
|
||||
open) label="${GREEN}${BOLD}OPEN${RESET}" ;;
|
||||
bound) label="bound" ;;
|
||||
closed) label="${RED}CLOSED${RESET}" ;;
|
||||
timeout) label="no reply (filtered?)" ;;
|
||||
no-reply) label="no reply" ;;
|
||||
not-bound) label="not bound" ;;
|
||||
@@ -257,9 +257,9 @@ ping_host() {
|
||||
|
||||
state_label() {
|
||||
case "$1" in
|
||||
open) printf 'OPEN' ;;
|
||||
closed) printf 'CLOSED' ;;
|
||||
filtered) printf 'FILTERED' ;;
|
||||
open) printf "${GREEN}${BOLD}OPEN${RESET}" ;;
|
||||
closed) printf "${RED}CLOSED${RESET}" ;;
|
||||
filtered) printf "${YELLOW}FILTERED${RESET}" ;;
|
||||
"open|filtered") printf 'open|filtered' ;;
|
||||
timeout) printf 'no reply (filtered?)' ;;
|
||||
no-reply) printf 'no reply (open|filtered)' ;;
|
||||
|
||||
@@ -6,9 +6,9 @@ set -euo pipefail
|
||||
|
||||
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
||||
|
||||
command -v aria2c &>/dev/null || err "aria2c not found (install aria2)"
|
||||
command -v jq &>/dev/null || err "jq not found (install jq)"
|
||||
command -v curl &>/dev/null || err "curl not found (install curl)"
|
||||
command -v aria2c &>/dev/null || err "aria2c not found — install with: sudo apt install aria2"
|
||||
command -v jq &>/dev/null || err "jq not found — install with: sudo apt install jq"
|
||||
command -v curl &>/dev/null || err "curl not found — install with: sudo apt install curl"
|
||||
|
||||
# ── Config / seams (env overrides for tests) ───────────────────
|
||||
RPC_PORT="${RPC_PORT:-6800}"
|
||||
@@ -142,9 +142,6 @@ rpc() { # rpc <method> [json-args...]
|
||||
daemon_active() { systemctl --user is-active --quiet "$SERVICE" 2>/dev/null; }
|
||||
|
||||
cmd_start() {
|
||||
if [ -z "$RPC_SECRET" ] && [ -f "$CONFIG_FILE" ]; then
|
||||
RPC_SECRET=$(grep -E '^RPC_SECRET=' "$CONFIG_FILE" 2>/dev/null | head -1 | cut -d= -f2-)
|
||||
fi
|
||||
if [ -z "$RPC_SECRET" ]; then
|
||||
RPC_SECRET=$(head -c 24 /dev/urandom | od -An -tx1 | tr -d ' \n')
|
||||
if [ "${DRY_RUN:-0}" -eq 1 ]; then
|
||||
@@ -258,11 +255,11 @@ cmd_add() {
|
||||
local urls=()
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--dir) dir="${2:-$DOWNLOAD_DIR}"; shift 2 ;;
|
||||
--dir) [ $# -ge 2 ] || err "--dir requires a value"; dir="$2"; shift 2 ;;
|
||||
--dir=*) dir="${1#*=}"; shift ;;
|
||||
--out) out="${2:-}"; shift 2 ;;
|
||||
--out) [ $# -ge 2 ] || err "--out requires a value"; out="$2"; shift 2 ;;
|
||||
--out=*) out="${1#*=}"; shift ;;
|
||||
--split) split="${2:-}"; shift 2 ;;
|
||||
--split) [ $# -ge 2 ] || err "--split requires a value"; split="$2"; shift 2 ;;
|
||||
--split=*) split="${1#*=}"; shift ;;
|
||||
--tmux) tmux=1; shift ;;
|
||||
-h|--help) usage ;;
|
||||
@@ -317,7 +314,7 @@ cmd_torrent() {
|
||||
fi
|
||||
gid=$(printf '%s' "$resp" | jq -r '.result')
|
||||
if [ "$seed" -eq 1 ]; then
|
||||
rpc aria2.changeOption "$(json_str "$gid")" "$(opts_json "seed-ratio=0")" >/dev/null
|
||||
rpc aria2.changeOption "$(json_str "$gid")" "$(opts_json "seed-time=14400 seed-ratio=0")" >/dev/null
|
||||
log "added torrent $gid: $item (seeding until stopped)"
|
||||
else
|
||||
log "added torrent $gid: $item"
|
||||
@@ -407,7 +404,7 @@ do_restart() { # do_restart <gid> <dir> <seed 0|1> <split> → prints new gids
|
||||
ng=$(rpc aria2.addUri "$(json_arr "$magnet")" "$(opts_json "${oa[@]}")" | jq -r '.result')
|
||||
newgids+=("$ng")
|
||||
if [ "$seed" -eq 1 ]; then
|
||||
rpc aria2.changeOption "$(json_str "$ng")" "$(opts_json "seed-ratio=0")" >/dev/null
|
||||
rpc aria2.changeOption "$(json_str "$ng")" "$(opts_json "seed-time=14400 seed-ratio=0")" >/dev/null
|
||||
fi
|
||||
else
|
||||
while IFS=$'\t' read -r uri fdir fname; do
|
||||
@@ -830,7 +827,7 @@ cmd_set() {
|
||||
local opts
|
||||
opts=$(opts_json "${kvs[@]}")
|
||||
if [ -n "$gid" ]; then
|
||||
rpc aria2.changeOption "$gid" "$opts" >/dev/null
|
||||
rpc aria2.changeOption "$(json_str "$gid")" "$opts" >/dev/null
|
||||
log "set: gid $gid — ${kvs[*]}"
|
||||
else
|
||||
rpc aria2.changeGlobalOption "$opts" >/dev/null
|
||||
|
||||
@@ -3,6 +3,8 @@ set -euo pipefail
|
||||
# POS: network hotspot — Wi-Fi hotspot via create_ap + wihotspot-gui
|
||||
# POS_FLAGS: --foreground
|
||||
|
||||
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
||||
|
||||
LOGFILE=/var/log/linux_post_install_hotspot.log
|
||||
|
||||
usage() {
|
||||
@@ -58,7 +60,7 @@ case "$cmd" in
|
||||
fi
|
||||
if [ $bg -eq 1 ]; then
|
||||
sudo create_ap --daemon --logfile "$LOGFILE" "$@"
|
||||
echo "[+] Hotspot started in background"
|
||||
log "Hotspot started in background"
|
||||
echo " Log : $LOGFILE"
|
||||
echo " Status: pos network hotspot status"
|
||||
echo " Stop : pos network hotspot stop"
|
||||
@@ -86,8 +88,6 @@ case "$cmd" in
|
||||
exec sudo create_ap --list-running
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: Unknown hotspot command '$cmd'"
|
||||
echo "Run 'pos network hotspot --help' for usage."
|
||||
exit 1
|
||||
err "Unknown hotspot command '$cmd' — run 'pos network hotspot --help' for usage."
|
||||
;;
|
||||
esac
|
||||
|
||||
+5
-11
@@ -2,6 +2,8 @@
|
||||
set -euo pipefail
|
||||
# POS: network ip — Show interfaces, routes, public IP + location
|
||||
|
||||
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: pos network ip
|
||||
@@ -15,11 +17,7 @@ case "${1:-}" in
|
||||
-h|--help) usage ;;
|
||||
esac
|
||||
|
||||
SEP() { printf '\u2550%.0s' $(seq 1 47); echo; }
|
||||
|
||||
SEP
|
||||
echo "Network Interfaces"
|
||||
SEP
|
||||
section "Network Interfaces"
|
||||
|
||||
ip -o -4 addr show | while read -r _ ifname _ ipaddr _; do
|
||||
printf "%-20s %s\n" "$ifname" "${ipaddr%%/*}"
|
||||
@@ -27,9 +25,7 @@ done
|
||||
|
||||
echo
|
||||
|
||||
SEP
|
||||
echo "Default Route"
|
||||
SEP
|
||||
section "Default Route"
|
||||
|
||||
gateway=$(ip route | awk '/default/ {print $3; exit}')
|
||||
iface=$(ip route | awk '/default/ {print $5; exit}')
|
||||
@@ -39,9 +35,7 @@ printf "%-20s %s\n" "Gateway" "${gateway:-N/A}"
|
||||
|
||||
echo
|
||||
|
||||
SEP
|
||||
echo "Public IP"
|
||||
SEP
|
||||
section "Public IP"
|
||||
|
||||
PUBLIC_IP=$(curl -4 -s --max-time 5 https://ifconfig.me 2>/dev/null) || true
|
||||
|
||||
|
||||
+25
-29
@@ -2,6 +2,8 @@
|
||||
set -euo pipefail
|
||||
# POS: network scan — Parallel ping sweep of CIDR
|
||||
|
||||
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: pos network scan <cidr> [--full] [--retries N]
|
||||
@@ -25,10 +27,7 @@ EOF
|
||||
}
|
||||
|
||||
# Deps guard before -h|--help (help also errors without nmap).
|
||||
if ! command -v nmap &>/dev/null; then
|
||||
echo "ERROR: nmap is required. Install with: sudo apt install nmap"
|
||||
exit 1
|
||||
fi
|
||||
command -v nmap &>/dev/null || err "nmap not found — install with: sudo apt install nmap"
|
||||
|
||||
case "${1:-}" in
|
||||
-h|--help|"") usage ;;
|
||||
@@ -43,8 +42,7 @@ while [[ $# -gt 0 ]]; do
|
||||
--full) full=1; shift ;;
|
||||
--retries)
|
||||
if [[ -z "${2:-}" || "$2" == --* ]]; then
|
||||
echo "ERROR: --retries requires a number"
|
||||
exit 1
|
||||
err "--retries requires a number"
|
||||
fi
|
||||
retries="$2"; shift 2 ;;
|
||||
*) net="$1"; shift ;;
|
||||
@@ -52,8 +50,7 @@ while [[ $# -gt 0 ]]; do
|
||||
done
|
||||
|
||||
if [[ -z "$net" ]]; then
|
||||
echo "ERROR: Missing CIDR (e.g. 192.168.1.0/24)"
|
||||
exit 1
|
||||
err "CIDR missing — e.g. pos network scan 192.168.1.0/24"
|
||||
fi
|
||||
|
||||
# ── Input validation ───────────────────────────────────────────
|
||||
@@ -64,9 +61,7 @@ if [[ "$net" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
|
||||
elif [[ "$net" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/[0-9]{1,2}$ ]]; then
|
||||
: # ok
|
||||
else
|
||||
echo "ERROR: Invalid target '$net'"
|
||||
echo "Expected: IP (172.1.1.104) or CIDR (192.168.1.0/24)"
|
||||
exit 1
|
||||
err "bad target '$net' — expected IP (172.1.1.104) or CIDR (192.168.1.0/24)"
|
||||
fi
|
||||
|
||||
# ── Estimate host count ────────────────────────────────────────
|
||||
@@ -143,8 +138,9 @@ nmap_args="$nmap_args --script ssh-hostkey,ssl-cert,http-title,http-server-heade
|
||||
[[ "$can_sudo" -eq 1 ]] && nmap_args="$nmap_args -O --osscan-guess"
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
$nmap_cmd $nmap_args -iL "$tmpfile" 2>/dev/null | awk '
|
||||
BEGIN { ip_count = 0; has_os = 0 }
|
||||
$nmap_cmd $nmap_args -iL "$tmpfile" 2>/dev/null | \
|
||||
awk -v cy="$CYAN" -v bo="$BOLD" -v di="$DIM" -v re="$RESET" '
|
||||
BEGIN { ip_count = 0; has_os = 0; hc = cy bo; dc = di }
|
||||
|
||||
/^Nmap scan report for/ {
|
||||
ip = $(NF);
|
||||
@@ -161,16 +157,16 @@ BEGIN { ip_count = 0; has_os = 0 }
|
||||
ip_count++;
|
||||
has_os = 0;
|
||||
if (hostname != "" && hostname != ip)
|
||||
printf "\033[1;36m%s\033[0m (%s)\n", ip, hostname;
|
||||
printf "%s%s (%s)%s\n", hc, ip, hostname, re;
|
||||
else
|
||||
printf "\033[1;36m%s\033[0m\n", ip;
|
||||
printf "%s%s%s\n", hc, ip, re;
|
||||
}
|
||||
|
||||
/^MAC Address/ {
|
||||
vendor = $0;
|
||||
sub(/.*\(/, "", vendor);
|
||||
sub(/\).*/, "", vendor);
|
||||
printf " \033[2m%-10s\033[0m %s %s\n", "MAC:", $3, vendor;
|
||||
printf " %s%-10s%s %s %s\n", dc, "MAC:", re, $3, vendor;
|
||||
}
|
||||
|
||||
/^Aggressive OS guesses:/ {
|
||||
@@ -178,18 +174,18 @@ BEGIN { ip_count = 0; has_os = 0 }
|
||||
line = $0;
|
||||
sub(/.*guesses: /, "", line);
|
||||
gsub(/\s*\(.*/, "", line);
|
||||
printf " \033[2m%-10s\033[0m %s\n", "OS:", line;
|
||||
printf " %s%-10s%s %s\n", dc, "OS:", re, line;
|
||||
}
|
||||
|
||||
/^OS details:/ {
|
||||
has_os = 1;
|
||||
sub(/.*OS details: /, "");
|
||||
printf " \033[2m%-10s\033[0m %s\n", "OS:", $0;
|
||||
printf " %s%-10s%s %s\n", dc, "OS:", re, $0;
|
||||
}
|
||||
|
||||
/^Running:/ {
|
||||
sub(/.*Running: /, "");
|
||||
printf " \033[2m%-10s\033[0m %s\n", "OS:", $0;
|
||||
printf " %s%-10s%s %s\n", dc, "OS:", re, $0;
|
||||
}
|
||||
|
||||
/^Service Info:/ {
|
||||
@@ -197,7 +193,7 @@ BEGIN { ip_count = 0; has_os = 0 }
|
||||
sub(/.*Service Info:/, "", line);
|
||||
gsub(/^ +/, "", line);
|
||||
if (has_os == 0)
|
||||
printf " \033[2m%-10s\033[0m %s\n", "Info:", line;
|
||||
printf " %s%-10s%s %s\n", dc, "Info:", re, line;
|
||||
}
|
||||
|
||||
/^\| ssh-hostkey:/ {
|
||||
@@ -207,7 +203,7 @@ BEGIN { ip_count = 0; has_os = 0 }
|
||||
if (line ~ /SHA256/) {
|
||||
match(line, /SHA256:[A-Za-z0-9+\/=]+/);
|
||||
key = substr(line, RSTART, RLENGTH);
|
||||
printf " \033[2m%-10s\033[0m %s\n", "SSH:", key;
|
||||
printf " %s%-10s%s %s\n", dc, "SSH:", re, key;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,14 +213,14 @@ BEGIN { ip_count = 0; has_os = 0 }
|
||||
gsub(/^ +/, "", line);
|
||||
gsub(/\s*\[.*$/, "", line);
|
||||
if (line != "" && line !~ /^No/)
|
||||
printf " \033[2m%-10s\033[0m %s\n", "HTTP Title:", line;
|
||||
printf " %s%-10s%s %s\n", dc, "HTTP Title:", re, line;
|
||||
}
|
||||
|
||||
/^\| http-server-header:/ {
|
||||
line = $0;
|
||||
sub(/.*http-server-header:/, "", line);
|
||||
gsub(/^ +/, "", line);
|
||||
printf " \033[2m%-10s\033[0m %s\n", "HTTP Server:", line;
|
||||
printf " %s%-10s%s %s\n", dc, "HTTP Server:", re, line;
|
||||
}
|
||||
|
||||
/^\|_?NetBIOS name:/ {
|
||||
@@ -232,14 +228,14 @@ BEGIN { ip_count = 0; has_os = 0 }
|
||||
sub(/.*NetBIOS name:/, "", line);
|
||||
sub(/,.*$/, "", line);
|
||||
gsub(/^ +/, "", line);
|
||||
printf " \033[2m%-10s\033[0m %s\n", "NetBIOS:", line;
|
||||
printf " %s%-10s%s %s\n", dc, "NetBIOS:", re, line;
|
||||
}
|
||||
|
||||
/^\|_?SMB OS:/ {
|
||||
line = $0;
|
||||
sub(/.*SMB OS:/, "", line);
|
||||
gsub(/^ +/, "", line);
|
||||
printf " \033[2m%-10s\033[0m %s\n", "SMB:", line;
|
||||
printf " %s%-10s%s %s\n", dc, "SMB:", re, line;
|
||||
}
|
||||
|
||||
/^\|_?Domain:/ {
|
||||
@@ -247,13 +243,13 @@ BEGIN { ip_count = 0; has_os = 0 }
|
||||
sub(/.*Domain:/, "", line);
|
||||
gsub(/^ +/, "", line);
|
||||
if (line != "" && line !~ /^WORKGROUP/)
|
||||
printf " \033[2m%-10s\033[0m %s\n", "Domain:", line;
|
||||
printf " %s%-10s%s %s\n", dc, "Domain:", re, line;
|
||||
}
|
||||
|
||||
/^\| [0-9]+\/tcp/ {
|
||||
line = $0;
|
||||
gsub(/^ *\| */, "", line);
|
||||
printf " \033[2m%-10s\033[0m %s\n", "RPC:", line;
|
||||
printf " %s%-10s%s %s\n", dc, "RPC:", re, line;
|
||||
}
|
||||
|
||||
/^[0-9]+\/tcp[[:space:]]+open/ {
|
||||
@@ -262,9 +258,9 @@ BEGIN { ip_count = 0; has_os = 0 }
|
||||
for (i = 4; i <= NF; i++) version = version " " $i;
|
||||
gsub(/^ +/, "", version);
|
||||
if (version != "")
|
||||
printf " \033[2m%-10s\033[0m %s — %s\n", port, service, version;
|
||||
printf " %s%-10s%s %s — %s\n", dc, port, re, service, version;
|
||||
else
|
||||
printf " \033[2m%-10s\033[0m %s\n", port, service;
|
||||
printf " %s%-10s%s %s\n", dc, port, re, service;
|
||||
}
|
||||
'
|
||||
|
||||
|
||||
@@ -40,10 +40,10 @@ EOF
|
||||
}
|
||||
|
||||
cmd_ls() {
|
||||
echo "==== HOST USB DEVICES ===="
|
||||
section "Host USB Devices"
|
||||
usbsrv -list-devices
|
||||
echo
|
||||
echo "==== CONNECTED CLIENTS ===="
|
||||
section "Connected Clients"
|
||||
usbsrv -list-clients
|
||||
}
|
||||
|
||||
@@ -65,10 +65,10 @@ cmd_ls_shared() {
|
||||
cmd_share() {
|
||||
local dev="${1:-}" client="${2:-}"
|
||||
if [ -z "$dev" ]; then
|
||||
echo "==== HOST USB DEVICES ===="
|
||||
section "Host USB Devices"
|
||||
usbsrv -list-devices
|
||||
echo
|
||||
echo "==== CONNECTED CLIENTS ===="
|
||||
section "Connected Clients"
|
||||
usbsrv -list-clients
|
||||
echo
|
||||
read -rp "Enter device ID to share: " dev
|
||||
|
||||
+12
-1
@@ -2,6 +2,8 @@
|
||||
set -euo pipefail
|
||||
# POS: ssh load-keys — Load all SSH keys into the agent
|
||||
|
||||
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: pos ssh load-keys
|
||||
@@ -19,13 +21,22 @@ esac
|
||||
|
||||
export SSH_AUTH_SOCK="${SSH_AUTH_SOCK:-/run/ssh-agent/socket}"
|
||||
|
||||
loaded=0
|
||||
for key in ~/.ssh/id_*; do
|
||||
[ -f "$key" ] || continue
|
||||
case "$key" in
|
||||
*.pub|known_hosts|authorized_keys|config) continue ;;
|
||||
esac
|
||||
ssh-keygen -y -f "$key" &>/dev/null || continue
|
||||
ssh-add "$key" 2>/dev/null
|
||||
if ssh-add "$key" 2>/dev/null; then
|
||||
loaded=$((loaded + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$loaded" -eq 0 ]; then
|
||||
echo "No SSH keys found in ~/.ssh/"
|
||||
else
|
||||
ok "Loaded $loaded key(s) into ssh-agent"
|
||||
fi
|
||||
|
||||
ssh-add -l
|
||||
|
||||
+17
-8
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
set -E
|
||||
# POS: system backup — Encrypted (AES-256) folder snapshots (tar + gpg)
|
||||
# POS_FLAGS: --service --no-encrypt
|
||||
# POS_CONFIG: notify | notify.env | NOTIFY_PLATFORM=:Comma-separated notify platforms (default telegram) — shared by backup, firewall, share nfs client/server
|
||||
@@ -52,7 +53,7 @@ EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
command -v tar &>/dev/null || err "tar not found"
|
||||
command -v tar &>/dev/null || err "tar not found — install with: sudo apt install tar"
|
||||
|
||||
# ── USB copy (optional post-backup step) ─────────────────────────
|
||||
# Detection runs AFTER the backup finished, so a stick plugged in while
|
||||
@@ -164,19 +165,23 @@ NAME="$(basename "$FOLDER")"
|
||||
DATE="$(date +%Y-%m-%d_%H-%M-%S)"
|
||||
ARCHIVE="${NAME}_${DATE}.tar.gz"
|
||||
|
||||
echo
|
||||
log "Creating backup..."
|
||||
# ── Backup steps ────────────────────────────────────────────────
|
||||
# Total steps: 3 with encryption, 2 without
|
||||
_total=2
|
||||
[ "$ENCRYPT" -eq 1 ] && _total=3
|
||||
|
||||
step 1 "$_total" "Creating backup archive"
|
||||
echo "Source : $FOLDER"
|
||||
echo "Output : $ARCHIVE"
|
||||
|
||||
sudo tar -czvf "$ARCHIVE" -C "$(dirname "$FOLDER")" "$NAME"
|
||||
|
||||
log "Verifying archive..."
|
||||
step 2 "$_total" "Verifying archive"
|
||||
tar -tzf "$ARCHIVE" > /dev/null
|
||||
log "Archive verified"
|
||||
|
||||
if [ "$ENCRYPT" -eq 1 ]; then
|
||||
command -v gpg &>/dev/null || err "gpg not found (install gnupg)"
|
||||
command -v gpg &>/dev/null || err "gpg not found — install with: sudo apt install gnupg"
|
||||
|
||||
while true; do
|
||||
read -s -rp "Enter backup password: " PASS
|
||||
@@ -190,15 +195,19 @@ if [ "$ENCRYPT" -eq 1 ]; then
|
||||
done
|
||||
unset CONFIRM
|
||||
|
||||
log "Encrypting backup..."
|
||||
gpg --batch --yes --passphrase "$PASS" --symmetric --cipher-algo AES256 "$ARCHIVE"
|
||||
step 3 "$_total" "Encrypting backup"
|
||||
_passfd="$(mktemp)"; printf '%s' "$PASS" > "$_passfd"; chmod 600 "$_passfd"
|
||||
gpg --batch --yes --passphrase-file "$_passfd" --symmetric --cipher-algo AES256 "$ARCHIVE" || { rm -f "$_passfd"; err "GPG encryption failed"; }
|
||||
rm -f "$_passfd"
|
||||
|
||||
rm -f "$ARCHIVE"
|
||||
ARCHIVE="${ARCHIVE}.gpg"
|
||||
chmod 600 "$ARCHIVE"
|
||||
|
||||
log "Verifying encrypted backup..."
|
||||
gpg --batch --quiet --passphrase "$PASS" --decrypt "$ARCHIVE" | tar -tzf - > /dev/null
|
||||
_passfd="$(mktemp)"; printf '%s' "$PASS" > "$_passfd"; chmod 600 "$_passfd"
|
||||
gpg --batch --quiet --passphrase-file "$_passfd" --decrypt "$ARCHIVE" | tar -tzf - > /dev/null || { rm -f "$_passfd"; err "GPG verification failed"; }
|
||||
rm -f "$_passfd"
|
||||
|
||||
unset PASS
|
||||
else
|
||||
|
||||
@@ -4,11 +4,11 @@ set -euo pipefail
|
||||
IFS=$'\n\t'
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "ERROR: Please run as root (sudo)."
|
||||
echo "Usage: sudo pos system firewall"
|
||||
echo "ERROR: firewall requires root — run with sudo"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source "$(dirname "$0")/../lib/common.sh" 2>/dev/null || source "$(dirname "$0")/common.sh"
|
||||
source "$(dirname "$0")/../lib/notify.sh" 2>/dev/null || source "$(dirname "$0")/notify.sh"
|
||||
|
||||
HISTORY=()
|
||||
@@ -35,10 +35,6 @@ if [[ "${1:-}" == "--dry-run" ]]; then
|
||||
DRY_RUN=1
|
||||
fi
|
||||
|
||||
log() { echo "[+] $*"; }
|
||||
warn() { echo "[!] $*"; }
|
||||
err() { echo "ERROR: $*" >&2; exit 1; }
|
||||
|
||||
run_cmd() {
|
||||
local -a cmd=("$@")
|
||||
printf "\n>>> %s\n" "${cmd[*]}"
|
||||
|
||||
@@ -67,7 +67,7 @@ for a in "$@"; do
|
||||
done
|
||||
set -- "${args[@]}"
|
||||
|
||||
[ -d "$SCHEDULE_DIR" ] || log "no jobs yet — add one with 'pos system schedule config'"
|
||||
[ -d "$SCHEDULE_DIR" ] || { echo "No jobs yet — add one with 'pos system schedule config'"; exit 0; }
|
||||
|
||||
case "${1:-}" in
|
||||
run) sched_run "${2:-all}" ;;
|
||||
|
||||
+16
-4
@@ -18,6 +18,7 @@ _pos_flags[network-hotspot]="--foreground"
|
||||
_pos_flags[share-usb-server]="--ls --ls-shared --share --unshare --auto-share --callback --close-callback --auto-connect --disconnect --nickname --timeout --port --info --version"
|
||||
_pos_flags[system-backup]="--service --no-encrypt"
|
||||
_pos_flags[system-schedule]="--dry-run"
|
||||
_pos_flags[dashboard]="--help"
|
||||
_pos_flags[tree]="--depth"
|
||||
# GEN:END posflags
|
||||
# GEN:START possubcmds
|
||||
@@ -30,9 +31,10 @@ _pos_subcmds[docker-compose]="ls installed up down restart logs update config"
|
||||
_pos_subcmds[docker-vbox]="create enter stop start rm ls"
|
||||
_pos_subcmds[network-download]="start stop status add torrent metalink list info files peers pause resume remove purge move limit set watch restart retry replace"
|
||||
_pos_subcmds[system-schedule]="run list config enable disable status migrate"
|
||||
_pos_subcmds[dashboard]="start stop status port config url"
|
||||
# GEN:END possubcmds
|
||||
# GEN:START posconfigscopes
|
||||
declare -a _pos_config_scopes=(ai compose entertainment matrix notify scrcpy system telegram)
|
||||
declare -a _pos_config_scopes=(ai compose dashboard entertainment matrix notify scrcpy system telegram)
|
||||
# GEN:END posconfigscopes
|
||||
|
||||
_pos() {
|
||||
@@ -64,8 +66,10 @@ _pos() {
|
||||
# ── Build category→subcommand map ──────────────────────────
|
||||
# Nested sub-tools (pos-<cat>-<a>-<b> where pos-<cat>-<a> exists) are
|
||||
# offered under their parent tool, not at the category level.
|
||||
# Category-less tools (pos-<cmd> with no dash) are offered at top level.
|
||||
local -A cat_cmds
|
||||
local -A nested
|
||||
local standalone_cmds=""
|
||||
local cmd2 c2 cat2 sub2
|
||||
for cmd in "${all_cmds[@]}"; do
|
||||
local cat="${cmd%%-*}"
|
||||
@@ -84,19 +88,27 @@ _pos() {
|
||||
for cmd in "${all_cmds[@]}"; do
|
||||
local cat="${cmd%%-*}"
|
||||
local sub="${cmd#*-}"
|
||||
if [ "$cat" != "$cmd" ] && [ -z "${nested[$cmd]:-}" ]; then
|
||||
if [ "$cat" = "$cmd" ]; then
|
||||
# Category-less tool (e.g. dashboard, tree, config)
|
||||
standalone_cmds+="${cmd} "
|
||||
elif [ -z "${nested[$cmd]:-}" ]; then
|
||||
cat_cmds["$cat"]+="${sub} "
|
||||
fi
|
||||
done
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────
|
||||
_pos_complete_categories() {
|
||||
COMPREPLY=($(compgen -W "${!cat_cmds[*]} config" -- "$cur"))
|
||||
COMPREPLY=($(compgen -W "${!cat_cmds[*]} ${standalone_cmds}" -- "$cur"))
|
||||
}
|
||||
|
||||
_pos_complete_subcats() {
|
||||
local cat="${words[1]}"
|
||||
COMPREPLY=($(compgen -W "${cat_cmds[$cat]:-} --help" -- "$cur"))
|
||||
# Standalone tools (dashboard, tree) have subcommands in _pos_subcmds
|
||||
if [ -n "${_pos_subcmds[$cat]:-}" ]; then
|
||||
COMPREPLY=($(compgen -W "${_pos_subcmds[$cat]} ${_pos_flags[$cat]:-} --help" -- "$cur"))
|
||||
else
|
||||
COMPREPLY=($(compgen -W "${cat_cmds[$cat]:-} --help" -- "$cur"))
|
||||
fi
|
||||
}
|
||||
|
||||
# Nested sub-tool group (pos-<key>-* with no direct tool) → suggest suffixes.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Dashboard configuration
|
||||
DASHBOARD_PORT=8080
|
||||
DASHBOARD_HOST=0.0.0.0
|
||||
# DASHBOARD_LOG_LEVEL=info
|
||||
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
Docker API blueprint.
|
||||
|
||||
Routes under ``/api/docker/`` for container and stack management.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from lib.runner import run_pos, run_cmd
|
||||
from lib.parsers import parse_docker_ps, parse_docker_health, parse_docker_stacks
|
||||
|
||||
docker_bp = Blueprint("docker", __name__, url_prefix="/api/docker")
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _check_docker() -> str | None:
|
||||
"""Return an error string if docker is unavailable, else None."""
|
||||
result = run_cmd(["docker", "info"], timeout=10)
|
||||
if result.get("error") or result["returncode"] != 0:
|
||||
return result.get("error", "docker daemon not reachable")
|
||||
return None
|
||||
|
||||
|
||||
def _validate_service_name(name: str) -> str | None:
|
||||
"""Return None if valid, otherwise an error message."""
|
||||
if not name:
|
||||
return "missing 'service' in request body"
|
||||
if not re.match(r"^[a-zA-Z0-9._-]+$", name):
|
||||
return f"Invalid service name '{name}'"
|
||||
return None
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@docker_bp.route("/ps", methods=["GET"])
|
||||
def docker_ps():
|
||||
"""List containers: ``docker ps -a --format ...`` or ``pos docker ps``."""
|
||||
docker_err = _check_docker()
|
||||
if docker_err:
|
||||
return jsonify({"error": docker_err}), 503
|
||||
|
||||
# Use docker inspect directly for reliable JSON output, fall back to pos docker ps
|
||||
fmt = "{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}\t{{.ID}}"
|
||||
result = run_cmd(
|
||||
["docker", "ps", "-a", "--format", fmt],
|
||||
timeout=15,
|
||||
)
|
||||
if result.get("error") or result["returncode"] != 0:
|
||||
# Fallback to pos docker ps
|
||||
result = run_pos(["docker", "ps"], timeout=30)
|
||||
|
||||
if result.get("error") and result["returncode"] != 0:
|
||||
return jsonify({"error": result["error"]}), 500
|
||||
|
||||
# Try structured parsing via pos docker ps
|
||||
containers = parse_docker_ps(result["stdout"])
|
||||
|
||||
# If pos docker ps didn't parse well, parse docker ps --format output directly
|
||||
if not containers and result["stdout"].strip():
|
||||
containers = _parse_raw_docker_ps(result["stdout"])
|
||||
|
||||
return jsonify({"containers": containers})
|
||||
|
||||
|
||||
def _parse_raw_docker_ps(text: str) -> list[dict]:
|
||||
"""Parse ``docker ps -a --format`` tab-separated output."""
|
||||
containers = []
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split("\t")
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
name, image, status, ports = parts[0], parts[1], parts[2], parts[3]
|
||||
cid = parts[4] if len(parts) > 4 else ""
|
||||
containers.append({
|
||||
"name": name,
|
||||
"image": image,
|
||||
"status": status,
|
||||
"health": "",
|
||||
"uptime": "",
|
||||
"ips": "",
|
||||
"ports": ports,
|
||||
"id": cid,
|
||||
})
|
||||
return containers
|
||||
|
||||
|
||||
@docker_bp.route("/health", methods=["GET"])
|
||||
def docker_health():
|
||||
"""Get container health summary."""
|
||||
docker_err = _check_docker()
|
||||
if docker_err:
|
||||
return jsonify({"error": docker_err}), 503
|
||||
|
||||
result = run_pos(["docker", "health"], timeout=30)
|
||||
if result.get("error") and result["returncode"] != 0:
|
||||
# health exits 1 when unhealthy — that's still valid output
|
||||
if not result["stdout"]:
|
||||
return jsonify({"error": result.get("error", "health check failed")}), 500
|
||||
|
||||
summary = parse_docker_health(result["stdout"])
|
||||
return jsonify(summary)
|
||||
|
||||
|
||||
@docker_bp.route("/stacks", methods=["GET"])
|
||||
def docker_stacks():
|
||||
"""Get containers grouped by compose stack."""
|
||||
docker_err = _check_docker()
|
||||
if docker_err:
|
||||
return jsonify({"error": docker_err}), 503
|
||||
|
||||
result = run_pos(["docker", "stack"], timeout=30)
|
||||
if result.get("error") and result["returncode"] != 0:
|
||||
return jsonify({"error": result["error"]}), 500
|
||||
|
||||
stacks = parse_docker_stacks(result["stdout"])
|
||||
return jsonify({"stacks": stacks})
|
||||
|
||||
|
||||
@docker_bp.route("/compose/up", methods=["POST"])
|
||||
def docker_compose_up():
|
||||
"""Bring up a compose service."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
service = body.get("service", "")
|
||||
err = _validate_service_name(service)
|
||||
if err:
|
||||
return jsonify({"error": err}), 400
|
||||
|
||||
result = run_pos(["docker", "compose", "up", service], timeout=120)
|
||||
if result.get("error") and result["returncode"] != 0:
|
||||
return jsonify({
|
||||
"error": result["error"],
|
||||
"stdout": result.get("stdout", ""),
|
||||
"stderr": result.get("stderr", ""),
|
||||
}), 500
|
||||
|
||||
return jsonify({
|
||||
"message": f"compose up: {service}",
|
||||
"output": result["stdout"].strip(),
|
||||
})
|
||||
|
||||
|
||||
@docker_bp.route("/compose/down", methods=["POST"])
|
||||
def docker_compose_down():
|
||||
"""Bring down a compose service."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
service = body.get("service", "")
|
||||
err = _validate_service_name(service)
|
||||
if err:
|
||||
return jsonify({"error": err}), 400
|
||||
|
||||
result = run_pos(["docker", "compose", "down", service], timeout=120)
|
||||
if result.get("error") and result["returncode"] != 0:
|
||||
return jsonify({
|
||||
"error": result["error"],
|
||||
"stdout": result.get("stdout", ""),
|
||||
"stderr": result.get("stderr", ""),
|
||||
}), 500
|
||||
|
||||
return jsonify({
|
||||
"message": f"compose down: {service}",
|
||||
"output": result["stdout"].strip(),
|
||||
})
|
||||
|
||||
|
||||
@docker_bp.route("/compose/restart", methods=["POST"])
|
||||
def docker_compose_restart():
|
||||
"""Restart a compose service."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
service = body.get("service", "")
|
||||
err = _validate_service_name(service)
|
||||
if err:
|
||||
return jsonify({"error": err}), 400
|
||||
|
||||
result = run_pos(["docker", "compose", "restart", service], timeout=120)
|
||||
if result.get("error") and result["returncode"] != 0:
|
||||
return jsonify({
|
||||
"error": result["error"],
|
||||
"stdout": result.get("stdout", ""),
|
||||
"stderr": result.get("stderr", ""),
|
||||
}), 500
|
||||
|
||||
return jsonify({
|
||||
"message": f"compose restart: {service}",
|
||||
"output": result["stdout"].strip(),
|
||||
})
|
||||
@@ -0,0 +1,236 @@
|
||||
"""
|
||||
Entertainment API blueprint.
|
||||
|
||||
Routes under ``/api/entertainment/`` that wrap the entertainment CLI tools.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from lib.runner import run_pos, run_cmd
|
||||
from lib.parsers import parse_entertainment_status
|
||||
from lib.config import load_entertainment_env, mask_secrets
|
||||
|
||||
entertainment_bp = Blueprint("entertainment", __name__, url_prefix="/api/entertainment")
|
||||
|
||||
# Directories to search for installed plugins
|
||||
_PLUGIN_SEARCH_DIRS = [
|
||||
Path("/usr/local/share/linux_post_install/entertainment"),
|
||||
Path(__file__).resolve().parents[2] / "entertainment",
|
||||
]
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _plugin_dirs():
|
||||
"""Yield existing plugin directories."""
|
||||
for d in _PLUGIN_SEARCH_DIRS:
|
||||
if d.is_dir():
|
||||
yield d
|
||||
|
||||
|
||||
def _discover_plugins() -> list[dict]:
|
||||
"""Read ``# POS_PLUGIN:`` / ``# POS_KEYS:`` headers from installed plugins."""
|
||||
plugins: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for d in _plugin_dirs():
|
||||
for script in sorted(d.glob("*.sh")):
|
||||
name = ""
|
||||
keys: list[dict] = []
|
||||
description = ""
|
||||
with open(script, "r", encoding="utf-8", errors="replace") as fh:
|
||||
for line in fh:
|
||||
if line.startswith("# POS_PLUGIN:"):
|
||||
name = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("# POS_KEYS:"):
|
||||
raw = line.split(":", 1)[1].strip()
|
||||
# Format: KEY description (required|optional)
|
||||
parts = raw.split(None, 1)
|
||||
if parts:
|
||||
key_entry = {"key": parts[0]}
|
||||
if len(parts) > 1:
|
||||
rest = parts[1]
|
||||
if "(required)" in rest:
|
||||
key_entry["required"] = True
|
||||
key_entry["description"] = rest.replace("(required)", "").strip()
|
||||
elif "(optional)" in rest:
|
||||
key_entry["required"] = False
|
||||
key_entry["description"] = rest.replace("(optional)", "").strip()
|
||||
else:
|
||||
key_entry["description"] = rest.strip()
|
||||
keys.append(key_entry)
|
||||
elif line.startswith("# Entertainment plugin:") and not description:
|
||||
description = line.split(":", 1)[1].strip()
|
||||
|
||||
# Stop reading after code starts
|
||||
if name and (line.startswith("usage()") or line.startswith("main()")):
|
||||
break
|
||||
|
||||
if name and name not in seen:
|
||||
seen.add(name)
|
||||
plugins.append({
|
||||
"name": name,
|
||||
"description": description,
|
||||
"path": str(script),
|
||||
"keys": keys,
|
||||
})
|
||||
|
||||
return plugins
|
||||
|
||||
|
||||
def _plugin_names() -> set[str]:
|
||||
"""Set of installed plugin names."""
|
||||
return {p["name"] for p in _discover_plugins()}
|
||||
|
||||
|
||||
def _validate_plugin_name(name: str) -> str | None:
|
||||
"""Return None if valid, otherwise an error message."""
|
||||
if not name or not re.match(r"^[a-zA-Z0-9_-]+$", name):
|
||||
return "Invalid plugin name"
|
||||
if name not in _plugin_names():
|
||||
return f"Plugin '{name}' not found"
|
||||
return None
|
||||
|
||||
|
||||
def _validate_interval(interval: str) -> str | None:
|
||||
"""Return None if valid, otherwise an error message."""
|
||||
pattern = r"^[0-9]+[mhd]$|^(hourly|daily|weekly)$|^OnCalendar="
|
||||
if not re.match(pattern, interval):
|
||||
return f"Invalid interval '{interval}' (allowed: 5m, 10m, 15m, 30m, 45m, hourly, 2h, 6h, 12h, daily, weekly)"
|
||||
return None
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@entertainment_bp.route("/status", methods=["GET"])
|
||||
def entertainment_status():
|
||||
"""Run ``pos entertainment status`` and return parsed JSON."""
|
||||
result = run_pos(["entertainment", "status"], timeout=15)
|
||||
if result.get("error"):
|
||||
return jsonify({"error": result["error"]}), 500
|
||||
parsed = parse_entertainment_status(result["stdout"])
|
||||
return jsonify(parsed)
|
||||
|
||||
|
||||
@entertainment_bp.route("/plugins", methods=["GET"])
|
||||
def entertainment_plugins():
|
||||
"""Discover installed entertainment plugins and return their metadata."""
|
||||
plugins = _discover_plugins()
|
||||
return jsonify({"plugins": plugins})
|
||||
|
||||
|
||||
@entertainment_bp.route("/send/<plugin>", methods=["POST"])
|
||||
def entertainment_send(plugin: str):
|
||||
"""Run ``pos entertainment send <plugin> --print`` and return output."""
|
||||
err = _validate_plugin_name(plugin)
|
||||
if err:
|
||||
return jsonify({"error": err}), 400
|
||||
|
||||
result = run_pos(["entertainment", "send", plugin, "--print"], timeout=60)
|
||||
if result.get("error") and result["returncode"] != 0:
|
||||
return jsonify({
|
||||
"error": result["error"],
|
||||
"stderr": result.get("stderr", ""),
|
||||
"returncode": result["returncode"],
|
||||
}), 500
|
||||
|
||||
return jsonify({
|
||||
"output": result["stdout"].strip(),
|
||||
"returncode": result["returncode"],
|
||||
})
|
||||
|
||||
|
||||
@entertainment_bp.route("/enable", methods=["POST"])
|
||||
def entertainment_enable():
|
||||
"""Enable an auto-trigger: ``pos entertainment enable <plugin> <interval>``."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
plugin = body.get("plugin", "")
|
||||
interval = body.get("interval", "")
|
||||
|
||||
if not plugin:
|
||||
return jsonify({"error": "missing 'plugin' in request body"}), 400
|
||||
|
||||
err = _validate_plugin_name(plugin)
|
||||
if err:
|
||||
return jsonify({"error": err}), 400
|
||||
|
||||
if interval:
|
||||
err = _validate_interval(interval)
|
||||
if err:
|
||||
return jsonify({"error": err}), 400
|
||||
|
||||
args = ["entertainment", "enable", plugin]
|
||||
if interval:
|
||||
args.append(interval)
|
||||
|
||||
result = run_pos(args, timeout=30)
|
||||
if result.get("error") and result["returncode"] != 0:
|
||||
return jsonify({
|
||||
"error": result["error"],
|
||||
"stderr": result.get("stderr", ""),
|
||||
}), 500
|
||||
|
||||
return jsonify({"message": result["stdout"].strip() or f"enabled {plugin}"})
|
||||
|
||||
|
||||
@entertainment_bp.route("/disable", methods=["POST"])
|
||||
def entertainment_disable():
|
||||
"""Disable an auto-trigger: ``pos entertainment disable <plugin>``."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
plugin = body.get("plugin", "")
|
||||
|
||||
if not plugin:
|
||||
return jsonify({"error": "missing 'plugin' in request body"}), 400
|
||||
|
||||
err = _validate_plugin_name(plugin)
|
||||
if err:
|
||||
return jsonify({"error": err}), 400
|
||||
|
||||
result = run_pos(["entertainment", "disable", plugin], timeout=30)
|
||||
if result.get("error") and result["returncode"] != 0:
|
||||
return jsonify({
|
||||
"error": result["error"],
|
||||
"stderr": result.get("stderr", ""),
|
||||
}), 500
|
||||
|
||||
return jsonify({"message": result["stdout"].strip() or f"disabled {plugin}"})
|
||||
|
||||
|
||||
@entertainment_bp.route("/config", methods=["GET"])
|
||||
def entertainment_config():
|
||||
"""Load entertainment.env, mask secrets, return key-value pairs."""
|
||||
data = load_entertainment_env()
|
||||
masked = mask_secrets(data)
|
||||
return jsonify({"config": masked})
|
||||
|
||||
|
||||
@entertainment_bp.route("/config/set", methods=["POST"])
|
||||
def entertainment_config_set():
|
||||
"""Set a config value: ``pos entertainment config set KEY=VALUE``."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
key = body.get("key", "")
|
||||
value = body.get("value")
|
||||
|
||||
if not key:
|
||||
return jsonify({"error": "missing 'key' in request body"}), 400
|
||||
if value is None:
|
||||
return jsonify({"error": "missing 'value' in request body"}), 400
|
||||
|
||||
# Validate key format
|
||||
if not re.match(r"^[A-Z][A-Z0-9_]*$", key):
|
||||
return jsonify({"error": f"Invalid key '{key}' (expected UPPER_SNAKE)"}), 400
|
||||
|
||||
pair = f"{key}={value}"
|
||||
result = run_pos(["entertainment", "config", "set", pair], timeout=15)
|
||||
if result.get("error") and result["returncode"] != 0:
|
||||
return jsonify({
|
||||
"error": result["error"],
|
||||
"stderr": result.get("stderr", ""),
|
||||
}), 500
|
||||
|
||||
return jsonify({"message": result["stdout"].strip() or f"set {key}"})
|
||||
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
System API blueprint.
|
||||
|
||||
Routes under ``/api/system/`` for host health, info, and dashboard settings.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from lib.runner import run_pos, run_cmd
|
||||
from lib.parsers import parse_system_health
|
||||
|
||||
system_bp = Blueprint("system", __name__, url_prefix="/api/system")
|
||||
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _config_dir() -> Path:
|
||||
return Path(os.environ.get(
|
||||
"XDG_CONFIG_HOME", os.path.expanduser("~/.config")
|
||||
)) / "linux_post_install"
|
||||
|
||||
|
||||
def _dashboard_env_path() -> Path:
|
||||
return _config_dir() / "dashboard.env"
|
||||
|
||||
|
||||
def _read_dashboard_env() -> dict[str, str]:
|
||||
"""Read dashboard.env into a dict (no shell expansion)."""
|
||||
path = _dashboard_env_path()
|
||||
data: dict[str, str] = {}
|
||||
if not path.is_file():
|
||||
return data
|
||||
for line in path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
data[k.strip()] = v.strip().strip('"').strip("'")
|
||||
return data
|
||||
|
||||
|
||||
def _write_dashboard_env(data: dict[str, str]) -> None:
|
||||
"""Write dashboard.env preserving comments."""
|
||||
path = _dashboard_env_path()
|
||||
lines: list[str] = []
|
||||
for k, v in data.items():
|
||||
lines.append(f"{k}={v}")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("\n".join(lines) + "\n")
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@system_bp.route("/health", methods=["GET"])
|
||||
def system_health():
|
||||
"""Run ``pos system health`` and return structured checks."""
|
||||
result = run_pos(["system", "health"], timeout=30)
|
||||
if result.get("error") and not result["stdout"]:
|
||||
return jsonify({"error": result["error"]}), 500
|
||||
|
||||
checks = parse_system_health(result["stdout"])
|
||||
return jsonify({"checks": checks})
|
||||
|
||||
|
||||
@system_bp.route("/info", methods=["GET"])
|
||||
def system_info():
|
||||
"""Collect basic system information.
|
||||
|
||||
Returns::
|
||||
|
||||
{
|
||||
"hostname": "...",
|
||||
"uptime": "...",
|
||||
"load": "...",
|
||||
"ip": "...",
|
||||
"os": { "name": "...", "version": "...", "id": "..." }
|
||||
}
|
||||
"""
|
||||
info: dict = {}
|
||||
|
||||
# Hostname
|
||||
res = run_cmd(["hostname", "-s"], timeout=5)
|
||||
info["hostname"] = res["stdout"].strip() if not res.get("error") else "unknown"
|
||||
|
||||
# Uptime
|
||||
res = run_cmd(["uptime", "-p"], timeout=5)
|
||||
info["uptime"] = res["stdout"].strip() if not res.get("error") else "unknown"
|
||||
|
||||
# Load average
|
||||
try:
|
||||
with open("/proc/loadavg", "r") as f:
|
||||
parts = f.read().strip().split()
|
||||
info["load"] = " ".join(parts[:3]) if len(parts) >= 3 else "unknown"
|
||||
except (OSError, IndexError):
|
||||
info["load"] = "unknown"
|
||||
|
||||
# Public IP (best-effort, fast timeout)
|
||||
res = run_cmd(["curl", "-fsS", "-m", "5", "https://api.ipify.org"], timeout=10)
|
||||
info["ip"] = res["stdout"].strip() if not res.get("error") else "unreachable"
|
||||
|
||||
# OS release info
|
||||
os_info: dict = {}
|
||||
try:
|
||||
with open("/etc/os-release", "r") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
v = v.strip('"')
|
||||
k_lower = k.lower()
|
||||
if k_lower == "name":
|
||||
os_info["name"] = v
|
||||
elif k_lower == "version":
|
||||
os_info["version"] = v
|
||||
elif k_lower == "id":
|
||||
os_info["id"] = v
|
||||
except OSError:
|
||||
os_info = {"name": "unknown", "version": "unknown", "id": "unknown"}
|
||||
|
||||
info["os"] = os_info
|
||||
|
||||
return jsonify(info)
|
||||
|
||||
|
||||
# ── Dashboard settings ─────────────────────────────────────────────────────────
|
||||
|
||||
@system_bp.route("/settings", methods=["GET"])
|
||||
def dashboard_settings():
|
||||
"""Return current dashboard config (secrets masked)."""
|
||||
data = _read_dashboard_env()
|
||||
# Mask secrets
|
||||
for k in list(data.keys()):
|
||||
if any(s in k.upper() for s in ("TOKEN", "KEY", "SECRET", "PASSWORD")):
|
||||
val = data[k]
|
||||
data[k] = val[:4] + "****" if len(val) > 4 else "****"
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@system_bp.route("/settings", methods=["POST"])
|
||||
def dashboard_settings_update():
|
||||
"""Update dashboard config values.
|
||||
|
||||
Expects JSON body: ``{"key": "DASHBOARD_PORT", "value": "9090"}``
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
key = (body.get("key") or "").strip()
|
||||
value = (body.get("value") or "").strip()
|
||||
|
||||
if not key:
|
||||
return jsonify({"error": "missing key"}), 400
|
||||
|
||||
# Validate key name (UPPER_SNAKE only)
|
||||
if not re.match(r"^[A-Z][A-Z0-9_]*$", key):
|
||||
return jsonify({"error": f"invalid key format: {key}"}), 400
|
||||
|
||||
allowed_keys = {"DASHBOARD_PORT", "DASHBOARD_HOST", "DASHBOARD_LOG_LEVEL"}
|
||||
if key not in allowed_keys:
|
||||
return jsonify({"error": f"key not allowed: {key} — allowed: {', '.join(sorted(allowed_keys))}"}), 400
|
||||
|
||||
# Validate port specifically
|
||||
if key == "DASHBOARD_PORT":
|
||||
if not value.isdigit() or not (1 <= int(value) <= 65535):
|
||||
return jsonify({"error": "port must be a number between 1 and 65535"}), 400
|
||||
|
||||
data = _read_dashboard_env()
|
||||
old_value = data.get(key)
|
||||
data[key] = value
|
||||
_write_dashboard_env(data)
|
||||
|
||||
restarted = False
|
||||
# Restart the dashboard service if it's running and port/host changed
|
||||
if key in ("DASHBOARD_PORT", "DASHBOARD_HOST") and old_value != value:
|
||||
svc = "pos-dashboard.service"
|
||||
try:
|
||||
import subprocess
|
||||
res = subprocess.run(
|
||||
["systemctl", "--user", "is-active", "--quiet", svc],
|
||||
timeout=5,
|
||||
)
|
||||
if res.returncode == 0:
|
||||
subprocess.run(
|
||||
["systemctl", "--user", "restart", svc],
|
||||
timeout=10,
|
||||
)
|
||||
restarted = True
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
resp: dict = {"ok": True, "key": key, "value": value}
|
||||
if restarted:
|
||||
resp["restarted"] = True
|
||||
resp["message"] = f"Updated {key} and restarted dashboard"
|
||||
else:
|
||||
resp["message"] = f"Updated {key} — restart with: pos dashboard start"
|
||||
return jsonify(resp)
|
||||
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
Telegram Listener API blueprint.
|
||||
|
||||
Routes under ``/api/telegram/`` that manage the Telegram bot listener service.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from lib.runner import run_pos, run_cmd
|
||||
from lib.parsers import parse_telegram_commands
|
||||
from lib.config import load_telegram_env, mask_secrets
|
||||
|
||||
telegram_bp = Blueprint("telegram", __name__, url_prefix="/api/telegram")
|
||||
|
||||
_SERVICE = "pos-telegram-listener.service"
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _systemd_user(*args: str, timeout: int = 15) -> dict:
|
||||
"""Run a ``systemctl --user`` command."""
|
||||
return run_cmd(["systemctl", "--user"] + list(args), timeout=timeout)
|
||||
|
||||
|
||||
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@telegram_bp.route("/status", methods=["GET"])
|
||||
def telegram_status():
|
||||
"""Check the systemd user service status for the Telegram listener.
|
||||
|
||||
Returns::
|
||||
|
||||
{"running": bool, "active_state": str, "sub_state": str}
|
||||
"""
|
||||
# Check active state
|
||||
res = _systemd_user("is-active", _SERVICE)
|
||||
running = res["returncode"] == 0 and res["stdout"].strip() == "active"
|
||||
|
||||
# Get detailed properties
|
||||
props: dict = {"active_state": res["stdout"].strip(), "sub_state": ""}
|
||||
detail = _systemd_user("show", _SERVICE, "--property=ActiveState,SubState,MainPID")
|
||||
if detail["returncode"] == 0:
|
||||
for line in detail["stdout"].splitlines():
|
||||
if "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
props[k.strip().lower()] = v.strip()
|
||||
|
||||
return jsonify({
|
||||
"running": running,
|
||||
"active_state": props.get("activestate", "unknown"),
|
||||
"sub_state": props.get("substate", "unknown"),
|
||||
"pid": props.get("mainpid", ""),
|
||||
})
|
||||
|
||||
|
||||
@telegram_bp.route("/enable", methods=["POST"])
|
||||
def telegram_enable():
|
||||
"""Enable the Telegram listener service."""
|
||||
result = run_pos(["communication", "telegram-listener", "--enable"], timeout=30)
|
||||
if result.get("error") and result["returncode"] != 0:
|
||||
return jsonify({
|
||||
"error": result["error"],
|
||||
"stderr": result.get("stderr", ""),
|
||||
}), 500
|
||||
|
||||
return jsonify({"message": result["stdout"].strip() or "telegram listener enabled"})
|
||||
|
||||
|
||||
@telegram_bp.route("/disable", methods=["POST"])
|
||||
def telegram_disable():
|
||||
"""Disable the Telegram listener service."""
|
||||
result = run_pos(["communication", "telegram-listener", "--disable"], timeout=30)
|
||||
if result.get("error") and result["returncode"] != 0:
|
||||
return jsonify({
|
||||
"error": result["error"],
|
||||
"stderr": result.get("stderr", ""),
|
||||
}), 500
|
||||
|
||||
return jsonify({"message": result["stdout"].strip() or "telegram listener disabled"})
|
||||
|
||||
|
||||
@telegram_bp.route("/commands", methods=["GET"])
|
||||
def telegram_commands():
|
||||
"""Read the telegram_commands.env and return parsed command map."""
|
||||
config_dir = Path(
|
||||
__import__("os").environ.get(
|
||||
"XDG_CONFIG_HOME",
|
||||
__import__("os").path.expanduser("~/.config"),
|
||||
)
|
||||
) / "linux_post_install"
|
||||
map_file = config_dir / "telegram_commands.env"
|
||||
|
||||
if not map_file.is_file():
|
||||
return jsonify({"commands": [], "map_file": str(map_file)})
|
||||
|
||||
text = map_file.read_text(encoding="utf-8", errors="replace")
|
||||
commands = parse_telegram_commands(text)
|
||||
return jsonify({"commands": commands, "map_file": str(map_file)})
|
||||
|
||||
|
||||
@telegram_bp.route("/commands", methods=["POST"])
|
||||
def telegram_commands_add():
|
||||
"""Add a command to the telegram_commands.env map file.
|
||||
|
||||
Expects JSON body: ``{"command": "/status", "description": "...", "script": "..."}``
|
||||
"""
|
||||
body = request.get_json(silent=True) or {}
|
||||
command = (body.get("command") or "").strip()
|
||||
description = (body.get("description") or "").strip()
|
||||
script = (body.get("script") or "").strip()
|
||||
|
||||
if not command:
|
||||
return jsonify({"error": "missing command name"}), 400
|
||||
if not script:
|
||||
return jsonify({"error": "missing bash script"}), 400
|
||||
|
||||
# Validate command starts with /
|
||||
if not command.startswith("/"):
|
||||
command = "/" + command
|
||||
|
||||
# Validate command name (alphanumeric, hyphens, underscores)
|
||||
if not re.match(r"^/[a-zA-Z0-9_-]+$", command):
|
||||
return jsonify({"error": f"invalid command name: {command} — use only letters, numbers, hyphens"}), 400
|
||||
|
||||
# Build the map line
|
||||
if description:
|
||||
line = f"{command}::{description}={script}"
|
||||
else:
|
||||
line = f"{command}={script}"
|
||||
|
||||
# Write to the map file
|
||||
config_dir = Path(
|
||||
os.environ.get(
|
||||
"XDG_CONFIG_HOME",
|
||||
os.path.expanduser("~/.config"),
|
||||
)
|
||||
) / "linux_post_install"
|
||||
map_file = config_dir / "telegram_commands.env"
|
||||
|
||||
# Check if command already exists
|
||||
if map_file.is_file():
|
||||
existing = map_file.read_text(encoding="utf-8", errors="replace")
|
||||
for existing_line in existing.splitlines():
|
||||
existing_line = existing_line.strip()
|
||||
if not existing_line or existing_line.startswith("#"):
|
||||
continue
|
||||
existing_cmd = existing_line.split("=")[0].split("::")[0].strip()
|
||||
if existing_cmd == command:
|
||||
return jsonify({"error": f"command {command} already exists — delete it first"}), 409
|
||||
|
||||
# Append the new command
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(map_file, "a", encoding="utf-8") as f:
|
||||
f.write(line + "\n")
|
||||
|
||||
return jsonify({"ok": True, "command": command, "message": f"Command {command} added"})
|
||||
|
||||
|
||||
@telegram_bp.route("/commands/sync", methods=["POST"])
|
||||
def telegram_commands_sync():
|
||||
"""Push mapped commands to the bot's "/" menu (setMyCommands)."""
|
||||
result = run_pos(
|
||||
["communication", "telegram-listener", "--sync-commands"],
|
||||
timeout=30,
|
||||
)
|
||||
if result.get("error") and result["returncode"] != 0:
|
||||
return jsonify({
|
||||
"error": result["error"],
|
||||
"stderr": result.get("stderr", ""),
|
||||
}), 500
|
||||
|
||||
return jsonify({"message": result["stdout"].strip() or "commands synced"})
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
pos Web Dashboard — Flask backend.
|
||||
|
||||
Thin API layer that wraps pos-* CLI tools via subprocess, parses their
|
||||
text output, and serves JSON. Designed to run behind Tailscale (no auth).
|
||||
|
||||
Usage:
|
||||
python3 app.py # direct
|
||||
python3 -m flask --app app run # via flask CLI
|
||||
"""
|
||||
|
||||
import os
|
||||
from flask import Flask, jsonify, send_from_directory
|
||||
|
||||
app = Flask(
|
||||
__name__,
|
||||
static_folder="static",
|
||||
template_folder="templates",
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration from environment
|
||||
# ---------------------------------------------------------------------------
|
||||
DASHBOARD_HOST = os.environ.get("DASHBOARD_HOST", "0.0.0.0")
|
||||
DASHBOARD_PORT = int(os.environ.get("DASHBOARD_PORT", "8080"))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CORS (development convenience — Tailscale-only in production)
|
||||
# ---------------------------------------------------------------------------
|
||||
@app.after_request
|
||||
def _add_cors(response):
|
||||
response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
response.headers["Access-Control-Allow-Headers"] = "Content-Type"
|
||||
response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
|
||||
return response
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Register API blueprints
|
||||
# ---------------------------------------------------------------------------
|
||||
from api.entertainment import entertainment_bp # noqa: E402
|
||||
from api.telegram import telegram_bp # noqa: E402
|
||||
from api.docker import docker_bp # noqa: E402
|
||||
from api.system import system_bp # noqa: E402
|
||||
|
||||
app.register_blueprint(entertainment_bp)
|
||||
app.register_blueprint(telegram_bp)
|
||||
app.register_blueprint(docker_bp)
|
||||
app.register_blueprint(system_bp)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SPA shell — serves the single-page app at /
|
||||
# ---------------------------------------------------------------------------
|
||||
@app.route("/")
|
||||
def index():
|
||||
return send_from_directory(app.template_folder, "index.html")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Health check
|
||||
# ---------------------------------------------------------------------------
|
||||
@app.route("/api/health")
|
||||
def api_health():
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
@app.errorhandler(404)
|
||||
def not_found(_exc):
|
||||
return jsonify({"error": "not found"}), 404
|
||||
|
||||
@app.errorhandler(500)
|
||||
def server_error(_exc):
|
||||
return jsonify({"error": "internal server error"}), 500
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Direct execution
|
||||
# ---------------------------------------------------------------------------
|
||||
if __name__ == "__main__":
|
||||
app.run(host=DASHBOARD_HOST, port=DASHBOARD_PORT, debug=True)
|
||||
@@ -0,0 +1,168 @@
|
||||
# Dashboard Purpose & Scope — Philosopher Analysis
|
||||
|
||||
> **Status: PURPOSE_ESTABLISHED**
|
||||
>
|
||||
> The dashboard exists to make the entertainment and monitoring features accessible from any device, with minimal cognitive load, while respecting the project's simplicity and self-contained nature.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why Does This Dashboard Exist?
|
||||
|
||||
The dashboard exists to solve a fundamental limitation of the CLI: **location dependency**. The `pos` tools are powerful but require terminal access. This creates friction in two scenarios:
|
||||
|
||||
**A) The "couch user" scenario.** You're on your phone, your tablet, or another computer. You want to check the weather, see if your Docker containers are healthy, or view the gold price. Currently, you must SSH into the server or remember CLI commands. A web dashboard removes this friction.
|
||||
|
||||
**B) The "at-a-glance" scenario.** The CLI excels at actions but is poor at visualization. `pos system health` gives you text output. A dashboard can show you a green/yellow/red status bar, trend lines, and historical data in a single glance. The CLI is the scalpel; the dashboard is the cockpit.
|
||||
|
||||
**C) The "entertainment hub" scenario.** Entertainment plugins (weather, joke, gold) are designed to be sent to Telegram. But what if you want to preview them, configure them, or see their history without opening Telegram? The dashboard becomes the control room for your entertainment pipeline.
|
||||
|
||||
The dashboard should not exist to replace the CLI. The CLI is the primary interface. The dashboard is the **remote control** — a thin layer that calls the same tools and displays their output in a visual format.
|
||||
|
||||
---
|
||||
|
||||
## 2. Who Is the User?
|
||||
|
||||
The user is a **homelab operator** — someone who runs a personal server at home. This means:
|
||||
|
||||
- **Technical but time-constrained.** They can SSH, but they'd rather not when they're on the couch.
|
||||
- **Mobile-first for casual use.** Phone/tablet is the primary dashboard device. The UI must work on small screens.
|
||||
- **Privacy-conscious.** This is a personal homelab, not a public service. The dashboard runs on Tailscale or localhost. No authentication is needed beyond network access.
|
||||
- **Aesthetics matter.** "Very solid and very modern UI/UX" means they want it to look good, not just work. This is a personal project they'll look at daily.
|
||||
|
||||
---
|
||||
|
||||
## 3. Core Value Proposition
|
||||
|
||||
**"I can see and control my homelab from anywhere, without thinking about it."**
|
||||
|
||||
The dashboard's value is not in doing something the CLI can't. It's in doing the same things with **less cognitive load**:
|
||||
- One glance → system health
|
||||
- One tap → enable/disable entertainment plugin
|
||||
- One view → see all Docker containers
|
||||
- One screen → see Telegram listener status and command map
|
||||
|
||||
The CLI is for power. The dashboard is for convenience.
|
||||
|
||||
---
|
||||
|
||||
## 4. Scope Boundaries
|
||||
|
||||
### IN scope for v1:
|
||||
- **Entertainment hub** (the "mainly" focus):
|
||||
- View installed plugins (weather, joke, gold)
|
||||
- Enable/disable auto-triggers with interval selection
|
||||
- View last-run history and output preview
|
||||
- Send test messages
|
||||
- Configure plugin settings (weather location, etc.)
|
||||
- **Telegram listener overview:**
|
||||
- Listener status (running/stopped)
|
||||
- Command map visualization (/command → bash)
|
||||
- Add/edit/remove commands via web UI
|
||||
- Recent command execution log
|
||||
- **Docker container overview:**
|
||||
- Container status (running, unhealthy, stopped)
|
||||
- Quick actions: restart, stop
|
||||
- Basic logs view
|
||||
- **System health:**
|
||||
- Visual dashboard of disk, RAM, services, backups
|
||||
- Trend indicators (green/yellow/red)
|
||||
- **Unified navigation:**
|
||||
- Category sidebar (ai, communication, docker, entertainment, media, network, share, ssh, system)
|
||||
- Status-only for most categories (not full UI)
|
||||
|
||||
### OUT of scope for v1:
|
||||
- Full CLI replacement (every tool having a web UI)
|
||||
- Interactive terminal in browser (xterm.js is out)
|
||||
- Docker Compose service deployment (ScaleTail)
|
||||
- Media downloads (mp3/mp4)
|
||||
- Network scanning
|
||||
- SSH key management
|
||||
- Complex firewall management
|
||||
|
||||
---
|
||||
|
||||
## 5. Telegram Listener Integration
|
||||
|
||||
The "also Telegram listener" request means the dashboard should be the **control panel** for the Telegram bot. Specifically:
|
||||
|
||||
**Show status:**
|
||||
- Is the listener running? (check `pos-telegram-listener.service` status)
|
||||
- How many commands are mapped?
|
||||
- When was the last command executed?
|
||||
|
||||
**Command map management:**
|
||||
- Visual list of `/command → bash` mappings
|
||||
- Add new commands via form (with syntax validation)
|
||||
- Edit existing commands inline
|
||||
- Delete commands
|
||||
- Toggle `@quiet` prefix
|
||||
|
||||
**Execution history:**
|
||||
- Recent command executions (what was sent, output, exit code)
|
||||
- This is useful for debugging why a command failed
|
||||
|
||||
**Security consideration:**
|
||||
- The dashboard should NOT expose bot tokens or chat IDs
|
||||
- Configuration changes should go through the same `pos config` mechanisms
|
||||
- The dashboard is a UI layer, not a direct Telegram API client
|
||||
|
||||
---
|
||||
|
||||
## 6. Entertainment Focus
|
||||
|
||||
"Mainly for entertainment" means the entertainment module is the **primary use case** for the dashboard. This is interesting because entertainment is currently the most "visual" module — it sends formatted messages to Telegram. The dashboard extends this visual nature to the browser.
|
||||
|
||||
**Entertainment dashboard features:**
|
||||
|
||||
- **Plugin cards:** Each plugin (weather, joke, gold) gets a card showing:
|
||||
- Plugin name and description
|
||||
- Current status (enabled/disabled)
|
||||
- Interval (if enabled)
|
||||
- Last run time and output preview
|
||||
- "Send now" button (triggers `pos entertainment send <plugin> --print`)
|
||||
|
||||
- **Configuration panel:**
|
||||
- Weather: latitude, longitude, city label
|
||||
- Future plugins: their respective keys
|
||||
- ENABLED list management (add/remove intervals)
|
||||
|
||||
- **History timeline:**
|
||||
- When each plugin last ran
|
||||
- Success/failure status
|
||||
- Preview of last output
|
||||
|
||||
---
|
||||
|
||||
## 7. "All pos tools" — What This Actually Means
|
||||
|
||||
This does NOT mean every single tool gets a web UI. That would be:
|
||||
- A massive scope explosion
|
||||
- A maintenance nightmare (maintaining two UIs for every tool)
|
||||
- Antithetical to the project's "simplicity" value
|
||||
|
||||
Instead, "all pos tools" means **unified visibility**:
|
||||
|
||||
- **Overview tab:** Status of all categories at a glance (green/yellow/red)
|
||||
- **Deep-dive tabs:** Full UI for high-value categories:
|
||||
- Entertainment (primary)
|
||||
- Docker (secondary)
|
||||
- System health (secondary)
|
||||
- **Status-only for others:** Show status, link to CLI help
|
||||
- Network: show IP, hotspot status
|
||||
- Share: show NFS/SMB server status
|
||||
- SSH: show key count
|
||||
- Communication: show Telegram/Matrix listener status
|
||||
- Media: show last download (if logged)
|
||||
- AI: show Gemini session count (if available)
|
||||
|
||||
---
|
||||
|
||||
## 8. The Soul of the Dashboard
|
||||
|
||||
The dashboard is not a tech demo. It is a **convenience layer** for a personal homelab. Its soul is:
|
||||
|
||||
- **Thin:** It calls the same CLI tools under the hood. No business logic duplication.
|
||||
- **Beautiful:** Modern UI/UX that matches the "very solid and very modern" request.
|
||||
- **Focused:** Entertainment first, then Docker/system, then everything else as status.
|
||||
- **Personal:** This runs on YOUR homelab, for YOU. No auth, no multi-tenancy, no scaling.
|
||||
- **Bash-native:** The dashboard should feel like a natural extension, not a foreign body. If possible, the API layer should be a thin POSIX server that wraps CLI commands.
|
||||
@@ -0,0 +1,298 @@
|
||||
# Dashboard Architecture — Architect Decisions
|
||||
|
||||
> **Status: DECISION_MADE**
|
||||
>
|
||||
> Technology stack, API design, frontend framework, deployment model, and integration pattern for the web dashboard.
|
||||
|
||||
---
|
||||
|
||||
## Decision 1: Backend — Python 3 + Flask
|
||||
|
||||
**Choice:** `python3-flask` (apt package) for the backend server.
|
||||
|
||||
| Criterion | Python + Flask | Go | Node.js | Pure Bash |
|
||||
|-----------|---------------|-----|---------|-----------|
|
||||
| Already installed | `python3` ✓ | ✗ | ✗ | ✓ |
|
||||
| New apt dep | `python3-flask` (1 pkg) | `golang` (~300MB) | `nodejs` (~100MB) | None |
|
||||
| Subprocess wrapping | `subprocess.run([...])` — clean, safe | Good | Good | Fragile |
|
||||
| JSON output | `json.dumps()` — trivial | Good | Native | Manual |
|
||||
| Static file serving | Flask built-in | Manual | Manual | Manual |
|
||||
| Code volume | ~200-300 lines | ~200 lines | ~300 lines | ~500+ lines |
|
||||
|
||||
**Why not alternatives:**
|
||||
- **Go:** Adds a compilation toolchain to a Bash+apt project. Overkill for a thin API wrapper.
|
||||
- **Node.js:** Not in PACKAGES. Heavy runtime. npm ecosystem is overkill.
|
||||
- **Pure Bash + socat/ncat:** Too primitive for proper routing, JSON, and static files.
|
||||
- **FastAPI:** Requires pip install (uvicorn, pydantic). Flask is apt-available and simpler.
|
||||
|
||||
The backend is deliberately thin. It does not replicate CLI logic. It:
|
||||
1. Receives HTTP requests
|
||||
2. Calls `pos-*` tools via `subprocess.run()`
|
||||
3. Parses the text output (strip ANSI, extract structured data)
|
||||
4. Returns JSON
|
||||
|
||||
---
|
||||
|
||||
## Decision 2: Frontend — Alpine.js + htmx + Tailwind CSS (CDN, no build step)
|
||||
|
||||
| Library | Version | Size | Purpose |
|
||||
|---------|---------|------|---------|
|
||||
| Alpine.js | 3.x | ~15KB | Reactive UI components (toggles, tabs, dynamic lists) |
|
||||
| htmx | 2.x | ~14KB | HTML-over-the-wire — buttons swap HTML fragments from backend |
|
||||
| Tailwind CSS | 3.x CDN | ~300KB | Mobile-first utility CSS, dark mode |
|
||||
|
||||
- **No React/Vue/Angular:** A single-user personal dashboard does not need a component framework, virtual DOM, or a build pipeline.
|
||||
- **Offline consideration:** For Tailscale use, CDN works fine. If offline use is needed later, these can be vendored as local files (<350KB total).
|
||||
|
||||
---
|
||||
|
||||
## Decision 3: File Structure
|
||||
|
||||
```
|
||||
Linux_post_install/
|
||||
├── dashboard/ # Web application
|
||||
│ ├── app.py # Flask app — routes, static serving
|
||||
│ ├── api/ # API route modules
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── entertainment.py # /api/entertainment/* routes
|
||||
│ │ ├── telegram.py # /api/telegram/* routes
|
||||
│ │ ├── docker.py # /api/docker/* routes
|
||||
│ │ └── system.py # /api/system/* routes
|
||||
│ ├── lib/ # Backend helpers
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── runner.py # subprocess.run() wrapper, output capture
|
||||
│ │ ├── parsers.py # Strip ANSI, parse CLI text → dicts
|
||||
│ │ └── config.py # Load ~/.config/.../dashboard.env
|
||||
│ ├── templates/
|
||||
│ │ └── index.html # SPA — Alpine.js + htmx + Tailwind
|
||||
│ ├── static/
|
||||
│ │ ├── css/
|
||||
│ │ │ └── custom.css # Beyond Tailwind (overrides, animations)
|
||||
│ │ └── js/
|
||||
│ │ └── app.js # Alpine.js component data, helpers
|
||||
│ └── docs/ # Design specs (this folder)
|
||||
│
|
||||
├── bin/
|
||||
│ └── pos-dashboard # CLI tool (start/stop/status/config/url)
|
||||
│
|
||||
├── config/
|
||||
│ └── dashboard.env # Template: DASHBOARD_PORT, DASHBOARD_HOST
|
||||
```
|
||||
|
||||
### Install mapping (in `install.sh`):
|
||||
|
||||
| Source | Destination | Mode | Notes |
|
||||
|--------|-------------|------|-------|
|
||||
| `bin/pos-dashboard` | `/usr/local/bin/pos-dashboard` | 755 | Like all `bin/*` |
|
||||
| `dashboard/` | `/usr/local/share/linux_post_install/dashboard/` | recursive | Like ScaleTail |
|
||||
| `config/dashboard.env` | `~/.config/linux_post_install/dashboard.env` | 600 | Template, no clobber |
|
||||
|
||||
---
|
||||
|
||||
## Decision 4: API Design
|
||||
|
||||
RESTful JSON API, one endpoint per data domain. The backend calls CLI tools via subprocess and parses their text output.
|
||||
|
||||
```
|
||||
Base URL: http://<tailscale-ip>:8080/api/
|
||||
|
||||
── Entertainment (PRIMARY) ──────────────────────────────────
|
||||
GET /api/entertainment/status Parsed status (plugins, intervals, last-run)
|
||||
GET /api/entertainment/plugins List installed plugins with metadata
|
||||
POST /api/entertainment/send/<plugin> Run plugin (--print mode), return output
|
||||
POST /api/entertainment/enable Body: {"plugin":"x","interval":"5m"}
|
||||
POST /api/entertainment/disable Body: {"plugin":"x"}
|
||||
GET /api/entertainment/config Show config keys/values (secrets masked)
|
||||
POST /api/entertainment/config/set Body: {"key":"K","value":"V"}
|
||||
|
||||
── Telegram Listener ────────────────────────────────────────
|
||||
GET /api/telegram/status Service state + command map
|
||||
POST /api/telegram/enable Install + start service
|
||||
POST /api/telegram/disable Stop + remove service
|
||||
GET /api/telegram/commands Parsed /command → bash map
|
||||
POST /api/telegram/commands/sync Push commands to bot menu
|
||||
|
||||
── Docker ────────────────────────────────────────────────────
|
||||
GET /api/docker/ps Container list (name, image, status, health, ports, uptime)
|
||||
GET /api/docker/health Health summary (healthy/unhealthy counts)
|
||||
GET /api/docker/stacks Containers grouped by compose project
|
||||
POST /api/docker/compose/up Body: {"service":"jellyfin"}
|
||||
POST /api/docker/compose/down Body: {"service":"jellyfin"}
|
||||
POST /api/docker/compose/restart Body: {"service":"jellyfin"}
|
||||
|
||||
── System ────────────────────────────────────────────────────
|
||||
GET /api/system/health Parsed health report (checks + verdict)
|
||||
GET /api/system/info hostname, uptime, load, IP, disk, RAM
|
||||
|
||||
── Generic (escape hatch) ───────────────────────────────────
|
||||
POST /api/run Body: {"cmd":["entertainment","send","joke","--print"]}
|
||||
(restricted to safe commands, no shell=True)
|
||||
```
|
||||
|
||||
### Output parsing strategy:
|
||||
|
||||
1. **ANSI stripping:** `re.sub(r'\x1b\[[0-9;]*m', '', text)` removes color codes
|
||||
2. **Known format parsers:** Each CLI tool has a predictable output format
|
||||
3. **Fallback:** Raw text returned as-is for endpoints without a specific parser
|
||||
|
||||
For Docker specifically: the backend can call `docker inspect --format '{{json .}}'` directly (avoids parsing CLI text).
|
||||
|
||||
---
|
||||
|
||||
## Decision 5: Deployment — systemd User Service
|
||||
|
||||
Following the exact pattern of `pos-communication-telegram-listener`:
|
||||
|
||||
```
|
||||
pos dashboard start → Creates user service, starts it
|
||||
pos dashboard stop → Stops + disables + removes service
|
||||
pos dashboard status → systemctl --user status + print URL
|
||||
pos dashboard config → Show/edit DASHBOARD_PORT, DASHBOARD_HOST
|
||||
pos dashboard url → Print http://<tailscale-ip>:<port>/
|
||||
```
|
||||
|
||||
**Service unit** (generated by `pos dashboard start`):
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=pos Web Dashboard
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/python3 /usr/local/share/linux_post_install/dashboard/app.py
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
EnvironmentFile=%h/.config/linux_post_install/dashboard.env
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
```
|
||||
|
||||
**Config file** (`~/.config/linux_post_install/dashboard.env`):
|
||||
|
||||
```bash
|
||||
DASHBOARD_PORT=8080
|
||||
DASHBOARD_HOST=0.0.0.0
|
||||
# DASHBOARD_LOG_LEVEL=info
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Decision 6: Security Model
|
||||
|
||||
**Perimeter:** Tailscale. No auth middleware.
|
||||
|
||||
| Protection | Implementation |
|
||||
|------------|---------------|
|
||||
| **No shell injection** | `subprocess.run([...])` with argument lists, never `shell=True` |
|
||||
| **Input validation** | Plugin names validated against installed list; intervals regex-checked |
|
||||
| **Parameterized commands** | CLI arguments passed as list elements, not string interpolation |
|
||||
| **Secret masking** | Config endpoint masks tokens (same as `pos config telegram`) |
|
||||
| **Bind address** | Default `0.0.0.0` (Tailscale access); configurable to `127.0.0.1` |
|
||||
| **Read-only default** | GET endpoints only; mutation requires explicit POST |
|
||||
| **Config permissions** | `dashboard.env` chmod 600 |
|
||||
|
||||
---
|
||||
|
||||
## Decision 7: Dependencies
|
||||
|
||||
**New apt package (1 total):**
|
||||
```
|
||||
python3-flask
|
||||
```
|
||||
|
||||
Added to `PACKAGES` array in `preinstall.sh`. That's it.
|
||||
|
||||
**CDN-loaded (no install):** Alpine.js 3.x, htmx 2.x, Tailwind CSS 3.x
|
||||
|
||||
**Already installed:** python3, curl, jq, docker, systemctl
|
||||
|
||||
---
|
||||
|
||||
## Decision 8: Integration Points
|
||||
|
||||
### New/modified files:
|
||||
|
||||
| File | Change | Scope |
|
||||
|------|--------|-------|
|
||||
| `bin/pos-dashboard` | **New** — CLI tool (start/stop/status/config/url) | Bash script, ~100 lines |
|
||||
| `dashboard/` | **New** — entire web application | Python + HTML/CSS/JS |
|
||||
| `config/dashboard.env` | **New** — config template | 4-line env file |
|
||||
| `preinstall.sh` | **Modify** — add `python3-flask` to PACKAGES | 1 line |
|
||||
| `install.sh` | **Modify** — add dashboard/ → /usr/local/share/ copy phase | ~10 lines |
|
||||
| `postinstall.sh` | **Modify** — add dashboard.env template copy | ~5 lines |
|
||||
| `.gitignore` | **Modify** — add `dashboard/__pycache__/` | 1 line |
|
||||
|
||||
### What NOT to change:
|
||||
- `bin/pos` — no INTERACTIVE_CMDS change needed
|
||||
- `lib/common.sh` — no changes
|
||||
- `lib/entertainment-lib.sh` — no changes
|
||||
- Existing `pos-*` tools — no modifications whatsoever
|
||||
|
||||
---
|
||||
|
||||
## Decision 9: Data Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ USER'S DEVICE │
|
||||
│ (phone, tablet, couch computer) │
|
||||
│ │
|
||||
│ Browser ──HTTP/Tailscale──┐ │
|
||||
└────────────────────────────┼─────────────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ Tailscale VPN │
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────────────────────────┼─────────────────┐
|
||||
│ SERVER (Debian) │
|
||||
│ │
|
||||
│ ┌─────────────────────────▼──────────────┐ │
|
||||
│ │ Flask Backend (app.py :8080) │ │
|
||||
│ │ │ │
|
||||
│ │ /api/entertainment/status │ │
|
||||
│ │ /api/entertainment/send/<plugin> │ │
|
||||
│ │ /api/docker/ps │ │
|
||||
│ │ /api/system/health │ │
|
||||
│ │ /api/telegram/status │ │
|
||||
│ └───────┬──────────┬──────────┬───────────┘ │
|
||||
│ │ │ │ │
|
||||
│ ┌──────▼───┐ ┌────▼────┐ ┌──▼──────────┐ │
|
||||
│ │subprocess│ │subprocess│ │ docker │ │
|
||||
│ │ pos │ │ pos │ │ inspect │ │
|
||||
│ │ ent.* │ │ docker* │ │ (direct) │ │
|
||||
│ └──────┬───┘ └────┬────┘ └──┬──────────┘ │
|
||||
│ │ │ │ │
|
||||
│ ┌──────▼──────────▼──────────▼──────────┐ │
|
||||
│ │ pos-* CLI Tools │ │
|
||||
│ │ → stdout (text with ANSI colors) │ │
|
||||
│ └───────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────┐ │
|
||||
│ │ parsers.py (ANSI strip + parse) │ │
|
||||
│ │ → JSON response │ │
|
||||
│ └───────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Decision 10: Constraints for Builder
|
||||
|
||||
**Must:**
|
||||
- Follow Bash conventions for `bin/pos-dashboard` (shebang, set -euo pipefail, POS: header, --help)
|
||||
- Use `subprocess.run([...])` — never `shell=True`
|
||||
- Python code must be self-contained (no pip install beyond apt)
|
||||
- Dashboard must work after repo deletion (install to /usr/local/share/)
|
||||
- Config file `chmod 600`
|
||||
- CLI tool must have proper `# POS:` header for `make gen`
|
||||
|
||||
**Must NOT:**
|
||||
- Modify any existing `pos-*` tool
|
||||
- Modify `lib/common.sh`
|
||||
- Add pip-only dependencies
|
||||
- Require a build step (no webpack, no npm)
|
||||
- Use authentication middleware
|
||||
- Add the dashboard to `INTERACTIVE_CMDS` in `bin/pos`
|
||||
@@ -0,0 +1,717 @@
|
||||
# Homelab Dashboard — Web UI/UX Design Specification
|
||||
|
||||
> **Status: DESIGNED**
|
||||
>
|
||||
> **Scope:** Complete visual design system, component library, page layouts, and interaction spec for the `pos` web dashboard.
|
||||
>
|
||||
> **Design authority:** This document is the design authority for all web dashboard visual output. When implementation conflicts with this spec, the spec wins.
|
||||
>
|
||||
> **Relationship to CLI spec:** The CLI design spec (`DESIGN-SPEC.md`) defines the terminal experience. This document defines the web experience. Both share the same semantic color meaning (green=OK, yellow=WARN, red=FAIL) but use different implementations (tput vs Tailwind classes).
|
||||
|
||||
---
|
||||
|
||||
## 1. Design Principles
|
||||
|
||||
1. **Glance-first.** The user opens the dashboard on their phone to check one thing. The answer must be visible within 2 seconds — no scrolling, no tapping, no hunting. Status badges, health counters, and last-run times are the heroes.
|
||||
|
||||
2. **Dark by default, light-capable.** This is a homelab dashboard viewed at 11 PM from a couch. Dark mode is the default and the primary design target. A light mode toggle exists but is secondary.
|
||||
|
||||
3. **Mobile-native feel.** Bottom tab bar, pull-to-refresh gesture, swipeable cards. The dashboard should feel like a native mobile app, not a shrunken desktop page.
|
||||
|
||||
4. **Progressive density.** Mobile shows summary + badges. Tablet shows summary + one detail column. Desktop shows full multi-column layout. The same data, three densities.
|
||||
|
||||
5. **Zero-build, CDN-only.** Alpine.js + htmx + Tailwind CSS via CDN. No webpack, no npm, no build step. The Builder must be able to edit HTML and see results immediately.
|
||||
|
||||
6. **Consistent with CLI semantics.** Status badges use the same color meaning as `pos` CLI. A user who reads `[ OK ]` in the terminal and sees a green badge in the dashboard immediately understands both.
|
||||
|
||||
---
|
||||
|
||||
## 2. Color System
|
||||
|
||||
### 2.1 Background Layers (Dark Mode)
|
||||
|
||||
| Layer | Tailwind Classes | Hex Approximation | Usage |
|
||||
|-------|-----------------|-------------------|-------|
|
||||
| **Base** | `bg-slate-950` | `#020617` | Page background, outermost shell |
|
||||
| **Surface** | `bg-slate-900` | `#0f172a` | Cards, panels, sidebar |
|
||||
| **Surface-raised** | `bg-slate-800` | `#1e293b` | Hovered cards, active panels, modals |
|
||||
| **Surface-overlay** | `bg-slate-800/80 backdrop-blur-xl` | `#1e293bcc` | Dropdown menus, toast backgrounds |
|
||||
| **Border** | `border-slate-700/50` | `#33415580` | Card borders, dividers |
|
||||
| **Border-strong** | `border-slate-600` | `#475569` | Focus rings, active state borders |
|
||||
|
||||
### 2.2 Text Hierarchy
|
||||
|
||||
| Level | Tailwind Classes | Usage |
|
||||
|-------|-----------------|-------|
|
||||
| **Primary** | `text-slate-50` | Headings, card titles, primary data |
|
||||
| **Secondary** | `text-slate-400` | Descriptions, labels, table data |
|
||||
| **Tertiary** | `text-slate-500` | Timestamps, metadata, hints |
|
||||
| **Disabled** | `text-slate-600` | Inactive states, placeholder text |
|
||||
| **Inverse (on light badges)** | `text-slate-950` | Text on colored badge backgrounds |
|
||||
|
||||
### 2.3 Status / Semantic Colors
|
||||
|
||||
| Semantic | Badge BG | Badge Text | Subtle BG | Subtle Border | Tailwind (bg) | Tailwind (text) |
|
||||
|----------|----------|------------|-----------|---------------|---------------|----------------|
|
||||
| **Success / OK** | `emerald-500` | `white` | `emerald-500/10` | `emerald-500/20` | `bg-emerald-500` | `text-emerald-400` |
|
||||
| **Warning** | `amber-500` | `slate-950` | `amber-500/10` | `amber-500/20` | `bg-amber-500` | `text-amber-400` |
|
||||
| **Error / Fail** | `rose-500` | `white` | `rose-500/10` | `rose-500/20` | `bg-rose-500` | `text-rose-400` |
|
||||
| **Info** | `sky-500` | `white` | `sky-500/10` | `sky-500/20` | `bg-sky-500` | `text-sky-400` |
|
||||
| **Neutral** | `slate-600` | `slate-200` | `slate-500/10` | `slate-500/20` | `bg-slate-600` | `text-slate-400` |
|
||||
|
||||
### 2.4 Accent / Brand Colors
|
||||
|
||||
| Element | Tailwind Classes | Usage |
|
||||
|---------|-----------------|-------|
|
||||
| **Primary accent** | `cyan-400` / `cyan-500` | Tab active indicator, links, focus rings, active nav |
|
||||
| **Accent subtle** | `cyan-500/10` | Hover backgrounds for interactive elements |
|
||||
| **Accent strong** | `cyan-400` with `shadow-cyan-500/20` | Primary CTA buttons, glowing active states |
|
||||
|
||||
> **Design rationale:** Cyan was chosen to align with the CLI's `CYAN` token used for section headers. The dashboard and CLI share a family resemblance.
|
||||
|
||||
---
|
||||
|
||||
## 3. Typography
|
||||
|
||||
### 3.1 Font Stack
|
||||
|
||||
```html
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
```
|
||||
|
||||
### 3.2 Type Scale
|
||||
|
||||
| Role | Tailwind Classes | Size | Weight | Usage |
|
||||
|------|-----------------|------|--------|-------|
|
||||
| **Page title** | `text-2xl font-bold tracking-tight` | 1.5rem | 700 | Tab page heading |
|
||||
| **Section title** | `text-lg font-semibold` | 1.125rem | 600 | Card group header |
|
||||
| **Card title** | `text-base font-semibold` | 1rem | 600 | Plugin name, container name |
|
||||
| **Body** | `text-sm` | 0.875rem | 400 | Default text, descriptions |
|
||||
| **Small / Label** | `text-xs font-medium uppercase tracking-wider` | 0.75rem | 500 | Table headers, badge labels |
|
||||
| **Caption** | `text-xs` | 0.75rem | 400 | Timestamps, secondary info |
|
||||
| **Badge text** | `text-xs font-semibold` | 0.75rem | 600 | Status badges, counts |
|
||||
| **Tabular data** | `text-sm tabular-nums` | 0.875rem | 400 | Numeric columns, stats |
|
||||
|
||||
---
|
||||
|
||||
## 4. Layout System
|
||||
|
||||
### 4.1 Overall Structure
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ HEADER BAR (h-14) │
|
||||
│ [hamburger] Logo/Title [refresh] │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ CONTENT AREA │
|
||||
│ (scrollable, p-4 sm:p-6) │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ Card │ │ Card │ │ Card │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ TAB BAR (fixed bottom, h-16) │
|
||||
│ [🎬] [💬] [🐳] [⚙️] │
|
||||
│ Enter Tele Docker System │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.2 Responsive Breakpoints
|
||||
|
||||
| Breakpoint | Width | Tab bar | Content grid |
|
||||
|------------|-------|---------|-------------|
|
||||
| **Mobile** | < 640px | Fixed bottom, 4 tabs | `grid-cols-1 gap-4` |
|
||||
| **Tablet** | ≥ 640px | Fixed bottom, 4 tabs | `sm:grid-cols-2 gap-4` |
|
||||
| **Desktop** | ≥ 1024px | Tabs move to header | `lg:grid-cols-3 gap-4` |
|
||||
| **Wide** | ≥ 1280px | Tabs in header | `xl:grid-cols-4 gap-5` |
|
||||
|
||||
### 4.3 Container
|
||||
|
||||
```html
|
||||
<main class="min-h-screen bg-slate-950 pb-20 lg:pb-0">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-6">
|
||||
<!-- content -->
|
||||
</div>
|
||||
</main>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Component Library
|
||||
|
||||
### 5.1 Status Badge
|
||||
|
||||
The most important visual element. Maps directly to CLI's `[ OK ]` / `[WARN]` / `[FAIL]`.
|
||||
|
||||
```html
|
||||
<!-- Success / OK -->
|
||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span>
|
||||
OK
|
||||
</span>
|
||||
|
||||
<!-- Warning -->
|
||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-500/10 text-amber-400 border border-amber-500/20">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-400"></span>
|
||||
WARN
|
||||
</span>
|
||||
|
||||
<!-- Error / Fail -->
|
||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/20">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span>
|
||||
FAIL
|
||||
</span>
|
||||
|
||||
<!-- Neutral / Off -->
|
||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-500/10 text-slate-400 border border-slate-500/20">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-slate-400"></span>
|
||||
OFF
|
||||
</span>
|
||||
```
|
||||
|
||||
### 5.2 Card
|
||||
|
||||
```html
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-700/50 shadow-lg shadow-black/20 overflow-hidden transition-all duration-200 hover:border-slate-600/50 hover:shadow-xl hover:shadow-black/30">
|
||||
<div class="px-5 py-4 border-b border-slate-700/50">
|
||||
<h3 class="text-base font-semibold text-slate-50">Card Title</h3>
|
||||
<p class="mt-1 text-xs text-slate-500">Optional description</p>
|
||||
</div>
|
||||
<div class="px-5 py-4">
|
||||
<!-- content -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 5.3 Plugin Card (Entertainment)
|
||||
|
||||
```html
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-700/50 shadow-lg shadow-black/20 overflow-hidden">
|
||||
<!-- Header: name + status -->
|
||||
<div class="flex items-center justify-between px-5 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-slate-800 text-lg">☁️</div>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-slate-50">Weather</h3>
|
||||
<p class="text-xs text-slate-500">Weather updates to Telegram</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="...badge...">OK</span>
|
||||
</div>
|
||||
<!-- Stats row -->
|
||||
<div class="px-5 py-3 bg-slate-800/50 border-t border-slate-700/30">
|
||||
<div class="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<p class="text-[10px] font-medium uppercase tracking-wider text-slate-500">Interval</p>
|
||||
<p class="mt-1 text-sm font-semibold text-slate-200 tabular-nums">5m</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[10px] font-medium uppercase tracking-wider text-slate-500">Last Run</p>
|
||||
<p class="mt-1 text-sm font-semibold text-slate-200 tabular-nums">2m ago</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[10px] font-medium uppercase tracking-wider text-slate-500">Status</p>
|
||||
<p class="mt-1 text-sm font-semibold text-emerald-400 tabular-nums">Success</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Output preview -->
|
||||
<div class="px-5 py-3 border-t border-slate-700/30">
|
||||
<p class="text-xs text-slate-400 line-clamp-2 font-mono bg-slate-800/50 rounded-lg px-3 py-2">
|
||||
☀️ Istanbul: 28°C, Partly Cloudy
|
||||
</p>
|
||||
</div>
|
||||
<!-- Actions: toggle + send -->
|
||||
<div class="px-5 py-3 border-t border-slate-700/30 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<button role="switch" class="...toggle switch..." :class="enabled ? 'bg-emerald-500' : 'bg-slate-600'">
|
||||
<span class="...thumb..." :class="enabled ? 'translate-x-4' : 'translate-x-0'"></span>
|
||||
</button>
|
||||
<span class="text-xs text-slate-400" x-text="enabled ? 'Enabled' : 'Disabled'"></span>
|
||||
</div>
|
||||
<button class="...small cyan button...">Send Now</button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 5.4 Toggle Switch
|
||||
|
||||
```html
|
||||
<button role="switch"
|
||||
class="relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2 focus:ring-offset-slate-900"
|
||||
:class="enabled ? 'bg-emerald-500' : 'bg-slate-600'"
|
||||
@click="enabled = !enabled"
|
||||
:aria-checked="enabled.toString()">
|
||||
<span class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out"
|
||||
:class="enabled ? 'translate-x-5' : 'translate-x-0'"></span>
|
||||
</button>
|
||||
```
|
||||
|
||||
### 5.5 Buttons
|
||||
|
||||
**Primary (Cyan):**
|
||||
```html
|
||||
<button class="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium bg-cyan-500 text-white hover:bg-cyan-400 active:scale-[0.97] shadow-lg shadow-cyan-500/25 transition-all duration-150 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2 focus:ring-offset-slate-900 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
</button>
|
||||
```
|
||||
|
||||
**Secondary (Ghost):**
|
||||
```html
|
||||
<button class="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-slate-300 bg-slate-800 border border-slate-700 hover:bg-slate-700 hover:text-slate-100 active:scale-[0.97] transition-all duration-150 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2 focus:ring-offset-slate-900">
|
||||
</button>
|
||||
```
|
||||
|
||||
**Danger:**
|
||||
```html
|
||||
<button class="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium bg-rose-500/10 text-rose-400 border border-rose-500/20 hover:bg-rose-500/20 hover:text-rose-300 active:scale-[0.97] transition-all duration-150 focus:outline-none focus:ring-2 focus:ring-rose-500 focus:ring-offset-2 focus:ring-offset-slate-900">
|
||||
</button>
|
||||
```
|
||||
|
||||
**Icon-only:**
|
||||
```html
|
||||
<button class="inline-flex items-center justify-center p-2 rounded-lg text-slate-400 hover:text-slate-200 hover:bg-slate-800 active:scale-95 transition-all duration-150 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2 focus:ring-offset-slate-900">
|
||||
</button>
|
||||
```
|
||||
|
||||
### 5.6 Table
|
||||
|
||||
```html
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-700/50 shadow-lg shadow-black/20 overflow-hidden">
|
||||
<div class="px-5 py-3 border-b border-slate-700/50 bg-slate-800/50">
|
||||
<div class="grid grid-cols-12 gap-4 text-[10px] font-medium uppercase tracking-wider text-slate-500">
|
||||
<div class="col-span-5">Name</div>
|
||||
<div class="col-span-3">Status</div>
|
||||
<div class="col-span-2 hidden sm:block">Uptime</div>
|
||||
<div class="col-span-2 text-right">Actions</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-700/30">
|
||||
<div class="px-5 py-3 hover:bg-slate-800/50 transition-colors duration-150">
|
||||
<div class="grid grid-cols-12 gap-4 items-center text-sm">
|
||||
<div class="col-span-5">
|
||||
<p class="font-medium text-slate-100">jellyfin</p>
|
||||
<p class="text-xs text-slate-500 truncate">jellyfin/jellyfin:latest</p>
|
||||
</div>
|
||||
<div class="col-span-3"><span class="...badge...">healthy</span></div>
|
||||
<div class="col-span-2 hidden sm:block text-sm text-slate-400 tabular-nums">3d 12h</div>
|
||||
<div class="col-span-2 flex items-center justify-end gap-1">
|
||||
<button class="...icon btn..."><!-- restart --></button>
|
||||
<button class="...icon btn..."><!-- stop --></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 5.7 Modal / Dialog
|
||||
|
||||
```html
|
||||
<!-- Backdrop -->
|
||||
<div class="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm transition-opacity duration-200"
|
||||
x-show="showModal" @click.self="showModal = false" x-cloak>
|
||||
</div>
|
||||
<!-- Panel -->
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
x-show="showModal" x-cloak>
|
||||
<div class="w-full max-w-lg bg-slate-900 rounded-2xl border border-slate-700/50 shadow-2xl shadow-black/50 overflow-hidden">
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-700/50">
|
||||
<h3 class="text-lg font-semibold text-slate-50">Title</h3>
|
||||
<button @click="showModal = false" class="...icon btn..."><!-- close --></button>
|
||||
</div>
|
||||
<form class="px-6 py-5 space-y-4">
|
||||
<!-- form fields -->
|
||||
</form>
|
||||
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-slate-700/50 bg-slate-800/30">
|
||||
<button @click="showModal = false" class="...secondary...">Cancel</button>
|
||||
<button class="...primary...">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 5.8 Toast Notification
|
||||
|
||||
```html
|
||||
<div class="fixed bottom-20 right-4 z-50 flex flex-col gap-2 sm:bottom-4"
|
||||
x-data="{ toasts: [] }"
|
||||
@show-toast.window="toasts.push({...$event.detail, id: Date.now()}); setTimeout(() => toasts.shift(), 4000)">
|
||||
<template x-for="toast in toasts" :key="toast.id">
|
||||
<div class="flex items-center gap-3 px-4 py-3 rounded-xl bg-slate-800/95 backdrop-blur-xl border border-slate-700/50 shadow-xl shadow-black/30 max-w-sm">
|
||||
<div class="flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center" :class="toast.variant === 'success' ? 'bg-emerald-500/15' : toast.variant === 'error' ? 'bg-rose-500/15' : 'bg-amber-500/15'">
|
||||
<!-- icon -->
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-slate-100" x-text="toast.title"></p>
|
||||
<p class="text-xs text-slate-400" x-text="toast.message"></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 5.9 Loading Skeleton
|
||||
|
||||
```html
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-700/50 overflow-hidden animate-pulse">
|
||||
<div class="px-5 py-4 flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-lg bg-slate-800"></div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-4 w-24 rounded bg-slate-800"></div>
|
||||
<div class="h-3 w-40 rounded bg-slate-800/60"></div>
|
||||
</div>
|
||||
<div class="h-6 w-14 rounded-full bg-slate-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 5.10 Empty State
|
||||
|
||||
```html
|
||||
<div class="flex flex-col items-center justify-center py-12 px-6 text-center">
|
||||
<div class="w-16 h-16 rounded-2xl bg-slate-800 flex items-center justify-center mb-4">
|
||||
<svg class="w-8 h-8 text-slate-600"><!-- icon --></svg>
|
||||
</div>
|
||||
<h4 class="text-base font-semibold text-slate-400">No containers running</h4>
|
||||
<p class="mt-1 text-sm text-slate-500 max-w-xs">Start a stack with <code class="px-1.5 py-0.5 rounded bg-slate-800 text-cyan-400 text-xs font-mono">pos docker compose up</code></p>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 5.11 Health Summary Bar (Docker)
|
||||
|
||||
```html
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-700/50 p-4 text-center">
|
||||
<p class="text-2xl font-bold tabular-nums text-slate-50">12</p>
|
||||
<p class="text-[10px] font-medium uppercase tracking-wider text-slate-500 mt-1">Total</p>
|
||||
</div>
|
||||
<div class="bg-slate-900 rounded-xl border border-emerald-500/20 p-4 text-center">
|
||||
<p class="text-2xl font-bold tabular-nums text-emerald-400">10</p>
|
||||
<p class="text-[10px] font-medium uppercase tracking-wider text-emerald-500/70 mt-1">Healthy</p>
|
||||
</div>
|
||||
<div class="bg-slate-900 rounded-xl border border-rose-500/20 p-4 text-center">
|
||||
<p class="text-2xl font-bold tabular-nums text-rose-400">1</p>
|
||||
<p class="text-[10px] font-medium uppercase tracking-wider text-rose-500/70 mt-1">Unhealthy</p>
|
||||
</div>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-700/50 p-4 text-center">
|
||||
<p class="text-2xl font-bold tabular-nums text-slate-500">1</p>
|
||||
<p class="text-[10px] font-medium uppercase tracking-wider text-slate-500 mt-1">Stopped</p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Page Layouts
|
||||
|
||||
### 6.1 Entertainment Tab (Default)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ ENTERTAINMENT │
|
||||
│ Automated messages from public APIs │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ ┌─ Plugin Cards Grid ────────────────────┐ │
|
||||
│ │ ┌──────────┐ ┌──────────┐ │ │
|
||||
│ │ │ Weather │ │ Joke │ │ │
|
||||
│ │ │ ☁️ OK │ │ 😂 OK │ │ │
|
||||
│ │ │ 5m 2m │ │ 1h 45m │ │ │
|
||||
│ │ │ [Send] │ │ [Send] │ │ │
|
||||
│ │ └──────────┘ └──────────┘ │ │
|
||||
│ │ ┌──────────┐ │ │
|
||||
│ │ │ Gold │ │ │
|
||||
│ │ │ 💰 WARN │ │ │
|
||||
│ │ └──────────┘ │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
│ ┌─ Configuration Panel (collapsible) ────┐ │
|
||||
│ │ ⚙️ Configuration [▼] │ │
|
||||
│ │ Lat: [____] Lon: [____] City: [___]│ │
|
||||
│ │ Enabled: [weather] [joke] [+ Add] │ │
|
||||
│ │ [Save Config] │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
│ ┌─ Recent History ───────────────────────┐ │
|
||||
│ │ 🕐 Timeline │ │
|
||||
│ │ ● 14:00 weather ☀️ 28°C... OK │ │
|
||||
│ │ ● 13:45 joke Why did... OK │ │
|
||||
│ │ ● 13:00 gold $2,345/oz OK │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.2 Telegram Tab
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ TELEGRAM │
|
||||
│ Bot listener and command management │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ ┌─ Listener Status ──────────────────────┐ │
|
||||
│ │ 🟢 Listener running │ │
|
||||
│ │ PID: 12345 | Uptime: 3d 12h │ │
|
||||
│ │ [Restart] [Stop] │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
│ ┌─ Command Map ──────────────────────────┐ │
|
||||
│ │ /status → systemctl status ... │ │
|
||||
│ │ /temp → sensors | head -3 │ │
|
||||
│ │ /backup → pos system backup │ │
|
||||
│ │ [+ Add Cmd] │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
│ ┌─ Recent Executions ────────────────────┐ │
|
||||
│ │ 14:00 /status OK 0.2s │ │
|
||||
│ │ 13:55 /temp OK 0.1s │ │
|
||||
│ │ 13:30 /backup FAIL 12.4s │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.3 Docker Tab
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ DOCKER │
|
||||
│ Container health and management │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ ┌─ Health Summary ───────────────────────┐ │
|
||||
│ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ │
|
||||
│ │ │ 12 │ │ 10 │ │ 1 │ │ 1 │ │ │
|
||||
│ │ │TOTAL │ │ OK │ │UNH │ │STOP │ │ │
|
||||
│ │ └──────┘ └──────┘ └──────┘ └──────┘ │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
│ ┌─ View Toggle ─────────────────────────┐ │
|
||||
│ │ [All] [Stacks] │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
│ ┌─ Container Table ─────────────────────┐ │
|
||||
│ │ NAME STATUS UPTIME ACT │ │
|
||||
│ │ jellyfin healthy 3d 12h [⟲][■] │ │
|
||||
│ │ plex unhealthy 1d 4h [⟲][■] │ │
|
||||
│ │ nginx healthy 14d [⟲][■] │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.4 System Tab
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ SYSTEM │
|
||||
│ Host health and information │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ ┌─ Host Info ────────────────────────────┐ │
|
||||
│ │ 🖥️ hostname-here │ │
|
||||
│ │ Uptime: 42d 7h · Load: 0.42 0.38 0.35│ │
|
||||
│ │ IP: 100.x.x.x · OS: Debian 12 │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
│ ┌─ Health Checks ───────────────────────┐ │
|
||||
│ │ 💾 Disk [ OK ] 72% │ │
|
||||
│ │ 🧠 RAM [ OK ] 45% │ │
|
||||
│ │ 🔧 Services [ WARN ] │ │
|
||||
│ │ 💿 Backup Age [ OK ] 2d │ │
|
||||
│ │ 🛡️ Fail2ban [ OK ] │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Interactions & Transitions
|
||||
|
||||
### 7.1 Hover Effects
|
||||
|
||||
| Element | Hover Effect | Tailwind Classes |
|
||||
|---------|-------------|-----------------|
|
||||
| **Card** | Border brightens + shadow grows | `hover:border-slate-600/50 hover:shadow-xl hover:shadow-black/30` |
|
||||
| **Button** | Background lightens | `hover:bg-cyan-400` (primary) |
|
||||
| **Table row** | Subtle background tint | `hover:bg-slate-800/50` |
|
||||
| **Input** | Border ring appears | `focus:ring-2 focus:ring-cyan-500 focus:border-transparent` |
|
||||
|
||||
### 7.2 Click Feedback
|
||||
|
||||
All interactive elements use `active:scale-[0.97]` (buttons) or `active:scale-95` (small elements).
|
||||
|
||||
### 7.3 Transitions
|
||||
|
||||
| Property | Duration | Usage |
|
||||
|----------|----------|-------|
|
||||
| Color changes | `duration-150` | Button hover, text color |
|
||||
| Transform | `duration-150` | Button press |
|
||||
| Opacity | `duration-200` | Show/hide elements |
|
||||
| Expand/collapse | `duration-200` | Panels (`x-collapse`) |
|
||||
| Toast slide in | `duration-300` | Toast enter |
|
||||
| Modal enter | `duration-200` | Modal backdrop + panel |
|
||||
|
||||
---
|
||||
|
||||
## 8. Mobile Adaptation
|
||||
|
||||
| Component | Mobile (< 640px) | Tablet (640-1023px) | Desktop (≥ 1024px) |
|
||||
|-----------|-------------------|---------------------|---------------------|
|
||||
| **Tab bar** | Fixed bottom, icons + labels | Fixed bottom | In header, text only |
|
||||
| **Plugin cards** | 1 column | 2 columns | 3 columns |
|
||||
| **Health summary** | 2×2 grid | 4 columns | 4 columns |
|
||||
| **Container table** | Name + status only | + uptime | All columns |
|
||||
| **Modal** | Bottom sheet | Centered | Centered |
|
||||
| **Toast** | Bottom-center, full width | Bottom-right | Bottom-right |
|
||||
|
||||
---
|
||||
|
||||
## 9. Animation Specification
|
||||
|
||||
### 9.1 Staggered Card Entry
|
||||
|
||||
```css
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.3s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
```
|
||||
|
||||
```html
|
||||
<div class="animate-fade-in-up" style="animation-delay: 0ms"> <!-- card 1 -->
|
||||
<div class="animate-fade-in-up" style="animation-delay: 50ms"> <!-- card 2 -->
|
||||
<div class="animate-fade-in-up" style="animation-delay: 100ms"><!-- card 3 -->
|
||||
```
|
||||
|
||||
### 9.2 Tab Switch Transition
|
||||
|
||||
```html
|
||||
<div x-show="activeTab === 'entertainment'"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 translate-y-2"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2">
|
||||
```
|
||||
|
||||
### 9.3 What NOT to Animate
|
||||
|
||||
- **Scroll position** — never hijack scroll
|
||||
- **Layout shifts** — avoid animating height/width/padding
|
||||
- **Status badge colors** — instant change (user needs to notice immediately)
|
||||
- **Error states** — instant appearance
|
||||
|
||||
---
|
||||
|
||||
## 10. Custom CSS (`custom.css`)
|
||||
|
||||
```css
|
||||
/* Animations */
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.3s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes badgePop {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.1); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
.animate-badge-pop {
|
||||
animation: badgePop 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Safe area padding for notched phones */
|
||||
main {
|
||||
padding-bottom: calc(4rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
main { padding-bottom: 0; }
|
||||
}
|
||||
|
||||
/* Alpine.js cloak */
|
||||
[x-cloak] { display: none !important; }
|
||||
|
||||
/* Scrollbar styling (dark) */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #334155; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #475569; }
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* htmx loading indicator */
|
||||
.htmx-indicator { display: none; }
|
||||
.htmx-request .htmx-indicator,
|
||||
.htmx-request.htmx-indicator { display: block; }
|
||||
.htmx-settling .htmx-indicator { display: none; }
|
||||
|
||||
/* Focus visible only for keyboard users */
|
||||
:focus:not(:focus-visible) { outline: none; }
|
||||
:focus-visible { outline: 2px solid #06b6d4; outline-offset: 2px; }
|
||||
|
||||
/* Line clamp utility */
|
||||
.line-clamp-1 { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; }
|
||||
.line-clamp-2 { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Design Checklist for Builder
|
||||
|
||||
### Colors & Theme
|
||||
- [ ] All backgrounds use dark palette (slate-950/900/800)
|
||||
- [ ] Status badges use emerald/amber/rose consistently
|
||||
- [ ] Accent color is cyan-400/500 throughout
|
||||
- [ ] No white backgrounds anywhere (dark mode)
|
||||
- [ ] Border colors are `slate-700/50`
|
||||
|
||||
### Typography
|
||||
- [ ] Inter font loaded via Google Fonts CDN
|
||||
- [ ] `text-2xl font-bold` for page titles
|
||||
- [ ] `text-xs font-medium uppercase tracking-wider` for labels
|
||||
- [ ] `tabular-nums` on all numeric data
|
||||
- [ ] `font-mono` on code/command output
|
||||
|
||||
### Layout
|
||||
- [ ] Bottom tab bar on `< lg:` breakpoints
|
||||
- [ ] Tabs in header on `lg:` and above
|
||||
- [ ] Max-width container: `max-w-7xl`
|
||||
- [ ] Consistent padding: `p-4 sm:p-6`
|
||||
|
||||
### Components
|
||||
- [ ] All status badges have dot + text + border
|
||||
- [ ] All cards have `rounded-xl border shadow-lg`
|
||||
- [ ] All buttons have `active:scale-95` or `active:scale-[0.97]`
|
||||
- [ ] All inputs have focus ring: `focus:ring-2 focus:ring-cyan-500`
|
||||
- [ ] Toggle switches use Alpine.js with smooth translate
|
||||
- [ ] Modals have backdrop blur + scale transition
|
||||
- [ ] Toasts slide in from right, auto-dismiss after 4s
|
||||
|
||||
### Interactions
|
||||
- [ ] Cards hover: border brightens + shadow grows
|
||||
- [ ] Table rows hover: background tint
|
||||
- [ ] Refresh button spins during refresh
|
||||
- [ ] Tab switch: content fades + slides up
|
||||
- [ ] Staggered card entry animation on load
|
||||
|
||||
### Mobile
|
||||
- [ ] Bottom tab bar with icons + labels
|
||||
- [ ] Plugin cards stack to single column
|
||||
- [ ] Table hides secondary columns on small screens
|
||||
- [ ] Modal becomes bottom sheet with drag handle
|
||||
- [ ] Toast appears above tab bar
|
||||
|
||||
### Accessibility
|
||||
- [ ] Focus rings visible on all interactive elements
|
||||
- [ ] ARIA roles on toggles and modals
|
||||
- [ ] `prefers-reduced-motion` disables all animations
|
||||
- [ ] Color contrast meets WCAG AA
|
||||
|
||||
---
|
||||
|
||||
*This document is the design authority for the web dashboard. When implementation conflicts with this spec, the spec wins. All Tailwind classes referenced are exact — the Builder should copy them directly.*
|
||||
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
Config loader for the dashboard.
|
||||
|
||||
Reads env-style config files from ~/.config/linux_post_install/ and
|
||||
provides secret-masking for the API layer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
_CONFIG_DIR = Path(
|
||||
os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config"))
|
||||
) / "linux_post_install"
|
||||
|
||||
_SECRET_KEYWORDS = ("TOKEN", "KEY", "SECRET", "PASSWORD")
|
||||
|
||||
|
||||
def _parse_env_file(path: Path) -> Dict[str, str]:
|
||||
"""Parse a KEY=VALUE env file (comments and blank lines ignored).
|
||||
|
||||
Values may be optionally wrapped in single or double quotes.
|
||||
"""
|
||||
data: Dict[str, str] = {}
|
||||
if not path.is_file():
|
||||
return data
|
||||
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
for raw_line in fh:
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
eq_pos = line.find("=")
|
||||
if eq_pos == -1:
|
||||
continue
|
||||
key = line[:eq_pos].strip()
|
||||
value = line[eq_pos + 1 :].strip()
|
||||
# Strip matching quotes
|
||||
if len(value) >= 2:
|
||||
if (value[0] == '"' and value[-1] == '"') or \
|
||||
(value[0] == "'" and value[-1] == "'"):
|
||||
value = value[1:-1]
|
||||
data[key] = value
|
||||
return data
|
||||
|
||||
|
||||
def load_dashboard_env() -> Dict[str, str]:
|
||||
"""Load ``~/.config/linux_post_install/dashboard.env``."""
|
||||
return _parse_env_file(_CONFIG_DIR / "dashboard.env")
|
||||
|
||||
|
||||
def load_entertainment_env() -> Dict[str, str]:
|
||||
"""Load ``~/.config/linux_post_install/entertainment.env``."""
|
||||
return _parse_env_file(_CONFIG_DIR / "entertainment.env")
|
||||
|
||||
|
||||
def load_telegram_env() -> Dict[str, str]:
|
||||
"""Load ``~/.config/linux_post_install/telegram.env``."""
|
||||
return _parse_env_file(_CONFIG_DIR / "telegram.env")
|
||||
|
||||
|
||||
def mask_secrets(data: Dict[str, str]) -> Dict[str, str]:
|
||||
"""Return a copy of *data* with secret values masked.
|
||||
|
||||
A key is considered secret if it contains any of: TOKEN, KEY, SECRET,
|
||||
PASSWORD (case-insensitive).
|
||||
"""
|
||||
masked: Dict[str, str] = {}
|
||||
for key, value in data.items():
|
||||
upper = key.upper()
|
||||
if any(kw in upper for kw in _SECRET_KEYWORDS):
|
||||
if value and len(value) > 4:
|
||||
masked[key] = value[:2] + "*" * (len(value) - 4) + value[-2:]
|
||||
elif value:
|
||||
masked[key] = "****"
|
||||
else:
|
||||
masked[key] = ""
|
||||
else:
|
||||
masked[key] = value
|
||||
return masked
|
||||
@@ -0,0 +1,366 @@
|
||||
"""
|
||||
Output parsers for pos-* CLI text output.
|
||||
|
||||
Every parser:
|
||||
• accepts a raw text string (already ANSI-stripped)
|
||||
• returns structured data (dict or list of dicts)
|
||||
• handles empty / unrecognised input gracefully (returns a sensible default)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Dict, List
|
||||
|
||||
|
||||
# ── Entertainment ────────────────────────────────────────────────────────────
|
||||
|
||||
def parse_entertainment_status(text: str) -> Dict[str, List[dict]]:
|
||||
"""Parse ``pos entertainment status`` output.
|
||||
|
||||
Returns::
|
||||
|
||||
{
|
||||
"plugins": [
|
||||
{
|
||||
"name": "weather",
|
||||
"interval": "5m",
|
||||
"last_run": "rc=0 (08-17 10:30)"
|
||||
},
|
||||
...
|
||||
],
|
||||
"not_enabled": ["joke"],
|
||||
"scheduler": "systemd user timers"
|
||||
}
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return {"plugins": [], "not_enabled": [], "scheduler": "unknown"}
|
||||
|
||||
plugins: List[dict] = []
|
||||
not_enabled: List[str] = []
|
||||
scheduler = "unknown"
|
||||
|
||||
lines = text.splitlines()
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
stripped = line.strip()
|
||||
|
||||
# Scheduler line
|
||||
if stripped.startswith("Scheduler:"):
|
||||
scheduler = stripped.split(":", 1)[1].strip()
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Table header detection: line that starts with PLUGIN
|
||||
if stripped.startswith("PLUGIN") or stripped.upper().startswith("PLUGIN"):
|
||||
i += 1
|
||||
# Skip separator lines
|
||||
while i < len(lines) and (lines[i].strip().startswith("---") or lines[i].strip() == ""):
|
||||
i += 1
|
||||
# Parse table rows
|
||||
while i < len(lines):
|
||||
row = lines[i].strip()
|
||||
if not row or row.startswith("Installed") or row.startswith("Scheduler") or row.startswith("--"):
|
||||
break
|
||||
# Expected format: PLUGIN INTERVAL LAST RUN
|
||||
# Split on two or more whitespace chars
|
||||
parts = re.split(r"\s{2,}", row)
|
||||
if len(parts) >= 1 and parts[0]:
|
||||
entry = {
|
||||
"name": parts[0],
|
||||
"interval": parts[1] if len(parts) > 1 else "",
|
||||
"last_run": parts[2] if len(parts) > 2 else "never",
|
||||
}
|
||||
plugins.append(entry)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# "Installed but not enabled:" section
|
||||
if "not enabled" in stripped.lower():
|
||||
i += 1
|
||||
while i < len(lines):
|
||||
row = lines[i].strip()
|
||||
if not row or row.startswith("Scheduler") or row.startswith("--"):
|
||||
break
|
||||
not_enabled.append(row.lstrip("•- "))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Direct "Enabled plugins:" with "(none" line
|
||||
if "enabled plugins" in stripped.lower() and i + 1 < len(lines):
|
||||
nxt = lines[i + 1].strip()
|
||||
if nxt.startswith("(") or nxt.startswith("(none"):
|
||||
i += 2
|
||||
continue
|
||||
|
||||
i += 1
|
||||
|
||||
return {"plugins": plugins, "not_enabled": not_enabled, "scheduler": scheduler}
|
||||
|
||||
|
||||
# ── Docker ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def parse_docker_ps(text: str) -> List[dict]:
|
||||
"""Parse ``pos docker ps`` output (table with ANSI codes already stripped).
|
||||
|
||||
Returns a list of dicts with keys:
|
||||
name, image, status, health, uptime, ips, ports, id
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return []
|
||||
|
||||
containers: List[dict] = []
|
||||
lines = text.splitlines()
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
# Skip header, separator, and summary lines
|
||||
if not stripped:
|
||||
continue
|
||||
if stripped.startswith("NAME") or stripped.startswith("───"):
|
||||
continue
|
||||
if stripped.startswith("Containers:") or stripped.startswith("Healthy:") \
|
||||
or stripped.startswith("Unhealthy:") or stripped.startswith("Running:") \
|
||||
or stripped.startswith("Warning:"):
|
||||
continue
|
||||
|
||||
# pos docker ps format:
|
||||
# NAME IMAGE STATUS(HEALTH) UPTIME IPS PORTS CONTAINER ID
|
||||
parts = re.split(r"\s{2,}", stripped)
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
|
||||
name = parts[0]
|
||||
image = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
# Status may include (health) in parens
|
||||
status_raw = parts[2] if len(parts) > 2 else ""
|
||||
health = ""
|
||||
status = status_raw
|
||||
m = re.match(r"(\S+)\s*\((\w+)\)", status_raw)
|
||||
if m:
|
||||
status = m.group(1)
|
||||
health = m.group(2)
|
||||
|
||||
uptime = parts[3] if len(parts) > 3 else ""
|
||||
ips = parts[4] if len(parts) > 4 else ""
|
||||
ports = parts[5] if len(parts) > 5 else ""
|
||||
cid = parts[6] if len(parts) > 6 else ""
|
||||
|
||||
containers.append({
|
||||
"name": name,
|
||||
"image": image,
|
||||
"status": status,
|
||||
"health": health,
|
||||
"uptime": uptime,
|
||||
"ips": ips,
|
||||
"ports": ports,
|
||||
"id": cid,
|
||||
})
|
||||
|
||||
return containers
|
||||
|
||||
|
||||
def parse_docker_health(text: str) -> Dict[str, int]:
|
||||
"""Parse ``pos docker health`` summary line.
|
||||
|
||||
Returns::
|
||||
|
||||
{"total": N, "healthy": N, "unhealthy": N, "no_check": N}
|
||||
|
||||
Falls back to counting per-container lines when the summary is absent.
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return {"total": 0, "healthy": 0, "unhealthy": 0, "no_check": 0}
|
||||
|
||||
result = {"total": 0, "healthy": 0, "unhealthy": 0, "no_check": 0}
|
||||
|
||||
# Try the summary line first: "Total: N healthy: N unhealthy: N no check: N"
|
||||
for line in text.splitlines():
|
||||
m = re.search(
|
||||
r"Total:\s*(\d+).*?healthy:\s*(\d+).*?unhealthy:\s*(\d+)",
|
||||
line,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if m:
|
||||
result["total"] = int(m.group(1))
|
||||
result["healthy"] = int(m.group(2))
|
||||
result["unhealthy"] = int(m.group(3))
|
||||
# no_check may follow
|
||||
m2 = re.search(r"no.check:\s*(\d+)", line, re.IGNORECASE)
|
||||
if m2:
|
||||
result["no_check"] = int(m2.group(1))
|
||||
return result
|
||||
|
||||
# Fallback: parse the per-container lines
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("──") or stripped.startswith("CONTAINER") \
|
||||
or "Docker Health" in stripped or stripped.startswith("Total:"):
|
||||
continue
|
||||
# Each line: CONTAINER_NAME STATUS_TEXT UPTIME
|
||||
# Look for health keywords in the status text
|
||||
lower = stripped.lower()
|
||||
if "healthy" in lower and "unhealthy" not in lower:
|
||||
result["healthy"] += 1
|
||||
result["total"] += 1
|
||||
elif "unhealthy" in lower:
|
||||
result["unhealthy"] += 1
|
||||
result["total"] += 1
|
||||
elif "no healthcheck" in lower or "no check" in lower:
|
||||
result["no_check"] += 1
|
||||
result["total"] += 1
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ── System ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def parse_system_health(text: str) -> List[dict]:
|
||||
"""Parse ``pos system health`` output into structured checks.
|
||||
|
||||
Returns a list of dicts with keys:
|
||||
check (str), status ("ok"|"warn"|"fail"), detail (str)
|
||||
Plus a ``verdict`` entry at the end.
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return []
|
||||
|
||||
checks: List[dict] = []
|
||||
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
|
||||
# Match "[ OK ] check_name: detail" (and WARN/FAIL variants)
|
||||
m = re.match(
|
||||
r"\[\s*(OK|WARN|FAIL)\s*\]\s+(\S+):\s*(.*)",
|
||||
stripped,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if m:
|
||||
status_word = m.group(1).lower()
|
||||
checks.append({
|
||||
"check": m.group(2),
|
||||
"status": status_word,
|
||||
"detail": m.group(3).strip(),
|
||||
})
|
||||
continue
|
||||
|
||||
# Verdict line
|
||||
m = re.match(r"VERDICT\s+(.*)", stripped, re.IGNORECASE)
|
||||
if m:
|
||||
checks.append({
|
||||
"check": "verdict",
|
||||
"status": "info",
|
||||
"detail": m.group(1).strip(),
|
||||
})
|
||||
|
||||
return checks
|
||||
|
||||
|
||||
# ── Telegram ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def parse_telegram_commands(text: str) -> List[dict]:
|
||||
"""Parse ``telegram_commands.env`` content.
|
||||
|
||||
Format per line: ``/command=bash command`` or
|
||||
``/command::description=bash command``.
|
||||
|
||||
Prefix ``@quiet`` in the value means the command runs silently.
|
||||
|
||||
Returns a list of dicts with keys:
|
||||
command, description, script, quiet
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return []
|
||||
|
||||
commands: List[dict] = []
|
||||
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
|
||||
# Split on first '='
|
||||
eq_pos = line.find("=")
|
||||
if eq_pos == -1:
|
||||
continue
|
||||
|
||||
left = line[:eq_pos]
|
||||
value = line[eq_pos + 1:]
|
||||
|
||||
# Left side: /cmd or /cmd::description
|
||||
desc = ""
|
||||
if "::" in left:
|
||||
parts = left.split("::", 1)
|
||||
cmd_name = parts[0]
|
||||
desc = parts[1]
|
||||
else:
|
||||
cmd_name = left
|
||||
|
||||
# Detect @quiet prefix in value
|
||||
quiet = False
|
||||
if value.startswith("@quiet "):
|
||||
quiet = True
|
||||
value = value[7:]
|
||||
|
||||
commands.append({
|
||||
"command": cmd_name,
|
||||
"description": desc,
|
||||
"script": value,
|
||||
"quiet": quiet,
|
||||
})
|
||||
|
||||
return commands
|
||||
|
||||
|
||||
def parse_docker_stacks(text: str) -> List[dict]:
|
||||
"""Parse ``pos docker stack`` output into grouped stacks.
|
||||
|
||||
Returns a list of dicts with keys:
|
||||
name (str), containers (list of {name, status, ports})
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return []
|
||||
|
||||
stacks: List[dict] = []
|
||||
current_stack: dict | None = None
|
||||
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
|
||||
# Stack header: "── stackname ──────"
|
||||
m = re.match(r"──\s+\S+\s+(.*)", stripped)
|
||||
if m and not stripped.startswith("───"):
|
||||
# Check if it's a real stack header (contains cyan marker removed)
|
||||
# After ANSI strip it looks like: "── label ──────────"
|
||||
# Extract label between "── " and " ─"
|
||||
hm = re.match(r"──\s+(.+?)\s+──", stripped)
|
||||
if hm:
|
||||
label = hm.group(1).strip()
|
||||
else:
|
||||
# Might be "── label" with trailing ──
|
||||
parts = stripped.split("──")
|
||||
label = parts[1].strip() if len(parts) > 1 else stripped
|
||||
|
||||
current_stack = {"name": label, "containers": []}
|
||||
stacks.append(current_stack)
|
||||
continue
|
||||
|
||||
# Container row inside a stack: " name status ports"
|
||||
if current_stack is not None and stripped:
|
||||
# Skip separator/summary lines
|
||||
if stripped.startswith("Stacks:") or re.match(r"^[─]+", stripped):
|
||||
continue
|
||||
# Parse: NAME STATUS PORTS
|
||||
parts = re.split(r"\s{2,}", stripped)
|
||||
if len(parts) >= 2:
|
||||
container = {
|
||||
"name": parts[0],
|
||||
"status": parts[1] if len(parts) > 1 else "",
|
||||
"ports": parts[2] if len(parts) > 2 else "",
|
||||
}
|
||||
current_stack["containers"].append(container)
|
||||
|
||||
return stacks
|
||||
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
Subprocess runner for CLI commands.
|
||||
|
||||
All functions return a dict with keys:
|
||||
stdout — captured stdout (str)
|
||||
stderr — captured stderr (str)
|
||||
returncode — process exit code (int)
|
||||
|
||||
On catastrophic failure (command not found, timeout) the dict also contains
|
||||
an ``error`` key with a human-readable explanation.
|
||||
|
||||
NEVER uses shell=True — every call is an argument list.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from typing import List
|
||||
|
||||
# ANSI escape sequence pattern: ESC [ ... letter/tilde
|
||||
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
||||
|
||||
|
||||
def strip_ansi(text: str) -> str:
|
||||
"""Remove ANSI escape codes from *text*."""
|
||||
return _ANSI_RE.sub("", text)
|
||||
|
||||
|
||||
def run_cmd(args: List[str], timeout: int = 30) -> dict:
|
||||
"""Run an arbitrary command and capture its output.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
args : list[str]
|
||||
Command and arguments (no shell).
|
||||
timeout : int
|
||||
Seconds before the process is killed (default 30).
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict {stdout, stderr, returncode} — or {error, returncode} on failure.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
return {
|
||||
"stdout": strip_ansi(result.stdout),
|
||||
"stderr": strip_ansi(result.stderr),
|
||||
"returncode": result.returncode,
|
||||
}
|
||||
except FileNotFoundError:
|
||||
return {
|
||||
"error": f"command not found: {args[0]}",
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"returncode": -1,
|
||||
}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {
|
||||
"error": f"command timed out after {timeout}s: {' '.join(args)}",
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"returncode": -1,
|
||||
}
|
||||
except Exception as exc:
|
||||
return {
|
||||
"error": str(exc),
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"returncode": -1,
|
||||
}
|
||||
|
||||
|
||||
def run_pos(args: List[str], timeout: int = 30) -> dict:
|
||||
"""Run a ``pos`` CLI command (prepends ``pos`` to *args*).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
args : list[str]
|
||||
Subcommand and arguments, e.g. ``["entertainment", "send", "joke"]``.
|
||||
timeout : int
|
||||
Seconds before the process is killed (default 30).
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict {stdout, stderr, returncode}
|
||||
"""
|
||||
return run_cmd(["pos"] + args, timeout=timeout)
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Homelab Dashboard — Custom styles
|
||||
*
|
||||
* Beyond Tailwind: animations, scrollbars, safe-area, htmx indicators.
|
||||
* This file is the design authority for non-utility CSS (see 03-DESIGN-SPEC §10).
|
||||
*/
|
||||
|
||||
/* ── Animations ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.animate-fade-in-up {
|
||||
animation: fadeInUp 0.3s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes badgePop {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.1); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
.animate-badge-pop {
|
||||
animation: badgePop 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* ── Safe area padding for notched phones ───────────────────────────────────── */
|
||||
|
||||
main {
|
||||
padding-bottom: calc(4rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
main { padding-bottom: 0; }
|
||||
}
|
||||
|
||||
/* ── Alpine.js cloak ────────────────────────────────────────────────────────── */
|
||||
|
||||
[x-cloak] { display: none !important; }
|
||||
|
||||
/* ── Scrollbar styling (dark) ───────────────────────────────────────────────── */
|
||||
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #334155; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #475569; }
|
||||
|
||||
/* Firefox */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #334155 transparent;
|
||||
}
|
||||
|
||||
/* ── Reduced motion ─────────────────────────────────────────────────────────── */
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── htmx loading indicator ─────────────────────────────────────────────────── */
|
||||
|
||||
.htmx-indicator { display: none; }
|
||||
.htmx-request .htmx-indicator,
|
||||
.htmx-request.htmx-indicator { display: block; }
|
||||
.htmx-settling .htmx-indicator { display: none; }
|
||||
|
||||
/* ── Focus visible (keyboard users only) ────────────────────────────────────── */
|
||||
|
||||
:focus:not(:focus-visible) { outline: none; }
|
||||
:focus-visible {
|
||||
outline: 2px solid #06b6d4;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ── Line clamp utility ─────────────────────────────────────────────────────── */
|
||||
|
||||
.line-clamp-1 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
.line-clamp-2 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Homelab Dashboard — Alpine.js helpers & htmx configuration.
|
||||
*
|
||||
* Loaded after Alpine.js and htmx CDNs.
|
||||
*/
|
||||
|
||||
/* ── htmx defaults ─────────────────────────────────────────────────────────── */
|
||||
|
||||
if (typeof htmx !== 'undefined') {
|
||||
htmx.config.defaultSwapStyle = 'innerHTML';
|
||||
htmx.config.defaultSettleDelay = 0;
|
||||
}
|
||||
|
||||
/* ── Toast notification helper ──────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Dispatch a toast notification.
|
||||
* @param {string} title – Bold heading
|
||||
* @param {string} message – Detail text
|
||||
* @param {'success'|'warning'|'error'} variant – Visual style
|
||||
*/
|
||||
function showToast(title, message, variant) {
|
||||
variant = variant || 'success';
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('show-toast', {
|
||||
detail: { title: title, message: message, variant: variant },
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Formatting helpers ─────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Convert seconds (or a "Xd Yh" string) to a human-readable uptime.
|
||||
* @param {number|string} input – Seconds or pre-formatted string
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatUptime(input) {
|
||||
if (typeof input === 'string' && input.match(/[a-zA-Z]/)) return input;
|
||||
var secs = parseInt(input, 10);
|
||||
if (isNaN(secs) || secs < 0) return '--';
|
||||
var d = Math.floor(secs / 86400);
|
||||
var h = Math.floor((secs % 86400) / 3600);
|
||||
var m = Math.floor((secs % 3600) / 60);
|
||||
if (d > 0) return d + 'd ' + h + 'h';
|
||||
if (h > 0) return h + 'h ' + m + 'm';
|
||||
if (m > 0) return m + 'm';
|
||||
return secs + 's';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an ISO date string to a relative "2m ago" string.
|
||||
* @param {string} dateString – ISO 8601
|
||||
* @returns {string}
|
||||
*/
|
||||
function timeAgo(dateString) {
|
||||
if (!dateString) return '--';
|
||||
var now = Date.now();
|
||||
var then = new Date(dateString).getTime();
|
||||
if (isNaN(then)) return dateString;
|
||||
var diff = Math.floor((now - then) / 1000);
|
||||
if (diff < 0) return 'just now';
|
||||
if (diff < 60) return diff + 's ago';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
||||
return Math.floor(diff / 86400) + 'd ago';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the status badge variant class for a health / status string.
|
||||
* @param {string} status – 'ok', 'healthy', 'running', 'warn', 'unhealthy', 'stopped', etc.
|
||||
* @returns {{ bg: string, text: string, dot: string, border: string, label: string }}
|
||||
*/
|
||||
function statusBadge(status) {
|
||||
var s = (status || '').toLowerCase();
|
||||
if (s === 'ok' || s === 'healthy' || s === 'running' || s === 'active' || s === 'success') {
|
||||
return {
|
||||
bg: 'bg-emerald-500/10', text: 'text-emerald-400', dot: 'bg-emerald-400',
|
||||
border: 'border-emerald-500/20', label: status || 'OK',
|
||||
};
|
||||
}
|
||||
if (s === 'warn' || s === 'warning' || s === 'degraded') {
|
||||
return {
|
||||
bg: 'bg-amber-500/10', text: 'text-amber-400', dot: 'bg-amber-400',
|
||||
border: 'border-amber-500/20', label: status || 'WARN',
|
||||
};
|
||||
}
|
||||
if (s === 'fail' || s === 'failed' || s === 'error' || s === 'unhealthy' || s === 'stopped' || s === 'inactive' || s === 'dead') {
|
||||
return {
|
||||
bg: 'bg-rose-500/10', text: 'text-rose-400', dot: 'bg-rose-400',
|
||||
border: 'border-rose-500/20', label: status || 'FAIL',
|
||||
};
|
||||
}
|
||||
return {
|
||||
bg: 'bg-slate-500/10', text: 'text-slate-400', dot: 'bg-slate-400',
|
||||
border: 'border-slate-500/20', label: status || 'OFF',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the display label for a status string.
|
||||
*/
|
||||
function statusLabel(status) {
|
||||
var s = (status || '').toLowerCase();
|
||||
if (s === 'ok' || s === 'healthy' || s === 'running' || s === 'active' || s === 'success') return 'OK';
|
||||
if (s === 'warn' || s === 'warning' || s === 'degraded') return 'WARN';
|
||||
if (s === 'fail' || s === 'failed' || s === 'error' || s === 'unhealthy' || s === 'stopped' || s === 'inactive' || s === 'dead') return 'FAIL';
|
||||
return 'OFF';
|
||||
}
|
||||
|
||||
/* ── Alpine.js global store ─────────────────────────────────────────────────── */
|
||||
|
||||
document.addEventListener('alpine:init', function () {
|
||||
Alpine.store('app', {
|
||||
activeTab: 'entertainment',
|
||||
refreshing: false,
|
||||
lastRefresh: null,
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
+17
-2
@@ -61,7 +61,7 @@ Options:
|
||||
--skip <phase> Skip a phase (repeatable):
|
||||
preinstall, scripts, postinstall, scalepoint, apps
|
||||
--steps <spec> Run only specific phases. Format: 1,3,4 or 1-3
|
||||
(1=preinstall, 2=scripts, 3=postinstall, 4=scalepoint)
|
||||
(1=preinstall, 2=scripts, 3=postinstall, 4=scalepoint, 5=apps)
|
||||
--no-color Disable colored output
|
||||
-h, --help Show this help message
|
||||
|
||||
@@ -96,7 +96,7 @@ while [[ $# -gt 0 ]]; do
|
||||
done
|
||||
|
||||
# ── Phase runner ────────────────────────────────────────────────
|
||||
# Phase names → numbers: preinstall=1 scripts=2 postinstall=3 scalepoint=4
|
||||
# Phase names → numbers: preinstall=1 scripts=2 postinstall=3 scalepoint=4 apps=5
|
||||
should_run() {
|
||||
local phase_num="$1"
|
||||
local phase_name="$2"
|
||||
@@ -159,6 +159,21 @@ if should_run 2 scripts; then
|
||||
done
|
||||
ok "$pcount entertainment plugins -> /usr/local/bin: ${pnames% }"
|
||||
|
||||
# ── Dashboard web application ──────────────────────────────
|
||||
if [ -d dashboard ]; then
|
||||
dash_dest="/usr/local/share/linux_post_install/dashboard"
|
||||
run sudo mkdir -p "$dash_dest"
|
||||
run sudo cp -a dashboard/* "$dash_dest/"
|
||||
log "dashboard/ -> $dash_dest"
|
||||
# Config template (no clobber — postinstall.sh handles the user copy)
|
||||
if [ -f config/dashboard.env ] && [ ! -f "$HOME/.config/linux_post_install/dashboard.env" ]; then
|
||||
run mkdir -p "$HOME/.config/linux_post_install"
|
||||
run cp config/dashboard.env "$HOME/.config/linux_post_install/dashboard.env"
|
||||
run chmod 600 "$HOME/.config/linux_post_install/dashboard.env"
|
||||
log "Installed dashboard.env template"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Precompiled architecture binaries ─────────────────────
|
||||
# Manually-compiled binaries (not available on the internet),
|
||||
# copied straight into /usr/local/bin for the matching arch.
|
||||
|
||||
+19
-2
@@ -1,5 +1,5 @@
|
||||
# ── Colors (auto-off when not a TTY) ───────────────────────────
|
||||
if [ -t 1 ]; then
|
||||
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
|
||||
CYAN=$(tput setaf 6)
|
||||
GREEN=$(tput setaf 2)
|
||||
YELLOW=$(tput setaf 3)
|
||||
@@ -51,7 +51,7 @@ run() {
|
||||
}
|
||||
|
||||
# ── Internal: nanoseconds → formatted time string ─────────────
|
||||
_nano_now() { date +%s%N; }
|
||||
_nano_now() { local t; t=$(date +%s%N 2>/dev/null); [[ "$t" =~ ^[0-9]+$ ]] && echo "$t" || echo "$(date +%s)000000000"; }
|
||||
_elapsed() {
|
||||
local start="$1" end
|
||||
end=$(_nano_now)
|
||||
@@ -128,6 +128,23 @@ confirm() {
|
||||
fi
|
||||
}
|
||||
|
||||
# ── load_tool_config ───────────────────────────────────────────
|
||||
# load_tool_config <config_file>
|
||||
# Load per-tool env file. Env vars already set win (precedence).
|
||||
# Skips comments, strips quotes and \r.
|
||||
load_tool_config() {
|
||||
local file="${1:-}" k v
|
||||
[ -f "$file" ] || return 0
|
||||
while IFS='=' read -r k v || [ -n "$k" ]; do
|
||||
[[ "$k" =~ ^[[:space:]]*# ]] && continue
|
||||
[[ "$k" =~ ^[A-Z_][A-Z0-9_]*$ ]] || continue
|
||||
v="${v#\"}"; v="${v%\"}"
|
||||
v="${v#\'}"; v="${v%\'}"
|
||||
v="${v//$'\r'/}"
|
||||
[ -z "${!k:-}" ] && export "$k"="$v"
|
||||
done < "$file"
|
||||
}
|
||||
|
||||
# ── system.env loader ──────────────────────────────────────────
|
||||
# Shared "system" tool config (~/.config/linux_post_install/system.env).
|
||||
# Fills only variables that are not already exported — an explicitly-set
|
||||
|
||||
+5
-3
@@ -180,12 +180,14 @@ cfg_value() {
|
||||
# "-" removes the key's line. Same semantics as write_config_key(). Replaces
|
||||
# via grep-v + append (not sed), so values may contain &, |, \ etc. safely.
|
||||
cfg_write() {
|
||||
local file="$1" key="$2" val="$3" tmp
|
||||
local file="$1" key="$2" val="$3" tmp _esc_key
|
||||
# Escape regex special characters in the key for safe use in grep -v
|
||||
_esc_key="$(printf '%s' "$key" | sed 's/[.[\*^$()+?{|]/\\&/g')"
|
||||
mkdir -p "$(dirname "$file")"
|
||||
if [ "$val" = "-" ]; then
|
||||
[ -f "$file" ] || return 0
|
||||
tmp="$(mktemp)"
|
||||
grep -v "^${key}=" "$file" >"$tmp" || true
|
||||
grep -v "^${_esc_key}=" "$file" >"$tmp" || true
|
||||
mv "$tmp" "$file"
|
||||
chmod 600 "$file"
|
||||
return 0
|
||||
@@ -196,7 +198,7 @@ cfg_write() {
|
||||
warn "multi-line paste — using first line only"
|
||||
fi
|
||||
tmp="$(mktemp)"
|
||||
grep -v "^${key}=" "$file" 2>/dev/null >"$tmp" || true
|
||||
grep -v "^${_esc_key}=" "$file" 2>/dev/null >"$tmp" || true
|
||||
printf '%s="%s"\n' "$key" "$val" >>"$tmp"
|
||||
mv "$tmp" "$file"
|
||||
chmod 600 "$file"
|
||||
|
||||
@@ -34,20 +34,21 @@ config_value() {
|
||||
}
|
||||
|
||||
write_config_key() {
|
||||
local key="$1" val="$2" tmp
|
||||
local key="$1" val="$2" tmp _esc_key
|
||||
val="${val//$'\r'/}"
|
||||
val="${val%%$'\n'*}"
|
||||
_esc_key="$(printf '%s' "$key" | sed 's/[.[\*^$()+?{|]/\\&/g')"
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
if [ "$val" = "-" ]; then
|
||||
[ -f "$CONFIG_FILE" ] || return 0
|
||||
tmp="$(mktemp)"
|
||||
grep -v "^${key}=" "$CONFIG_FILE" >"$tmp" || true
|
||||
grep -v "^${_esc_key}=" "$CONFIG_FILE" >"$tmp" || true
|
||||
mv "$tmp" "$CONFIG_FILE"
|
||||
chmod 600 "$CONFIG_FILE"
|
||||
return 0
|
||||
fi
|
||||
tmp="$(mktemp)"
|
||||
grep -v "^${key}=" "$CONFIG_FILE" 2>/dev/null >"$tmp" || true
|
||||
grep -v "^${_esc_key}=" "$CONFIG_FILE" 2>/dev/null >"$tmp" || true
|
||||
printf '%s="%s"\n' "$key" "$val" >>"$tmp"
|
||||
mv "$tmp" "$CONFIG_FILE"
|
||||
chmod 600 "$CONFIG_FILE"
|
||||
@@ -164,7 +165,12 @@ parse_enabled() {
|
||||
[ -n "$cur" ] && ENABLED_ENTRIES+=("$cur")
|
||||
cur="$token"
|
||||
elif [ -n "$cur" ]; then
|
||||
cur="$cur,$token"
|
||||
# Token is not a plugin name — check if it looks like a valid interval
|
||||
if [[ "$token" =~ ^[0-9]+[mhd]$ ]] || [[ "$token" =~ ^(hourly|daily|weekly)$ ]]; then
|
||||
cur="$cur,$token"
|
||||
else
|
||||
warn "skipping unknown token '$token' in ENABLED (not a plugin or interval)"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
[ -n "$cur" ] && ENABLED_ENTRIES+=("$cur")
|
||||
@@ -187,7 +193,7 @@ enabled_upsert() {
|
||||
local plugin="$1" interval="${2:-}" raw entry p
|
||||
raw="$(config_value ENABLED)"
|
||||
local -a out=() found=0
|
||||
[ -n "$raw" ] && parse_enabled "$raw"
|
||||
parse_enabled "$raw"
|
||||
for entry in "${ENABLED_ENTRIES[@]}"; do
|
||||
p="${entry%%,*}"
|
||||
if [ "$p" = "$plugin" ]; then
|
||||
@@ -209,13 +215,19 @@ enabled_remove() {
|
||||
local plugin="$1" raw entry p
|
||||
raw="$(config_value ENABLED)"
|
||||
local -a out=()
|
||||
[ -n "$raw" ] && parse_enabled "$raw"
|
||||
parse_enabled "$raw"
|
||||
for entry in "${ENABLED_ENTRIES[@]}"; do
|
||||
p="${entry%%,*}"
|
||||
[ "$p" = "$plugin" ] && continue
|
||||
out+=("$entry")
|
||||
done
|
||||
write_config_key ENABLED "$(render_enabled "${out[@]}")"
|
||||
local result
|
||||
result="$(render_enabled "${out[@]}")"
|
||||
if [ -n "$result" ]; then
|
||||
write_config_key ENABLED "$result"
|
||||
else
|
||||
write_config_key ENABLED "-"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── systemd user timers ───────────────────────────────────────────
|
||||
@@ -232,7 +244,7 @@ sync_timers() {
|
||||
|
||||
# ── Backend: systemd user timers ─────────────────────────────────
|
||||
sync_systemd() {
|
||||
local raw entry plugin oncal
|
||||
local raw entry plugin oncal interval
|
||||
raw="$(config_value ENABLED)"
|
||||
parse_enabled "$raw"
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
# || source "$(dirname "$0")/entertainment-plugin-lib.sh"
|
||||
|
||||
# Config file is the source of truth — read it like the pos tools do (never source it).
|
||||
plugin_config_file="${PLUGIN_CONFIG_FILE:-$HOME/.config/linux_post_install/entertainment.env}"
|
||||
plugin_config_file="${PLUGIN_CONFIG_FILE:-${XDG_CONFIG_HOME:-$HOME/.config}/linux_post_install/entertainment.env}"
|
||||
|
||||
plugin_err() { echo "ERROR: $*" >&2; exit 1; }
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
SCHEDULE_DIR="${SCHEDULE_DIR:-$HOME/.config/linux_post_install/schedule.d}"
|
||||
SCHEDULE_STATE_DIR="${SCHEDULE_STATE_DIR:-$HOME/.local/share/linux_post_install/schedule/state}"
|
||||
SCHEDULE_LOG_DIR="${SCHEDULE_LOG_DIR:-$HOME/.local/share/linux_post_install/schedule/logs}"
|
||||
USER_SYSTEMD_DIR="${USER_SYSTEMD_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user}"
|
||||
SCHED_PREFIX="pos-schedule"
|
||||
SCHED_RUNNER="$(command -v pos-system-schedule 2>/dev/null || echo /usr/local/bin/pos-system-schedule)"
|
||||
SCHED_DEFAULT_INTERVAL="5m"
|
||||
@@ -41,9 +40,7 @@ declare -F warn >/dev/null || warn() { echo "[!] $*"; }
|
||||
declare -F ok >/dev/null || ok() { echo " OK $*"; }
|
||||
declare -F log >/dev/null || log() { echo "[+] $*"; }
|
||||
|
||||
# Shared systemd **user** timer machinery (interval→OnCalendar mapping, unit
|
||||
# pair writer, linger bootstrap) — the same lib the entertainment module uses,
|
||||
# so the two unit templates never drift apart. Defines USER_SYSTEMD_DIR + ut_*.
|
||||
# Shared systemd **user** timer machinery
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/../lib/user-timers-lib.sh" 2>/dev/null \
|
||||
|| source "$(dirname "${BASH_SOURCE[0]}")/user-timers-lib.sh" 2>/dev/null \
|
||||
|| source "$(dirname "$0")/../lib/user-timers-lib.sh" 2>/dev/null \
|
||||
@@ -117,6 +114,7 @@ sched_write_job() { # $1 = name — writes the JOB_* globals to schedule.d/<na
|
||||
[ -z "$JOB_MSG" ] || printf 'MSG=%s\n' "$JOB_MSG"
|
||||
[ -z "$JOB_RULE" ] || printf 'RULE=%s\n' "$JOB_RULE"
|
||||
printf 'COMMAND=%s\n' "$JOB_COMMAND"
|
||||
[ -z "${JOB_ENABLED:-}" ] || printf 'ENABLED=%s\n' "$JOB_ENABLED"
|
||||
} >"$tmp"
|
||||
mv "$tmp" "$f"
|
||||
chmod 600 "$f"
|
||||
@@ -334,6 +332,7 @@ sched_run_job() { # $1 = job name
|
||||
warn "job '$1' unparseable — skipping"
|
||||
return 1
|
||||
fi
|
||||
[ "${JOB_ENABLED:-}" = "false" ] && { log "job '$1' is disabled — skipping"; return 0; }
|
||||
sched_run_command
|
||||
case "$JOB_NOTIFY" in
|
||||
threshold) sched_policy_threshold ;;
|
||||
@@ -637,7 +636,7 @@ sched_status() {
|
||||
|
||||
# ── Interactive job editor (config) ─────────────────────────────
|
||||
sched_config_editor() {
|
||||
local -a jobs=() name action n ok
|
||||
local -a jobs=() name action n
|
||||
while true; do
|
||||
mapfile -t jobs < <(sched_list_jobs)
|
||||
echo "── jobs in $SCHEDULE_DIR ──" >&2
|
||||
@@ -727,14 +726,14 @@ sched_editor_edit() {
|
||||
}
|
||||
|
||||
sched_editor_remove() {
|
||||
local n="$1" name ok
|
||||
local n="$1" name yn
|
||||
[[ "$n" =~ ^[0-9]+$ ]] || { warn "use: remove <number>"; return; }
|
||||
local -a jobs=()
|
||||
mapfile -t jobs < <(sched_list_jobs)
|
||||
[ "$n" -ge 1 ] && [ "$n" -le "${#jobs[@]}" ] || { warn "no job $n"; return; }
|
||||
name="${jobs[$((n - 1))]}"
|
||||
read -rp "remove job '$name'? [y/N]: " ok
|
||||
[[ "$ok" =~ ^[Yy] ]] || return
|
||||
read -rp "remove job '$name'? [y/N]: " yn
|
||||
[[ "$yn" =~ ^[Yy] ]] || return
|
||||
if [ "${DRY_RUN:-0}" -eq 1 ]; then
|
||||
log "(dry-run) would remove job '$name'"
|
||||
return
|
||||
|
||||
+1
-1
@@ -59,7 +59,7 @@ usb_detect() {
|
||||
fi
|
||||
if [ -n "$mp" ]; then
|
||||
USB_MOUNTED+=("$mp|$label|$size|$model|$fs")
|
||||
elif [ "$children" = "0" ]; then
|
||||
elif [ "$children" -eq 0 ]; then
|
||||
USB_UNMOUNTED+=("$path|$label|$size|$model")
|
||||
fi
|
||||
done < <(printf '%s' "$out" | jq -r '
|
||||
|
||||
@@ -49,6 +49,17 @@ for tpl in system.env notify.env ai.env; do
|
||||
fi
|
||||
done
|
||||
|
||||
# ── dashboard config template ──────────────────────────────────
|
||||
if [ -f config/dashboard.env ]; then
|
||||
if [ -f "$ENT_DIR/dashboard.env" ]; then
|
||||
log "dashboard.env already exists, keeping it"
|
||||
else
|
||||
run cp config/dashboard.env "$ENT_DIR/dashboard.env"
|
||||
run chmod 600 "$ENT_DIR/dashboard.env"
|
||||
log "Installed dashboard.env — edit $ENT_DIR/dashboard.env"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── schedule starter jobs (interactive) ────────────────────────
|
||||
# config/schedule.d has ready-made example jobs (NVMe health, CPU/disk
|
||||
# thresholds, silent log cleanup). Ask before copying — only into an empty
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ PACKAGES=(
|
||||
ca-certificates gnupg lsb-release
|
||||
lm-sensors smartmontools nvme-cli hdparm
|
||||
sysstat iotop atop vnstat
|
||||
python3 python3-pip rclone
|
||||
python3 python3-pip python3-flask rclone
|
||||
ffmpeg
|
||||
libqrencode4 libgtk-3-0 adb
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user