From 4d77a36f99433d7fd03d21af01fce5390a90bc95 Mon Sep 17 00:00:00 2001 From: Myron Blair Date: Sun, 26 Jul 2026 17:53:19 -0500 Subject: [PATCH] Add admin-only service status panel with restart, wired to Docker directly --- backend/requirements.txt | 1 + backend/server.py | 15 +++++ backend/services.py | 46 +++++++++++++++ docker-compose.yml | 2 + frontend/src/components/ServiceStatus.jsx | 69 +++++++++++++++++++++++ frontend/src/pages/Browse.jsx | 4 ++ 6 files changed, 137 insertions(+) create mode 100644 backend/services.py create mode 100644 frontend/src/components/ServiceStatus.jsx diff --git a/backend/requirements.txt b/backend/requirements.txt index b833a27..549b966 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -24,3 +24,4 @@ numpy>=1.26.0 python-multipart>=0.0.9 jq>=1.6.0 typer>=0.9.0 +docker>=7.1.0 diff --git a/backend/server.py b/backend/server.py index 295b426..0c657d7 100644 --- a/backend/server.py +++ b/backend/server.py @@ -32,6 +32,7 @@ from seed import SAMPLE_MOVIES import tmdb as tmdb_client import radarr as radarr_client import sonarr as sonarr_client +import services as services_client import trakt as trakt_client import opensubtitles as opensubs_client from transcode import transcode_quick, transcode_abr, srt_to_vtt @@ -1495,6 +1496,20 @@ async def _auto_fetch_subtitle(content_type: str, content_id: str, tmdb_id: Opti logger.info(f"Auto-attached subtitle for {content_type} {content_id[:8]}") +# ============ SERVICES (admin) ============ +@api.get("/services") +async def services_status(user: dict = Depends(require_admin)): + return await asyncio.to_thread(services_client.list_service_status) + + +@api.post("/services/{key}/restart") +async def services_restart(key: str, user: dict = Depends(require_admin)): + ok = await asyncio.to_thread(services_client.restart_service, key) + if not ok: + raise HTTPException(status_code=404, detail="Unknown service") + return {"ok": True} + + # ============ HEALTH ============ @api.get("/") async def root(): diff --git a/backend/services.py b/backend/services.py new file mode 100644 index 0000000..5972ebd --- /dev/null +++ b/backend/services.py @@ -0,0 +1,46 @@ +"""Docker service status/restart for the internal StreamHoard stack.""" +import docker + +SERVICES = [ + {"key": "gluetun", "label": "VPN (gluetun)", "container": "streamhoard-gluetun"}, + {"key": "sonarr", "label": "Sonarr", "container": "streamhoard-sonarr"}, + {"key": "radarr", "label": "Radarr", "container": "streamhoard-radarr"}, + {"key": "prowlarr", "label": "Prowlarr", "container": "streamhoard-prowlarr"}, + {"key": "qbittorrent", "label": "qBittorrent", "container": "streamhoard-qbittorrent"}, + {"key": "mongo", "label": "Database", "container": "streamhoard-mongo"}, + {"key": "portainer", "label": "Portainer", "container": "streamhoard-portainer"}, +] + +_client = None + + +def _get_client(): + global _client + if _client is None: + _client = docker.DockerClient(base_url="unix:///var/run/docker.sock") + return _client + + +def list_service_status(): + client = _get_client() + out = [] + for svc in SERVICES: + try: + c = client.containers.get(svc["container"]) + running = c.status == "running" + except docker.errors.NotFound: + running = False + except Exception: + running = False + out.append({"key": svc["key"], "label": svc["label"], "running": running}) + return out + + +def restart_service(key: str) -> bool: + svc = next((s for s in SERVICES if s["key"] == key), None) + if not svc: + return False + client = _get_client() + c = client.containers.get(svc["container"]) + c.restart(timeout=15) + return True diff --git a/docker-compose.yml b/docker-compose.yml index bfc9602..91fb318 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -46,6 +46,8 @@ services: - ${MEDIA_ROOT_HOST:-./media}:/media # Radarr/Sonarr report absolute file paths under this same path — must match exactly - ${MEDIA_NAS_ROOT:-/mnt/nas/video}:/mnt/nas/video:ro + # Docker socket — lets the admin Services panel check/restart sibling containers + - /var/run/docker.sock:/var/run/docker.sock networks: - streamhoard # No direct host port — frontend container reverse-proxies /api → backend:8001 diff --git a/frontend/src/components/ServiceStatus.jsx b/frontend/src/components/ServiceStatus.jsx new file mode 100644 index 0000000..6da0b04 --- /dev/null +++ b/frontend/src/components/ServiceStatus.jsx @@ -0,0 +1,69 @@ +import { useEffect, useState, useCallback } from "react"; +import api from "../lib/api"; +import { toast } from "sonner"; +import { RotateCw } from "lucide-react"; + +export default function ServiceStatus() { + const [services, setServices] = useState([]); + const [restarting, setRestarting] = useState(null); + + const load = useCallback(async () => { + try { + const { data } = await api.get("/services"); + setServices(data); + } catch { + setServices([]); + } + }, []); + + useEffect(() => { + load(); + const t = setInterval(load, 15000); + return () => clearInterval(t); + }, [load]); + + const restart = async (key, label) => { + setRestarting(key); + try { + await api.post(`/services/${key}/restart`); + toast.success(`${label} restarting…`); + setTimeout(load, 4000); + } catch (err) { + toast.error(err.response?.data?.detail || `Could not restart ${label}`); + } finally { + setRestarting(null); + } + }; + + if (!services.length) return null; + + return ( +
+
+ {services.map((s) => ( +
+ + {s.label} + {!s.running && ( + + )} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/pages/Browse.jsx b/frontend/src/pages/Browse.jsx index 206c510..6890fd1 100644 --- a/frontend/src/pages/Browse.jsx +++ b/frontend/src/pages/Browse.jsx @@ -1,12 +1,15 @@ import { useEffect, useState, useCallback } from "react"; import { useNavigate } from "react-router-dom"; import api from "../lib/api"; +import { useAuth } from "../lib/auth"; import Hero from "../components/Hero"; import Row from "../components/Row"; import MovieDetailModal from "../components/MovieDetailModal"; +import ServiceStatus from "../components/ServiceStatus"; export default function Browse() { const nav = useNavigate(); + const { user } = useAuth(); const [featured, setFeatured] = useState(null); const [movies, setMovies] = useState([]); const [genres, setGenres] = useState([]); @@ -57,6 +60,7 @@ export default function Browse() { )}
+ {user?.is_admin && } {continueWatching.length > 0 && ( )}