Add admin-only service status panel with restart, wired to Docker directly

This commit is contained in:
Myron Blair
2026-07-26 17:53:19 -05:00
parent 19374b694e
commit 4d77a36f99
6 changed files with 137 additions and 0 deletions
+1
View File
@@ -24,3 +24,4 @@ numpy>=1.26.0
python-multipart>=0.0.9 python-multipart>=0.0.9
jq>=1.6.0 jq>=1.6.0
typer>=0.9.0 typer>=0.9.0
docker>=7.1.0
+15
View File
@@ -32,6 +32,7 @@ from seed import SAMPLE_MOVIES
import tmdb as tmdb_client import tmdb as tmdb_client
import radarr as radarr_client import radarr as radarr_client
import sonarr as sonarr_client import sonarr as sonarr_client
import services as services_client
import trakt as trakt_client import trakt as trakt_client
import opensubtitles as opensubs_client import opensubtitles as opensubs_client
from transcode import transcode_quick, transcode_abr, srt_to_vtt 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]}") 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 ============ # ============ HEALTH ============
@api.get("/") @api.get("/")
async def root(): async def root():
+46
View File
@@ -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
+2
View File
@@ -46,6 +46,8 @@ services:
- ${MEDIA_ROOT_HOST:-./media}:/media - ${MEDIA_ROOT_HOST:-./media}:/media
# Radarr/Sonarr report absolute file paths under this same path — must match exactly # Radarr/Sonarr report absolute file paths under this same path — must match exactly
- ${MEDIA_NAS_ROOT:-/mnt/nas/video}:/mnt/nas/video:ro - ${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: networks:
- streamhoard - streamhoard
# No direct host port — frontend container reverse-proxies /api → backend:8001 # No direct host port — frontend container reverse-proxies /api → backend:8001
+69
View File
@@ -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 (
<div className="px-6 md:px-12 pt-6" data-testid="service-status">
<div className="flex flex-wrap gap-2">
{services.map((s) => (
<div
key={s.key}
className="flex items-center gap-2 bg-[#0F0F0F] border border-[#222] px-3 py-1.5 text-xs"
data-testid={`service-${s.key}`}
>
<span
className={`inline-block w-2 h-2 rounded-full ${s.running ? "bg-[#86efac]" : "bg-[#fca5a5]"}`}
/>
<span className="text-[#8A8A8A]">{s.label}</span>
{!s.running && (
<button
onClick={() => restart(s.key, s.label)}
disabled={restarting === s.key}
className="ml-1 flex items-center gap-1 text-[#D9381E] hover:text-[#ED4B32] disabled:opacity-50"
data-testid={`service-restart-${s.key}`}
>
<RotateCw size={12} className={restarting === s.key ? "animate-spin" : ""} />
Restart
</button>
)}
</div>
))}
</div>
</div>
);
}
+4
View File
@@ -1,12 +1,15 @@
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import api from "../lib/api"; import api from "../lib/api";
import { useAuth } from "../lib/auth";
import Hero from "../components/Hero"; import Hero from "../components/Hero";
import Row from "../components/Row"; import Row from "../components/Row";
import MovieDetailModal from "../components/MovieDetailModal"; import MovieDetailModal from "../components/MovieDetailModal";
import ServiceStatus from "../components/ServiceStatus";
export default function Browse() { export default function Browse() {
const nav = useNavigate(); const nav = useNavigate();
const { user } = useAuth();
const [featured, setFeatured] = useState(null); const [featured, setFeatured] = useState(null);
const [movies, setMovies] = useState([]); const [movies, setMovies] = useState([]);
const [genres, setGenres] = useState([]); const [genres, setGenres] = useState([]);
@@ -57,6 +60,7 @@ export default function Browse() {
)} )}
<div className="-mt-20 relative z-10 pb-24"> <div className="-mt-20 relative z-10 pb-24">
{user?.is_admin && <ServiceStatus />}
{continueWatching.length > 0 && ( {continueWatching.length > 0 && (
<Row title="Resume" movies={continueWatching} onCardClick={handleMore} progressMap={progressMap} /> <Row title="Resume" movies={continueWatching} onCardClick={handleMore} progressMap={progressMap} />
)} )}