mirror of
https://github.com/myronblair/kino-app
synced 2026-07-28 21:44:14 -05:00
Add admin-only service status panel with restart, wired to Docker directly
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
)}
|
||||
|
||||
<div className="-mt-20 relative z-10 pb-24">
|
||||
{user?.is_admin && <ServiceStatus />}
|
||||
{continueWatching.length > 0 && (
|
||||
<Row title="Resume" movies={continueWatching} onCardClick={handleMore} progressMap={progressMap} />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user