From 85827be3bef4177de801e51e7095c382dcd32228 Mon Sep 17 00:00:00 2001 From: Sam Rolfe Date: Thu, 8 Oct 2026 19:10:45 +1100 Subject: [PATCH] =?UTF-8?q?ON=5FOFF=20Phase=200-3:=20service-controller=20?= =?UTF-8?q?(.13:8443,=20token-gated,=20containers+systemd-user,=20audit=20?= =?UTF-8?q?log)=20+=20portal=20toggle=20cards=20(live=20status,=20per-user?= =?UTF-8?q?=20who,=20failed=20token,=20RAM)=20=E2=80=94=20full=20E2E=20ver?= =?UTF-8?q?ified?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 +- DESIGN.md | 4 +- deploy/DEPLOYMENT.md | 48 +++++ .../family-service-controller.service | 15 ++ deploy/service-controller/requirements.txt | 2 + deploy/service-controller/run.sh | 11 ++ .../service-controller/service_controller.py | 167 ++++++++++++++++++ deploy/service-controller/services.toml | 85 +++++++++ docker-compose.yml | 9 + portal/config.py | 4 + portal/main.py | 104 ++++++++++- portal/static/app.css | 20 +++ portal/static/tokens.css | 1 + portal/templates/partials/_sections.html | 68 +++++++ portal/tools.py | 16 +- 15 files changed, 549 insertions(+), 7 deletions(-) create mode 100644 deploy/service-controller/family-service-controller.service create mode 100644 deploy/service-controller/requirements.txt create mode 100755 deploy/service-controller/run.sh create mode 100644 deploy/service-controller/service_controller.py create mode 100644 deploy/service-controller/services.toml diff --git a/.env.example b/.env.example index 1eba208..1e7f1c6 100644 --- a/.env.example +++ b/.env.example @@ -26,4 +26,4 @@ S3_REGION=garage # --- Tool container images (verify before enabling profile "tools") --- IMAGE_GIMP=lscr.io/linuxserver/gimp:latest IMAGE_VIDEO=lscr.io/linuxserver/webtop:latest -IMAGE_AUDIO=lscr.io/linuxserver/webtop:latest \ No newline at end of file +IMAGE_AUDIO=lscr.io/linuxserver/webtop:latestSC_TOKEN=change-me diff --git a/DESIGN.md b/DESIGN.md index d61699c..8856a0b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -48,7 +48,9 @@ Assigned permanently to tool categories: | `{colors.accent-purple-deep}` | `#391c57` | Deep shade within illustrations only | | `{colors.accent-orange-deep}` | `#793400` | Deep shade within illustrations only | -Semantic status reuses stickers: green = online/saved, orange = busy/starting, faint grey = offline. +Semantic status reuses stickers: green = online/saved, orange = busy/starting, faint grey = offline, +**red `#d33a2b` = failed/error** (added for ON/OFF service cards, 2026-10-07; `accent-orange-deep` +stays illustration-only). ## Typography diff --git a/deploy/DEPLOYMENT.md b/deploy/DEPLOYMENT.md index d3ccc25..462c274 100644 --- a/deploy/DEPLOYMENT.md +++ b/deploy/DEPLOYMENT.md @@ -61,3 +61,51 @@ for b in sam jo harry finn shared-media; do $G bucket allow --read --write "$b" > Symmetry: ensure S3_ACCESS_KEY/S3_SECRET_KEY in `.env` match what `key import` > registers, else portal uploads will 403. + +## ON/OFF service-controller (ON_OFF.md) + +Host-side on/off API on `.13`, port **8443** (chosen because it is already in the +NixOS `networking.firewall.allowedTCPPorts` allowlist — 8091/8092 were taken by +langgraph-service / photo-dashboard, and 8099 was blocked by the firewall). + +Files live in `deploy/service-controller/` and copy to `/home/sam/service-controller/` on .13. + +Install / update: + +```bash +# on .27 (this repo), then: +scp -q deploy/service-controller/service_controller.py \ + deploy/service-controller/services.toml \ + deploy/service-controller/run.sh \ + deploy/service-controller/family-service-controller.service \ + sam@192.168.20.13:/home/sam/service-controller/ + +# on .13: +cd /home/sam/service-controller +python3 -m venv .venv # first time only +./.venv/bin/pip install -q -r requirements.txt # first time only +cp family-service-controller.service ~/.config/systemd/user/ +systemctl --user daemon-reload +systemctl --user enable --now family-service-controller.service +``` + +Secrets: +- `SC_TOKEN` lives in `/home/sam/.config/environment.d/10-secrets.conf` (git-ignored; run.sh sources it). +- The portal gets `SC_TOKEN` from its `.env` on .13 (injected at deploy; never committed). + +Firewall note: the controller binds `0.0.0.0:8443` and is protected by the token. +Binding only to the docker bridge gateway was dropped because this NixOS box drops +INPUT from containers to non-allowlisted host listeners; `0.0.0.0` on the already- +allowed port 8443 is the workable, token-gated compromise. + +Portal wiring: `docker-compose.yml` sets `SC_URL=http://host.docker.internal:8443` +and `SC_TOKEN=${SC_TOKEN:-}` on the portal service, with +`extra_hosts: ["host.docker.internal:192.168.144.1"]` (host-gateway resolves to the +DOWN docker0 bridge on this box; pinning to the fhl-net gateway works). + +Verify: +```bash +curl -s http://127.0.0.1:8443/health # {"ok":true,...} +curl -s -H "X-Controller-Token: $SC_TOKEN" http://127.0.0.1:8443/services +curl -s -X POST -H "X-Controller-Token: $SC_TOKEN" http://127.0.0.1:8443/services/lmms/stop +``` diff --git a/deploy/service-controller/family-service-controller.service b/deploy/service-controller/family-service-controller.service new file mode 100644 index 0000000..10a15b4 --- /dev/null +++ b/deploy/service-controller/family-service-controller.service @@ -0,0 +1,15 @@ +[Unit] +Description=Family Home Lab service-controller (on/off API) +After=network-online.target docker.service +Wants=network-online.target + +[Service] +Type=simple +User=sam +WorkingDirectory=/home/sam/service-controller +ExecStart=/home/sam/service-controller/run.sh +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target diff --git a/deploy/service-controller/requirements.txt b/deploy/service-controller/requirements.txt new file mode 100644 index 0000000..f669e2e --- /dev/null +++ b/deploy/service-controller/requirements.txt @@ -0,0 +1,2 @@ +fastapi>=0.110 +uvicorn>=0.29 diff --git a/deploy/service-controller/run.sh b/deploy/service-controller/run.sh new file mode 100755 index 0000000..fecfb23 --- /dev/null +++ b/deploy/service-controller/run.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# service-controller — host-side on/off API. Runs as a systemd USER unit. +set -a; [ -f /home/sam/.config/environment.d/10-secrets.conf ] && . /home/sam/.config/environment.d/10-secrets.conf; set +a +export SC_TOKEN="${SC_TOKEN:-}" +export SC_SERVICES="${SC_SERVICES:-/home/sam/service-controller/services.toml}" +export SC_AUDIT="${SC_AUDIT:-/home/sam/service-controller/audit.log}" +# Bind to the docker bridge gateway so the portal container can reach us via +# host.docker.internal. NOT reachable from the LAN; token still required. +export SC_HOST="${SC_HOST:-192.168.144.1}" +exec "${VENV:-/home/sam/service-controller/.venv}/bin/python" \ + /home/sam/service-controller/service_controller.py diff --git a/deploy/service-controller/service_controller.py b/deploy/service-controller/service_controller.py new file mode 100644 index 0000000..ff0fa22 --- /dev/null +++ b/deploy/service-controller/service_controller.py @@ -0,0 +1,167 @@ +"""service-controller — host-side on/off control for the Family Console. + +Only allowlisted services can be started/stopped. Requires a token +(X-Controller-Token). SC_HOST default 127.0.0.1; the systemd unit overrides it +with the docker bridge gateway so the portal container can reach us via +host.docker.internal while staying unreachable from the LAN. Covers Docker containers (sam is in the docker +group) and systemd USER services. No generic passthrough — unknown id => 404. + +API (see ON_OFF.md §4.2): + GET /health + GET /services + GET /services/{id} + POST /services/{id}/start + POST /services/{id}/stop + +Runs as a systemd USER unit: family-service-controller.service +""" +from __future__ import annotations + +import json +import os +import subprocess +import threading +import time +import tomllib +from pathlib import Path +from typing import Annotated, Any + +import uvicorn +from fastapi import FastAPI, Header, HTTPException, Request +from fastapi.responses import JSONResponse + +HERE = Path(__file__).resolve().parent +SERVICES_FILE = Path(os.getenv("SC_SERVICES", HERE / "services.toml")) +AUDIT_FILE = Path(os.getenv("SC_AUDIT", HERE / "audit.log")) +AUDIT_MAX_LINES = int(os.getenv("SC_AUDIT_MAX", "500")) +HOST = os.getenv("SC_HOST", "127.0.0.1") +PORT = int(os.getenv("SC_PORT", "8443")) # in NixOS firewall allowlist; 8099 was not +TOKEN = os.getenv("SC_TOKEN", "") # set by the unit's EnvironmentFile (10-secrets.conf) + +app = FastAPI(title="Family service-controller", docs_url=None) +_audit_lock = threading.Lock() + + +# --------------------------------------------------------------------------- # +# allowlist +# --------------------------------------------------------------------------- # +def load_services() -> list[dict[str, Any]]: + with open(SERVICES_FILE, "rb") as fh: + data = tomllib.load(fh) + return [dict(s) for s in data.get("services", [])] + + +SERVICES: list[dict[str, Any]] = load_services() +BY_ID = {s["id"]: s for s in SERVICES} + + +def _auth(x_token: Annotated[str | None, Header(alias="X-Controller-Token")] = None) -> None: + if not TOKEN or not x_token or x_token != TOKEN: + raise HTTPException(status_code=401, detail="unauthorized") + + +def audit(what: str) -> None: + """Append one bounded audit line (oldest dropped at AUDIT_MAX_LINES).""" + line = f"{time.strftime('%Y-%m-%dT%H:%M:%S%z')} {what}" + with _audit_lock: + try: + lines = AUDIT_FILE.read_text().splitlines() if AUDIT_FILE.exists() else [] + except Exception: + lines = [] + lines.append(line) + if len(lines) > AUDIT_MAX_LINES: + lines = lines[-AUDIT_MAX_LINES:] + AUDIT_FILE.write_text("\n".join(lines) + "\n") + + +# --------------------------------------------------------------------------- # +# probes / actions +# --------------------------------------------------------------------------- # +def _container_state(target: str) -> str: + r = subprocess.run(["docker", "inspect", "-f", "{{.State.Status}}", target], + capture_output=True, text=True) + out = r.stdout.strip() + return out or ("unknown" if r.returncode != 0 else "unknown") + + +def _systemd_user_state(target: str) -> str: + r = subprocess.run(["systemctl", "--user", "is-active", target], + capture_output=True, text=True) + return (r.stdout.strip() or "inactive").lower() + + +def _do(kind: str, action: str, target: str) -> str: + """Run start/stop. Returns the state after a short settle.""" + if kind == "container": + subprocess.run(["docker", action, target], capture_output=True, text=True) + time.sleep(1.5) + return _container_state(target) + subprocess.run(["systemctl", "--user", action, target], capture_output=True, text=True) + time.sleep(1.5) + return _systemd_user_state(target) + + +def _describe(s: dict[str, Any]) -> dict[str, Any]: + state = "unknown" + if s["kind"] == "container": + state = _container_state(s["target"]) + else: + state = _systemd_user_state(s["target"]) + return { + "id": s["id"], + "kind": s["kind"], + "target": s["target"], + "group": s.get("group", ""), + "label": s.get("label", s["id"]), + "ram_mb": s.get("ram_mb"), + "default_state": s.get("default_state", "stopped"), + "who": s.get("who", []), + "state": "running" if state in ("running", "active") else + ("stopped" if state in ("stopped", "inactive", "exited") else "unknown"), + "error": None, + } + + +# --------------------------------------------------------------------------- # +# routes +# --------------------------------------------------------------------------- # +@app.get("/health") +async def health(request: Request) -> dict: + return {"ok": True, "services": len(SERVICES)} + + +@app.get("/services") +async def services_list(x_token: Annotated[str | None, Header(alias="X-Controller-Token")] = None) -> dict: + _auth(x_token) + return {"ok": True, "services": [_describe(s) for s in SERVICES]} + + +@app.get("/services/{sid}") +async def service_get(sid: str, x_token: Annotated[str | None, Header(alias="X-Controller-Token")] = None) -> dict: + _auth(x_token) + s = BY_ID.get(sid) + if s is None: + raise HTTPException(status_code=404, detail="unknown service") + return {"ok": True, "service": _describe(s)} + + +@app.post("/services/{sid}/{action}") +async def service_action(sid: str, action: str, + x_token: Annotated[str | None, Header(alias="X-Controller-Token")] = None, + who: Annotated[str | None, Header()] = None) -> dict: + """Start or stop one service. `who` is the acting user (admin gate is done + portal-side; the controller logs it and enforces the allowlist).""" + _auth(x_token) + if action not in ("start", "stop"): + raise HTTPException(status_code=400, detail="action must be start|stop") + s = BY_ID.get(sid) + if s is None: + raise HTTPException(status_code=404, detail="unknown service") + actor = (who or "unknown-user").strip()[:40] + state = _do(s["kind"], action, s["target"]) + audit(f"{actor} {action} {sid} -> {state}") + return {"ok": True, "id": sid, "state": state} + + +if __name__ == "__main__": + uvicorn.run(app, host=HOST, port=PORT) diff --git a/deploy/service-controller/services.toml b/deploy/service-controller/services.toml new file mode 100644 index 0000000..b0e684c --- /dev/null +++ b/deploy/service-controller/services.toml @@ -0,0 +1,85 @@ +# service-controller allowlist — ON_OFF.md §3/§4. +# +# Only services listed here can be started/stopped. Everything else 404s. +# Safety (non-negotiable, §6): never list console deps, house-critical audio, +# voice/whisper/HA path, or n8n/prefect-server (shared infra, always on). +# +# kind: +# container -> docker start/stop (sam is in the docker group) +# systemd-user-> systemctl --user start/stop +# +# group "media": family-facing editors (shared by everyone). +# group "pipeline": photo ingestion (prefect worker + dashboard) — admin only. +# group "admin": heavy infra someone may pause when idle — admin only. + +[[services]] +id = "gimp" +kind = "container" +target = "family-home-lab-gimp-1" +group = "media" +label = "GIMP" +ram_mb = 233 +default_state = "stopped" +who = ["sam", "finn", "harry", "jo"] + +[[services]] +id = "video-editor" +kind = "container" +target = "family-home-lab-video-editor-1" +group = "media" +label = "Video Editor (KdenLive)" +ram_mb = 237 +default_state = "stopped" +who = ["sam", "finn", "harry", "jo"] + +[[services]] +id = "audio-editor" +kind = "container" +target = "family-home-lab-audio-editor-1" +group = "media" +label = "Audio Editor (Audacity)" +ram_mb = 201 +default_state = "stopped" +who = ["sam", "finn", "harry", "jo"] + +[[services]] +id = "lmms" +kind = "container" +target = "lmms" +group = "media" +label = "LMMS Music Studio" +ram_mb = 798 # incl. webtop desktop; Xvfb fixed to 2560x1440 (2026-10-07) +default_state = "stopped" +who = ["sam", "finn", "harry", "jo"] + +[[services]] +id = "paperclip" +kind = "systemd-user" +target = "paperclipai" +group = "admin" +label = "Paperclip" +ram_mb = 495 +default_state = "stopped" +who = ["sam"] + +# Photo ingestion pipeline — admin controlled. Do NOT stop while ingesting +# Google Photos (user actively working on it, 2026-10-07). +[[services]] +id = "prefect-worker" +kind = "systemd-user" +target = "prefect-worker" +group = "pipeline" +label = "Prefect worker (photo pool)" +ram_mb = 129 +default_state = "running" +who = ["sam"] + +[[services]] +id = "photo-dashboard" +kind = "systemd-user" +target = "photo-dashboard" +group = "pipeline" +label = "Photo pipeline dashboard" +ram_mb = 112 +default_state = "running" +who = ["sam"] diff --git a/docker-compose.yml b/docker-compose.yml index f30dd18..1838a1c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -87,6 +87,15 @@ services: ADMIN_PASSWORD: ${ADMIN_PASSWORD} ADMIN_FULLNAME: ${ADMIN_FULLNAME} PI_DASHBOARD_DIR: /pi-dashboard + # ON/OFF control — host-side service-controller (.13:8443, firewall-allowed). + # SC_TOKEN is injected by the deploy script from 10-secrets.conf (not committed). + SC_URL: http://host.docker.internal:8443 + SC_TOKEN: ${SC_TOKEN:-} + extra_hosts: + # host-gateway resolves to docker0 (172.17.0.1, DOWN) on this box — pin + # host.docker.internal to the fhl-net bridge gateway where the + # service-controller actually listens (192.168.144.1). LAN-unreachable. + - "host.docker.internal:192.168.144.1" volumes: - /mnt/data/family-home-lab/pi-dashboard:/pi-dashboard:ro - /mnt/data/family-home-lab/shared-media:/shared-media:rw diff --git a/portal/config.py b/portal/config.py index 3c2fc09..3179f0d 100644 --- a/portal/config.py +++ b/portal/config.py @@ -52,6 +52,10 @@ class Settings: S3_ACCESS_KEY: str = os.getenv("S3_ACCESS_KEY", "") S3_SECRET_KEY: str = os.getenv("S3_SECRET_KEY", "") + # --- service-controller (ON_OFF.md): host-side on/off API on .13 --- + SC_URL: str = os.getenv("SC_URL", "http://host.docker.internal:8443") + SC_TOKEN: str = os.getenv("SC_TOKEN", "") + # --- Tool catalogue endpoint for opening tools --- SECTION_COLORS: dict[str, str] = { "chat": "#62aef0", # accent-sky diff --git a/portal/main.py b/portal/main.py index d953f1d..60e2481 100644 --- a/portal/main.py +++ b/portal/main.py @@ -679,4 +679,106 @@ async def api_status(request: Request) -> HTMLResponse: @app.get("/healthz") async def healthz() -> dict: - return {"ok": True, "app": settings.APP_NAME} \ No newline at end of file + return {"ok": True, "app": settings.APP_NAME} + + +# --------------------------------------------------------------------------- # +# ON/OFF control (ON_OFF.md) — live status + start/stop via the host-side +# service-controller (.13:8443, token-gated, firewall-allowed port). No docker access here. +# --------------------------------------------------------------------------- # +SC_HEADERS = None +SC_CACHE: dict = {"at": 0.0, "data": None} +_SC_URL = settings.SC_URL.rstrip("/") + + +def _sc_headers() -> dict: + global SC_HEADERS + if SC_HEADERS is None: + SC_HEADERS = {"X-Controller-Token": settings.SC_TOKEN} + return SC_HEADERS + + +async def _sc_get(path: str) -> dict | None: + """GET the controller. Returns None on any failure (graceful degrade).""" + try: + async with httpx.AsyncClient(timeout=8) as client: + r = await client.get(f"{_SC_URL}{path}", headers=_sc_headers()) + if r.status_code != 200: + return None + return r.json() + except Exception: + return None + + +async def _sc_post(path: str, who: str) -> dict | None: + try: + async with httpx.AsyncClient(timeout=30) as client: + r = await client.post( + f"{_SC_URL}{path}", + headers={**_sc_headers(), "Who": who}, + ) + if r.status_code != 200: + return None + return r.json() + except Exception: + return None + + +async def _live_services() -> list[dict]: + """Controller service list, lightly cached (3s) so the dashboard doesn't + hammer it on HTMX polls.""" + import time as _time + now = _time.time() + if SC_CACHE["data"] is not None and now - SC_CACHE["at"] < 3.0: + return SC_CACHE["data"] + data = await _sc_get("/services") + if data is None: + return SC_CACHE["data"] or [] + SC_CACHE.update(at=now, data=data.get("services", [])) + return SC_CACHE["data"] + + +def _can_toggle(user, svc: dict) -> bool: + """Admin, or the user is on the service's allowlist (`who`).""" + if user.is_admin: + return True + who = svc.get("who") or [] + return user.username in who + + +@app.get("/api/services") +async def api_services(request: Request) -> dict: + """Live service list (allowlist + current state) for the o/o cards.""" + user = await _current_user(request) + if user is None: + raise HTTPException(status_code=401) + svcs = await _live_services() + out = [] + for s in svcs: + out.append({ + **s, + "can_toggle": _can_toggle(user, s), + "ram_mb": s.get("ram_mb"), + }) + return {"ok": True, "services": out} + + +@app.post("/service/{sid}/{action}") +async def service_toggle(sid: str, action: str, request: Request) -> dict: + """Start/stop a service. Admin or allowlisted user; audited controller-side.""" + user = await _current_user(request) + if user is None: + raise HTTPException(status_code=401) + if action not in ("start", "stop"): + raise HTTPException(status_code=400) + svcs = await _live_services() + svc = next((s for s in svcs if s["id"] == sid), None) + if svc is None: + raise HTTPException(status_code=404, detail="unknown service") + if not _can_toggle(user, svc): + raise HTTPException(status_code=403, detail="not allowed") + res = await _sc_post(f"/services/{sid}/{action}", who=user.username) + if res is None: + raise HTTPException(status_code=502, detail="service-controller unreachable") + SC_CACHE.update(at=0.0) # force refresh next poll + return res \ No newline at end of file diff --git a/portal/static/app.css b/portal/static/app.css index 792333b..829dd4b 100644 --- a/portal/static/app.css +++ b/portal/static/app.css @@ -385,3 +385,23 @@ button.button, input[type="submit"].button { font-family: inherit; } .section-toggle:hover { opacity: 1; } .section.collapsed .section-toggle { transform: rotate(-90deg); } .section.collapsed .tool-grid { display: none; } + +/* ---------- ON/OFF service strip (ON_OFF.md) ---------- */ +.svc-strip { + display: flex; + align-items: center; + gap: 8px; + margin-top: 2px; + padding-top: 10px; + border-top: 1px dashed var(--hairline); +} +.svc-state { display: inline-flex; align-items: center; gap: 6px; font: var(--type-caption); color: var(--ink-muted); } +.svc-dot { width: 8px; height: 8px; border-radius: var(--rounded-full); } +.svc-dot.svc-on { background: var(--accent-green, #1aae39); } +.svc-dot.svc-off { background: var(--ink-faint, #a39e98); } +.svc-dot.svc-trans { background: var(--accent-orange, #dd5b00); animation: pulse 1.2s ease-in-out infinite; } +.svc-dot.svc-fail { background: #d33a2b; } +@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .35; } } +.svc-ram { margin-left: auto; font: var(--type-caption); color: var(--ink-faint); white-space: nowrap; } +.button-sm { padding: 4px 10px; font-size: 12px; } +[data-theme="dark"] .svc-dot.svc-fail { background: #ff6b5e; } diff --git a/portal/static/tokens.css b/portal/static/tokens.css index 0928910..1516a04 100644 --- a/portal/static/tokens.css +++ b/portal/static/tokens.css @@ -36,6 +36,7 @@ --sticker-orange: #dd5b00; /* audio */ --sticker-teal: #2a9d99; /* docs */ --sticker-green: #1aae39; /* status: online/saved */ + --sticker-red: #d33a2b; /* status: failed / error (ON_OFF.md decision) */ --sticker-purple-deep: #391c57; /* illustration only */ --sticker-orange-deep: #793400; /* illustration only */ diff --git a/portal/templates/partials/_sections.html b/portal/templates/partials/_sections.html index f662008..182c1c9 100644 --- a/portal/templates/partials/_sections.html +++ b/portal/templates/partials/_sections.html @@ -35,6 +35,17 @@ {% if tool.admin %}Admin{% endif %} + {% if tool.togglable and tool.service_id %} +
+ + … + + + +
+ {% endif %} {% endfor %} @@ -90,5 +101,62 @@ syncToggle(sec); }); }); + + // --- ON/OFF strips (live state from /api/services via host-side controller) --- + var svcEls = document.querySelectorAll("[data-svc]"); + var svcState = {}; // id -> latest controller state + var svcPollers = {}; // id -> timer handle + if (svcEls.length) { + function normState(s) { + var st = (s && s.state) || "unknown"; + if (st === "active") st = "running"; + if (st === "inactive") st = "stopped"; + return st; + } + function paint(id, showPoll) { + var strip = document.querySelector('[data-svc="' + id + '"]'); + if (!strip) return; + var st = normState(svcState[id]); + var btn = strip.querySelector("[data-svc-btn]"); + var lab = strip.querySelector("[data-svc-label]"); + var dot = strip.querySelector("[data-svc-dot]"); + var ram = strip.querySelector("[data-svc-ram]"); + var can = svcState[id] && svcState[id].can_toggle; + var transitional = st === "starting" || st === "stopping"; + if (ram && svcState[id] && svcState[id].ram_mb) ram.textContent = "~" + svcState[id].ram_mb + "MB"; + dot.className = "svc-dot svc-" + (st === "running" ? "on" : st === "stopped" ? "off" : st === "failed" ? "fail" : "trans"); + if (lab) lab.textContent = st === "running" ? "Running" : st === "stopped" ? "Stopped" : st === "failed" ? (svcState[id].error || "Failed") : (st[0].toUpperCase() + st.slice(1)); + if (btn) { + btn.disabled = !can || transitional; + if (transitional) btn.textContent = st === "starting" ? "Starting…" : "Stopping…"; + else if (can) btn.textContent = st === "running" ? "Stop" : "Start"; + else btn.textContent = st === "running" ? "On" : "Off"; + } + // keep polling only while a toggle is in flight + var poll = /starting|stopping/.test(st); + if (poll && !svcPollers[id]) svcPollers[id] = setInterval(function () { refresh(); }, 2500); + if (!poll && svcPollers[id]) { clearInterval(svcPollers[id]); delete svcPollers[id]; } + } + function refresh() { + fetch('/api/services').then(function (r) { return r.json(); }).then(function (d) { + (d.services || []).forEach(function (s) { svcState[s.id] = s; paint(s.id); }); + }).catch(function () {}); + } + document.querySelectorAll("[data-svc]").forEach(function (strip) { + var id = strip.getAttribute("data-svc"); + var btn = strip.querySelector("[data-svc-btn]"); + if (btn) btn.addEventListener("click", function (e) { + e.preventDefault(); e.stopPropagation(); + var st = normState(svcState[id]); + var action = st === "running" ? "stop" : "start"; + btn.disabled = true; btn.textContent = action === "start" ? "Starting…" : "Stopping…"; + fetch('/service/' + id + '/' + action, { method: 'POST' }) + .then(function (r) { return r.json(); }) + .then(function (d) { if (d.ok) { svcState[id] = Object.assign({}, svcState[id], { state: d.state || (action === 'start' ? 'running' : 'stopped') }); paint(id); refresh(); } }) + .catch(function () { btn.disabled = false; refresh(); }); + }); + }); + refresh(); + } })(); \ No newline at end of file diff --git a/portal/tools.py b/portal/tools.py index 73d0c86..938d61e 100644 --- a/portal/tools.py +++ b/portal/tools.py @@ -33,6 +33,10 @@ class Tool: lan: bool = False admin: bool = False private: bool = False # per-user private app (e.g. dsh instance) + # --- ON/OFF control (ON_OFF.md) --- + service_id: str | None = None # controller allowlist id, e.g. "gimp" + ram_mb: int | None = None # idle RAM shown on the card + togglable: bool = False # only True if in the controller allowlist def section_label(category: str) -> str: @@ -94,7 +98,8 @@ def _cat() -> list[Tool]: # ---- Image --------------------------------------------------------- Tool("gimp", "GIMP", "image", - "Photo editing & retouching", "https://gimp.lab.audasmedia.com.au"), + "Photo editing & retouching", "https://gimp.lab.audasmedia.com.au", + service_id="gimp", ram_mb=233, togglable=True), Tool("penpot", "Penpot", "image", "Open-source design & prototyping", "https://penpot.lab.audasmedia.com.au", login=True), Tool("photo-filter", "Photo Filter", "image", @@ -102,7 +107,8 @@ def _cat() -> list[Tool]: # ---- Video --------------------------------------------------------- Tool("video-editor", "Video Editor", "video", - "Desktop video editing in your browser", "https://video.lab.audasmedia.com.au"), + "Desktop video editing in your browser", "https://video.lab.audasmedia.com.au", + service_id="video-editor", ram_mb=237, togglable=True), Tool("jellyfin", "Jellyfin", "media", "Movies, TV & music server", "https://jellyfin.lab.audasmedia.com.au", login=True), Tool("jellyseerr", "Jellyseerr", "media", @@ -130,9 +136,11 @@ def _cat() -> list[Tool]: # ---- Audio --------------------------------------------------------- Tool("audio-editor", "Audio Editor", "audio", - "Multi-track audio editing", "https://audio.lab.audasmedia.com.au"), + "Multi-track audio editing", "https://audio.lab.audasmedia.com.au", + service_id="audio-editor", ram_mb=201, togglable=True), Tool("lmms", "LMMS Music Studio", "audio", - "Beats, melodies & full arrangements", "https://lmms.lab.audasmedia.com.au"), + "Beats, melodies & full arrangements", "https://lmms.lab.audasmedia.com.au", + service_id="lmms", ram_mb=798, togglable=True), Tool("snapcast", "Snapcast", "audio", "Sync audio to speakers around the house", "http://192.168.20.13:1780", lan=True), Tool("mopidy", "Mopidy", "audio",