This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/night-scheduler/scheduler.py
BreakPilot Dev 3f7032260b feat(infrastructure): Add night-scheduler for automated Docker service shutdown
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>
2026-02-08 22:45:03 -08:00

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)