Compare commits

...

4 Commits

Author SHA1 Message Date
Your Name 99085adc76 feat: add web dashboard with slide-in drawer navigation
- Flask backend with 23 API routes (entertainment, telegram, docker, system)
- Alpine.js + Tailwind CSS dark-mode SPA with 4 tabs
- pos-dashboard CLI tool with port/config management
- Mobile slide-in drawer with swipe-to-close
- Sticky header stays pinned on scroll
- Tab completion fixes for category-less tools
- POST /api/telegram/commands endpoint for adding commands
2026-08-18 11:54:41 -04:00
Your Name 06b077db40 fix: pos media sync — strip all trailing slashes + mark partial sync 2026-08-16 04:16:14 -04:00
Your Name ba12a418ff fix: pos media sync — normalize trailing-slash source + surface find errors 2026-08-16 04:02:05 -04:00
Your Name 2554696cfa docs: AGENTS.md — CI trigger precision + stub-harness testing convention 2026-08-16 03:31:00 -04:00
65 changed files with 5964 additions and 122858 deletions
+4
View File
@@ -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/
+6 -1
View File
@@ -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.
+2
View File
@@ -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.
+35 -32
View File
@@ -10,19 +10,19 @@
<!-- GEN:START docmap -->
| ## 1. Project Overview | 2843 |
| ## 2. Directory Structure | 44195 |
| ## 3. Installation Flow | 196247 |
| ## 4. The `pos` CLI System | 248320 |
| ## 5. Shared Library — `lib/common.sh` | 321352 |
| ## 6. Docker Compose / ScaleTail | 353395 |
| ## 7. Optional Apps (`apps/`) | 396425 |
| ## 8. Entertainment Module | 426439 |
| ## 9. Systemd Services | 440451 |
| ## 10. Configuration Files | 452478 |
| ## 11. Coding Conventions | 479511 |
| ## 12. Development Workflow | 512564 |
| ## 13. Key File Quick Reference | 565628 |
| ## 14. Common Tasks for Agents | 629661 |
| ## 2. Directory Structure | 44196 |
| ## 3. Installation Flow | 197248 |
| ## 4. The `pos` CLI System | 249322 |
| ## 5. Shared Library — `lib/common.sh` | 323354 |
| ## 6. Docker Compose / ScaleTail | 355397 |
| ## 7. Optional Apps (`apps/`) | 398427 |
| ## 8. Entertainment Module | 428441 |
| ## 9. Systemd Services | 442453 |
| ## 10. Configuration Files | 454480 |
| ## 11. Coding Conventions | 481513 |
| ## 12. Development Workflow | 514566 |
| ## 13. Key File Quick Reference | 567631 |
| ## 14. Common Tasks for Agents | 632664 |
<!-- 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
View File
File diff suppressed because it is too large Load Diff
+21 -1
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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 -5
View File
@@ -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]
+2 -3
View File
@@ -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
+2 -16
View File
@@ -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 -5
View File
@@ -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]
+2 -2
View File
@@ -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
View File
@@ -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:-}"
+275
View File
@@ -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
View File
@@ -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
+2 -1
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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')"
}
+2
View File
@@ -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
+4
View File
@@ -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"
+1 -1
View File
@@ -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
+11 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+8 -8
View File
@@ -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)' ;;
+9 -12
View File
@@ -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
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
'
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2 -6
View File
@@ -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[*]}"
+1 -1
View File
@@ -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
View File
@@ -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.
+4
View File
@@ -0,0 +1,4 @@
# Dashboard configuration
DASHBOARD_PORT=8080
DASHBOARD_HOST=0.0.0.0
# DASHBOARD_LOG_LEVEL=info
View File
+193
View File
@@ -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(),
})
+236
View File
@@ -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}"})
+204
View File
@@ -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)
+179
View File
@@ -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"})
+80
View File
@@ -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)
+168
View File
@@ -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.
+298
View File
@@ -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`
+717
View File
@@ -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.*
View File
+82
View File
@@ -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
+366
View File
@@ -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
+93
View File
@@ -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)
+92
View File
@@ -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;
}
+119
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+20 -8
View 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"
+1 -1
View File
@@ -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; }
+7 -8
View File
@@ -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
View File
@@ -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 '
+11
View File
@@ -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
View File
@@ -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
)
-122623
View File
File diff suppressed because one or more lines are too long