Implement dashboard-controlled night mode for automatic Docker service management. Services are stopped at a configurable time (default 22:00) and restarted in the morning (default 06:00). Features: - Python/FastAPI scheduler service (port 8096) - Admin dashboard API routes at /api/admin/night-mode - Toggle for enable/disable night mode - Time picker for shutdown and startup times - Manual start/stop buttons for immediate actions - Excluded services (night-scheduler, nginx always run) Files added: - night-scheduler/scheduler.py - Main scheduler with REST API - night-scheduler/Dockerfile - Container with Docker CLI - night-scheduler/requirements.txt - FastAPI, Uvicorn, Pydantic - night-scheduler/tests/test_scheduler.py - Unit tests - admin-v2/app/api/admin/night-mode/* - API proxy routes - .claude/rules/night-scheduler.md - Developer documentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
390 lines
13 KiB
Python
390 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Night Scheduler - Leichtgewichtiger Scheduler für Nachtabschaltung
|
|
|
|
Prüft jede Minute die Konfiguration und führt docker compose up/down aus.
|
|
REST-API auf Port 8096 für Dashboard-Zugriff.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import asyncio
|
|
from datetime import datetime, time, timedelta
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import FastAPI, HTTPException
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from pydantic import BaseModel, Field
|
|
|
|
# Konfiguration
|
|
CONFIG_PATH = Path("/config/night-mode.json")
|
|
COMPOSE_FILE = Path("/app/docker-compose.yml")
|
|
COMPOSE_PROJECT = os.getenv("COMPOSE_PROJECT_NAME", "breakpilot-pwa")
|
|
|
|
# Services die NICHT gestoppt werden sollen
|
|
EXCLUDED_SERVICES = {"night-scheduler", "nginx"}
|
|
|
|
|
|
class NightModeConfig(BaseModel):
|
|
"""Konfiguration für den Nachtmodus"""
|
|
enabled: bool = False
|
|
shutdown_time: str = "22:00"
|
|
startup_time: str = "06:00"
|
|
last_action: Optional[str] = None # "shutdown" oder "startup"
|
|
last_action_time: Optional[str] = None
|
|
excluded_services: list[str] = Field(default_factory=lambda: list(EXCLUDED_SERVICES))
|
|
|
|
|
|
class NightModeStatus(BaseModel):
|
|
"""Status-Response für die API"""
|
|
config: NightModeConfig
|
|
current_time: str
|
|
next_action: Optional[str] = None # "shutdown" oder "startup"
|
|
next_action_time: Optional[str] = None
|
|
time_until_next_action: Optional[str] = None
|
|
services_status: dict[str, str] = Field(default_factory=dict)
|
|
|
|
|
|
class ExecuteRequest(BaseModel):
|
|
"""Request für sofortige Ausführung"""
|
|
action: str # "start" oder "stop"
|
|
|
|
|
|
def load_config() -> NightModeConfig:
|
|
"""Lädt die Konfiguration aus der JSON-Datei"""
|
|
if CONFIG_PATH.exists():
|
|
try:
|
|
with open(CONFIG_PATH) as f:
|
|
data = json.load(f)
|
|
return NightModeConfig(**data)
|
|
except (json.JSONDecodeError, Exception) as e:
|
|
print(f"Fehler beim Laden der Konfiguration: {e}")
|
|
return NightModeConfig()
|
|
|
|
|
|
def save_config(config: NightModeConfig) -> None:
|
|
"""Speichert die Konfiguration in die JSON-Datei"""
|
|
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(CONFIG_PATH, "w") as f:
|
|
json.dump(config.model_dump(), f, indent=2)
|
|
|
|
|
|
def parse_time(time_str: str) -> time:
|
|
"""Parst einen Zeit-String im Format HH:MM"""
|
|
parts = time_str.split(":")
|
|
return time(int(parts[0]), int(parts[1]))
|
|
|
|
|
|
def get_services_to_manage() -> list[str]:
|
|
"""Ermittelt alle Services, die verwaltet werden sollen"""
|
|
try:
|
|
result = subprocess.run(
|
|
["docker", "compose", "-f", str(COMPOSE_FILE), "config", "--services"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30
|
|
)
|
|
if result.returncode == 0:
|
|
services = result.stdout.strip().split("\n")
|
|
config = load_config()
|
|
excluded = set(config.excluded_services)
|
|
return [s for s in services if s and s not in excluded]
|
|
except Exception as e:
|
|
print(f"Fehler beim Ermitteln der Services: {e}")
|
|
return []
|
|
|
|
|
|
def get_services_status() -> dict[str, str]:
|
|
"""Ermittelt den Status aller Services"""
|
|
status = {}
|
|
try:
|
|
result = subprocess.run(
|
|
["docker", "compose", "-f", str(COMPOSE_FILE), "ps", "--format", "json"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30
|
|
)
|
|
if result.returncode == 0 and result.stdout.strip():
|
|
# Docker compose ps gibt JSON-Lines aus
|
|
for line in result.stdout.strip().split("\n"):
|
|
if line:
|
|
try:
|
|
container = json.loads(line)
|
|
service = container.get("Service", container.get("Name", "unknown"))
|
|
state = container.get("State", container.get("Status", "unknown"))
|
|
status[service] = state
|
|
except json.JSONDecodeError:
|
|
continue
|
|
except Exception as e:
|
|
print(f"Fehler beim Abrufen des Service-Status: {e}")
|
|
return status
|
|
|
|
|
|
def execute_docker_command(action: str) -> tuple[bool, str]:
|
|
"""
|
|
Führt docker compose Befehl aus.
|
|
|
|
Args:
|
|
action: "start" oder "stop"
|
|
|
|
Returns:
|
|
(success, message)
|
|
"""
|
|
services = get_services_to_manage()
|
|
if not services:
|
|
return False, "Keine Services zum Verwalten gefunden"
|
|
|
|
if action == "stop":
|
|
cmd = ["docker", "compose", "-f", str(COMPOSE_FILE), "stop"] + services
|
|
elif action == "start":
|
|
cmd = ["docker", "compose", "-f", str(COMPOSE_FILE), "start"] + services
|
|
else:
|
|
return False, f"Unbekannte Aktion: {action}"
|
|
|
|
try:
|
|
print(f"Führe aus: {' '.join(cmd)}")
|
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
|
|
|
|
if result.returncode == 0:
|
|
return True, f"Aktion '{action}' erfolgreich ausgeführt für {len(services)} Services"
|
|
else:
|
|
return False, f"Fehler: {result.stderr}"
|
|
except subprocess.TimeoutExpired:
|
|
return False, "Timeout beim Ausführen des Befehls"
|
|
except Exception as e:
|
|
return False, f"Ausnahme: {str(e)}"
|
|
|
|
|
|
def calculate_next_action(config: NightModeConfig) -> tuple[Optional[str], Optional[datetime], Optional[timedelta]]:
|
|
"""
|
|
Berechnet die nächste Aktion basierend auf der aktuellen Zeit.
|
|
|
|
Returns:
|
|
(action, next_time, time_until)
|
|
"""
|
|
if not config.enabled:
|
|
return None, None, None
|
|
|
|
now = datetime.now()
|
|
today = now.date()
|
|
|
|
shutdown_time = parse_time(config.shutdown_time)
|
|
startup_time = parse_time(config.startup_time)
|
|
|
|
shutdown_dt = datetime.combine(today, shutdown_time)
|
|
startup_dt = datetime.combine(today, startup_time)
|
|
|
|
# Wenn startup vor shutdown ist, bedeutet das über Mitternacht
|
|
# z.B. shutdown 22:00, startup 06:00
|
|
if startup_time < shutdown_time:
|
|
# Wir sind in der Nacht
|
|
if now.time() < startup_time:
|
|
# Vor Startup-Zeit -> nächste Aktion ist Startup heute
|
|
return "startup", startup_dt, startup_dt - now
|
|
elif now.time() < shutdown_time:
|
|
# Zwischen Startup und Shutdown -> nächste Aktion ist Shutdown heute
|
|
return "shutdown", shutdown_dt, shutdown_dt - now
|
|
else:
|
|
# Nach Shutdown -> nächste Aktion ist Startup morgen
|
|
next_startup = startup_dt + timedelta(days=1)
|
|
return "startup", next_startup, next_startup - now
|
|
else:
|
|
# Startup nach Shutdown am selben Tag (ungewöhnlich, aber unterstützt)
|
|
if now.time() < shutdown_time:
|
|
return "shutdown", shutdown_dt, shutdown_dt - now
|
|
elif now.time() < startup_time:
|
|
return "startup", startup_dt, startup_dt - now
|
|
else:
|
|
next_shutdown = shutdown_dt + timedelta(days=1)
|
|
return "shutdown", next_shutdown, next_shutdown - now
|
|
|
|
|
|
def format_timedelta(td: timedelta) -> str:
|
|
"""Formatiert ein timedelta als lesbaren String"""
|
|
total_seconds = int(td.total_seconds())
|
|
hours, remainder = divmod(total_seconds, 3600)
|
|
minutes, _ = divmod(remainder, 60)
|
|
|
|
if hours > 0:
|
|
return f"{hours}h {minutes}min"
|
|
return f"{minutes}min"
|
|
|
|
|
|
async def scheduler_loop():
|
|
"""Haupt-Scheduler-Schleife, prüft jede Minute"""
|
|
print("Scheduler-Loop gestartet")
|
|
|
|
while True:
|
|
try:
|
|
config = load_config()
|
|
|
|
if config.enabled:
|
|
now = datetime.now()
|
|
current_time = now.time()
|
|
|
|
shutdown_time = parse_time(config.shutdown_time)
|
|
startup_time = parse_time(config.startup_time)
|
|
|
|
# Prüfe ob Shutdown-Zeit erreicht
|
|
if (current_time.hour == shutdown_time.hour and
|
|
current_time.minute == shutdown_time.minute):
|
|
|
|
if config.last_action != "shutdown" or (
|
|
config.last_action_time and
|
|
datetime.fromisoformat(config.last_action_time).date() < now.date()
|
|
):
|
|
print(f"Shutdown-Zeit erreicht: {config.shutdown_time}")
|
|
success, msg = execute_docker_command("stop")
|
|
print(f"Shutdown: {msg}")
|
|
|
|
config.last_action = "shutdown"
|
|
config.last_action_time = now.isoformat()
|
|
save_config(config)
|
|
|
|
# Prüfe ob Startup-Zeit erreicht
|
|
elif (current_time.hour == startup_time.hour and
|
|
current_time.minute == startup_time.minute):
|
|
|
|
if config.last_action != "startup" or (
|
|
config.last_action_time and
|
|
datetime.fromisoformat(config.last_action_time).date() < now.date()
|
|
):
|
|
print(f"Startup-Zeit erreicht: {config.startup_time}")
|
|
success, msg = execute_docker_command("start")
|
|
print(f"Startup: {msg}")
|
|
|
|
config.last_action = "startup"
|
|
config.last_action_time = now.isoformat()
|
|
save_config(config)
|
|
|
|
except Exception as e:
|
|
print(f"Fehler in Scheduler-Loop: {e}")
|
|
|
|
# Warte 60 Sekunden
|
|
await asyncio.sleep(60)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
"""Lifecycle-Manager für FastAPI"""
|
|
# Startup: Starte den Scheduler
|
|
task = asyncio.create_task(scheduler_loop())
|
|
yield
|
|
# Shutdown: Stoppe den Scheduler
|
|
task.cancel()
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
|
|
# FastAPI App
|
|
app = FastAPI(
|
|
title="Night Scheduler API",
|
|
description="API für die Dashboard-gesteuerte Nachtabschaltung",
|
|
version="1.0.0",
|
|
lifespan=lifespan
|
|
)
|
|
|
|
# CORS für Admin-Dashboard
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
"""Health-Check-Endpoint"""
|
|
return {"status": "healthy", "service": "night-scheduler"}
|
|
|
|
|
|
@app.get("/api/night-mode", response_model=NightModeStatus)
|
|
async def get_status():
|
|
"""Gibt den aktuellen Status und die Konfiguration zurück"""
|
|
config = load_config()
|
|
now = datetime.now()
|
|
|
|
next_action, next_time, time_until = calculate_next_action(config)
|
|
|
|
return NightModeStatus(
|
|
config=config,
|
|
current_time=now.strftime("%H:%M:%S"),
|
|
next_action=next_action,
|
|
next_action_time=next_time.strftime("%H:%M") if next_time else None,
|
|
time_until_next_action=format_timedelta(time_until) if time_until else None,
|
|
services_status=get_services_status()
|
|
)
|
|
|
|
|
|
@app.post("/api/night-mode", response_model=NightModeConfig)
|
|
async def update_config(new_config: NightModeConfig):
|
|
"""Aktualisiert die Nachtmodus-Konfiguration"""
|
|
# Validiere Zeitformate
|
|
try:
|
|
parse_time(new_config.shutdown_time)
|
|
parse_time(new_config.startup_time)
|
|
except (ValueError, IndexError):
|
|
raise HTTPException(status_code=400, detail="Ungültiges Zeitformat. Erwartet: HH:MM")
|
|
|
|
# Behalte last_action bei, wenn nicht explizit gesetzt
|
|
if new_config.last_action is None:
|
|
old_config = load_config()
|
|
new_config.last_action = old_config.last_action
|
|
new_config.last_action_time = old_config.last_action_time
|
|
|
|
save_config(new_config)
|
|
return new_config
|
|
|
|
|
|
@app.post("/api/night-mode/execute")
|
|
async def execute_action(request: ExecuteRequest):
|
|
"""Führt eine Aktion sofort aus (start/stop)"""
|
|
if request.action not in ["start", "stop"]:
|
|
raise HTTPException(status_code=400, detail="Aktion muss 'start' oder 'stop' sein")
|
|
|
|
success, message = execute_docker_command(request.action)
|
|
|
|
if success:
|
|
# Aktualisiere last_action
|
|
config = load_config()
|
|
config.last_action = "startup" if request.action == "start" else "shutdown"
|
|
config.last_action_time = datetime.now().isoformat()
|
|
save_config(config)
|
|
|
|
return {"success": True, "message": message}
|
|
else:
|
|
raise HTTPException(status_code=500, detail=message)
|
|
|
|
|
|
@app.get("/api/night-mode/services")
|
|
async def get_services():
|
|
"""Gibt die Liste aller verwaltbaren Services zurück"""
|
|
return {
|
|
"all_services": get_services_to_manage(),
|
|
"excluded_services": list(load_config().excluded_services),
|
|
"status": get_services_status()
|
|
}
|
|
|
|
|
|
@app.get("/api/night-mode/logs")
|
|
async def get_logs():
|
|
"""Gibt die letzten Aktionen zurück"""
|
|
config = load_config()
|
|
return {
|
|
"last_action": config.last_action,
|
|
"last_action_time": config.last_action_time,
|
|
"enabled": config.enabled
|
|
}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
uvicorn.run(app, host="0.0.0.0", port=8096)
|