ON_OFF Phase 0-3: service-controller (.13:8443, token-gated, containers+systemd-user, audit log) + portal toggle cards (live status, per-user who, failed token, RAM) — full E2E verified
This commit is contained in:
@@ -26,4 +26,4 @@ S3_REGION=garage
|
|||||||
# --- Tool container images (verify before enabling profile "tools") ---
|
# --- Tool container images (verify before enabling profile "tools") ---
|
||||||
IMAGE_GIMP=lscr.io/linuxserver/gimp:latest
|
IMAGE_GIMP=lscr.io/linuxserver/gimp:latest
|
||||||
IMAGE_VIDEO=lscr.io/linuxserver/webtop:latest
|
IMAGE_VIDEO=lscr.io/linuxserver/webtop:latest
|
||||||
IMAGE_AUDIO=lscr.io/linuxserver/webtop:latest
|
IMAGE_AUDIO=lscr.io/linuxserver/webtop:latestSC_TOKEN=change-me
|
||||||
|
|||||||
@@ -48,7 +48,9 @@ Assigned permanently to tool categories:
|
|||||||
| `{colors.accent-purple-deep}` | `#391c57` | Deep shade within illustrations only |
|
| `{colors.accent-purple-deep}` | `#391c57` | Deep shade within illustrations only |
|
||||||
| `{colors.accent-orange-deep}` | `#793400` | 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
|
## Typography
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
> Symmetry: ensure S3_ACCESS_KEY/S3_SECRET_KEY in `.env` match what `key import`
|
||||||
> registers, else portal uploads will 403.
|
> 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
|
||||||
|
```
|
||||||
|
|||||||
15
deploy/service-controller/family-service-controller.service
Normal file
15
deploy/service-controller/family-service-controller.service
Normal file
@@ -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
|
||||||
2
deploy/service-controller/requirements.txt
Normal file
2
deploy/service-controller/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fastapi>=0.110
|
||||||
|
uvicorn>=0.29
|
||||||
11
deploy/service-controller/run.sh
Executable file
11
deploy/service-controller/run.sh
Executable file
@@ -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
|
||||||
167
deploy/service-controller/service_controller.py
Normal file
167
deploy/service-controller/service_controller.py
Normal file
@@ -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)
|
||||||
85
deploy/service-controller/services.toml
Normal file
85
deploy/service-controller/services.toml
Normal file
@@ -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 <target> (sam is in the docker group)
|
||||||
|
# systemd-user-> systemctl --user start/stop <target>
|
||||||
|
#
|
||||||
|
# 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"]
|
||||||
@@ -87,6 +87,15 @@ services:
|
|||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
ADMIN_FULLNAME: ${ADMIN_FULLNAME}
|
ADMIN_FULLNAME: ${ADMIN_FULLNAME}
|
||||||
PI_DASHBOARD_DIR: /pi-dashboard
|
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:
|
volumes:
|
||||||
- /mnt/data/family-home-lab/pi-dashboard:/pi-dashboard:ro
|
- /mnt/data/family-home-lab/pi-dashboard:/pi-dashboard:ro
|
||||||
- /mnt/data/family-home-lab/shared-media:/shared-media:rw
|
- /mnt/data/family-home-lab/shared-media:/shared-media:rw
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ class Settings:
|
|||||||
S3_ACCESS_KEY: str = os.getenv("S3_ACCESS_KEY", "")
|
S3_ACCESS_KEY: str = os.getenv("S3_ACCESS_KEY", "")
|
||||||
S3_SECRET_KEY: str = os.getenv("S3_SECRET_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 ---
|
# --- Tool catalogue endpoint for opening tools ---
|
||||||
SECTION_COLORS: dict[str, str] = {
|
SECTION_COLORS: dict[str, str] = {
|
||||||
"chat": "#62aef0", # accent-sky
|
"chat": "#62aef0", # accent-sky
|
||||||
|
|||||||
104
portal/main.py
104
portal/main.py
@@ -679,4 +679,106 @@ async def api_status(request: Request) -> HTMLResponse:
|
|||||||
|
|
||||||
@app.get("/healthz")
|
@app.get("/healthz")
|
||||||
async def healthz() -> dict:
|
async def healthz() -> dict:
|
||||||
return {"ok": True, "app": settings.APP_NAME}
|
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
|
||||||
@@ -385,3 +385,23 @@ button.button, input[type="submit"].button { font-family: inherit; }
|
|||||||
.section-toggle:hover { opacity: 1; }
|
.section-toggle:hover { opacity: 1; }
|
||||||
.section.collapsed .section-toggle { transform: rotate(-90deg); }
|
.section.collapsed .section-toggle { transform: rotate(-90deg); }
|
||||||
.section.collapsed .tool-grid { display: none; }
|
.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; }
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
--sticker-orange: #dd5b00; /* audio */
|
--sticker-orange: #dd5b00; /* audio */
|
||||||
--sticker-teal: #2a9d99; /* docs */
|
--sticker-teal: #2a9d99; /* docs */
|
||||||
--sticker-green: #1aae39; /* status: online/saved */
|
--sticker-green: #1aae39; /* status: online/saved */
|
||||||
|
--sticker-red: #d33a2b; /* status: failed / error (ON_OFF.md decision) */
|
||||||
--sticker-purple-deep: #391c57; /* illustration only */
|
--sticker-purple-deep: #391c57; /* illustration only */
|
||||||
--sticker-orange-deep: #793400; /* illustration only */
|
--sticker-orange-deep: #793400; /* illustration only */
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,17 @@
|
|||||||
{% if tool.admin %}<span class="tag-chip tag-chip-admin" title="Admins only">Admin</span>{% endif %}
|
{% if tool.admin %}<span class="tag-chip tag-chip-admin" title="Admins only">Admin</span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if tool.togglable and tool.service_id %}
|
||||||
|
<div class="svc-strip" data-svc="{{ tool.service_id }}"
|
||||||
|
data-ram="{{ tool.ram_mb or '' }}" data-name="{{ tool.name }}"
|
||||||
|
onclick="event.preventDefault();event.stopPropagation();">
|
||||||
|
<span class="svc-state" data-svc-state>
|
||||||
|
<span class="svc-dot" data-svc-dot></span><span data-svc-label>…</span>
|
||||||
|
</span>
|
||||||
|
<span class="svc-ram" data-svc-ram></span>
|
||||||
|
<button type="button" class="button button-utility button-sm" data-svc-btn disabled>…</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -90,5 +101,62 @@
|
|||||||
syncToggle(sec);
|
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();
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
@@ -33,6 +33,10 @@ class Tool:
|
|||||||
lan: bool = False
|
lan: bool = False
|
||||||
admin: bool = False
|
admin: bool = False
|
||||||
private: bool = False # per-user private app (e.g. dsh instance)
|
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:
|
def section_label(category: str) -> str:
|
||||||
@@ -94,7 +98,8 @@ def _cat() -> list[Tool]:
|
|||||||
|
|
||||||
# ---- Image ---------------------------------------------------------
|
# ---- Image ---------------------------------------------------------
|
||||||
Tool("gimp", "GIMP", "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",
|
Tool("penpot", "Penpot", "image",
|
||||||
"Open-source design & prototyping", "https://penpot.lab.audasmedia.com.au", login=True),
|
"Open-source design & prototyping", "https://penpot.lab.audasmedia.com.au", login=True),
|
||||||
Tool("photo-filter", "Photo Filter", "image",
|
Tool("photo-filter", "Photo Filter", "image",
|
||||||
@@ -102,7 +107,8 @@ def _cat() -> list[Tool]:
|
|||||||
|
|
||||||
# ---- Video ---------------------------------------------------------
|
# ---- Video ---------------------------------------------------------
|
||||||
Tool("video-editor", "Video Editor", "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",
|
Tool("jellyfin", "Jellyfin", "media",
|
||||||
"Movies, TV & music server", "https://jellyfin.lab.audasmedia.com.au", login=True),
|
"Movies, TV & music server", "https://jellyfin.lab.audasmedia.com.au", login=True),
|
||||||
Tool("jellyseerr", "Jellyseerr", "media",
|
Tool("jellyseerr", "Jellyseerr", "media",
|
||||||
@@ -130,9 +136,11 @@ def _cat() -> list[Tool]:
|
|||||||
|
|
||||||
# ---- Audio ---------------------------------------------------------
|
# ---- Audio ---------------------------------------------------------
|
||||||
Tool("audio-editor", "Audio Editor", "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",
|
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",
|
Tool("snapcast", "Snapcast", "audio",
|
||||||
"Sync audio to speakers around the house", "http://192.168.20.13:1780", lan=True),
|
"Sync audio to speakers around the house", "http://192.168.20.13:1780", lan=True),
|
||||||
Tool("mopidy", "Mopidy", "audio",
|
Tool("mopidy", "Mopidy", "audio",
|
||||||
|
|||||||
Reference in New Issue
Block a user