mirror of
https://github.com/myronblair/kino-app
synced 2026-07-28 13:33:49 -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
|
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
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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
|
- ${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
|
||||||
|
|||||||
@@ -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 { 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} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user