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:
2026-10-08 19:10:45 +11:00
parent bb2c6e5d0d
commit 85827be3be
15 changed files with 549 additions and 7 deletions

View File

@@ -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
IMAGE_AUDIO=lscr.io/linuxserver/webtop:latestSC_TOKEN=change-me

View File

@@ -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

View File

@@ -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
```

View 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

View File

@@ -0,0 +1,2 @@
fastapi>=0.110
uvicorn>=0.29

View 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

View 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)

View 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"]

View File

@@ -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

View File

@@ -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

View File

@@ -680,3 +680,105 @@ async def api_status(request: Request) -> HTMLResponse:
@app.get("/healthz")
async def healthz() -> dict:
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

View File

@@ -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; }

View File

@@ -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 */

View File

@@ -35,6 +35,17 @@
{% if tool.admin %}<span class="tag-chip tag-chip-admin" title="Admins only">Admin</span>{% endif %}
</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>
{% endfor %}
</div>
@@ -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();
}
})();
</script>

View File

@@ -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",