fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
574
backend/mac_mini_api.py
Normal file
574
backend/mac_mini_api.py
Normal file
@@ -0,0 +1,574 @@
|
||||
"""
|
||||
Mac Mini Remote Control API.
|
||||
|
||||
Provides endpoints for:
|
||||
- Power control (shutdown, restart, wake-on-LAN)
|
||||
- Status monitoring (ping, SSH, services)
|
||||
- Docker container management
|
||||
- Ollama model management
|
||||
|
||||
This API can run in two modes:
|
||||
1. Remote mode: Running on MacBook, controlling Mac Mini via SSH
|
||||
2. Local mode: Running on Mac Mini (in Docker), using direct commands
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
import os
|
||||
import httpx
|
||||
from typing import Optional, List, Dict, Any
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
import json
|
||||
import socket
|
||||
|
||||
router = APIRouter(prefix="/api/mac-mini", tags=["Mac Mini Control"])
|
||||
|
||||
# Configuration
|
||||
MAC_MINI_IP = os.getenv("MAC_MINI_IP", "192.168.178.100")
|
||||
MAC_MINI_USER = os.getenv("MAC_MINI_USER", "benjaminadmin")
|
||||
MAC_MINI_MAC = os.getenv("MAC_MINI_MAC", "") # MAC address for Wake-on-LAN
|
||||
PROJECT_PATH = "/Users/benjaminadmin/Projekte/breakpilot-pwa"
|
||||
|
||||
# Detect if running inside Docker (local mode on Mac Mini)
|
||||
# In Docker, we use host.docker.internal to access host services
|
||||
RUNNING_IN_DOCKER = os.path.exists("/.dockerenv")
|
||||
DOCKER_HOST_IP = "host.docker.internal" if RUNNING_IN_DOCKER else MAC_MINI_IP
|
||||
OLLAMA_HOST = f"http://{DOCKER_HOST_IP}:11434" if RUNNING_IN_DOCKER else f"http://{MAC_MINI_IP}:11434"
|
||||
|
||||
|
||||
class ModelPullRequest(BaseModel):
|
||||
model: str
|
||||
|
||||
|
||||
class CommandResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
output: Optional[str] = None
|
||||
|
||||
|
||||
async def run_ssh_command(command: str, timeout: int = 30) -> tuple[bool, str]:
|
||||
"""Run a command via SSH on Mac Mini."""
|
||||
ssh_cmd = f"ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no {MAC_MINI_USER}@{MAC_MINI_IP} \"{command}\""
|
||||
try:
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
ssh_cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(),
|
||||
timeout=timeout
|
||||
)
|
||||
output = stdout.decode() + stderr.decode()
|
||||
return process.returncode == 0, output.strip()
|
||||
except asyncio.TimeoutError:
|
||||
return False, "Command timed out"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
async def check_ping() -> bool:
|
||||
"""Check if Mac Mini responds to ping."""
|
||||
try:
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
f"ping -c 1 -W 2 {MAC_MINI_IP}",
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL
|
||||
)
|
||||
await process.wait()
|
||||
return process.returncode == 0
|
||||
except:
|
||||
return False
|
||||
|
||||
|
||||
async def check_ssh() -> bool:
|
||||
"""Check if SSH is available."""
|
||||
success, _ = await run_ssh_command("echo ok", timeout=10)
|
||||
return success
|
||||
|
||||
|
||||
async def check_service_http(url: str, timeout: int = 5) -> bool:
|
||||
"""Check if an HTTP service is responding."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.get(url)
|
||||
return response.status_code == 200
|
||||
except:
|
||||
return False
|
||||
|
||||
|
||||
async def check_internet() -> bool:
|
||||
"""Check if Mac Mini has internet access by pinging external servers."""
|
||||
# Try multiple methods for reliability
|
||||
|
||||
# Method 1: HTTP check to a reliable endpoint
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
response = await client.get("https://www.google.com/generate_204")
|
||||
if response.status_code == 204:
|
||||
return True
|
||||
except:
|
||||
pass
|
||||
|
||||
# Method 2: DNS resolution check
|
||||
try:
|
||||
socket.getaddrinfo("google.com", 80, socket.AF_INET)
|
||||
return True
|
||||
except:
|
||||
pass
|
||||
|
||||
# Method 3: Ping to 8.8.8.8 (Google DNS)
|
||||
try:
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
"ping -c 1 -W 2 8.8.8.8",
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL
|
||||
)
|
||||
await asyncio.wait_for(process.wait(), timeout=5)
|
||||
if process.returncode == 0:
|
||||
return True
|
||||
except:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def get_local_system_info():
|
||||
"""Get system info when running locally (in Docker on Mac Mini)."""
|
||||
uptime = None
|
||||
cpu_load = None
|
||||
memory = None
|
||||
|
||||
# These won't work inside Docker, but we can try basic checks
|
||||
# The host system info requires the Docker socket or host access
|
||||
|
||||
return uptime, cpu_load, memory
|
||||
|
||||
|
||||
async def get_docker_containers_local():
|
||||
"""Get Docker container status when running inside Docker via Docker socket."""
|
||||
containers = []
|
||||
|
||||
docker_socket = "/var/run/docker.sock"
|
||||
if not os.path.exists(docker_socket):
|
||||
return False, containers
|
||||
|
||||
try:
|
||||
# Use Python to query Docker API via Unix socket (no curl needed)
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
class UnixSocketHandler(urllib.request.AbstractHTTPHandler):
|
||||
def unix_open(self, req):
|
||||
import http.client
|
||||
import socket as sock
|
||||
|
||||
class UnixHTTPConnection(http.client.HTTPConnection):
|
||||
def __init__(self, socket_path):
|
||||
super().__init__("localhost")
|
||||
self.socket_path = socket_path
|
||||
|
||||
def connect(self):
|
||||
self.sock = sock.socket(sock.AF_UNIX, sock.SOCK_STREAM)
|
||||
self.sock.connect(self.socket_path)
|
||||
|
||||
conn = UnixHTTPConnection(docker_socket)
|
||||
conn.request(req.get_method(), req.selector)
|
||||
return conn.getresponse()
|
||||
|
||||
# Query Docker API
|
||||
opener = urllib.request.build_opener(UnixSocketHandler())
|
||||
req = urllib.request.Request("unix:///containers/json?all=true")
|
||||
response = opener.open(req, timeout=5)
|
||||
data = json.loads(response.read().decode())
|
||||
|
||||
for c in data:
|
||||
name = c.get("Names", ["/unknown"])[0].lstrip("/")
|
||||
state = c.get("State", "unknown")
|
||||
status = c.get("Status", state)
|
||||
containers.append({"name": name, "status": status})
|
||||
return True, containers
|
||||
except Exception as e:
|
||||
# Fallback: try using httpx with unix socket support
|
||||
try:
|
||||
import httpx
|
||||
transport = httpx.HTTPTransport(uds=docker_socket)
|
||||
async with httpx.AsyncClient(transport=transport, timeout=5) as client:
|
||||
response = await client.get("http://localhost/containers/json?all=true")
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
for c in data:
|
||||
name = c.get("Names", ["/unknown"])[0].lstrip("/")
|
||||
state = c.get("State", "unknown")
|
||||
status = c.get("Status", state)
|
||||
containers.append({"name": name, "status": status})
|
||||
return True, containers
|
||||
except:
|
||||
pass
|
||||
|
||||
return False, containers
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_status():
|
||||
"""Get comprehensive Mac Mini status."""
|
||||
|
||||
# When running inside Docker on Mac Mini, we're always "online"
|
||||
if RUNNING_IN_DOCKER:
|
||||
# Local mode - running on Mac Mini itself
|
||||
# Check services in parallel
|
||||
ollama_task = asyncio.create_task(check_service_http(f"{OLLAMA_HOST}/api/tags"))
|
||||
internet_task = asyncio.create_task(check_internet())
|
||||
docker_task = asyncio.create_task(get_docker_containers_local())
|
||||
|
||||
ollama_ok = await ollama_task
|
||||
internet_ok = await internet_task
|
||||
docker_ok, containers = await docker_task
|
||||
|
||||
# Get Ollama models
|
||||
models = []
|
||||
if ollama_ok:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
response = await client.get(f"{OLLAMA_HOST}/api/tags")
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
models = data.get("models", [])
|
||||
except:
|
||||
pass
|
||||
|
||||
return {
|
||||
"online": True,
|
||||
"ip": MAC_MINI_IP,
|
||||
"ping": True,
|
||||
"ssh": True, # We're running locally, SSH not needed
|
||||
"docker": docker_ok,
|
||||
"backend": True, # We're the backend
|
||||
"ollama": ollama_ok,
|
||||
"internet": internet_ok, # Neuer Status: Internet-Zugang
|
||||
"uptime": "Lokal", # Can't get this from inside Docker easily
|
||||
"cpu_load": None,
|
||||
"memory": None,
|
||||
"containers": containers,
|
||||
"models": models
|
||||
}
|
||||
|
||||
# Remote mode - running on MacBook, checking Mac Mini via SSH
|
||||
# Start all checks in parallel
|
||||
ping_task = asyncio.create_task(check_ping())
|
||||
|
||||
# Wait for ping first
|
||||
ping_ok = await ping_task
|
||||
|
||||
if not ping_ok:
|
||||
return {
|
||||
"online": False,
|
||||
"ip": MAC_MINI_IP,
|
||||
"ping": False,
|
||||
"ssh": False,
|
||||
"docker": False,
|
||||
"backend": False,
|
||||
"ollama": False,
|
||||
"internet": False,
|
||||
"uptime": None,
|
||||
"cpu_load": None,
|
||||
"memory": None,
|
||||
"containers": [],
|
||||
"models": []
|
||||
}
|
||||
|
||||
# If ping succeeds, check other services
|
||||
ssh_task = asyncio.create_task(check_ssh())
|
||||
backend_task = asyncio.create_task(check_service_http(f"http://{MAC_MINI_IP}:8000/health"))
|
||||
ollama_task = asyncio.create_task(check_service_http(f"http://{MAC_MINI_IP}:11434/api/tags"))
|
||||
|
||||
ssh_ok = await ssh_task
|
||||
backend_ok = await backend_task
|
||||
ollama_ok = await ollama_task
|
||||
|
||||
# Check internet via SSH on Mac Mini
|
||||
internet_ok = False
|
||||
|
||||
# Get system info if SSH is available
|
||||
uptime = None
|
||||
cpu_load = None
|
||||
memory = None
|
||||
docker_ok = False
|
||||
containers = []
|
||||
models = []
|
||||
|
||||
if ssh_ok:
|
||||
# Get uptime
|
||||
success, output = await run_ssh_command("uptime | awk -F'up ' '{print $2}' | awk -F',' '{print $1}'")
|
||||
if success:
|
||||
uptime = output.strip()
|
||||
|
||||
# Get CPU load
|
||||
success, output = await run_ssh_command("sysctl -n vm.loadavg | awk '{print $2}'")
|
||||
if success:
|
||||
cpu_load = output.strip()
|
||||
|
||||
# Get memory usage
|
||||
success, output = await run_ssh_command("vm_stat | awk '/Pages active/ {active=$3} /Pages wired/ {wired=$3} END {print int((active+wired)*4096/1024/1024/1024*10)/10 \" GB\"}'")
|
||||
if success:
|
||||
memory = output.strip()
|
||||
|
||||
# Check Docker and get containers
|
||||
success, output = await run_ssh_command("/usr/local/bin/docker ps --format '{{.Names}}|{{.Status}}'")
|
||||
if success:
|
||||
docker_ok = True
|
||||
for line in output.strip().split('\n'):
|
||||
if '|' in line:
|
||||
name, status = line.split('|', 1)
|
||||
containers.append({"name": name, "status": status})
|
||||
|
||||
# Check internet access on Mac Mini
|
||||
success, _ = await run_ssh_command("ping -c 1 -W 2 8.8.8.8", timeout=10)
|
||||
internet_ok = success
|
||||
|
||||
# Get Ollama models if available
|
||||
if ollama_ok:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
response = await client.get(f"http://{MAC_MINI_IP}:11434/api/tags")
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
models = data.get("models", [])
|
||||
except:
|
||||
pass
|
||||
|
||||
return {
|
||||
"online": ping_ok and ssh_ok,
|
||||
"ip": MAC_MINI_IP,
|
||||
"ping": ping_ok,
|
||||
"ssh": ssh_ok,
|
||||
"docker": docker_ok,
|
||||
"backend": backend_ok,
|
||||
"ollama": ollama_ok,
|
||||
"internet": internet_ok,
|
||||
"uptime": uptime,
|
||||
"cpu_load": cpu_load,
|
||||
"memory": memory,
|
||||
"containers": containers,
|
||||
"models": models
|
||||
}
|
||||
|
||||
|
||||
@router.post("/wake")
|
||||
async def wake_on_lan():
|
||||
"""Send Wake-on-LAN magic packet to Mac Mini."""
|
||||
if not MAC_MINI_MAC:
|
||||
# Try to get MAC address from ARP cache
|
||||
try:
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
f"arp -n {MAC_MINI_IP} | awk '{{print $3}}'",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, _ = await process.communicate()
|
||||
mac = stdout.decode().strip()
|
||||
if not mac or mac == "(incomplete)":
|
||||
return CommandResponse(
|
||||
success=False,
|
||||
message="MAC-Adresse nicht gefunden. Bitte MAC_MINI_MAC setzen.",
|
||||
output="Wake-on-LAN benötigt die MAC-Adresse des Mac Mini"
|
||||
)
|
||||
except:
|
||||
return CommandResponse(
|
||||
success=False,
|
||||
message="Fehler beim Ermitteln der MAC-Adresse"
|
||||
)
|
||||
else:
|
||||
mac = MAC_MINI_MAC
|
||||
|
||||
# Send WOL packet using wakeonlan if available, otherwise use Python
|
||||
try:
|
||||
# Try wakeonlan command first
|
||||
process = await asyncio.create_subprocess_shell(
|
||||
f"wakeonlan {mac}",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode == 0:
|
||||
return CommandResponse(
|
||||
success=True,
|
||||
message=f"Wake-on-LAN Paket an {mac} gesendet. Der Mac Mini sollte in 30-60 Sekunden starten.",
|
||||
output=stdout.decode()
|
||||
)
|
||||
else:
|
||||
# Fall back to Python WOL
|
||||
import socket
|
||||
import struct
|
||||
|
||||
# Create magic packet
|
||||
mac_bytes = bytes.fromhex(mac.replace(':', '').replace('-', ''))
|
||||
magic = b'\xff' * 6 + mac_bytes * 16
|
||||
|
||||
# Send to broadcast
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
sock.sendto(magic, ('255.255.255.255', 9))
|
||||
sock.close()
|
||||
|
||||
return CommandResponse(
|
||||
success=True,
|
||||
message=f"Wake-on-LAN Paket an {mac} gesendet (Python fallback).",
|
||||
output="Magic packet sent via broadcast"
|
||||
)
|
||||
except Exception as e:
|
||||
return CommandResponse(
|
||||
success=False,
|
||||
message=f"Fehler beim Senden des WOL-Pakets: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/restart")
|
||||
async def restart_mac_mini():
|
||||
"""Restart Mac Mini via SSH."""
|
||||
# Use osascript for graceful restart
|
||||
success, output = await run_ssh_command(
|
||||
"osascript -e 'tell application \"System Events\" to restart'",
|
||||
timeout=15
|
||||
)
|
||||
|
||||
if success:
|
||||
return CommandResponse(
|
||||
success=True,
|
||||
message="Neustart wurde ausgelöst. Der Mac Mini startet in wenigen Sekunden neu.",
|
||||
output=output
|
||||
)
|
||||
else:
|
||||
# Try sudo reboot as fallback
|
||||
success, output = await run_ssh_command("sudo -n reboot", timeout=15)
|
||||
if success:
|
||||
return CommandResponse(
|
||||
success=True,
|
||||
message="Neustart wurde ausgelöst (sudo).",
|
||||
output=output
|
||||
)
|
||||
return CommandResponse(
|
||||
success=False,
|
||||
message="Neustart fehlgeschlagen. SSH-Verbindung oder Berechtigung fehlt.",
|
||||
output=output
|
||||
)
|
||||
|
||||
|
||||
@router.post("/shutdown")
|
||||
async def shutdown_mac_mini():
|
||||
"""Shutdown Mac Mini via SSH."""
|
||||
# Use osascript for graceful shutdown
|
||||
success, output = await run_ssh_command(
|
||||
"osascript -e 'tell application \"System Events\" to shut down'",
|
||||
timeout=15
|
||||
)
|
||||
|
||||
if success:
|
||||
return CommandResponse(
|
||||
success=True,
|
||||
message="Shutdown wurde ausgelöst. Der Mac Mini fährt in wenigen Sekunden herunter.",
|
||||
output=output
|
||||
)
|
||||
else:
|
||||
return CommandResponse(
|
||||
success=False,
|
||||
message="Shutdown fehlgeschlagen. SSH-Verbindung oder Berechtigung fehlt.",
|
||||
output=output
|
||||
)
|
||||
|
||||
|
||||
@router.post("/docker/up")
|
||||
async def docker_up():
|
||||
"""Start Docker containers on Mac Mini."""
|
||||
success, output = await run_ssh_command(
|
||||
f"cd {PROJECT_PATH} && /usr/local/bin/docker compose up -d",
|
||||
timeout=120
|
||||
)
|
||||
|
||||
return CommandResponse(
|
||||
success=success,
|
||||
message="Container werden gestartet..." if success else "Fehler beim Starten der Container",
|
||||
output=output
|
||||
)
|
||||
|
||||
|
||||
@router.post("/docker/down")
|
||||
async def docker_down():
|
||||
"""Stop Docker containers on Mac Mini."""
|
||||
success, output = await run_ssh_command(
|
||||
f"cd {PROJECT_PATH} && /usr/local/bin/docker compose down",
|
||||
timeout=60
|
||||
)
|
||||
|
||||
return CommandResponse(
|
||||
success=success,
|
||||
message="Container werden gestoppt..." if success else "Fehler beim Stoppen der Container",
|
||||
output=output
|
||||
)
|
||||
|
||||
|
||||
@router.post("/ollama/pull")
|
||||
async def pull_ollama_model(request: ModelPullRequest):
|
||||
"""Pull an Ollama model with streaming progress."""
|
||||
model_name = request.model
|
||||
|
||||
async def stream_progress():
|
||||
"""Stream the pull progress as JSON lines."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
f"{OLLAMA_HOST}/api/pull",
|
||||
json={"name": model_name, "stream": True}
|
||||
) as response:
|
||||
async for line in response.aiter_lines():
|
||||
if line:
|
||||
yield line + "\n"
|
||||
except Exception as e:
|
||||
yield json.dumps({"status": f"error: {str(e)}"}) + "\n"
|
||||
|
||||
return StreamingResponse(
|
||||
stream_progress(),
|
||||
media_type="application/x-ndjson"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ollama/models")
|
||||
async def get_ollama_models():
|
||||
"""Get list of installed Ollama models."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
response = await client.get(f"{OLLAMA_HOST}/api/tags")
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
return {"models": []}
|
||||
except:
|
||||
return {"models": []}
|
||||
|
||||
|
||||
@router.delete("/ollama/models/{model_name}")
|
||||
async def delete_ollama_model(model_name: str):
|
||||
"""Delete an Ollama model."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.delete(
|
||||
f"{OLLAMA_HOST}/api/delete",
|
||||
json={"name": model_name}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return CommandResponse(
|
||||
success=True,
|
||||
message=f"Modell '{model_name}' wurde gelöscht"
|
||||
)
|
||||
return CommandResponse(
|
||||
success=False,
|
||||
message=f"Fehler beim Löschen: {response.text}"
|
||||
)
|
||||
except Exception as e:
|
||||
return CommandResponse(
|
||||
success=False,
|
||||
message=f"Fehler: {str(e)}"
|
||||
)
|
||||
Reference in New Issue
Block a user