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

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

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