Compare commits
203
Commits
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mc2-memory": {
|
||||
"command": "python",
|
||||
"args": [
|
||||
"F:\\Coding Stuff\\mission-control-2\\mcp\\mcp_memory.py"
|
||||
]
|
||||
},
|
||||
"mc2-system": {
|
||||
"command": "python",
|
||||
"args": [
|
||||
"F:\\Coding Stuff\\mission-control-2\\mcp\\mcp_mc.py"
|
||||
]
|
||||
},
|
||||
"mc2-web": {
|
||||
"command": "python",
|
||||
"args": [
|
||||
"F:\\Coding Stuff\\mission-control-2\\mcp\\mcp_web.py"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
# .aiexclude — was der KI-Assistent (Antigravity/Gemini) NICHT lesen/indexieren soll.
|
||||
# Gitignore-Syntax. Ziel: der Review fokussiert auf QUELLCODE, nicht auf Abhängigkeiten,
|
||||
# Build-Output oder Binärdateien.
|
||||
|
||||
# Abhängigkeiten & Build-Output (kein Review-Ziel)
|
||||
backend/.venv/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
.git/
|
||||
|
||||
# Große / binäre / sensible Dateien
|
||||
*.vrm
|
||||
*.gguf
|
||||
*.onnx
|
||||
*.safetensors
|
||||
*.wav
|
||||
*.env
|
||||
.env
|
||||
credentials*
|
||||
*.key
|
||||
*.pem
|
||||
|
||||
# Lokale Scratch-/Recon-Skripte
|
||||
box_recon*
|
||||
gemma_swap*
|
||||
+22
-4
@@ -18,18 +18,36 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev", "--", "--port", "5180", "--strictPort"],
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"dev",
|
||||
"--",
|
||||
"--port",
|
||||
"5180",
|
||||
"--strictPort"
|
||||
],
|
||||
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
|
||||
"env": { "MC_API_TARGET": "http://192.168.178.151:9001" },
|
||||
"env": {
|
||||
"MC_API_TARGET": "http://192.168.178.151:9001"
|
||||
},
|
||||
"autoPort": false,
|
||||
"port": 5180
|
||||
},
|
||||
{
|
||||
"name": "frontend-mock",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev", "--", "--port", "5181", "--strictPort"],
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"dev",
|
||||
"--",
|
||||
"--port",
|
||||
"5181",
|
||||
"--strictPort"
|
||||
],
|
||||
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
|
||||
"env": { "MC_API_TARGET": "http://127.0.0.1:9000" },
|
||||
"env": {
|
||||
"MC_API_TARGET": "http://127.0.0.1:9000"
|
||||
},
|
||||
"autoPort": false,
|
||||
"port": 5181
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"autoMode": {
|
||||
"allow": [
|
||||
"$defaults",
|
||||
"SSH/scp commands to hitonabi@192.168.178.151 that read or write files under ~/.hermes including ~/.hermes/hermes-agent (the Hermes runtime) — the user authorized direct patching of the box's Hermes runtime for the mission-control-2 project"
|
||||
]
|
||||
}
|
||||
"Bash(git fetch *)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(git rev-list *)",
|
||||
"Bash(ssh hitonabi@192.168.178.151:*)",
|
||||
"Bash(grep -E \"\\\\.py$|^d\")",
|
||||
"Bash(grep -rn \"http://\\\\|https://\\\\|/srv/\\\\|/opt/\\\\|/etc/\" services/*.py routers/*.py)",
|
||||
"Bash(awk '/^\\(async \\)?def /{in_func=1; func=$0; line=NR} in_func {count++} /^\\(async \\)?def / && NR!=line {if\\(count>30\\) print \"Line \" line \": \" func \" \\(\" count \" lines\\)\"; count=0; func=$0; line=NR} END{if\\(count>30\\) print \"Line \" line \": \" func \" \\(\" count \" lines\\)\"}' routers/*.py services/*.py)",
|
||||
"Bash(grep -rhoE 'api\\(<[^>]+>\\)?\\\\\\(\"\\(/api|/v1\\)[^\"`]*' frontend/src/)",
|
||||
"Bash(sed -E 's/api\\(<[^>]+>\\)?\\\\\\(\"//')",
|
||||
"Bash(grep -rhoE 'api\\(<[^>]+>\\)?\\\\\\(`[^`]*`' frontend/src/)",
|
||||
"Bash(python -c ' *)",
|
||||
"Bash(echo \"tsc exit: $?\")",
|
||||
"WebSearch",
|
||||
"WebFetch(domain:lobehub.com)",
|
||||
"WebFetch(domain:docs.litellm.ai)",
|
||||
"WebFetch(domain:github.com)",
|
||||
"WebFetch(domain:runaihome.com)",
|
||||
"WebFetch(domain:docs.getbifrost.ai)",
|
||||
"WebFetch(domain:thefrontierlab.ai)",
|
||||
"WebFetch(domain:www.glukhov.org)",
|
||||
"WebFetch(domain:cognio.so)",
|
||||
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=10 hitonabi@192.168.178.151 'curl -s http://127.0.0.1:8080/running; echo; echo \"--- after a quick hermes ping, whats running ---\"; curl -s http://127.0.0.1:8080/running')",
|
||||
"Bash(git checkout *)",
|
||||
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=10 hitonabi@192.168.178.151 \"sed -n '46,75p' /etc/llama-swap/config.yaml\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"node --version 2>/dev/null || echo 'no-node'; docker --version 2>/dev/null || echo 'no-docker'; python3 --version; systemctl --user list-units --type=service --state=running 2>/dev/null | head -10\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"systemctl --user cat hermes-gateway.service 2>/dev/null; echo '---'; ls ~/hermes-gateway/ 2>/dev/null || ls ~/mission-control-v2/deploy/ | grep gateway\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"ls ~/.hermes/ && echo '---' && ls ~/.hermes/hermes-agent/ 2>/dev/null && echo '---' && cat ~/.hermes/config/config.yaml 2>/dev/null || cat ~/.hermes/config.yaml 2>/dev/null\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"journalctl --user -u hermes-gateway.service --no-pager -n 20\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"cat ~/.hermes/config.yaml | grep -A5 'api_server\\\\|api_key\\\\|gateway_api\\\\|secret' | head -30\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"grep -r 'gateway_api_key\\\\|api_server\\\\|HERMES_API_KEY\\\\|8642' ~/.hermes/config.yaml ~/.config/environment.d/ 2>/dev/null | head -20; echo '---'; cat ~/.config/environment.d/*.conf 2>/dev/null | grep -i hermes | head -20\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"grep -r 'hermes.*gateway\\\\|gateway.*url\\\\|8642\\\\|GATEWAY' ~/mission-control-v2/backend/ 2>/dev/null | grep -v '.pyc' | head -20\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"grep -A20 'def get_agent_status\\\\|def check\\\\|HERMES_API' ~/mission-control-v2/backend/services/agent.py | head -40\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"cat ~/.hermes/gateway_state.json 2>/dev/null | python3 -c 'import json,sys; d=json.load\\(sys.stdin\\); print\\(list\\(d.keys\\(\\)\\)\\); print\\(d.get\\(\\\\\"api_key\\\\\"\\) or d.get\\(\\\\\"key\\\\\"\\) or \\\\\"no key field\\\\\"\\)' 2>/dev/null; echo '---'; ls ~/.hermes/auth* ~/.hermes/pairing/ 2>/dev/null\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"~/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main gateway status 2>&1 | head -20; echo '---'; curl -s http://127.0.0.1:8642/health 2>/dev/null || curl -s http://127.0.0.1:8642/ 2>/dev/null | head -5\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"cat ~/.hermes/gateway_state.json | python3 -c 'import json,sys; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d.get\\(\\\\\"platforms\\\\\", {}\\), indent=2\\)\\)'\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"bash ~/mission-control-v2/deploy/deploy.sh\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"~/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main gateway connect --help 2>&1 | head -30\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"~/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main gateway enroll --help 2>&1\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"~/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main gateway setup --help 2>&1\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"~/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main --help 2>&1 | head -40\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"cat ~/.hermes/.env 2>/dev/null; echo '---'; ~/ .hermes/hermes-agent/venv/bin/python -m hermes_cli.main config --help 2>&1 | head -20\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"sed -i 's|# TELEGRAM_BOT_TOKEN=|TELEGRAM_BOT_TOKEN=8908266336:AAElPDmdEJXZ5cs0gUzbAnm4a9glRY18Zac|' ~/.hermes/.env && grep TELEGRAM_BOT_TOKEN ~/.hermes/.env\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"systemctl --user restart hermes-gateway && sleep 3 && journalctl --user -u hermes-gateway --no-pager -n 15\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"sleep 4 && journalctl --user -u hermes-gateway --no-pager -n 20 --since '1 minute ago'\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"sleep 3 && journalctl --user -u hermes-gateway --no-pager -n 25 --since '2 minutes ago'\")",
|
||||
"Bash(ssh hitonabi@192.168.178.151 \"sed -i 's|# TELEGRAM_ALLOWED_USERS=.*|TELEGRAM_ALLOWED_USERS=6150562984|' ~/.hermes/.env && grep TELEGRAM_ALLOWED ~/.hermes/.env\")",
|
||||
"Bash(mkdir -p \"F:\\\\Coding Stuff\\\\mission-control-2\\\\docs\\\\memory\")",
|
||||
"Bash(cp \"C:\\\\Users\\\\TobisPC\\\\.claude\\\\projects\\\\F--Coding-Stuff-mission-control-2\\\\memory\\\\project-stack-state.md\" \"F:\\\\Coding Stuff\\\\mission-control-2\\\\docs\\\\memory\\\\\" && cp \"C:\\\\Users\\\\TobisPC\\\\.claude\\\\projects\\\\F--Coding-Stuff-mission-control-2\\\\memory\\\\project-hermes-setup.md\" \"F:\\\\Coding Stuff\\\\mission-control-2\\\\docs\\\\memory\\\\\")",
|
||||
"Bash(cp \"C:\\\\Users\\\\TobisPC\\\\.claude\\\\projects\\\\F--Coding-Stuff-mission-control-2\\\\memory\\\\project-mc2-architecture.md\" \"F:\\\\Coding Stuff\\\\mission-control-2\\\\docs\\\\memory\\\\\" && cp \"C:\\\\Users\\\\TobisPC\\\\.claude\\\\projects\\\\F--Coding-Stuff-mission-control-2\\\\memory\\\\project-pending-tasks.md\" \"F:\\\\Coding Stuff\\\\mission-control-2\\\\docs\\\\memory\\\\\")",
|
||||
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=8 hitonabi@192.168.178.151 'echo \"=== ENGINE VERSION ===\"; /usr/local/bin/llama-server --version 2>&1 | head -3; echo \"=== LLAMA-SWAP VERSION ===\"; \\(llama-swap --version 2>&1 || /usr/local/bin/llama-swap --version 2>&1 || echo \"no --version\"\\) | head -3; echo \"=== RUNNING MODELS ===\"; curl -s http://127.0.0.1:8080/running 2>&1 | head -c 2000; echo; echo \"=== FREE MEM ===\"; free -g | head -3')",
|
||||
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=8 hitonabi@192.168.178.151 'echo \"=== HERMES CONFIG ===\"; cat ~/.hermes/config.yaml 2>&1 | head -120; echo \"=== HERMES DIR ===\"; ls -la ~/.hermes/ 2>&1 | head -40')",
|
||||
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=8 hitonabi@192.168.178.151 'echo \"=== HERMES CONFIG \\(rest\\) ===\"; sed -n \"120,400p\" ~/.hermes/config.yaml 2>&1; echo \"=== TOOLS COUNT \\(api/all\\) ===\"; cd ~/.hermes 2>/dev/null; \\(hermes tools list 2>&1 | tail -40\\) || echo \"hermes CLI not on PATH\"')",
|
||||
"Bash(ssh -o BatchMode=yes -o ConnectTimeout=8 hitonabi@192.168.178.151 ' *)",
|
||||
"Bash(xargs cat)",
|
||||
"Bash(ssh -o ConnectTimeout=8 -o BatchMode=yes hitonabi@192.168.178.151 'echo \"=== llama-server version ===\"; /usr/local/bin/llama-server --version 2>&1 | head -5; echo \"=== running models ===\"; curl -s http://127.0.0.1:8080/running 2>/dev/null | head -c 2000; echo; echo \"=== free ===\"; free -g')",
|
||||
"Bash(ssh -o ConnectTimeout=8 -o BatchMode=yes hitonabi@192.168.178.151 'echo \"=== config.yaml ===\"; cat /etc/llama-swap/config.yaml; echo; echo \"=== models on disk ===\"; du -sh /srv/models/* 2>/dev/null | sort -h')",
|
||||
"Bash(ssh -o ConnectTimeout=8 -o BatchMode=yes hitonabi@192.168.178.151 'echo \"=== Qwen3.6 MTP dir ===\"; ls -la /srv/models/Qwen3.6-35B-A3B-MTP-GGUF/ 2>/dev/null; echo \"=== drafts dir ===\"; ls -la /srv/models/drafts/ 2>/dev/null; echo \"=== disk free ===\"; df -h /srv 2>/dev/null; echo \"=== gemma remnant ===\"; ls -la /srv/models/gemma-4-26B-A4B-it-GGUF/ 2>/dev/null')",
|
||||
"Bash(ssh -o ConnectTimeout=8 -o BatchMode=yes hitonabi@192.168.178.151 ' *)",
|
||||
"Bash(ssh -o ConnectTimeout=12 -o BatchMode=yes hitonabi@192.168.178.151 ' *)",
|
||||
"WebFetch(domain:unsloth.ai)",
|
||||
"WebFetch(domain:huggingface.co)",
|
||||
"Bash(xargs ls -la)",
|
||||
"Bash(xargs wc -l)",
|
||||
"PowerShell(ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"echo '=== engine ==='; /opt/llamacpp-vulkan/llama-server --version 2>&1 | head -3; echo; echo '=== llama-swap version ==='; /opt/llama-swap/llama-swap --version 2>/dev/null || llama-swap --version 2>/dev/null || ls -la /opt/llama-swap 2>/dev/null | head -5; echo; echo '=== running models ==='; curl -s http://127.0.0.1:8080/running; echo; echo '=== services ==='; for s in llama-swap mc2-backend hermes-api hermes-webui mem0-service voice-service; do printf '%s: ' $s; systemctl is-active $s 2>/dev/null; done; echo '=== uname/mem ==='; uname -r; free -g | head -2\")",
|
||||
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"echo '=== ports ==='; ss -tlnp 2>/dev/null | grep -E ':\\(9001|8642|8650|8765|8787|7681|8080|8130\\)'; echo; echo '=== wer serviert 9001? ==='; systemctl list-units --all --type=service --no-pager --no-legend | grep -iE 'gateway|backend|control|api'; echo; echo '=== mem0 venv ==='; systemctl cat mem0-service 2>/dev/null | grep -E 'ExecStart|WorkingDirectory'; echo; echo '=== voice venv ==='; systemctl cat voice-service 2>/dev/null | grep -E 'ExecStart|WorkingDirectory'\")",
|
||||
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"echo '=== 9001 Prozess ==='; cat /proc/11257/cmdline 2>/dev/null | tr '\\\\0' ' '; echo; readlink /proc/11257/cwd; ps -o etime= -p 11257; echo; echo '=== mc2 backend unit? ==='; ls /etc/systemd/system/ | grep -iE 'mc2|mission|backend'; echo; echo '=== mem0 pip ==='; MEMPY=/proc/7277/exe; readlink /proc/7277/cwd; readlink /proc/7277/exe; V=\\(dirname \\(readlink /proc/7277/exe\\)\\); echo; /srv/mc2/mem0-venv/bin/pip show mem0ai 2>/dev/null | head -2\")",
|
||||
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"echo '=== 9001 Prozess ==='; tr '\\\\0' ' ' < /proc/11257/cmdline; echo; readlink /proc/11257/cwd; ps -o etime= -p 11257; echo '=== mc2 unit? ==='; ls /etc/systemd/system/ | grep -iE 'mc2|mission|backend|llama|hermes|mem0|voice'; echo '=== mem0 exe ==='; readlink /proc/7277/exe; readlink /proc/7277/cwd\")",
|
||||
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"echo '=== mission-control.service ==='; systemctl is-enabled mission-control 2>&1; systemctl is-active mission-control 2>&1; grep -E 'ExecStart|WorkingDirectory' /etc/systemd/system/mission-control.service; echo; echo '=== mem0ai version ==='; ls /home/hitonabi/mission-control-v2/mem0_service/ | head; for p in /home/hitonabi/mission-control-v2/mem0_service/.venv/bin/pip /home/hitonabi/mission-control-v2/mem0_service/venv/bin/pip; do [ -x \\\\\"\\\\$p\\\\\" ] && \\\\\"\\\\$p\\\\\" show mem0ai chromadb 2>/dev/null | grep -E 'Name|Version'; done\")",
|
||||
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"tr '\\\\0' '\\\\n' < /proc/7277/environ | grep -E 'VIRTUAL_ENV|PATH=' | head -3; tr '\\\\0' ' ' < /proc/7277/cmdline; echo; /home/hitonabi/.local/share/uv/python/cpython-3.12.13-linux-x86_64-gnu/bin/python3.12 -c 'import mem0; print\\(mem0.__version__\\)' 2>&1 | tail -1\")",
|
||||
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"/home/hitonabi/.mem0/venv/bin/pip show mem0ai 2>/dev/null | grep -E 'Name|Version'; /home/hitonabi/.mem0/venv/bin/pip show chromadb 2>/dev/null | grep Version\")",
|
||||
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"ls /home/hitonabi/.mem0/venv/bin/ | head -8; /home/hitonabi/.mem0/venv/bin/python -m pip show mem0ai chromadb 2>&1 | grep -E 'Name:|Version:'\")",
|
||||
"PowerShell(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 \"/home/hitonabi/.mem0/venv/bin/python -c 'import importlib.metadata as m; print\\(\\\\\"mem0ai\\\\\", m.version\\(\\\\\"mem0ai\\\\\"\\)\\); print\\(\\\\\"chromadb\\\\\", m.version\\(\\\\\"chromadb\\\\\"\\)\\)' 2>&1\")",
|
||||
"Bash(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 '/home/hitonabi/.mem0/venv/bin/python -c \"import importlib.metadata as m; print\\(\\\\\"mem0ai\\\\\", m.version\\(\\\\\"mem0ai\\\\\"\\)\\); print\\(\\\\\"chromadb\\\\\", m.version\\(\\\\\"chromadb\\\\\"\\)\\)\"')",
|
||||
"WebFetch(domain:docs.mem0.ai)",
|
||||
"PowerShell(Write-Output '=== Lucy-Prozesse lokal ==='; Get-Process | Where-Object {$_.ProcessName -match 'electron|python|node'} | Select-Object ProcessName, Id, WorkingSet64 | Format-Table; Write-Output '=== Port 8130 \\(Pocket-TTS\\) ==='; Get-NetTCPConnection -LocalPort 8130 -State Listen -ErrorAction SilentlyContinue | Select-Object LocalAddress, OwningProcess; Write-Output '=== GPU ==='; Get-CimInstance Win32_VideoController | Select-Object Name, DriverVersion | Format-Table)",
|
||||
"PowerShell($p = 'F:\\\\Coding Stuff\\\\mission-control-2\\\\client\\\\lucy-tts\\\\ptts-venv\\\\Scripts\\\\python.exe'; if \\(Test-Path $p\\) { & $p -c \"import importlib.metadata as m; [print\\(n, m.version\\(n\\)\\) for n in ['pocket-tts','torch','onnxruntime','fastapi','numpy'] if True]\" 2>&1 } else { Write-Output 'ptts-venv nicht gefunden'; Get-ChildItem 'F:\\\\Coding Stuff\\\\mission-control-2\\\\client\\\\lucy-tts' -Directory | Select-Object Name })",
|
||||
"Bash(ssh -o ConnectTimeout=10 -o BatchMode=yes hitonabi@192.168.178.151 'echo \"=== /v1/models ===\"; curl -s http://127.0.0.1:9001/v1/models | head -c 600; echo; echo \"=== voice metrics ===\"; curl -s http://127.0.0.1:9001/api/voice/metrics | head -c 1200')",
|
||||
"Bash(ssh -o ConnectTimeout=15 -o BatchMode=yes hitonabi@192.168.178.151 'curl -s -o /dev/null -w \"TTFB_chat_lane: %{time_starttransfer}s total: %{time_total}s\\\\n\" -N -X POST http://127.0.0.1:9001/v1/chat/completions -H \"Content-Type: application/json\" -d \"{\\\\\"model\\\\\":\\\\\"chat\\\\\",\\\\\"stream\\\\\":true,\\\\\"max_tokens\\\\\":24,\\\\\"messages\\\\\":[{\\\\\"role\\\\\":\\\\\"user\\\\\",\\\\\"content\\\\\":\\\\\"Sag nur: hallo\\\\\"}]}\"')",
|
||||
"Bash(ssh -o ConnectTimeout=30 -o BatchMode=yes hitonabi@192.168.178.151 'curl -s -N -X POST http://127.0.0.1:9001/v1/chat/completions -H \"Content-Type: application/json\" -d \"{\\\\\"model\\\\\":\\\\\"chat\\\\\",\\\\\"stream\\\\\":true,\\\\\"max_tokens\\\\\":24,\\\\\"messages\\\\\":[{\\\\\"role\\\\\":\\\\\"user\\\\\",\\\\\"content\\\\\":\\\\\"Sag nur: hallo\\\\\"}]}\" | head -c 500')",
|
||||
"Bash(ssh -o ConnectTimeout=60 -o BatchMode=yes hitonabi@192.168.178.151 'echo \"=== routing policy ===\"; curl -s http://127.0.0.1:9001/api/routing | head -c 800; echo; echo \"=== aliases in llama-swap config ===\"; grep -B1 -A3 \"aliases:\" /etc/llama-swap/config.yaml | head -40; echo \"=== direkt hermes ===\"; curl -s -o /dev/null -w \"TTFB: %{time_starttransfer}s\\\\n\" -N -X POST http://127.0.0.1:9001/v1/chat/completions -H \"Content-Type: application/json\" -d \"{\\\\\"model\\\\\":\\\\\"hermes\\\\\",\\\\\"stream\\\\\":true,\\\\\"max_tokens\\\\\":24,\\\\\"messages\\\\\":[{\\\\\"role\\\\\":\\\\\"user\\\\\",\\\\\"content\\\\\":\\\\\"Sag nur: hallo\\\\\"}]}\"')",
|
||||
"Bash(ssh -o ConnectTimeout=60 -o BatchMode=yes hitonabi@192.168.178.151 'curl -s -N -X POST http://127.0.0.1:9001/v1/chat/completions -H \"Content-Type: application/json\" -d \"{\\\\\"model\\\\\":\\\\\"hermes\\\\\",\\\\\"stream\\\\\":true,\\\\\"max_tokens\\\\\":24,\\\\\"messages\\\\\":[{\\\\\"role\\\\\":\\\\\"user\\\\\",\\\\\"content\\\\\":\\\\\"Sag nur: hallo\\\\\"}]}\" | head -c 400; echo; echo \"=== direkt an llama-swap :8080 ===\"; curl -s -o /dev/null -w \"TTFB: %{time_starttransfer}s total: %{time_total}s\\\\n\" -N -X POST http://127.0.0.1:8080/v1/chat/completions -H \"Content-Type: application/json\" -d \"{\\\\\"model\\\\\":\\\\\"hermes\\\\\",\\\\\"stream\\\\\":true,\\\\\"max_tokens\\\\\":24,\\\\\"messages\\\\\":[{\\\\\"role\\\\\":\\\\\"user\\\\\",\\\\\"content\\\\\":\\\\\"Sag nur: hallo\\\\\"}]}\"')",
|
||||
"WebFetch(domain:www.hermes-ai.net)",
|
||||
"WebFetch(domain:releases.electronjs.org)",
|
||||
"WebFetch(domain:dev.to)",
|
||||
"WebFetch(domain:kyuz0.github.io)",
|
||||
"WebFetch(domain:kmarble.dev)"
|
||||
"Bash(ssh -F /dev/null -o IdentitiesOnly=yes -i /c/Users/TobisPC/.ssh/id_rsa hitonabi@192.168.178.151:*)",
|
||||
"Bash(scp -F /dev/null -o IdentitiesOnly=yes -i /c/Users/TobisPC/.ssh/id_rsa:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
# Ampel-CI fuer MC2 — auf dieses Multi-Service-Monorepo zugeschnitten (24.07.2026).
|
||||
#
|
||||
# Die universelle Vorlage (deploy/ampel-ci.yml) passt fuer EIN-Service-Repos. MC2 hat
|
||||
# 5 Python-Dienste (backend/voice_service/mem0_service/mcp/client) mit schweren ML-
|
||||
# Abhaengigkeiten (Whisper/TTS/Embeddings) + ein Frontend. "pip install ALLER requirements
|
||||
# + pytest repo-weit" in einem stateless Container ist weder machbar (GB-schwere Wheels,
|
||||
# CUDA) noch aussagekraeftig — die echten Tests sind der Pruefstand (deploy/pruefstand)
|
||||
# und die Integration auf der Box mit LIVE-Diensten/Modellen, nicht isolierte Unit-Tests.
|
||||
#
|
||||
# Darum prueft die MC2-Ampel, was im Container EHRLICH gruen sein kann und trotzdem echte
|
||||
# Fehler faengt: Lint (ruff, Projekt-Politik in ruff.toml) + Import/Syntax (compileall) +
|
||||
# Frontend-Build inkl. TypeScript-Typecheck (tsc). Rot ist ein Ergebnis, kein Aergernis.
|
||||
name: Ampel
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
ampel:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Ampel — Lint + Import + Frontend-Build (MC2-Zuschnitt)
|
||||
shell: bash
|
||||
run: |
|
||||
# Runner-bash laeuft mit -e; abschalten und JEDEN Fehler selbst werten (rot=1),
|
||||
# am Ende EIN Klartext-Urteil (Lehre Ampel-Lauf #5).
|
||||
set -u +e
|
||||
rot=0
|
||||
|
||||
echo "== Python: Lint (ruff, Projekt-Politik aus ruff.toml) =="
|
||||
python3 -m venv /tmp/ampel-venv && . /tmp/ampel-venv/bin/activate || { echo "❌ venv kaputt"; exit 1; }
|
||||
pip install -q ruff || { echo "❌ ruff-Install kaputt"; exit 1; }
|
||||
ruff check . || rot=1
|
||||
|
||||
echo "== Python: Import/Syntax (compileall) =="
|
||||
python3 -m compileall -q backend voice_service mem0_service mcp client deploy hermes scripts || rot=1
|
||||
|
||||
echo "== Frontend: reproduzierbarer Build (npm ci + tsc + vite) =="
|
||||
if [ -f frontend/package.json ]; then
|
||||
if [ ! -f frontend/package-lock.json ]; then
|
||||
echo "❌ frontend/: kein package-lock.json — Build nicht reproduzierbar."
|
||||
rot=1
|
||||
else
|
||||
( cd frontend && npm ci --no-audit --no-fund && npm run build ) || rot=1
|
||||
|
||||
if [ $rot -eq 0 ]; then
|
||||
echo "== Frontend: Push nach release-dist =="
|
||||
git config --global user.name "Gitea Actions"
|
||||
git config --global user.email "actions@gitea.local"
|
||||
CURRENT_COMMIT=$(git rev-parse HEAD)
|
||||
# Erzeuge (oder hole) den orphan branch 'release-dist'
|
||||
git checkout --orphan release-dist 2>/dev/null || git checkout release-dist
|
||||
git rm -rf . >/dev/null 2>&1 || true
|
||||
|
||||
# Checkout nur frontend/dist vom aktuellen Stand
|
||||
git add -f frontend/dist
|
||||
|
||||
if ! git diff-index --quiet HEAD; then
|
||||
git commit -m "Automatischer Frontend Build ($CURRENT_COMMIT) [skip ci]"
|
||||
# Push to release-dist. Token auth from runner is expected to work for Gitea Actions.
|
||||
git push origin HEAD:release-dist -f
|
||||
else
|
||||
echo "Keine Änderungen am Frontend-Build."
|
||||
fi
|
||||
# Zurück zum originalen Branch für den Rest des Skripts
|
||||
git checkout $CURRENT_COMMIT
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ $rot -ne 0 ]; then
|
||||
echo "❌ AMPEL ROT — nichts heißt ‚fertig', solange das rot ist."
|
||||
else
|
||||
echo "✅ AMPEL GRÜN — Lint + Import + Frontend-Build sauber."
|
||||
fi
|
||||
exit $rot
|
||||
+15
-5
@@ -7,11 +7,6 @@ __pycache__/
|
||||
frontend/node_modules/
|
||||
# frontend/dist wird committet (kein Node-Build auf der Box) — siehe deploy/
|
||||
|
||||
# Avatar-VRM (groß + lizenz-/redistributionssensibel) — liegt lokal + auf der Box, nicht in git.
|
||||
# Wird per Direkt-Deploy auf die Box gespielt (dist/avatar.vrm), nicht über git.
|
||||
frontend/public/avatar.vrm
|
||||
frontend/dist/avatar.vrm
|
||||
|
||||
# Env / local
|
||||
*.env
|
||||
.DS_Store
|
||||
@@ -22,3 +17,18 @@ gemma_swap*
|
||||
|
||||
# TypeScript-Inkrementalcache (reines Build-Artefakt, maschinenabhängig)
|
||||
frontend/tsconfig.tsbuildinfo
|
||||
|
||||
# Lokale Claude-Code-Einstellungen (enthielten bis 24.09.2026 ein Token) und Worktree-Ordner
|
||||
.claude/settings.local.json
|
||||
.claude/worktrees/
|
||||
|
||||
# Caches und lokale Umgebungen, egal in welchem Ordner
|
||||
.ruff_cache/
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
node_modules/
|
||||
|
||||
# Sicherungskopien von Hand
|
||||
*.bak
|
||||
*.bak-*
|
||||
*.orig
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# AGENTS.md — Mission Control 2.0
|
||||
# AGENTS.md — Homelab Orchestrator (vormals Mission Control 2.0)
|
||||
|
||||
Projekt-Geschmack für Coding-Agenten (Kilo Code, Claude Code lesen diese Datei).
|
||||
Kurz gehalten, nur die Wahrheiten, die man sonst schmerzhaft lernt. Details: `README.md`, `docs/`.
|
||||
@@ -12,7 +12,7 @@ Engine (llama-swap) · Builtin-Routing-Gateway (`model: auto`) · MC2 (FastAPI +
|
||||
- **System:** Bosgame M5 (AMD Strix Halo APU)
|
||||
- **Arbeitsspeicher (RAM/VRAM):** 128 GB Shared Memory (ca. 122.7 GB nutzbar).
|
||||
- **Inference-Limit:** Modelle im GGUF-Format dürfen maximal ca. 100-110 GB groß sein (entspricht ca. 150B Parametern bei Q4_K_M). Größere Modelle (wie 200B+ oder 2T Parameter) **können lokal nicht ausgeführt werden** und sind auszuschließen, es sei denn, es handelt sich um stark quantisierte MoEs.
|
||||
**Gedächtnis & Dienste:** Hermes ist das autonome Agenten-Gehirn („Lucy"). Mem0 läuft als dedizierter semantischer Langzeit-Gedächtnis-Sidecar (`mem0-service` auf `:8765`) und wird von Hermes via MCP (`mcp_memory.py`) eingebunden. Governor, Zed-Workflows und alte Mittelschichten sind komplett gelöscht.
|
||||
**Gedächtnis & Dienste:** Hermes ist das autonome Agenten-Gehirn („Lucy") **und die alleinige Gedächtnis-Wahrheit** — es führt sein Gedächtnis selbst. Der frühere Sidecar auf `:8765` samt Memory-MCP-Server und MC2-Memory-Plugin wurde am 07.08.2026 abgelöst und am 27.08.2026 restlos ausgebaut (`docs/wissen/VERDIKTE.md`): MC2 hat **keine** `/api/memory`-Routen mehr, nichts darf mehr dorthin greifen. Governor, Zed-Workflows und alte Mittelschichten sind komplett gelöscht.
|
||||
|
||||
## Sprache (nicht verhandelbar)
|
||||
- **Alles User-Sichtbare ist Deutsch:** UI-Texte, Fehlermeldungen, Update-/Radar-Meldungen,
|
||||
@@ -24,16 +24,21 @@ Engine (llama-swap) · Builtin-Routing-Gateway (`model: auto`) · MC2 (FastAPI +
|
||||
- **`frontend/dist` WIRD committet.** Auf der Box läuft KEIN Node-Build; das Backend liefert die
|
||||
gebauten Assets direkt aus. Nach jeder Frontend-Änderung: `cd frontend && npm run build`, dann
|
||||
**das neue `frontend/dist` mit-committen**. Vergessen = Box zeigt alten Stand.
|
||||
(Ausnahme: `frontend/dist/avatar.vrm` ist bewusst nicht in git — siehe `.gitignore`.)
|
||||
- **Deploy macht `git reset --hard origin/main`** (`deploy/deploy.sh`). Heißt: **`main` muss vor
|
||||
dem Deploy auf Gitea liegen**, und uncommittete Box-Änderungen gehen verloren (Absicht).
|
||||
- **Deploy** (`bash ~/mission-control-v2/deploy/deploy.sh` auf der Box) holt `origin/main` per
|
||||
fast-forward — **`main` muss vorher auf Gitea liegen**, im Box-Checkout nie von Hand ändern.
|
||||
Zweistufig seit 24.09.2026: Stufe 1 holt den Stand und startet die NEUE Fassung des Skripts,
|
||||
Stufe 2 prüft (pruefen.sh), spielt Units/Cron-Skripte/Skills aus, startet neu und prüft nach.
|
||||
Scheitert etwas: automatisch zurück auf den alten Stand + dringende Meldung. Laufende Update-Jobs
|
||||
verschieben den Deploy. Die llama-swap-Config wird nur überschrieben, wenn sich der Repo-Abzug im
|
||||
selben Deploy geändert hat (sonst gewinnt die lebende Datei, die MC2 selbst schreibt).
|
||||
- **Nie direkt auf `main` arbeiten.** Immer Branch (`wartung/...`), Gate grün, dann Merge/Deploy.
|
||||
|
||||
## Agentic IDE & Vibe Coding
|
||||
- **Zero Middle-Layers:** Coding passiert zu 100% lokal auf dem Dev-PC in der **OpenCode Desktop IDE**.
|
||||
- **Zero Middle-Layers:** Coding passiert zu 100% lokal auf dem Dev-PC in **OpenChamber** (mit eigener OpenCode-CLI).
|
||||
- Es gibt keinen Zed-Workflow, keine OpenCode-CLI-Mittelschicht und keinen Governor mehr.
|
||||
- Der Agent in OpenCode Desktop nutzt via MCP (`.agents/mcp_config.json`) die API der Box (`:9001/v1`), um autonom Projekte zu bauen.
|
||||
- **Mix-Ansatz beim Testen:** Der Agent testet lokal. Vor dem Push muss er prüfen, ob das Gitea-`VERIFY`-Skript fehlerfrei durchläuft.
|
||||
- Der Agent in OpenChamber nutzt die Modelle der Box über `http://192.168.178.151:9001/v1` (Provider `aibox`, nur Rollen-Aliase; Vorlage der PC-Config: `deploy/opencode-config/`).
|
||||
- **Prüftor:** `bash deploy/pruefen.sh` (Shell-/Python-Syntax, ruff, Importe, pytest) muss vor jedem Push grün sein.
|
||||
Der Deploy auf der Box führt es vor dem Umschalten noch einmal aus; rot = automatisch zurück auf den alten Stand.
|
||||
|
||||
## Zeit & Umgebung
|
||||
- **Box = Ubuntu, läuft in `Europe/Berlin`** (seit 03.07.2026; vorher UTC). Dev-PC = Windows.
|
||||
@@ -45,9 +50,17 @@ Engine (llama-swap) · Builtin-Routing-Gateway (`model: auto`) · MC2 (FastAPI +
|
||||
(Single Source of Truth — Router bleiben dünn). Beispiel-Lehre: Restart-Allowlist lebt NUR in
|
||||
`services.maintenance` (System-Dienste via `sudo -n`, User-Dienste via `systemctl --user`);
|
||||
keine zweite Allowlist in einem Router duplizieren.
|
||||
- **Gate vor Commit:** `python -m py_compile <geänderte .py>` muss durchlaufen.
|
||||
- **Gate vor Commit:** `bash deploy/pruefen.sh` (enthält `py_compile`, `ruff check .` und die Tests).
|
||||
- Wartung ist **sudo-frei** gedacht (systemctl --user). Wo doch sudo nötig ist (llama-swap =
|
||||
System-Dienst), sauber über die NOPASSWD-Whitelist / `password_required`-Rückgabe, nie hart failen.
|
||||
- ‼️ **Die feingranularen sudoers.d-Regeln sind faktisch wirkungslos.** In `/etc/sudoers` steht
|
||||
`hitonabi ALL=(ALL) NOPASSWD: ALL` — der Nutzer, unter dem alle MC2-Dienste laufen, darf ohnehin
|
||||
alles passwortlos. `sudoers.d/mc2-autonomie` und `sudoers.d/mission-control` dokumentieren also,
|
||||
was gebraucht *würde*, schränken aber nichts ein. Das ist eine bewusste Entscheidung für die
|
||||
Single-User-Appliance; verlasse dich beim Bauen nicht darauf, dass eine Whitelist dich bremst.
|
||||
Wer das wirklich härten will, kommt um einen eigenen Service-User nicht herum — die Pauschalzeile
|
||||
einfach zu ziehen, bricht OS-Update, Config-Sync und den wöchentlichen Auto-Neustart still.
|
||||
(Geprüft 27.08.2026; die doppelte Zeile wurde damals entfernt, Sicherung `/root/sudoers.bak-*`.)
|
||||
- Lokal (Windows) müssen Box-Shell-Befehle **harmlos fehlschlagen** statt zu crashen.
|
||||
|
||||
## Grenzen (Verdikt, eingehalten)
|
||||
@@ -56,5 +69,6 @@ Engine (llama-swap) · Builtin-Routing-Gateway (`model: auto`) · MC2 (FastAPI +
|
||||
- Alles reversibel halten: Branch + Backup + Pin.
|
||||
|
||||
## Gitea
|
||||
Remote = `Hitonabi/mission-control-v2`. Auth ist flatterhaft → **Push mit Retry**, nur über
|
||||
PowerShell/GCM. Neue Repos per API anlegen. Kein GitHub.
|
||||
Remote = `ssh://gitea@192.168.178.153:2222/Hitonabi/mission-control-v2.git` (SSH, Push aus
|
||||
Git-Bash geht). Bei Aussetzern **Push mit Retry**. Neue Repos per API anlegen. Kein GitHub.
|
||||
Ungemergtes, das weg soll, erst als Tag `archiv/<name>` sichern, dann löschen.
|
||||
|
||||
@@ -1,145 +1,90 @@
|
||||
# Mission Control 2.0
|
||||
# Homelab Orchestrator
|
||||
|
||||
Steuerzentrale des lokalen KI-Stacks auf dem **Bosgame M5** (AMD Ryzen AI MAX+ 395 „Strix Halo",
|
||||
122 GB Unified Memory, **keine dedizierte GPU** — llama.cpp über **Vulkan/RADV**). Läuft live als
|
||||
sudo-freier systemd-User-Dienst und ist auf **Autonomie** ausgelegt: Die Box wartet sich selbst,
|
||||
prüft sich selbst und meldet sich, wenn etwas nicht stimmt.
|
||||
Ein Steuerpult fürs Heimnetz, das sich selbst wartet. Zwei Bereiche, eine Oberfläche:
|
||||
|
||||
> **100 % lokal.** Kein Cloud-Modell, kein externer Dienst im Datenpfad.
|
||||
- **Box-Wart** betreut die KI-Box (Bosgame M5, AMD Ryzen AI MAX+ 395, 128 GB gemeinsamer Speicher, llama.cpp über
|
||||
Vulkan): hält sie aktuell, passt auf sie auf und sucht bessere Modelle. Live seit 23.09.2026.
|
||||
- **Homelab** soll den Proxmox-PC mit seinen Containern und Arcane (Docker) betreuen: offene Updates zeigen, per
|
||||
Knopf einspielen, danach die Erreichbarkeit prüfen. In Arbeit ([Fahrplan](docs/wissen/OFFENE-FAEDEN.md)).
|
||||
|
||||
## Die drei Bahnen
|
||||
Dieselbe Codebasis läuft als zwei Instanzen: auf der KI-Box (Rolle `box`) und später als Container auf dem
|
||||
Proxmox-PC (Rolle `homelab`); beide prüfen sich gegenseitig. Repo, Dienste und Dateien tragen noch den alten Namen
|
||||
„Mission Control 2" (`mission-control-v2`, `mission-control-2`, `mc2-*`). Alles läuft lokal, kein Cloud-Modell im
|
||||
Datenpfad.
|
||||
|
||||
| Bahn | Was sie tut | Wo |
|
||||
## Oberfläche
|
||||
|
||||
Im Heimnetz `http://192.168.178.151:9001`, am PC oder am Handy.
|
||||
|
||||
| Seite | Inhalt |
|
||||
|---|---|
|
||||
| **Start** | Warnlampen, Instrumente (Speicher, Temperatur, Platte, Laufzeit), Checkliste mit den Hinweisen des Wächters und ihren Knöpfen, Flugplan (was lief, was kommt), Radar-Kasten |
|
||||
| **Updates** | Bausteine Betriebssystem, Motor (llama.cpp), llama-swap und Hermes; Verlauf der Update-Läufe; Sicherungen |
|
||||
| **Modelle** | Speicher, Rollen Hirn und Coder, wer die Modelle nutzt, Modell-Radar, Aufräumen, Modelle selbst suchen |
|
||||
| oben rechts bzw. „Mehr" | Hermes-Dashboard, Dienste und Protokolle, Einstellungen |
|
||||
|
||||
Anleitung für den Alltag: [docs/BEDIENUNG.md](docs/BEDIENUNG.md).
|
||||
|
||||
## Was von allein läuft
|
||||
|
||||
- **Wächter** (jede Minute): Dienste, Timer, Hermes-Jobs, Kern, Platte, festgehaltene Updates und, sobald
|
||||
eingerichtet, die Partner-Instanz. Abgestürzte Dienste startet er selbst neu (höchstens 2× je Stunde), Rotes meldet
|
||||
er per Telegram.
|
||||
- **Updates** sonntags 04:30: llama-swap → Motor → Hermes, danach Prüfung; rot → zurück und festhalten. Die Box
|
||||
startet nur sonntags zwischen 04:00 und 06:59 neu.
|
||||
- **Modell-Radar** nachts 00:30–02:30: sucht Kandidaten für Hirn und Coder und testet höchstens einen pro Woche;
|
||||
übernommen wird nur per Knopf.
|
||||
- **Sicherung** täglich gegen 03:30, 14 Stück, Kopie auf dem Proxmox-Host.
|
||||
- **Meldungen** gehen an Telegram und in Lucys Briefkasten; nachts gesammelt, um 07:00 als eine Morgenmeldung,
|
||||
Dringendes sofort.
|
||||
|
||||
Alle Zeiten: [docs/wissen/STACK.md](docs/wissen/STACK.md), Abschnitt „Automatik".
|
||||
|
||||
## Dienste und Ports (KI-Box)
|
||||
|
||||
| Port | Unit | Aufgabe |
|
||||
|---|---|---|
|
||||
| **Steuerzentrale** | Modelle, Routing, Wartung, Gedächtnis, Ideen-Queue, Auftragsbuch | MC2 `:9001` |
|
||||
| **Coding** | Agentic IDE (z.B. OpenCode Desktop) auf Dev-PC via lokaler API + MCP | 100% lokal |
|
||||
| **Lucy** | Sprach-Companion mit 3D-Avatar, Augen und Ohren | eigenes Repo `Hitonabi/lucy` |
|
||||
| `9001` | `mission-control-2` | Oberfläche, `/api`, `/v1` für Lucy und OpenChamber, Hermes-Dashboard unter `/hermes-ui/` |
|
||||
| `9010` (nur lokal) | `mc2-gateway` | `/v1`-Datenpfad: `model: auto`, Bild-Weiche |
|
||||
| – | `mc2-steward` | Wächter und Re-Warm |
|
||||
| `8080` | `llama-swap` (System-Dienst) | Modell-Router, startet je Modell einen `llama-server` |
|
||||
| `8642` | `hermes-gateway` | Hermes Agent „Lucy", Telegram |
|
||||
| `9119` | `hermes-builtin-ui` | Hermes-Dashboard |
|
||||
| `8021` (nur lokal) | `lucy-stimme` | Lucys Stimme |
|
||||
| `8650` (nur lokal) | `voice-service` | Spracherkennung (schläft seit 24.09.) |
|
||||
| `7682` (nur lokal) | `box-console` | Web-Konsole (schläft seit 24.09.) |
|
||||
|
||||
Lucy holt ihr Hirn direkt über das MC2-Gateway (`:9010/v1`) mit nativer Bildweiche und Voice-Streaming.
|
||||
|
||||
## Ports & Dienste
|
||||
|
||||
| Port | Dienst | Erreichbar |
|
||||
|---|---|---|
|
||||
| `9001` | **MC2** (FastAPI + React) — Cockpit, API, Konsole | LAN |
|
||||
| `8080` | **llama-swap** — Modell-Router | LAN |
|
||||
| `9010` | `mc2-gateway` — `/v1`-Datenpfad (`model: auto`, Bild-Weiche) | loopback |
|
||||
| `8642` · `9119` | Hermes-Gateway · Hermes-Web-UI | loopback |
|
||||
| `8765` · `8650` | Mem0-Sidecar · Voice-Sidecar (STT/TTS) | loopback |
|
||||
| `7682` | ttyd — Box-Konsole, same-origin unter `/console/` | loopback |
|
||||
|
||||
Units in [`deploy/`](deploy/): `mission-control-2` · `mc2-gateway` · `mc2-steward` ·
|
||||
`mem0-service` · `voice-service` · `box-console` · `hermes-builtin-ui` + Timer für Backup,
|
||||
Auto-Update, Self-Smoke und Bagatell-Annahme. `llama-swap` läuft als System-Unit.
|
||||
|
||||
## Die Bau-Pipeline
|
||||
|
||||
```
|
||||
Idee in MC2 → Gitea-Repo (mit CI-Ampel + VERIFY) → in Agentic IDE bauen → Ampel → main
|
||||
└── autonomes Vibe Coding via OpenCode Desktop + MCP
|
||||
```
|
||||
|
||||
Jedes neue Repo wird von [`deploy/gitea-repo-create.sh`](deploy/gitea-repo-create.sh) **mit beiden
|
||||
Wächtern geboren**:
|
||||
|
||||
- **`.gitea/workflows/ci.yml`** — die Außen-Prüfung nach dem Push (Gitea Actions)
|
||||
- **`VERIFY`** — die Innen-Prüfung: eine Zeile, wie man das Projekt testet. Die Agentic IDE
|
||||
führt sie im Mix-Ansatz vor jedem Push aus, um Code-Fehler ("Quality Gap") abzufangen.
|
||||
Keine `VERIFY`-Datei = Prüf-Tor aus.
|
||||
|
||||
## Modelle & Rollen
|
||||
|
||||
Gemessen auf der Box (Stand: August 2026, Vulkan b10502):
|
||||
|
||||
| Rolle | Modell | Tempo | Aufgabe |
|
||||
|---|---|---|---|
|
||||
| `coder` | Qwen3-Coder-Next (80B-A3B) | **51,5 t/s** · Prefill **754 t/s** | Plant und baut — Kopf der Coding-Mannschaft (131k Kontext) |
|
||||
| `hermes` / `fast` | Qwen3.6-35B-A3B | **69,6–90 t/s** | Lucys Hirn **und** Sucher-Subagent. Immer warm |
|
||||
| `debugger` / `doctor` | Muse-Glimmer-30B | **40–50 t/s** (DFlash) | Runtime-Debugger & Fehler-Diagnostiker (Multimodal) |
|
||||
| `kritiker` | Devstral-Small-2-24B | **15,0 t/s** · Prefill **265–318 t/s** | Liest gegen — bewusst **fremde Modellfamilie** (Mistral statt Qwen, 16k Deckel) |
|
||||
| `vision` | Qwen3-VL-30B-A3B | auf Abruf | Lucys Augen (On-Demand) |
|
||||
| `scout` | GLM-4.6V-Flash | auf Abruf | Schneller Vision- und Tool-Allrounder |
|
||||
| `heavy` | gpt-oss-120b | nur nachts | Chef-Gutachter (4:30 Uhr). **Nie tagsüber** — verdrängt das warme Set |
|
||||
| `dense-planer` | Qwen3.8-27B | **12,7 t/s** (On-Demand) | Dichtes 27B-Modell für tiefes Reasoning & 262k Kontext |
|
||||
| `embed` · `reranker` | Qwen3 0.6B | immer warm | Gedächtnis + Feinsortierung |
|
||||
|
||||
‼️ **Der Kritiker ist bewusst auf 16k Kontext gedeckelt.** Devstral ist ein *dichtes* Modell —
|
||||
auf dieser bandbreitenbegrenzten Box bricht sein Prefill mit wachsendem Kontext ein (bei 32k
|
||||
gemessene 63 t/s ≈ **9 Minuten nur zum Lesen**). Mit dem 16k-Deckel bleibt der schlimmste Fall
|
||||
je Review unter einer Minute. Deshalb ist er der **Gegenleser für Etappen**, nicht der Coder —
|
||||
als Coder ist er auf dieser Box disqualifiziert (siehe VERDIKTE).
|
||||
|
||||
‼️ **Speicher-Regel:** `Warm-Set + größtes On-Demand-Modell ≤ ~115 GB`. Die ko-residente Gruppe
|
||||
(`groups.brains` in der llama-swap-Config) muss **`persistent: false`** sein — sonst räumt
|
||||
llama-swap vor einem großen Modell nicht ab und der Kernel schießt Prozesse ab. Details und
|
||||
Messwerte: [`docs/wissen/VERDIKTE.md`](docs/wissen/VERDIKTE.md).
|
||||
|
||||
## Qualitäts-Tore
|
||||
|
||||
1. **Prüf-Tor** — `VERIFY` nach jeder Etappe; rot → der Agent (OpenCode Desktop) repariert lokal nach.
|
||||
2. **CI-Ampel** — Lint (ruff) · Import/Syntax (compileall) · Frontend-Build. Läuft in Gitea.
|
||||
|
||||
Gemeinsame Lehre dahinter: **Wissen lebt in Datei + git, nicht im schrumpfenden Chat-Kontext.**
|
||||
Kontext-Komprimierung löscht still die Regeln mit.
|
||||
|
||||
## Selbsterhaltung
|
||||
|
||||
- **Health-Wächter** (`sentry`) + **Warm-Set-Wächter** (`warmer`, per Env abschaltbar)
|
||||
- **Updates mit Fangnetz** — Backup → Update → Postcheck → bei Fehler **Auto-Rollback + Pin**
|
||||
- **Melde-Briefkasten** (`/api/voice/announce`) — alles, was die Box von sich aus sagen will;
|
||||
Lucy pollt ihn und spricht es. Absender `loop` = Coding-Ereignisse
|
||||
- **Gedächtnis** — Mem0-Sidecar, auto-lernend, semantisch, mit Auto-Dedupe
|
||||
- Tägliche Self-Smokes, Zeitmaschine (Snapshot + Ein-Klick-Restore), Chronik der autonomen Taten
|
||||
|
||||
## API (Auswahl)
|
||||
|
||||
`health · models/* · discover · fit · groups · routing/* · system/* · maintenance/* · connect ·
|
||||
memory/* · agent/* · ideen/* · auftragsbuch/* · chronik · eigenleben · wissen ·
|
||||
zeitmaschine/* · reminders/* · voice/* · events (SSE)`
|
||||
OpenAI-kompatibel: `/v1/chat/completions`, `/v1/completions`. MCP-Server: [`mcp/`](mcp/).
|
||||
Dazu die Timer `mc2-radar`, `mc2-backup`, `mc2-morgenmeldung`, `mc2-autoupdate` und `projekte-sync`. Die Unit-Dateien
|
||||
liegen in [`deploy/`](deploy/).
|
||||
|
||||
## Entwickeln
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
# Backend lokal auf :9000 (Windows)
|
||||
cd backend
|
||||
python -m venv .venv && .venv/Scripts/python -m pip install -r requirements.txt # Windows
|
||||
python -m venv .venv
|
||||
.venv/Scripts/python -m pip install -r requirements.txt -r requirements-dev.txt
|
||||
.venv/Scripts/python -m uvicorn app:app --port 9000
|
||||
|
||||
# Frontend (Dev, proxyt /api → :9000)
|
||||
cd frontend && npm install && npm run dev # http://localhost:5173
|
||||
|
||||
# Frontend (Build → wird vom Backend ausgeliefert)
|
||||
cd frontend && npm run build # → frontend/dist
|
||||
# Frontend (Vite leitet /api an :9000 weiter; MC_API_TARGET=http://192.168.178.151:9001 zeigt auf die Box)
|
||||
cd frontend && npm ci && npm run dev
|
||||
```
|
||||
|
||||
`frontend/dist` **wird mitcommittet** — die Box hat kein Node; Deploy ist ein `git pull` plus
|
||||
Dienst-Neustart. Vor jedem Commit lohnt der Ampel-Vorlauf: `ruff check .` und `npm run build`.
|
||||
Vor jedem Push: `bash deploy/pruefen.sh` (Shell- und Python-Syntax, `ruff check .`, Importe, `pytest backend/tests`);
|
||||
bei Frontend-Änderungen zusätzlich `npm run lint`, `npm test` und `npm run build` in `frontend/`, und das neue
|
||||
`frontend/dist` mitcommitten, denn die Box baut kein Frontend. Gearbeitet wird auf einem Branch; `main` liegt auf
|
||||
Gitea (`ssh://gitea@192.168.178.153:2222/Hitonabi/mission-control-v2.git`), danach auf der Box
|
||||
`bash ~/mission-control-v2/deploy/deploy.sh`. Regeln für Agenten: [AGENTS.md](AGENTS.md).
|
||||
|
||||
## Env-Vars (Auswahl)
|
||||
## Doku
|
||||
|
||||
| Variable | Default | Zweck |
|
||||
| Dokument | Für | Inhalt |
|
||||
|---|---|---|
|
||||
| `MC_PORT` | `9000` | MC-Backend-Port (**die Unit auf der Box setzt `9001`**) |
|
||||
| `MC_LLAMA_SWAP_URL` | `http://127.0.0.1:8080` | Engine/Router |
|
||||
| `MC_CONFIG_PATH` | `/etc/llama-swap/config.yaml` | llama-swap-Config |
|
||||
| `MC_V1_UPSTREAM` | – | gesetzt → `/v1` geht an `mc2-gateway` (`:9010`) statt in-process |
|
||||
|
||||
| `MC_ENGINE_PATH` | `/opt/llamacpp-vulkan` | aktive Engine (Vulkan-Build) |
|
||||
| `MC_REWARM_ENABLED` | `1` | Warm-Set-Wächter (auf der Box bewusst `0`) |
|
||||
| `MC_DRAFTS_DIR` · `MC_SPEC_TYPE` | `$MODELS/drafts` · `draft-simple` | Spekulatives Dekodieren |
|
||||
|
||||
|
||||
|
||||
## Weiterlesen
|
||||
|
||||
| Dokument | Inhalt |
|
||||
|---|---|
|
||||
| [`docs/BEDIENUNG.md`](docs/BEDIENUNG.md) | Wie man MC2 benutzt |
|
||||
| [`docs/RUNBOOK.md`](docs/RUNBOOK.md) · [`docs/DISASTER_RECOVERY.md`](docs/DISASTER_RECOVERY.md) | Betrieb, Störungen, Wiederherstellung |
|
||||
| [`docs/HERMES_SETUP.md`](docs/HERMES_SETUP.md) | Hermes-Agent, Profile, Crons |
|
||||
| [`docs/AUFTRAGSBUCH.md`](docs/AUFTRAGSBUCH.md) | Vorschlags-Inbox und das Ein-Klick-Gate |
|
||||
| [`docs/wissen/VERDIKTE.md`](docs/wissen/VERDIKTE.md) | **Finale Technik-Entscheide — nicht neu aufrollen** |
|
||||
| [`docs/wissen/FALLEN.md`](docs/wissen/FALLEN.md) · [`docs/wissen/OFFENE-FAEDEN.md`](docs/wissen/OFFENE-FAEDEN.md) | Stolpersteine · offene Punkte |
|
||||
|
||||
| [`AGENTS.md`](AGENTS.md) | Arbeitsregeln für Agenten in diesem Repo |
|
||||
| [docs/README.md](docs/README.md) | alle | Index, Lesereihenfolge, Pflegeregeln |
|
||||
| [docs/BEDIENUNG.md](docs/BEDIENUNG.md) | User | Oberfläche und Telegram-Nachrichten |
|
||||
| [docs/ARCHITEKTUR.md](docs/ARCHITEKTUR.md) | Agenten, Technik | Prozesse, Rollen, Datenwege, wer was schreibt |
|
||||
| [docs/BETRIEB.md](docs/BETRIEB.md) | Agenten, Technik | Handgriffe, Meldungen, Wächter, Deploy, Sicherung, Notfall |
|
||||
| [docs/UPDATES.md](docs/UPDATES.md) | Agenten, Technik | Sonntags-Update, Festhalten, Freigeben |
|
||||
| [docs/RADAR.md](docs/RADAR.md) | Agenten, Technik | Modell-Radar, Stack-Radar, Prüfstand |
|
||||
| [docs/WIEDERAUFBAU.md](docs/WIEDERAUFBAU.md) | Technik | die KI-Box von null aufbauen |
|
||||
| [docs/wissen/](docs/wissen/) | Agenten | Stand, Verdikte, Fallen, Arbeitsweise, offene Fäden |
|
||||
|
||||
+105
-66
@@ -1,9 +1,16 @@
|
||||
"""
|
||||
Mission Control 2.0 — dünner FastAPI-Einstieg.
|
||||
Homelab Orchestrator (vormals Mission Control 2.0) — dünner FastAPI-Einstieg.
|
||||
|
||||
Hängt die Router ein, liefert (in Prod) das gebaute React-Frontend aus und
|
||||
setzt eine no-cache-Middleware. Im Dev läuft das Frontend über den Vite-Dev-
|
||||
Server (proxyt /api hierher), daher CORS für localhost offen.
|
||||
|
||||
Zwei Rollen, derselbe Code (MC_ROLLE, kern/einstellungen.py, seit 24.09.2026):
|
||||
box — die KI-Box: Box-Wart (Updater, Wächter, Modell-Radar) plus die Schnittstellen,
|
||||
die Lucy-Desktop, OpenChamber und Hermes brauchen (Sprache, /v1, MCP-Werkzeuge)
|
||||
homelab — der Proxmox-PC: der Homelab-Teil
|
||||
Beide liefern dieselbe Oberfläche aus und reichen den Bereich der anderen Instanz unter
|
||||
/api/partner/… durch.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -15,34 +22,17 @@ from typing import Any
|
||||
|
||||
import httpx
|
||||
from config import FRONTEND_DIST, V1_UPSTREAM, VERSION
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from routers import (
|
||||
agent,
|
||||
auftragsbuch,
|
||||
chronik,
|
||||
connect,
|
||||
console,
|
||||
events,
|
||||
gateway_proxy,
|
||||
health,
|
||||
hermes_ui,
|
||||
ideen,
|
||||
maintenance,
|
||||
models,
|
||||
routing,
|
||||
skills,
|
||||
system,
|
||||
voice,
|
||||
wissen,
|
||||
zeitmaschine,
|
||||
)
|
||||
from routers import reminders as reminders_router
|
||||
from services import metrics_history, reminders, sentry, warmer
|
||||
from kern.einstellungen import einstellungen
|
||||
from routers import health, partner
|
||||
from services.herkunft import erlaubt
|
||||
from starlette.requests import Request
|
||||
|
||||
ROLLE = einstellungen().rolle
|
||||
|
||||
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
|
||||
# für alle Module (logging.getLogger(__name__)).
|
||||
logging.basicConfig(
|
||||
@@ -52,10 +42,11 @@ logging.basicConfig(
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Hintergrund-Tasks an den App-Lebenszyklus binden: Re-Warm-Wächter fürs Agent-Hirn
|
||||
+ Health-Wächter (meldet Ausfälle/Erholung in den Lucy-Briefkasten und auf Telegram)."""
|
||||
def _box_hintergrund() -> list[asyncio.Task[Any]]:
|
||||
"""Hintergrund-Tasks der KI-Box. Der Wächter läuft NICHT hier, sondern im mc2-steward
|
||||
(eigener Prozess, steward.py) — ein totes MC2 könnte sich sonst nicht selbst melden."""
|
||||
from services import reminders, warmer
|
||||
|
||||
tasks: list[asyncio.Task[Any]] = []
|
||||
if warmer.ENABLED:
|
||||
tasks.append(asyncio.create_task(warmer.rewarm_loop()))
|
||||
@@ -63,11 +54,27 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"Hirn-Re-Warm-Wächter aktiv (Intervall %ss, Hirn dynamisch aus Hermes-Config)",
|
||||
warmer.INTERVAL,
|
||||
)
|
||||
if sentry.ENABLED:
|
||||
tasks.append(asyncio.create_task(sentry.sentry_loop()))
|
||||
# Probelauf (neue Fassung neben der laufenden, Box-Wart-Umbau 09/2026): Erinnerungen
|
||||
# gehören dem echten MC2 — zwei Schreiber würden Erinnerungen doppelt feuern.
|
||||
if os.environ.get("MC_PROBELAUF", "") != "1":
|
||||
tasks.append(asyncio.create_task(reminders.reminders_loop()))
|
||||
# 24-h-Metrik-Verlauf (Cockpit-Zeitachse): 10-s-Sampler, Ringpuffer, persistiert.
|
||||
tasks.append(asyncio.create_task(metrics_history.sampler_loop()))
|
||||
else:
|
||||
log.info("Probelauf: Erinnerungen bleiben beim echten MC2.")
|
||||
return tasks
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""Hintergrund-Tasks an den App-Lebenszyklus binden."""
|
||||
# Aufträge (Updates, Downloads) laufen als eigene Einheiten weiter, während MC2 neu startet —
|
||||
# hier werden sie wieder beobachtet. Ein Probelauf daneben fasst sie nicht an.
|
||||
if os.environ.get("MC_PROBELAUF", "") != "1":
|
||||
from services import jobengine
|
||||
await asyncio.to_thread(jobengine.wiederaufnehmen)
|
||||
tasks = _box_hintergrund() if ROLLE == "box" else []
|
||||
if ROLLE == "homelab":
|
||||
from services.homelab import updates
|
||||
updates.unterbrochene_abschliessen() # Läufe des vorigen Prozesses gelten als unterbrochen
|
||||
# Geteilter HTTP-Client zur lokalen Engine: Keep-Alive/Connection-Pooling statt neuer Client
|
||||
# pro /v1-Anfrage (spart Sockets/TIME_WAIT unter parallelen Agent-Strömen von Zed/Kilo).
|
||||
app.state.gw_client = httpx.AsyncClient(
|
||||
@@ -82,7 +89,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
await app.state.gw_client.aclose()
|
||||
|
||||
|
||||
app = FastAPI(title="Mission Control 2.0", version=VERSION, lifespan=lifespan)
|
||||
app = FastAPI(title="Homelab Orchestrator", version=VERSION, lifespan=lifespan)
|
||||
|
||||
# Dev: Vite-Dev-Server (5173) ruft das Backend per /api auf.
|
||||
app.add_middleware(
|
||||
@@ -93,6 +100,15 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
|
||||
# Herkunftsprüfung (24.09.2026, User-Entscheid: keine Anmeldung): Knöpfe fremder Webseiten werden
|
||||
# abgelehnt, Skripte, Desktop-Lucy und /v1 bleiben unberührt. Regeln in services/herkunft.py.
|
||||
@app.middleware("http")
|
||||
async def herkunft(request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]]):
|
||||
if not erlaubt(request.method, request.url.path, request.headers.get("origin"), request.headers.get("host")):
|
||||
return JSONResponse({"detail": "Diese Aktion geht nur von der Orchestrator-Seite selbst aus."}, status_code=403)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def no_cache(
|
||||
request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]]
|
||||
@@ -103,42 +119,61 @@ async def no_cache(
|
||||
return resp
|
||||
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(models.router)
|
||||
app.include_router(routing.router)
|
||||
app.include_router(system.router)
|
||||
def _box_router(app: FastAPI) -> None:
|
||||
"""Die Schnittstellen der KI-Box (Box-Wart und alles, was Lucy, OpenChamber und Hermes brauchen)."""
|
||||
from routers import (
|
||||
boxwart,
|
||||
einstellungen,
|
||||
events,
|
||||
gateway_proxy,
|
||||
hermes_ui,
|
||||
lucy,
|
||||
maintenance,
|
||||
messwerte,
|
||||
models,
|
||||
radar,
|
||||
routing,
|
||||
system,
|
||||
voice,
|
||||
zeitmaschine,
|
||||
)
|
||||
from routers import reminders as reminders_router
|
||||
|
||||
app.include_router(connect.router)
|
||||
app.include_router(agent.router)
|
||||
app.include_router(
|
||||
voice.router
|
||||
) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat (Voice-Tab)
|
||||
app.include_router(
|
||||
reminders_router.router
|
||||
) # Erinnerungen/Routinen (A3) — feuern in den Briefkasten
|
||||
# /v1-Datenpfad: Nach dem Gateway-Auszug (UMBAU v3 P1) läuft der eigentliche Gateway als
|
||||
# eigener Prozess (mc2-gateway, Loopback :9010) — MC2 reicht /v1 dann nur roh durch, damit
|
||||
# LAN-Clients (IDE-Lane) weiter über :9001 kommen. Ohne MC_V1_UPSTREAM (vor dem ersten
|
||||
# Deploy der neuen Unit / nach Rollback) bedient MC2 /v1 wie bisher selbst.
|
||||
if V1_UPSTREAM:
|
||||
app.include_router(boxwart.router) # Cockpit: Start, Hinweise, Modell-Nutzung, Zeitplan
|
||||
app.include_router(einstellungen.router) # Update-Zeitfenster, Radar-Schalter, Telegram-Test
|
||||
app.include_router(radar.router) # Modell-Radar: Kandidaten, Nachttests, Übernehmen/Verwerfen
|
||||
app.include_router(messwerte.router) # Messwerte mit Verlauf (1h, 24h, 7d; schreibt der Steward)
|
||||
app.include_router(models.router)
|
||||
app.include_router(routing.router)
|
||||
app.include_router(system.router)
|
||||
app.include_router(voice.router) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat
|
||||
app.include_router(reminders_router.router) # Erinnerungen/Routinen — feuern in den Briefkasten
|
||||
app.include_router(lucy.router) # Lucys Freiheiten, Rückfragen, Untersuchungen, Wochenbericht (Welle 3)
|
||||
# /v1-Datenpfad: Nach dem Gateway-Auszug (UMBAU v3 P1) läuft der eigentliche Gateway als
|
||||
# eigener Prozess (mc2-gateway, Loopback :9010) — MC2 reicht /v1 dann nur roh durch, damit
|
||||
# LAN-Clients (IDE-Lane) weiter über :9001 kommen. Ohne MC_V1_UPSTREAM (vor dem ersten
|
||||
# Deploy der neuen Unit / nach Rollback) bedient MC2 /v1 wie bisher selbst.
|
||||
if V1_UPSTREAM:
|
||||
from routers import gateway_forward
|
||||
app.include_router(gateway_forward.router) # dünner Roh-Weiterleiter → mc2-gateway
|
||||
else:
|
||||
else:
|
||||
app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
|
||||
app.include_router(maintenance.router)
|
||||
app.include_router(auftragsbuch.router) # Vorschlags-Inbox (Mensch-Gate als Klick)
|
||||
app.include_router(ideen.router) # Ideen-Queue (natives Hermes-Kanban) — Tür der Zentrale
|
||||
app.include_router(chronik.router) # Timeline der autonomen Taten (Announce-Store)
|
||||
app.include_router(events.router) # SSE-Eventstrom /api/events (P3a) — Invalidation-Bus
|
||||
app.include_router(wissen.router) # Wissens-Vault (Traum-Notizen) read-only
|
||||
app.include_router(zeitmaschine.router) # Snapshots ansehen + Ein-Klick-Restore (detached)
|
||||
app.include_router(skills.router) # Skills & Jobs Dashboard
|
||||
app.include_router(
|
||||
console.router
|
||||
) # Box-Konsole (ttyd) same-origin durchreichen — VOR dem SPA-Catch-all
|
||||
app.include_router(
|
||||
hermes_ui.router
|
||||
) # Eingebaute Hermes-Web-GUI (hermes serve) same-origin unter /hermes-ui/ — VOR dem SPA-Catch-all
|
||||
app.include_router(maintenance.router)
|
||||
app.include_router(events.router) # SSE-Strom /api/stream — Messwerte + Invalidation
|
||||
app.include_router(zeitmaschine.router) # Sicherungen ansehen + zurückspielen (detached)
|
||||
# Eingebaute Hermes-Web-GUI (hermes dashboard) same-origin unter /hermes-ui/ — VOR dem SPA-Catch-all
|
||||
app.include_router(hermes_ui.router)
|
||||
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(partner.router) # zweite Instanz: Lebenszeichen + Durchreiche
|
||||
if ROLLE == "box":
|
||||
_box_router(app)
|
||||
else:
|
||||
from routers import einstellungen, homelab
|
||||
app.include_router(homelab.router) # Proxmox-Host, Container, Arcane (Phase 3/4)
|
||||
app.include_router(homelab.strom_router) # Live-Strom der Homelab-Instanz
|
||||
app.include_router(einstellungen.homelab_router) # Telegram-Test des Homelab-Teils
|
||||
|
||||
|
||||
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
|
||||
@@ -149,6 +184,10 @@ if FRONTEND_DIST.exists():
|
||||
|
||||
@app.get("/{full_path:path}")
|
||||
def spa(full_path: str):
|
||||
# Unbekannte /api-Pfade sind ein echter 404, keine Startseite: Sonst bekommt die
|
||||
# Oberfläche HTML statt JSON und stürzt ab (erster Probelauf 23.09., /api/radar).
|
||||
if full_path == "api" or full_path.startswith("api/"):
|
||||
raise HTTPException(status_code=404, detail="Diese Schnittstelle gibt es nicht.")
|
||||
# Datei direkt aus FRONTEND_DIST ausliefern (manifest.webmanifest, favicon.ico, …) — aber
|
||||
# NUR innerhalb des dist-Ordners: Pfad auflösen + Traversal (../, absolute Pfade) hart raus.
|
||||
try:
|
||||
|
||||
+21
-31
@@ -9,8 +9,6 @@ Dienste (llama-swap, LiteLLM-Gateway, Hermes) steuert, statt sie nachzubauen.
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
# --- Engine (llama-swap) -----------------------------------------------------
|
||||
LLAMA_SWAP_URL = os.environ.get("MC_LLAMA_SWAP_URL", "http://127.0.0.1:8080").rstrip("/")
|
||||
CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/etc/llama-swap/config.yaml"))
|
||||
@@ -19,20 +17,18 @@ MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models"))
|
||||
# Persistent neben den Modellen (übersteht Deploys). TTL = Frische-Fenster.
|
||||
DISCOVER_CACHE_PATH = Path(os.environ.get("MC_DISCOVER_CACHE", str(MODELS_DIR / "mc2-discover.json")))
|
||||
DISCOVER_TTL = int(os.environ.get("MC_DISCOVER_TTL", "43200")) # 12 h
|
||||
# Geteiltes Gedächtnis (SQLite, WAL). Persistent neben den Modellen.
|
||||
# Hinweis: nur noch für die einmalige Mem0-Migration relevant — das aktive Gedächtnis
|
||||
# liegt jetzt in Mem0/Chroma hinter dem Sidecar (siehe MEM0_SERVICE_URL).
|
||||
MEMORY_DB = Path(os.environ.get("MC_MEMORY_DB", str(MODELS_DIR / "mc2-memory.db")))
|
||||
# Mem0-Sidecar (auto-lernendes, semantisches Gedächtnis). Läuft im ~/.mem0/venv (Python 3.12),
|
||||
# weil mem0+chromadb unter dem 3.14-Backend nicht laufen. MC2 spricht ihn lokal per HTTP an.
|
||||
MEM0_SERVICE_URL = os.environ.get("MC_MEM0_SERVICE_URL", "http://127.0.0.1:8765").rstrip("/")
|
||||
# Gedächtnis: MC2 hält KEINS mehr. Bis August 2026 lief hier erst eine eigene SQLite-DB,
|
||||
# dann ein semantischer Sidecar auf :8765 — beides ist abgelöst, der Port ist tot.
|
||||
# Einzige Gedächtnis-Wahrheit ist jetzt Hermes selbst (HERMES_API_URL, siehe unten).
|
||||
# Befehl-Vorlage für llama-swap: {model}=GGUF-Pfad, {ctx}=Kontext, ${PORT} bleibt stehen.
|
||||
# Hinweis: --prompt-cache/--prompt-cache-all sind llama-CLI-Flags, NICHT llama-server —
|
||||
# llama-server lehnt sie ab ("invalid argument") und startet dann nicht. Prompt-Caching
|
||||
# macht llama-server ohnehin automatisch pro Slot (KV-Reuse).
|
||||
# --load-mode none = das frühere --no-mmap; das alte Flag ist seit llama.cpp b10936 weg
|
||||
# ("invalid argument", jedes Modell stirbt beim Start — gelernt 13./17.09.2026).
|
||||
_DEFAULT_CMD_TEMPLATE = (
|
||||
"llama-server -m {model} --host 127.0.0.1 --port ${PORT} "
|
||||
"-c {ctx} -ngl 999 -fa on --no-mmap"
|
||||
"-c {ctx} -ngl 999 -fa on --load-mode none"
|
||||
)
|
||||
CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE)
|
||||
if "{model}" not in CMD_TEMPLATE:
|
||||
@@ -72,7 +68,7 @@ V1_UPSTREAM = os.environ.get("MC_V1_UPSTREAM", "").rstrip("/")
|
||||
HERMES_API_URL = os.environ.get("HERMES_API_URL", "http://127.0.0.1:8642").rstrip("/")
|
||||
# API-Key der Hermes-`api_server`-Plattform (~/.hermes/.env: API_SERVER_KEY). Nötig für
|
||||
# /v1/chat/completions (Voice-Pipeline) — Bearer-Auth, sonst 401. Derselbe volle Agent
|
||||
# (Tools + geteiltes Mem0) wie CLI/Telegram, nur über HTTP.
|
||||
# (Tools + eigenes Gedächtnis) wie CLI/Telegram, nur über HTTP.
|
||||
def _read_hermes_env(key: str) -> str:
|
||||
"""Liest einen Schlüssel aus ~/.hermes/.env (Fallback, falls nicht in der Prozess-Env).
|
||||
Der MC2-Dienst erbt die Hermes-Secrets sonst nicht."""
|
||||
@@ -97,32 +93,30 @@ HERMES_API_KEY = (
|
||||
HERMES_API_MODEL = os.environ.get("HERMES_API_MODEL", "hermes")
|
||||
|
||||
# --- Voice-Sidecar (STT faster-whisper + TTS Piper/Chatterbox) ---------------
|
||||
# Eigenes Python-3.12-venv (~/.voice/venv), analog Mem0-Sidecar. MC2 proxyt nach außen.
|
||||
# Eigenes Python-3.12-venv (~/.voice/venv), weil Parakeet/Piper nicht ins 3.14-Backend-venv
|
||||
# passen. MC2 proxyt nach außen.
|
||||
VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:8650").rstrip("/")
|
||||
# Box-Konsole: ttyd-Web-Terminal (echte Login-Shell). Bindet NUR an Loopback
|
||||
# (127.0.0.1:7682, base-path /console) und wird von MC2 über den ohnehin offenen Port 9001
|
||||
# rückwärts geproxyt (routers/console.py → same-origin /console/). So braucht die Konsole
|
||||
# KEINE eigene Firewall-Freigabe (Port 7682 ist von außen dicht) und keinen sudo-Eingriff.
|
||||
# Direkter, SSH-artiger Zugriff, kein Passwort — gleiches LAN-Trust-Modell wie das Dashboard.
|
||||
BOX_CONSOLE_UPSTREAM = os.environ.get("MC_BOX_CONSOLE_UPSTREAM", "http://127.0.0.1:7682").rstrip("/")
|
||||
# Öffentlicher, gleicher-Ursprung-Pfad, unter dem MC2 die Konsole ausliefert (iframe-Ziel).
|
||||
BOX_CONSOLE_PATH = "/console/"
|
||||
# --- Lucys Stimme (lucy-stimme.service, pocket-tts german_24l, Klon aus ref.mp3) -----------
|
||||
# Eigener User-Dienst in ~/.lucy-stimme (nur Loopback :8021). Spricht die Telegram-Sprachnachrichten
|
||||
# UND — seit dem Raphael-Umbau der Desktop-Lucy (04.09.2026) — auch den PC: MC2 reicht ihn unter
|
||||
# /api/lucy/stimme/* ins LAN (routers/voice.py). EINE Stimme für alle Türen. :8650 ist NICHT Lucy.
|
||||
LUCY_STIMME_URL = os.environ.get("MC_LUCY_STIMME_URL", "http://127.0.0.1:8021").rstrip("/")
|
||||
# Eingebaute Hermes-Web-GUI (`hermes serve`/`dashboard` — die richtige Agent-Oberfläche mit
|
||||
# Threads/Tool-Calls, die auch die Electron-Desktop-App umhüllt). Bindet NUR an Loopback
|
||||
# (127.0.0.1:9119, kein Login — LAN-Trust wie Terminal/Konsole) und wird von MC2 same-origin unter
|
||||
# Threads/Tool-Calls, die auch die Electron-Desktop-App umhüllt). ‼️ Sie bindet NICHT auf Loopback:
|
||||
# ein systemd-Drop-in überschreibt `--host 127.0.0.1` mit `0.0.0.0`, weil Hermes seine Anmeldung auf
|
||||
# Loopback abschaltet (dann wäre /hermes-ui/ ohne Login offen). Dass sie trotzdem nicht im LAN hängt,
|
||||
# liegt allein an ufw (9119 ist nicht freigegeben — 27.08. und 25.09.2026 vom PC aus gegengeprüft).
|
||||
# Wer die Firewall anfasst, öffnet damit auch diese Oberfläche. Das feste Session-Token der früheren
|
||||
# Desktop-Anbindung ist seit 25.09.2026 weg. MC2 erreicht sie über Loopback und reicht sie
|
||||
# same-origin unter
|
||||
# /hermes-ui/ durchgereicht (routers/hermes_ui.py). Anders als das ttyd-Terminal ist es eine volle
|
||||
# SPA → das Bundle wird mit Vite-base=/hermes-ui/ gebaut, damit Assets/API/WS unter dem Präfix
|
||||
# liegen; der Proxy streift /hermes-ui ab. Kein eigener Firewall-Port nötig.
|
||||
HERMES_BUILTIN_UI_UPSTREAM = os.environ.get("MC_HERMES_BUILTIN_UI_UPSTREAM", "http://127.0.0.1:9119").rstrip("/")
|
||||
HERMES_BUILTIN_UI_PATH = "/hermes-ui/"
|
||||
# GitHub-Repo für Update-Checks.
|
||||
HERMES_AGENT_REPO = os.environ.get("MC_HERMES_AGENT_REPO", "NousResearch/hermes-agent")
|
||||
HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes")))
|
||||
# PC Executor — läuft auf dem Windows-PC, erreichbar über LAN.
|
||||
PC_EXECUTOR_URL = os.environ.get("MC_PC_EXECUTOR_URL", "http://192.168.178.98:7777").rstrip("/")
|
||||
|
||||
# --- Server ------------------------------------------------------------------
|
||||
HOST = os.environ.get("MC_HOST", "0.0.0.0")
|
||||
PORT = int(os.environ.get("MC_PORT", "9000"))
|
||||
# Gebautes React-Frontend (frontend/dist). In Prod liefert FastAPI es statisch aus;
|
||||
# im Dev läuft der Vite-Dev-Server separat und proxyt /api hierher.
|
||||
@@ -130,7 +124,3 @@ FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resol
|
||||
|
||||
# Version (Phase 0 — Greenfield-Skeleton).
|
||||
VERSION = "2.0.0-w8"
|
||||
|
||||
# Gemeinsame YAML-Instanz (preserve_quotes hält Kommentare/Quotes in config.yaml).
|
||||
yaml = YAML()
|
||||
yaml.preserve_quotes = True
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Kern des Homelab Orchestrators (Phase 2, ab 24.09.2026): was beide Instanzen teilen —
|
||||
Einstellungen, Zeitzone, Partner-Instanz. Neue Module lesen ihre Einstellungen hier; ältere
|
||||
Module ziehen nach und nach um."""
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Zentrale Einstellungen einer Instanz des Homelab Orchestrators.
|
||||
|
||||
Zwei Rollen, derselbe Code (User-Entscheid 24.09.2026, „zwei Instanzen, eine Oberfläche“):
|
||||
box — auf der KI-Box: Box-Wart (Updates, Wächter, Modelle, Radar, Gateway für Lucy)
|
||||
homelab — auf dem Proxmox-PC: Homelab-Teil (Proxmox-Host, Container, Arcane)
|
||||
Beide kennen die jeweils andere Instanz als Partner: die Oberfläche zeigt beide Bereiche, und
|
||||
jede Instanz prüft, ob die andere noch antwortet.
|
||||
"""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
ROLLEN = ("box", "homelab")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Einstellungen:
|
||||
rolle: str # "box" | "homelab"
|
||||
instanz: str # Anzeigename dieser Instanz
|
||||
daten_dir: Path # Zustandsdateien dieser Instanz
|
||||
partner_url: str # Basis-URL der anderen Instanz, leer = keine
|
||||
partner_name: str # Anzeigename der anderen Instanz
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def einstellungen() -> Einstellungen:
|
||||
rolle = os.environ.get("MC_ROLLE", "box").strip().lower() or "box"
|
||||
if rolle not in ROLLEN:
|
||||
raise ValueError(f"MC_ROLLE muss eine von {ROLLEN} sein, nicht {rolle!r}.")
|
||||
box = rolle == "box"
|
||||
standard_daten = os.environ.get("MC_MODELS_DIR", "/srv/models") if box else "/var/lib/mc2"
|
||||
return Einstellungen(
|
||||
rolle=rolle,
|
||||
instanz=os.environ.get("MC_INSTANZ", "Box-Wart" if box else "Homelab"),
|
||||
daten_dir=Path(os.environ.get("MC_DATEN_DIR", standard_daten)),
|
||||
partner_url=os.environ.get("MC_PARTNER_URL", "").strip().rstrip("/"),
|
||||
partner_name=os.environ.get("MC_PARTNER_NAME", "Homelab" if box else "Box-Wart"),
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
"""GitHub-Abfragen mit Zwischenspeicher — für beide Rollen (Box: Motor und llama-swap, Homelab: die Apps).
|
||||
|
||||
GitHub erlaubt ohne Anmeldung 60 Anfragen pro Stunde. Jeder Blick auf die Update-Details fragte früher
|
||||
neu; jetzt gilt eine Antwort 15 Minuten. Fehler (auch das Anfragelimit) werden nicht gemerkt.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
GITHUB_CACHE_S = int(os.environ.get("MC_GITHUB_CACHE_S", "900"))
|
||||
_cache: dict[str, tuple[float, object]] = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def github_json(pfad: str, timeout: float = 8) -> object:
|
||||
"""GET https://api.github.com/<pfad>, 15 Minuten gemerkt. Wirft bei HTTP-Fehlern."""
|
||||
jetzt = time.time()
|
||||
with _lock:
|
||||
if (eintrag := _cache.get(pfad)) and jetzt - eintrag[0] < GITHUB_CACHE_S:
|
||||
return eintrag[1]
|
||||
r = httpx.get(f"https://api.github.com/{pfad}", timeout=timeout, headers={"User-Agent": "MissionControl2"})
|
||||
r.raise_for_status()
|
||||
daten = r.json()
|
||||
with _lock:
|
||||
_cache[pfad] = (jetzt, daten)
|
||||
return daten
|
||||
|
||||
|
||||
_VERSION = re.compile(r"^v?(\d+(?:\.\d+)+|\d{4}-\d{2}-\d{2}(?:-r\d+)?)", re.IGNORECASE)
|
||||
|
||||
|
||||
def neueste_version(repo: str) -> dict | None:
|
||||
"""Neueste Veröffentlichung eines Repos: {"tag", "version", "datum"} oder None. Manche Projekte
|
||||
veröffentlichen „untagged“ und tragen die Version nur im Namen (PVE Scripts Local, 09/2026)."""
|
||||
daten = github_json(f"repos/{repo}/releases/latest")
|
||||
if not isinstance(daten, dict) or not daten.get("tag_name"):
|
||||
return None
|
||||
tag, name = str(daten["tag_name"]), str(daten.get("name") or "")
|
||||
roh = tag if _VERSION.match(tag) or not _VERSION.match(name) else name
|
||||
return {"tag": roh, "tag_roh": tag, "version": roh.lstrip("vV"),
|
||||
"datum": str(daten.get("published_at") or "")[:10] or None}
|
||||
@@ -0,0 +1,301 @@
|
||||
"""Messreihen: Minutenwerte mit Verlauf für beide Bereiche (Monitoring, seit 24.09.2026).
|
||||
|
||||
Ablage: je Quelle und Tag eine Datei, darin eine JSON-Zeile je Minute:
|
||||
<Datenordner>/mc2-messwerte/<quelle>-JJJJ-MM-TT.jsonl (Tag nach Berliner Zeit, kern/zeit.py)
|
||||
{"t":1790000000,"cpu":12.5,"ram":41.2,…} (t = Unix-Sekunden der Messung, null = keine Messung)
|
||||
Quellen: „box“ (die KI-Box; schreibt ihr Steward) sowie „pve“, „ct-<vmid>“ und „vm-<vmid>“ (das Homelab; schreibt der
|
||||
Homelab-Teil, was der Ausführer jede Minute schickt). Dateien, deren Tag mehr als AUFBEWAHREN_TAGE zurückliegt, löscht
|
||||
das erste Schreiben eines neuen Tages.
|
||||
|
||||
Schreiben: eine Zeile anhängen, unter einer Thread-Sperre und (Linux) flock auf der Datei. Fehlt am Dateiende der
|
||||
Zeilenumbruch (Absturz mitten im Schreiben), beginnt die neue Zeile auf einer eigenen statt an die kaputte anzuwachsen.
|
||||
|
||||
Lesen (lesen()): 1h in 60-s-Schritten, 24h als 5-min-Mittel, 7d als 30-min-Mittel. Die Schritte liegen auf vollen
|
||||
Vielfachen ihrer Länge, der letzte ist der laufende. Ein Schritt ohne Messung bleibt null — Lücken werden nicht
|
||||
aufgefüllt. Kaputte Zeilen werden übersprungen. Die Summen je Tagesdatei werden für 5- und 30-min-Schritte gemerkt,
|
||||
solange sich die Datei nicht ändert (vergangene Tage liest so nur die erste Anfrage).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Iterable, Iterator
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from kern.einstellungen import einstellungen
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
try:
|
||||
import fcntl # Linux: Sperre über Prozessgrenzen
|
||||
except ImportError: # Windows (Entwicklung): nur die Thread-Sperre
|
||||
fcntl = None
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
ORDNER_NAME = "mc2-messwerte"
|
||||
AUFBEWAHREN_TAGE = 8
|
||||
# Zeitraum → (Länge, Schritt) in Sekunden.
|
||||
ZEITRAEUME: dict[str, tuple[int, int]] = {"1h": (3600, 60), "24h": (24 * 3600, 300), "7d": (7 * 24 * 3600, 1800)}
|
||||
MERKEN_AB_S = 300 # Summen je Datei merken; bei 60-s-Schritten (1440 je Tag) lohnt es den Speicher nicht
|
||||
MERKEN_MAX = 512
|
||||
LUECKE_MAX_S = 300 # Zählerstände, die weiter auseinanderliegen, ergeben keine Rate mehr
|
||||
RATE_MAX = 5e9 # mehr als 5 GB/s ist kein Verkehr, sondern ein Zählersprung
|
||||
|
||||
_QUELLE = re.compile(r"[a-z0-9][a-z0-9-]{0,62}")
|
||||
_DATEI = re.compile(r"(?P<quelle>[a-z0-9][a-z0-9-]*)-(?P<tag>\d{4}-\d{2}-\d{2})\.jsonl")
|
||||
|
||||
_lock = threading.Lock()
|
||||
_aufgeraeumt: dict[str, str] = {} # Ordner → Tag des letzten Aufräumens (je Prozess)
|
||||
_merk_lock = threading.Lock()
|
||||
_merk: dict[tuple[str, int], tuple[tuple[int, int], dict[int, dict[str, list[float]]]]] = {}
|
||||
|
||||
|
||||
def ordner() -> Path:
|
||||
return einstellungen().daten_dir / ORDNER_NAME
|
||||
|
||||
|
||||
def ist_zahl(wert: object) -> bool:
|
||||
"""Eine endliche Zahl. bool zählt nicht, NaN und unendlich auch nicht."""
|
||||
return isinstance(wert, (int, float)) and not isinstance(wert, bool) and math.isfinite(wert)
|
||||
|
||||
|
||||
def zeitraum_pruefen(zeitraum: str) -> tuple[int, int]:
|
||||
"""(Länge, Schritt) in Sekunden; ValueError bei einem unbekannten Zeitraum."""
|
||||
if zeitraum not in ZEITRAEUME:
|
||||
raise ValueError(f"Den Zeitraum „{zeitraum}“ gibt es nicht; möglich sind {', '.join(ZEITRAEUME)}.")
|
||||
return ZEITRAEUME[zeitraum]
|
||||
|
||||
|
||||
def _datum(t: float):
|
||||
return datetime.fromtimestamp(t, LOCAL_TZ).date()
|
||||
|
||||
|
||||
def _tage(von: float, bis: float) -> list[str]:
|
||||
"""Alle Tage (Berliner Zeit), deren Dateien Messungen aus [von, bis) enthalten können."""
|
||||
tag, letzter = _datum(von), _datum(max(von, bis - 1))
|
||||
tage = []
|
||||
while tag <= letzter:
|
||||
tage.append(tag.isoformat())
|
||||
tag += timedelta(days=1)
|
||||
return tage
|
||||
|
||||
|
||||
def _datei(quelle: str, tag: str) -> Path:
|
||||
if not _QUELLE.fullmatch(quelle):
|
||||
raise ValueError(f"Ungültiger Name einer Messquelle: {quelle!r}")
|
||||
return ordner() / f"{quelle}-{tag}.jsonl"
|
||||
|
||||
|
||||
# --- Schreiben -----------------------------------------------------------------------------------
|
||||
|
||||
def _wert(wert: object) -> int | float | None:
|
||||
if not ist_zahl(wert):
|
||||
return None
|
||||
return wert if isinstance(wert, int) else round(float(wert), 3)
|
||||
|
||||
|
||||
def schreiben(quelle: str, werte: dict[str, object], t: float | None = None) -> None:
|
||||
"""Einen Messpunkt anhängen. werte: Name → Zahl oder None (nicht gemessen)."""
|
||||
t = time.time() if t is None else float(t)
|
||||
punkt: dict[str, object] = {"t": int(t)}
|
||||
punkt.update({name: _wert(wert) for name, wert in werte.items() if name != "t"})
|
||||
zeile = (json.dumps(punkt, separators=(",", ":")) + "\n").encode()
|
||||
pfad = _datei(quelle, _datum(t).isoformat())
|
||||
with _lock:
|
||||
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(pfad, "a+b") as f:
|
||||
if fcntl is not None:
|
||||
fcntl.flock(f, fcntl.LOCK_EX)
|
||||
try:
|
||||
f.seek(0, os.SEEK_END)
|
||||
if f.tell() > 0:
|
||||
f.seek(-1, os.SEEK_END)
|
||||
if f.read(1) != b"\n":
|
||||
zeile = b"\n" + zeile # die letzte Zeile brach ab: nicht an sie anhängen
|
||||
f.write(zeile)
|
||||
f.flush()
|
||||
finally:
|
||||
if fcntl is not None:
|
||||
fcntl.flock(f, fcntl.LOCK_UN)
|
||||
_einmal_am_tag_aufraeumen(t)
|
||||
|
||||
|
||||
def _einmal_am_tag_aufraeumen(t: float) -> None:
|
||||
schluessel, tag = str(ordner()), _datum(t).isoformat()
|
||||
if _aufgeraeumt.get(schluessel) == tag:
|
||||
return
|
||||
_aufgeraeumt[schluessel] = tag
|
||||
try:
|
||||
aufraeumen(t)
|
||||
except OSError:
|
||||
log.warning("messreihen: Aufräumen in %s ging nicht", schluessel, exc_info=True)
|
||||
|
||||
|
||||
def aufraeumen(jetzt: float | None = None) -> int:
|
||||
"""Tagesdateien löschen, deren Tag mehr als AUFBEWAHREN_TAGE zurückliegt. Andere Dateien bleiben. Rückgabe: wie
|
||||
viele gelöscht wurden."""
|
||||
grenze = (_datum(time.time() if jetzt is None else jetzt) - timedelta(days=AUFBEWAHREN_TAGE)).isoformat()
|
||||
try:
|
||||
namen = os.listdir(ordner())
|
||||
except FileNotFoundError:
|
||||
return 0
|
||||
geloescht = 0
|
||||
for name in namen:
|
||||
treffer = _DATEI.fullmatch(name)
|
||||
if treffer and treffer["tag"] < grenze:
|
||||
try:
|
||||
(ordner() / name).unlink()
|
||||
geloescht += 1
|
||||
except FileNotFoundError:
|
||||
pass # ein anderer Prozess war schneller
|
||||
return geloescht
|
||||
|
||||
|
||||
# --- Lesen ---------------------------------------------------------------------------------------
|
||||
|
||||
def _punkt(zeile: bytes) -> dict | None:
|
||||
"""Eine Zeile als Messpunkt; None bei allem, was keiner ist (kaputt, leer, ohne Zeit)."""
|
||||
zeile = zeile.strip()
|
||||
if not zeile:
|
||||
return None
|
||||
try:
|
||||
punkt = json.loads(zeile)
|
||||
except ValueError: # auch UnicodeDecodeError
|
||||
return None
|
||||
return punkt if isinstance(punkt, dict) and ist_zahl(punkt.get("t")) else None
|
||||
|
||||
|
||||
def _punkte_der_datei(pfad: Path) -> Iterator[dict]:
|
||||
try:
|
||||
roh = pfad.read_bytes()
|
||||
except OSError:
|
||||
return
|
||||
for zeile in roh.split(b"\n"):
|
||||
if (punkt := _punkt(zeile)) is not None:
|
||||
yield punkt
|
||||
|
||||
|
||||
def _eimer(pfad: Path, schritt: int, ab: float) -> dict[int, dict[str, list[float]]]:
|
||||
"""Summe und Anzahl je Schritt und Wert für eine Tagesdatei. Gemerkt (ab 5-min-Schritten) wird die ganze Datei;
|
||||
sonst zählen erst Messungen ab `ab`. Das Ergebnis nicht verändern: es kann gemerkt sein."""
|
||||
try:
|
||||
stand = pfad.stat()
|
||||
except OSError:
|
||||
return {}
|
||||
merken = schritt >= MERKEN_AB_S
|
||||
schluessel, kennung = (str(pfad), schritt), (stand.st_mtime_ns, stand.st_size)
|
||||
if merken:
|
||||
with _merk_lock:
|
||||
gemerkt = _merk.get(schluessel)
|
||||
if gemerkt and gemerkt[0] == kennung:
|
||||
return gemerkt[1]
|
||||
eimer: dict[int, dict[str, list[float]]] = {}
|
||||
for punkt in _punkte_der_datei(pfad):
|
||||
if not merken and punkt["t"] < ab:
|
||||
continue
|
||||
ziel = eimer.setdefault(int(punkt["t"]) // schritt * schritt, {})
|
||||
for name, wert in punkt.items():
|
||||
if name != "t" and ist_zahl(wert):
|
||||
summe = ziel.setdefault(name, [0.0, 0])
|
||||
summe[0] += wert
|
||||
summe[1] += 1
|
||||
if merken:
|
||||
with _merk_lock:
|
||||
if len(_merk) >= MERKEN_MAX:
|
||||
_merk.clear()
|
||||
_merk[schluessel] = (kennung, eimer)
|
||||
return eimer
|
||||
|
||||
|
||||
def _mittel(summe: list[float] | None) -> int | float | None:
|
||||
"""Mittel eines Schritts: ab 100 ganzzahlig, darunter eine Nachkommastelle; ohne Messung None."""
|
||||
if not summe or not summe[1]:
|
||||
return None
|
||||
wert = summe[0] / summe[1]
|
||||
return round(wert) if abs(wert) >= 100 else round(wert, 1)
|
||||
|
||||
|
||||
def lesen(quelle: str, zeitraum: str, namen: Iterable[str], jetzt: float | None = None) -> dict:
|
||||
"""Die Reihen einer Quelle: {"zeitraum", "schritt_s", "reihen": {name: [[t, wert], …]}}. t = Beginn des Schritts
|
||||
(Unix-Sekunden), wert = Mittel der Messungen darin oder None. Jede Reihe hat Länge/Schritt Einträge (60, 288, 336)."""
|
||||
dauer, schritt = zeitraum_pruefen(zeitraum)
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
ende = (int(jetzt) // schritt + 1) * schritt # Ende des laufenden Schritts
|
||||
beginn = ende - dauer
|
||||
summen: dict[int, dict[str, list[float]]] = {}
|
||||
for tag in _tage(beginn, ende):
|
||||
for t0, werte in _eimer(_datei(quelle, tag), schritt, beginn).items():
|
||||
if not beginn <= t0 < ende:
|
||||
continue
|
||||
ziel = summen.setdefault(t0, {})
|
||||
for name, (summe, anzahl) in werte.items():
|
||||
gesamt = ziel.setdefault(name, [0.0, 0])
|
||||
gesamt[0] += summe
|
||||
gesamt[1] += anzahl
|
||||
raster = range(beginn, ende, schritt)
|
||||
return {"zeitraum": zeitraum, "schritt_s": schritt,
|
||||
"reihen": {name: [[t0, _mittel(summen.get(t0, {}).get(name))] for t0 in raster] for name in namen}}
|
||||
|
||||
|
||||
def _ende_der_datei(pfad: Path, groesse: int = 16384) -> list[bytes]:
|
||||
"""Die letzten Zeilen einer Datei, ohne sie ganz zu lesen."""
|
||||
try:
|
||||
with open(pfad, "rb") as f:
|
||||
f.seek(0, os.SEEK_END)
|
||||
laenge = f.tell()
|
||||
f.seek(max(0, laenge - groesse))
|
||||
roh = f.read()
|
||||
except OSError:
|
||||
return []
|
||||
zeilen = roh.split(b"\n")
|
||||
return zeilen[1:] if laenge > groesse else zeilen # die erste Zeile ist dann angeschnitten
|
||||
|
||||
|
||||
def letzter_punkt(quelle: str, jetzt: float | None = None, max_alter_s: float | None = None) -> dict | None:
|
||||
"""Die jüngste Messung einer Quelle (heute, sonst gestern). None, wenn es keine gibt oder sie älter ist als
|
||||
max_alter_s — ein alter Wert ist kein aktueller."""
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
heute = _datum(jetzt)
|
||||
for tag in (heute, heute - timedelta(days=1)):
|
||||
for zeile in reversed(_ende_der_datei(_datei(quelle, tag.isoformat()))):
|
||||
if (punkt := _punkt(zeile)) is not None:
|
||||
if max_alter_s is not None and jetzt - punkt["t"] > max_alter_s:
|
||||
return None
|
||||
return punkt
|
||||
return None
|
||||
|
||||
|
||||
def punkte(quelle: str, von: float, bis: float) -> list[dict]:
|
||||
"""Alle Messungen einer Quelle mit von ≤ t < bis, nach Zeit geordnet (für Prüfungen des Wächters)."""
|
||||
gefunden = [p for tag in _tage(von, bis) for p in _punkte_der_datei(_datei(quelle, tag)) if von <= p["t"] < bis]
|
||||
return sorted(gefunden, key=lambda p: p["t"])
|
||||
|
||||
|
||||
def quellen(von: float, bis: float) -> list[str]:
|
||||
"""Die Quellen, von denen es für [von, bis) eine Tagesdatei gibt."""
|
||||
tage = set(_tage(von, bis))
|
||||
try:
|
||||
namen = os.listdir(ordner())
|
||||
except OSError:
|
||||
return []
|
||||
return sorted({t["quelle"] for name in namen if (t := _DATEI.fullmatch(name)) and t["tag"] in tage})
|
||||
|
||||
|
||||
# --- Zähler → Raten --------------------------------------------------------------------------------
|
||||
|
||||
def rate(alt: object, neu: object, dt: float) -> float | None:
|
||||
"""Zuwachs je Sekunde zwischen zwei Ständen eines kumulativen Zählers (Bytes, Tokens). None, wenn ein Stand fehlt,
|
||||
die Stände mehr als LUECKE_MAX_S auseinanderliegen, der Zähler kleiner wurde (Neustart, Überlauf) oder der Sprung
|
||||
unmöglich groß ist — eine erfundene Rate wäre schlimmer als eine Lücke."""
|
||||
if not (ist_zahl(alt) and ist_zahl(neu)) or not 0 < dt <= LUECKE_MAX_S:
|
||||
return None
|
||||
zuwachs = float(neu) - float(alt)
|
||||
if zuwachs < 0:
|
||||
return None
|
||||
wert = zuwachs / dt
|
||||
return None if wert > RATE_MAX else wert
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Die Partner-Instanz (User-Entscheid 24.09.2026: zwei Instanzen, eine Oberfläche).
|
||||
|
||||
Box-Wart läuft auf der KI-Box, der Homelab-Teil in einem Container auf dem Proxmox-PC. Jede
|
||||
Instanz kennt die andere über MC_PARTNER_URL. Sie fragt deren Lebenszeichen ab (für den Wächter
|
||||
und die Kopfzeile der Oberfläche) und reicht Anfragen der Oberfläche unter /api/partner/… durch,
|
||||
damit eine Seite beide Bereiche zeigt — egal, auf welcher Instanz man sie öffnet.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from kern.einstellungen import einstellungen
|
||||
|
||||
ZEITLIMIT_S = 5.0
|
||||
CACHE_S = 15.0
|
||||
|
||||
_lock = threading.Lock()
|
||||
_cache: dict = {"ts": 0.0, "daten": None}
|
||||
|
||||
|
||||
def _kurz(exc: Exception) -> str:
|
||||
if isinstance(exc, httpx.TimeoutException):
|
||||
return "antwortet nicht rechtzeitig"
|
||||
if isinstance(exc, httpx.ConnectError):
|
||||
return "nicht erreichbar"
|
||||
if isinstance(exc, httpx.HTTPStatusError):
|
||||
return f"antwortet mit HTTP {exc.response.status_code}"
|
||||
return f"{exc.__class__.__name__}: {exc}"[:160]
|
||||
|
||||
|
||||
def status(frisch: bool = False) -> dict:
|
||||
"""Lebenszeichen der Partner-Instanz. Ohne MC_PARTNER_URL: {"eingerichtet": False}."""
|
||||
e = einstellungen()
|
||||
if not e.partner_url:
|
||||
return {"eingerichtet": False}
|
||||
with _lock:
|
||||
if not frisch and _cache["daten"] and time.time() - _cache["ts"] < CACHE_S:
|
||||
return dict(_cache["daten"])
|
||||
daten: dict = {"eingerichtet": True, "name": e.partner_name, "url": e.partner_url,
|
||||
"erreichbar": False, "rolle": None, "instanz": None, "version": None, "fehler": None}
|
||||
try:
|
||||
r = httpx.get(f"{e.partner_url}/api/health", timeout=ZEITLIMIT_S)
|
||||
r.raise_for_status()
|
||||
h = r.json()
|
||||
daten.update(erreichbar=True, rolle=h.get("rolle"), instanz=h.get("instanz"), version=h.get("version"))
|
||||
except Exception as exc:
|
||||
daten["fehler"] = _kurz(exc)
|
||||
daten["geprueft"] = time.time()
|
||||
with _lock:
|
||||
_cache.update(ts=time.time(), daten=daten)
|
||||
return dict(daten)
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Füllstands-Vorhersage „voll in etwa N Tagen“ (Ausbauplan Welle 1, seit 25.09.2026).
|
||||
|
||||
Die Schwellen bei 80 und 90 % melden sich erst, wenn es schon eng ist. Die Vorhersage schaut auf das Tempo: Wächst
|
||||
eine Belegung weiter wie zuletzt, wann ist sie voll? Gelb unter GELB_TAGE, rot unter ROT_TAGE Tagen — so bleibt Zeit,
|
||||
Platz zu schaffen, bevor etwas stillsteht.
|
||||
|
||||
Grundlage sind die 30-min-Mittel der letzten sieben Tage aus kern/messreihen.py (Belegung in %). Daraus werden
|
||||
Tagesmittel über 24-Stunden-Fenster (das jüngste endet jetzt); ein Fenster zählt ab sechs Stunden mit Messungen.
|
||||
Die Rate in Prozentpunkten am Tag:
|
||||
• Normalfall: die Steigung der Ausgleichsgeraden durch die Tagesmittel der Woche.
|
||||
• Das Wachstum stockt — der letzte Tag wuchs um weniger als ein Viertel der Wochenrate, etwa nach einem großen
|
||||
Download, der einmal kam: Dann gilt der letzte Tag (mindestens 0). Ein einzelner Sprung hält so keinen Alarm.
|
||||
• Es wird schneller — die letzten beiden Tage wuchsen beide stärker als die Woche, und in der Woche wurde nichts
|
||||
freigeräumt: Dann gilt ihr Mittel. Ein Protokoll, das gerade vollläuft, wartet so nicht eine Woche.
|
||||
Unter MIN_RATE gilt die Belegung als gleichbleibend. Ohne die beiden jüngsten Fenster und ein drittes (also rund zwei
|
||||
Tage Verlauf) oder ohne Messung in den letzten zwei Stunden gibt es keine Vorhersage.
|
||||
|
||||
„Voll“ ist nicht überall 100 %: Proxmox rechnet bei Containern und beim Host die für root reservierten Blöcke von
|
||||
ext4 (5 %) als belegt — dort ist für Dienste schon bei rund 95 % Schluss (AdGuard am 24.09.2026). Das gibt der
|
||||
Aufrufer mit (voll_bei).
|
||||
"""
|
||||
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
|
||||
from kern import messreihen
|
||||
|
||||
TAG_S = 86400
|
||||
FENSTER = 7 # Tagesfenster in der Woche
|
||||
MIN_WERTE = 12 # 30-min-Mittel je Fenster: sechs Stunden mit Messungen
|
||||
FRISCH_S = 2 * 3600 # so alt darf die jüngste Messung höchstens sein
|
||||
MIN_RATE = 0.05 # Prozentpunkte am Tag; darunter „gleichbleibend“
|
||||
STOCKT = 0.25 # der letzte Tag wuchs um weniger als dieser Anteil der Wochenrate
|
||||
FREIGERAEUMT = -0.5 # ein Tag, der um mehr als so viele Prozentpunkte sank, hat freigeräumt
|
||||
GELB_TAGE, ROT_TAGE = 14, 3
|
||||
MERKEN_S = 600 # je Reihe höchstens alle zehn Minuten neu rechnen (der Wächter fragt jede Minute)
|
||||
VOLL_EXT4 = 95.0 # Container und Proxmox-Host: siehe oben
|
||||
|
||||
_lock = threading.Lock()
|
||||
_merk: dict[tuple[str, str, float], tuple[float, dict]] = {}
|
||||
|
||||
|
||||
def tagesmittel(reihe: list, jetzt: float) -> dict[int, float]:
|
||||
"""Fenster k (0 = die letzten 24 Stunden, 1 = die 24 davor …) → Mittel der Werte darin. Nur Fenster mit
|
||||
mindestens MIN_WERTE Werten. reihe: [[t, wert], …] wie messreihen.lesen() sie liefert."""
|
||||
summen: dict[int, list[float]] = {}
|
||||
for t, wert in reihe:
|
||||
if not messreihen.ist_zahl(wert):
|
||||
continue
|
||||
k = int((jetzt - t) // TAG_S)
|
||||
if 0 <= k < FENSTER:
|
||||
summe = summen.setdefault(k, [0.0, 0])
|
||||
summe[0] += wert
|
||||
summe[1] += 1
|
||||
return {k: s[0] / s[1] for k, s in summen.items() if s[1] >= MIN_WERTE}
|
||||
|
||||
|
||||
def _steigung(punkte: list[tuple[float, float]]) -> float:
|
||||
"""Steigung der Ausgleichsgeraden (kleinste Quadrate)."""
|
||||
mx = sum(x for x, _ in punkte) / len(punkte)
|
||||
my = sum(y for _, y in punkte) / len(punkte)
|
||||
sxx = sum((x - mx) ** 2 for x, _ in punkte)
|
||||
return sum((x - mx) * (y - my) for x, y in punkte) / sxx if sxx else 0.0
|
||||
|
||||
|
||||
def vorhersage(reihe: list, jetzt: float, voll_bei: float = 100.0) -> dict:
|
||||
"""{"tage": Tage bis voll oder None, "rate": Prozentpunkte am Tag oder None, "anteil": jüngstes Mittel in % oder
|
||||
None, "grund": None | "verlauf" (zu wenig Verlauf) | "gleich" (wächst nicht)}."""
|
||||
werte = [(t, w) for t, w in reihe if messreihen.ist_zahl(w)]
|
||||
if not werte or jetzt - werte[-1][0] > FRISCH_S:
|
||||
return {"tage": None, "rate": None, "anteil": None, "grund": "verlauf"}
|
||||
anteil = float(werte[-1][1])
|
||||
mittel = tagesmittel(werte, jetzt)
|
||||
if 0 not in mittel or 1 not in mittel or len(mittel) < 3:
|
||||
return {"tage": None, "rate": None, "anteil": anteil, "grund": "verlauf"}
|
||||
woche = _steigung([(-k, m) for k, m in mittel.items()])
|
||||
zuletzt = mittel[0] - mittel[1]
|
||||
davor = mittel[1] - mittel[2] if 2 in mittel else None
|
||||
schritte = [mittel[k] - mittel[k + 1] for k in range(FENSTER - 1) if k in mittel and k + 1 in mittel]
|
||||
rate = woche
|
||||
if zuletzt < STOCKT * woche:
|
||||
rate = max(zuletzt, 0.0)
|
||||
elif davor is not None and zuletzt > woche and davor > woche and min(schritte) > FREIGERAEUMT:
|
||||
rate = (zuletzt + davor) / 2
|
||||
if rate < MIN_RATE:
|
||||
return {"tage": None, "rate": round(rate, 2), "anteil": anteil, "grund": "gleich"}
|
||||
return {"tage": round(max(0.0, (voll_bei - anteil) / rate), 1), "rate": round(rate, 2), "anteil": anteil,
|
||||
"grund": None}
|
||||
|
||||
|
||||
def aus_messreihe(quelle: str, name: str, voll_bei: float = 100.0, jetzt: float | None = None) -> dict:
|
||||
"""Die Vorhersage für eine Reihe aus kern/messreihen.py. Ohne jetzt (Betrieb) je Reihe höchstens alle MERKEN_S
|
||||
neu gerechnet; mit jetzt (Tests) immer frisch."""
|
||||
betrieb = jetzt is None
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
schluessel = (quelle, name, voll_bei)
|
||||
if betrieb:
|
||||
with _lock:
|
||||
gemerkt = _merk.get(schluessel)
|
||||
if gemerkt and 0 <= jetzt - gemerkt[0] < MERKEN_S:
|
||||
return gemerkt[1]
|
||||
ergebnis = vorhersage(messreihen.lesen(quelle, "7d", [name], jetzt)["reihen"][name], jetzt, voll_bei)
|
||||
if betrieb:
|
||||
with _lock:
|
||||
_merk[schluessel] = (jetzt, ergebnis)
|
||||
return ergebnis
|
||||
|
||||
|
||||
def stufe(v: dict | None) -> str:
|
||||
"""„rot“ unter ROT_TAGE, „gelb“ unter GELB_TAGE Tagen bis voll, sonst „ok“."""
|
||||
tage = (v or {}).get("tage")
|
||||
if tage is None:
|
||||
return "ok"
|
||||
return "rot" if tage < ROT_TAGE else "gelb" if tage < GELB_TAGE else "ok"
|
||||
|
||||
|
||||
def in_tagen(tage: float) -> str:
|
||||
"""„in weniger als einem Tag“, „in etwa 9 Tagen“, „in etwa 5 Wochen“ … — für Hinweise."""
|
||||
if tage < 1:
|
||||
return "in weniger als einem Tag"
|
||||
if tage < 1.5:
|
||||
return "in etwa einem Tag"
|
||||
if tage < 21:
|
||||
return f"in etwa {round(tage)} Tagen"
|
||||
if tage < 90:
|
||||
return f"in etwa {round(tage / 7)} Wochen"
|
||||
return f"in etwa {round(tage / 30)} Monaten" if tage < 365 else "frühestens in einem Jahr"
|
||||
|
||||
|
||||
def tempo(v: dict) -> str:
|
||||
"""„Die Belegung wächst um etwa 1,2 % am Tag (jetzt 78 %).“"""
|
||||
rate = f"{v['rate']:.1f}".replace(".", ",")
|
||||
return f"Die Belegung wächst um etwa {rate} % am Tag (jetzt {round(v['anteil'])} %)."
|
||||
|
||||
|
||||
def bis_voll(v: dict | None) -> float:
|
||||
"""Tage bis voll; unendlich, wenn es keine Vorhersage gibt."""
|
||||
return v["tage"] if v and v.get("tage") is not None else math.inf
|
||||
|
||||
|
||||
IM_TEXT_TAGE = 60 # weiter weg sagt ein Hinweis zum Füllstand nicht mehr dazu, wann es voll wäre
|
||||
|
||||
|
||||
def dazu(v: dict | None) -> str:
|
||||
"""„ Bei diesem Tempo voll in etwa 9 Tagen.“ — der Zusatz für Hinweise, die schon der Füllstand auslöst."""
|
||||
tage = bis_voll(v)
|
||||
return f" Bei diesem Tempo voll {in_tagen(tage)}." if tage < IM_TEXT_TAGE else ""
|
||||
|
||||
|
||||
RANG = {"ok": 0, "gelb": 1, "rot": 2}
|
||||
|
||||
|
||||
def schlimmer(stufe_jetzt: str | None, v: dict | None) -> tuple[str, bool]:
|
||||
"""Die ernstere Stufe aus Füllstand (None/„ok“ = unauffällig) und Vorhersage — und ob die Vorhersage sie bestimmt
|
||||
(dann gehört „voll in etwa N Tagen“ in den Titel)."""
|
||||
jetzt, kuenftig = stufe_jetzt or "ok", stufe(v)
|
||||
return (kuenftig, True) if RANG[kuenftig] > RANG.get(jetzt, 0) else (jetzt, False)
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Die eine Zeitzone für alles, was Zeiten anzeigt oder naive Zeiten deutet (Projektregel:
|
||||
nie datetime.now() ohne Zone). Vorher stand dieselbe Zeile in sieben Modulen."""
|
||||
|
||||
import os
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Ziele und Bausteine (Phase 2, 24.09.2026): ein Modell für alles, was Updates bekommt.
|
||||
|
||||
Ein Ziel ist ein Gerät: die KI-Box, der Proxmox-Host, ein Container, die Arcane-VM. Es besteht aus
|
||||
Bausteinen (Betriebssystem, Motor, die App im Container, Docker-Images …). Jeder Baustein sagt, wie
|
||||
sein Stand ist — neu, aktuell, unbekannt (mit Grund) oder festgehalten — und wie man ihn einspielt.
|
||||
|
||||
Die KI-Box meldet ihre Bausteine über den Box-Adapter (services/box_updates.py), der Homelab-Teil
|
||||
ab Phase 3 über Proxmox- und Arcane-Adapter. So wird aus beiden Instanzen EINE Liste offener Updates
|
||||
(User-Entscheid 24.09.: eine Anlaufstelle für Updates im ganzen Homelab).
|
||||
"""
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
Zustand = Literal["neu", "aktuell", "unbekannt", "festgehalten", "wird-geprueft"]
|
||||
# Reihenfolge für die Anzeige: was Aufmerksamkeit braucht, zuerst.
|
||||
_RANG = {"unbekannt": 0, "festgehalten": 1, "neu": 2, "wird-geprueft": 3, "aktuell": 4}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Aktion:
|
||||
"""Wie ein Update angestoßen wird: eine Schnittstelle der Instanz, die das Ziel pflegt."""
|
||||
methode: str # "POST"
|
||||
pfad: str # "/api/maintenance/engine-update"
|
||||
frage: str # Rückfrage vor dem Klick
|
||||
label: str = "Jetzt updaten"
|
||||
|
||||
|
||||
@dataclass
|
||||
class BausteinStand:
|
||||
id: str # "engine"
|
||||
name: str # "Motor (llama.cpp)"
|
||||
zustand: Zustand
|
||||
installiert: str | None = None
|
||||
verfuegbar: str | None = None
|
||||
kurz: str = "" # „10 Pakete", „b11160 → b11172", „743 Änderungen"
|
||||
grund: str | None = None # bei „unbekannt" und „festgehalten": warum
|
||||
aktion: Aktion | None = None # None = kein Knopf (aktuell, festgehalten, unbekannt)
|
||||
sicherheit: int | None = None # davon Sicherheitsupdates (seit 25.09.2026; None = unbekannt)
|
||||
uebersprungen: str | None = None # diese Version überspringt der User (seit 25.09.2026)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ZielStand:
|
||||
id: str # "ki-box"
|
||||
name: str # "KI-Box"
|
||||
art: str # "ki-box" | "proxmox-host" | "container" | "vm"
|
||||
instanz: str # welche Instanz es pflegt: "box" | "homelab"
|
||||
erreichbar: bool | None = None # None = nicht geprüft
|
||||
bausteine: list[BausteinStand] = field(default_factory=list)
|
||||
geprueft: float | None = None # Unix-Sekunden der letzten Prüfung
|
||||
rueckweg: str | None = None # „Sicherung vor jedem Lauf", „Snapshot", „nur Backup"
|
||||
rueckweg_art: str | None = None # Kurzform für die Oberfläche: „snapshot“, „sicherung“ oder „keiner“
|
||||
platte: dict | None = None # {"belegt", "gesamt"} in Bytes, wo bekannt (laufende Container)
|
||||
kennzahl: dict | None = None # eine Live-Zahl für die Kachel {"text", "zeit"?, "stufe"?} (seit 25.09.2026)
|
||||
|
||||
def als_dict(self) -> dict:
|
||||
daten = asdict(self)
|
||||
daten["bausteine"].sort(key=lambda b: _RANG.get(b["zustand"], 9))
|
||||
zaehlung: dict[str, int] = {}
|
||||
for b in self.bausteine:
|
||||
zaehlung[b.zustand] = zaehlung.get(b.zustand, 0) + 1
|
||||
daten["zaehlung"] = zaehlung
|
||||
daten["offen"] = zaehlung.get("neu", 0)
|
||||
return daten
|
||||
@@ -1,57 +0,0 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add backend directory to sys.path so we can import services
|
||||
sys.path.append(str(Path(__file__).resolve().parent))
|
||||
|
||||
from config import CONFIG_PATH
|
||||
from services.llamaswap import _PATH_RE, read_config, spec_draft_flags, write_config
|
||||
|
||||
|
||||
def migrate():
|
||||
print(f"Reading config from {CONFIG_PATH}...")
|
||||
if not CONFIG_PATH.exists():
|
||||
print(f"Config path {CONFIG_PATH} does not exist. Skipping.")
|
||||
return
|
||||
|
||||
cfg = read_config()
|
||||
models = cfg.get("models", {})
|
||||
|
||||
for name, spec in models.items():
|
||||
if not isinstance(spec, dict):
|
||||
continue
|
||||
cmd = spec.get("cmd", "")
|
||||
if not cmd:
|
||||
continue
|
||||
|
||||
print(f"Migrating model: {name}")
|
||||
|
||||
# 1. Defektes --prompt-cache/--prompt-cache-all entfernen (llama-CLI-Flags,
|
||||
# die llama-server ablehnt → Start scheitert). Caching macht llama-server
|
||||
# automatisch pro Slot.
|
||||
cmd = cmd.replace(" --prompt-cache-all", "").replace(" --prompt-cache", "")
|
||||
|
||||
# 2. Extract aliases/role
|
||||
aliases = spec.get("aliases", [])
|
||||
role = aliases[0] if aliases else None
|
||||
|
||||
# 3. Add parallel + (nur vocab-kompatibles) Speculative Decoding für fast/coder.
|
||||
# spec_draft_flags() prüft die Vocab-Kompatibilität und hängt --spec-type an;
|
||||
# ein inkompatibler Draft (z.B. qwen2.5 ↔ Qwen3.6) wird NICHT gesetzt.
|
||||
if role in ("fast", "coder"):
|
||||
if "--parallel" not in cmd:
|
||||
cmd = cmd.strip() + " --parallel 2"
|
||||
if "--spec-draft-model" not in cmd:
|
||||
target = mt.group(1) if (mt := _PATH_RE.search(cmd)) else ""
|
||||
cmd = cmd.strip() + spec_draft_flags(target)
|
||||
|
||||
# Update cmd
|
||||
from ruamel.yaml.scalarstring import LiteralScalarString
|
||||
spec["cmd"] = LiteralScalarString(cmd.strip() + "\n")
|
||||
|
||||
print(f"Writing updated config back to {CONFIG_PATH}...")
|
||||
write_config(cfg)
|
||||
print("Migration completed successfully!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate()
|
||||
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
Nächtlicher Lauf des Modell-Radars (Box-Wart, 09/2026) — gestartet von mc2-radar.timer um 00:30.
|
||||
|
||||
Warum ein eigener Einstieg: Der Test lädt ein bis zu ~85 GB großes Modell neben Lucys Hirn. Das
|
||||
darf nur nachts passieren und muss um 02:30 sicher vorbei sein, weil um 03:00 der NerdQuiz-
|
||||
Nachtlauf das Hirn braucht. Ablauf:
|
||||
|
||||
1. Suchen — höchstens einmal am Tag (Merkliste + Entdeckung).
|
||||
2. Offene Urteile nachholen („getestet“: damals fehlte die Vergleichsmessung).
|
||||
3. Testen — nur im Fenster 00:30–02:30 und höchstens ein neuer Kandidat pro Woche.
|
||||
|
||||
Die Messungen prüfen die Frist selbst. Hängt trotzdem etwas, zieht fünf Minuten nach der Frist die
|
||||
Notbremse: Kandidaten-Server und Download werden gestoppt, der Prozess endet hart. systemd
|
||||
(RuntimeMaxSec) ist die letzte Sicherung. Ist nichts fällig, endet der Lauf sofort mit Code 0.
|
||||
|
||||
Mit --nur-suche (seit 24.09.2026) nur Schritt 1, jederzeit: So startet der Knopf „Jetzt suchen“ die Suche als
|
||||
Auftrag (services/radar.suche_starten). Getestet wird dabei nichts.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from services import radar
|
||||
|
||||
logging.basicConfig(
|
||||
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
|
||||
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
||||
)
|
||||
log = logging.getLogger("radar_lauf")
|
||||
|
||||
NOTBREMSE_S = int(os.environ.get("MC_RADAR_NOTBREMSE_S", "300"))
|
||||
|
||||
|
||||
def _beim_beenden(signum, _frame) -> None:
|
||||
"""SIGTERM (systemd stop, RuntimeMaxSec) → normal auslaufen, damit der Kandidaten-Server gestoppt wird."""
|
||||
raise SystemExit(f"Signal {signum}")
|
||||
|
||||
|
||||
def _notbremse(frist: float) -> threading.Timer:
|
||||
def ziehen() -> None:
|
||||
log.error("Radar: Notbremse – %s s nach der Frist läuft noch etwas, der Lauf wird hart beendet.", NOTBREMSE_S)
|
||||
try:
|
||||
radar.notstopp()
|
||||
finally:
|
||||
os._exit(3)
|
||||
|
||||
bremse = threading.Timer(max(frist - time.time(), 0) + NOTBREMSE_S, ziehen)
|
||||
bremse.daemon = True
|
||||
bremse.start()
|
||||
return bremse
|
||||
|
||||
|
||||
NOTIFY = Path(__file__).resolve().parents[1] / "deploy" / "notify.sh"
|
||||
|
||||
|
||||
def melde_bestanden(test: dict) -> None:
|
||||
"""Ein bestandener Kandidat wartet auf eine Entscheidung — einmal Bescheid geben, sonst liegt er
|
||||
unbemerkt herum (Lehre der Pins vom September). notify.sh sammelt Nachtmeldungen für den Morgen."""
|
||||
k = radar.lade_stand()["kandidaten"].get(test.get("kandidat")) or {}
|
||||
rolle = "das Hirn" if test.get("rolle") == "hirn" else "den Coder"
|
||||
text = (f"{k.get('name') or test.get('kandidat')} hat den Nachttest für {rolle} bestanden. "
|
||||
f"{str(test.get('zusammenfassung') or '')[:300]} "
|
||||
"In MC2 unter Modelle kannst du ihn übernehmen oder verwerfen.")
|
||||
try:
|
||||
subprocess.run(["bash", str(NOTIFY), "-s", "[Modell-Radar]", text], timeout=120, check=False,
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
log.warning("Radar: Meldung über den bestandenen Kandidaten ging nicht raus.", exc_info=True)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
beginn = radar.jetzt()
|
||||
if radar.suche_faellig(beginn):
|
||||
try:
|
||||
ergebnis = radar.suche()
|
||||
log.info("Radar: Suche – %s neu, %s warten", len(ergebnis.get("neu") or []), ergebnis.get("wartend"))
|
||||
except Exception:
|
||||
log.warning("Radar: Suche fehlgeschlagen", exc_info=True)
|
||||
else:
|
||||
log.info("Radar: heute schon gesucht.")
|
||||
|
||||
if not radar.im_fenster(radar.jetzt()):
|
||||
log.info("Radar: außerhalb des Test-Fensters %s–%s, nichts zu testen.", radar.FENSTER_START,
|
||||
radar.FENSTER_ENDE)
|
||||
return 0
|
||||
|
||||
frist = radar.fenster_ende(radar.jetzt()).timestamp()
|
||||
signal.signal(signal.SIGTERM, _beim_beenden)
|
||||
bremse = _notbremse(frist)
|
||||
try:
|
||||
if (nachgeholt := radar.nachurteilen(frist)):
|
||||
log.info("Radar: %s offene Urteile nachgeholt.", nachgeholt)
|
||||
plan = radar.plane_test(radar.lade_stand(), radar.jetzt())
|
||||
if not plan.get("kandidat"):
|
||||
log.info("Radar: nichts fällig (%s).", plan.get("grund"))
|
||||
return 0
|
||||
log.info("Radar: teste %s bis spätestens %s.", plan.get("grund"), radar.FENSTER_ENDE)
|
||||
test = radar.teste(plan["kandidat"], frist)
|
||||
if test:
|
||||
log.info("Radar: %s – %s", test.get("ergebnis"), test.get("zusammenfassung"))
|
||||
if test.get("ergebnis") == "bestanden":
|
||||
melde_bestanden(test)
|
||||
return 0
|
||||
finally:
|
||||
bremse.cancel()
|
||||
|
||||
|
||||
def nur_suche() -> int:
|
||||
"""„Jetzt suchen“ aus der Oberfläche: nur die Suche, kein Test. Die letzte Zeile sagt das Ergebnis in Worten —
|
||||
die Auftragskarte zeigt sie. Code 1, wenn die Suche scheitert oder keine Quelle erreichbar war."""
|
||||
# Das Protokoll des Auftrags zeigt die Oberfläche: ohne eine Zeile je Hugging-Face-Abruf (samt signierter
|
||||
# Download-Adressen), nur die Schritte des Radars und das Ergebnis.
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
try:
|
||||
ergebnis = radar.suche()
|
||||
except Exception as exc:
|
||||
log.exception("Radar: Suche fehlgeschlagen")
|
||||
print(f"Die Suche ist gescheitert: {exc.__class__.__name__}: {exc}", flush=True)
|
||||
return 1
|
||||
neu = ergebnis.get("neu") or []
|
||||
quellen = ergebnis.get("quellen") or {}
|
||||
teile = [f"{len(neu)} neu ({', '.join(neu)})" if neu else "nichts Neues",
|
||||
f"{ergebnis.get('wartend', 0)} warten auf den Nachttest"]
|
||||
if not quellen.get("discover", True):
|
||||
teile.append("Hugging Face war nicht erreichbar")
|
||||
if not quellen.get("watchlist", True):
|
||||
teile.append("die Merkliste war nicht lesbar")
|
||||
print(f"Suche fertig: {', '.join(teile)}.", flush=True)
|
||||
return 0 if any(quellen.values()) else 1
|
||||
|
||||
|
||||
def melde_ergebnis(test: dict) -> None:
|
||||
"""Nach „Jetzt testen“ kommt jedes Ergebnis als Meldung — der Commander hat den Test selbst angestoßen."""
|
||||
if test.get("ergebnis") == "bestanden":
|
||||
melde_bestanden(test)
|
||||
return
|
||||
k = radar.lade_stand()["kandidaten"].get(test.get("kandidat")) or {}
|
||||
text = f"Test von {k.get('name') or test.get('kandidat')}: {str(test.get('zusammenfassung') or test.get('ergebnis'))[:400]}"
|
||||
try:
|
||||
subprocess.run(["bash", str(NOTIFY), "-s", "[Modell-Radar]", text], timeout=120, check=False,
|
||||
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
log.warning("Radar: Meldung zum Test ging nicht raus.", exc_info=True)
|
||||
|
||||
|
||||
def test_auf_knopfdruck(kid: str) -> int:
|
||||
"""„Jetzt testen“ (seit 25.09.2026, services/radar.test_starten): ein Kandidat, jetzt, mit eigener Frist von
|
||||
TAGTEST_H Stunden. Dieselben Leitplanken wie nachts: nur Warm-Set neben dem Kandidaten, Test-Wächter, Notbremse.
|
||||
Ob der Kandidat tagsüber überhaupt darf (Speicher mit Coder, Sperrzeit), hat test_starten geprüft; hier noch
|
||||
einmal, falls zwischen Knopf und Start Zeit verging."""
|
||||
k = radar.lade_stand()["kandidaten"].get(kid)
|
||||
testbar, grund = radar.jetzt_testbar(k or {})
|
||||
if not k or not testbar:
|
||||
print(f"Kein Test: {grund or 'den Kandidaten gibt es nicht mehr'}.", flush=True)
|
||||
return 1
|
||||
frist = time.time() + radar.TAGTEST_H * 3600
|
||||
signal.signal(signal.SIGTERM, _beim_beenden)
|
||||
bremse = _notbremse(frist)
|
||||
try:
|
||||
test = radar.teste(kid, frist, auf_knopfdruck=True)
|
||||
if test:
|
||||
print(f"{k.get('name')}: {test.get('ergebnis')} – {test.get('zusammenfassung')}", flush=True)
|
||||
melde_ergebnis(test)
|
||||
return 0
|
||||
finally:
|
||||
bremse.cancel()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
argumente = sys.argv[1:]
|
||||
if "--nur-suche" in argumente:
|
||||
sys.exit(nur_suche())
|
||||
if "--test" in argumente and argumente.index("--test") + 1 < len(argumente):
|
||||
sys.exit(test_auf_knopfdruck(argumente[argumente.index("--test") + 1]))
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,3 @@
|
||||
# Nur fürs Prüftor (deploy/pruefen.sh) — die Dienste selbst brauchen das nicht.
|
||||
# Gepinnt am 24.09.2026 auf den Stand im venv der Box (der Container installiert diese Datei nicht).
|
||||
pytest==9.1.1
|
||||
@@ -1,9 +1,16 @@
|
||||
fastapi>=0.115
|
||||
python-multipart>=0.0.9
|
||||
uvicorn[standard]>=0.30
|
||||
httpx>=0.27
|
||||
ruamel.yaml>=0.18
|
||||
psutil>=5.9
|
||||
huggingface_hub>=0.27
|
||||
mcp>=1.2.0
|
||||
tzdata>=2024.1
|
||||
# Gepinnt am 24.09.2026 auf das, was nachweislich läuft: `pip freeze` im venv der KI-Box (Python 3.14) und im
|
||||
# Homelab-Container 107 (/opt/mc2/backend/.venv, Python 3.13). Gleiche Version = „==“, sonst ein Bereich, der
|
||||
# beide Stände abdeckt — so installiert weder deploy.sh (Box) noch einrichten.sh (Container) etwas Neues.
|
||||
# Wer eine Grenze anhebt: vorher deploy/probelauf-box.sh, und die neue Version in beiden Umgebungen prüfen.
|
||||
fastapi>=0.139.0,<=0.141.1
|
||||
python-multipart==0.0.32
|
||||
uvicorn[standard]>=0.51.0,<=0.53.0
|
||||
httpx==0.28.1
|
||||
ruamel.yaml==0.19.1
|
||||
psutil==7.2.2
|
||||
huggingface_hub>=1.23.0,<=2.0.0
|
||||
# mcp braucht nur die MCP-Server in mcp/ (Hermes startet sie mit diesem venv, auf der Box). mcp 2.x hat FastMCP
|
||||
# entfernt — `from mcp.server.fastmcp import FastMCP` scheitert dort. Der Container hat 2.2.0, nutzt es aber nicht;
|
||||
# deshalb hier der Stand der Box (einrichten.sh zieht den Container beim nächsten Ausrollen auf 1.28.1).
|
||||
mcp==1.28.1
|
||||
tzdata>=2026.3,<=2026.4
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
"""Agent-Endpoint: Hermes-Status + WebUI-Link (MC verlinkt nur, betreibt nicht)."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from services.agent import agent_status, hermes_brain_info, set_agent_brain, update_brain_model
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
class BrainReq(BaseModel):
|
||||
model: str
|
||||
|
||||
|
||||
class SetBrainReq(BaseModel):
|
||||
model_id: str
|
||||
|
||||
|
||||
@router.get("/agent/status")
|
||||
def status() -> dict:
|
||||
return agent_status()
|
||||
|
||||
|
||||
@router.get("/agent/brain")
|
||||
def brain_info() -> dict:
|
||||
"""Aktuelles Agent-Hirn (hermes) + bestes NousResearch-Hermes-Update."""
|
||||
return hermes_brain_info()
|
||||
|
||||
|
||||
@router.post("/agent/brain/set")
|
||||
def set_brain(body: SetBrainReq) -> dict:
|
||||
"""Setzt ein installiertes Modell als Agent-Hirn (Alias + warm-Gruppe + Config)."""
|
||||
res = set_agent_brain(body.model_id)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("reason", "Fehler beim Setzen des Agent-Hirns"))
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/agent/brain")
|
||||
def set_brain_model(body: BrainReq) -> dict:
|
||||
ok = update_brain_model(body.model)
|
||||
return {"ok": ok}
|
||||
@@ -1,63 +0,0 @@
|
||||
"""Auftragsbuch-Endpoints (Vorschlags-Inbox) — dünner REST-Layer über services/auftragsbuch.py.
|
||||
LAN-only wie alle MC2-Endpoints; das Gate ist der Klick des Commanders."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from services import auftragsbuch
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
class BranchIn(BaseModel):
|
||||
branch: str
|
||||
repo: str = "mc2" # "mc2" (Box-Stack) oder "lucy" (Desktop-App, Annahme baut am PC)
|
||||
grund: str = "" # nur beim Ablehnen: optionaler Grund → Lern-Gedächtnis des Kreislaufs
|
||||
|
||||
|
||||
class KandidatIn(BaseModel):
|
||||
file: str
|
||||
|
||||
|
||||
@router.get("/auftragsbuch")
|
||||
def list_proposals() -> dict:
|
||||
return auftragsbuch.list_proposals()
|
||||
|
||||
|
||||
@router.get("/auftragsbuch/diff")
|
||||
def diff(branch: str, repo: str = "mc2") -> dict:
|
||||
res = auftragsbuch.diff_of(branch, repo)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "Diff nicht verfügbar."))
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/auftragsbuch/annehmen")
|
||||
def accept(body: BranchIn) -> dict:
|
||||
res = auftragsbuch.accept(body.branch, body.repo)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "Annehmen fehlgeschlagen."))
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/auftragsbuch/ablehnen")
|
||||
def reject(body: BranchIn) -> dict:
|
||||
res = auftragsbuch.reject(body.branch, body.repo, body.grund)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "Ablehnen fehlgeschlagen."))
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/auftragsbuch/skill/annehmen")
|
||||
def skill_accept(body: KandidatIn) -> dict:
|
||||
res = auftragsbuch.skill_accept(body.file)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "Beauftragen fehlgeschlagen."))
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/auftragsbuch/skill/ablehnen")
|
||||
def skill_reject(body: KandidatIn) -> dict:
|
||||
res = auftragsbuch.skill_reject(body.file)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "Verwerfen fehlgeschlagen."))
|
||||
return res
|
||||
@@ -0,0 +1,230 @@
|
||||
"""
|
||||
Box-Wart-Schnittstellen (Umbau 09/2026): alles, was der Cockpit-Startbildschirm braucht.
|
||||
|
||||
GET /api/start Zustand, Hinweise, Warnlampen, Box-Werte, Updates, Flugplan
|
||||
GET /api/hinweise Hinweise + Verlauf des Wächters
|
||||
POST /api/hinweise/{id}/aktion/{aktion} Knopf eines Hinweises ausführen
|
||||
GET /api/modelle/nutzung Wer nutzt die Modelle (7 Tage, 24 h je Stunde)
|
||||
GET /api/updates/verlauf Was die letzten Update-Läufe wirklich gebracht haben
|
||||
POST /api/updates/festgehalten/{b}/freigeben Festgehaltenen Baustein wieder freigeben
|
||||
GET /api/modelle/aufraeumen Was auf der Modell-Platte ungenutzt Platz belegt
|
||||
GET /api/ziele Die KI-Box als Ziel mit Bausteinen (gemeinsames Modell, Phase 2)
|
||||
POST /api/modelle/aufraeumen/loeschen Einen Kandidaten löschen (nur auf Knopfdruck)
|
||||
GET /api/aufraeumen/altreste Altreste neben dem Betrieb (feste Liste, nur was da ist)
|
||||
POST /api/aufraeumen/altreste/loeschen Einen Altrest löschen — per Kennung, nie per Pfad
|
||||
|
||||
Dünn: die Logik liegt in services/waechter.py, modell_nutzung.py, zeitplan.py, update_verlauf.py.
|
||||
"""
|
||||
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import psutil
|
||||
from config import MODELS_DIR
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from kern import prognose
|
||||
from kern.zeit import LOCAL_TZ
|
||||
from pydantic import BaseModel
|
||||
from services import (
|
||||
aufraeumen,
|
||||
backup,
|
||||
box_updates,
|
||||
llamaswap,
|
||||
messwerte,
|
||||
modell_nutzung,
|
||||
system,
|
||||
update_verlauf,
|
||||
waechter,
|
||||
zeitplan,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["boxwart"])
|
||||
|
||||
def _zeit_kurz(ts: float | None) -> str:
|
||||
if not ts:
|
||||
return "–"
|
||||
dt = datetime.fromtimestamp(ts, LOCAL_TZ)
|
||||
heute = datetime.now(LOCAL_TZ).date()
|
||||
if dt.date() == heute:
|
||||
return f"heute {dt:%H:%M}"
|
||||
if (heute - dt.date()).days == 1:
|
||||
return f"gestern {dt:%H:%M}"
|
||||
return f"{dt:%d.%m. %H:%M}"
|
||||
|
||||
|
||||
def _lampen(stand: dict, u: dict) -> list[dict]:
|
||||
ids = {h.get("id", "") for h in stand["hinweise"]}
|
||||
versionen = system.check_versions_cached()
|
||||
|
||||
def lampe(lid: str, label: str, zustand: str, wert: str) -> dict:
|
||||
return {"id": lid, "label": label, "zustand": zustand, "wert": wert}
|
||||
|
||||
engine_ok = llamaswap.engine_reachable()
|
||||
build = (versionen.get("engine") or {}).get("version_text", "")
|
||||
lampen = [lampe("motor", "Motor", "ok" if engine_ok else "fehler", build if engine_ok else "antwortet nicht")]
|
||||
|
||||
hirn = llamaswap.brain_status() if engine_ok else {}
|
||||
lampen.append(lampe("hirn", "Hirn", "ok" if hirn.get("ready") else "fehler",
|
||||
"geladen" if hirn.get("ready") else "lädt nicht"))
|
||||
|
||||
laufend = set(llamaswap.get_running_models()) if engine_ok else set()
|
||||
coder = next((m for m in llamaswap.list_models() if "coder" in (m.get("aliases") or [])), None)
|
||||
coder_an = bool(coder and coder.get("name") in laufend)
|
||||
lampen.append(lampe("coder", "Coder", "ok" if coder_an else "aus", "geladen" if coder_an else "auf Abruf"))
|
||||
|
||||
hermes_weg = any(i in ids for i in ("kern:hermes", "dienst:hermes-gateway"))
|
||||
lampen.append(lampe("hermes", "Hermes", "fehler" if hermes_weg else "ok",
|
||||
"antwortet nicht" if hermes_weg else "läuft"))
|
||||
|
||||
job_hinweise = [h for h in stand["hinweise"] if h.get("id", "").startswith(("job:", "timer:"))]
|
||||
rot = any(h.get("stufe") == "rot" for h in job_hinweise)
|
||||
lampen.append(lampe("jobs", "Jobs", "fehler" if rot else ("warn" if job_hinweise else "ok"),
|
||||
f"{len(job_hinweise)} Hinweis{'e' if len(job_hinweise) != 1 else ''}"
|
||||
if job_hinweise else "alles gelaufen"))
|
||||
|
||||
try:
|
||||
sicherungen = backup.list_backups()
|
||||
except Exception:
|
||||
sicherungen = []
|
||||
letzte = max((s.get("mtime") or s.get("ts") or 0 for s in sicherungen), default=0)
|
||||
zu_alt = not letzte or time.time() - letzte > 36 * 3600
|
||||
lampen.append(lampe("sicherung", "Sicherung", "warn" if zu_alt else "ok", _zeit_kurz(letzte)))
|
||||
|
||||
try:
|
||||
platte = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else ".").percent
|
||||
except Exception:
|
||||
platte = None
|
||||
zustand = "ok" if platte is None or platte < waechter.DISK_GELB_PCT else (
|
||||
"warn" if platte < waechter.DISK_ROT_PCT else "fehler")
|
||||
lampen.append(lampe("platte", "Platte", zustand, f"{platte:.0f} %" if platte is not None else "–"))
|
||||
|
||||
n = len(box_updates.bausteine(u))
|
||||
fest = len(update_verlauf.festgehalten())
|
||||
unklar = len(box_updates.unklar(u))
|
||||
if fest:
|
||||
lampen.append(lampe("updates", "Updates", "warn", f"{fest} festgehalten"))
|
||||
elif not box_updates.gelesen():
|
||||
lampen.append(lampe("updates", "Updates", "aus", "wird geprüft"))
|
||||
elif n:
|
||||
lampen.append(lampe("updates", "Updates", "info", f"{n} bereit"))
|
||||
elif unklar:
|
||||
lampen.append(lampe("updates", "Updates", "warn", "Prüfung unklar"))
|
||||
else:
|
||||
lampen.append(lampe("updates", "Updates", "ok", "aktuell"))
|
||||
return lampen
|
||||
|
||||
|
||||
def _box() -> dict:
|
||||
p = system.metrik_punkt()
|
||||
try:
|
||||
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else ".")
|
||||
# Seit 25.09.2026 mit „voll in etwa N Tagen“ aus dem Verlauf der Woche (kern/prognose.py).
|
||||
platte = {"used": du.used, "total": du.total, "percent": du.percent,
|
||||
"vorhersage": prognose.aus_messreihe(messwerte.QUELLE, "platte")}
|
||||
except Exception:
|
||||
platte = None
|
||||
return {"ram_used": p.get("ram_used"), "ram_total": p.get("ram_total"),
|
||||
"temp_cpu": p.get("temp_cpu"), "temp_gpu": p.get("temp_gpu"),
|
||||
"platte": platte, "uptime_s": p.get("uptime_s")}
|
||||
|
||||
|
||||
@router.get("/start")
|
||||
def start() -> dict:
|
||||
stand = waechter.lese_stand()
|
||||
u = box_updates.gecacht()
|
||||
# Stumm geschaltete Hinweise (seit 25.09.2026) stehen in der Liste, lösen aber die Leuchte nicht aus.
|
||||
laut = [h for h in stand["hinweise"] if h.get("zustand") != "stumm"]
|
||||
rot = sum(1 for h in laut if h.get("stufe") == "rot")
|
||||
anzahl = len(laut)
|
||||
wach = bool(stand["stand"]) and time.time() - float(stand["stand"] or 0) < 5 * 60
|
||||
return {
|
||||
"zustand": {
|
||||
"stufe": "rot" if rot else ("gelb" if anzahl else "ok"),
|
||||
"anzahl": anzahl,
|
||||
"waechter_wach": wach,
|
||||
"stand": stand["stand"],
|
||||
"update_laeuft": stand["update_laeuft"],
|
||||
},
|
||||
"hinweise": stand["hinweise"],
|
||||
"verlauf": stand["verlauf"][:20],
|
||||
"lampen": _lampen(stand, u),
|
||||
"box": _box(),
|
||||
"updates": {"bausteine": box_updates.bausteine(u), "unklar": box_updates.unklar(u),
|
||||
"festgehalten": update_verlauf.festgehalten(),
|
||||
"gelesen": box_updates.gelesen()},
|
||||
"flugplan": zeitplan.flugplan(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/hinweise")
|
||||
def hinweise() -> dict:
|
||||
return waechter.lese_stand()
|
||||
|
||||
|
||||
@router.post("/hinweise/{hinweis_id}/aktion/{aktion_id}")
|
||||
def hinweis_aktion(hinweis_id: str, aktion_id: str) -> dict:
|
||||
ergebnis = waechter.fuehre_aktion_aus(hinweis_id, aktion_id)
|
||||
if ergebnis.get("detail") and not ergebnis.get("ok"):
|
||||
raise HTTPException(status_code=409, detail=ergebnis["detail"])
|
||||
return ergebnis
|
||||
|
||||
|
||||
@router.get("/modelle/nutzung")
|
||||
def nutzung() -> dict:
|
||||
return modell_nutzung.nutzung()
|
||||
|
||||
|
||||
@router.get("/updates/verlauf")
|
||||
def updates_verlauf(anzahl: int = 8) -> dict:
|
||||
"""Die letzten Update-Läufe mit dem Ergebnis je Baustein (aus dem Meldeprotokoll der Box)."""
|
||||
return {"laeufe": update_verlauf.laeufe(max(1, min(anzahl, 30)))}
|
||||
|
||||
|
||||
@router.post("/updates/festgehalten/{baustein}/freigeben")
|
||||
def festgehalten_freigeben(baustein: str) -> dict:
|
||||
if not update_verlauf.freigeben(baustein):
|
||||
raise HTTPException(status_code=404, detail="Dieser Baustein ist nicht festgehalten.")
|
||||
return {"ok": True, "text": update_verlauf.FREIGEGEBEN_TEXT}
|
||||
|
||||
|
||||
class LoeschAuftrag(BaseModel):
|
||||
art: str # "eintrag" | "ordner"
|
||||
name: str
|
||||
|
||||
|
||||
@router.get("/modelle/aufraeumen")
|
||||
def aufraeumen_liste() -> dict:
|
||||
return {"kandidaten": aufraeumen.kandidaten()}
|
||||
|
||||
|
||||
@router.post("/modelle/aufraeumen/loeschen")
|
||||
def aufraeumen_loeschen(auftrag: LoeschAuftrag) -> dict:
|
||||
ergebnis = aufraeumen.loeschen(auftrag.art, auftrag.name)
|
||||
if not ergebnis.get("ok"):
|
||||
raise HTTPException(status_code=409, detail=ergebnis.get("detail") or "Löschen ging nicht.")
|
||||
return ergebnis
|
||||
|
||||
|
||||
class AltrestAuftrag(BaseModel):
|
||||
id: str # Kennung aus der festen Liste (services/aufraeumen.ALTRESTE), nie ein Pfad
|
||||
|
||||
|
||||
@router.get("/aufraeumen/altreste")
|
||||
def altreste_liste() -> dict:
|
||||
"""Altreste neben dem Betrieb (Test-Ordner, alte Umgebungen, Worktrees …), nur was da und nicht in Gebrauch ist."""
|
||||
return {"altreste": aufraeumen.altreste()}
|
||||
|
||||
|
||||
@router.post("/aufraeumen/altreste/loeschen")
|
||||
def altrest_loeschen(auftrag: AltrestAuftrag) -> dict:
|
||||
ergebnis = aufraeumen.altrest_loeschen(auftrag.id)
|
||||
if not ergebnis.get("ok"):
|
||||
raise HTTPException(status_code=409, detail=ergebnis.get("detail") or "Löschen ging nicht.")
|
||||
return ergebnis
|
||||
|
||||
|
||||
@router.get("/ziele")
|
||||
def ziele() -> dict:
|
||||
"""Die KI-Box im gemeinsamen Ziel-Modell (kern/ziele.py). Der Homelab-Teil liefert seine Ziele ab
|
||||
Phase 3 unter /api/homelab/ziele; die Oberfläche legt beide zu einer Update-Liste zusammen."""
|
||||
return {"ziele": [box_updates.ki_box_ziel().als_dict()]}
|
||||
@@ -1,28 +0,0 @@
|
||||
"""Chronik — die lesbare Timeline dessen, was die Box von allein getan und gemeldet hat.
|
||||
Quelle ist der persistente Melde-Briefkasten (services/announce.py): Health-Wächter,
|
||||
Auto-Updates, Erinnerungen, Radar/Traum/Chef-Gutachter-Crons, Auftragsbuch — alle
|
||||
autonomen Kanäle laufen dort bereits durch. Hier wird nichts Neues erhoben, nur erzählt."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
from services import announce
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
class ChronikItem(BaseModel):
|
||||
id: int
|
||||
ts: float
|
||||
subject: str
|
||||
text: str
|
||||
source: str
|
||||
priority: str
|
||||
|
||||
|
||||
class ChronikResponse(BaseModel):
|
||||
items: list[ChronikItem]
|
||||
|
||||
|
||||
@router.get("/chronik", response_model=ChronikResponse)
|
||||
def chronik(limit: int = 150) -> dict:
|
||||
return {"items": announce.list_recent(limit)}
|
||||
@@ -1,20 +0,0 @@
|
||||
"""Connect-Endpoint: erzeugt IDE-/Agent-Snippets (auf den Gateway + Memory-MCP)."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from services.connect import DEFAULT_HOST, build_snippets, check_health
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
@router.get("/connect")
|
||||
def connect(host: str = DEFAULT_HOST, mcp_path: str | None = None) -> dict:
|
||||
kwargs = {}
|
||||
if mcp_path:
|
||||
kwargs["mcp_script_path"] = mcp_path
|
||||
return build_snippets(host=host, **kwargs)
|
||||
|
||||
|
||||
@router.get("/connect/health")
|
||||
def connect_health() -> dict:
|
||||
"""Live-Status der zwei Leitungen (Gateway + Gedächtnis) für den Verbinden-Tab."""
|
||||
return check_health()
|
||||
@@ -1,87 +0,0 @@
|
||||
"""
|
||||
ttyd-Reverse-Proxy für die eingebetteten Web-Terminals (Box-Konsole + Hermes-Terminal).
|
||||
|
||||
Beide ttyd-Dienste binden NUR an Loopback (--base-path /<name>) und werden hier über
|
||||
den ohnehin offenen MC2-Port (9001) same-origin durchgereicht — HTTP (index.html/token)
|
||||
+ WebSocket (/<name>/ws). Vorteil: keine eigene Firewall-Freigabe je Terminal nötig und
|
||||
alles läuft same-origin zum Dashboard. Reiner Byte-Durchreicher; ttyds `tty`-Subprotokoll
|
||||
wird auf beiden Seiten ausgehandelt. Der Login-Schutz sitzt IM Terminal (ttyd startet die
|
||||
Shell über `su` → fragt das Box-Passwort ab), nicht im Proxy.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
from config import BOX_CONSOLE_UPSTREAM
|
||||
from fastapi import APIRouter, Request, WebSocket
|
||||
from starlette.responses import Response
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _register(base: str, upstream: str) -> None:
|
||||
"""Registriert HTTP- + WS-Proxy-Routen für eine ttyd-Instanz (base-path `/<base>`)."""
|
||||
ws_upstream = upstream.replace("http://", "ws://").replace("https://", "wss://")
|
||||
|
||||
@router.websocket(f"/{base}/ws")
|
||||
async def _proxy_ws(ws: WebSocket) -> None:
|
||||
await ws.accept(subprotocol="tty")
|
||||
try:
|
||||
async with websockets.connect(
|
||||
f"{ws_upstream}/{base}/ws", subprotocols=["tty"],
|
||||
open_timeout=10, max_size=None, ping_interval=None,
|
||||
) as up:
|
||||
async def c2u() -> None:
|
||||
while True:
|
||||
msg = await ws.receive()
|
||||
if msg.get("type") == "websocket.disconnect":
|
||||
return
|
||||
if (t := msg.get("text")) is not None:
|
||||
await up.send(t)
|
||||
elif (b := msg.get("bytes")) is not None:
|
||||
await up.send(b)
|
||||
|
||||
async def u2c() -> None:
|
||||
async for m in up:
|
||||
if isinstance(m, (bytes, bytearray)):
|
||||
await ws.send_bytes(bytes(m))
|
||||
else:
|
||||
await ws.send_text(m)
|
||||
|
||||
_done, pending = await asyncio.wait(
|
||||
[asyncio.create_task(c2u()), asyncio.create_task(u2c())],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
except Exception:
|
||||
log.debug("ttyd-proxy ws (%s) beendet/fehlgeschlagen", base, exc_info=True)
|
||||
finally:
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _proxy_http(request: Request, path: str = "") -> Response:
|
||||
url = f"{upstream}/{base}/{path}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as c:
|
||||
r = await c.request(request.method, url, content=await request.body())
|
||||
return Response(content=r.content, status_code=r.status_code,
|
||||
media_type=r.headers.get("content-type"))
|
||||
except httpx.HTTPError as exc:
|
||||
log.debug("ttyd-proxy http (%s) nicht erreichbar: %s", base, exc)
|
||||
return Response(content=b"Terminal (ttyd) nicht erreichbar.", status_code=502,
|
||||
media_type="text/plain")
|
||||
|
||||
router.add_api_route(f"/{base}", _proxy_http, methods=["GET"], name=f"{base}_root")
|
||||
router.add_api_route(f"/{base}/{{path:path}}", _proxy_http, methods=["GET", "POST"],
|
||||
name=f"{base}_path")
|
||||
|
||||
|
||||
# Box-Konsole (Login-Shell) — Loopback-ttyd. (Das Hermes-Terminal-ttyd ist mit dem
|
||||
# Umzug auf Hermes Desktop entfallen; die Agent-Oberfläche ist die Desktop-App bzw. /hermes-ui/.)
|
||||
_register("console", BOX_CONSOLE_UPSTREAM)
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Einstellungen der Oberfläche (seit 24.09.2026).
|
||||
|
||||
KI-Box (Rolle box):
|
||||
GET /api/einstellungen Update-Zeitfenster und Radar-Schalter samt Zustand laut systemd
|
||||
POST /api/einstellungen/update-fenster {tag: 1–7, uhrzeit: "HH:MM"}
|
||||
POST /api/einstellungen/radar {an: true|false}
|
||||
POST /api/einstellungen/telegram-test Testmeldung über notify.sh, Ergebnis aus dem Melde-Log
|
||||
Homelab-Teil (Rolle homelab):
|
||||
POST /api/homelab/einstellungen/telegram-test dasselbe für den Meldeweg des Homelab-Teils
|
||||
|
||||
Die Oberfläche schickt alles unter /api/homelab/… an den Homelab-Teil und den Rest an die KI-Box (lib/instanz.ts) —
|
||||
so erreicht jeder Test-Knopf die richtige Instanz, egal welche die Seite ausliefert. Dünn: die Logik liegt in
|
||||
services/einstellungen.py.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from services import einstellungen
|
||||
|
||||
router = APIRouter(prefix="/api/einstellungen", tags=["einstellungen"])
|
||||
homelab_router = APIRouter(prefix="/api/homelab/einstellungen", tags=["einstellungen"])
|
||||
|
||||
|
||||
class Zeitfenster(BaseModel):
|
||||
tag: int
|
||||
uhrzeit: str
|
||||
|
||||
|
||||
class Schalter(BaseModel):
|
||||
an: bool
|
||||
|
||||
|
||||
def _antwort(ergebnis: dict) -> dict:
|
||||
if not ergebnis.get("ok"):
|
||||
raise HTTPException(status_code=409, detail=ergebnis.get("detail") or "Das ging gerade nicht.")
|
||||
return ergebnis
|
||||
|
||||
|
||||
@router.get("")
|
||||
def stand() -> dict:
|
||||
return einstellungen.stand()
|
||||
|
||||
|
||||
@router.post("/update-fenster")
|
||||
def update_fenster(fenster: Zeitfenster) -> dict:
|
||||
return _antwort(einstellungen.update_fenster_setzen(fenster.tag, fenster.uhrzeit))
|
||||
|
||||
|
||||
@router.post("/radar")
|
||||
def radar(schalter: Schalter) -> dict:
|
||||
return _antwort(einstellungen.radar_schalten(schalter.an))
|
||||
|
||||
|
||||
@router.post("/telegram-test")
|
||||
def telegram_test() -> dict:
|
||||
"""Gesendet oder nicht, beides ist eine Antwort (200) — der Grund steht im Ergebnis."""
|
||||
return einstellungen.telegram_test()
|
||||
|
||||
|
||||
@homelab_router.post("/telegram-test")
|
||||
def telegram_test_homelab() -> dict:
|
||||
return einstellungen.telegram_test()
|
||||
+73
-45
@@ -1,25 +1,26 @@
|
||||
"""SSE-Eventstrom (UMBAU v3 P3a) — ein Kanal sagt der Zentrale, WANN neu laden lohnt.
|
||||
"""Ereignisstrom — ein Kanal sagt der Zentrale, WANN neu laden lohnt, und schickt Metriken.
|
||||
|
||||
GET /api/events liefert Server-Sent Events. Ein Sammler prüft alle paar Sekunden
|
||||
billige Fingerabdrücke der ereignishaften Quellen und schickt NUR bei Änderung ein
|
||||
`invalidate`-Event mit den React-Query-Keys. Die Wahrheit bleibt in den bestehenden
|
||||
Endpunkten — der Strom ist ein reiner Invalidation-Bus, kein zweites Zustandsmodell.
|
||||
GET /api/stream (v3-Umbau P4, 28.08.2026) — zwei Ereignisarten:
|
||||
· `invalidate` Nur bei Änderung, mit den betroffenen React-Query-Schlüsseln.
|
||||
· `metrik` Jede Sekunde ein Messpunkt (CPU/RAM/GPU/Temp/Token-Zähler).
|
||||
|
||||
Quellen: Briefkasten/Chronik (in-process-Cursor), Ideen-Queue ((id,status)-Paare),
|
||||
Auftragsbuch + Erinnerungen (Datei-mtimes), geladene Modelle (Running-Set — Idee aus
|
||||
der Werkstatt-Karte feature/sse-backend-v1). BEWUSST NICHT dabei: System-/Token-
|
||||
Metriken (ändern sich jede Sekunde — da ist Polling das richtige Werkzeug und ein
|
||||
invalidate-Event nur Lärm).
|
||||
Der alte Kanal /api/events ist mit dem Box-Wart-Umbau (09/2026) entfallen.
|
||||
|
||||
Versöhnt 15.07. abends: Die angenommene Werkstatt-Version nutzte `type:` statt
|
||||
`event:` (ungültiges SSE-Framing → EventSource-Listener feuert NIE), einen globalen
|
||||
Snapshot über alle Clients und einen nicht existierenden Ideen-Endpunkt — Kern
|
||||
wieder die getestete Hand-Implementierung (E2E: Announce → invalidate binnen
|
||||
Sekunden), Modell-Quelle aus der Karte übernommen.
|
||||
WARUM METRIKEN JETZT MITKOMMEN: Bis P4 pollte das Frontend `/api/system/status` und
|
||||
`/api/system/token-stats` im 3-Sekunden-Takt — zwei Dauer-Anfragen, unabhängig davon, ob
|
||||
sich etwas geändert hat, plus sechs weitere langsamere Poller auf der Startseite. Der
|
||||
Messpunkt kostet hier 0,2 ms (gemessen); `system_status()` würde 100 ms kosten, weil
|
||||
`psutil.cpu_percent(interval=0.1)` wartet. Deshalb der eigene, leichte `metrik_punkt()`.
|
||||
|
||||
Frontend-Gegenstück: frontend/src/lib/events.ts (EventSource, invalidiert die
|
||||
Caches, entspannt die Fallback-Poller ×5; reißt der Strom, reconnectet EventSource
|
||||
selbst und bis dahin pollt die UI wie bisher).
|
||||
WAS BEWUSST NICHT DRIN IST: Ein `agent`-Thema für Lucys Denkschritte. MC2 kann Hermes'
|
||||
interne Schritte nicht sehen, ohne dessen Quellcode zu patchen — und das ist per AGENTS.md
|
||||
verboten. Eine leere Leitung zu bauen, wäre eine Zusage, die keiner einlöst.
|
||||
|
||||
Die Wahrheit bleibt in den bestehenden Endpunkten: `invalidate` ist ein reiner
|
||||
Anstoß-Bus, kein zweites Zustandsmodell. `metrik` ist die einzige Ausnahme — es ist der
|
||||
Wert selbst, weil ein Anstoß für eine Zahl, die sich jede Sekunde ändert, nur Lärm wäre.
|
||||
|
||||
Frontend-Gegenstück: frontend/src/lib/events.ts
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -27,7 +28,6 @@ import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from config import MODELS_DIR
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
@@ -35,7 +35,8 @@ log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
TICK_S = 3.0 # Prüf-Takt des Sammlers (nur Fingerabdrücke, kein Neuberechnen)
|
||||
METRIK_S = 1.0 # Takt der Messpunkte
|
||||
ABDRUCK_S = 3.0 # Takt der Änderungs-Prüfung (nur Fingerabdrücke, kein Neuberechnen)
|
||||
KEEPALIVE_S = 20.0 # Kommentar-Ping, damit Proxies/Browser die Verbindung halten
|
||||
|
||||
|
||||
@@ -50,15 +51,9 @@ def _fingerprints() -> dict[str, object]:
|
||||
"""Billige Änderungs-Signale je Quelle → React-Query-Key. Fehler einer Quelle
|
||||
dürfen den Strom nie reißen (dann bleibt ihr Abdruck einfach stehen)."""
|
||||
fp: dict[str, object] = {}
|
||||
try: # Briefkasten trägt Chronik UND Kontext-Limit-Warnungen — in-process, spottbillig
|
||||
from services import announce
|
||||
fp["chronik"] = announce.list_after(None)["latest"]
|
||||
except Exception:
|
||||
pass
|
||||
try: # Queue: (id,status)-Paare; list_queue cached selbst ~15 s, der Tick kostet nichts
|
||||
from services import ideen
|
||||
d = ideen.list_queue()
|
||||
fp["ideen"] = json.dumps([(i.get("id"), i.get("status")) for i in d.get("items") or []])
|
||||
try: # Wächter-Stand (mc2-steward schreibt ihn jede Minute): Hinweise → Startseite neu laden
|
||||
from services import waechter
|
||||
fp["start"] = _mtime(waechter.STORE_PATH)
|
||||
except Exception:
|
||||
pass
|
||||
try: # Geladene Modelle (Running-Set): Laden/Entladen soll die Modelle-Ansicht anstoßen
|
||||
@@ -66,35 +61,68 @@ def _fingerprints() -> dict[str, object]:
|
||||
fp["models"] = json.dumps(sorted(str(m) for m in llamaswap.get_running_models()))
|
||||
except Exception:
|
||||
pass
|
||||
# Auftragsbuch (Annahme-Status + Karten-Meldungen) & Erinnerungen: Datei-mtimes
|
||||
fp["auftragsbuch"] = (_mtime(MODELS_DIR / "mc2-auftragsbuch.json"),
|
||||
_mtime(MODELS_DIR / "mc2-announce-branches.json"))
|
||||
fp["reminders"] = _mtime(MODELS_DIR / "mc2-reminders.json")
|
||||
try: # Jobs (Downloads, Wartung): Zustand + Fortschritt — spart den 3-s-Poller der Schublade
|
||||
from services import jobengine
|
||||
fp["jobs"] = json.dumps(
|
||||
[(j.get("id"), j.get("state"), j.get("progress")) for j in jobengine.public_jobs(mit_log=False)]
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return fp
|
||||
|
||||
|
||||
@router.get("/events")
|
||||
async def events(request: Request) -> StreamingResponse:
|
||||
async def strom():
|
||||
# Basislinie JE VERBINDUNG (der Client hat beim Verbinden frisch geladen) —
|
||||
# ein globaler Snapshot würde bei mehreren Clients Events verschlucken.
|
||||
alt = _fingerprints()
|
||||
async def _strom(request: Request, mit_metrik: bool):
|
||||
"""Gemeinsamer Kern beider Endpunkte.
|
||||
|
||||
Die Basislinie entsteht JE VERBINDUNG (der Client hat beim Verbinden frisch geladen) —
|
||||
ein globaler Snapshot würde bei mehreren Clients Events verschlucken.
|
||||
"""
|
||||
# Die Abdrücke fragen u. a. llama-swap ab (bis 2 s). Im Event-Loop blockierte das jede andere
|
||||
# Anfrage dieses Prozesses, auch Lucys /v1-Weiterleitung — seit 24.09.2026 im Thread.
|
||||
alt = await asyncio.to_thread(_fingerprints)
|
||||
yield ": verbunden\n\n"
|
||||
|
||||
seit_abdruck = 0.0
|
||||
seit_ping = 0.0
|
||||
takt = METRIK_S if mit_metrik else ABDRUCK_S
|
||||
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
return
|
||||
await asyncio.sleep(TICK_S)
|
||||
seit_ping += TICK_S
|
||||
neu = _fingerprints()
|
||||
await asyncio.sleep(takt)
|
||||
seit_abdruck += takt
|
||||
seit_ping += takt
|
||||
|
||||
if mit_metrik:
|
||||
try:
|
||||
from services.system import metrik_punkt
|
||||
punkt = await asyncio.to_thread(metrik_punkt)
|
||||
yield f"event: metrik\ndata: {json.dumps(punkt)}\n\n"
|
||||
seit_ping = 0.0
|
||||
except Exception:
|
||||
# Ein kaputter Messpunkt darf den Strom nicht reißen — die Ansicht fällt
|
||||
# dann auf ihre Poller zurück, das ist besser als eine tote Leitung.
|
||||
log.warning("Messpunkt fehlgeschlagen", exc_info=True)
|
||||
|
||||
if seit_abdruck >= ABDRUCK_S:
|
||||
seit_abdruck = 0.0
|
||||
neu = await asyncio.to_thread(_fingerprints)
|
||||
keys = [k for k, v in neu.items() if k in alt and v != alt[k]]
|
||||
alt.update(neu)
|
||||
if keys:
|
||||
yield f"event: invalidate\ndata: {json.dumps({'keys': keys})}\n\n"
|
||||
seit_ping = 0.0
|
||||
elif seit_ping >= KEEPALIVE_S:
|
||||
|
||||
if seit_ping >= KEEPALIVE_S:
|
||||
yield ": ping\n\n"
|
||||
seit_ping = 0.0
|
||||
|
||||
return StreamingResponse(strom(), media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
||||
|
||||
_KOPF = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
|
||||
|
||||
|
||||
@router.get("/stream")
|
||||
async def stream(request: Request) -> StreamingResponse:
|
||||
"""Anstöße UND Messpunkte."""
|
||||
return StreamingResponse(_strom(request, mit_metrik=True),
|
||||
media_type="text/event-stream", headers=_KOPF)
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from collections import OrderedDict
|
||||
|
||||
import httpx
|
||||
from config import LLAMA_SWAP_URL
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from services.gateway_stream import record_stream_chunk, record_usage, warn_truncation
|
||||
from services.router_logic import IMAGE_PART_TYPES, VISION_CAPABLE, choose_for_lane, has_image
|
||||
from services.router_logic import VISION_CAPABLE, bild_ziel, bilder_aufteilen, choose_for_lane, has_image
|
||||
from services.routing_policy import load_policy
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -22,8 +25,15 @@ _LANG_DIRECTIVE = os.environ.get(
|
||||
"Quellcode, Bezeichner und Shell-Befehle bleiben unverändert.")
|
||||
|
||||
|
||||
# Bild-Beschreibung für Coder-Ziele: Prompt bewusst auf wörtliche Wiedergabe von
|
||||
# Code/Fehlermeldungen getrimmt — der Coder arbeitet nur mit diesem Text weiter.
|
||||
# Deckel für die Bild-Beschreibungen. Ohne ihn wächst die Wartezeit linear mit der Bildzahl:
|
||||
# je Bild bis zu 2 Versuche à _BILD_TIMEOUT_S, und der Client hängt so lange am offenen
|
||||
# Request. Sind mehr als _BILD_MAX Bilder NEU zu beschreiben, bleiben alle Bilder drin und die
|
||||
# Anfrage geht an den Bild-Zwilling (ehrlich langsam statt scheinbar hängend).
|
||||
_BILD_TIMEOUT_S = float(os.environ.get("MC_CODER_IMAGE_TIMEOUT_S", "240"))
|
||||
_BILD_MAX = int(os.environ.get("MC_CODER_IMAGE_MAX", "4"))
|
||||
|
||||
# Beschreibung älterer Bilder: Prompt bewusst auf wörtliche Wiedergabe von Code/Fehlermeldungen
|
||||
# getrimmt — das schnelle Modell arbeitet in den Folgeschritten nur noch mit diesem Text.
|
||||
_BILD_BESCHREIB_PROMPT = os.environ.get(
|
||||
"MC_CODER_IMAGE_PROMPT",
|
||||
"Beschreibe dieses Bild vollstaendig und praezise auf Deutsch: sichtbarer Text, "
|
||||
@@ -63,57 +73,74 @@ def _apply_no_think_marker(body: dict) -> None:
|
||||
body["chat_template_kwargs"] = {"enable_thinking": False}
|
||||
|
||||
|
||||
def _ist_coder_alias(alias: str) -> bool:
|
||||
"""Coder-Ziele (coder, rohe Qwen-Coder-IDs) bekommen Bild-BESCHREIBUNGEN statt der
|
||||
stumpfen Vision-Umleitung — die Code-Frage bleibt beim Spezialisten."""
|
||||
return "coder" in (alias or "").lower()
|
||||
# Bild-Weiche v3 (24.09.2026). llama.cpp kann Draft-Beschleunigung und Bilder nicht zusammen (HTTP 500
|
||||
# „failed to process speculative batch“, b11057 und b11157 geprüft). Darum haben Hirn und Coder je einen
|
||||
# Bild-Zwilling: dieselben Gewichte mit Bild-Projektor, ohne Draft (vision, coder-bild).
|
||||
# • Bild im aktuellen Schritt → der Zwilling der Rolle sieht es selbst, auch mitten in einer Agenten-Aufgabe.
|
||||
# • Bild aus früheren Schritten → einmal von vision beschrieben (je Bild gemerkt) und als Text mitgeschickt,
|
||||
# damit der Rest der Aufgabe wieder beim schnellen Modell mit Draft läuft.
|
||||
_BESCHREIBUNGEN: OrderedDict[str, str] = OrderedDict()
|
||||
_BESCHREIBUNGEN_MAX = 64
|
||||
|
||||
|
||||
async def _bilder_fuer_coder_beschreiben(body: dict, client, vision_alias: str) -> bool:
|
||||
"""Ersetzt jeden Bild-Part durch eine Text-Beschreibung vom Vision-Modell.
|
||||
def _bild_schluessel(part: dict) -> str:
|
||||
return hashlib.sha1(json.dumps(part, sort_keys=True).encode("utf-8")).hexdigest()
|
||||
|
||||
Idee aus einem verwaisten, nie verdrahteten Patch in der Hermes-Quelle
|
||||
(gateway/platforms/api_server.py, 07/2026) — bei der Projekt-Review 15.07.
|
||||
regelkonform hierher umgezogen (Archiv: docs/archiv/). True = alle Bilder
|
||||
ersetzt; False = eine Analyse scheiterte, Aufrufer nutzt die normale
|
||||
Vision-Umleitung als Fallback.
|
||||
"""
|
||||
fundstellen: list[tuple[dict, int, dict]] = []
|
||||
for m in body.get("messages") or []:
|
||||
if not isinstance(m, dict) or not isinstance(m.get("content"), list):
|
||||
continue
|
||||
for i, part in enumerate(m["content"]):
|
||||
if isinstance(part, dict) and part.get("type") in IMAGE_PART_TYPES:
|
||||
fundstellen.append((m, i, part))
|
||||
for nr, (msg, idx, part) in enumerate(fundstellen, start=1):
|
||||
|
||||
async def _beschreibe(part: dict, vision_alias: str) -> str:
|
||||
"""Beschreibung eines Bild-Parts; Hermes und OpenCode schicken den ganzen Verlauf mit jedem Schritt
|
||||
neu, darum wird jedes Bild nur einmal beschrieben."""
|
||||
schluessel = _bild_schluessel(part)
|
||||
if schluessel in _BESCHREIBUNGEN:
|
||||
_BESCHREIBUNGEN.move_to_end(schluessel)
|
||||
return _BESCHREIBUNGEN[schluessel]
|
||||
frage = {
|
||||
"model": vision_alias,
|
||||
"stream": False,
|
||||
"max_tokens": 700,
|
||||
"messages": [{"role": "user",
|
||||
"content": [part, {"type": "text", "text": _BILD_BESCHREIB_PROMPT}]}],
|
||||
# Denken aus: sonst frisst die Denkphase das Token-Budget und die Beschreibung bleibt leer.
|
||||
"chat_template_kwargs": {"enable_thinking": False},
|
||||
"messages": [{"role": "user", "content": [part, {"type": "text", "text": _BILD_BESCHREIB_PROMPT}]}],
|
||||
}
|
||||
# Eigener Kurzzeit-Client statt des gepoolten (Befund 15.07.: ReadTimeout nach
|
||||
# Sekunden trotz timeout=240 — llama-swap kappt beim Modell-Swap gern alte
|
||||
# Keep-Alive-Sockets, der Pool reicht sie trotzdem wieder aus) + 1 Retry.
|
||||
text = ""
|
||||
# Eigener Kurzzeit-Client statt des gepoolten (Befund 15.07.: ReadTimeout nach Sekunden trotz
|
||||
# timeout=240 — llama-swap kappt beim Modell-Swap gern alte Keep-Alive-Sockets) + 1 Retry.
|
||||
for versuch in (1, 2):
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=240.0) as c:
|
||||
async with httpx.AsyncClient(timeout=_BILD_TIMEOUT_S) as c:
|
||||
r = await c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=frage)
|
||||
if r.status_code == 200:
|
||||
text = ((r.json().get("choices") or [{}])[0].get("message") or {}).get("content") or ""
|
||||
if text.strip():
|
||||
break
|
||||
log.warning("Bild-Weiche v2 (Versuch %s): Beschreibung leer/HTTP %s — %.200s",
|
||||
log.warning("Bild-Weiche (Versuch %s): Beschreibung leer/HTTP %s — %.200s",
|
||||
versuch, r.status_code, r.text)
|
||||
except Exception:
|
||||
log.warning("Bild-Weiche v2 (Versuch %s): Vision-Aufruf scheiterte", versuch, exc_info=True)
|
||||
if not text.strip():
|
||||
log.warning("Bild-Weiche (Versuch %s): Beschreibung scheiterte", versuch, exc_info=True)
|
||||
text = text.strip()
|
||||
if text:
|
||||
_BESCHREIBUNGEN[schluessel] = text
|
||||
while len(_BESCHREIBUNGEN) > _BESCHREIBUNGEN_MAX:
|
||||
_BESCHREIBUNGEN.popitem(last=False)
|
||||
return text
|
||||
|
||||
|
||||
async def _alte_bilder_beschreiben(alt: list[tuple[dict, int]], vision_alias: str) -> bool:
|
||||
"""Bilder aus früheren Schritten durch ihre Beschreibung ersetzen. False = ging nicht (zu viele neue
|
||||
oder eine Beschreibung scheiterte) — dann bleiben alle Bilder drin."""
|
||||
neu = {_bild_schluessel(msg["content"][idx]) for msg, idx in alt} - set(_BESCHREIBUNGEN)
|
||||
if len(neu) > _BILD_MAX:
|
||||
log.warning("Bild-Weiche: %s ältere Bilder neu zu beschreiben (Deckel %s) — Anfrage geht mit allen "
|
||||
"Bildern an den Bild-Zwilling", len(neu), _BILD_MAX)
|
||||
return False
|
||||
msg["content"][idx] = {"type": "text", "text": (
|
||||
f"[Bild {nr}: dem Request lag ein Bild bei — {vision_alias} beschreibt es so:\n"
|
||||
f"{text.strip()}]")}
|
||||
texte = []
|
||||
for msg, idx in alt:
|
||||
text = await _beschreibe(msg["content"][idx], vision_alias)
|
||||
if not text:
|
||||
return False
|
||||
texte.append((msg, idx, text))
|
||||
for msg, idx, text in texte:
|
||||
msg["content"][idx] = {"type": "text", "text": f"[Bild aus einem früheren Schritt — so sah es aus:\n{text}]"}
|
||||
return True
|
||||
|
||||
|
||||
@@ -134,11 +161,29 @@ def _inject_language(body: dict, alias: str) -> None:
|
||||
elif isinstance(first_sys.get("content"), list):
|
||||
first_sys["content"].append({"type": "text", "text": _LANG_DIRECTIVE})
|
||||
|
||||
def _upstream_fehler(text: str, status: int = 502, headers: dict | None = None) -> JSONResponse:
|
||||
"""Fehler im OpenAI-Format statt eines nackten 500 (24.09.2026): Hermes, OpenChamber und Lucy
|
||||
können damit umgehen und zeigen den Grund an."""
|
||||
return JSONResponse({"error": {"message": text, "type": "upstream_error"}},
|
||||
status_code=status, headers=headers)
|
||||
|
||||
|
||||
@router.get("/models")
|
||||
async def models(request: Request):
|
||||
client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan)
|
||||
try:
|
||||
r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0)
|
||||
except httpx.HTTPError as exc:
|
||||
return _upstream_fehler(f"Engine nicht erreichbar: {exc.__class__.__name__}")
|
||||
try:
|
||||
data = r.json()
|
||||
except ValueError:
|
||||
# Engine antwortet, aber nicht mit JSON (Startphase/Fehlerseite) — ehrlicher 502
|
||||
# statt eines 500ers aus dem Parser.
|
||||
log.warning("/v1/models: Engine-Antwort ist kein JSON (HTTP %s): %.200s", r.status_code, r.text)
|
||||
return JSONResponse(
|
||||
{"error": {"message": "Engine lieferte keine gültige Modell-Liste.",
|
||||
"type": "upstream_error"}}, status_code=502)
|
||||
# Aufgeräumt (07/2026): Die Router-Lanes (coding/chat) werden NICHT mehr als „Modell"
|
||||
# angeboten — sie verwirrten (coding vs. coder) und seit dem Zed-Aus wählt sie niemand
|
||||
# mehr. Das Routing bleibt erhalten (siehe _proxy), sie stehen nur nicht mehr in der Liste.
|
||||
@@ -187,7 +232,20 @@ async def models(request: Request):
|
||||
|
||||
|
||||
async def _proxy(path: str, request: Request):
|
||||
# Ungültige Payloads gehören dem Client, nicht dem Server: ohne diese Prüfung wirft
|
||||
# request.json() bzw. body.get(...) durch und der Aufrufer bekommt einen 500er
|
||||
# (gemessen 27.08.2026: Rohtext, leerer Body, JSON-Liste, JSON-String und null — 5/5 mal 500).
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
{"error": {"message": "Ungültiger Request-Body: JSON erwartet.",
|
||||
"type": "invalid_request_error"}}, status_code=400)
|
||||
if not isinstance(body, dict):
|
||||
return JSONResponse(
|
||||
{"error": {"message": "Ungültiger Request-Body: JSON-Objekt erwartet, "
|
||||
f"'{type(body).__name__}' bekommen.",
|
||||
"type": "invalid_request_error"}}, status_code=400)
|
||||
_apply_no_think_marker(body) # Lucy-Voice-Schnellspur: Marker strippen + Thinking aus
|
||||
requested = str(body.get("model") or "auto")
|
||||
if requested.lower() in ("auto", "chat", "coding"):
|
||||
@@ -201,31 +259,36 @@ async def _proxy(path: str, request: Request):
|
||||
|
||||
pol = load_policy()
|
||||
client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan)
|
||||
# Bild-Weiche (Faden 11): Requests mit Bild-Anhang automatisch ans Vision-Modell umleiten,
|
||||
# sofern das Ziel nicht ohnehin bildfähig ist. Das MTP-Hirn (fast/hermes) kann keine Bilder —
|
||||
# so bleibt Lucy schnell, ohne dass jemand manuell das Modell wechselt (Entscheid Weg A).
|
||||
# Coder-Sonderweg (15.07.): Coder-Ziele behalten den Request — die Bilder werden vorab vom
|
||||
# Vision-Modell BESCHRIEBEN und als Text injiziert (Screenshot-Debugging bleibt beim Coder).
|
||||
angefragt = alias
|
||||
# Bild-Weiche v3 (siehe oben): Bild im aktuellen Schritt → Bild-Zwilling der Rolle; ältere Bilder →
|
||||
# Beschreibung als Text, die Anfrage bleibt beim schnellen Modell.
|
||||
vision_alias = pol.get("vision")
|
||||
if vision_alias and alias not in VISION_CAPABLE and has_image(body):
|
||||
if _ist_coder_alias(alias) and await _bilder_fuer_coder_beschreiben(body, client, vision_alias):
|
||||
routed = {"x-mc-routed-to": alias,
|
||||
"x-mc-route-reason": "Bild beschrieben (Vision) -> bleibt beim Coder",
|
||||
"x-mc-lane": routed.get("x-mc-lane", "-")}
|
||||
else:
|
||||
alias = vision_alias
|
||||
frisch, alt = bilder_aufteilen(body)
|
||||
beschrieben = bool(alt) and await _alte_bilder_beschreiben(alt, vision_alias)
|
||||
lane = routed.get("x-mc-lane", "-")
|
||||
if frisch or not beschrieben:
|
||||
alias = bild_ziel(alias, vision_alias, pol.get("coder_vision") or None)
|
||||
body["model"] = alias
|
||||
# HTTP-Header-Werte muessen latin-1 sein — kein '→' o.ae. (sonst 500, 13.07.).
|
||||
routed = {"x-mc-routed-to": alias, "x-mc-route-reason": "Bild erkannt -> Vision-Modell",
|
||||
"x-mc-lane": routed.get("x-mc-lane", "-")}
|
||||
# fast-Spur: Thinking aus für flotte Antworten (sofern Client es nicht selbst setzt).
|
||||
if pol["fast_no_think"] and alias == pol["fast"] and "chat_template_kwargs" not in body:
|
||||
routed = {"x-mc-routed-to": alias, "x-mc-route-reason": "Bild im aktuellen Schritt -> Bild-Zwilling",
|
||||
"x-mc-lane": lane}
|
||||
else:
|
||||
routed = {"x-mc-routed-to": alias,
|
||||
"x-mc-route-reason": "aeltere Bilder beschrieben -> bleibt beim schnellen Modell",
|
||||
"x-mc-lane": lane}
|
||||
# fast-Spur: Thinking aus für flotte Antworten (sofern Client es nicht selbst setzt) — auch wenn die
|
||||
# Anfrage wegen eines Bildes beim Hirn-Zwilling landet (gleiches Hirn, gleiche Spur).
|
||||
if pol["fast_no_think"] and angefragt == pol["fast"] and "chat_template_kwargs" not in body:
|
||||
body["chat_template_kwargs"] = {"enable_thinking": False}
|
||||
_inject_language(body, alias)
|
||||
url = f"{LLAMA_SWAP_URL}{path}"
|
||||
if body.get("stream"):
|
||||
req = client.build_request("POST", url, json=body, timeout=None)
|
||||
try:
|
||||
r = await client.send(req, stream=True)
|
||||
except httpx.HTTPError as exc:
|
||||
return _upstream_fehler(f"Engine nicht erreichbar: {exc.__class__.__name__}", headers=routed)
|
||||
if r.status_code != 200:
|
||||
await r.aread()
|
||||
try:
|
||||
@@ -243,8 +306,15 @@ async def _proxy(path: str, request: Request):
|
||||
await r.aclose()
|
||||
return StreamingResponse(gen(), media_type="text/event-stream", headers=routed)
|
||||
|
||||
try:
|
||||
r = await client.post(url, json=body, timeout=600.0)
|
||||
except httpx.HTTPError as exc:
|
||||
return _upstream_fehler(f"Engine nicht erreichbar: {exc.__class__.__name__}", headers=routed)
|
||||
try:
|
||||
resp_json = r.json()
|
||||
except ValueError:
|
||||
log.warning("%s: Engine-Antwort ist kein JSON (HTTP %s): %.200s", path, r.status_code, r.text)
|
||||
return _upstream_fehler(f"Engine lieferte keine gültige Antwort (HTTP {r.status_code}).", headers=routed)
|
||||
if isinstance(resp_json, dict):
|
||||
record_usage(resp_json.get("usage"), alias)
|
||||
if any(isinstance(c, dict) and c.get("finish_reason") == "length"
|
||||
|
||||
+24
-15
@@ -1,9 +1,12 @@
|
||||
"""Health-/Status-Endpoint — schlanker Lebenszeichen-Check für MC 2.0."""
|
||||
"""Health-/Status-Endpoint — schlanker Lebenszeichen-Check einer Instanz.
|
||||
|
||||
Seit 24.09.2026 nennt er auch Rolle und Namen der Instanz: Die Partner-Instanz liest daran ab,
|
||||
wer ihr antwortet. Engine, Gateway und Hirn gibt es nur auf der KI-Box."""
|
||||
|
||||
from config import VERSION
|
||||
from fastapi import APIRouter
|
||||
from kern.einstellungen import einstellungen
|
||||
from pydantic import BaseModel
|
||||
from services import gateway, llamaswap
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
@@ -11,19 +14,25 @@ router = APIRouter(prefix="/api")
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
version: str
|
||||
engine_reachable: bool
|
||||
gateway_reachable: bool
|
||||
brain: dict
|
||||
rolle: str
|
||||
instanz: str
|
||||
engine_reachable: bool | None = None
|
||||
gateway_reachable: bool | None = None
|
||||
brain: dict | None = None
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponse)
|
||||
@router.get("/health", response_model=HealthResponse, response_model_exclude_none=True)
|
||||
def health() -> dict:
|
||||
return {
|
||||
"status": "ok",
|
||||
"version": VERSION,
|
||||
"engine_reachable": llamaswap.engine_reachable(),
|
||||
"gateway_reachable": gateway.gateway_reachable(),
|
||||
# Echte Hirn-Bereitschaft: Engine kann erreichbar sein, das Agent-Hirn ('fast') aber tot
|
||||
# (Crash/OOM nach Engine-Update). Das wäre sonst ein silent fail (App-Fehler statt Status).
|
||||
"brain": llamaswap.brain_status(),
|
||||
}
|
||||
e = einstellungen()
|
||||
antwort: dict = {"status": "ok", "version": VERSION, "rolle": e.rolle, "instanz": e.instanz}
|
||||
if e.rolle == "box":
|
||||
from services import gateway, llamaswap
|
||||
|
||||
antwort.update(
|
||||
engine_reachable=llamaswap.engine_reachable(),
|
||||
gateway_reachable=gateway.gateway_reachable(),
|
||||
# Echte Hirn-Bereitschaft: Engine kann erreichbar sein, das Agent-Hirn aber tot
|
||||
# (Crash/OOM nach Engine-Update). Das wäre sonst ein silent fail.
|
||||
brain=llamaswap.brain_status(),
|
||||
)
|
||||
return antwort
|
||||
|
||||
@@ -6,14 +6,19 @@ Hermes-GUI eine volle Single-Page-App: HTML + gehashte Assets + JSON-API + MEHRE
|
||||
(/api/ws, /api/console, /api/pty, /api/events). Deshalb hier ein GENERISCHER Reverse-Proxy für
|
||||
alles unter `/hermes-ui/*` (alle HTTP-Methoden + beliebige WebSockets).
|
||||
|
||||
Der Backend-Server läuft NUR auf Loopback (127.0.0.1:9119, `hermes serve --skip-build`, kein
|
||||
Login — gleiches LAN-Trust-Modell wie Konsole/Terminal) und wird über den ohnehin offenen
|
||||
MC2-Port (9001) same-origin durchgereicht → keine eigene Firewall-Freigabe, kein CORS.
|
||||
Der Backend-Server (`hermes dashboard`, :9119) wird über den MC2-Port (9001) same-origin
|
||||
durchgereicht → keine eigene Firewall-Freigabe, kein CORS.
|
||||
|
||||
Damit die absoluten Pfade der SPA (`/assets`, `/api`, `/api/ws`) nicht mit MC2s eigenen (`/assets`,
|
||||
`/api`) kollidieren, wird das Hermes-Bundle mit Vite-`base=/hermes-ui/` gebaut (deploy.sh) — dann
|
||||
liegen ALLE seine Pfade unter `/hermes-ui/`. Dieser Proxy streift das Präfix ab und leitet an
|
||||
`:9119/...` weiter.
|
||||
|
||||
Seit Hermes v0.21 hat das Dashboard eine eigene Anmeldung: `/` leitet auf `/login?next=…` um,
|
||||
und die Anmeldeseite schickt an `/auth/password-login`. Beide Pfade sind absolut und kennen
|
||||
das Präfix nicht — MC2 reichte die Umleitung unverändert weiter, und der Knopf „Hermes-GUI
|
||||
öffnen“ landete auf MC2s eigener „Diese Seite gibt es nicht“ (gefunden 23.09.2026). Deshalb
|
||||
schreibt der Proxy Umleitungen und diese Pfade im HTML auf `/hermes-ui/…` um.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -91,6 +96,24 @@ async def _proxy_ws(ws: WebSocket, path: str) -> None:
|
||||
_BASE_PATH_RE = re.compile(rb'window\.__HERMES_BASE_PATH__\s*=\s*"[^"]*"')
|
||||
_BASE_PATH_SET = b'window.__HERMES_BASE_PATH__="/' + _BASE.encode() + b'"'
|
||||
_HEAD_INJECT = b"<head><script>" + _BASE_PATH_SET + b";</script>"
|
||||
# Absolute Anmelde-Pfade in der (server-gerenderten) Login-Seite: "/auth/…", "/login", "/logout".
|
||||
_ANMELDE_PFADE = re.compile(rb'(["\'(=])/(auth|login|logout)(?=[/?"\')\s])')
|
||||
|
||||
|
||||
def praefixiere_ort(ort: str) -> str:
|
||||
"""Umleitungsziel unter /hermes-ui/ holen. Fremde Ziele (http…, //host) bleiben."""
|
||||
if ort.startswith("/") and not ort.startswith(("//", f"/{_BASE}/")):
|
||||
return f"/{_BASE}{ort}"
|
||||
return ort
|
||||
|
||||
|
||||
def praefixiere_html(body: bytes) -> bytes:
|
||||
"""Basis-Pfad der SPA setzen und die absoluten Anmelde-Pfade unter /hermes-ui/ legen."""
|
||||
if _BASE_PATH_RE.search(body):
|
||||
body = _BASE_PATH_RE.sub(_BASE_PATH_SET, body)
|
||||
elif b"<head>" in body:
|
||||
body = body.replace(b"<head>", _HEAD_INJECT, 1)
|
||||
return _ANMELDE_PFADE.sub(rb"\1/" + _BASE.encode() + rb"/\2", body)
|
||||
|
||||
|
||||
async def _proxy_http(request: Request, path: str = "") -> Response:
|
||||
@@ -113,12 +136,18 @@ async def _proxy_http(request: Request, path: str = "") -> Response:
|
||||
)
|
||||
body = r.content
|
||||
if "text/html" in r.headers.get("content-type", "").lower():
|
||||
if _BASE_PATH_RE.search(body):
|
||||
body = _BASE_PATH_RE.sub(_BASE_PATH_SET, body)
|
||||
elif b"<head>" in body:
|
||||
body = body.replace(b"<head>", _HEAD_INJECT, 1)
|
||||
resp_headers = {k: v for k, v in r.headers.items() if k.lower() not in _DROP}
|
||||
return Response(content=body, status_code=r.status_code, headers=resp_headers)
|
||||
body = praefixiere_html(body)
|
||||
resp = Response(content=body, status_code=r.status_code)
|
||||
# multi_items statt dict: Die Anmeldung setzt Cookies, und mehrere Set-Cookie-Zeilen
|
||||
# dürfen nicht zu einer zusammenfallen.
|
||||
for k, v in r.headers.multi_items():
|
||||
kl = k.lower()
|
||||
if kl in _DROP:
|
||||
continue
|
||||
if kl == "location":
|
||||
v = praefixiere_ort(v)
|
||||
resp.raw_headers.append((kl.encode("latin-1"), v.encode("latin-1")))
|
||||
return resp
|
||||
|
||||
|
||||
@router.get(f"/{_BASE}")
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
"""Schnittstellen des Homelab-Teils (Rolle homelab, Phase 3/4, 24.09.2026).
|
||||
|
||||
GET /api/homelab/ziele Proxmox-Host und Gäste im gemeinsamen Ziel-Modell
|
||||
POST /api/homelab/ziele/{id}/update „Jetzt updaten“ (App bzw. Host-Pakete)
|
||||
POST /api/homelab/ziele/{id}/os-update Pakete im Gast einspielen
|
||||
POST /api/homelab/ziele/{id}/suchen Paketlisten im Gast erneuern
|
||||
POST /api/homelab/ziele/{id}/docker Docker über Arcane (zuerst Probelauf)
|
||||
POST /api/homelab/ziele/{id}/rueckweg-probe Rückweg testen: Snapshot, absichtlich rot, zurück, nachprüfen
|
||||
GET /api/homelab/ziele/{id}/neuerungen?baustein=app|pakete Was ist neu? Release Notes + Lucys Kurzfassung
|
||||
POST /api/homelab/ziele/pve/neustart Proxmox-Host neu starten (eigener Knopf)
|
||||
GET /api/homelab/laeufe Die letzten Läufe (Updates, Snapshot-Aktionen) mit ihren Schritten
|
||||
GET /api/homelab/snapshots Snapshots und Sicherungen je Gerät (Aktionen: siehe unten)
|
||||
POST /api/homelab/snapshots/rueckwege-loeschen Alle Rückwege von Updates löschen (nur per Klick)
|
||||
POST /api/homelab/sicherungen/probe Probe-Wiederherstellung jetzt (nur lesend, sonst einmal die Woche)
|
||||
GET /api/homelab/zertifikate https-Zertifikate (NPMplus, Proxmox-Host, PBS) mit Stufe
|
||||
GET /api/homelab/neu „Neu entdeckt“: Geräte und Docker-Stacks, die er noch nicht kennt
|
||||
GET /api/homelab/wartung Wartungsfenster, Regel je Gerät, übersprungene Versionen, letzter Lauf
|
||||
POST /api/homelab/wartung {"fenster"?, "standard"?, "regeln"?} ändern
|
||||
POST /api/homelab/ziele/{id}/ueberspringen {"baustein": "app", "version": …}; DELETE bietet sie wieder an
|
||||
GET /api/homelab/docker Docker-Dienste unter Arcane je Stack: Zustand, Gesundheit, Update
|
||||
POST /api/homelab/neu/{id} {"wahl": freigeben | beobachten | ausschliessen} — übernehmen
|
||||
GET /api/homelab/hinweise Hinweise des Wächters dieser Instanz
|
||||
GET /api/homelab/ausfuehrer Lebenszeichen des Ausführers
|
||||
GET /api/homelab/ausfuehrer/auftrag ┐
|
||||
POST /api/homelab/ausfuehrer/bericht ├ nur für den Ausführer (Kopfzeile X-MC2-Ausfuehrer)
|
||||
POST /api/homelab/ausfuehrer/ergebnis/{id} ┘
|
||||
|
||||
GET /api/stream Live-Strom dieser Instanz (Anstöße, kein Messpunkt)
|
||||
|
||||
Dünn: die Logik liegt in services/homelab/.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from kern.einstellungen import einstellungen
|
||||
from pydantic import BaseModel
|
||||
from services.homelab import inventar, kanal, updates
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/homelab", tags=["homelab"])
|
||||
strom_router = APIRouter(prefix="/api", tags=["homelab"])
|
||||
|
||||
|
||||
def _nur_ausfuehrer(x_mc2_ausfuehrer: str | None = Header(default=None)) -> None:
|
||||
if not kanal.token_gueltig(x_mc2_ausfuehrer):
|
||||
raise HTTPException(status_code=403, detail="Nur für den Ausführer auf dem Proxmox-Host.")
|
||||
kanal.kontakt()
|
||||
|
||||
|
||||
@router.get("/ziele")
|
||||
def ziele() -> dict:
|
||||
return inventar.ziele()
|
||||
|
||||
|
||||
def _antwort(ergebnis: dict) -> dict:
|
||||
if not ergebnis.get("ok"):
|
||||
raise HTTPException(status_code=409, detail=ergebnis.get("detail") or "Das geht gerade nicht.")
|
||||
return ergebnis
|
||||
|
||||
|
||||
@router.post("/ziele/{ziel_id}/update")
|
||||
def update(ziel_id: str) -> dict:
|
||||
return _antwort(updates.starten(ziel_id, "pakete" if ziel_id == "pve" else "app"))
|
||||
|
||||
|
||||
@router.post("/ziele/{ziel_id}/os-update")
|
||||
def os_update(ziel_id: str) -> dict:
|
||||
return _antwort(updates.starten(ziel_id, "os"))
|
||||
|
||||
|
||||
@router.post("/ziele/{ziel_id}/docker")
|
||||
def docker(ziel_id: str) -> dict:
|
||||
"""Docker über Arcanes Updater — zuerst nur als Probelauf (MC_ARCANE_ECHT=1 macht es echt)."""
|
||||
return _antwort(updates.starten(ziel_id, "docker"))
|
||||
|
||||
|
||||
@router.post("/ziele/{ziel_id}/sicherheitsupdates")
|
||||
def sicherheitsupdates(ziel_id: str) -> dict:
|
||||
"""Nur die Sicherheitsupdates (seit 25.09.2026): für Lucy und Rückfragen, wie im Wartungsfenster — Snapshot vorher,
|
||||
Prüfung danach, bei Rot zurück; der Proxmox-Host startet dabei nicht neu."""
|
||||
return _antwort(updates.starten(ziel_id, "sicherheit"))
|
||||
|
||||
|
||||
@router.get("/ziele/{ziel_id}/journal")
|
||||
def journal(ziel_id: str, zeilen: int = 80) -> dict:
|
||||
"""Warnungen und Fehler aus dem Journal eines Containers (seit 25.09.2026, für Lucys Untersuchungen)."""
|
||||
from services.homelab import journal as geraete_journal
|
||||
return _antwort(geraete_journal.lesen(ziel_id, zeilen))
|
||||
|
||||
|
||||
@router.post("/ziele/{ziel_id}/rueckweg-probe")
|
||||
def rueckweg_probe(ziel_id: str) -> dict:
|
||||
"""Rückweg testen (seit 25.09.2026): Snapshot anlegen, absichtlich rot werten, zurückgehen, nachprüfen."""
|
||||
return _antwort(updates.starten(ziel_id, "probe"))
|
||||
|
||||
|
||||
@router.post("/ziele/pve/neustart")
|
||||
def host_neustart() -> dict:
|
||||
return _antwort(updates.starten("pve", "neustart"))
|
||||
|
||||
|
||||
@router.post("/ziele/pve/kernel-aufraeumen")
|
||||
def host_kernel_aufraeumen() -> dict:
|
||||
"""Alte Kernel vom Proxmox-Host entfernen (seit 25.09.2026, nur per Knopf). Welche, entscheidet der Ausführer."""
|
||||
return _antwort(updates.starten("pve", "kernel"))
|
||||
|
||||
|
||||
@router.post("/ziele/{ziel_id}/suchen")
|
||||
def suchen(ziel_id: str) -> dict:
|
||||
gast = inventar.gast(ziel_id)
|
||||
if not gast or not gast.get("erlaubt"):
|
||||
raise HTTPException(status_code=404, detail="Dieses Gerät steht nicht (freigegeben) im Bericht.")
|
||||
return {"ok": True, "auftrag": kanal.anlegen("suchen", {"vmid": gast["vmid"]})}
|
||||
|
||||
|
||||
@router.get("/hinweise")
|
||||
def hinweise() -> dict:
|
||||
"""Was der Wächter dieser Instanz gerade sieht (Ausführer schweigt, Gast antwortet nicht …)."""
|
||||
from services import waechter
|
||||
return waechter.lese_stand()
|
||||
|
||||
|
||||
@router.post("/hinweise/{hinweis_id}/aktion/{aktion_id}")
|
||||
def hinweis_aktion(hinweis_id: str, aktion_id: str) -> dict:
|
||||
"""Knöpfe eines Hinweises dieser Instanz — auch „gesehen“, „stumm“ (24 h) und „laut“ (seit 25.09.2026)."""
|
||||
from services import waechter
|
||||
return _antwort(waechter.fuehre_aktion_aus(hinweis_id, aktion_id))
|
||||
|
||||
|
||||
@router.get("/laeufe")
|
||||
def laeufe(anzahl: int = 20) -> dict:
|
||||
return {"laeufe": updates.laeufe(max(1, min(anzahl, 100)))}
|
||||
|
||||
|
||||
# --- „Alle aktualisieren“, Protokoll, Einstellungen (24.09.2026) ------------------------------------------------
|
||||
# GET /api/homelab/alle/plan was „Alle aktualisieren“ täte, in dieser Reihenfolge
|
||||
# POST /api/homelab/alle „Alle aktualisieren“ starten
|
||||
# GET /api/homelab/sammellauf der laufende oder der letzte Sammellauf
|
||||
# GET /api/homelab/protokoll?grenze=200&berichte=0 was wann geschah, neueste zuerst
|
||||
# GET /api/homelab/einstellungen Arcane (Schlüssel, echt), Wartezeit, Ausführer
|
||||
# POST /api/homelab/einstellungen/arcane-schluessel {"schluessel": …}: erst gegen Arcane prüfen, dann speichern
|
||||
# DELETE /api/homelab/einstellungen/arcane-schluessel
|
||||
# POST /api/homelab/einstellungen/arcane-echt {"an": bool}: echte Docker-Updates statt Probelauf
|
||||
# POST /api/homelab/einstellungen/adguard-zugang {"benutzer", "passwort"}: erst bei AdGuard prüfen, dann speichern
|
||||
# DELETE /api/homelab/einstellungen/adguard-zugang
|
||||
# Was nicht geht, beantworten sie mit {"ok": false, "detail": …} und HTTP 200, wie der Telegram-Test (die Oberfläche
|
||||
# liest ok). Die Dienste laden die Endpunkte selbst, wie hinweise().
|
||||
|
||||
@router.get("/alle/plan")
|
||||
def alle_plan() -> dict:
|
||||
from services.homelab import sammellauf
|
||||
return sammellauf.plan()
|
||||
|
||||
|
||||
@router.post("/alle")
|
||||
def alle_starten() -> dict:
|
||||
from services.homelab import sammellauf
|
||||
return sammellauf.starten()
|
||||
|
||||
|
||||
@router.get("/sammellauf")
|
||||
def sammellauf_stand() -> dict:
|
||||
from services.homelab import sammellauf
|
||||
return {"sammellauf": sammellauf.aktueller()}
|
||||
|
||||
|
||||
@router.post("/sammellauf/{sl_id}/quittieren")
|
||||
def sammellauf_quittieren(sl_id: str) -> dict:
|
||||
"""Beendetes „Alle aktualisieren“ als gesehen markieren (die Update-Seite blendet es aus)."""
|
||||
from services.homelab import sammellauf
|
||||
return {"ok": sammellauf.quittieren(sl_id)}
|
||||
|
||||
|
||||
@router.get("/protokoll")
|
||||
def protokoll_lesen(grenze: int = 200, berichte: bool = False, ziel: str | None = None) -> dict:
|
||||
"""Was geschah, neueste zuerst; mit ziel nur ein Gerät (Zeitleiste je Gerät, seit 25.09.2026)."""
|
||||
from services.homelab import protokoll
|
||||
return {"eintraege": protokoll.eintraege(grenze, berichte, ziel or None)}
|
||||
|
||||
|
||||
@router.get("/speicher")
|
||||
def speicher_lage() -> dict:
|
||||
"""Speicherpool, NAS und Sicherungen samt Empfehlungen, was Platz bringt (Karte im Homelab-Cockpit)."""
|
||||
from services.homelab import speicher
|
||||
return speicher.lage()
|
||||
|
||||
|
||||
@router.get("/ziele/{ziel_id}/neuerungen")
|
||||
def neuerungen(ziel_id: str, baustein: str = "app") -> dict:
|
||||
"""Release Notes bzw. Änderungslisten vor dem Update, mit Lucys Kurzfassung (seit 25.09.2026). Kann beim ersten
|
||||
Mal einige Sekunden dauern (GitHub, Ausführer, Modell); danach ist es gemerkt."""
|
||||
from services.homelab import neuerungen as neu
|
||||
return neu.fuer(ziel_id, baustein)
|
||||
|
||||
|
||||
@router.get("/wartung")
|
||||
def wartung_lage() -> dict:
|
||||
"""Wartungsfenster und Update-Regel je Gerät (seit 25.09.2026)."""
|
||||
from services.homelab import wartung
|
||||
return wartung.lage()
|
||||
|
||||
|
||||
@router.post("/wartung")
|
||||
def wartung_setzen(daten: dict) -> dict:
|
||||
from services.homelab import wartung
|
||||
return _antwort(wartung.setzen(daten))
|
||||
|
||||
|
||||
class Ueberspringen(BaseModel):
|
||||
baustein: str = "app"
|
||||
version: str
|
||||
|
||||
|
||||
@router.post("/ziele/{ziel_id}/ueberspringen")
|
||||
def version_ueberspringen(ziel_id: str, u: Ueberspringen) -> dict:
|
||||
from services.homelab import wartung
|
||||
return _antwort(wartung.ueberspringen(ziel_id, u.baustein, u.version))
|
||||
|
||||
|
||||
@router.delete("/ziele/{ziel_id}/ueberspringen")
|
||||
def version_wieder_anbieten(ziel_id: str, baustein: str = "app") -> dict:
|
||||
from services.homelab import wartung
|
||||
return _antwort(wartung.ueberspringen(ziel_id, baustein, None))
|
||||
|
||||
|
||||
@router.get("/neu")
|
||||
def neu_entdeckt() -> dict:
|
||||
"""Geräte, die der Orchestrator noch nicht kennt, mit Vorschlägen (seit 25.09.2026)."""
|
||||
from services.homelab import entdecken
|
||||
return {"geraete": entdecken.neue(), "docker": entdecken.neue_docker()}
|
||||
|
||||
|
||||
@router.get("/docker")
|
||||
def docker_dienste() -> dict:
|
||||
"""Die Docker-Container unter Arcane, je Stack (seit 25.09.2026)."""
|
||||
from services.homelab import docker
|
||||
return docker.lage()
|
||||
|
||||
|
||||
class Wahl(BaseModel):
|
||||
wahl: str
|
||||
|
||||
|
||||
@router.post("/neu/{ziel_id}")
|
||||
def neu_uebernehmen(ziel_id: str, w: Wahl) -> dict:
|
||||
"""Übernehmen: freigeben (Etikett watcher), beobachten (kein Etikett) oder ausschliessen (watcher-aus)."""
|
||||
from services.homelab import entdecken
|
||||
return _antwort(entdecken.uebernehmen(ziel_id, w.wahl))
|
||||
|
||||
|
||||
@router.get("/zertifikate")
|
||||
def zertifikate_lage() -> dict:
|
||||
"""Die https-Zertifikate mit Ablauf und Stufe (Zertifikats-Wache, seit 25.09.2026)."""
|
||||
from services.homelab import zertifikate
|
||||
return zertifikate.lage()
|
||||
|
||||
|
||||
@router.post("/sicherungen/probe")
|
||||
def sicherungen_probe() -> dict:
|
||||
"""Knopf „Jetzt prüfen“: die jüngste Sicherung jedes Geräts probeweise öffnen (seit 25.09.2026)."""
|
||||
from services.homelab import speicher
|
||||
return _antwort(speicher.probe_starten())
|
||||
|
||||
|
||||
# --- Snapshots und Sicherungen verwalten (Seite Homelab → Snapshots, seit 25.09.2026) ------------------------------
|
||||
# GET /api/homelab/snapshots je Gerät Snapshots, Sicherungen, ob gerade etwas läuft
|
||||
# POST /api/homelab/snapshots/{id}/anlegen Snapshot jetzt anlegen (freigegeben, Snapshot möglich)
|
||||
# POST /api/homelab/snapshots/{id}/{name}/loeschen nur Snapshots des Orchestrators (mc2-…)
|
||||
# POST /api/homelab/snapshots/{id}/{name}/zuruecksetzen ebenso; danach Prüfung wie nach einem Update
|
||||
# POST /api/homelab/sicherungen/{id}/{datei}/loeschen nur Sicherungen des Orchestrators (Bericht)
|
||||
# POST /api/homelab/snapshots/rueckwege-loeschen alle Rückwege von Updates, nacheinander (nur per Klick)
|
||||
# Jede Aktion ist ein Lauf wie „Jetzt updaten“; was nicht geht, beantworten sie mit 409 wie die Update-Knöpfe.
|
||||
|
||||
@router.get("/snapshots")
|
||||
def snapshots_liste() -> dict:
|
||||
from services.homelab import snapshots
|
||||
return snapshots.liste()
|
||||
|
||||
|
||||
@router.post("/snapshots/rueckwege-loeschen")
|
||||
def rueckwege_loeschen() -> dict:
|
||||
"""Alle Rückwege von Updates löschen (Snapshots mit Herkunft „update“, Sicherungen des Orchestrators) — nur per
|
||||
Klick des Users, nacheinander, mit Meldung am Ende."""
|
||||
from services.homelab import snapshots
|
||||
return _antwort(snapshots.rueckwege_loeschen())
|
||||
|
||||
|
||||
@router.post("/snapshots/{ziel_id}/anlegen")
|
||||
def snapshot_anlegen(ziel_id: str) -> dict:
|
||||
from services.homelab import snapshots
|
||||
return _antwort(snapshots.anlegen(ziel_id))
|
||||
|
||||
|
||||
@router.post("/snapshots/{ziel_id}/{name}/loeschen")
|
||||
def snapshot_loeschen(ziel_id: str, name: str) -> dict:
|
||||
from services.homelab import snapshots
|
||||
return _antwort(snapshots.loeschen(ziel_id, name))
|
||||
|
||||
|
||||
@router.post("/snapshots/{ziel_id}/{name}/zuruecksetzen")
|
||||
def snapshot_zuruecksetzen(ziel_id: str, name: str) -> dict:
|
||||
from services.homelab import snapshots
|
||||
return _antwort(snapshots.zuruecksetzen(ziel_id, name))
|
||||
|
||||
|
||||
@router.post("/sicherungen/{ziel_id}/{datei}/loeschen")
|
||||
def sicherung_loeschen(ziel_id: str, datei: str) -> dict:
|
||||
from services.homelab import snapshots
|
||||
return _antwort(snapshots.sicherung_loeschen(ziel_id, datei))
|
||||
|
||||
|
||||
@router.get("/einstellungen")
|
||||
def einstellungen_stand() -> dict:
|
||||
from services.homelab import einstellungen as homelab_einstellungen
|
||||
return homelab_einstellungen.stand()
|
||||
|
||||
|
||||
@router.post("/einstellungen/arcane-schluessel")
|
||||
async def arcane_schluessel_setzen(request: Request) -> dict:
|
||||
"""Den Body liest der Dienst selbst: Ein Pydantic-Modell würde bei falscher Form mit 422 antworten und die
|
||||
Eingabe, also den Schlüssel, dabei wiederholen."""
|
||||
from services.homelab import einstellungen as homelab_einstellungen
|
||||
wert = homelab_einstellungen.schluessel_aus_body(await request.body())
|
||||
return await asyncio.to_thread(homelab_einstellungen.arcane_schluessel_setzen, wert)
|
||||
|
||||
|
||||
@router.delete("/einstellungen/arcane-schluessel")
|
||||
def arcane_schluessel_loeschen() -> dict:
|
||||
from services.homelab import einstellungen as homelab_einstellungen
|
||||
return homelab_einstellungen.arcane_schluessel_loeschen()
|
||||
|
||||
|
||||
class ArcaneEcht(BaseModel):
|
||||
an: bool
|
||||
|
||||
|
||||
@router.post("/einstellungen/arcane-echt")
|
||||
def arcane_echt(schalter: ArcaneEcht) -> dict:
|
||||
from services.homelab import einstellungen as homelab_einstellungen
|
||||
return homelab_einstellungen.arcane_echt_setzen(schalter.an)
|
||||
|
||||
|
||||
@router.post("/einstellungen/adguard-zugang")
|
||||
async def adguard_zugang_setzen(request: Request) -> dict:
|
||||
"""Wie beim Arcane-Schlüssel liest der Dienst den Body selbst: Eine 422 würde das Passwort wiederholen."""
|
||||
from services.homelab import einstellungen as homelab_einstellungen
|
||||
werte = homelab_einstellungen.zugang_aus_body(await request.body())
|
||||
return await asyncio.to_thread(homelab_einstellungen.adguard_zugang_setzen, werte)
|
||||
|
||||
|
||||
@router.delete("/einstellungen/adguard-zugang")
|
||||
def adguard_zugang_loeschen() -> dict:
|
||||
from services.homelab import einstellungen as homelab_einstellungen
|
||||
return homelab_einstellungen.adguard_zugang_loeschen()
|
||||
|
||||
|
||||
@router.get("/ausfuehrer")
|
||||
def ausfuehrer() -> dict:
|
||||
zuletzt = kanal.zuletzt()
|
||||
return {"zuletzt": zuletzt, "verbunden": bool(zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S),
|
||||
"auftraege": kanal.liste()[:20]}
|
||||
|
||||
|
||||
@router.get("/ausfuehrer/auftrag", dependencies=[Depends(_nur_ausfuehrer)])
|
||||
def auftrag_abholen() -> dict:
|
||||
return kanal.naechster() or {}
|
||||
|
||||
|
||||
@router.post("/ausfuehrer/bericht", dependencies=[Depends(_nur_ausfuehrer)])
|
||||
def bericht(daten: dict) -> dict:
|
||||
from services.homelab import entdecken, katalog, speicher
|
||||
kanal.bericht_speichern(daten)
|
||||
speicher.verlauf_merken(daten) # Verlauf für „voll in etwa N Tagen“ (seit 25.09.2026)
|
||||
try: # „Neu entdeckt“ und der Katalog (seit 25.09.2026) halten den Bericht nie auf
|
||||
entdecken.bericht_gesehen(daten)
|
||||
katalog.ergaenzen(daten)
|
||||
threading.Thread(target=entdecken.docker_abgleichen, name="docker-abgleich", daemon=True).start()
|
||||
except Exception:
|
||||
log.warning("homelab: Abgleich mit dem Register gescheitert", exc_info=True)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
class Ergebnis(BaseModel):
|
||||
code: int
|
||||
text: str = ""
|
||||
|
||||
|
||||
@router.post("/ausfuehrer/ergebnis/{auftrag_id}", dependencies=[Depends(_nur_ausfuehrer)])
|
||||
def ergebnis(auftrag_id: str, e: Ergebnis) -> dict:
|
||||
return {"ok": kanal.ergebnis(auftrag_id, e.code, e.text)}
|
||||
|
||||
|
||||
# --- Live-Strom der Homelab-Instanz ---------------------------------------------------------------
|
||||
# Die Oberfläche hört auf /api/stream der Instanz, die sie ausliefert. Im Homelab gibt es keine
|
||||
# Messpunkte der KI-Box; der Strom stößt nur neu laden an, wenn sich Bericht, Läufe oder Hinweise ändern,
|
||||
# und meldet sich alle 10 s mit einem leeren Anstoß (sonst hielte die Oberfläche die Leitung für tot).
|
||||
_DATEIEN = (("homelab", "pve-bericht.json"), ("homelab-laeufe", "homelab-laeufe.json"),
|
||||
("homelab-hinweise", "mc2-waechter.json"))
|
||||
|
||||
|
||||
def _abdruecke() -> dict[str, float]:
|
||||
ordner = einstellungen().daten_dir
|
||||
abdruck = {}
|
||||
for schluessel, name in _DATEIEN:
|
||||
try:
|
||||
abdruck[schluessel] = (ordner / name).stat().st_mtime
|
||||
except OSError:
|
||||
abdruck[schluessel] = 0.0
|
||||
return abdruck
|
||||
|
||||
|
||||
async def _strom(request: Request, takt_s: float = 2.0, lebenszeichen_takte: int = 5):
|
||||
alt = _abdruecke()
|
||||
yield ": verbunden\n\n"
|
||||
takte = 0
|
||||
while not await request.is_disconnected():
|
||||
await asyncio.sleep(takt_s)
|
||||
takte += 1
|
||||
neu = _abdruecke()
|
||||
schluessel = [k for k, v in neu.items() if v != alt.get(k)]
|
||||
alt = neu
|
||||
if schluessel or takte % lebenszeichen_takte == 0:
|
||||
yield f"event: invalidate\ndata: {json.dumps({'keys': schluessel})}\n\n"
|
||||
|
||||
|
||||
@strom_router.get("/stream")
|
||||
async def stream(request: Request) -> StreamingResponse:
|
||||
return StreamingResponse(_strom(request), media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
||||
|
||||
|
||||
# --- Messwerte mit Verlauf (Monitoring, seit 24.09.2026) ------------------------------------------------------
|
||||
# POST /api/homelab/ausfuehrer/messwerte jede Minute vom Ausführer (Kopfzeile X-MC2-Ausfuehrer)
|
||||
# GET /api/homelab/messwerte?zeitraum=1h|24h|7d Proxmox-Host und Gäste: Reihen und letzter Punkt
|
||||
# Die Logik liegt in services/homelab/messwerte.py, die Ablage in kern/messreihen.py.
|
||||
|
||||
@router.post("/ausfuehrer/messwerte", dependencies=[Depends(_nur_ausfuehrer)])
|
||||
def messwerte_annehmen(daten: dict) -> dict:
|
||||
from services.homelab import messwerte as homelab_messwerte
|
||||
return {"ok": True, "geraete": homelab_messwerte.annehmen(daten)}
|
||||
|
||||
|
||||
@router.get("/messwerte")
|
||||
def messwerte_lesen(zeitraum: str = "1h") -> dict:
|
||||
from services.homelab import messwerte as homelab_messwerte
|
||||
if zeitraum not in homelab_messwerte.ZEITRAEUME:
|
||||
raise HTTPException(status_code=400, detail="Den Zeitraum gibt es nicht; möglich sind 1h, 24h und 7d.")
|
||||
return homelab_messwerte.abfrage(zeitraum)
|
||||
@@ -1,154 +0,0 @@
|
||||
"""Ideen-Queue-Endpoints — dünner REST-Layer über services/ideen.py (natives Hermes-Kanban).
|
||||
LAN-only wie alle MC2-Endpoints; das Mensch-Gate bleibt die Karte im Auftragsbuch."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from services import ideen
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
class IdeeIn(BaseModel):
|
||||
titel: str
|
||||
notiz: str = ""
|
||||
welt: str = "box" # "box" = Werkstatt baut · "ide" = vorbereiten für Agentic IDE
|
||||
tiefe: str = "" # nur ide: "simpel" | "gruendlich" (leer = Box schätzt selbst)
|
||||
ziel: str = "" # nur ide-Projekt: "lxc" | "docker" (leer = Box entscheidet)
|
||||
art: str = "projekt" # nur ide: "projekt" (Repo+Roadmap+Bau) | "idee" (nur durchdenken)
|
||||
|
||||
|
||||
class TaskIn(BaseModel):
|
||||
id: str
|
||||
|
||||
|
||||
class AntwortIn(BaseModel):
|
||||
id: str
|
||||
antwort: str
|
||||
|
||||
|
||||
class ArchivAlleIn(BaseModel):
|
||||
# Leer = alle fertigen Karten der Queue; gesetzt = nur die dieses Projekts.
|
||||
projekt: str = ""
|
||||
|
||||
|
||||
class EinordnenIn(BaseModel):
|
||||
text: str
|
||||
|
||||
|
||||
@router.get("/ideen")
|
||||
def list_queue() -> dict:
|
||||
return ideen.list_queue()
|
||||
|
||||
|
||||
@router.post("/ideen")
|
||||
def add_idea(body: IdeeIn) -> dict:
|
||||
res = ideen.add_idea(body.titel, body.notiz, welt=body.welt, tiefe=body.tiefe,
|
||||
ziel=body.ziel, art=body.art)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "Idee konnte nicht angelegt werden."))
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/ideen/antwort")
|
||||
def answer(body: AntwortIn) -> dict:
|
||||
res = ideen.answer_task(body.id, body.antwort)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "Antwort konnte nicht gesendet werden."))
|
||||
return res
|
||||
|
||||
|
||||
class PrioIn(BaseModel):
|
||||
id: str
|
||||
richtung: str # hoch | runter
|
||||
|
||||
|
||||
@router.post("/ideen/stopp")
|
||||
def stop(body: TaskIn) -> dict:
|
||||
res = ideen.stop_task(body.id)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "Stoppen fehlgeschlagen."))
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/ideen/retry")
|
||||
def retry(body: TaskIn) -> dict:
|
||||
res = ideen.retry_task(body.id)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "Neuer Versuch fehlgeschlagen."))
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/ideen/prio")
|
||||
def prio(body: PrioIn) -> dict:
|
||||
res = ideen.prio_task(body.id, body.richtung)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "Prio-Änderung fehlgeschlagen."))
|
||||
return res
|
||||
|
||||
|
||||
class WeiterIn(BaseModel):
|
||||
id: str
|
||||
ziel: str = "" # "lxc" | "docker" (leer = Box entscheidet)
|
||||
tiefe: str = "" # "simpel" | "gruendlich" (leer = Box schätzt selbst)
|
||||
|
||||
|
||||
@router.post("/ideen/weiterfuehren")
|
||||
def weiterfuehren(body: WeiterIn) -> dict:
|
||||
"""„Konzept gefällt mir" → IDE-Projekt-Karte mit dem fertigen Konzept als Vorlage."""
|
||||
res = ideen.projekt_aus_idee(body.id, ziel=body.ziel, tiefe=body.tiefe)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "Projekt konnte nicht angelegt werden."))
|
||||
return res
|
||||
|
||||
|
||||
class NachschaerfenIn(BaseModel):
|
||||
id: str
|
||||
hinweis: str
|
||||
|
||||
|
||||
@router.post("/ideen/nachschaerfen")
|
||||
def nachschaerfen(body: NachschaerfenIn) -> dict:
|
||||
"""„Punkt X passt nicht" → Überarbeitungs-Runde am bestehenden Konzept."""
|
||||
res = ideen.konzept_ueberarbeiten(body.id, body.hinweis)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "Überarbeitung konnte nicht gestartet werden."))
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/ideen/archivieren")
|
||||
def archive(body: TaskIn) -> dict:
|
||||
res = ideen.archive_task(body.id)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "Archivieren fehlgeschlagen."))
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/ideen/einordnen")
|
||||
def einordnen(body: EinordnenIn) -> dict:
|
||||
"""Einordnungs-VORSCHLAG beim Tippen. Wirft nie — scheitert es, kommt `ok: false`
|
||||
und die Oberfläche behält ihre Voreinstellung."""
|
||||
return ideen.einordnen(body.text)
|
||||
|
||||
|
||||
@router.post("/ideen/archivieren-alle")
|
||||
def archive_alle(body: ArchivAlleIn) -> dict:
|
||||
"""Fertige Karten sammelweise wegräumen. Fasst ausschließlich `done` an."""
|
||||
res = ideen.archive_done(body.projekt)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("error", "Archivieren fehlgeschlagen."))
|
||||
return res
|
||||
|
||||
|
||||
@router.get("/ideen/{id}/log")
|
||||
def get_log(id: str) -> dict:
|
||||
return ideen.log_of(id)
|
||||
|
||||
|
||||
@router.get("/ideen/{id}/ergebnis")
|
||||
def get_ergebnis(id: str) -> dict:
|
||||
return ideen.ergebnis_of(id)
|
||||
|
||||
|
||||
@router.get("/ideen/{id}/konzept")
|
||||
def get_konzept(id: str) -> dict:
|
||||
return ideen.konzept_of(id)
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Lucys Freiheiten, Rückfragen, Untersuchungen und Wochenbericht (Ausbauplan Welle 3, seit 25.09.2026; nur KI-Box).
|
||||
|
||||
GET /api/lucy/autonomie Stufe je Aktionsart (nur lesen, erst fragen, selbst handeln)
|
||||
POST /api/lucy/autonomie {"stufen": {art: stufe}}
|
||||
POST /api/lucy/gehandelt Meldung danach: {"was", "grund", "ergebnis"} (Lucys Werkzeuge)
|
||||
POST /api/lucy/befund roter Hinweis vom Wächter → Zeilen und Antworttasten für seine Nachricht
|
||||
POST /api/lucy/ermitteln Lucy untersucht einen Hinweis {"bereich", "id", "titel", "text"}
|
||||
GET /api/lucy/wochenbericht der letzte und wann der nächste kommt
|
||||
POST /api/lucy/wochenbericht jetzt schreiben und schicken (Timer sonntags 07:30, Knopf)
|
||||
GET /api/rueckfragen offene und zuletzt beantwortete
|
||||
POST /api/rueckfragen stellen: {"frage", "pfad", "daten", "text", "von", "stufe", "befund", "still"}
|
||||
POST /api/rueckfragen/antwort {"nr": 7 oder null, "ja": bool, "wer"} — ohne Nummer nur, wenn genau eine offen ist
|
||||
POST /api/rueckfragen/{nr}/ja|nein die Knöpfe der Oberfläche
|
||||
Was nicht geht, beantworten sie mit HTTP 409 und detail (wie die Update-Knöpfe).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException
|
||||
from services import ermittlung, lucy_autonomie, rueckfragen, wochenbericht
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["lucy"])
|
||||
|
||||
|
||||
def _antwort(ergebnis: dict) -> dict:
|
||||
if not ergebnis.get("ok"):
|
||||
raise HTTPException(status_code=409, detail=ergebnis.get("detail") or "Das geht gerade nicht.")
|
||||
return ergebnis
|
||||
|
||||
|
||||
@router.get("/lucy/autonomie")
|
||||
def autonomie() -> dict:
|
||||
return lucy_autonomie.stand()
|
||||
|
||||
|
||||
@router.post("/lucy/autonomie")
|
||||
def autonomie_setzen(daten: dict = Body(...)) -> dict:
|
||||
return _antwort(lucy_autonomie.setzen(daten))
|
||||
|
||||
|
||||
@router.post("/lucy/gehandelt")
|
||||
def gehandelt(daten: dict = Body(...)) -> dict:
|
||||
return _antwort(lucy_autonomie.gehandelt(daten))
|
||||
|
||||
|
||||
@router.post("/lucy/befund")
|
||||
def befund(daten: dict = Body(...)) -> dict:
|
||||
return _antwort(ermittlung.befund_gemeldet(daten))
|
||||
|
||||
|
||||
@router.post("/lucy/ermitteln")
|
||||
def ermitteln(daten: dict = Body(...)) -> dict:
|
||||
return _antwort(ermittlung.starten(daten))
|
||||
|
||||
|
||||
@router.get("/lucy/wochenbericht")
|
||||
def wochenbericht_stand() -> dict:
|
||||
return wochenbericht.stand()
|
||||
|
||||
|
||||
@router.post("/lucy/wochenbericht")
|
||||
def wochenbericht_senden() -> dict:
|
||||
return _antwort(wochenbericht.senden())
|
||||
|
||||
|
||||
@router.get("/rueckfragen")
|
||||
def rueckfragen_liste(grenze: int = 20) -> dict:
|
||||
return rueckfragen.liste(grenze)
|
||||
|
||||
|
||||
@router.post("/rueckfragen")
|
||||
def rueckfrage_stellen(daten: dict = Body(...)) -> dict:
|
||||
return _antwort(rueckfragen.stellen(
|
||||
str(daten.get("frage") or ""), str(daten.get("pfad") or ""),
|
||||
daten.get("daten") if isinstance(daten.get("daten"), dict) else None,
|
||||
text=str(daten.get("text") or ""), von=str(daten.get("von") or "lucy"),
|
||||
stufe=str(daten.get("stufe") or "normal"), befund=daten.get("befund"), still=bool(daten.get("still"))))
|
||||
|
||||
|
||||
@router.post("/rueckfragen/antwort")
|
||||
def rueckfrage_antwort(daten: dict = Body(...)) -> dict:
|
||||
nr = daten.get("nr")
|
||||
if nr is not None and not isinstance(nr, int):
|
||||
raise HTTPException(status_code=422, detail="nr muss eine Zahl sein.")
|
||||
return _antwort(rueckfragen.beantworten(nr, bool(daten.get("ja")), wer=str(daten.get("wer") or "lucy")))
|
||||
|
||||
|
||||
@router.post("/rueckfragen/{nr}/ja")
|
||||
def rueckfrage_ja(nr: int) -> dict:
|
||||
return _antwort(rueckfragen.beantworten(nr, True, wer="oberflaeche"))
|
||||
|
||||
|
||||
@router.post("/rueckfragen/{nr}/nein")
|
||||
def rueckfrage_nein(nr: int) -> dict:
|
||||
return _antwort(rueckfragen.beantworten(nr, False, wer="oberflaeche"))
|
||||
@@ -1,19 +1,28 @@
|
||||
"""Wartungs-Endpoints: Update-Badge, OS-/Engine-Update, Reboot, Restart, Logs."""
|
||||
"""Wartungs-Endpoints: Update-Badge, OS-/Engine-Update, Reboot, Restart, Logs.
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException
|
||||
v3-Umbau P1 (28.08.2026): Das Sudo-Passwort ist hier ersatzlos entfallen. Auf der Box
|
||||
gemessen — `sudo -n true` läuft durch, weil `/etc/sudoers` den Dienst-Nutzer mit
|
||||
`NOPASSWD: ALL` führt. Das Passwort wurde also nie gebraucht, lag aber im
|
||||
`localStorage` des Browsers und reiste bei jedem mutierenden Request mit. Sollte die
|
||||
sudoers-Zeile je fallen, meldet `services.maintenance` sauber `password_required`
|
||||
statt still zu scheitern — das ist dann ein Konfigurations-Signal und nichts, was man
|
||||
mit einem im Browser geparkten Geheimnis übertüncht.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from services import maintenance
|
||||
from services import geheimnisse, maintenance
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
class SudoReq(BaseModel):
|
||||
sudo_password: str | None = None
|
||||
|
||||
|
||||
class RestartReq(BaseModel):
|
||||
service: str
|
||||
sudo_password: str | None = None
|
||||
|
||||
|
||||
class GeheimnisReq(BaseModel):
|
||||
schluessel: str
|
||||
wert: str | None = None
|
||||
|
||||
|
||||
@router.get("/maintenance/updates")
|
||||
@@ -28,57 +37,67 @@ def update_details(kind: str) -> dict:
|
||||
return maintenance.update_details(kind)
|
||||
|
||||
@router.post("/maintenance/check-updates")
|
||||
def check_updates(body: SudoReq) -> dict:
|
||||
res = maintenance.check_updates_job(body.sudo_password)
|
||||
def check_updates() -> dict:
|
||||
res = maintenance.check_updates_job()
|
||||
if isinstance(res, dict) and not res.get("ok", True):
|
||||
return res
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/maintenance/os-update")
|
||||
def os_update(body: SudoReq) -> dict:
|
||||
res = maintenance.os_update_job(body.sudo_password)
|
||||
def os_update() -> dict:
|
||||
res = maintenance.os_update_job()
|
||||
if isinstance(res, dict) and not res.get("ok", True):
|
||||
return res
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/maintenance/engine-update")
|
||||
def engine_update(body: SudoReq) -> dict:
|
||||
res = maintenance.engine_update_job(body.sudo_password)
|
||||
def engine_update() -> dict:
|
||||
res = maintenance.engine_update_job()
|
||||
if not res:
|
||||
raise HTTPException(400, "Kein Engine-Update-Befehl gesetzt (MC_ENGINE_UPDATE_CMD).")
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/maintenance/swap-update")
|
||||
def swap_update(body: SudoReq) -> dict:
|
||||
res = maintenance.swap_update_job(body.sudo_password)
|
||||
def swap_update() -> dict:
|
||||
res = maintenance.swap_update_job()
|
||||
if not res:
|
||||
raise HTTPException(400, "Kein Router-Update-Befehl gesetzt (MC_SWAP_UPDATE_CMD).")
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/maintenance/hermes-update")
|
||||
def hermes_update() -> dict:
|
||||
return maintenance.hermes_update_job()
|
||||
def hermes_update(verlauf: bool = True) -> dict:
|
||||
"""verlauf=false: Der Sonntags-Lauf trägt das Ergebnis selbst in seinen Update-Verlauf ein."""
|
||||
return maintenance.hermes_update_job(verlauf=verlauf)
|
||||
|
||||
|
||||
@router.post("/maintenance/update-all")
|
||||
def update_all(body: SudoReq) -> dict:
|
||||
return maintenance.update_all_job(body.sudo_password)
|
||||
|
||||
|
||||
@router.post("/maintenance/reboot")
|
||||
def reboot(body: SudoReq) -> dict:
|
||||
return maintenance.reboot(body.sudo_password)
|
||||
def update_all() -> dict:
|
||||
return maintenance.update_all_job()
|
||||
|
||||
|
||||
@router.post("/maintenance/restart")
|
||||
def restart(body: RestartReq) -> dict:
|
||||
return maintenance.restart_service(body.service, body.sudo_password)
|
||||
return maintenance.restart_service(body.service)
|
||||
|
||||
|
||||
@router.get("/maintenance/logs")
|
||||
def logs(service: str, lines: int = 200, x_sudo_password: str | None = Header(None)) -> dict:
|
||||
return maintenance.logs(service, lines, x_sudo_password)
|
||||
def logs(service: str, lines: int = 200) -> dict:
|
||||
return maintenance.logs(service, lines)
|
||||
|
||||
|
||||
@router.get("/maintenance/geheimnisse")
|
||||
def geheimnisse_status() -> dict:
|
||||
"""Nur der Zustand — ob ein Token gesetzt ist, niemals sein Wert."""
|
||||
return geheimnisse.status()
|
||||
|
||||
|
||||
@router.post("/maintenance/geheimnisse")
|
||||
def geheimnisse_setzen(body: GeheimnisReq) -> dict:
|
||||
"""Setzt (oder löscht bei leerem Wert) ein Geheimnis in der Box-Ablage."""
|
||||
if not geheimnisse.setzen(body.schluessel, body.wert):
|
||||
raise HTTPException(400, f"Geheimnis '{body.schluessel}' konnte nicht gespeichert werden.")
|
||||
return {"ok": True, **geheimnisse.status()}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Messwerte der KI-Box mit Verlauf (Rolle box, seit 24.09.2026).
|
||||
|
||||
GET /api/messwerte?zeitraum=1h|24h|7d Minutenwerte des Stewards, verdichtet auf 60 s, 5 min bzw. 30 min
|
||||
|
||||
Dünn: die Logik liegt in services/messwerte.py, die Ablage in kern/messreihen.py.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from services import messwerte
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["messwerte"])
|
||||
|
||||
|
||||
@router.get("/messwerte")
|
||||
def messwerte_lesen(zeitraum: str = "1h") -> dict:
|
||||
if zeitraum not in messwerte.ZEITRAEUME:
|
||||
raise HTTPException(status_code=400, detail="Den Zeitraum gibt es nicht; möglich sind 1h, 24h und 7d.")
|
||||
return messwerte.abfrage(zeitraum)
|
||||
+41
-133
@@ -1,11 +1,12 @@
|
||||
"""Modelle-Endpoints: Liste (mit Caps), Discover, Fit, Register, Groups."""
|
||||
"""Modelle-Endpoints: Liste, Discover, Suche und Installation bei Hugging Face, Jobs, Rollen,
|
||||
Laden/Entladen/Löschen. Fit-, Kontext-, Draft- und Gruppen-Routen sind am 24.09.2026 entfallen
|
||||
(ohne Nutzer seit dem Box-Wart-Umbau)."""
|
||||
|
||||
import psutil
|
||||
from config import HF_DOWNLOAD_ENV, MODELS_DIR
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from services import budget, discover, hf, jobengine, llamaswap
|
||||
from services.fit import evaluate_fit, max_ctx_for
|
||||
from services import budget, discover, geheimnisse, hf, jobengine, llamaswap
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
@@ -29,27 +30,6 @@ def discover_models(force: bool = False) -> dict:
|
||||
return {**data, "sys_ram_gb": round(ram, 1)}
|
||||
|
||||
|
||||
@router.get("/fit")
|
||||
def fit(params_b: float = 0, quant: str = "Q4_K_M", ctx: int = 8192,
|
||||
name: str = "", role: str = "") -> dict:
|
||||
"""Hardware-Fit-Vorschau. params_b<=0 → aus KATALOG (echte Metadaten, MoE-bewusst)
|
||||
oder sonst aus dem Namen geschätzt. assigned_ctx = der ctx, der TATSÄCHLICH vergeben
|
||||
würde: SETUP-BEWUSST (neben Hirn/warmem Set), nicht nur gegen den Gesamt-RAM.
|
||||
So sieht die 'Erweiterte Ansicht' vor dem Download Ampel + echten ctx."""
|
||||
ram = _ram_gb()
|
||||
pb = params_b if params_b > 0 else budget.params_b_for(name)
|
||||
saw = budget.setup_aware_ctx(pb, quant, role=role or None)
|
||||
return {
|
||||
"params_b": round(pb, 1),
|
||||
"fit": evaluate_fit(pb, quant, ctx, ram, name=name),
|
||||
"optimal_ctx": max_ctx_for(pb, quant, ram), # Roh-Obergrenze (Modell allein)
|
||||
"assigned_ctx": saw["ctx"], # setup-bewusst vergeben
|
||||
"budget": {"gtt_gb": saw["gtt_gb"], "reserved_gb": saw["reserved_gb"],
|
||||
"budget_gb": saw["budget_gb"], "mode": saw["mode"]},
|
||||
"sys_ram_gb": round(ram, 1),
|
||||
}
|
||||
|
||||
|
||||
class RegisterReq(BaseModel):
|
||||
model_path: str
|
||||
role: str | None = None
|
||||
@@ -61,6 +41,10 @@ class RegisterReq(BaseModel):
|
||||
|
||||
@router.post("/models/register")
|
||||
def register(req: RegisterReq) -> dict:
|
||||
# Nur Dateien aus dem Modellordner (24.09.2026): der Pfad landet im Startbefehl der Engine.
|
||||
for pfad in (req.model_path, req.mmproj_path):
|
||||
if pfad and not llamaswap.im_modellordner(pfad):
|
||||
raise HTTPException(400, f"Pfad liegt nicht im Modellordner ({MODELS_DIR}): {pfad}")
|
||||
try:
|
||||
model_id = llamaswap.register_model(
|
||||
req.model_path, role=req.role, ctx=req.ctx, ttl=req.ttl,
|
||||
@@ -71,13 +55,29 @@ def register(req: RegisterReq) -> dict:
|
||||
return {"ok": True, "model_id": model_id}
|
||||
|
||||
|
||||
@jobengine.nacharbeit("modell:rolle")
|
||||
def _rolle_uebernehmen(model_id: str, role: str) -> None:
|
||||
"""Nach dem Download die gewünschte Rolle setzen. hermes geht dabei durch den warm-bewussten
|
||||
Hirn-Flow (brains-Gruppe, ttl 0, Hermes-Config, Gateway-Restart) — der rohe Alias-Move hatte
|
||||
diesen Flow bisher umgangen."""
|
||||
if role == "hermes":
|
||||
from services.agent import set_agent_brain
|
||||
res = set_agent_brain(model_id)
|
||||
if not res.get("ok"):
|
||||
raise RuntimeError(res.get("reason", "Hirn-Wechsel fehlgeschlagen"))
|
||||
else:
|
||||
llamaswap.set_role(model_id, role)
|
||||
|
||||
|
||||
class InstallReq(BaseModel):
|
||||
repo: str
|
||||
role: str | None = None
|
||||
quant: str = "Q4_K_M"
|
||||
ctx: int | None = None
|
||||
jinja: bool = False
|
||||
hf_token: str | None = None
|
||||
# Kein hf_token mehr im Request (v3-Umbau P1): der Token lag frueher im localStorage
|
||||
# des Browsers und reiste hier mit. Jetzt liegt er auf der Box (services.geheimnisse)
|
||||
# und wird unten von dort gelesen — die Oberflaeche sieht ihn nie wieder.
|
||||
|
||||
|
||||
@router.get("/hf/search")
|
||||
@@ -123,20 +123,9 @@ def install(req: InstallReq) -> dict:
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(500, str(exc))
|
||||
|
||||
# Rolle erst NACH erfolgreichem Download übernehmen. hermes geht dabei durch den
|
||||
# warm-bewussten Hirn-Flow (brains-Gruppe, ttl 0, Hermes-Config, Gateway-Restart) —
|
||||
# der rohe Alias-Move hatte diesen Flow bisher umgangen.
|
||||
# Rolle erst NACH erfolgreichem Download übernehmen (_rolle_uebernehmen).
|
||||
role = (req.role or "").strip().lower()
|
||||
|
||||
def _apply_role() -> None:
|
||||
if role == "hermes":
|
||||
from services.agent import set_agent_brain
|
||||
res = set_agent_brain(model_id)
|
||||
if not res.get("ok"):
|
||||
raise RuntimeError(res.get("reason", "Hirn-Wechsel fehlgeschlagen"))
|
||||
else:
|
||||
llamaswap.set_role(model_id, role)
|
||||
|
||||
# Download-Job: alle GGUF-Teile (+ mmproj) per --include holen.
|
||||
args = [hf.hf_bin(), "download", repo]
|
||||
args.extend(info["files"])
|
||||
@@ -144,10 +133,13 @@ def install(req: InstallReq) -> dict:
|
||||
args.append(info["mmproj"])
|
||||
args += ["--local-dir", str(target)]
|
||||
env = dict(HF_DOWNLOAD_ENV)
|
||||
if req.hf_token:
|
||||
env["HF_TOKEN"] = req.hf_token
|
||||
job_id = jobengine.start_job(args, f"download {req.repo}", env=env,
|
||||
on_done=_apply_role if role else None)
|
||||
if token := geheimnisse.hf_token():
|
||||
env["HF_TOKEN"] = token
|
||||
# Gruppe „download“: so taucht der Download in der Oberfläche mit Fortschritt und Abbrechen auf.
|
||||
# Der Download läuft als eigener Auftrag weiter, auch wenn MC2 zwischendurch neu startet.
|
||||
job_id = jobengine.start_job(args, f"Download {repo}", env=env, group="download", zeitlimit_s=6 * 3600,
|
||||
nacharbeit="modell:rolle" if role else None,
|
||||
nacharbeit_daten={"model_id": model_id, "role": role} if role else None)
|
||||
jobengine.attach_download_progress(job_id, str(target), info["total_bytes"])
|
||||
return {"ok": True, "job_id": job_id, "model_id": model_id, "model_path": model_path,
|
||||
"total_bytes": info["total_bytes"], "files": len(info["files"])}
|
||||
@@ -163,18 +155,16 @@ def cancel(job_id: str) -> dict:
|
||||
return {"ok": jobengine.cancel_job(job_id)}
|
||||
|
||||
|
||||
@router.post("/jobs/{job_id}/quittieren")
|
||||
def quittieren(job_id: str) -> dict:
|
||||
"""Beendeten Auftrag als gesehen markieren (die Oberfläche blendet ihn aus)."""
|
||||
return {"ok": jobengine.quittieren(job_id)}
|
||||
|
||||
|
||||
class RoleReq(BaseModel):
|
||||
role: str | None = None
|
||||
|
||||
|
||||
@router.get("/roles/{role}/recommend")
|
||||
def recommend_role(role: str) -> dict:
|
||||
"""Welches installierte Modell passt am besten auf diese Rolle? (Capability + setup-
|
||||
bewusster Fit). Basis für 'Empfohlen'-Hinweis + Auto-Pick im Rollen-Zuweisungs-Modal."""
|
||||
from services import roles
|
||||
return roles.recommend_for_role(role)
|
||||
|
||||
|
||||
@router.post("/models/{model_id}/role")
|
||||
def set_model_role(model_id: str, body: RoleReq) -> dict:
|
||||
new_role = (body.role or "").strip().lower()
|
||||
@@ -199,54 +189,6 @@ def set_model_role(model_id: str, body: RoleReq) -> dict:
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
class CtxReq(BaseModel):
|
||||
ctx: int
|
||||
|
||||
|
||||
@router.get("/models/{model_id}/ctx/auto")
|
||||
def auto_ctx(model_id: str) -> dict:
|
||||
"""Setup-bewusster Optimal-ctx für ein bestehendes Modell (Rolle/Params/Quant +
|
||||
aktuelles Setup). Basis für den 'Auto'-Button an der Modellkarte."""
|
||||
m = next((x for x in llamaswap.list_models() if x["name"] == model_id), None)
|
||||
if not m:
|
||||
raise HTTPException(404, "Modell nicht gefunden")
|
||||
saw = budget.setup_aware_ctx_for_model(m)
|
||||
return {"model_id": model_id, "current_ctx": m.get("ctx"),
|
||||
"params_b": round(budget.params_of_model(m), 1), "quant": m.get("quant"),
|
||||
"role": m.get("role"), **saw}
|
||||
|
||||
|
||||
@router.post("/models/{model_id}/ctx")
|
||||
def set_model_ctx(model_id: str, body: CtxReq) -> dict:
|
||||
if not llamaswap.set_ctx(model_id, body.ctx):
|
||||
raise HTTPException(404, "Modell nicht gefunden")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/models/drafts")
|
||||
def list_drafts(target: str = "") -> dict:
|
||||
"""Verfügbare Draft-Modelle + ihre Vocab-Kompatibilität zum Ziel-Modell
|
||||
(target = GGUF-Pfad). Basis für die idiotensichere Spec-Draft-Auswahl im UI."""
|
||||
return llamaswap.drafts_for(target)
|
||||
|
||||
|
||||
class DraftReq(BaseModel):
|
||||
draft_path: str | None = None
|
||||
|
||||
|
||||
@router.post("/models/{model_id}/draft")
|
||||
def set_model_draft(model_id: str, body: DraftReq) -> dict:
|
||||
"""Setzt/entfernt den Speculative-Decoding-Draft eines Modells. Inkompatible
|
||||
(oder nicht prüfbare) Drafts werden serverseitig abgelehnt."""
|
||||
try:
|
||||
res = llamaswap.set_spec_draft(model_id, body.draft_path)
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(500, str(exc))
|
||||
if not res["ok"]:
|
||||
raise HTTPException(400 if "kompatib" in res["reason"].lower() else 404, res["reason"])
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/models/unload")
|
||||
def unload_all_models() -> dict:
|
||||
import httpx
|
||||
@@ -273,21 +215,8 @@ def unload_model(model_id: str) -> dict:
|
||||
|
||||
@router.post("/models/{model_id}/load")
|
||||
def load_model(model_id: str) -> dict:
|
||||
import httpx
|
||||
from config import LLAMA_SWAP_URL
|
||||
try:
|
||||
# Trigger load by sending a lightweight completion request.
|
||||
body = {
|
||||
"model": model_id,
|
||||
"messages": [{"role": "user", "content": "ping"}],
|
||||
"max_tokens": 1
|
||||
}
|
||||
# High timeout because model loading might take time
|
||||
with httpx.Client(timeout=60.0) as c:
|
||||
c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=body)
|
||||
return {"ok": True}
|
||||
except Exception as exc:
|
||||
raise HTTPException(500, str(exc))
|
||||
"""Lädt ein Modell. Kommt es nicht zustande, steht ok: false mit dem Grund in `detail` (services/llamaswap)."""
|
||||
return llamaswap.lade_modell(model_id)
|
||||
|
||||
|
||||
@router.delete("/models/{model_id}")
|
||||
@@ -295,24 +224,3 @@ def delete(model_id: str) -> dict:
|
||||
if not llamaswap.delete_model(model_id):
|
||||
raise HTTPException(404, "Modell nicht gefunden")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/groups")
|
||||
def groups() -> dict:
|
||||
return {"groups": llamaswap.list_groups()}
|
||||
|
||||
|
||||
class GroupReq(BaseModel):
|
||||
group: str
|
||||
members: list[str]
|
||||
swap: bool = False
|
||||
persist: bool = False
|
||||
|
||||
|
||||
@router.put("/groups")
|
||||
def set_group(req: GroupReq) -> dict:
|
||||
try:
|
||||
llamaswap.set_group(req.group, req.members, swap=req.swap, persist=req.persist)
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(500, str(exc))
|
||||
return {"ok": True}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Partner-Instanz: Lebenszeichen und Durchreiche (zwei Instanzen, eine Oberfläche, 24.09.2026).
|
||||
|
||||
Die Oberfläche fragt den Bereich der anderen Instanz über /api/partner/<pfad> ab; hier wird
|
||||
daraus <partner>/api/<pfad>. Die Herkunftsprüfung (services/herkunft.py) greift wie bei jeder
|
||||
anderen schreibenden Anfrage schon hier, bevor etwas weitergereicht wird.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
from kern import partner
|
||||
from kern.einstellungen import einstellungen
|
||||
from services import software_stand
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["partner"])
|
||||
|
||||
# Nicht durchreichen: den Partner-Weg des Partners (sonst reichen sich zwei Instanzen eine Anfrage
|
||||
# endlos zu) und den Live-Strom (SSE hält die Verbindung offen; der Partner-Bereich fragt ab).
|
||||
_GESPERRT = ("partner", "stream")
|
||||
_ZEITLIMIT = httpx.Timeout(30.0, connect=5.0)
|
||||
|
||||
|
||||
@router.get("/instanz")
|
||||
def instanz() -> dict:
|
||||
"""Wer liefert die Seite aus? Ohne Netzabfragen, damit die Oberfläche sofort weiß, welche Anfragen
|
||||
sie selbst beantwortet und welche über /api/partner/… an die andere Instanz gehen. Seit 24.09.2026 mit dem
|
||||
Software-Stand (welcher Commit seit wann live ist); den der anderen Instanz liefert /api/partner/instanz."""
|
||||
e = einstellungen()
|
||||
return {"rolle": e.rolle, "instanz": e.instanz,
|
||||
"partner": {"eingerichtet": bool(e.partner_url), "name": e.partner_name},
|
||||
"stand": software_stand.stand()}
|
||||
|
||||
|
||||
@router.get("/partner")
|
||||
def partner_status() -> dict:
|
||||
return partner.status()
|
||||
|
||||
|
||||
@router.api_route("/partner/{pfad:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"], include_in_schema=False)
|
||||
async def weiterreichen(pfad: str, request: Request) -> Response:
|
||||
e = einstellungen()
|
||||
if not e.partner_url:
|
||||
raise HTTPException(status_code=404, detail="Es ist keine zweite Instanz eingerichtet.")
|
||||
if pfad.split("/", 1)[0] in _GESPERRT:
|
||||
raise HTTPException(status_code=404, detail="Diese Schnittstelle wird nicht weitergereicht.")
|
||||
kopf = {"X-MC-Von": e.instanz}
|
||||
if request.headers.get("content-type"):
|
||||
kopf["Content-Type"] = request.headers["content-type"]
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_ZEITLIMIT) as c:
|
||||
r = await c.request(request.method, f"{e.partner_url}/api/{pfad}",
|
||||
params=request.query_params, content=await request.body(), headers=kopf)
|
||||
except httpx.HTTPError:
|
||||
raise HTTPException(status_code=502, detail=f"{e.partner_name} ist gerade nicht erreichbar.") from None
|
||||
return Response(content=r.content, status_code=r.status_code,
|
||||
media_type=r.headers.get("content-type", "application/json"))
|
||||
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
Modell-Radar-Schnittstellen (Box-Wart, Umbau 09/2026).
|
||||
|
||||
GET /api/radar Nächster und letzter Test, Kandidaten, Test-Verlauf
|
||||
POST /api/radar/suche Jetzt suchen (Merkliste + Hugging-Face-Entdeckung) — als Auftrag, antwortet sofort
|
||||
POST /api/radar/{id}/uebernehmen Bestandenen Kandidaten in Betrieb nehmen (Modell-Tausch)
|
||||
POST /api/radar/{id}/verwerfen Kandidaten verwerfen (Dateien weg, nie wieder testen)
|
||||
POST /api/radar/{id}/testen Jetzt testen (seit 25.09.2026; nur wenn er samt Coder in den Speicher passt)
|
||||
|
||||
Dünn: die Logik liegt in services/radar.py. Getestet wird nachts (backend/radar_lauf.py) oder auf Knopfdruck.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from services import radar
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["radar"])
|
||||
|
||||
|
||||
def _antwort(ergebnis: dict) -> dict:
|
||||
if not ergebnis.get("ok"):
|
||||
raise HTTPException(status_code=int(ergebnis.get("status") or 409),
|
||||
detail=ergebnis.get("detail") or "Das ging nicht.")
|
||||
return ergebnis
|
||||
|
||||
|
||||
@router.get("/radar")
|
||||
def radar_stand() -> dict:
|
||||
return radar.uebersicht()
|
||||
|
||||
|
||||
@router.post("/radar/suche")
|
||||
def radar_suche() -> dict:
|
||||
"""Startet die Suche als Auftrag (sie kann Minuten dauern) und antwortet sofort mit der Auftrags-ID."""
|
||||
return radar.suche_starten()
|
||||
|
||||
|
||||
@router.post("/radar/{kandidat_id}/uebernehmen")
|
||||
def radar_uebernehmen(kandidat_id: str) -> dict:
|
||||
return _antwort(radar.uebernehmen(kandidat_id))
|
||||
|
||||
|
||||
@router.post("/radar/{kandidat_id}/testen")
|
||||
def radar_testen(kandidat_id: str) -> dict:
|
||||
"""„Jetzt testen“ (seit 25.09.2026): nur, wenn der Kandidat samt Coder neben das Warm-Set passt und nicht in
|
||||
der Nacht-Sperre — sonst 409 mit Grund. Antwortet sofort mit der Auftrags-ID."""
|
||||
return _antwort(radar.test_starten(kandidat_id))
|
||||
|
||||
|
||||
@router.post("/radar/{kandidat_id}/verwerfen")
|
||||
def radar_verwerfen(kandidat_id: str) -> dict:
|
||||
return _antwort(radar.verwerfen(kandidat_id))
|
||||
@@ -1,9 +1,8 @@
|
||||
"""Routing-Endpoints: Lane-Summary (chat/coding) + UI-editierbare Policy (hot-reload)."""
|
||||
"""Routing-Übersicht (Lanes, Gateway erreichbar). Der Policy-Editor ist seit dem Box-Wart-Umbau
|
||||
aus der Oberfläche raus; die Policy selbst liest der Gateway weiter aus mc2-routing.json."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter
|
||||
from services import gateway
|
||||
from services.routing_policy import policy_meta, save_policy
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
@@ -11,32 +10,3 @@ router = APIRouter(prefix="/api")
|
||||
@router.get("/routing")
|
||||
def routing() -> dict:
|
||||
return {**gateway.routing_summary(), "gateway_reachable": gateway.gateway_reachable()}
|
||||
|
||||
|
||||
@router.get("/routing/policy")
|
||||
def get_policy() -> dict:
|
||||
"""Aktuelle Policy + Defaults (für „Zurücksetzen“) + Feld-Spezifikation für den Editor."""
|
||||
return policy_meta()
|
||||
|
||||
|
||||
class PolicyPatch(BaseModel):
|
||||
fast: str | None = None
|
||||
heavy: str | None = None
|
||||
coder: str | None = None
|
||||
coder_lite: str | None = None
|
||||
heavy_chars: int | None = None
|
||||
coding_escalate_chars: int | None = None
|
||||
fast_no_think: bool | None = None
|
||||
|
||||
|
||||
@router.put("/routing/policy")
|
||||
def put_policy(patch: PolicyPatch) -> dict:
|
||||
"""Teil-Update der Routing-Policy. Validiert, persistiert atomar, sofort wirksam (hot-reload)."""
|
||||
fields = {k: v for k, v in patch.model_dump().items() if v is not None}
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren.")
|
||||
try:
|
||||
new_policy = save_policy(fields)
|
||||
except (ValueError, TypeError) as e:
|
||||
raise HTTPException(status_code=400, detail=f"Ungültige Policy: {e}")
|
||||
return {"policy": new_policy}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import psutil
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
class RunSkillRequest(BaseModel):
|
||||
skill_name: str
|
||||
|
||||
@router.get("/skills")
|
||||
def list_skills():
|
||||
"""Listet alle verfügbaren Skills aus dem deploy/skills Verzeichnis auf."""
|
||||
# Pfad relativ zu dieser Datei (backend/routers/skills.py)
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
repo_root = os.path.dirname(os.path.dirname(current_dir))
|
||||
skills_dir = os.path.join(repo_root, "deploy", "skills")
|
||||
skills = []
|
||||
|
||||
# Check currently running hermes skill processes
|
||||
running_skills = set()
|
||||
for p in psutil.process_iter(['name', 'cmdline']):
|
||||
try:
|
||||
cmdline = p.info.get('cmdline')
|
||||
if cmdline and any("hermes" in arg for arg in cmdline):
|
||||
for arg in cmdline:
|
||||
if "Führe den " in arg and " Skill aus" in arg:
|
||||
# Extract skill name from "Führe den 'skill_name' Skill aus"
|
||||
import re
|
||||
match = re.search(r"Führe den '(.+?)' Skill aus", arg)
|
||||
if match:
|
||||
running_skills.add(match.group(1))
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
pass
|
||||
|
||||
if os.path.exists(skills_dir) and os.path.isdir(skills_dir):
|
||||
for entry in os.scandir(skills_dir):
|
||||
if entry.is_dir():
|
||||
# Suche nach SKILL.md für Metadaten
|
||||
skill_md_path = os.path.join(entry.path, "SKILL.md")
|
||||
description = ""
|
||||
if os.path.exists(skill_md_path):
|
||||
try:
|
||||
with open(skill_md_path, "r", encoding="utf-8") as f:
|
||||
# Lese erste Zeilen für Description (YAML Frontmatter)
|
||||
lines = f.readlines()[:10]
|
||||
for line in lines:
|
||||
if "description:" in line.lower():
|
||||
description = line.split(":", 1)[1].strip().strip('"\'')
|
||||
break
|
||||
elif "author:" in line.lower() and not description:
|
||||
description = line.strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
skills.append({
|
||||
"name": entry.name,
|
||||
"description": description or "Keine Beschreibung verfügbar.",
|
||||
"running": entry.name in running_skills
|
||||
})
|
||||
return {"skills": skills}
|
||||
|
||||
@router.post("/skills/run")
|
||||
def run_skill(req: RunSkillRequest):
|
||||
"""Startet einen autonomen Skill via Hermes im Hintergrund."""
|
||||
hermes_bin = os.path.expanduser("~/.local/bin/hermes")
|
||||
if not os.path.exists(hermes_bin):
|
||||
# Fallback falls lokal (auf Windows) entwickelt wird
|
||||
return {"ok": False, "err": "Hermes CLI nicht gefunden (~/.local/bin/hermes fehlt). Bist du lokal unterwegs?"}
|
||||
|
||||
# Entspricht: hermes -z "Führe den 'X' Skill aus" chat
|
||||
cmd = [hermes_bin, "-z", f"Führe den '{req.skill_name}' Skill aus", "chat"]
|
||||
try:
|
||||
log_path = os.path.expanduser(f"~/.hermes/logs/skill_{req.skill_name.replace('/', '_')}.log")
|
||||
os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
||||
with open(log_path, "a") as f:
|
||||
f.write(f"\n--- Starting Skill: {req.skill_name} ---\n")
|
||||
subprocess.Popen(cmd, stdout=f, stderr=subprocess.STDOUT)
|
||||
return {"ok": True, "msg": f"Skill '{req.skill_name}' erfolgreich angestoßen."}
|
||||
except Exception as e:
|
||||
return {"ok": False, "err": str(e)}
|
||||
+13
-108
@@ -1,4 +1,4 @@
|
||||
"""System-Endpoints: Live-Status + Wartung (Restart/Self-Update — auf der Box).
|
||||
"""System-Endpoints: Live-Status, Dienste-Liste, Sicherung, Neustart, Token-Verbrauch.
|
||||
|
||||
Wartung läuft als systemd-USER-Dienst → KEIN sudo/Passwort (Nordstern).
|
||||
Lokal (Windows) schlagen die Shell-Befehle harmlos fehl und werden als Fehler
|
||||
@@ -6,18 +6,12 @@ zurückgegeben statt zu crashen.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import httpx
|
||||
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, V1_UPSTREAM, VOICE_SERVICE_URL
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
from services import ablaeufe, dienste, maintenance
|
||||
from services import backup as backup_svc
|
||||
from services import maintenance
|
||||
from services.agent import agent_status
|
||||
from services.gateway import gateway_reachable
|
||||
from services.llamaswap import engine_reachable, list_models
|
||||
from services.llamaswap import list_models
|
||||
from services.pricing import compute_savings
|
||||
from services.system import system_status
|
||||
from services.token_stats import get_stats
|
||||
@@ -26,94 +20,23 @@ log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
# Quelle für Self-Update (auf der Box ~/mission-control-v2).
|
||||
SOURCE_DIR = os.path.expanduser(os.environ.get("MC2_SOURCE_DIR", "~/mission-control-v2"))
|
||||
|
||||
|
||||
@router.get("/system/status")
|
||||
def status() -> dict:
|
||||
return system_status()
|
||||
|
||||
|
||||
@router.get("/system/history")
|
||||
def history(minutes: int = 60) -> dict:
|
||||
"""Metrik-Verlauf (max. 24 h) für die Cockpit-Zeitachse — s. services/metrics_history."""
|
||||
from services import metrics_history
|
||||
return metrics_history.history(minutes)
|
||||
|
||||
|
||||
def _mem0_reachable() -> bool:
|
||||
try:
|
||||
return httpx.get(f"{MEM0_SERVICE_URL}/health", timeout=2).status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _voice_reachable() -> bool:
|
||||
try:
|
||||
return httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=2).status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _user_unit_active(unit: str) -> bool:
|
||||
"""systemd-User-Unit ohne eigenen HTTP-Port (z. B. mc2-steward) — Zustand via is-active.
|
||||
Auf Windows/Dev schlägt das harmlos fehl (False)."""
|
||||
if os.name != "posix":
|
||||
return False
|
||||
try:
|
||||
r = subprocess.run(["systemctl", "--user", "is-active", unit],
|
||||
capture_output=True, text=True, timeout=3)
|
||||
return r.stdout.strip() == "active"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@router.get("/system/services")
|
||||
def services() -> dict:
|
||||
"""Aggregierte Erreichbarkeit aller Stack-Dienste — die EINE Quelle für Cockpit-Kachel,
|
||||
Schubladen-Liste und Log-Auswahl (Frontend rendert diese Liste, keine UI-Kopie mehr).
|
||||
`scope`: user|system (Restart-Weg), `unit`: echter systemd-Name (Restart/Logs/Matching)."""
|
||||
a = agent_status()
|
||||
gw_url = f"{GATEWAY_URL}/v1"
|
||||
rows = [
|
||||
{"name": "Engine (llama-swap)", "unit": "llama-swap", "url": LLAMA_SWAP_URL,
|
||||
"ok": engine_reachable(), "scope": "system"},
|
||||
]
|
||||
# Seit UMBAU v3 P1 ist der LLM-Gateway ein EIGENER Prozess — ohne diese Zeile war er
|
||||
# in der Dienste-Ampel unsichtbar (Kachel "6/6 grün" bei totem Denkpfad).
|
||||
if V1_UPSTREAM:
|
||||
rows.append({"name": "LLM-Gateway (Denkpfad)", "unit": "mc2-gateway", "url": V1_UPSTREAM,
|
||||
"ok": gateway_reachable(), "scope": "user"})
|
||||
# MC2 selbst antwortet gerade auf diesen Request — ok=True ist hier ehrlich;
|
||||
# die Zeile existiert für Restart/Logs, nicht als Erreichbarkeits-Orakel.
|
||||
rows.append({"name": "Steuerpult (MC2)", "unit": "mission-control-2", "url": gw_url,
|
||||
"ok": True, "scope": "user"})
|
||||
else:
|
||||
rows.append({"name": "Gateway (integriert)", "unit": "mission-control-2", "url": gw_url,
|
||||
"ok": gateway_reachable(), "scope": "user"})
|
||||
rows += [
|
||||
{"name": "Wächter (Steward)", "unit": "mc2-steward", "url": "",
|
||||
"ok": _user_unit_active("mc2-steward"), "scope": "user"},
|
||||
{"name": "Hermes-Gateway (Agent & Gedächtnis)", "unit": "hermes-gateway", "url": HERMES_API_URL,
|
||||
"ok": a["gateway_reachable"], "scope": "user"},
|
||||
{"name": "Hermes-GUI (Desktop-Gateway)", "unit": "hermes-builtin-ui", "url": a["hermes_ui_url"],
|
||||
"ok": a["hermes_ui_reachable"], "scope": "user"},
|
||||
{"name": "Voice (STT/TTS)", "unit": "voice-service", "url": VOICE_SERVICE_URL,
|
||||
"ok": _voice_reachable(), "scope": "user"},
|
||||
# ttyd hinter dem /console/-Proxy — war als einziger UI-Dienst NICHT in der Liste,
|
||||
# das Konsole-Overlay schickte den User deshalb ins SSH statt zum Restart-Knopf.
|
||||
{"name": "Box-Konsole (ttyd)", "unit": "box-console", "url": "/console/",
|
||||
"ok": bool(a.get("box_console_reachable")), "scope": "user"},
|
||||
]
|
||||
return {
|
||||
"services": rows,
|
||||
"links": {
|
||||
"engine_ui": f"{LLAMA_SWAP_URL}/ui",
|
||||
"gateway": gw_url,
|
||||
"hermes_ui": a["hermes_ui_url"],
|
||||
},
|
||||
}
|
||||
"""Alle Dienste der Box mit Erreichbarkeit, Zustand und Kennzahlen (Speicher, CPU, Laufzeit,
|
||||
Neustarts) — die EINE Quelle für die Seite „Dienste und Protokolle“ und die MCP-Werkzeuge.
|
||||
Logik in services/dienste.py; `scope`: user|system (Restart-Weg), `unit`: echter systemd-Name."""
|
||||
return dienste.liste()
|
||||
|
||||
|
||||
@router.get("/system/ablaeufe")
|
||||
def ablaeufe_liste() -> dict:
|
||||
"""Die geplanten Abläufe der Box mit letztem und nächstem Lauf — der Totmannschalter (services/ablaeufe.py)."""
|
||||
return {"ablaeufe": ablaeufe.ablaeufe()}
|
||||
|
||||
|
||||
@router.post("/system/backup")
|
||||
@@ -126,15 +49,6 @@ def backups() -> dict:
|
||||
return {"backups": backup_svc.list_backups()}
|
||||
|
||||
|
||||
def _run(cmd: list[str], cwd: str | None = None) -> dict:
|
||||
try:
|
||||
p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=180)
|
||||
return {"ok": p.returncode == 0, "code": p.returncode,
|
||||
"out": (p.stdout or "")[-2000:], "err": (p.stderr or "")[-2000:]}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "code": -1, "out": "", "err": str(exc)}
|
||||
|
||||
|
||||
class RestartReq(BaseModel):
|
||||
service: str
|
||||
|
||||
@@ -149,15 +63,6 @@ def restart(req: RestartReq) -> dict:
|
||||
return maintenance.restart_service(req.service)
|
||||
|
||||
|
||||
@router.post("/system/self-update")
|
||||
def self_update() -> dict:
|
||||
"""git pull (Source) → venv-Deps → Dienst-Restart. Auf der Box; lokal Fehler."""
|
||||
pull = _run(["git", "fetch", "--all"], cwd=SOURCE_DIR)
|
||||
reset = _run(["git", "reset", "--hard", "origin/main"], cwd=SOURCE_DIR)
|
||||
restart_res = _run(["systemctl", "--user", "restart", "mission-control-2"])
|
||||
return {"pull": pull, "reset": reset, "restart": restart_res}
|
||||
|
||||
|
||||
@router.get("/system/token-stats")
|
||||
def token_stats() -> dict:
|
||||
"""Token-Verbrauch + Cloud-Ersparnis. Logik im pricing-Service (SSoT)."""
|
||||
|
||||
+103
-171
@@ -1,35 +1,29 @@
|
||||
"""
|
||||
Voice-Endpoints für „Mit Hermes reden" (Browser-Voice + 3D-Avatar).
|
||||
Sprach- und Melde-Endpunkte für Lucy (Desktop-App, Telegram-Spiegel, Wächter).
|
||||
|
||||
Dünner Layer: STT/TTS werden zum Voice-Sidecar (:8650) geproxyt; der Chat geht an den
|
||||
Hermes-`api_server` (:8642, OpenAI-kompatibel) — denselben vollen Agenten mit Tools +
|
||||
geteiltem Mem0 wie CLI/Telegram. Mit stabilem `X-Hermes-Session-Id` hält die Plattform den
|
||||
Transcript server-seitig, daher schickt der Client je Turn nur die neue User-Nachricht.
|
||||
Dünner Layer: STT und die Turn-Erkennung gehen an den Voice-Sidecar (:8650, schläft seit
|
||||
24.09.2026 bis zur Android-App), der Chat an den Hermes-`api_server` (:8642, OpenAI-kompatibel) —
|
||||
derselbe volle Agent mit Werkzeugen und Gedächtnis wie Telegram. Mit stabilem
|
||||
`X-Hermes-Session-Id` hält die Plattform den Verlauf, der Client schickt je Turn nur die neue
|
||||
Nachricht. Lucys Stimme (pocket-tts, :8021) wird ins LAN gereicht.
|
||||
|
||||
LAN-only (kein Token in der 2.0-Phase), wie die übrigen MC2-Endpoints.
|
||||
Abgebaut am 24.09.2026 (ohne Nutzer): Voice-Health, Latenz-Metriken und -Trace, Stimmenliste,
|
||||
Klon-Referenz und das alte Piper/Chatterbox-TTS.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv).
|
||||
import sys as _sys
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL
|
||||
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, LUCY_STIMME_URL, VOICE_SERVICE_URL
|
||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from services import announce
|
||||
from services.voice_metrics import ( # Per-Stage-Latenz + Per-Turn-Trace (intern)
|
||||
Timer,
|
||||
TurnTrace,
|
||||
get_metrics,
|
||||
get_trace,
|
||||
park,
|
||||
record_stage,
|
||||
)
|
||||
|
||||
_GUARD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "mcp")
|
||||
if _GUARD_DIR not in _sys.path:
|
||||
@@ -43,17 +37,22 @@ except Exception: # den Voice-Pfad nie wegen des Filters lahmlegen
|
||||
log = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
# Bildschirm-Sicht: das DEDIZIERTE Vision-Modell (Qwen3-VL-8B) beschreibt das Bild; die Beschreibung
|
||||
# geht als TEXT an Hermes -> Lucy behält ihr volles Hirn/Gedächtnis UND nutzt das bessere VL-Modell
|
||||
# (statt der schwächeren Vision der fast-MoE). Per Env abschaltbar/umstellbar.
|
||||
# Bildschirm-Sicht: das Bild-Modell beschreibt die Screenshots; die Beschreibung geht als TEXT an
|
||||
# Hermes -> Lucy behält ihr volles Hirn und Gedächtnis. Per Env umstellbar.
|
||||
VISION_MODEL = os.environ.get("MC_VISION_MODEL", "vision")
|
||||
# Knappe Beschreibung = schnellere VL-Generierung UND weniger Hermes-Kontext-Bloat (B2).
|
||||
# Knappe Beschreibung = schnellere Generierung UND weniger Kontext für Hermes.
|
||||
VISION_MAX_TOKENS = int(os.environ.get("MC_VISION_MAX_TOKENS", "280"))
|
||||
_STT_TIMEOUT = httpx.Timeout(120.0, connect=5.0)
|
||||
|
||||
|
||||
def _sse_fehler(text: str) -> bytes:
|
||||
"""SSE-Fehlerzeile als gültiges JSON (Anführungszeichen im Text brachen früher den Lucy-Client)."""
|
||||
return f"data: {json.dumps({'error': text}, ensure_ascii=False)}\n\n".encode()
|
||||
|
||||
|
||||
async def _describe_images(image_urls: list[str], hint: str) -> str:
|
||||
"""Lässt das Vision-Modell die Screenshots (1 je Monitor) knapp beschreiben (Deutsch).
|
||||
Mehrere Bilder gehen in EINER Nachricht ans VL-Modell. Leerer String bei Fehler."""
|
||||
"""Lässt das Bild-Modell die Screenshots (1 je Monitor) knapp beschreiben (Deutsch).
|
||||
Mehrere Bilder gehen in EINER Nachricht ans Modell. Leerer String bei Fehler."""
|
||||
multi = len(image_urls) > 1
|
||||
intro = (f"Hier sind {len(image_urls)} Screenshots (je ein Monitor). Beschreibe auf Deutsch in höchstens "
|
||||
"5 kurzen Sätzen das Wesentliche (pro Monitor: App/Fenster, wichtige Inhalte, sichtbarer Text/Code). "
|
||||
@@ -65,8 +64,8 @@ async def _describe_images(image_urls: list[str], hint: str) -> str:
|
||||
for u in image_urls:
|
||||
content.append({"type": "image_url", "image_url": {"url": u}})
|
||||
try:
|
||||
# 45 s statt 120 s: Qwen3-VL braucht warm ~5 s; wenn es 45 s nicht schafft, ist etwas
|
||||
# kaputt und Lucy soll lieber ohne Bildschirm-Kontext antworten als ewig hängen.
|
||||
# 45 s: Wenn das Bild-Modell so lange braucht, ist etwas kaputt, und Lucy soll lieber ohne
|
||||
# Bildschirm-Kontext antworten als ewig hängen.
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(float(os.environ.get("MC_VISION_TIMEOUT", "45")), connect=5.0)) as client:
|
||||
r = await client.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json={
|
||||
"model": VISION_MODEL, "max_tokens": VISION_MAX_TOKENS, "stream": False,
|
||||
@@ -78,21 +77,11 @@ async def _describe_images(image_urls: list[str], hint: str) -> str:
|
||||
log.warning("Vision-Beschreibung fehlgeschlagen: %s", exc)
|
||||
return ""
|
||||
|
||||
_TIMEOUT = httpx.Timeout(120.0, connect=5.0) # Chatterbox-TTS auf CPU darf dauern
|
||||
|
||||
|
||||
class TTSIn(BaseModel):
|
||||
text: str
|
||||
engine: str = "piper"
|
||||
voice: str = ""
|
||||
language: str = ""
|
||||
ref_path: str = ""
|
||||
|
||||
|
||||
class ChatIn(BaseModel):
|
||||
text: str # die neue User-Äußerung (STT-Ergebnis)
|
||||
session_id: str # stabiler Voice-Faden → server-seitiger Transcript
|
||||
session_key: str = "" # optional: Langzeit-Memory-Scope
|
||||
session_id: str # stabiler Gesprächsfaden → server-seitiger Verlauf
|
||||
session_key: str = "" # optional an Hermes durchgereicht (X-Hermes-Session-Key)
|
||||
system: str = "" # optionaler ephemerer System-Prompt (z.B. „antworte knapp/gesprochen")
|
||||
model: str = ""
|
||||
images: list[str] = [] # optionale Bildschirm-Sicht: ein data:-URL je Monitor (Lucys „Augen")
|
||||
@@ -115,9 +104,8 @@ class AlarmIn(BaseModel):
|
||||
def alarm(body: AlarmIn) -> dict:
|
||||
"""Lucy-UNABHÄNGIGER Alarm-Weg: schickt direkt auf Telegram (und legt die Meldung in den
|
||||
Briefkasten). Für Absender, die NICHT auf die sprechende Lucy zählen können — allen voran
|
||||
der PC-seitige Lucy-Watchdog, wenn die Desktop-App selbst hängt (dann nützt der Briefkasten
|
||||
nichts, weil niemand ihn vorliest → Telegram ist der einzige verlässliche Kanal). LAN-only
|
||||
wie alle MC2-Endpoints."""
|
||||
der PC-seitige Lucy-Watchdog, wenn die Desktop-App selbst hängt. Der Betreff „[Alarm]“ geht
|
||||
auch nachts sofort raus (notify.sh)."""
|
||||
text = (body.text or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(400, "Leere Meldung.")
|
||||
@@ -132,8 +120,8 @@ def alarm(body: AlarmIn) -> dict:
|
||||
|
||||
@router.post("/voice/announce")
|
||||
def voice_announce(body: AnnounceIn) -> dict:
|
||||
"""Meldung in den Briefkasten legen (Lucy-Proaktivität). Absender: Health-Wächter,
|
||||
notify.sh (Updates/Radar/Telegram-Spiegel), Hermes-cron. LAN-only wie alle MC2-Endpoints."""
|
||||
"""Meldung in den Briefkasten legen (Lucy-Proaktivität). Absender: Wächter, notify.sh
|
||||
(Updates/Radar/Telegram-Spiegel), Hermes-Cron."""
|
||||
try:
|
||||
return {"ok": True, "item": announce.add(body.text, body.subject, body.source, body.priority)}
|
||||
except ValueError as exc:
|
||||
@@ -147,43 +135,6 @@ def voice_announcements(after: int | None = None, limit: int = 20) -> dict:
|
||||
return announce.list_after(after, limit)
|
||||
|
||||
|
||||
@router.get("/voice/health")
|
||||
def voice_health() -> dict:
|
||||
"""Erreichbarkeit des Voice-Sidecars + ob der Hermes-API-Key gesetzt ist."""
|
||||
out: dict = {"sidecar": False, "hermes_key": bool(HERMES_API_KEY)}
|
||||
try:
|
||||
r = httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=httpx.Timeout(5.0))
|
||||
out["sidecar"] = r.status_code == 200
|
||||
out["detail"] = r.json() if r.status_code == 200 else None
|
||||
except Exception as exc:
|
||||
out["error"] = str(exc)
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/voice/metrics")
|
||||
def voice_metrics() -> dict:
|
||||
"""Rollende Latenz-Stats je Stufe (avg/p50/p95/last, ms). Quelle u.a. für selbstkritik-feed.sh."""
|
||||
return get_metrics()
|
||||
|
||||
|
||||
@router.get("/voice/trace")
|
||||
def voice_trace(limit: int = 20) -> dict:
|
||||
"""Per-Turn-Trace: die letzten `limit` Chat-Turns mit Stufen-Breakdown (STT · Vision · Hirn ·
|
||||
Generierung, Mem0 als Unter-Detail). Neueste zuerst. Für die Latenz-Ansicht im Cockpit —
|
||||
damit man den EINEN langsamen Turn sieht, den ein Durchschnitt verschluckt."""
|
||||
return {"turns": get_trace(limit)}
|
||||
|
||||
|
||||
@router.get("/voice/voices")
|
||||
def voice_voices() -> dict:
|
||||
try:
|
||||
r = httpx.get(f"{VOICE_SERVICE_URL}/voices", timeout=httpx.Timeout(10.0))
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as exc:
|
||||
raise HTTPException(502, f"Voice-Sidecar nicht erreichbar: {exc}")
|
||||
|
||||
|
||||
@router.post("/voice/stt")
|
||||
async def voice_stt(audio: UploadFile = File(...), language: str = Form(default="")) -> dict:
|
||||
"""Mikro-Audio → Text (Proxy auf Sidecar /stt)."""
|
||||
@@ -192,12 +143,8 @@ async def voice_stt(audio: UploadFile = File(...), language: str = Form(default=
|
||||
raise HTTPException(400, "Leeres Audio.")
|
||||
files = {"audio": (audio.filename or "rec.webm", data, audio.content_type or "audio/webm")}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
_t0 = time.perf_counter()
|
||||
async with httpx.AsyncClient(timeout=_STT_TIMEOUT) as client:
|
||||
r = await client.post(f"{VOICE_SERVICE_URL}/stt", files=files, data={"language": language})
|
||||
_ms = (time.perf_counter() - _t0) * 1000.0
|
||||
record_stage("stt", _ms)
|
||||
park("stt", _ms) # der folgende /voice/chat-Turn sammelt die STT-Dauer für seinen Trace ein
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except httpx.HTTPError as exc:
|
||||
@@ -213,7 +160,6 @@ async def voice_turn(audio: UploadFile = File(...)) -> dict:
|
||||
files = {"audio": (audio.filename or "rec.wav", data, audio.content_type or "audio/wav")}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client:
|
||||
with Timer("turn"):
|
||||
r = await client.post(f"{VOICE_SERVICE_URL}/turn", files=files)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
@@ -223,55 +169,6 @@ async def voice_turn(audio: UploadFile = File(...)) -> dict:
|
||||
return {"complete": True, "probability": 1.0, "engine": "fallback"}
|
||||
|
||||
|
||||
@router.post("/voice/reference")
|
||||
async def voice_set_reference(audio: UploadFile = File(...)) -> dict:
|
||||
"""Klon-Referenz (z.B. ElevenLabs-Erzeugnis) hochladen → Chatterbox nutzt sie. Proxy → Sidecar."""
|
||||
data = await audio.read()
|
||||
if not data:
|
||||
raise HTTPException(400, "Leeres Audio.")
|
||||
files = {"audio": (audio.filename or "ref.wav", data, audio.content_type or "audio/mpeg")}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
r = await client.post(f"{VOICE_SERVICE_URL}/reference", files=files)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(502, f"Referenz-Upload fehlgeschlagen: {exc}")
|
||||
|
||||
|
||||
@router.get("/voice/reference")
|
||||
def voice_get_reference() -> dict:
|
||||
try:
|
||||
r = httpx.get(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as exc:
|
||||
return {"active": False, "error": str(exc)}
|
||||
|
||||
|
||||
@router.delete("/voice/reference")
|
||||
def voice_clear_reference() -> dict:
|
||||
try:
|
||||
r = httpx.delete(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(502, f"Löschen fehlgeschlagen: {exc}")
|
||||
|
||||
|
||||
@router.post("/voice/tts")
|
||||
async def voice_tts(body: TTSIn) -> Response:
|
||||
"""Text → Sprache (Proxy auf Sidecar /tts), liefert WAV-Bytes."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
with Timer("tts"):
|
||||
r = await client.post(f"{VOICE_SERVICE_URL}/tts", json=body.model_dump())
|
||||
r.raise_for_status()
|
||||
return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav"))
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(502, f"TTS fehlgeschlagen: {exc}")
|
||||
|
||||
|
||||
@router.post("/voice/chat")
|
||||
async def voice_chat(body: ChatIn) -> StreamingResponse:
|
||||
"""Neue User-Äußerung → Hermes-Agent (api_server, streamend). SSE wird 1:1 durchgereicht.
|
||||
@@ -289,31 +186,14 @@ async def voice_chat(body: ChatIn) -> StreamingResponse:
|
||||
headers["X-Hermes-Session-Key"] = body.session_key
|
||||
|
||||
async def gen():
|
||||
# Per-Turn-Trace: sammelt STT (davor, geparkt) + Vision + Hirn-TTFT + Generierung + Mem0
|
||||
# (Rückruf während) zu EINEM Datensatz -> die Latenz-Ansicht zeigt den einzelnen Hänger.
|
||||
trace = TurnTrace(session_id=body.session_id, kind="voice")
|
||||
first = True
|
||||
first_content = True
|
||||
committed = False
|
||||
|
||||
def _commit() -> None:
|
||||
nonlocal committed
|
||||
if not committed:
|
||||
committed = True
|
||||
trace.commit()
|
||||
|
||||
try:
|
||||
# Bildschirm-Sicht INNERHALB des Streams (C2-Fix): so startet die SSE-Antwort sofort und
|
||||
# der Client bekommt ein Progress-Event (-> Lucy kann eine Warte-Ansage sprechen), statt
|
||||
# dass der Request bis zu 120 s "tot" hängt, während das Vision-Modell beschreibt.
|
||||
# Bildschirm-Sicht INNERHALB des Streams: so startet die SSE-Antwort sofort und der Client
|
||||
# bekommt ein Progress-Event (-> Lucy kann eine Warte-Ansage sprechen), statt dass der
|
||||
# Request hängt, während das Bild-Modell beschreibt.
|
||||
user_text = body.text
|
||||
imgs = [u for u in (body.images or []) if u]
|
||||
trace.had_images = bool(imgs)
|
||||
if imgs:
|
||||
yield b'event: hermes.vision.progress\ndata: {"note": "Bildschirm wird angeschaut"}\n\n'
|
||||
_tv = time.perf_counter()
|
||||
desc = await _describe_images(imgs, body.text)
|
||||
trace.note_vision((time.perf_counter() - _tv) * 1000.0)
|
||||
if desc:
|
||||
safe_desc = wrap_untrusted(desc, "BILDSCHIRM")
|
||||
user_text = f"[Bildschirm-Sicht — das ist gerade auf dem/den Schirm(en) zu sehen:\n{safe_desc}\n]\n\n{body.text}"
|
||||
@@ -321,11 +201,9 @@ async def voice_chat(body: ChatIn) -> StreamingResponse:
|
||||
if body.system:
|
||||
messages.append({"role": "system", "content": body.system})
|
||||
messages.append({"role": "user", "content": user_text})
|
||||
trace.mark_brain_start() # ab hier zählt die Hirn-Zeit (Vision ist schon abgeschlossen)
|
||||
payload = {"model": body.model or HERMES_API_MODEL, "messages": messages, "stream": True}
|
||||
# Lucys Hirn (Qwen3.6) ist ein Thinking-Modell -> für die gesprochene Assistentin Thinking AUS,
|
||||
# sonst generiert es tausende Reasoning-Token VOR der kurzen Antwort (gemessen: 11k Token, ~30s TTFB).
|
||||
# Gleiches Muster wie die fast-Spur im Gateway (gateway_proxy.py) und die Mem0-Extraktion.
|
||||
# Lucys Hirn ist ein Thinking-Modell -> für die gesprochene Assistentin Thinking AUS, sonst
|
||||
# generiert es tausende Reasoning-Token VOR der kurzen Antwort (gemessen: 11k Token, ~30 s).
|
||||
if os.environ.get("MC_VOICE_NO_THINK", "1") not in ("0", "false", "False"):
|
||||
payload["chat_template_kwargs"] = {"enable_thinking": False}
|
||||
try:
|
||||
@@ -335,23 +213,77 @@ async def voice_chat(body: ChatIn) -> StreamingResponse:
|
||||
) as r:
|
||||
if r.status_code != 200:
|
||||
detail = (await r.aread()).decode("utf-8", "replace")[:500]
|
||||
trace.error = f"Hermes {r.status_code}"
|
||||
yield f"data: {{\"error\": \"Hermes {r.status_code}: {detail}\"}}\n\n".encode()
|
||||
yield _sse_fehler(f"Hermes {r.status_code}: {detail}")
|
||||
return
|
||||
async for chunk in r.aiter_raw():
|
||||
if first: # Time-To-First-Byte des Hermes-Streams (Verbindungs-Overhead)
|
||||
trace.note_ttfb()
|
||||
first = False
|
||||
# Erster CONTENT-Delta = echte Hirn-Latenz (Agent-Overhead + Mem0 + LLM-TTFT) —
|
||||
# chat_ttfb misst nur den SSE-Start (~5 ms) und ist dafür blind.
|
||||
if first_content and b'"content"' in chunk:
|
||||
trace.note_first_content()
|
||||
first_content = False
|
||||
yield chunk
|
||||
except httpx.HTTPError as exc:
|
||||
trace.error = "verbindung"
|
||||
yield f"data: {{\"error\": \"Verbindung zu Hermes fehlgeschlagen: {exc}\"}}\n\n".encode()
|
||||
finally:
|
||||
_commit() # Turn immer verbuchen (auch bei Fehler/Abbruch)
|
||||
yield _sse_fehler(f"Verbindung zu Hermes fehlgeschlagen: {exc}")
|
||||
|
||||
return StreamingResponse(gen(), media_type="text/event-stream")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# Lucys Stimme ins LAN reichen (Raphael-Umbau 04.09.2026). lucy-stimme.service (:8021, pocket-tts
|
||||
# german_24l) bindet nur Loopback; die Desktop-Lucy am PC spricht mit DIESEM — dieselbe Stimme wie
|
||||
# die Telegram-Sprachnachrichten. Dünner Proxy, API 1:1 (pocket_server: /health, /tts -> WAV,
|
||||
# /tts/stream -> PCM16 + X-Sample-Rate).
|
||||
|
||||
class LucyTtsIn(BaseModel):
|
||||
text: str
|
||||
emo: str | None = None # Stimmungs-Profil (pocket_server EMO_PROFILES); Raphael-Lucy setzt keins
|
||||
|
||||
|
||||
@router.get("/lucy/stimme/health")
|
||||
def lucy_stimme_health() -> dict:
|
||||
"""Bereitschaft von Lucys Stimme (pocket_server /health: status ok|loading)."""
|
||||
try:
|
||||
r = httpx.get(f"{LUCY_STIMME_URL}/health", timeout=httpx.Timeout(5.0))
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except Exception as exc:
|
||||
raise HTTPException(502, f"Lucys Stimme (:8021) nicht erreichbar: {exc}")
|
||||
|
||||
|
||||
@router.post("/lucy/stimme/tts")
|
||||
async def lucy_stimme_tts(body: LucyTtsIn) -> Response:
|
||||
"""Text -> WAV (ganzer Text). Warm-up der Desktop-Lucy + Jobs, die eine Datei brauchen."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=5.0)) as client:
|
||||
r = await client.post(f"{LUCY_STIMME_URL}/tts", json=body.model_dump(exclude_none=True))
|
||||
r.raise_for_status()
|
||||
return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav"),
|
||||
headers={k: v for k, v in r.headers.items() if k.lower().startswith("x-")})
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise HTTPException(exc.response.status_code, f"Lucys Stimme: {exc.response.text[:200]}")
|
||||
except httpx.HTTPError as exc:
|
||||
raise HTTPException(502, f"Lucys Stimme nicht erreichbar: {exc}")
|
||||
|
||||
|
||||
@router.post("/lucy/stimme/tts/stream")
|
||||
async def lucy_stimme_tts_stream(body: LucyTtsIn) -> StreamingResponse:
|
||||
"""Text -> rohes PCM16-mono, satzweise gestreamt (Samplerate im Header X-Sample-Rate).
|
||||
Der Live-Pfad der Desktop-Lucy: erstes Audio nach dem ersten Satz. Der Upstream-Stream bleibt
|
||||
offen, solange der Client liest — bricht der Client ab (Barge-in), schließt httpx den Upstream."""
|
||||
client = httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0))
|
||||
try:
|
||||
req = client.build_request("POST", f"{LUCY_STIMME_URL}/tts/stream", json=body.model_dump(exclude_none=True))
|
||||
upstream = await client.send(req, stream=True)
|
||||
except httpx.HTTPError as exc:
|
||||
await client.aclose()
|
||||
raise HTTPException(502, f"Lucys Stimme nicht erreichbar: {exc}")
|
||||
if upstream.status_code != 200:
|
||||
detail = (await upstream.aread()).decode("utf-8", "replace")[:200]
|
||||
await upstream.aclose(); await client.aclose()
|
||||
raise HTTPException(upstream.status_code, f"Lucys Stimme: {detail}")
|
||||
|
||||
async def gen():
|
||||
try:
|
||||
async for chunk in upstream.aiter_raw():
|
||||
yield chunk
|
||||
finally:
|
||||
await upstream.aclose()
|
||||
await client.aclose()
|
||||
|
||||
return StreamingResponse(gen(), media_type="application/octet-stream",
|
||||
headers={"X-Sample-Rate": upstream.headers.get("x-sample-rate", "24000")})
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
"""Wissens-Vault-Reader: die nächtlichen Traum-Notizen (~/wissens-vault) als klickbares
|
||||
Wiki in der Zentrale. Bewusst READ-ONLY — geschrieben wird der Vault nur vom Traum-Cron
|
||||
(und via Auftragsbuch-Entscheidungen); hier wird nur gelesen und navigiert."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
VAULT = Path(os.environ.get("MC_VAULT_DIR", "~/wissens-vault")).expanduser()
|
||||
|
||||
|
||||
def _available() -> bool:
|
||||
return VAULT.is_dir()
|
||||
|
||||
|
||||
def _title_of(p: Path) -> str:
|
||||
"""Erste nicht-leere Zeile (ohne Markdown-#) als Anzeigename."""
|
||||
try:
|
||||
with p.open(encoding="utf-8", errors="replace") as fh:
|
||||
for line in fh:
|
||||
s = line.strip()
|
||||
if s:
|
||||
return s.lstrip("# ").strip()[:160]
|
||||
except OSError:
|
||||
pass
|
||||
return p.stem
|
||||
|
||||
|
||||
class VaultFile(BaseModel):
|
||||
path: str
|
||||
name: str
|
||||
dir: str
|
||||
title: str
|
||||
mtime: float
|
||||
neu: bool
|
||||
|
||||
|
||||
class WissenListResponse(BaseModel):
|
||||
available: bool
|
||||
files: list[VaultFile]
|
||||
|
||||
|
||||
class WissenFileResponse(BaseModel):
|
||||
path: str
|
||||
content: str
|
||||
mtime: float
|
||||
|
||||
|
||||
@router.get("/wissen", response_model=WissenListResponse)
|
||||
def list_vault() -> dict:
|
||||
"""Alle Markdown-Notizen des Vaults (relativer Pfad, Titel, Ordner, Alter)."""
|
||||
if not _available():
|
||||
return {"available": False, "files": []}
|
||||
files = []
|
||||
now = time.time()
|
||||
for p in sorted(VAULT.rglob("*.md")):
|
||||
if ".git" in p.parts or "traeume" in p.parts:
|
||||
continue
|
||||
rel = p.relative_to(VAULT).as_posix()
|
||||
st = p.stat()
|
||||
files.append({
|
||||
"path": rel,
|
||||
"name": p.stem,
|
||||
"dir": p.parent.relative_to(VAULT).as_posix() if p.parent != VAULT else "",
|
||||
"title": _title_of(p),
|
||||
"mtime": st.st_mtime,
|
||||
"neu": (now - st.st_mtime) < 36 * 3600, # „neu seit gestern Nacht"
|
||||
})
|
||||
files.sort(key=lambda f: f["mtime"], reverse=True)
|
||||
return {"available": True, "files": files}
|
||||
|
||||
|
||||
@router.get("/wissen/datei", response_model=WissenFileResponse)
|
||||
def read_file(pfad: str) -> dict:
|
||||
"""Inhalt einer Vault-Notiz — Traversal hart geblockt (resolve + is_relative_to)."""
|
||||
if not _available():
|
||||
raise HTTPException(404, "Wissens-Vault liegt auf der Box (hier nicht verfügbar).")
|
||||
try:
|
||||
target = (VAULT / pfad).resolve()
|
||||
if not target.is_relative_to(VAULT.resolve()) or target.suffix != ".md" or not target.is_file():
|
||||
raise HTTPException(404, "Notiz nicht gefunden.")
|
||||
return {"path": pfad, "content": target.read_text(encoding="utf-8", errors="replace")[:400_000],
|
||||
"mtime": target.stat().st_mtime}
|
||||
except HTTPException:
|
||||
raise
|
||||
except (ValueError, OSError):
|
||||
raise HTTPException(404, "Notiz nicht lesbar.")
|
||||
|
||||
|
||||
@router.get("/wissen/graph")
|
||||
def graph_vault() -> dict:
|
||||
"""Obsidian-artiger Graph des Vaults (Nodes = Dateien, Edges = Wiki-Links)."""
|
||||
if not _available():
|
||||
return {"nodes": [], "edges": []}
|
||||
|
||||
nodes = []
|
||||
edges = []
|
||||
# Für schnelle Link-Auflösung (case-insensitive Name -> relativer Pfad als ID)
|
||||
name_to_id = {}
|
||||
|
||||
# 1. Alle Nodes sammeln
|
||||
for p in VAULT.rglob("*.md"):
|
||||
if ".git" in p.parts or "traeume" in p.parts:
|
||||
continue
|
||||
rel = p.relative_to(VAULT).as_posix()
|
||||
name_to_id[p.stem.lower()] = rel
|
||||
|
||||
# Kategorie aus dem Ordner ableiten
|
||||
cat = "knowledge"
|
||||
parent = p.parent.name if p.parent != VAULT else ""
|
||||
if parent == "traeume":
|
||||
cat = "events"
|
||||
elif parent == "muster":
|
||||
cat = "rules"
|
||||
elif parent == "skill-kandidaten":
|
||||
cat = "identity"
|
||||
|
||||
nodes.append({
|
||||
"id": rel,
|
||||
"content": p.stem, # stem ist oft kürzer/prägnanter als der Titel
|
||||
"category": cat,
|
||||
"source": "wiki"
|
||||
})
|
||||
|
||||
# 2. Edges extrahieren (Wiki-Links [[Name]])
|
||||
link_rx = re.compile(r"\[\[([^\]]+)\]\]")
|
||||
for n in nodes:
|
||||
try:
|
||||
target = VAULT / n["id"]
|
||||
content = target.read_text(encoding="utf-8", errors="replace")
|
||||
# Set, um mehrfache Links auf dieselbe Datei zu entduplizieren
|
||||
found_links = {m.group(1).strip().lower() for m in link_rx.finditer(content)}
|
||||
|
||||
for link in found_links:
|
||||
target_id = name_to_id.get(link)
|
||||
if target_id and target_id != n["id"]:
|
||||
edges.append({
|
||||
"source": n["id"],
|
||||
"target": target_id,
|
||||
"weight": 1.0
|
||||
})
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return {"nodes": nodes, "edges": edges}
|
||||
@@ -31,17 +31,6 @@ def list_snapshots() -> dict:
|
||||
return {"available": os.name == "posix", "backups": backup_svc.list_backups()}
|
||||
|
||||
|
||||
@router.get("/zeitmaschine/inhalt")
|
||||
def snapshot_contents(file: str) -> dict:
|
||||
"""Top-Level-Komponenten eines Snapshots (mem0, hermes, llama-swap, MANIFEST …)."""
|
||||
if not _FILE_RX.match(file):
|
||||
raise HTTPException(400, "Ungültiger Snapshot-Name.")
|
||||
p = backup_svc.BACKUP_DIR / file
|
||||
if not p.is_file():
|
||||
raise HTTPException(404, "Snapshot nicht gefunden.")
|
||||
return {"file": file, "components": backup_svc.snapshot_components(p)}
|
||||
|
||||
|
||||
@router.post("/zeitmaschine/restore")
|
||||
def restore(body: RestoreIn) -> dict:
|
||||
"""Wiederherstellung starten (detached). restore.sh macht VORHER selbst ein
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Parse systemd timeout errors for mission-control-2.service from journalctl.
|
||||
|
||||
Captures lines containing "State 'stop-sigterm' timed out" and appends them
|
||||
to ~/logs/mc2-timeout.log with ISO timestamps.
|
||||
|
||||
Permission errors are handled gracefully.
|
||||
|
||||
Uses user journal (systemctl --user) to query logs.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
LOG_DIR = Path.home() / "logs"
|
||||
LOG_FILE = LOG_DIR / "mc2-timeout.log"
|
||||
SERVICE_NAME = "mission-control-2.service"
|
||||
SEARCH_PATTERN = "State 'stop-sigterm' timed out"
|
||||
JOURNALCTL_LIMIT = 1000
|
||||
|
||||
|
||||
def ensure_log_dir() -> None:
|
||||
"""Create log directory if it doesn't exist."""
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def get_timeout_entries() -> list[str]:
|
||||
"""Run journalctl --user and return lines matching the timeout pattern."""
|
||||
cmd = [
|
||||
"journalctl",
|
||||
"--user",
|
||||
"-u", SERVICE_NAME,
|
||||
"--no-pager",
|
||||
"-n", str(JOURNALCTL_LIMIT),
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
print("journalctl timed out")
|
||||
return []
|
||||
except PermissionError:
|
||||
print("Permission denied: journalctl requires access to user logs")
|
||||
return []
|
||||
|
||||
if result.returncode != 0:
|
||||
if "Permission denied" in result.stderr:
|
||||
print("Permission denied: journalctl requires access to user logs")
|
||||
else:
|
||||
print(f"journalctl failed: {result.stderr.strip()}")
|
||||
return []
|
||||
|
||||
return [
|
||||
line
|
||||
for line in result.stdout.splitlines()
|
||||
if SEARCH_PATTERN in line
|
||||
]
|
||||
|
||||
|
||||
def write_to_log(entries: list[str]) -> int:
|
||||
"""Append entries to log file with ISO timestamps. Returns count written."""
|
||||
ensure_log_dir()
|
||||
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
written = 0
|
||||
|
||||
with LOG_FILE.open("a", encoding="utf-8") as f:
|
||||
for entry in entries:
|
||||
f.write(f"[{timestamp}] {entry}\n")
|
||||
written += 1
|
||||
|
||||
return written
|
||||
|
||||
|
||||
def main() -> None:
|
||||
entries = get_timeout_entries()
|
||||
if not entries:
|
||||
print("No timeout entries found.")
|
||||
return
|
||||
|
||||
count = write_to_log(entries)
|
||||
print(f"Appended {count} timeout entry/ies to {LOG_FILE}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Abläufe der KI-Box — der Totmannschalter (Ausbauplan Welle 1, Punkt 1, seit 25.09.2026).
|
||||
|
||||
Der Wächter sah bisher nur, wenn ein geplanter Ablauf SCHEITERTE (pruefe_timer_dienste, pruefe_hermes_jobs). Ein Ablauf,
|
||||
der gar nicht erst läuft, fiel niemandem auf: Vom 06. bis 17.09.2026 stand die Sonntags-Update-Kette still, bis es
|
||||
jemand zufällig bemerkte. Hier steht deshalb je Ablauf, wie lange er höchstens schweigen darf (Takt + Karenz), und was
|
||||
darüber liegt, wird ein Hinweis:
|
||||
|
||||
systemd-Timer letzter Start (LastTriggerUSec) älter als erlaubt → überfällig; der Timer selbst aus
|
||||
(eingetragen, aber nicht aktiv) → aus. Bewusst abgeschaltet (inaktiv und ausgetragen, z. B. das
|
||||
Radar in den Einstellungen) ist kein Befund.
|
||||
Hermes-Cron-Jobs der nächste Lauf (next_run_at) liegt mehr als eine Stunde zurück → der Planer von Hermes lief
|
||||
nicht (Gateway hing oder war aus).
|
||||
Gitea-Ampel der Actions-Runner meldet sich in Gitea nicht als online (seit dem 28.08. stand er still, weil
|
||||
er von Hand gestartet war; seit 25.09. ein Dienst auf arcane).
|
||||
|
||||
Nur lesend. Die Liste für die Oberfläche (Seite „Dienste und Protokolle“ → Zeitpläne) kommt aus ablaeufe().
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
STUNDE = 3600
|
||||
TAG = 24 * STUNDE
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Zeitplan:
|
||||
unit: str # systemd-User-Timer ohne „.timer“
|
||||
name: str
|
||||
erlaubt_s: int # so lange darf er höchstens schweigen (Takt + Karenz)
|
||||
takt: str # in Worten, für die Oberfläche
|
||||
|
||||
|
||||
# Takt aus deploy/*.timer; Karenz: täglich 3 h, wöchentlich 6 h (RandomizedDelaySec, Neustart am Sonntag), monatlich
|
||||
# fünf Wochen (erster Montag im Monat).
|
||||
ZEITPLAENE: tuple[Zeitplan, ...] = (
|
||||
Zeitplan("mc2-autoupdate", "Updates am Sonntag", 7 * TAG + 6 * STUNDE, "sonntags 04:30"),
|
||||
Zeitplan("mc2-backup", "Sicherung der Box", TAG + 3 * STUNDE, "täglich 03:30"),
|
||||
Zeitplan("pbs-backup", "Sicherung auf den PBS", TAG + 3 * STUNDE, "täglich 03:50"),
|
||||
Zeitplan("mc2-morgenmeldung", "Morgenmeldung", TAG + 3 * STUNDE, "täglich 07:00"),
|
||||
Zeitplan("mc2-radar", "Modell-Radar", TAG + 3 * STUNDE, "täglich 00:30"),
|
||||
Zeitplan("mc2-probe-wiederherstellung", "Probe-Wiederherstellung", 35 * TAG, "erster Montag im Monat 05:15"),
|
||||
Zeitplan("projekte-sync", "Projekte-Abgleich", 3 * STUNDE, "stündlich"),
|
||||
Zeitplan("mc2-wochenbericht", "Wochenbericht", 7 * TAG + 6 * STUNDE, "sonntags 07:30"),
|
||||
)
|
||||
HERMES_KARENZ_S = STUNDE
|
||||
CACHE_S = 300 # der Wächter fragt jede Minute; die Zeitpläne ändern sich nicht so schnell
|
||||
|
||||
GITEA_URL = os.environ.get("MC_GITEA_URL", "http://192.168.178.153:3000").rstrip("/")
|
||||
GIT_ZUGANG = Path(os.environ.get("MC_GIT_ZUGANG", str(Path.home() / ".git-credentials")))
|
||||
|
||||
_cache: dict[str, tuple[float, object]] = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _gemerkt(schluessel: str, holen):
|
||||
with _lock:
|
||||
eintrag = _cache.get(schluessel)
|
||||
if eintrag and time.monotonic() - eintrag[0] < CACHE_S:
|
||||
return eintrag[1]
|
||||
wert = holen()
|
||||
with _lock:
|
||||
_cache[schluessel] = (time.monotonic(), wert)
|
||||
return wert
|
||||
|
||||
|
||||
def cache_leeren() -> None:
|
||||
with _lock:
|
||||
_cache.clear()
|
||||
|
||||
|
||||
# --- systemd --------------------------------------------------------------------------------------------------------
|
||||
|
||||
def _unix(wert: str | None) -> float | None:
|
||||
"""„@1790300009“ (systemctl --timestamp=unix) → Sekunden; leer oder „n/a“ → None."""
|
||||
m = re.fullmatch(r"@(\d+(?:\.\d+)?)", (wert or "").strip())
|
||||
return float(m.group(1)) if m else None
|
||||
|
||||
|
||||
def _timer_zustand(unit: str) -> dict[str, str]:
|
||||
"""Zustand eines User-Timers. Ohne systemd (Windows, Tests): leer."""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["systemctl", "--user", "show", f"{unit}.timer", "--timestamp=unix", "-p",
|
||||
"LoadState,ActiveState,UnitFileState,ActiveEnterTimestamp,LastTriggerUSec,NextElapseUSecRealtime"],
|
||||
capture_output=True, text=True, timeout=10).stdout
|
||||
except Exception:
|
||||
return {}
|
||||
return dict(zeile.split("=", 1) for zeile in out.splitlines() if "=" in zeile)
|
||||
|
||||
|
||||
def bewerte_timer(z: Zeitplan, zustand: dict[str, str], jetzt: float) -> dict | None:
|
||||
"""Ein Eintrag der Liste: Zustand in Worten und ob es ein Befund ist. None = gibt es hier nicht (kein systemd)."""
|
||||
if not zustand or zustand.get("LoadState") in ("", "not-found"):
|
||||
return None
|
||||
letzter = _unix(zustand.get("LastTriggerUSec"))
|
||||
naechster = _unix(zustand.get("NextElapseUSecRealtime"))
|
||||
eintrag = {"id": f"timer:{z.unit}", "name": z.name, "art": "zeitplan", "takt": z.takt,
|
||||
"letzter": letzter, "naechster": naechster, "status": "ok", "text": None}
|
||||
aktiv = zustand.get("ActiveState") == "active"
|
||||
eingetragen = zustand.get("UnitFileState") in ("enabled", "static", "enabled-runtime")
|
||||
if not aktiv and not eingetragen:
|
||||
return {**eintrag, "status": "aus", "text": "Bewusst abgeschaltet."}
|
||||
if not aktiv:
|
||||
return {**eintrag, "status": "gestoppt",
|
||||
"text": f"Der Zeitplan ist eingetragen, läuft aber nicht — {z.name} startet so nie."}
|
||||
# Nie gestartet: gezählt wird ab dem Moment, seit dem der Timer läuft (nach einer Neuinstallation ist das normal).
|
||||
bezug = letzter or _unix(zustand.get("ActiveEnterTimestamp"))
|
||||
if bezug is not None and jetzt - bezug > z.erlaubt_s:
|
||||
seit = _dauer(jetzt - bezug)
|
||||
text = (f"Zuletzt vor {seit} gestartet ({z.takt} erwartet)." if letzter
|
||||
else f"Seit {seit} kein einziger Start ({z.takt} erwartet).")
|
||||
return {**eintrag, "status": "ueberfaellig", "text": text}
|
||||
return eintrag
|
||||
|
||||
|
||||
def _dauer(s: float) -> str:
|
||||
if s < 2 * TAG:
|
||||
return f"{int(s // STUNDE)} Stunden"
|
||||
return f"{int(s // TAG)} Tagen"
|
||||
|
||||
|
||||
# --- Hermes ---------------------------------------------------------------------------------------------------------
|
||||
|
||||
def _zeit(wert) -> float | None:
|
||||
if not wert:
|
||||
return None
|
||||
try:
|
||||
dt = datetime.fromisoformat(str(wert).replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return (dt if dt.tzinfo else dt.replace(tzinfo=LOCAL_TZ)).timestamp()
|
||||
|
||||
|
||||
def bewerte_job(job: dict, jetzt: float) -> dict | None:
|
||||
if not job.get("enabled", True):
|
||||
return None
|
||||
naechster, letzter = _zeit(job.get("next_run_at")), _zeit(job.get("last_run_at"))
|
||||
takt = str((job.get("schedule") or {}).get("display") or (job.get("schedule") or {}).get("expr") or "")
|
||||
eintrag = {"id": f"hermes:{job.get('id')}", "name": str(job.get("name") or "Hermes-Job"), "art": "hermes",
|
||||
"takt": f"Hermes-Cron {takt}".strip(), "letzter": letzter, "naechster": naechster, "status": "ok",
|
||||
"text": None}
|
||||
if naechster is not None and jetzt - naechster > HERMES_KARENZ_S:
|
||||
return {**eintrag, "status": "ueberfaellig",
|
||||
"text": f"Sollte vor {_dauer(jetzt - naechster)} laufen — der Planer von Hermes hat ihn nicht gestartet."}
|
||||
return eintrag
|
||||
|
||||
|
||||
# --- Gitea-Ampel ------------------------------------------------------------------------------------------------------
|
||||
|
||||
def _gitea_token() -> str | None:
|
||||
"""Das Token aus ~/.git-credentials (Zeile für die Gitea-Adresse). Erscheint nie in einer Ausgabe."""
|
||||
try:
|
||||
zeilen = GIT_ZUGANG.read_text(encoding="utf-8").splitlines()
|
||||
except OSError:
|
||||
return None
|
||||
host = GITEA_URL.split("//", 1)[-1].split(":", 1)[0]
|
||||
for z in zeilen:
|
||||
m = re.match(r"https?://[^:/@]+:([^@]+)@([^/:]+)", z.strip())
|
||||
if m and (m.group(2) == host or "ddnsfree" in m.group(2)):
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def _runner() -> list[dict] | None:
|
||||
token = _gitea_token()
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
r = httpx.get(f"{GITEA_URL}/api/v1/admin/actions/runners", headers={"Authorization": f"token {token}"},
|
||||
timeout=10)
|
||||
r.raise_for_status()
|
||||
daten = r.json()
|
||||
except Exception:
|
||||
log.info("ablaeufe: Gitea-Runner nicht abfragbar", exc_info=True)
|
||||
return None
|
||||
return [x for x in (daten.get("runners") or []) if isinstance(x, dict)] if isinstance(daten, dict) else None
|
||||
|
||||
|
||||
def bewerte_runner(runner: list[dict] | None) -> list[dict]:
|
||||
if runner is None:
|
||||
return []
|
||||
if not runner:
|
||||
return [{"id": "gitea:runner", "name": "Gitea-Ampel", "art": "ampel", "takt": "bei jedem Push",
|
||||
"letzter": None, "naechster": None, "status": "gestoppt",
|
||||
"text": "In Gitea ist kein Runner eingetragen — die Ampel prüft nichts."}]
|
||||
liste = []
|
||||
for x in runner:
|
||||
online = x.get("status") == "online" and not x.get("disabled")
|
||||
liste.append({"id": f"gitea:runner:{x.get('id')}", "name": f"Gitea-Ampel ({x.get('name')})", "art": "ampel",
|
||||
"takt": "bei jedem Push", "letzter": None, "naechster": None,
|
||||
"status": "ok" if online else "gestoppt",
|
||||
"text": None if online else (f"Der Runner {x.get('name')} meldet sich in Gitea als "
|
||||
f"„{x.get('status')}“ — Prüfläufe bleiben stehen.")})
|
||||
return liste
|
||||
|
||||
|
||||
# --- Zusammen -------------------------------------------------------------------------------------------------------
|
||||
|
||||
def _jobs() -> list[dict]:
|
||||
from services import waechter # waechter importiert dieses Modul
|
||||
return waechter._hermes_jobs()
|
||||
|
||||
|
||||
def ablaeufe(jetzt: float | None = None) -> list[dict]:
|
||||
"""Alle Abläufe mit Zustand, Probleme zuerst — für die Oberfläche und den Wächter."""
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
liste: list[dict] = []
|
||||
for z in ZEITPLAENE:
|
||||
if (e := bewerte_timer(z, _gemerkt(f"timer:{z.unit}", lambda u=z.unit: _timer_zustand(u)), jetzt)):
|
||||
liste.append(e)
|
||||
for job in _jobs():
|
||||
if (e := bewerte_job(job, jetzt)):
|
||||
liste.append(e)
|
||||
liste += bewerte_runner(_gemerkt("gitea", _runner))
|
||||
rang = {"ueberfaellig": 0, "gestoppt": 0, "ok": 1, "aus": 2}
|
||||
return sorted(liste, key=lambda e: rang.get(e["status"], 1))
|
||||
|
||||
|
||||
def pruefe_ueberfaellig():
|
||||
"""Befunde für den Wächter: überfällige oder gestoppte Abläufe (gelb). Die Sonntags-Updates rot — deren Stillstand
|
||||
kostete im September elf Tage."""
|
||||
from services.waechter import Befund, _aktion
|
||||
befunde = []
|
||||
for e in ablaeufe():
|
||||
if e["status"] not in ("ueberfaellig", "gestoppt"):
|
||||
continue
|
||||
wichtig = e["id"] == "timer:mc2-autoupdate"
|
||||
titel = f"{e['name']} ist überfällig" if e["status"] == "ueberfaellig" else f"{e['name']} läuft nicht"
|
||||
aktionen = []
|
||||
if e["art"] == "zeitplan":
|
||||
unit = e["id"].split(":", 1)[1]
|
||||
aktionen = [_aktion("protokoll", "Protokoll", dienst=unit)]
|
||||
befunde.append(Befund(id=f"ablauf:{e['id']}", stufe="rot" if wichtig else "gelb", titel=titel,
|
||||
text=e["text"] or "", quelle=e["id"], aktionen=aktionen, nach_takten=5))
|
||||
return befunde
|
||||
+51
-98
@@ -1,36 +1,22 @@
|
||||
"""
|
||||
Hermes-Agent-Status (Control-Plane-Read). MC betreibt Hermes NICHT — es zeigt nur
|
||||
Status + verlinkt das standalone hermes-webui. Voller Zugriff + Tools/MCP werden in
|
||||
Hermes' eigener Config verdrahtet (siehe docs/HERMES_SETUP.md).
|
||||
Hermes-Agent: Status und Hirn-Umstellung. MC betreibt Hermes NICHT — Werkzeuge und MCP werden in
|
||||
Hermes' eigener Config verdrahtet (siehe docs/ARCHITEKTUR.md, „Wer schreibt was").
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
import httpx
|
||||
from config import (
|
||||
BOX_CONSOLE_PATH,
|
||||
BOX_CONSOLE_UPSTREAM,
|
||||
HERMES_API_URL,
|
||||
HERMES_BUILTIN_UI_PATH,
|
||||
HERMES_BUILTIN_UI_UPSTREAM,
|
||||
HERMES_HOME,
|
||||
PC_EXECUTOR_URL,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _hermes_version(name: str) -> float | None:
|
||||
"""Versionszahl aus 'Hermes-4.3', 'Hermes-4', 'Nous-Hermes-2' → 4.3/4.0/2.0."""
|
||||
low = (name or "").lower()
|
||||
if "hermes" not in low:
|
||||
return None
|
||||
m = re.search(r"hermes[-_ ]?(\d+(?:\.\d+)?)", low)
|
||||
return float(m.group(1)) if m else None
|
||||
|
||||
|
||||
def _active_brain_name() -> str:
|
||||
"""Aktives Agent-Hirn aus Hermes' Config: model.default (sonst model.model)."""
|
||||
try:
|
||||
@@ -106,76 +92,29 @@ def _reach(url: str, path: str = "") -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _count_enabled_mcp_servers() -> int:
|
||||
config_path = HERMES_HOME / "config.yaml"
|
||||
if not config_path.exists():
|
||||
return 0
|
||||
try:
|
||||
from ruamel.yaml import YAML
|
||||
r_yaml = YAML()
|
||||
with config_path.open("r", encoding="utf-8") as f:
|
||||
cfg = r_yaml.load(f) or {}
|
||||
mcp_servers = cfg.get("mcp_servers", {}) if isinstance(cfg, dict) else {}
|
||||
if not isinstance(mcp_servers, dict):
|
||||
return 0
|
||||
return sum(1 for v in mcp_servers.values() if isinstance(v, dict) and v.get("enabled", True))
|
||||
except Exception:
|
||||
log.debug("_count_enabled_mcp_servers: Fehler", exc_info=True)
|
||||
return 0
|
||||
|
||||
|
||||
def agent_status() -> dict:
|
||||
"""Erreichbarkeit von Gateway (:8642) + WebUI (:8787) + lokale Hinweise."""
|
||||
home = HERMES_HOME
|
||||
brain_model = "auto"
|
||||
config_path = home / "config.yaml"
|
||||
if config_path.exists():
|
||||
try:
|
||||
from ruamel.yaml import YAML
|
||||
r_yaml = YAML()
|
||||
with config_path.open("r", encoding="utf-8") as f:
|
||||
cfg = r_yaml.load(f) or {}
|
||||
if isinstance(cfg, dict):
|
||||
# Hermes nutzt model.default als aktives Modell (model.model = Provider-Param).
|
||||
m = cfg.get("model", {}) or {}
|
||||
brain_model = m.get("default") or m.get("model") or "auto"
|
||||
except Exception:
|
||||
log.debug("agent_status: Hermes-config.yaml nicht lesbar", exc_info=True)
|
||||
|
||||
"""Erreichbarkeit des Hermes-Gateways (:8642) und des Hermes-Dashboards — für die Dienste-Liste.
|
||||
|
||||
Bis 24.09.2026 fragte die Funktion bei jedem Aufruf auch die abgebaute Konsole und den
|
||||
PC-Ausführer ab (3 s Zeitlimit) und las die Hermes-Config, obwohl nur diese Felder gebraucht
|
||||
werden. Seit der PC-Ausführer schläft, hätte das jede Dienste-Abfrage um 3 s verzögert."""
|
||||
return {
|
||||
"gateway_url": HERMES_API_URL,
|
||||
# Box-Konsole (ttyd → Login-Shell): same-origin über MC2 geproxyt (/console/).
|
||||
"box_console_url": BOX_CONSOLE_PATH,
|
||||
# Eingebaute Hermes-Web-GUI (hermes serve): same-origin über MC2 geproxyt (/hermes-ui/).
|
||||
# Seit dem Umzug auf Hermes Desktop ist :9119 zugleich das Desktop-Gateway.
|
||||
"hermes_ui_url": HERMES_BUILTIN_UI_PATH,
|
||||
"gateway_reachable": _reach(HERMES_API_URL, "/health"),
|
||||
# Erreichbarkeit des lokalen ttyd-Upstreams (Loopback, base-path /console).
|
||||
"box_console_reachable": _reach(BOX_CONSOLE_UPSTREAM, "/console/"),
|
||||
# Erreichbarkeit der eingebauten Hermes-GUI / des Desktop-Gateways (Loopback :9119).
|
||||
"hermes_ui_reachable": _reach(HERMES_BUILTIN_UI_UPSTREAM, "/"),
|
||||
"home_exists": home.exists(),
|
||||
"brain_model": brain_model,
|
||||
# Best-effort: welche Verdrahtung lokal sichtbar ist (auf der Box aussagekräftig).
|
||||
"has_config": (home / "config.yaml").exists() or (home / "config.json").exists(),
|
||||
"has_skills": (home / "skills").exists(),
|
||||
"has_memories": (home / "memories").exists(),
|
||||
# Neue Felder: Telegram, MCP-Server-Anzahl, PC-Executor-Erreichbarkeit.
|
||||
"telegram_enabled": bool(os.environ.get("TELEGRAM_BOT_TOKEN", "")),
|
||||
"mcp_server_count": _count_enabled_mcp_servers(),
|
||||
"pc_executor_reachable": _reach(PC_EXECUTOR_URL, "/health"),
|
||||
# Konfigurierte Adresse mitliefern, damit die UI sie ANZEIGT statt hartcodiert (Review 15.07.).
|
||||
"pc_executor_url": PC_EXECUTOR_URL,
|
||||
}
|
||||
|
||||
|
||||
def set_agent_brain(model_id: str) -> dict:
|
||||
def set_agent_brain(model_id: str, hermes_umstellen: bool = True) -> dict:
|
||||
"""Setzt ein (bereits installiertes) Modell als Agent-Hirn — WARM-bewusst:
|
||||
1) vergibt den 'hermes'-Alias (das Agent-Hirn-Slot),
|
||||
2) tauscht es in die residente brains-Gruppe (altes Hirn raus, fast/vision bleiben),
|
||||
3) zeigt die Hermes-Config auf den 'hermes'-Alias + Gateway-Restart.
|
||||
So bleibt das neue Hirn warm und der Agent nutzt es sofort."""
|
||||
So bleibt das neue Hirn warm und der Agent nutzt es sofort.
|
||||
hermes_umstellen=False: Schritt 3 macht der Aufrufer selbst — etwa das Radar, das erst nach dem
|
||||
einen gesammelten Schreibvorgang der llama-swap-Config Hermes neu startet."""
|
||||
from services import llamaswap
|
||||
models = {m["name"]: m for m in llamaswap.list_models()}
|
||||
if model_id not in models:
|
||||
@@ -185,6 +124,7 @@ def set_agent_brain(model_id: str) -> dict:
|
||||
# Idempotent härten: auch wenn schon Hirn, warm (brains) + ttl 0 sicherstellen.
|
||||
try:
|
||||
from services.llamaswap import set_ttl
|
||||
with llamaswap.sammeln():
|
||||
brains = (llamaswap.list_groups().get("brains") or {}).get("members") or []
|
||||
if model_id not in brains:
|
||||
llamaswap.set_group("brains", brains + [model_id], swap=False, persist=True)
|
||||
@@ -193,17 +133,20 @@ def set_agent_brain(model_id: str) -> dict:
|
||||
return {"ok": False, "reason": str(exc)}
|
||||
return {"ok": True, "old": old, "new": model_id, "note": "ist bereits das Agent-Hirn"}
|
||||
try:
|
||||
from services.llamaswap import DEFAULT_TTL, set_ttl
|
||||
# Alias, Gruppe und ttl als EIN Schreibvorgang (24.09.2026) — jeder einzelne entlud alle Modelle.
|
||||
with llamaswap.sammeln():
|
||||
llamaswap.set_role(model_id, "hermes") # 1) Alias
|
||||
brains = (llamaswap.list_groups().get("brains") or {}).get("members") or []
|
||||
new_members = [x for x in brains if x not in (old, model_id)] + [model_id]
|
||||
llamaswap.set_group("brains", new_members, swap=False, persist=True) # 2) warm
|
||||
# 2b) TTL härten: neues Hirn nie auto-entladen; altes Hirn auf Default entspannen.
|
||||
from services.llamaswap import DEFAULT_TTL, set_ttl
|
||||
set_ttl(model_id, 0)
|
||||
if old:
|
||||
set_ttl(old, DEFAULT_TTL)
|
||||
except PermissionError as exc:
|
||||
return {"ok": False, "reason": str(exc)}
|
||||
if hermes_umstellen:
|
||||
update_brain_model("hermes") # 3) Config + Restart
|
||||
# Weiche Budget-Warnung (kein Hard-Block): passt Hirn + größtes on-demand zusammen ins GTT?
|
||||
warning = None
|
||||
@@ -220,49 +163,59 @@ def set_agent_brain(model_id: str) -> dict:
|
||||
|
||||
|
||||
def update_brain_model(new_model: str) -> bool:
|
||||
"""Setzt Lucys Hirn in Hermes' config.yaml (model.default + model.model) und startet den
|
||||
Hermes-Gateway neu.
|
||||
|
||||
Seit 24.09.2026 vorsichtig: Ist die Datei nicht lesbar (halb geschrieben, Syntaxfehler), wird
|
||||
NICHTS geschrieben — früher entstand dann eine neue Datei nur mit dem model-Block, und der Rest
|
||||
von Hermes' Konfiguration wäre weg gewesen. Geschrieben wird atomar (tmp + os.replace), vorher
|
||||
liegt eine Sicherung config.yaml.bak-hirn-<Zeitstempel> daneben."""
|
||||
import shutil
|
||||
import time as _time
|
||||
|
||||
from config import HERMES_HOME
|
||||
home = HERMES_HOME
|
||||
config_path = home / "config.yaml"
|
||||
|
||||
# Ensure home directory exists
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
cfg = {}
|
||||
if config_path.exists():
|
||||
try:
|
||||
from ruamel.yaml import YAML
|
||||
r_yaml = YAML()
|
||||
with config_path.open("r", encoding="utf-8") as f:
|
||||
cfg = r_yaml.load(f) or {}
|
||||
except Exception:
|
||||
log.debug("update_brain_model: bestehende config.yaml nicht lesbar", exc_info=True)
|
||||
cfg = {}
|
||||
|
||||
config_path = HERMES_HOME / "config.yaml"
|
||||
if not config_path.exists():
|
||||
log.warning("update_brain_model: %s fehlt — Hirn wird nicht umgestellt", config_path)
|
||||
return False
|
||||
r_yaml = YAML()
|
||||
r_yaml.preserve_quotes = True
|
||||
r_yaml.width = 100
|
||||
r_yaml.indent(mapping=2, sequence=4, offset=2)
|
||||
try:
|
||||
with config_path.open("r", encoding="utf-8") as f:
|
||||
cfg = r_yaml.load(f)
|
||||
except Exception:
|
||||
log.warning("update_brain_model: config.yaml nicht lesbar — nichts geschrieben", exc_info=True)
|
||||
return False
|
||||
if not isinstance(cfg, dict):
|
||||
cfg = {}
|
||||
log.warning("update_brain_model: config.yaml hat unerwarteten Inhalt — nichts geschrieben")
|
||||
return False
|
||||
|
||||
if "model" not in cfg or not isinstance(cfg["model"], dict):
|
||||
cfg["model"] = {}
|
||||
|
||||
# Hermes liest model.default als aktives Modell; model.model ist der Provider-Param.
|
||||
# Beide setzen, sonst greift die Umschaltung nicht (latenter Bug: nur model.model gesetzt).
|
||||
# Beide setzen, sonst greift die Umschaltung nicht.
|
||||
cfg["model"]["default"] = new_model
|
||||
cfg["model"]["model"] = new_model
|
||||
|
||||
tmp = config_path.with_suffix(".yaml.neu")
|
||||
try:
|
||||
from ruamel.yaml import YAML
|
||||
r_yaml = YAML()
|
||||
with config_path.open("w", encoding="utf-8") as f:
|
||||
shutil.copy2(config_path, config_path.with_name(f"config.yaml.bak-hirn-{_time.strftime('%Y%m%d-%H%M%S')}"))
|
||||
with tmp.open("w", encoding="utf-8") as f:
|
||||
r_yaml.dump(cfg, f)
|
||||
YAML(typ="safe").load(tmp.read_text(encoding="utf-8")) # muss wieder lesbar sein
|
||||
os.replace(tmp, config_path)
|
||||
except Exception:
|
||||
log.warning("update_brain_model: Schreiben der config.yaml fehlgeschlagen", exc_info=True)
|
||||
tmp.unlink(missing_ok=True)
|
||||
return False
|
||||
|
||||
# Restart the user-space service to apply changes
|
||||
try:
|
||||
from services import maintenance
|
||||
maintenance.restart_service("hermes-gateway")
|
||||
except Exception:
|
||||
log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True)
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
log.warning("update_brain_model: Schreiben der config.yaml fehlgeschlagen", exc_info=True)
|
||||
return False
|
||||
|
||||
@@ -20,13 +20,13 @@ import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from config import MODELS_DIR
|
||||
from kern.einstellungen import einstellungen
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
NOTIFY_SH = str(Path(__file__).resolve().parent.parent.parent / "deploy" / "notify.sh")
|
||||
|
||||
STORE_PATH = Path(os.environ.get("MC_ANNOUNCE_STORE", str(MODELS_DIR / "mc2-announce.json")))
|
||||
STORE_PATH = Path(os.environ.get("MC_ANNOUNCE_STORE", str(einstellungen().daten_dir / "mc2-announce.json")))
|
||||
MAX_ITEMS = int(os.environ.get("MC_ANNOUNCE_MAX", "200"))
|
||||
|
||||
# Steward-Auszug (UMBAU v3 P2): Läuft der Absender AUSSERHALB des MC2-Prozesses
|
||||
@@ -99,27 +99,24 @@ def add(text: str, subject: str = "", source: str = "", priority: str = "normal"
|
||||
return item
|
||||
|
||||
|
||||
def notify_telegram(subject: str, text: str) -> None:
|
||||
def notify_telegram(subject: str, text: str, dringend: bool = False, knoepfe: list[str] | None = None) -> None:
|
||||
"""Best-effort auch auf Telegram (User ist evtl. nicht am PC). MC_NOTIFY_NO_ANNOUNCE=1
|
||||
verhindert, dass notify.sh die Meldung ZURÜCK in den Briefkasten spiegelt — der Absender
|
||||
(Wächter/Erinnerung) hat sie dort schon selbst abgelegt. Auf Windows (Dev) ein No-op."""
|
||||
(Wächter/Erinnerung) hat sie dort schon selbst abgelegt. Auf Windows (Dev) ein No-op.
|
||||
dringend: geht auch nachts sofort raus. knoepfe (seit 25.09.2026, Rückfragen): Antworttasten wie „ja 7“ —
|
||||
ein Tipp schickt genau diesen Text an Lucy."""
|
||||
if not (os.name == "posix" and shutil.which("bash")):
|
||||
return
|
||||
befehl = ["bash", NOTIFY_SH] + (["-d"] if dringend else [])
|
||||
if knoepfe:
|
||||
befehl += ["-k", "|".join(k.replace("|", " ") for k in knoepfe)]
|
||||
try:
|
||||
subprocess.run(["bash", NOTIFY_SH, "-s", subject, text],
|
||||
subprocess.run([*befehl, "-s", subject, text],
|
||||
timeout=30, capture_output=True, env={**os.environ, "MC_NOTIFY_NO_ANNOUNCE": "1"})
|
||||
except Exception:
|
||||
log.warning("notify_telegram: notify.sh fehlgeschlagen", exc_info=True)
|
||||
|
||||
|
||||
def list_recent(limit: int = 150) -> list[dict]:
|
||||
"""Die jüngsten Einträge (neueste zuerst) — Datenquelle der Chronik: alles, was die Box
|
||||
dem Commander je aktiv gemeldet hat (Health-Wächter, Updates, Radar, Träume, Alarme)."""
|
||||
with _lock:
|
||||
state = _load()
|
||||
return list(reversed(state["items"][-max(1, min(limit, MAX_ITEMS)):]))
|
||||
|
||||
|
||||
def list_after(after: int | None, limit: int = 20) -> dict:
|
||||
"""Einträge NACH Cursor `after` (aufsteigend). Ohne Cursor nur den aktuellen
|
||||
Stand liefern (latest) — so initialisiert Lucy ihren Cursor, ohne Altes nachzuplappern."""
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
"""
|
||||
Aufräumen der Modell-Platte (Box-Wart, Umbau 09/2026): was belegt Platz, ohne gebraucht zu werden?
|
||||
|
||||
Zwei Arten von Kandidaten:
|
||||
• Einträge ohne Rolle llama-swap-Einträge, die keine der heutigen Rollen tragen (Hirn, Coder, ihre
|
||||
Bild-Zwillinge, Einbettung, Reranker) und in keiner immer-warmen Gruppe stehen.
|
||||
• Ordner ohne Eintrag Unterordner von MODELS_DIR, auf die kein Eintrag mehr zeigt (alte Originale,
|
||||
Reserve-Modelle, Drafts aus früheren Versuchen).
|
||||
|
||||
Gelöscht wird nur auf Knopfdruck des Nutzers (mit Rückfrage in der Oberfläche), nie automatisch
|
||||
(User-Entscheid 23.09.: „nach der Umstellung löschen“ — die Entscheidung je Modell bleibt beim Nutzer).
|
||||
Ein Eintrag wird aus der Config genommen, danach verschwinden nur Ordner, auf die NICHTS mehr zeigt:
|
||||
Die Bild-Zwillinge teilen sich ihre Gewichte mit Hirn und Coder, geteilte Dateien bleiben immer liegen.
|
||||
|
||||
Seit 24.09.2026 zusätzlich: Altreste neben dem Betrieb (Test-Ordner, alte Umgebungen, Worktrees, Alt-Units) aus
|
||||
einer festen Liste — siehe ALTRESTE unten.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from config import MODELS_DIR
|
||||
|
||||
from services import llamaswap
|
||||
|
||||
AKTIVE_ROLLEN = {"hermes", "fast", "coder", "heavy", "vision", "coder-bild", "embed", "reranker"}
|
||||
SYSTEM_ORDNER = {"mc2-backups", "mc2-messwerte", "radar", "pruefstand-cache", "lost+found"}
|
||||
AUF_DER_BOX = os.name == "posix" # auf dem Windows-PC (Entwicklung) wird nie gelöscht
|
||||
|
||||
# Was der Nutzer über bekannte Ordner wissen sollte, bevor er löscht (Stand 24.09.2026).
|
||||
HINWEISE = {
|
||||
"Qwen3.6-35B-A3B-MTP-GGUF": "Original des Hirns vor der Uncensored-Variante (17.09.) — der Rückweg, falls das Hirn "
|
||||
"einmal Probleme macht.",
|
||||
"Qwen3.8-27B-GGUF": "Original des Coders vor der Uncensored-Variante (17.09.) — der Rückweg für den Coder.",
|
||||
"Nemotron-3.5-Lightning-30B-A3B-GGUF": "Reserve-Hirn aus dem Prüfstand vom 17.09. (gleich gut bewertet, "
|
||||
"nie in Betrieb).",
|
||||
"DFlash-drafts": "Draft-Modelle aus früheren Versuchen, heute ungenutzt.",
|
||||
"drafts": "Draft-Modelle aus früheren Versuchen, heute ungenutzt.",
|
||||
"gpt-oss-120b": "Früheres heavy-Modell, hat seit dem 04.09. keine Rolle mehr.",
|
||||
"Qwen3-VL-30B-A3B-Instruct": "Früheres Bild-Modell, seit dem 24.09. durch die Bild-Zwillinge abgelöst.",
|
||||
"Muse-Glimmer-30B": "Debugger — die Rolle entfällt (Entscheid 23.09.: höchstens Hirn und Coder).",
|
||||
}
|
||||
|
||||
|
||||
def _ordner_von(pfad: str) -> str | None:
|
||||
"""Oberster Ordner unter MODELS_DIR, in dem eine Datei liegt (oder None, wenn sie woanders liegt)."""
|
||||
if not os.path.isabs(pfad):
|
||||
return None
|
||||
try:
|
||||
rel = Path(pfad).resolve().relative_to(Path(MODELS_DIR).resolve())
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
return rel.parts[0] if len(rel.parts) > 1 else None
|
||||
|
||||
|
||||
def _genutzte_ordner(models: dict) -> dict[str, set[str]]:
|
||||
"""Ordner → Einträge, deren Befehl eine Datei darin nennt (Gewichte, Projektor, Draft)."""
|
||||
genutzt: dict[str, set[str]] = {}
|
||||
for name, spec in models.items():
|
||||
if not isinstance(spec, dict):
|
||||
continue
|
||||
for wort in str(spec.get("cmd") or "").split(): # jedes Wort, das unter MODELS_DIR liegt
|
||||
if (ordner := _ordner_von(wort.strip("'\""))):
|
||||
genutzt.setdefault(ordner, set()).add(name)
|
||||
return genutzt
|
||||
|
||||
|
||||
def _groesse(pfad: Path) -> int:
|
||||
summe = 0
|
||||
for wurzel, _, dateien in os.walk(pfad):
|
||||
for datei in dateien:
|
||||
try:
|
||||
summe += os.path.getsize(os.path.join(wurzel, datei))
|
||||
except OSError:
|
||||
pass
|
||||
return summe
|
||||
|
||||
|
||||
def _immer_warm(cfg: dict) -> set[str]:
|
||||
return {m for g in (cfg.get("groups") or {}).values()
|
||||
if isinstance(g, dict) and (g.get("persistent") or g.get("persist")) for m in g.get("members") or []}
|
||||
|
||||
|
||||
def kandidaten() -> list[dict]:
|
||||
"""Alles, was sich löschen ließe — größte zuerst. Löscht nichts."""
|
||||
cfg = llamaswap.read_config()
|
||||
models = cfg.get("models") or {}
|
||||
warm = _immer_warm(cfg)
|
||||
genutzt = _genutzte_ordner(models)
|
||||
liste: list[dict] = []
|
||||
for name, spec in models.items():
|
||||
aliase = {str(a).lower() for a in (spec or {}).get("aliases") or []}
|
||||
if aliase & AKTIVE_ROLLEN or name in warm:
|
||||
continue
|
||||
eigene = {o for o, wer in genutzt.items() if wer == {name}} # nur von diesem Eintrag genutzt
|
||||
groesse = sum(_groesse(Path(MODELS_DIR) / o) for o in eigene)
|
||||
rolle = f"Rolle „{', '.join(sorted(aliase))}“ gibt es nicht mehr." if aliase else "Hat keine Rolle."
|
||||
liste.append({"art": "eintrag", "name": name, "groesse_gb": round(groesse / 1e9, 1),
|
||||
"hinweis": HINWEISE.get(name, rolle)})
|
||||
try:
|
||||
ordner = sorted(p for p in Path(MODELS_DIR).iterdir() if p.is_dir())
|
||||
except OSError:
|
||||
ordner = []
|
||||
for pfad in ordner:
|
||||
if pfad.name in genutzt or pfad.name in SYSTEM_ORDNER or pfad.name.startswith("."):
|
||||
continue
|
||||
liste.append({"art": "ordner", "name": pfad.name, "groesse_gb": round(_groesse(pfad) / 1e9, 1),
|
||||
"hinweis": HINWEISE.get(pfad.name, "Kein Eintrag nutzt diesen Ordner.")})
|
||||
return sorted(liste, key=lambda k: -k["groesse_gb"])
|
||||
|
||||
|
||||
def _ordner_loeschen(name: str) -> int:
|
||||
"""Einen Ordner direkt unter MODELS_DIR löschen; gibt die freigewordenen Bytes zurück."""
|
||||
wurzel = Path(MODELS_DIR).resolve()
|
||||
pfad = (wurzel / name).resolve()
|
||||
if pfad.parent != wurzel or not pfad.is_dir() or name in SYSTEM_ORDNER or name.startswith("."):
|
||||
raise ValueError(f"{name} ist kein löschbarer Modell-Ordner.")
|
||||
groesse = _groesse(pfad)
|
||||
shutil.rmtree(pfad)
|
||||
return groesse
|
||||
|
||||
|
||||
def loeschen(art: str, name: str) -> dict:
|
||||
"""Knopf „Löschen“: nur, was gerade als Kandidat gilt. Gibt {ok, text} oder {ok: False, detail} zurück."""
|
||||
if not any(k["art"] == art and k["name"] == name for k in kandidaten()):
|
||||
return {"ok": False, "detail": f"{name} ist nicht (mehr) zum Löschen frei."}
|
||||
if not AUF_DER_BOX:
|
||||
return {"ok": False, "detail": "Löschen geht nur auf der Box."}
|
||||
frei = 0
|
||||
try:
|
||||
if art == "ordner":
|
||||
frei = _ordner_loeschen(name)
|
||||
else:
|
||||
with llamaswap.config_sperre():
|
||||
cfg = llamaswap.read_config()
|
||||
models = cfg.get("models") or {}
|
||||
vorher = _genutzte_ordner(models)
|
||||
del models[name]
|
||||
for gruppe in (cfg.get("groups") or {}).values():
|
||||
if isinstance(gruppe, dict) and name in (gruppe.get("members") or []):
|
||||
gruppe["members"] = [m for m in gruppe["members"] if m != name]
|
||||
llamaswap.write_config(cfg)
|
||||
nachher = _genutzte_ordner(models)
|
||||
for ordner in sorted(set(vorher) - set(nachher)): # nur Ordner, auf die jetzt nichts mehr zeigt
|
||||
frei += _ordner_loeschen(ordner)
|
||||
except (OSError, ValueError) as e:
|
||||
return {"ok": False, "detail": f"Löschen ging nicht: {e}"}
|
||||
return {"ok": True, "text": f"{name} gelöscht, {frei / 1e9:.1f} GB frei."}
|
||||
|
||||
|
||||
# --- Altreste neben dem Betrieb (seit 24.09.2026) ---------------------------------------------------------------
|
||||
# Feste Liste aus dem Prüfbericht vom 24.09.2026, am selben Abend auf der Box nachgemessen (du) und gegengeprüft:
|
||||
# Kein laufender Dienst, keine eingetragene Unit, kein Cron- oder Hermes-Job, kein aktiver Skill und keine
|
||||
# Hermes-Einstellung nennt einen dieser Pfade. Angezeigt wird nur, was noch da ist; gelöscht wird nur auf Knopfdruck
|
||||
# mit Rückfrage und nur ein Eintrag dieser Liste — die Anfrage nennt eine Kennung, nie einen Pfad.
|
||||
#
|
||||
# Bewusst NICHT in der Liste, weil nicht klar tot: ~/.voice (die Spracherkennung schläft nur), ~/.cache (Modelle
|
||||
# von Lucys Stimme), ~/.opencode (steht in ~/.bashrc), ~/.hermes/kanban.db (Hermes öffnet sie noch), Archive
|
||||
# (profiles-archive, archiv-aufraeumen, wissens-vault), ~/.hermes/state-snapshots, der PBS-Weg, die Rückweg-Kopien
|
||||
# des Updaters (/opt/llamacpp-vulkan.bak, llama-swap.bak) und Skripte, die ein aktiver Skill noch nennt
|
||||
# (ampel-waechter.sh, night-cron-wrapper.sh, pc_path_lookup.*).
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Altrest:
|
||||
id: str
|
||||
name: str
|
||||
pfade: tuple[str, ...] # "~/…" (Home des Dienst-Nutzers) oder absolut
|
||||
hinweis: str
|
||||
art: str = "ordner" # "ordner" | "worktree" (danach git worktree prune) | "unit" (danach daemon-reload)
|
||||
sperre: tuple[str, ...] = () # User-Units, die weder laufen noch für den Autostart eingetragen sein dürfen
|
||||
sudo: bool = False # Elternordner gehört root (/opt): den geleerten Ordner mit sudo -n entfernen
|
||||
|
||||
|
||||
_UNITS = "~/.config/systemd/user/"
|
||||
_STEMPEL = "~/.local/share/systemd/timers/stamp-"
|
||||
_SKRIPTE = "~/.hermes/scripts/"
|
||||
|
||||
ALTRESTE: tuple[Altrest, ...] = (
|
||||
Altrest("zonos2-test", "TTS-Test Zonos2", ("~/zonos2-test",),
|
||||
"Test einer anderen Sprachausgabe vom 04.09. Lucys Stimme ist pocket-tts (~/.lucy-stimme); "
|
||||
"nichts ruft diesen Ordner auf."),
|
||||
Altrest("rocm-build", "Alter ROCm-Build von llama.cpp", ("/opt/llamacpp",),
|
||||
"Motor aus der ROCm-Zeit (Juni). Seit dem Vulkan-Verdikt läuft er aus /opt/llamacpp-vulkan, und "
|
||||
"llama-swap verweist nicht mehr hierher. Zurück zu ROCm ginge danach nur mit einem neuen Download.",
|
||||
sudo=True),
|
||||
Altrest("mem0", "mem0-Umgebung", ("~/.mem0",),
|
||||
"Gedächtnis-Sidecar, am 27.08. abgelöst: Hermes führt das Gedächtnis selbst. Nur die abgeschaltete "
|
||||
"Unit mem0-service verweist noch darauf.", sperre=("mem0-service.service",)),
|
||||
Altrest("kanban", "Kanban-Arbeitsordner", ("~/.hermes/kanban",),
|
||||
"Arbeitsordner, Anhänge und Protokolle der Kanban-Aufgaben aus Juli und August. Kanban ist in Hermes "
|
||||
"abgeschaltet; die kleine Datenbank kanban.db bleibt liegen, weil Hermes sie noch öffnet."),
|
||||
Altrest("alte-venvs", "Alte Python-Umgebungen", ("~/.venv-audit", "~/.venv-lint", "~/.litellm-venv"),
|
||||
"Umgebungen für den früheren venv-Audit, Lint-Versuche und LiteLLM (der Gateway ist seit Juli "
|
||||
"eingebaut). Kein Dienst und kein Job nutzt sie."),
|
||||
Altrest("audiocpp-test", "audio.cpp-Test", ("~/audiocpp-test",),
|
||||
"Test von audio.cpp mit Vulkan vom 04.09., nie in Betrieb gegangen."),
|
||||
Altrest("avatar", "Avatar in der Oberfläche", ("~/mission-control-v2/frontend/dist/avatar.vrm",),
|
||||
"VRM-Avatar vom 25.07., nie im Repo. Die Oberfläche lädt ihn nicht (kein Abruf in 30 Tagen), "
|
||||
"Lucy-Desktop bringt einen eigenen mit."),
|
||||
Altrest("worktrees", "Alte Git-Arbeitskopien",
|
||||
("~/.hermes/worktrees/orch-graph-verknuepfer", "~/.hermes/worktrees/orch-graph-verknuepfer-work",
|
||||
"~/.hermes/worktrees/wartung-remove-openrouter", "~/projekte/mc2-referenz",
|
||||
"~/mission-control-v2/.worktrees/t_b7b426dc"),
|
||||
"Arbeitskopien alter Aufgaben aus Juli und August, weit hinter main. Ihre Zweige bleiben im Box-Repo "
|
||||
"erhalten; verloren gingen nur Änderungen, die dort nie committet wurden.", art="worktree"),
|
||||
Altrest("test-reste", "Test-Reste im Home-Ordner",
|
||||
("~/v2test", "~/bench_ab", "~/stimm_varianten", "~/stimm_moods", "~/emotion_test", "~/english_probe",
|
||||
"~/zed-web-search-mcp", "~/cron_digest.py", "~/mc2-träum-duplikatscheck.patch"),
|
||||
"Hörproben der Stimm-Tests vom 09. bis 12.07., ein MCP-Server der abgelösten Zed-Bahn, ein Skript des "
|
||||
"alten Morgen-Digests und ein Patch aus der mem0-Zeit. Lucys Stimme nutzt ihre Referenz in "
|
||||
"~/.lucy-stimme."),
|
||||
Altrest("hermes-fremd", "Fremde Dateien im Hermes-Quellbaum",
|
||||
("~/.hermes/hermes-agent/hermes_cli/mc2_kanban_reaper.py",
|
||||
"~/.hermes/hermes-agent/hermes_cli/web_dist.bak-rootbuild"),
|
||||
"Ein alter Kanban-Aufräumer und eine Sicherung der Web-Oberfläche, beide nicht von Hermes. Nichts lädt "
|
||||
"sie; Hermes selbst bleibt unverändert."),
|
||||
Altrest("hermes-skripte", "Alte Skripte in ~/.hermes/scripts",
|
||||
tuple(_SKRIPTE + s for s in (
|
||||
"blogwatcher-logger.py", "briefing-date.sh", "fremdblick.sh", "gitea-repo-create.sh",
|
||||
"hermes-release-radar-feed.sh", "news-melden.sh.bak-20260923", "radar-feed.sh", "restore-probe.sh",
|
||||
"selbstkritik-feed.sh", "venv-audit.sh", "worker.sh")),
|
||||
"Skripte früherer Cron-Jobs (Werkstatt, Ampel, alte Radare). Kein Job, kein Cron und kein aktiver "
|
||||
"Skill ruft sie auf; deploy.sh legt nur die heutigen Skripte dorthin."),
|
||||
Altrest("hermes-alt-umgebung", "Alte Hermes-Umgebung (Python 3.11)",
|
||||
("~/.hermes/hermes-agent/venv", "~/.hermes/hermes-agent/venv.bak-20260925-vor-pm"),
|
||||
"Hermes' Umgebung von vor dem eigenen Paketmanager (hermes pm, 25.09.) und ihre Sicherung. Seit 25.09. "
|
||||
"starten Gateway und Dashboard aus der neuen Umgebung; Selbstreparatur und PC-Pfad-Skill nutzen das "
|
||||
"System-Python. Empfohlen: erst nach einem grünen Sonntagslauf (27.09.) löschen."),
|
||||
Altrest("mc2-memory", "Plugin mc2-memory", ("~/.hermes/plugins/mc2-memory",),
|
||||
"Gedächtnis-Plugin aus der mem0-Zeit, in Hermes nicht aktiviert und nicht mehr im Repo."),
|
||||
Altrest("pinned-version", "Veraltete PINNED_VERSION", ("~/.hermes/PINNED_VERSION",),
|
||||
"Sagt noch „v0.17.0“ und führt in die Irre: Festgehaltene Versionen stehen in /srv/models/mc2-pins.json."),
|
||||
Altrest("alt-units", "Abgeschaltete Alt-Units",
|
||||
tuple(_UNITS + u for u in (
|
||||
"mem0-service.service", "mem0-service.service.bak-174750", "mc2-morgen-digest.service",
|
||||
"mc2-morgen-digest.timer", "mc2-selfsmoke.service", "mc2-selfsmoke.timer",
|
||||
"hermes-dashboard.service", "lucy-stimme.service.bak-20260904"))
|
||||
+ (_STEMPEL + "mc2-morgen-digest.timer", _STEMPEL + "mc2-selfsmoke.timer"),
|
||||
"Units früherer Dienste (mem0, Morgen-Digest, Selbsttest, altes Hermes-Dashboard) samt zwei Sicherungen "
|
||||
"davon, alle abgeschaltet; ihre Skripte gibt es nicht mehr. Danach liest systemd die Units neu ein.",
|
||||
art="unit",
|
||||
sperre=("mem0-service.service", "mc2-morgen-digest.service", "mc2-morgen-digest.timer",
|
||||
"mc2-selfsmoke.service", "mc2-selfsmoke.timer", "hermes-dashboard.service")),
|
||||
)
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
ALTRESTE_CACHE_S = 300 # Größen messen dauert (os.walk über ~20 000 Dateien); sie ändern sich selten
|
||||
_altreste_lock = threading.Lock()
|
||||
_altreste_cache: dict = {"ts": 0.0, "liste": None}
|
||||
|
||||
|
||||
def _home() -> Path:
|
||||
return Path(os.environ.get("MC_ALTRESTE_HOME", str(Path.home())))
|
||||
|
||||
|
||||
def _pfad(roh: str) -> Path:
|
||||
return _home() / roh[2:] if roh.startswith("~/") else Path(roh)
|
||||
|
||||
|
||||
def _belegt(pfad: Path) -> int:
|
||||
"""Bytes einer Datei oder eines Ordners, ohne Verknüpfungen zu folgen (ein venv zeigt sonst aufs System-Python)."""
|
||||
try:
|
||||
st = pfad.lstat()
|
||||
except OSError:
|
||||
return 0
|
||||
if not stat.S_ISDIR(st.st_mode):
|
||||
return st.st_size
|
||||
summe = 0
|
||||
for wurzel, _, dateien in os.walk(pfad):
|
||||
for datei in dateien:
|
||||
try:
|
||||
summe += os.lstat(os.path.join(wurzel, datei)).st_size
|
||||
except OSError:
|
||||
pass
|
||||
return summe
|
||||
|
||||
|
||||
def _menge(n: int) -> str:
|
||||
"""Größe in Worten, dezimal wie die Modell-Kandidaten: „7,7 GB“, „323 MB“, „44 KB“."""
|
||||
if n >= 1e9:
|
||||
return f"{n / 1e9:.1f} GB".replace(".", ",")
|
||||
if n >= 1e6:
|
||||
return f"{n / 1e6:.0f} MB"
|
||||
if n >= 1e3:
|
||||
return f"{n / 1e3:.0f} KB"
|
||||
return "unter 1 KB"
|
||||
|
||||
|
||||
def _befehl(args: list[str]) -> tuple[bool, str]:
|
||||
"""Befehle (systemctl, git, sudo) über eine Schicht, die Tests ersetzen. Ohne das Programm: (False, Grund)."""
|
||||
try:
|
||||
r = subprocess.run(args, capture_output=True, text=True, timeout=60)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
return False, f"{exc.__class__.__name__}: {exc}"
|
||||
return r.returncode == 0, ((r.stdout if r.returncode == 0 else (r.stderr or r.stdout)) or "").strip()[:300]
|
||||
|
||||
|
||||
def _vorhanden(a: Altrest) -> list[tuple[str, Path]]:
|
||||
return [(roh, p) for roh in a.pfade if (p := _pfad(roh)).exists() or p.is_symlink()]
|
||||
|
||||
|
||||
def _sperrgrund(a: Altrest) -> str | None:
|
||||
"""Läuft eine der Units noch oder steht sie im Autostart, wird der Rest gebraucht — dann nicht anbieten."""
|
||||
for unit in a.sperre:
|
||||
ok, ausgabe = _befehl(["systemctl", "--user", "show", unit, "-p", "ActiveState,UnitFileState"])
|
||||
z = dict(zeile.split("=", 1) for zeile in ausgabe.splitlines() if "=" in zeile) if ok else {}
|
||||
if z.get("ActiveState") in ("active", "activating", "reloading"):
|
||||
return f"{unit} läuft gerade, {a.name} wird also noch gebraucht."
|
||||
if z.get("UnitFileState") in ("enabled", "enabled-runtime", "linked", "linked-runtime"):
|
||||
return f"{unit} steht im Autostart, {a.name} wird also noch gebraucht."
|
||||
return None
|
||||
|
||||
|
||||
def altreste(frisch: bool = False) -> list[dict]:
|
||||
"""Die Altreste der festen Liste, die noch da und nicht in Gebrauch sind — größte zuerst. Löscht nichts."""
|
||||
with _altreste_lock:
|
||||
if not frisch and _altreste_cache["liste"] is not None \
|
||||
and time.time() - _altreste_cache["ts"] < ALTRESTE_CACHE_S:
|
||||
return [dict(e) for e in _altreste_cache["liste"]]
|
||||
liste = []
|
||||
for a in ALTRESTE:
|
||||
da = _vorhanden(a)
|
||||
if not da or _sperrgrund(a):
|
||||
continue
|
||||
groesse = sum(_belegt(p) for _, p in da)
|
||||
liste.append({"id": a.id, "name": a.name, "pfade": [roh for roh, _ in da], "groesse_bytes": groesse,
|
||||
"groesse": _menge(groesse), "hinweis": a.hinweis})
|
||||
liste.sort(key=lambda e: -e["groesse_bytes"])
|
||||
with _altreste_lock:
|
||||
_altreste_cache.update(ts=time.time(), liste=liste)
|
||||
return [dict(e) for e in liste]
|
||||
|
||||
|
||||
def _entfernen(pfad: Path, a: Altrest) -> None:
|
||||
if pfad.is_symlink() or not pfad.is_dir():
|
||||
pfad.unlink() # eine Verknüpfung selbst, nie ihr Ziel
|
||||
return
|
||||
try:
|
||||
shutil.rmtree(pfad)
|
||||
except PermissionError:
|
||||
if not a.sudo:
|
||||
raise
|
||||
# /opt gehört root: Den Inhalt darf der Dienst-Nutzer löschen, den geleerten Ordner selbst nur mit sudo.
|
||||
ok, grund = _befehl(["sudo", "-n", "rm", "-rf", "--", str(pfad)])
|
||||
if not ok:
|
||||
raise PermissionError(f"sudo -n rm ging nicht ({grund})") from None
|
||||
|
||||
|
||||
def altrest_loeschen(kennung: str) -> dict:
|
||||
"""Knopf „Löschen“ eines Altrests: nur Einträge der festen Liste, nur was da ist, nur wenn nichts es braucht."""
|
||||
a = next((x for x in ALTRESTE if x.id == kennung), None)
|
||||
if a is None:
|
||||
return {"ok": False, "detail": "Diesen Altrest kennt die Liste nicht."}
|
||||
if not AUF_DER_BOX:
|
||||
return {"ok": False, "detail": "Löschen geht nur auf der Box."}
|
||||
da = _vorhanden(a)
|
||||
if not da:
|
||||
return {"ok": False, "detail": f"{a.name}: schon weg."}
|
||||
if (grund := _sperrgrund(a)):
|
||||
return {"ok": False, "detail": grund}
|
||||
frei, fehler = 0, []
|
||||
for roh, pfad in da:
|
||||
groesse = _belegt(pfad)
|
||||
try:
|
||||
_entfernen(pfad, a)
|
||||
frei += groesse
|
||||
except OSError as exc:
|
||||
fehler.append(f"{roh} ({exc.strerror or exc})")
|
||||
if a.art == "worktree": # Git vergisst die gelöschten Arbeitskopien, die Zweige bleiben im Repo
|
||||
_befehl(["git", "-C", str(REPO), "worktree", "prune"])
|
||||
elif a.art == "unit":
|
||||
_befehl(["systemctl", "--user", "daemon-reload"])
|
||||
with _altreste_lock:
|
||||
_altreste_cache.update(ts=0.0, liste=None)
|
||||
if fehler:
|
||||
return {"ok": False, "detail": f"{a.name} nur zum Teil gelöscht ({_menge(frei)} frei). Geblieben: "
|
||||
+ "; ".join(fehler)}
|
||||
return {"ok": True, "text": f"{a.name} gelöscht, {_menge(frei)} frei."}
|
||||
@@ -1,525 +0,0 @@
|
||||
"""
|
||||
Auftragsbuch — die Vorschlags-Inbox der Box (das Mensch-Gate als Klick statt Git-Handarbeit).
|
||||
|
||||
Quellen der Karten:
|
||||
• Vorschlags-Branches auf Gitea (wartung/*, orchestrator/*, doku/*) — die Werkstatt und der
|
||||
Orchestrator arbeiten propose-only und lassen ihre Ergebnisse dort liegen.
|
||||
Seit S3 (lucy-pipeline) aus ZWEI Repos: mission-control-v2 (Box-Stack) UND lucy (Desktop-App).
|
||||
• Skill-Kandidaten aus dem Wissens-Vault (~/wissens-vault/skill-kandidaten/) — Vorschläge des
|
||||
nächtlichen Traum-Crons, Gate war bisher „Commander sagt mach".
|
||||
|
||||
Annehmen (Branch) startet den Repo-eigenen Runner als EIGENE systemd-Unit (detached):
|
||||
• mc2: deploy/auftrag-annehmen.sh — Merge im Worktree → Push main → Deploy → Health → Revert bei Rot.
|
||||
• lucy: deploy/lucy-annahme.sh — Merge im Worktree → Push main → PC baut dist (via PC-Executor)
|
||||
und startet Lucy neu, wenn sie lief → bei Rot Revert auf main (die laufende Lucy bleibt die alte).
|
||||
Detached, weil deploy.sh mission-control-2 neu startet — ein Kind des Backends stürbe mittendrin.
|
||||
Der Fortschritt landet in STATUS_PATH (JSON), das Skript schreibt, die API liest nur.
|
||||
Status-Schlüssel: mc2 = Branch-Name pur (Bestand), lucy = "lucy:<branch>" (kollisionsfrei).
|
||||
|
||||
Lokal (Windows-Dev) ist alles harmlos: available=False, Aktionen geben Fehler statt zu crashen.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from config import MODELS_DIR
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
REPO = Path(os.environ.get("MC2_SOURCE_DIR", "~/mission-control-v2")).expanduser()
|
||||
LUCY = Path(os.environ.get("MC2_LUCY_DIR", "~/lucy")).expanduser()
|
||||
VAULT = Path(os.environ.get("MC_VAULT_DIR", "~/wissens-vault")).expanduser()
|
||||
STATUS_PATH = Path(os.environ.get("MC2_AUFTRAG_STATUS", str(MODELS_DIR / "mc2-auftragsbuch.json")))
|
||||
# Empfehlungs-Stempel des nächtlichen Karten-Gutachters (deploy/karten-gutachter.sh schreibt,
|
||||
# die API liest nur) und das Lern-Gedächtnis der Ablehnungen (Radar/Specifier lesen es).
|
||||
GUTACHTEN_PATH = Path(os.environ.get("MC2_KARTEN_GUTACHTEN", str(MODELS_DIR / "mc2-karten-gutachten.json")))
|
||||
ABLEHNUNGEN_PATH = Path(os.environ.get("MC2_ABLEHNUNGEN", str(MODELS_DIR / "mc2-ablehnungen.jsonl")))
|
||||
|
||||
# Karten-Quellen. Die Runner-Skripte liegen IMMER im MC2-Checkout (deploy/) — auch der
|
||||
# Lucy-Runner, denn er läuft auf der Box; nur der Build passiert am PC.
|
||||
REPOS: dict[str, dict] = {
|
||||
"mc2": {"path": REPO, "runner": "auftrag-annehmen.sh", "key": ""},
|
||||
"lucy": {"path": LUCY, "runner": "lucy-annahme.sh", "key": "lucy:"},
|
||||
}
|
||||
|
||||
# Persistenz für gemeldete Branches (Idempotenz: nur EINMAL pro Branch pingen).
|
||||
# Muster wie mc2-announce.json unter MODELS_DIR.
|
||||
ANNOUNCE_BRANCHES_PATH = Path(os.environ.get("MC2_ANNOUNCE_BRANCHES", str(MODELS_DIR / "mc2-announce-branches.json")))
|
||||
|
||||
# Nur diese Branch-Familien sind Vorschläge (main/HEAD & Fremdes bleiben draußen).
|
||||
PREFIXES = ("wartung/", "orchestrator/", "doku/", "feature/")
|
||||
# Branch-Namen kommen vom Client zurück → hart validieren (keine Shell-/Git-Injektion).
|
||||
_BRANCH_RX = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,120}$")
|
||||
_KANDIDAT_RX = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._ -]{0,120}\.md$")
|
||||
|
||||
_fetch_cache: dict = {}
|
||||
_FETCH_EVERY = 30.0 # s — Gitea nicht bei jedem UI-Poll anfragen
|
||||
_CI_CACHE: dict = {} # { (repo, branch, head_sha): {"status": "success", "ts": time} }
|
||||
|
||||
def _gitea_creds() -> tuple | None:
|
||||
# 1. Fallback: Versuch via git credential manager (zukunftsfähig & plattformübergreifend)
|
||||
try:
|
||||
import subprocess
|
||||
p = subprocess.run(
|
||||
["git", "credential", "fill"],
|
||||
input=b"protocol=https\nhost=git.tobisniceshomelab.ddnsfree.com\n",
|
||||
capture_output=True,
|
||||
check=True
|
||||
)
|
||||
out = p.stdout.decode(errors="replace")
|
||||
u_match = re.search(r"username=(.+)", out)
|
||||
p_match = re.search(r"password=(.+)", out)
|
||||
if u_match and p_match:
|
||||
return u_match.group(1).strip(), p_match.group(1).strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Fallback: Datei (hauptsächlich auf der AI-Box genutzt)
|
||||
try:
|
||||
cred = Path("~/.git-credentials").expanduser()
|
||||
for line in cred.read_text(encoding="utf-8").splitlines():
|
||||
m = re.match(r"https://([^:]+):([^@]+)@git\.tobisniceshomelab", line.strip())
|
||||
if m: return m.group(1), m.group(2)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _fetch_ci_status(repo: str, branch: str, head_sha: str) -> str | None:
|
||||
# Nur auf der Box (wo creds liegen) sinnvoll
|
||||
creds = _gitea_creds()
|
||||
if not creds: return None
|
||||
_user, token = creds
|
||||
cache_key = (repo, branch, head_sha)
|
||||
cached = _CI_CACHE.get(cache_key)
|
||||
if cached and (time.time() - cached["ts"] < 30 or cached["status"] in ("success", "failure", "skipped")):
|
||||
return cached["status"]
|
||||
|
||||
full_repo = "Hitonabi/mission-control-v2" if repo == "mc2" else "Hitonabi/lucy"
|
||||
url = f"http://192.168.178.153:3000/api/v1/repos/{full_repo}/actions/runs?branch={urllib.parse.quote(branch)}&limit=1"
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"Authorization": "token " + token})
|
||||
with urllib.request.urlopen(req, timeout=5) as r:
|
||||
data = json.load(r)
|
||||
runs = data if isinstance(data, list) else data.get("runs", data.get("workflow_runs", []))
|
||||
if runs:
|
||||
run = runs[0]
|
||||
if run.get("head_sha", "").startswith(head_sha[:7]):
|
||||
st = run.get("status")
|
||||
if st:
|
||||
_CI_CACHE[cache_key] = {"status": st, "ts": time.time()}
|
||||
return st
|
||||
except Exception as e:
|
||||
log.warning("auftragsbuch: CI-Status fetch fehler: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
def _available() -> bool:
|
||||
return os.name == "posix" and (REPO / ".git").exists()
|
||||
|
||||
|
||||
def _repo_ok(repo: str) -> bool:
|
||||
r = REPOS.get(repo)
|
||||
return bool(r) and os.name == "posix" and (r["path"] / ".git").exists()
|
||||
|
||||
|
||||
def _git(repo: str, args: list[str], timeout: int = 20) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(["git", "-C", str(REPOS[repo]["path"]), *args],
|
||||
capture_output=True, text=True, timeout=timeout)
|
||||
|
||||
|
||||
def _status_key(repo: str, branch: str) -> str:
|
||||
return f"{REPOS[repo]['key']}{branch}"
|
||||
|
||||
|
||||
def _fetch_throttled(repo: str) -> None:
|
||||
now = time.time()
|
||||
if now - _fetch_cache.get(repo, 0.0) < _FETCH_EVERY:
|
||||
return
|
||||
_fetch_cache[repo] = now
|
||||
try:
|
||||
_git(repo, ["fetch", "-q", "--prune", "origin"], timeout=30)
|
||||
except Exception:
|
||||
log.warning("auftragsbuch: git fetch (%s) fehlgeschlagen", repo, exc_info=True)
|
||||
|
||||
|
||||
def _load_announce_branches() -> set:
|
||||
"""Liefert die Menge der bereits gemeldeten Branch-Namen (Idempotenz)."""
|
||||
try:
|
||||
data = json.loads(ANNOUNCE_BRANCHES_PATH.read_text(encoding="utf-8"))
|
||||
return set(data) if isinstance(data, list) else set()
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
||||
def _save_announce_branches(branches: set) -> None:
|
||||
"""Speichert die Menge gemelder Branch-Namen."""
|
||||
try:
|
||||
tmp = ANNOUNCE_BRANCHES_PATH.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(sorted(branches), ensure_ascii=False), encoding="utf-8")
|
||||
tmp.replace(ANNOUNCE_BRANCHES_PATH)
|
||||
except OSError:
|
||||
log.warning("auftragsbuch: Announce-Branches %s nicht schreibbar", ANNOUNCE_BRANCHES_PATH, exc_info=True)
|
||||
|
||||
|
||||
def _statuses() -> dict:
|
||||
try:
|
||||
return (json.loads(STATUS_PATH.read_text(encoding="utf-8")) or {}).get("branches", {})
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _set_status(key: str, state: str, detail: str) -> None:
|
||||
try:
|
||||
try:
|
||||
data = json.loads(STATUS_PATH.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
data = {}
|
||||
data.setdefault("branches", {})[key] = {"state": state, "detail": detail, "ts": time.time()}
|
||||
tmp = STATUS_PATH.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
tmp.replace(STATUS_PATH)
|
||||
except OSError:
|
||||
log.warning("auftragsbuch: Status %s nicht schreibbar", STATUS_PATH, exc_info=True)
|
||||
|
||||
|
||||
def _gutachten() -> dict:
|
||||
"""Alle Karten-Stempel ("<repo>:<branch>" → {empfehlung, satz, richter, commit_ts, ts})."""
|
||||
try:
|
||||
data = json.loads(GUTACHTEN_PATH.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _remote_branches(repo: str) -> list[str]:
|
||||
r = _git(repo, ["for-each-ref", "--format=%(refname:short)", "refs/remotes/origin"])
|
||||
out = []
|
||||
for line in (r.stdout or "").splitlines():
|
||||
name = line.strip().removeprefix("origin/")
|
||||
if name and name != "HEAD" and name.startswith(PREFIXES):
|
||||
out.append(name)
|
||||
return out
|
||||
|
||||
|
||||
def _valid_branch(branch: str) -> bool:
|
||||
return bool(_BRANCH_RX.match(branch)) and ".." not in branch and branch.startswith(PREFIXES)
|
||||
|
||||
|
||||
def _repo_items(repo: str, statuses: dict, gutachten: dict) -> list[dict]:
|
||||
items = []
|
||||
for branch in _remote_branches(repo):
|
||||
ref = f"origin/{branch}"
|
||||
status = statuses.get(_status_key(repo, branch))
|
||||
try:
|
||||
ahead = int((_git(repo, ["rev-list", "--count", f"origin/main..{ref}"]).stdout or "0").strip() or 0)
|
||||
if ahead == 0 and ((status or {}).get("state") not in ("laeuft", "rollback")):
|
||||
continue # bereits in main enthalten → keine offene Entscheidung mehr
|
||||
behind = int((_git(repo, ["rev-list", "--count", f"{ref}..origin/main"]).stdout or "0").strip() or 0)
|
||||
# Kaputt aufgesetzte Branches entlarven (Worker machte git init/Shallow statt zu
|
||||
# klonen): ohne gemeinsamen Vorfahren kann git NIE mergen — Annehmen wäre ein
|
||||
# garantierter Fehllauf („refusing to merge unrelated histories").
|
||||
verwaist = _git(repo, ["merge-base", "origin/main", ref]).returncode != 0
|
||||
show = _git(repo, ["show", "-s", "--format=%s%x1f%b%x1f%ct%x1f%an", ref])
|
||||
subject, body, cts, author = ((show.stdout or "").split("\x1f") + ["", "", "", ""])[:4]
|
||||
ts_val = int(cts) if cts.strip().isdigit() else None
|
||||
# Stempel nur zeigen, wenn er zum AKTUELLEN Commit gehört (nachgeschobener
|
||||
# Commit macht das alte Gutachten ungültig — der Nacht-Lauf stempelt neu).
|
||||
stempel = gutachten.get(f"{repo}:{branch}")
|
||||
if not (isinstance(stempel, dict) and stempel.get("commit_ts") == ts_val):
|
||||
stempel = None
|
||||
|
||||
head_sha = (_git(repo, ["rev-parse", ref]).stdout or "").strip()
|
||||
ci_status = _fetch_ci_status(repo, branch, head_sha) if head_sha else None
|
||||
|
||||
stat = (_git(repo, ["diff", "--shortstat", f"origin/main...{ref}"]).stdout or "").strip()
|
||||
files_raw = (_git(repo, ["diff", "--name-status", f"origin/main...{ref}"]).stdout or "").splitlines()
|
||||
files = []
|
||||
for line in files_raw[:60]:
|
||||
parts = line.split("\t")
|
||||
if len(parts) >= 2:
|
||||
files.append({"status": parts[0][:2], "path": parts[-1]})
|
||||
paths = [f["path"] for f in files]
|
||||
frontend_src = any(p.startswith("frontend/src") for p in paths)
|
||||
frontend_dist = any(p.startswith("frontend/dist") for p in paths)
|
||||
items.append({
|
||||
"repo": repo,
|
||||
"branch": branch,
|
||||
"kind": branch.split("/", 1)[0],
|
||||
"subject": subject.strip(),
|
||||
"body": body.strip()[:2000],
|
||||
"author": author.strip(),
|
||||
"ts": ts_val,
|
||||
"empfehlung": stempel,
|
||||
"ahead": ahead,
|
||||
"behind": behind,
|
||||
"verwaist": verwaist,
|
||||
# Diff gegen main ist leer → der Branch brächte nichts (Inhalt schon in main
|
||||
# oder Worker hat am Repo vorbei gearbeitet). Bei verwaist ist der Diff
|
||||
# technisch leer (kein merge-base) — dann zählt nur das verwaist-Flag.
|
||||
"leer": not verwaist and ahead > 0 and not files_raw,
|
||||
"shortstat": stat,
|
||||
"files": files,
|
||||
"files_truncated": len(files_raw) > 60,
|
||||
# Nur mc2: Frontend-Quelltext ohne gebautes Bundle — die Box deployt dist so, wie
|
||||
# es im Repo liegt. Lucy wird dagegen IMMER am PC gebaut (kein dist im Repo).
|
||||
"frontend_ohne_build": repo == "mc2" and frontend_src and not frontend_dist,
|
||||
"status": None if (status or {}).get("state") == "eingespielt" and ahead > 0 else status,
|
||||
"ci_status": ci_status,
|
||||
})
|
||||
except Exception:
|
||||
log.warning("auftragsbuch: Branch %s (%s) nicht lesbar", branch, repo, exc_info=True)
|
||||
return items
|
||||
|
||||
|
||||
def list_proposals() -> dict:
|
||||
"""Alle offenen Vorschläge: Branches aus beiden Repos (mit Commit-/Diff-Zusammenfassung +
|
||||
Status) und Skill-Kandidaten aus dem Vault. Eine Antwort für die ganze Auftragsbuch-Seite.
|
||||
|
||||
Nebenbei: Pinge neu auftauchende Vorschlags-Branches per Telegram und füge sie dem
|
||||
Briefkasten hinzu (Idempotenz: nur EINMAL pro Branch)."""
|
||||
if not _available():
|
||||
return {"available": False, "items": [], "skill_kandidaten": [], "open_count": 0}
|
||||
|
||||
statuses = _statuses()
|
||||
gutachten = _gutachten()
|
||||
items = []
|
||||
for repo in REPOS:
|
||||
if not _repo_ok(repo):
|
||||
continue # z. B. ~/lucy (noch) nicht geklont → Karten dieses Repos einfach weglassen
|
||||
_fetch_throttled(repo)
|
||||
items.extend(_repo_items(repo, statuses, gutachten))
|
||||
items.sort(key=lambda i: i.get("ts") or 0, reverse=True)
|
||||
|
||||
# --- Telegram-Ping für NEUE Vorschlags-Branches (Idempotenz) ---
|
||||
# Alle aktuellen Branch-Namen aus beiden Repos sammeln
|
||||
aktuelle_branches = set()
|
||||
for repo in REPOS:
|
||||
if _repo_ok(repo):
|
||||
aktuelle_branches.update(_remote_branches(repo))
|
||||
|
||||
# Bereits gemeldete Branches laden
|
||||
gemeldet = _load_announce_branches()
|
||||
|
||||
# Nur wirklich NEUE Branches pingen
|
||||
neue = aktuelle_branches - gemeldet
|
||||
for branch in neue:
|
||||
# NotifyTelegram best-effort, niemals crashen
|
||||
try:
|
||||
from services import announce
|
||||
subject = "[Auftragsbuch]"
|
||||
text = f"Neue Karte wartet auf deinen Klick: {branch}"
|
||||
announce.notify_telegram(subject, text)
|
||||
# Und ein Eintrag für den Briefkasten (damit Lucy es spricht)
|
||||
announce.add(text, subject, "auftragsbuch", "normal")
|
||||
except Exception:
|
||||
log.warning("auftragsbuch: Telegram-Ping für %s fehlgeschlagen", branch, exc_info=True)
|
||||
|
||||
# Als gemeldet speichern — aber NUR bei echter Änderung. Ein unbedingtes Schreiben
|
||||
# bumpte die mtime bei jedem Aufruf, der SSE-Sammler (events.py, Fingerabdruck =
|
||||
# Datei-mtime) meldete daraufhin „geändert", die UI holte neu, schrieb wieder …
|
||||
# → 3-s-Endlosschleife auf /api/auftragsbuch je offenem Client (gefunden 15.07. nachts).
|
||||
if aktuelle_branches != gemeldet:
|
||||
_save_announce_branches(aktuelle_branches)
|
||||
|
||||
kandidaten = list_skill_kandidaten()
|
||||
open_count = sum(1 for i in items
|
||||
if (i.get("status") or {}).get("state") not in ("eingespielt",)) + len(kandidaten)
|
||||
return {"available": True, "items": items, "skill_kandidaten": kandidaten, "open_count": open_count}
|
||||
|
||||
|
||||
def diff_of(branch: str, repo: str = "mc2") -> dict:
|
||||
if not _repo_ok(repo):
|
||||
return {"ok": False, "error": "Nur auf der Box verfügbar."}
|
||||
if not _valid_branch(branch) or branch not in _remote_branches(repo):
|
||||
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
|
||||
r = _git(repo, ["diff", f"origin/main...origin/{branch}"], timeout=30)
|
||||
text = r.stdout or ""
|
||||
truncated = len(text) > 200_000
|
||||
return {"ok": True, "diff": text[:200_000], "truncated": truncated}
|
||||
|
||||
|
||||
def accept(branch: str, repo: str = "mc2") -> dict:
|
||||
"""Annehmen: detached Runner starten. mc2: Merge→Push→Deploy→Health→ggf. Rollback.
|
||||
lucy: Merge→Push→PC-Build+Neustart→ggf. Revert (der Runner spricht den PC-Executor an)."""
|
||||
if not _repo_ok(repo):
|
||||
return {"ok": False, "error": "Annehmen geht nur auf der Box."}
|
||||
if not _valid_branch(branch) or branch not in _remote_branches(repo):
|
||||
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
|
||||
key = _status_key(repo, branch)
|
||||
state = (_statuses().get(key) or {}).get("state")
|
||||
if state in ("laeuft", "rollback"):
|
||||
return {"ok": False, "error": "Für diesen Vorschlag läuft bereits ein Annahme-Lauf."}
|
||||
# Kaputt aufgesetzter Branch (kein gemeinsamer Vorfahre) → Merge kann NIE gelingen;
|
||||
# gar nicht erst einen Runner starten (die UI blendet den Knopf aus, die API hält dicht).
|
||||
if _git(repo, ["merge-base", "origin/main", f"origin/{branch}"]).returncode != 0:
|
||||
return {"ok": False, "error": "Dieser Branch hat keinen gemeinsamen Ursprung mit main "
|
||||
"(kaputt aufgesetzt, z. B. git init statt Klonen) — Annehmen ist technisch unmöglich. "
|
||||
"Bitte ablehnen (gern mit Grund) und die Idee neu in die Queue geben."}
|
||||
|
||||
# Runner als /tmp-Kopie starten: deploy.sh resettet das Repo hart — das Original-Skript
|
||||
# würde einem laufenden bash unter den Füßen getauscht (bekannte Selbst-Reset-Falle).
|
||||
src = REPO / "deploy" / REPOS[repo]["runner"]
|
||||
if not src.is_file():
|
||||
return {"ok": False, "error": f"Runner fehlt im Checkout: {src.name}"}
|
||||
runner = Path(f"/tmp/mc2-auftrag-runner-{int(time.time())}.sh")
|
||||
try:
|
||||
shutil.copyfile(src, runner)
|
||||
except OSError as exc:
|
||||
return {"ok": False, "error": f"Runner nicht kopierbar: {exc}"}
|
||||
|
||||
slug = re.sub(r"[^a-z0-9-]+", "-", f"{repo}-{branch}".lower())[:40].strip("-")
|
||||
unit = f"mc2-auftrag-{slug}-{int(time.time())}"
|
||||
r = subprocess.run(
|
||||
["systemd-run", "--user", "--collect", f"--unit={unit}",
|
||||
"/bin/bash", str(runner), branch],
|
||||
capture_output=True, text=True, timeout=20)
|
||||
if r.returncode != 0:
|
||||
return {"ok": False, "error": f"Start fehlgeschlagen: {(r.stderr or r.stdout).strip()[:300]}"}
|
||||
_set_status(key, "laeuft", "Annahme-Lauf gestartet")
|
||||
return {"ok": True, "unit": unit}
|
||||
|
||||
|
||||
def reject(branch: str, repo: str = "mc2", grund: str = "") -> dict:
|
||||
"""Ablehnen: Remote-Branch löschen (die Arbeit bleibt in der Gitea-Historie referenzierbar,
|
||||
aber die Entscheidung ist gefallen). Der optionale GRUND ist das Lern-Gedächtnis des
|
||||
Kreislaufs: er landet in ABLEHNUNGEN_PATH (jsonl), und Idle-Radar/Analysten bekommen
|
||||
ihn als „NIE wieder vorschlagen"-Material vorgelegt. Meldung in den Briefkasten."""
|
||||
if not _repo_ok(repo):
|
||||
return {"ok": False, "error": "Ablehnen geht nur auf der Box."}
|
||||
if not _valid_branch(branch) or branch not in _remote_branches(repo):
|
||||
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
|
||||
grund = (grund or "").strip()[:400]
|
||||
# Betreff VOR dem Löschen sichern — danach ist der Ref weg.
|
||||
subject = (_git(repo, ["show", "-s", "--format=%s", f"origin/{branch}"]).stdout or "").strip()[:200]
|
||||
r = _git(repo, ["push", "origin", "--delete", branch], timeout=30)
|
||||
if r.returncode != 0:
|
||||
return {"ok": False, "error": f"Löschen fehlgeschlagen: {(r.stderr or '').strip()[:300]}"}
|
||||
_git(repo, ["fetch", "-q", "--prune", "origin"], timeout=30)
|
||||
detail = "Vom Commander abgelehnt — Branch gelöscht"
|
||||
if grund:
|
||||
detail += f" (Grund: {grund})"
|
||||
_set_status(_status_key(repo, branch), "abgelehnt", detail)
|
||||
try:
|
||||
with ABLEHNUNGEN_PATH.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps({"ts": int(time.time()), "repo": repo, "branch": branch,
|
||||
"subject": subject, "grund": grund}, ensure_ascii=False) + "\n")
|
||||
except OSError:
|
||||
log.warning("auftragsbuch: Ablehn-Gedächtnis %s nicht schreibbar", ABLEHNUNGEN_PATH, exc_info=True)
|
||||
try:
|
||||
from services import announce
|
||||
wo = "im Lucy-Repo " if repo == "lucy" else ""
|
||||
warum = f" Grund: {grund}" if grund else ""
|
||||
announce.add(f"Vorschlag '{branch}' {wo}wurde abgelehnt und der Branch gelöscht.{warum}",
|
||||
"[Auftragsbuch]", "auftragsbuch", "silent")
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Skill-Kandidaten (Wissens-Vault) ─────────────────────────────────────────
|
||||
|
||||
def _kandidaten_dir() -> Path:
|
||||
return VAULT / "skill-kandidaten"
|
||||
|
||||
|
||||
def list_skill_kandidaten() -> list[dict]:
|
||||
d = _kandidaten_dir()
|
||||
if os.name != "posix" or not d.is_dir():
|
||||
return []
|
||||
out = []
|
||||
for p in sorted(d.glob("*.md"), key=lambda x: x.stat().st_mtime, reverse=True):
|
||||
try:
|
||||
text = p.read_text(encoding="utf-8", errors="replace")
|
||||
first = next((ln.strip().lstrip("# ") for ln in text.splitlines() if ln.strip()), p.stem)
|
||||
out.append({"file": p.name, "title": first[:160],
|
||||
"preview": text[:3000], "mtime": p.stat().st_mtime})
|
||||
except OSError:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def _vault_git(args: list[str]) -> None:
|
||||
try:
|
||||
subprocess.run(["git", "-C", str(VAULT), *args], capture_output=True, text=True, timeout=15)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _kandidat_path(fname: str) -> Path | None:
|
||||
if not _KANDIDAT_RX.match(fname):
|
||||
return None
|
||||
p = (_kandidaten_dir() / fname).resolve()
|
||||
if not p.is_relative_to(_kandidaten_dir().resolve()) or not p.is_file():
|
||||
return None
|
||||
return p
|
||||
|
||||
|
||||
def skill_accept(fname: str) -> dict:
|
||||
"""Skill-Kandidat beauftragen: Inhalt als Werkstatt-Auftrag an die bewährte
|
||||
`hermes -z`-CLI-Lane (detached — der Lauf dauert Minuten und braucht das Backend nicht).
|
||||
Ergebnis ist wieder propose-only: ein neuer Branch, der hier als Karte auftaucht."""
|
||||
if not _available():
|
||||
return {"ok": False, "error": "Beauftragen geht nur auf der Box."}
|
||||
p = _kandidat_path(fname)
|
||||
if not p:
|
||||
return {"ok": False, "error": f"Kandidat nicht gefunden: {fname}"}
|
||||
|
||||
beauftragt = _kandidaten_dir() / "beauftragt"
|
||||
beauftragt.mkdir(parents=True, exist_ok=True)
|
||||
target = beauftragt / p.name
|
||||
try:
|
||||
content = p.read_text(encoding="utf-8", errors="replace")
|
||||
p.rename(target)
|
||||
except OSError as exc:
|
||||
return {"ok": False, "error": f"Kandidat nicht verschiebbar: {exc}"}
|
||||
_vault_git(["add", "-A"])
|
||||
_vault_git(["commit", "-q", "-m", f"Skill-Kandidat beauftragt: {p.name}"])
|
||||
|
||||
order = ("Nutze den orchestrator-Skill (propose-only, Zwei-Kritiker-Gate). "
|
||||
"Auftrag aus dem Wissens-Vault (vom Commander im Auftragsbuch freigegeben):\n\n"
|
||||
+ content)
|
||||
order_file = Path(f"/tmp/mc2-skill-auftrag-{int(time.time())}.txt")
|
||||
try:
|
||||
order_file.write_text(order, encoding="utf-8")
|
||||
except OSError as exc:
|
||||
return {"ok": False, "error": f"Auftrag nicht schreibbar: {exc}"}
|
||||
unit = f"mc2-skill-auftrag-{int(time.time())}"
|
||||
r = subprocess.run(
|
||||
["systemd-run", "--user", "--collect", f"--unit={unit}",
|
||||
"/bin/bash", "-lc", f'hermes -z "$(cat {order_file})"'],
|
||||
capture_output=True, text=True, timeout=20)
|
||||
if r.returncode != 0:
|
||||
return {"ok": False, "error": f"Start fehlgeschlagen: {(r.stderr or r.stdout).strip()[:300]}"}
|
||||
try:
|
||||
from services import announce
|
||||
announce.add(f"Skill-Kandidat '{fname}' wurde beauftragt — die Werkstatt arbeitet, "
|
||||
"das Ergebnis erscheint als neuer Vorschlag im Auftragsbuch.",
|
||||
"[Auftragsbuch]", "auftragsbuch", "silent")
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "unit": unit}
|
||||
|
||||
|
||||
def skill_reject(fname: str) -> dict:
|
||||
if not _available():
|
||||
return {"ok": False, "error": "Verwerfen geht nur auf der Box."}
|
||||
p = _kandidat_path(fname)
|
||||
if not p:
|
||||
return {"ok": False, "error": f"Kandidat nicht gefunden: {fname}"}
|
||||
verworfen = _kandidaten_dir() / "verworfen"
|
||||
verworfen.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
p.rename(verworfen / p.name)
|
||||
except OSError as exc:
|
||||
return {"ok": False, "error": f"Kandidat nicht verschiebbar: {exc}"}
|
||||
_vault_git(["add", "-A"])
|
||||
_vault_git(["commit", "-q", "-m", f"Skill-Kandidat verworfen: {p.name}"])
|
||||
return {"ok": True}
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Voll-Zustands-Backup (mem0 + Hermes-Configs/Secrets + llama-swap config).
|
||||
Voll-Zustands-Backup (Hermes-Configs/Secrets + llama-swap config).
|
||||
Delegiert an deploy/backup.sh (eine Quelle der Wahrheit, identisch zum systemd-Timer);
|
||||
Restore läuft bewusst nur per CLI (deploy/restore.sh) — siehe docs/BACKUP.md.
|
||||
Restore läuft bewusst nur per CLI (deploy/restore.sh) — siehe docs/BETRIEB.md (Sicherung).
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
@@ -27,11 +27,6 @@ def _latest() -> Path | None:
|
||||
return snaps[0] if snaps else None
|
||||
|
||||
|
||||
def snapshot_components(tarball: Path) -> list[str]:
|
||||
"""Öffentliche Sicht auf die Snapshot-Komponenten (Zeitmaschine-Detail in der UI)."""
|
||||
return _components(tarball)
|
||||
|
||||
|
||||
def _components(tarball: Path) -> list[str]:
|
||||
"""Top-Level-Einträge im Tarball (zur Anzeige im UI)."""
|
||||
try:
|
||||
@@ -70,9 +65,11 @@ def list_backups() -> list[dict]:
|
||||
return []
|
||||
out = []
|
||||
for p in sorted(BACKUP_DIR.glob("mc2-state-*.tar.gz"), reverse=True):
|
||||
st = p.stat()
|
||||
out.append({
|
||||
"snapshot": _ts(p),
|
||||
"file": p.name,
|
||||
"size_mb": round(p.stat().st_size / 1_000_000, 2),
|
||||
"size_mb": round(st.st_size / 1_000_000, 2),
|
||||
"mtime": st.st_mtime, # für die Sicherungs-Lampe im Cockpit (Alter der letzten)
|
||||
})
|
||||
return out
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Updates der KI-Box: Zwischenspeicher für den Start-Bildschirm und der Box-Adapter (Phase 2).
|
||||
|
||||
maintenance.updates() fragt GitHub, apt und git und braucht nach einem Neustart gern zehn Sekunden
|
||||
und mehr. Der Start-Bildschirm wartet darauf nie: Er bekommt den letzten Stand (oder zunächst
|
||||
nichts), frisch geholt wird im Hintergrund. Seit 24.09.2026 liegt das hier statt im Router, weil
|
||||
auch der Box-Adapter (ki_box_ziel) davon liest.
|
||||
|
||||
ki_box_ziel() beschreibt die KI-Box im gemeinsamen Modell aus kern/ziele.py: vier Bausteine mit
|
||||
Stand, Versionen und dem Knopf, der das jeweilige Update anstößt.
|
||||
"""
|
||||
|
||||
import platform
|
||||
import threading
|
||||
import time
|
||||
|
||||
from kern.ziele import Aktion, BausteinStand, ZielStand
|
||||
|
||||
from services import maintenance, system, update_verlauf
|
||||
|
||||
_updates_lock = threading.Lock()
|
||||
_updates_cache: dict = {"ts": 0.0, "daten": None, "laeuft": False, "stand": -1}
|
||||
UPDATES_CACHE_S = 600
|
||||
|
||||
NAMEN = {"os": "Betriebssystem", "engine": "Motor (llama.cpp)", "swap": "llama-swap", "hermes": "Hermes"}
|
||||
NAMEN_KURZ = {"os": "Betriebssystem", "engine": "Motor", "swap": "llama-swap", "hermes": "Hermes"}
|
||||
|
||||
AKTIONEN = {
|
||||
"os": Aktion("POST", "/api/maintenance/os-update",
|
||||
"Betriebssystem-Updates jetzt einspielen? Danach wird der Stack geprüft."),
|
||||
"engine": Aktion("POST", "/api/maintenance/engine-update",
|
||||
"Den Motor jetzt aktualisieren? Die Modelle sind dabei kurz weg; scheitert die Prüfung, "
|
||||
"geht es zurück."),
|
||||
"swap": Aktion("POST", "/api/maintenance/swap-update",
|
||||
"llama-swap jetzt aktualisieren? Laufende Antworten brechen ab; scheitert die Prüfung, "
|
||||
"geht es zurück."),
|
||||
"hermes": Aktion("POST", "/api/maintenance/hermes-update",
|
||||
"Hermes jetzt aktualisieren? Lucy ist dabei ein paar Minuten weg."),
|
||||
}
|
||||
|
||||
|
||||
def _holen() -> None:
|
||||
stand = maintenance.update_stand
|
||||
try:
|
||||
daten = maintenance.updates()
|
||||
except Exception:
|
||||
daten = None
|
||||
with _updates_lock:
|
||||
if daten is not None or _updates_cache["daten"] is None:
|
||||
_updates_cache["daten"] = daten or {}
|
||||
_updates_cache["ts"] = time.time()
|
||||
_updates_cache["stand"] = stand
|
||||
_updates_cache["laeuft"] = False
|
||||
|
||||
|
||||
def gecacht() -> dict:
|
||||
"""Letzter Stand der Update-Prüfung; ist er veraltet, wird im Hintergrund neu geholt."""
|
||||
with _updates_lock:
|
||||
# Nach jedem abgeschlossenen Update zählt maintenance.update_stand hoch — dann sofort neu holen,
|
||||
# statt bis zu 10 Minuten „Aktualisieren" für schon eingespielte Updates zu zeigen (24.09.2026).
|
||||
veraltet = (_updates_cache["daten"] is None or time.time() - _updates_cache["ts"] > UPDATES_CACHE_S
|
||||
or _updates_cache["stand"] != maintenance.update_stand)
|
||||
if veraltet and not _updates_cache["laeuft"]:
|
||||
_updates_cache["laeuft"] = True
|
||||
threading.Thread(target=_holen, name="updates-holen", daemon=True).start()
|
||||
return _updates_cache["daten"] or {}
|
||||
|
||||
|
||||
def gelesen() -> bool:
|
||||
"""Liegt schon ein Ergebnis vor (nach einem Neustart dauert die erste Prüfung)?"""
|
||||
return _updates_cache["daten"] is not None
|
||||
|
||||
|
||||
def bausteine(u: dict) -> list[dict]:
|
||||
"""Welche Bausteine haben Neues? In der Reihenfolge, in der auch das Update läuft."""
|
||||
liste = []
|
||||
if u.get("os"):
|
||||
liste.append({"id": "os", "name": "Betriebssystem", "neu": f"{u['os']} Pakete"})
|
||||
if u.get("engine"):
|
||||
liste.append({"id": "engine", "name": "Motor", "neu": "neuer Build"})
|
||||
if u.get("swap"):
|
||||
liste.append({"id": "swap", "name": "llama-swap", "neu": "neue Version"})
|
||||
for c in u.get("components") or []:
|
||||
if c.get("key") == "hermes_agent" and c.get("update"):
|
||||
liste.append({"id": "hermes", "name": "Hermes", "neu": f"{c.get('behind', '?')} Änderungen"})
|
||||
return liste
|
||||
|
||||
|
||||
def unklar(u: dict) -> list[dict]:
|
||||
"""Bausteine, deren Update-Prüfung scheiterte — ehrlich „unbekannt" statt „aktuell"."""
|
||||
return [{"id": k, "name": NAMEN_KURZ.get(k, k), "grund": grund}
|
||||
for k, grund in (u.get("pruef_fehler") or {}).items() if grund]
|
||||
|
||||
|
||||
# --- Box-Adapter ------------------------------------------------------------------------
|
||||
|
||||
def _ubuntu() -> str | None:
|
||||
try:
|
||||
info = platform.freedesktop_os_release()
|
||||
except OSError:
|
||||
return None
|
||||
return " ".join(x for x in (info.get("NAME"), info.get("VERSION_ID")) if x) or None
|
||||
|
||||
|
||||
def _versionen(u: dict) -> dict[str, tuple[str | None, str | None]]:
|
||||
"""(installiert, verfügbar) je Baustein. „Verfügbar" nur, wenn es Neues gibt — die Abfrage
|
||||
dafür ist bei GitHub zwischengespeichert (maintenance._github_json, 15 Minuten)."""
|
||||
engine = maintenance._installed_engine_build()
|
||||
swap = maintenance._installed_swap_version()
|
||||
werte: dict[str, tuple[str | None, str | None]] = {
|
||||
"os": (_ubuntu(), f"{u['os']} Pakete" if u.get("os") else None),
|
||||
"engine": (f"b{engine}" if engine else None, None),
|
||||
"swap": (f"v{swap}" if swap else None, None),
|
||||
}
|
||||
try:
|
||||
if u.get("engine") and (rel := maintenance._latest_engine_asset_release()):
|
||||
werte["engine"] = (werte["engine"][0], str(rel.get("tag_name") or "") or None)
|
||||
if u.get("swap"):
|
||||
rel = maintenance._github_json(f"repos/{maintenance.SWAP_REPO}/releases/latest")
|
||||
tag = str(rel.get("tag_name") or "") if isinstance(rel, dict) else ""
|
||||
werte["swap"] = (werte["swap"][0], tag or None)
|
||||
except Exception:
|
||||
pass
|
||||
hermes = next((c for c in u.get("components") or [] if c.get("key") == "hermes_agent"), {})
|
||||
git = system.find_hermes_agent_git() or {}
|
||||
version = maintenance._hermes_version(git["path"]) if git.get("path") else None
|
||||
werte["hermes"] = (version or hermes.get("current"),
|
||||
f"+{hermes.get('behind')} Änderungen" if hermes.get("update") else None)
|
||||
return werte
|
||||
|
||||
|
||||
def ki_box_ziel() -> ZielStand:
|
||||
"""Die KI-Box als Ziel mit ihren vier Bausteinen."""
|
||||
u = gecacht()
|
||||
ziel = ZielStand(id="ki-box", name="KI-Box", art="ki-box", instanz="box", erreichbar=True,
|
||||
geprueft=_updates_cache["ts"] or None,
|
||||
rueckweg="Sicherung vor jedem Sonntags-Lauf; Motor, llama-swap und Hermes rollen "
|
||||
"bei rotem Check selbst zurück, das Betriebssystem nicht.")
|
||||
if not gelesen():
|
||||
ziel.bausteine = [BausteinStand(id=k, name=n, zustand="wird-geprueft") for k, n in NAMEN.items()]
|
||||
return ziel
|
||||
offen = {b["id"]: b["neu"] for b in bausteine(u)}
|
||||
fehler = {b["id"]: b["grund"] for b in unklar(u)}
|
||||
fest = {p["baustein"]: p for p in update_verlauf.festgehalten()}
|
||||
versionen = _versionen(u)
|
||||
for k, name in NAMEN.items():
|
||||
installiert, verfuegbar = versionen.get(k, (None, None))
|
||||
stand = BausteinStand(id=k, name=name, zustand="aktuell", installiert=installiert, verfuegbar=verfuegbar)
|
||||
if k in fest:
|
||||
p = fest[k]
|
||||
stand.zustand, stand.grund = "festgehalten", f"Seit {p['seit']} auf {p['version']}: {p['grund']}"
|
||||
elif k in fehler:
|
||||
stand.zustand, stand.grund = "unbekannt", fehler[k]
|
||||
elif k in offen:
|
||||
stand.zustand, stand.kurz, stand.aktion = "neu", offen[k], AKTIONEN[k]
|
||||
ziel.bausteine.append(stand)
|
||||
return ziel
|
||||
@@ -169,37 +169,3 @@ def setup_aware_ctx(params_b: float, quant: str, role: str | None = None) -> dic
|
||||
}
|
||||
|
||||
|
||||
def _snap_ctx(raw_ctx: float, cap: int | None = None) -> int:
|
||||
"""Größter 'schöner' Kontext ≤ raw_ctx (und ≤ Trainings-Kontext des Modells, falls bekannt)."""
|
||||
from services.fit import _NICE_CTX
|
||||
if cap:
|
||||
raw_ctx = min(raw_ctx, cap)
|
||||
best = _NICE_CTX[0]
|
||||
for c in _NICE_CTX:
|
||||
if c <= raw_ctx:
|
||||
best = c
|
||||
return best
|
||||
|
||||
|
||||
def setup_aware_ctx_for_model(model: dict) -> dict:
|
||||
"""Setup-bewusster Optimal-ctx für ein INSTALLIERTES Modell. Für den 'Auto'-Button an der
|
||||
Modellkarte. Nutzt die ECHTE KV-Größe des GGUF (gleiche Zahlensprache wie footprint_gb) —
|
||||
Fallback auf die params-Heuristik nur, wenn das GGUF nicht lesbar ist."""
|
||||
from services import gguf_meta
|
||||
quant = model.get("quant") or "Q4_K_M"
|
||||
path = model.get("gguf_path")
|
||||
meta = gguf_meta.arch_meta(path) if path else None
|
||||
if not meta:
|
||||
return setup_aware_ctx(params_of_model(model), quant, role=model.get("role"))
|
||||
|
||||
gtt = gtt_budget_gb()
|
||||
r = reserved_gb(model.get("role"))
|
||||
budget = max(gtt - r["reserved_gb"] - HEADROOM_GB, 0.0)
|
||||
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.55)
|
||||
size_gb = (model.get("size_bytes") or 0) / (1024 ** 3)
|
||||
weights = max(params_of_model(model) * bpp, size_gb)
|
||||
ck, cv = _cache_types(model.get("cmd") or "")
|
||||
per_tok = gguf_meta.kv_gb_per_token(meta, ck, cv)
|
||||
ctx = _snap_ctx((budget - weights) / per_tok, cap=meta.get("n_ctx_train")) if per_tok > 0 else 2048
|
||||
return {"ctx": ctx, "gtt_gb": gtt, "reserved_gb": round(r["reserved_gb"], 1),
|
||||
"budget_gb": round(budget, 1), "mode": r["mode"]}
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
"""
|
||||
Connect: erzeugt saubere, getestete Konfig-Snippets für IDEs/Agenten auf dem
|
||||
LOKALEN PC (separate Maschine im LAN). Alle zeigen auf den **Gateway** der Box
|
||||
(reale Modelle coder/heavy/vision, Cockpit-Port :9001/v1) + den **Shared-Memory-MCP** (MC :9001).
|
||||
|
||||
Wichtig: Host ist die LAN-IP der Box (NICHT eine Proxy-Domain) — das war in v1
|
||||
die häufigste Fehlerquelle. Der Aufrufer übergibt den Host explizit.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
from config import HERMES_BUILTIN_UI_UPSTREAM, LLAMA_SWAP_URL, PORT, V1_UPSTREAM
|
||||
|
||||
DEFAULT_HOST = "192.168.178.151"
|
||||
|
||||
# IDEs/Agenten bekommen die ECHTEN Box-Modelle direkt (kein Lane-Routing): Coder (bauen), Planer (denken),
|
||||
# Augen (Bilder, Vision). Der User wechselt im Modellwähler — spiegelt das reale Hermes-Desktop-Setup 1:1.
|
||||
PRIMARY_MODEL = "coder"
|
||||
|
||||
|
||||
def _caps(tools: bool = True, images: bool = False) -> dict:
|
||||
"""Volle 7-Feld-Capabilities — braucht sie für die Modellwahl."""
|
||||
return {"tools": tools, "images": images, "parallel_tool_calls": False,
|
||||
"prompt_cache_key": False, "chat_completions": True,
|
||||
"interleaved_reasoning": False, "max_tokens_parameter": False}
|
||||
|
||||
|
||||
# Reale Box-Modelle für die IDE-Welt (getrennt von Lucy): bauen · denken · sehen.
|
||||
IDE_MODELS = [
|
||||
{"name": "coder", "display_name": "Box / Coder (bauen)", "max_tokens": 131072, "capabilities": _caps(True, False)},
|
||||
{"name": "heavy", "display_name": "Box / Planer (denken)", "max_tokens": 32768, "capabilities": _caps(True, False)},
|
||||
{"name": "vision", "display_name": "Box / Augen (Bilder)", "max_tokens": 32768, "capabilities": _caps(False, True)},
|
||||
]
|
||||
|
||||
|
||||
def _gw(host: str) -> str:
|
||||
# Client-Endpunkt bleibt :9001/v1 (MC2-Port) — seit UMBAU v3 P1 reicht MC2 dort
|
||||
# nur noch roh an den eigenständigen mc2-gateway (:9010) durch.
|
||||
return f"http://{host}:{PORT}/v1"
|
||||
|
||||
|
||||
def build_snippets(host: str = DEFAULT_HOST,
|
||||
mcp_script_path: str = r"F:\\Coding Stuff\\mission-control-2\\mcp\\mcp_memory.py",
|
||||
mcp_python: str = "python") -> dict:
|
||||
gw = _gw(host)
|
||||
mc_url = f"http://{host}:{PORT}"
|
||||
|
||||
# Kilo Code = aktiver Nachfolger der Roo/Cline-Linie (Roo im April 2026 eingestellt).
|
||||
# Gleiche Provider-Settings-Struktur; Multi-Agent kommt aus dem Tool (Orchestrator-Modus).
|
||||
kilo = json.dumps({
|
||||
"apiProvider": "openai",
|
||||
"openAiBaseUrl": gw,
|
||||
"openAiApiKey": "local",
|
||||
"openAiModelId": PRIMARY_MODEL,
|
||||
}, indent=2)
|
||||
|
||||
# Hermes Desktop = Remote-IDE, die auf dem Gateway läuft.
|
||||
# Anleitung: Browser aufweisen → Token aus Datei laden → loslegen.
|
||||
hermes_desktop = (
|
||||
f"# Hermes Desktop — Remote-IDE auf der Box\n"\
|
||||
f"#\n"\
|
||||
f"# 1. Browser öffnen: http://{host}:9001/hermes-ui\n"\
|
||||
f"# (MC2-Proxy auf dem Gateway)\n"\
|
||||
f"#\n"\
|
||||
f"# 2. Session-Token: auf der Box liegen in\n"\
|
||||
f"# ~/.hermes/desktop-gateway-token\n"\
|
||||
f"# (den Dateiname kopieren, den TOKEN-WERT NICHT hardcoden!)\n"\
|
||||
f"#\n"\
|
||||
f"# 3. Token im Browser-Login einfügen — fertig.\n"\
|
||||
f"#\n"\
|
||||
f"# Modelle im Hermes Desktop: {PRIMARY_MODEL} (bauen), heavy (denken), vision (Bilder)."
|
||||
)
|
||||
|
||||
# Claude Code spricht das Anthropic-Format; der Gateway ist OpenAI-kompatibel und
|
||||
# bietet KEIN /v1/messages (verifiziert). Daher braucht es einen kleinen Übersetzer
|
||||
# (Anthropic ⇄ OpenAI) als Aufsatz. Die env-Vars sind Claude Codes echte Schnittstelle.
|
||||
claude_code = (
|
||||
f"# Claude Code spricht das Anthropic-Format — der Gateway ist OpenAI-kompatibel ({gw})\n"
|
||||
f"# und hat kein /v1/messages. Dazwischen muss ein Übersetzer (Anthropic ⇄ OpenAI) laufen:\n"
|
||||
f"# • claude-code-router (leichtgewichtig, npm)\n"
|
||||
f"# • oder LiteLLM mit /v1/messages-Bridge\n"
|
||||
f"# Den Übersetzer auf den Gateway zeigen lassen: baseURL={gw}, model=coder, apiKey=local.\n"
|
||||
f"# Dann Claude Code auf den lokalen Übersetzer richten (Beispiel-Port 3456):\n"
|
||||
f"\n"
|
||||
f'export ANTHROPIC_BASE_URL="http://localhost:3456"\n'
|
||||
f'export ANTHROPIC_AUTH_TOKEN="local"\n'
|
||||
f'export ANTHROPIC_MODEL="coder"'
|
||||
)
|
||||
|
||||
memory_mcp = json.dumps({
|
||||
"mcpServers": {
|
||||
"mission-control-memory": {
|
||||
"command": mcp_python,
|
||||
"args": [mcp_script_path],
|
||||
"env": {"MC_URL": mc_url},
|
||||
}
|
||||
}
|
||||
}, indent=2)
|
||||
|
||||
return {
|
||||
"host": host,
|
||||
"gateway_url": gw,
|
||||
"mc_url": mc_url,
|
||||
# Leitung 1 — das MODELL. Bewusst NUR 3 Tools (Verdikt 09.07.2026): Hermes Desktop
|
||||
# (Remote-IDE im Browser), Kilo Code (VS-Code-Fallback mit Orchestrator-Modus),
|
||||
# Claude Code (starke Sessions).
|
||||
# Cursor/Continue/Roo/OpenCode entfernt — Roo eingestellt, Rest cloud-first, Terminal
|
||||
# oder von Kilo abgedeckt. Das Evolution-Radar (AUTONOMIE_PLAN E4) überwacht die Kategorie.
|
||||
"tools": {
|
||||
"hermes_desktop": {"label": "Hermes Desktop (empfohlen)", "lang": "bash", "snippet": hermes_desktop,
|
||||
"note": "Remote-IDE im Browser — Gateway-URL + Token aus Datei laden. Drei Modelle: "
|
||||
"Coder (bauen) · Planer (denken) · Augen (Bilder) — oben im Wähler umschalten."},
|
||||
"kilo": {"label": "Kilo Code", "lang": "json", "snippet": kilo,
|
||||
"note": "VS Code/JetBrains (schwergewichtiger). OpenAI-Provider → Gateway. "
|
||||
"API-Profile je Modus: Code→coder · Architect/Orchestrator→heavy · "
|
||||
"Ask/Debug→Lane 'chat' (virtuelles Modell, wählt selbst fast oder heavy)."},
|
||||
"claude_code": {"label": "Claude Code", "lang": "bash", "snippet": claude_code,
|
||||
"note": "Für die starken Sessions. Lokal-Betrieb bräuchte einen Anthropic⇄OpenAI-Übersetzer vor dem Gateway; Gedächtnis-Anbindung via Memory-MCP unten."},
|
||||
},
|
||||
# Leitung 2 — das GEDÄCHTNIS. Separater MCP-Server, gilt zusätzlich zu jedem Tool oben.
|
||||
"memory": {"label": "Shared Memory (MCP)", "lang": "json", "snippet": memory_mcp,
|
||||
"note": "Eigene Leitung: MCP-Block für jedes MCP-fähige Tool. mcp_memory.py muss lokal liegen."},
|
||||
}
|
||||
|
||||
|
||||
def check_health() -> dict:
|
||||
"""Live-Erreichbarkeit der drei Leitungen, aus Sicht der Box:
|
||||
Leitung 1 = der ECHTE Modell-Pfad, den Clients nutzen (mc2-gateway bei V1_UPSTREAM,
|
||||
sonst llama-swap direkt), Leitung 2 = Natives Hermes-Gedächtnis (hermes-gateway),
|
||||
Leitung 3 = Desktop-Gateway (Hermes-Builtin-UI)."""
|
||||
gateway = {"ok": False, "detail": "nicht erreichbar"}
|
||||
try:
|
||||
with httpx.Client(timeout=3.0) as c:
|
||||
r = c.get(f"{V1_UPSTREAM or LLAMA_SWAP_URL}/v1/models")
|
||||
if r.status_code == 200:
|
||||
n = len(r.json().get("data", []))
|
||||
gateway = {"ok": True, "detail": f"{n} Modelle verfügbar" if n else "bereit"}
|
||||
else:
|
||||
gateway = {"ok": False, "detail": f"HTTP {r.status_code}"}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
memory = {"ok": False, "detail": "nicht erreichbar"}
|
||||
try:
|
||||
with httpx.Client(timeout=3.0) as c:
|
||||
# Hermes Gateway stellt das native Gedächtnis & Agent-Loop bereit
|
||||
r = c.get("http://127.0.0.1:8642/health")
|
||||
if r.status_code in (200, 404, 401):
|
||||
memory = {"ok": True, "detail": "Hermes-Nativ bereit"}
|
||||
else:
|
||||
memory = {"ok": False, "detail": f"HTTP {r.status_code}"}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
desktop_gateway = {"ok": False, "detail": "nicht erreichbar"}
|
||||
try:
|
||||
with httpx.Client(timeout=3.0) as c:
|
||||
r = c.get(f"{HERMES_BUILTIN_UI_UPSTREAM}/api/status")
|
||||
desktop_gateway = ({"ok": True, "detail": "bereit"} if r.status_code == 200
|
||||
else {"ok": False, "detail": f"HTTP {r.status_code}"})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"gateway": gateway, "memory": memory, "desktop_gateway": desktop_gateway}
|
||||
@@ -0,0 +1,286 @@
|
||||
"""
|
||||
Die Dienste der KI-Box für die Seite „Dienste und Protokolle“ (seit 25.09.2026 mit Kennzahlen).
|
||||
|
||||
Je Dienst steht hier, ob er antwortet (HTTP, wie bisher), in welchem Zustand systemd ihn sieht und was er gerade
|
||||
braucht:
|
||||
|
||||
speicher_bytes MemoryCurrent — was systemd der Unit zurechnet (alle Prozesse, samt Datei-Zwischenspeicher)
|
||||
grafik_bytes nur die Engine: belegter Grafikspeicher (GTT) der Box — dort liegen die geladenen Modelle, und
|
||||
den rechnet systemd keinem Dienst zu (25.09.: Engine 4,3 GB laut systemd, Modelle 27,6 GB GTT)
|
||||
cpu_prozent Anteil an allen Kernen der Box aus CPUUsageNSec zwischen zwei Abfragen (100 % = alle Kerne voll)
|
||||
laeuft_seit_s seit dem letzten Start (ActiveEnterTimestampMonotonic)
|
||||
aus_seit_s seit wann ein gestoppter oder gescheiterter Dienst aus ist (InactiveEnterTimestampMonotonic)
|
||||
neustarts NRestarts — wie oft systemd ihn nach einem Absturz von selbst neu gestartet hat
|
||||
|
||||
Die Zahlen kommen mit EINEM `systemctl show` je Bereich (User- und System-Dienste). Auf Windows (Entwicklung) oder
|
||||
ohne systemd fehlen sie einfach (None); die Liste steht trotzdem, der Zustand dann allein nach „antwortet“.
|
||||
|
||||
Bis 25.09.2026 stand die Liste im Router (routers/system.py). Die Felder name, unit, url, ok, scope und schlaeft sind
|
||||
geblieben — die MCP-Werkzeuge (mcp/mcp_mc.py, mcp/mcp_voice.py) lesen sie.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import psutil
|
||||
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, V1_UPSTREAM, VOICE_SERVICE_URL
|
||||
|
||||
from services import system, waechter
|
||||
from services.agent import agent_status
|
||||
from services.gateway import gateway_reachable
|
||||
from services.llamaswap import engine_reachable
|
||||
|
||||
EIGENSCHAFTEN = (
|
||||
"Id", "LoadState", "ActiveState", "UnitFileState", "MemoryCurrent", "CPUUsageNSec",
|
||||
"ActiveEnterTimestampMonotonic", "InactiveEnterTimestampMonotonic", "NRestarts",
|
||||
)
|
||||
AKTIV = ("active", "reloading", "refreshing")
|
||||
|
||||
# Nur die Engine rechnet auf der Grafik (Stand 25.09.2026: alle GTT-Belegung gehört den llama-server-Prozessen in
|
||||
# llama-swap.service). Deshalb bekommt allein sie den Grafikspeicher der Box zugeschrieben.
|
||||
GRAFIK_DIENST = "llama-swap"
|
||||
|
||||
CPU_MIN_S = 2.0 # kürzere Abstände zweier Abfragen sind Rauschen: dann gilt die letzte Messung weiter
|
||||
CPU_MAX_S = 120.0 # eine ältere Probe sagt nichts mehr über „gerade“: neu ansetzen
|
||||
CPU_KURZ_S = 0.5 # erste Abfrage ohne brauchbare Probe: zweite Probe nach einer halben Sekunde
|
||||
|
||||
LAUFEND = ("laeuft", "antwortet_nicht", "startet", "stoppt")
|
||||
|
||||
|
||||
# --- systemctl show lesen ------------------------------------------------------------------------------------------
|
||||
|
||||
def lies_show(text: str) -> list[dict[str, str]]:
|
||||
"""Die Ausgabe von `systemctl show a b c -p …`: je Unit ein Block „Schlüssel=Wert“, Blöcke durch Leerzeilen
|
||||
getrennt, in der Reihenfolge der Units."""
|
||||
bloecke: list[dict[str, str]] = []
|
||||
block: dict[str, str] = {}
|
||||
for zeile in text.splitlines():
|
||||
if not zeile.strip():
|
||||
if block:
|
||||
bloecke.append(block)
|
||||
block = {}
|
||||
continue
|
||||
if "=" in zeile:
|
||||
schluessel, wert = zeile.split("=", 1)
|
||||
block[schluessel.strip()] = wert.strip()
|
||||
if block:
|
||||
bloecke.append(block)
|
||||
return bloecke
|
||||
|
||||
|
||||
def zuordnen(bloecke: list[dict[str, str]], units: list[str]) -> dict[str, dict[str, str]]:
|
||||
"""Blöcke den Units zuordnen: nach Reihenfolge, wenn die Zahl stimmt (so kommen sie von systemctl), sonst nach Id."""
|
||||
if len(bloecke) == len(units):
|
||||
return dict(zip(units, bloecke, strict=True))
|
||||
return {b["Id"].removesuffix(".service"): b for b in bloecke if b.get("Id")}
|
||||
|
||||
|
||||
def _systemctl_show(units: list[str], system_dienst: bool) -> dict[str, dict[str, str]]:
|
||||
"""Eigenschaften mehrerer Units mit einem Aufruf. Auf Windows (Entwicklung) oder ohne systemd: leer."""
|
||||
if not units:
|
||||
return {}
|
||||
cmd = ["systemctl"] if system_dienst else ["systemctl", "--user"]
|
||||
cmd += ["show", *units, "-p", ",".join(EIGENSCHAFTEN)]
|
||||
try:
|
||||
out = subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout
|
||||
except Exception:
|
||||
return {}
|
||||
return zuordnen(lies_show(out), units)
|
||||
|
||||
|
||||
def zahl(wert: str | None) -> int | None:
|
||||
"""Ganze Zahl aus systemd — „[not set]“, „infinity“ und das alte UINT64_MAX („nicht gezählt“) werden None."""
|
||||
try:
|
||||
n = int(wert or "")
|
||||
except ValueError:
|
||||
return None
|
||||
return n if 0 <= n < 2**63 else None
|
||||
|
||||
|
||||
# --- Auswertung (rein, getestet) -----------------------------------------------------------------------------------
|
||||
|
||||
def zustand(e: dict[str, str], ok: bool, schlaeft: bool) -> str:
|
||||
"""Der Zustand in einem Wort: laeuft | antwortet_nicht | startet | stoppt | gestoppt | gescheitert | schlaeft.
|
||||
Ohne systemd-Angaben (Windows, Unit unbekannt) entscheidet allein, ob der Dienst antwortet."""
|
||||
if schlaeft:
|
||||
return "schlaeft"
|
||||
aktiv = e.get("ActiveState", "")
|
||||
if not aktiv or e.get("LoadState") in ("not-found", ""):
|
||||
return "laeuft" if ok else "antwortet_nicht"
|
||||
if aktiv == "failed":
|
||||
return "gescheitert"
|
||||
if aktiv == "activating":
|
||||
return "startet"
|
||||
if aktiv == "deactivating":
|
||||
return "stoppt"
|
||||
if aktiv == "inactive":
|
||||
return "gestoppt"
|
||||
return "laeuft" if ok else "antwortet_nicht" # active, reloading, refreshing
|
||||
|
||||
|
||||
def kennzahlen(e: dict[str, str], jetzt_s: float) -> dict:
|
||||
"""Speicher, Laufzeit und Neustarts einer Unit. jetzt_s = time.monotonic() beim Lesen — systemd führt seine
|
||||
…Monotonic-Zeitstempel auf derselben Uhr (CLOCK_MONOTONIC), in Mikrosekunden."""
|
||||
aktiv = e.get("ActiveState")
|
||||
an = zahl(e.get("ActiveEnterTimestampMonotonic"))
|
||||
aus = zahl(e.get("InactiveEnterTimestampMonotonic"))
|
||||
return {
|
||||
"speicher_bytes": zahl(e.get("MemoryCurrent")),
|
||||
"laeuft_seit_s": max(0, int(jetzt_s - an / 1e6)) if aktiv in AKTIV and an else None,
|
||||
"aus_seit_s": max(0, int(jetzt_s - aus / 1e6)) if aktiv in ("inactive", "failed") and aus else None,
|
||||
"neustarts": zahl(e.get("NRestarts")),
|
||||
}
|
||||
|
||||
|
||||
def cpu_anteil(vorher: tuple[float, int], jetzt: tuple[float, int], kerne: int) -> float | None:
|
||||
"""Anteil an allen Kernen in Prozent zwischen zwei Proben (Zeitpunkt in s, CPU-Zeit der Unit in ns). None, wenn
|
||||
keine Zeit verging oder der Zähler kleiner wurde (die Unit ist dazwischen neu gestartet)."""
|
||||
dauer = jetzt[0] - vorher[0]
|
||||
if dauer <= 0 or jetzt[1] < vorher[1] or kerne < 1:
|
||||
return None
|
||||
return round(min(100.0, (jetzt[1] - vorher[1]) / 1e9 / dauer / kerne * 100), 1)
|
||||
|
||||
|
||||
class CpuMessung:
|
||||
"""Merkt sich je Unit die letzte Probe von CPUUsageNSec und den daraus gemessenen Anteil. Fragt die Oberfläche alle
|
||||
paar Sekunden, ist das die Auslastung seit der letzten Abfrage — wie bei top, nur je Dienst."""
|
||||
|
||||
def __init__(self, kerne: int):
|
||||
self.kerne = max(1, kerne)
|
||||
self._proben: dict[str, tuple[float, int]] = {}
|
||||
self._werte: dict[str, float] = {}
|
||||
self._sperre = threading.Lock()
|
||||
|
||||
def probe(self, unit: str, t: float, ns: int | None) -> None:
|
||||
with self._sperre:
|
||||
if ns is None: # läuft nicht, oder systemd zählt ihre CPU-Zeit nicht
|
||||
self._proben.pop(unit, None)
|
||||
self._werte.pop(unit, None)
|
||||
return
|
||||
vorher = self._proben.get(unit)
|
||||
if vorher and unit in self._werte and t - vorher[0] < CPU_MIN_S:
|
||||
return # zu kurz nach der letzten Messung: deren Wert gilt weiter
|
||||
wert = cpu_anteil(vorher, (t, ns), self.kerne) if vorher and t - vorher[0] <= CPU_MAX_S else None
|
||||
self._proben[unit] = (t, ns)
|
||||
if wert is None:
|
||||
self._werte.pop(unit, None)
|
||||
else:
|
||||
self._werte[unit] = wert
|
||||
|
||||
def wert(self, unit: str) -> float | None:
|
||||
with self._sperre:
|
||||
return self._werte.get(unit)
|
||||
|
||||
|
||||
_cpu = CpuMessung(os.cpu_count() or 1)
|
||||
_uhr = time.monotonic # dieselbe Uhr wie systemds …Monotonic-Zeitstempel (Tests setzen eine eigene)
|
||||
|
||||
|
||||
# --- Die Liste -----------------------------------------------------------------------------------------------------
|
||||
|
||||
def _voice_antwortet() -> bool:
|
||||
try:
|
||||
return httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=2).status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _dienste() -> tuple[list[dict], dict]:
|
||||
"""Die Dienste in ihrer Reihenfolge mit HTTP-Erreichbarkeit (ok) — None heißt: systemd entscheidet (Dienste ohne
|
||||
eigenen HTTP-Port). `scope`: user|system (Weg für Neustart und Protokoll), `unit`: echter systemd-Name."""
|
||||
a = agent_status()
|
||||
gw_url = f"{GATEWAY_URL}/v1"
|
||||
zeilen: list[dict] = [
|
||||
{"name": "Engine (llama-swap)", "unit": "llama-swap", "url": LLAMA_SWAP_URL,
|
||||
"ok": engine_reachable(), "scope": "system"},
|
||||
]
|
||||
# Seit UMBAU v3 P1 ist der LLM-Gateway ein EIGENER Prozess — ohne diese Zeile war er
|
||||
# in der Dienste-Ampel unsichtbar (Kachel "6/6 grün" bei totem Denkpfad).
|
||||
if V1_UPSTREAM:
|
||||
zeilen.append({"name": "LLM-Gateway (Denkpfad)", "unit": "mc2-gateway", "url": V1_UPSTREAM,
|
||||
"ok": gateway_reachable(), "scope": "user"})
|
||||
# MC2 selbst antwortet gerade auf diesen Request — ok=True ist hier ehrlich;
|
||||
# die Zeile existiert für Restart/Logs, nicht als Erreichbarkeits-Orakel.
|
||||
zeilen.append({"name": "Box-Wart (MC2)", "unit": "mission-control-2", "url": gw_url,
|
||||
"ok": True, "scope": "user"})
|
||||
else:
|
||||
zeilen.append({"name": "Gateway (integriert)", "unit": "mission-control-2", "url": gw_url,
|
||||
"ok": gateway_reachable(), "scope": "user"})
|
||||
zeilen += [
|
||||
{"name": "Wächter (Steward)", "unit": "mc2-steward", "url": "", "ok": None, "scope": "user"},
|
||||
{"name": "Hermes-Gateway (Agent & Gedächtnis)", "unit": "hermes-gateway", "url": HERMES_API_URL,
|
||||
"ok": a["gateway_reachable"], "scope": "user"},
|
||||
{"name": "Hermes-Dashboard", "unit": "hermes-builtin-ui", "url": a["hermes_ui_url"],
|
||||
"ok": a["hermes_ui_reachable"], "scope": "user"},
|
||||
{"name": "Spracherkennung (Desktop-Lucy)", "unit": "voice-service", "url": VOICE_SERVICE_URL,
|
||||
"ok": _voice_antwortet(), "scope": "user"},
|
||||
# Lucys Stimme (pocket-tts) spricht Telegram und Lucy-Desktop — bis 09/2026 fehlte
|
||||
# sie hier; die Box-Konsole ist mit dem Box-Wart-Umbau aus MC2 raus.
|
||||
{"name": "Lucys Stimme", "unit": "lucy-stimme", "url": "", "ok": None, "scope": "user"},
|
||||
]
|
||||
links = {"engine_ui": f"{LLAMA_SWAP_URL}/ui", "gateway": gw_url, "hermes_ui": a["hermes_ui_url"]}
|
||||
return zeilen, links
|
||||
|
||||
|
||||
def _lesen(user: list[str], system_units: list[str]) -> dict[str, tuple[float, dict[str, str]]]:
|
||||
"""systemd-Stand je Unit samt dem Zeitpunkt, zu dem er gelesen wurde."""
|
||||
stand: dict[str, tuple[float, dict[str, str]]] = {}
|
||||
for units, system_dienst in ((user, False), (system_units, True)):
|
||||
gelesen = _systemctl_show(units, system_dienst)
|
||||
t = _uhr()
|
||||
for unit, e in gelesen.items():
|
||||
stand[unit] = (t, e)
|
||||
return stand
|
||||
|
||||
|
||||
def _messen(user: list[str], system_units: list[str]) -> dict[str, tuple[float, dict[str, str]]]:
|
||||
"""Einmal lesen und die CPU-Proben nachführen. Fehlt einem laufenden Dienst noch ein Wert (erste Abfrage nach dem
|
||||
Start von MC2, Dienst neu gestartet, lange niemand geschaut), nach einer halben Sekunde ein zweites Mal."""
|
||||
def nachfuehren(stand: dict[str, tuple[float, dict[str, str]]]) -> bool:
|
||||
fehlt = False
|
||||
for unit, (t, e) in stand.items():
|
||||
# Gestoppte Units behalten den Zähler ihres letzten Laufs (25.09.: projekte-sync, mc2-backup) — der
|
||||
# sagt nichts über „gerade“.
|
||||
ns = zahl(e.get("CPUUsageNSec")) if e.get("ActiveState") in (*AKTIV, "activating", "deactivating") else None
|
||||
_cpu.probe(unit, t, ns)
|
||||
fehlt = fehlt or (ns is not None and _cpu.wert(unit) is None)
|
||||
return fehlt
|
||||
|
||||
stand = _lesen(user, system_units)
|
||||
if nachfuehren(stand):
|
||||
time.sleep(CPU_KURZ_S)
|
||||
stand = _lesen(user, system_units)
|
||||
nachfuehren(stand)
|
||||
return stand
|
||||
|
||||
|
||||
def _speicher_gesamt() -> int | None:
|
||||
try:
|
||||
return psutil.virtual_memory().total
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def liste() -> dict:
|
||||
"""Alle Dienste der Box mit Zustand und Kennzahlen — die EINE Quelle für die Seite „Dienste und Protokolle“ und
|
||||
die MCP-Werkzeuge. speicher_gesamt (Bytes) und kerne sind der Bezug für die Balken der Oberfläche."""
|
||||
zeilen, links = _dienste()
|
||||
stand = _messen([z["unit"] for z in zeilen if z["scope"] == "user"],
|
||||
[z["unit"] for z in zeilen if z["scope"] == "system"])
|
||||
for z in zeilen:
|
||||
t, e = stand.get(z["unit"], (0.0, {}))
|
||||
if z["ok"] is None:
|
||||
z["ok"] = e.get("ActiveState") == "active"
|
||||
# Bewusst abgeschaltete Dienste (24.09.2026: Spracherkennung bis zur Android-App) sind kein
|
||||
# Fehler — die Oberfläche zeigt sie als „schläft“ mit Knopf zum Wecken statt rot.
|
||||
z["schlaeft"] = not z["ok"] and z["scope"] == "user" and waechter.schlaeft(e)
|
||||
z["zustand"] = zustand(e, z["ok"], z["schlaeft"])
|
||||
z.update(kennzahlen(e, t))
|
||||
z["cpu_prozent"] = _cpu.wert(z["unit"]) if z["zustand"] in LAUFEND else None
|
||||
z["grafik_bytes"] = None
|
||||
if z["unit"] == GRAFIK_DIENST and z["speicher_bytes"] is not None:
|
||||
z["grafik_bytes"] = (system._gpu_sysfs() or {}).get("gtt_used")
|
||||
return {"services": zeilen, "links": links, "speicher_gesamt": _speicher_gesamt(), "kerne": _cpu.kerne}
|
||||
@@ -25,8 +25,6 @@ from services.sources import CATEGORIES, SKIP_TOKENS, TRUSTED_AUTHORS
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_FIT_ORDER = {"perfect": 0, "marginal": 1, "too_tight": 2}
|
||||
|
||||
|
||||
def _categorize(repo_id: str) -> str:
|
||||
low = repo_id.lower()
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
"""
|
||||
Einstellungen der Oberfläche (seit 24.09.2026): was sich unter „Einstellungen“ wirklich einstellen lässt.
|
||||
|
||||
• Update-Zeitfenster Wochentag und Uhrzeit des wöchentlichen Update-Laufs (mc2-autoupdate.timer, Standard
|
||||
So 04:30). Umgesetzt als Drop-in ~/.config/systemd/user/mc2-autoupdate.timer.d/zeitfenster.conf,
|
||||
das deploy.sh nicht anfasst. Neu gestartet wird die Box nur in den drei Stunden ab der vollen
|
||||
Stunde des Beginns; deploy/wartungsfenster.sh liest das Fenster aus der Einstellungsdatei.
|
||||
• Modell-Radar mc2-radar.timer (täglich 00:30) an oder aus. deploy.sh respektiert den Schalter.
|
||||
• Telegram-Test eine Testmeldung über den echten Meldeweg (deploy/notify.sh -d, also auch nachts sofort);
|
||||
das Ergebnis samt Grund kommt aus dem Melde-Log. Geht auf beiden Instanzen.
|
||||
|
||||
Was eingestellt ist, steht in einer Datei im Datenordner (mc2-einstellungen.json; nur MC2 schreibt sie, deploy.sh
|
||||
und autoupdate.sh lesen sie). Was gilt, sagt systemd. Die Oberfläche zeigt beides: den Wunsch aus der Datei und den
|
||||
nächsten Lauf laut systemd, und ob beides zusammenpasst.
|
||||
|
||||
‼ Nachhol-Falle (24.09.2026): Beide Timer haben Persistent=true. Startet ein Timer mit neuem Plan, holt er einen
|
||||
Termin, der seit seinem letzten Lauf „verpasst“ wurde, sofort nach — so startete die Box an einem Donnerstagnachmittag
|
||||
neu. Darum wird der Update-Timer VOR dem daemon-reload angehalten (ein laufender Timer rechnet beim Reload mit seinem
|
||||
letzten Lauf und dem neuen Plan), und vor jedem Start steht der Stempel ~/.local/share/systemd/timers/stamp-<timer>
|
||||
auf jetzt: systemd nimmt dessen Zeit als letzten Lauf und plant den nächsten Termin von dort aus.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from kern.einstellungen import einstellungen as instanz
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
# Bewusst kein Import aus services.waechter: Der Telegram-Test läuft auch im Homelab-Teil, und der lädt nichts
|
||||
# von der KI-Box (waechter zieht llamaswap und maintenance nach, siehe test_partner).
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
UPDATE_TIMER = "mc2-autoupdate.timer"
|
||||
UPDATE_DIENST = "mc2-autoupdate.service"
|
||||
RADAR_TIMER = "mc2-radar.timer"
|
||||
TAGE = ("Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag")
|
||||
SYSTEMD_TAGE = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
|
||||
STANDARD_FENSTER = {"tag": 7, "uhrzeit": "04:30"}
|
||||
NEUSTART_DAUER_MIN = 180 # wie WARTUNG_DAUER_MIN in deploy/wartungsfenster.sh
|
||||
TEST_ABSTAND_S = 20 # höchstens ein Telegram-Test alle 20 s
|
||||
AUF_DER_BOX = os.name == "posix" # am Windows-PC (Entwicklung) wird nichts geschaltet und nichts gesendet
|
||||
NOTIFY_SH = Path(__file__).resolve().parents[2] / "deploy" / "notify.sh"
|
||||
|
||||
_sperre = threading.Lock()
|
||||
_test_sperre = threading.Lock()
|
||||
_letzter_test = {"ts": 0.0}
|
||||
|
||||
|
||||
# --- Orte (zur Laufzeit gelesen, damit Tests sie umlenken können) ------------------------------------
|
||||
|
||||
def datei() -> Path:
|
||||
return Path(os.environ.get("MC_EINSTELLUNGEN", str(instanz().daten_dir / "mc2-einstellungen.json")))
|
||||
|
||||
|
||||
def _user_units() -> Path:
|
||||
return Path(os.environ.get("MC_SYSTEMD_USER_DIR", str(Path.home() / ".config" / "systemd" / "user")))
|
||||
|
||||
|
||||
def _stempel_ordner() -> Path:
|
||||
return Path(os.environ.get("MC_TIMER_STEMPEL_DIR", str(Path.home() / ".local" / "share" / "systemd" / "timers")))
|
||||
|
||||
|
||||
def dropin() -> Path:
|
||||
return _user_units() / f"{UPDATE_TIMER}.d" / "zeitfenster.conf"
|
||||
|
||||
|
||||
def melde_log() -> Path:
|
||||
return Path(os.environ.get("MC_NOTIFY_LOG", str(Path.home() / "mc2-notify.log")))
|
||||
|
||||
|
||||
# --- Die Datei -------------------------------------------------------------------------------------
|
||||
|
||||
def fenster_fehler(tag: object, uhrzeit: object) -> str | None:
|
||||
"""Warum ein Zeitfenster ungültig ist — oder None."""
|
||||
if not isinstance(tag, int) or isinstance(tag, bool) or not 1 <= tag <= 7:
|
||||
return "Der Wochentag muss zwischen 1 (Montag) und 7 (Sonntag) liegen."
|
||||
if not isinstance(uhrzeit, str) or not re.fullmatch(r"([01]\d|2[0-3]):[0-5]\d", uhrzeit):
|
||||
return "Die Uhrzeit braucht die Form HH:MM, etwa 04:30."
|
||||
return None
|
||||
|
||||
|
||||
def lesen() -> dict:
|
||||
"""Was eingestellt ist. Fehlt die Datei oder ist ein Wert unsinnig, gilt der Standard (So 04:30, Radar an)."""
|
||||
try:
|
||||
roh = json.loads(datei().read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
roh = {}
|
||||
roh = roh if isinstance(roh, dict) else {}
|
||||
fenster = roh.get("update_fenster") if isinstance(roh.get("update_fenster"), dict) else {}
|
||||
tag, uhrzeit = fenster.get("tag"), fenster.get("uhrzeit")
|
||||
if fenster_fehler(tag, uhrzeit):
|
||||
tag, uhrzeit = STANDARD_FENSTER["tag"], STANDARD_FENSTER["uhrzeit"]
|
||||
radar = roh.get("radar") if isinstance(roh.get("radar"), dict) else {}
|
||||
an = radar.get("an") if isinstance(radar.get("an"), bool) else True
|
||||
return {"update_fenster": {"tag": tag, "uhrzeit": uhrzeit}, "radar": {"an": an}}
|
||||
|
||||
|
||||
def _speichern(daten: dict) -> None:
|
||||
"""Atomar über eine Nachbardatei — deploy.sh und autoupdate.sh lesen die Datei jederzeit."""
|
||||
ziel = datei()
|
||||
ziel.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = ziel.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps({**daten, "geaendert": datetime.now(LOCAL_TZ).isoformat(timespec="seconds")},
|
||||
ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
os.replace(tmp, ziel)
|
||||
|
||||
|
||||
# --- Zeitfenster -----------------------------------------------------------------------------------
|
||||
|
||||
def neustart_fenster(tag: int, uhrzeit: str) -> str:
|
||||
"""Wann die Box nach einem Update neu starten darf, in Worten (gleiche Regel wie deploy/wartungsfenster.sh):
|
||||
drei Stunden ab der vollen Stunde des Beginns. So 04:30 → „Sonntag 04:00–06:59“."""
|
||||
stunde = int(uhrzeit[:2])
|
||||
ende = stunde * 60 + NEUSTART_DAUER_MIN - 1
|
||||
tag_ende = tag
|
||||
if ende >= 1440:
|
||||
ende -= 1440
|
||||
tag_ende = tag % 7 + 1
|
||||
von, bis = f"{stunde:02d}:00", f"{ende // 60:02d}:{ende % 60:02d}"
|
||||
if tag_ende == tag:
|
||||
return f"{TAGE[tag - 1]} {von}–{bis}"
|
||||
return f"{TAGE[tag - 1]} {von} – {TAGE[tag_ende - 1]} {bis}"
|
||||
|
||||
|
||||
def dropin_text(tag: int, uhrzeit: str) -> str:
|
||||
"""Das leere OnCalendar= löscht den Plan der Repo-Unit, statt einen zweiten danebenzustellen."""
|
||||
return ("# Update-Zeitfenster aus den Einstellungen der Oberfläche (backend/services/einstellungen.py).\n"
|
||||
"# Nicht von Hand ändern: Die Oberfläche schreibt diese Datei beim Speichern neu; deploy.sh lässt sie stehen.\n"
|
||||
"[Timer]\n"
|
||||
"OnCalendar=\n"
|
||||
f"OnCalendar={SYSTEMD_TAGE[tag - 1]} *-*-* {uhrzeit}:00\n")
|
||||
|
||||
|
||||
def _dropin_lesen() -> str | None:
|
||||
try:
|
||||
return dropin().read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _dropin_schreiben(inhalt: str | None) -> None:
|
||||
"""None heißt: kein Drop-in (zurück auf den Plan der Repo-Unit)."""
|
||||
pfad = dropin()
|
||||
if inhalt is None:
|
||||
pfad.unlink(missing_ok=True)
|
||||
return
|
||||
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = pfad.with_suffix(".conf.tmp")
|
||||
tmp.write_text(inhalt, encoding="utf-8")
|
||||
os.replace(tmp, pfad)
|
||||
|
||||
|
||||
# --- systemd ---------------------------------------------------------------------------------------
|
||||
|
||||
def _systemctl(*args: str) -> tuple[bool, str]:
|
||||
"""systemctl --user … — die eine Schicht zu systemd, die Tests ersetzen. Gibt (ok, Ausgabe bzw. Grund)."""
|
||||
try:
|
||||
r = subprocess.run(["systemctl", "--user", *args], capture_output=True, text=True, timeout=60)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
return False, f"systemctl ging nicht ({exc.__class__.__name__}: {exc})"
|
||||
if r.returncode == 0:
|
||||
return True, (r.stdout or "").strip()
|
||||
return False, ((r.stderr or r.stdout or "").strip() or f"systemctl endete mit Code {r.returncode}")[:300]
|
||||
|
||||
|
||||
def _zustand(unit: str) -> dict[str, str]:
|
||||
ok, ausgabe = _systemctl("show", unit, "-p", "LoadState,ActiveState,UnitFileState")
|
||||
if not ok:
|
||||
return {}
|
||||
return dict(z.split("=", 1) for z in ausgabe.splitlines() if "=" in z)
|
||||
|
||||
|
||||
def _naechster_lauf(timer: str) -> str | None:
|
||||
ok, ausgabe = _systemctl("list-timers", "--all", "--output=json", timer)
|
||||
if not ok:
|
||||
return None
|
||||
try:
|
||||
timer_liste = json.loads(ausgabe or "[]")
|
||||
except ValueError:
|
||||
return None
|
||||
for t in timer_liste if isinstance(timer_liste, list) else []:
|
||||
if isinstance(t, dict) and t.get("unit") == timer and t.get("next"):
|
||||
try:
|
||||
return datetime.fromtimestamp(int(t["next"]) / 1_000_000, LOCAL_TZ).isoformat(timespec="seconds")
|
||||
except (TypeError, ValueError, OverflowError, OSError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _stempel_setzen(timer: str) -> None:
|
||||
"""Letzter Lauf = jetzt: So holt der Timer beim nächsten Start keinen „verpassten“ Termin nach (Persistent=true).
|
||||
Scheitert das, startet der Timer trotzdem — ein nachgeholter Lauf ist besser als gar keiner mehr."""
|
||||
try:
|
||||
pfad = _stempel_ordner() / f"stamp-{timer}"
|
||||
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||
pfad.touch(exist_ok=True)
|
||||
except OSError:
|
||||
log.warning("einstellungen: Stempel für %s nicht setzbar — der Timer könnte nachholen", timer, exc_info=True)
|
||||
|
||||
|
||||
def _fenster_anwenden(inhalt: str | None) -> tuple[bool, str]:
|
||||
"""Drop-in schreiben (None: entfernen) und den Update-Timer ohne Nachholen neu planen. Lief der Timer nicht,
|
||||
bleibt er aus — er übernimmt das Fenster beim nächsten Start."""
|
||||
lief = _zustand(UPDATE_TIMER).get("ActiveState") == "active"
|
||||
if lief:
|
||||
ok, grund = _systemctl("stop", UPDATE_TIMER)
|
||||
if not ok:
|
||||
return False, grund
|
||||
try:
|
||||
_dropin_schreiben(inhalt)
|
||||
ok, grund = _systemctl("daemon-reload")
|
||||
except OSError as exc:
|
||||
ok, grund = False, f"Drop-in {dropin()} nicht schreibbar: {exc}"
|
||||
if lief: # auch nach einem Fehler wieder starten: ohne Timer gäbe es gar keine Updates mehr
|
||||
_stempel_setzen(UPDATE_TIMER)
|
||||
gestartet, grund_start = _systemctl("start", UPDATE_TIMER)
|
||||
if ok and not gestartet:
|
||||
ok, grund = False, grund_start
|
||||
return ok, grund
|
||||
|
||||
|
||||
# --- Für die Oberfläche ----------------------------------------------------------------------------
|
||||
|
||||
def schlaeft(z: dict[str, str]) -> bool:
|
||||
"""Dieselbe Regel wie waechter.schlaeft (ein Test hält beide gleich): abgeschaltet und nicht mehr für den
|
||||
Autostart eingetragen = bewusst schlafen gelegt."""
|
||||
return z.get("ActiveState") == "inactive" and z.get("UnitFileState") == "disabled"
|
||||
|
||||
|
||||
def _radar_zustand(z: dict[str, str]) -> str:
|
||||
if not z or z.get("LoadState") in ("not-found", ""):
|
||||
return "unbekannt"
|
||||
if z.get("ActiveState") == "active":
|
||||
return "an"
|
||||
return "schlaeft" if schlaeft(z) else "aus"
|
||||
|
||||
|
||||
def stand() -> dict:
|
||||
"""Einstellungen samt dem, was systemd daraus gemacht hat."""
|
||||
daten = lesen()
|
||||
f = daten["update_fenster"]
|
||||
update = _zustand(UPDATE_TIMER)
|
||||
radar = _zustand(RADAR_TIMER)
|
||||
ist = _dropin_lesen()
|
||||
fenster_wirksam = (ist == dropin_text(f["tag"], f["uhrzeit"])) if ist is not None else (f == STANDARD_FENSTER)
|
||||
radar_zustand = _radar_zustand(radar)
|
||||
radar_an = daten["radar"]["an"]
|
||||
return {
|
||||
"schaltbar": AUF_DER_BOX and bool(update or radar),
|
||||
"update_fenster": {
|
||||
**f,
|
||||
"tag_name": TAGE[f["tag"] - 1],
|
||||
"neustart_fenster": neustart_fenster(f["tag"], f["uhrzeit"]),
|
||||
"timer_aktiv": update.get("ActiveState") == "active",
|
||||
"naechster_lauf": _naechster_lauf(UPDATE_TIMER) if update else None,
|
||||
"wirksam": fenster_wirksam,
|
||||
},
|
||||
"radar": {
|
||||
"an": radar_an,
|
||||
"zustand": radar_zustand,
|
||||
"naechster_lauf": _naechster_lauf(RADAR_TIMER) if radar_zustand == "an" else None,
|
||||
# „aus“ (gestoppt, aber noch im Autostart) passt nicht zu „aus“: nach einem Neustart liefe es wieder.
|
||||
"wirksam": radar_zustand == "unbekannt" or radar_zustand == ("an" if radar_an else "schlaeft"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def update_fenster_setzen(tag: int, uhrzeit: str) -> dict:
|
||||
"""Knopf „Speichern“ beim Update-Zeitfenster."""
|
||||
if (fehler := fenster_fehler(tag, uhrzeit)):
|
||||
return {"ok": False, "detail": fehler}
|
||||
if not AUF_DER_BOX:
|
||||
return {"ok": False, "detail": "Das Update-Zeitfenster lässt sich nur auf der Box einstellen."}
|
||||
with _sperre:
|
||||
if _zustand(UPDATE_DIENST).get("ActiveState") in ("active", "activating", "deactivating"):
|
||||
return {"ok": False, "detail": "Gerade läuft das Update. Das Zeitfenster lässt sich danach ändern."}
|
||||
vorher = _dropin_lesen()
|
||||
ok, grund = _fenster_anwenden(dropin_text(tag, uhrzeit))
|
||||
if not ok:
|
||||
zurueck, _ = _fenster_anwenden(vorher)
|
||||
return {"ok": False, "detail": f"systemd hat das neue Zeitfenster nicht übernommen: {grund}"
|
||||
+ ("" if zurueck else " Der Timer lässt sich auch nicht zurückstellen.")}
|
||||
daten = lesen()
|
||||
daten["update_fenster"] = {"tag": tag, "uhrzeit": uhrzeit}
|
||||
try:
|
||||
_speichern(daten)
|
||||
except OSError as exc:
|
||||
return {"ok": False, "detail": f"Der Timer ist umgestellt, die Einstellung ließ sich aber nicht speichern: {exc}"}
|
||||
return {"ok": True, "text": f"Updates laufen ab jetzt {TAGE[tag - 1]} um {uhrzeit}. Neu gestartet wird die Box "
|
||||
f"nur {neustart_fenster(tag, uhrzeit)}. Ein verpasster Termin wird nicht nachgeholt.",
|
||||
"einstellungen": stand()}
|
||||
|
||||
|
||||
def radar_schalten(an: bool) -> dict:
|
||||
"""Knopf „Einschalten“/„Ausschalten“ beim Modell-Radar."""
|
||||
if not AUF_DER_BOX:
|
||||
return {"ok": False, "detail": "Das Radar lässt sich nur auf der Box schalten."}
|
||||
with _sperre:
|
||||
if an:
|
||||
ok, grund = _systemctl("enable", RADAR_TIMER)
|
||||
if ok:
|
||||
_stempel_setzen(RADAR_TIMER)
|
||||
ok, grund = _systemctl("start", RADAR_TIMER)
|
||||
else: # ein Lauf, der gerade läuft (Nachttest), endet noch von selbst
|
||||
ok, grund = _systemctl("disable", "--now", RADAR_TIMER)
|
||||
if not ok:
|
||||
return {"ok": False, "detail": f"systemd hat das Radar nicht {'eingeschaltet' if an else 'ausgeschaltet'}: "
|
||||
f"{grund}"}
|
||||
daten = lesen()
|
||||
daten["radar"] = {"an": an}
|
||||
try:
|
||||
_speichern(daten)
|
||||
except OSError as exc:
|
||||
return {"ok": False, "detail": f"Das Radar ist geschaltet, die Einstellung ließ sich aber nicht speichern: "
|
||||
f"{exc}. Der nächste Deploy könnte es zurückschalten."}
|
||||
text = ("Das Radar ist an und sucht wieder jede Nacht ab 00:30. Ein verpasster Lauf wird nicht nachgeholt." if an
|
||||
else "Das Radar ist aus und schläft. Es sucht und testet keine Modelle mehr, bis du es wieder einschaltest.")
|
||||
return {"ok": True, "text": text, "einstellungen": stand()}
|
||||
|
||||
|
||||
# --- Telegram-Test ---------------------------------------------------------------------------------
|
||||
|
||||
_KOPF = re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} ")
|
||||
_FALLBACK = "FALLBACK (telegram fehlgeschlagen:"
|
||||
|
||||
|
||||
def log_eintrag(pfad: Path, marke: str, max_bytes: int = 262_144) -> str | None:
|
||||
"""Der jüngste Eintrag des Melde-Logs, der `marke` enthält — samt Folgezeilen, falls die Ausgabe von
|
||||
`hermes send` mehrzeilig war (dann beginnt nur die erste Zeile mit dem Zeitstempel)."""
|
||||
try:
|
||||
with pfad.open("rb") as f:
|
||||
f.seek(0, os.SEEK_END)
|
||||
f.seek(max(0, f.tell() - max_bytes))
|
||||
zeilen = f.read().decode("utf-8", "replace").splitlines()
|
||||
except OSError:
|
||||
return None
|
||||
for i in range(len(zeilen) - 1, -1, -1):
|
||||
if marke in zeilen[i]:
|
||||
anfang = i
|
||||
while anfang > 0 and not _KOPF.match(zeilen[anfang]):
|
||||
anfang -= 1
|
||||
return "\n".join(zeilen[anfang:i + 1])
|
||||
return None
|
||||
|
||||
|
||||
def deute_eintrag(eintrag: str | None, text: str) -> dict:
|
||||
"""Was notify.sh mit der Meldung gemacht hat. Wortlaut der Zeilen: deploy/notify.sh."""
|
||||
if eintrag is None:
|
||||
return {"gesendet": False, "weg": None, "grund": None}
|
||||
rest = _KOPF.sub("", eintrag, count=1)
|
||||
if rest.startswith("OK telegram direkt:"):
|
||||
return {"gesendet": True, "weg": "direkt", "grund": None}
|
||||
if rest.startswith("OK telegram:"):
|
||||
return {"gesendet": True, "weg": "hermes", "grund": None}
|
||||
if rest.startswith("QUEUED"):
|
||||
return {"gesendet": False, "weg": None,
|
||||
"grund": "notify.sh hat sie für die Morgenmeldung zurückgelegt statt sie zu senden."}
|
||||
if rest.startswith(_FALLBACK):
|
||||
innen = rest[len(_FALLBACK):]
|
||||
ende = innen.rfind("): " + text[:40])
|
||||
grund = " ".join((innen[:ende] if ende >= 0 else innen).split())
|
||||
return {"gesendet": False, "weg": None,
|
||||
"grund": grund[:400] or "Telegram hat sie nicht angenommen, ohne Angabe eines Grundes."}
|
||||
return {"gesendet": False, "weg": None, "grund": " ".join(rest.split())[:300]}
|
||||
|
||||
|
||||
def _notify(bash: str, args: list[str]) -> tuple[int | None, str]:
|
||||
"""deploy/notify.sh aufrufen — der echte Meldeweg. Lucys Briefkasten und wall bleiben beim Test außen vor."""
|
||||
env = {**os.environ, "MC_NOTIFY_NO_ANNOUNCE": "1", "MC_NOTIFY_OHNE_WALL": "1"}
|
||||
try:
|
||||
r = subprocess.run([bash, NOTIFY_SH.as_posix(), *args], env=env, capture_output=True, text=True,
|
||||
encoding="utf-8", errors="replace", timeout=90, stdin=subprocess.DEVNULL)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
return None, f"{exc.__class__.__name__}: {exc}"
|
||||
return r.returncode, (r.stderr or r.stdout or "").strip()
|
||||
|
||||
|
||||
def telegram_test() -> dict:
|
||||
"""Knopf „Testmeldung senden“: eine kurze Meldung, dringend (-d), damit sie auch nachts sofort rausgeht."""
|
||||
e = instanz()
|
||||
jetzt = datetime.now(LOCAL_TZ)
|
||||
basis = {"instanz": e.instanz, "zeit": jetzt.isoformat(timespec="seconds")}
|
||||
with _test_sperre:
|
||||
if time.time() - _letzter_test["ts"] < TEST_ABSTAND_S:
|
||||
return {**basis, "ok": False, "gesendet": False, "weg": None, "grund": None,
|
||||
"detail": "Der letzte Test ist erst ein paar Sekunden her. Bitte kurz warten."}
|
||||
_letzter_test["ts"] = time.time()
|
||||
bash = shutil.which("bash") # voller Pfad: unter Windows fände „bash“ sonst womöglich die WSL-Hülle
|
||||
if not AUF_DER_BOX or not bash:
|
||||
return {**basis, "ok": False, "gesendet": False, "weg": None, "grund": None,
|
||||
"detail": "Der Telegram-Test geht nur auf der Box und im Homelab-Teil."}
|
||||
kennung = secrets.token_hex(3)
|
||||
text = (f"Testmeldung aus den Einstellungen ({e.instanz}, {jetzt:%d.%m. %H:%M} Uhr, Kennung {kennung}). "
|
||||
"Kommt sie an, funktioniert der Meldeweg.")
|
||||
code, ausgabe = _notify(bash, ["-d", "-s", "[Test]", text])
|
||||
gedeutet = deute_eintrag(log_eintrag(melde_log(), f"Kennung {kennung}"), text)
|
||||
basis["kennung"] = kennung
|
||||
if gedeutet["gesendet"]:
|
||||
weg = "über Hermes" if gedeutet["weg"] == "hermes" else "direkt an die Telegram-Bot-API"
|
||||
return {**basis, "ok": True, **gedeutet,
|
||||
"text": f"Die Testmeldung ist raus ({weg}). In Telegram steht sie mit der Kennung {kennung}."}
|
||||
grund = gedeutet["grund"] or (f"Im Melde-Log {melde_log()} steht nichts zu dieser Testmeldung"
|
||||
+ (f"; notify.sh endete mit Code {code}" if code is not None else "")
|
||||
+ (f": {ausgabe[:200]}" if ausgabe else "."))
|
||||
return {**basis, "ok": False, **gedeutet, "grund": grund, "detail": f"Die Testmeldung ging nicht raus: {grund}"}
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Lucy untersucht rote Hinweise (Ausbauplan Welle 3, Punkt 14 „Lucy ermittelt, schlägt vor, prüft nach“, seit
|
||||
25.09.2026).
|
||||
|
||||
Der Wächter beider Teile meldet jeden roten Hinweis, bevor er ihn nach Telegram schickt, hierher
|
||||
(POST /api/lucy/befund → befund_gemeldet()). Zurück bekommt er die Zeilen, die er an seine Nachricht hängt:
|
||||
• Hat der Hinweis einen Knopf (Neustart, Job wiederholen, Freigeben), wird er zur Rückfrage: „Antworte „ja 7“, dann
|
||||
starte ich … neu.“ (services/rueckfragen.py)
|
||||
• Je nach Stufe der Aktionsart „ermitteln“ (services/lucy_autonomie.py):
|
||||
selbst Lucy sieht sofort nach — über ihren eigenen Zugang (Hermes-api_server: dieselbe Lucy wie in Telegram,
|
||||
mit allen Werkzeugen) — und schickt Ursache und Lösungsweg als eine Nachricht. Rückfragen und Meldungen
|
||||
danach, die sie dabei auslöst, hängen an genau dieser Nachricht (rueckfragen.gesammelt()).
|
||||
fragen „Antworte „ja 8“, dann sehe ich nach“ (nur, wenn der Hinweis selbst keinen Knopf hat)
|
||||
lesen nichts; sie erklärt, wenn der Commander fragt
|
||||
Bremsen: je Hinweis höchstens einmal in sechs Stunden, höchstens acht Untersuchungen am Tag, eine nach der anderen.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL
|
||||
|
||||
from services import lucy_autonomie, rueckfragen
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
BETREFF = "[Lucy ermittelt]"
|
||||
ZEITLIMIT_S = 600.0
|
||||
JE_HINWEIS_S = 6 * 3600
|
||||
JE_TAG = 8
|
||||
_ANGEBOT = ("neustart", "job-wiederholen", "freigeben")
|
||||
|
||||
AUFTRAG = """Der Wächter des Orchestrators meldet einen roten Hinweis ({wo}):
|
||||
„{titel}“ — {text}
|
||||
(Kennung {id})
|
||||
|
||||
Sieh nach, was dahintersteckt: lage(), protokoll() und {logs}. Belege, statt zu raten.
|
||||
Danach antworte dem Commander in höchstens sechs Sätzen; die Antwort geht so, wie sie ist, an Telegram: was los ist, \
|
||||
woran es liegt, was betroffen ist und welcher Weg es löst.
|
||||
Ist eine Aktion nötig, nimm das passende Werkzeug mit auf_bitte=false — deine Freiheiten (freiheiten()) entscheiden, ob \
|
||||
du selbst handelst oder den Commander fragst; die Antwortzeile einer Rückfrage hängt das System an, schreib sie nicht \
|
||||
selbst. Einen bewusst gestoppten Dienst startest du nicht selbst. Hast du selbst gehandelt, prüfe mit lage() nach, ob \
|
||||
der Hinweis weg ist, und sag es."""
|
||||
|
||||
_lock = threading.Lock() # eine Untersuchung nach der anderen
|
||||
_zaehler_lock = threading.Lock()
|
||||
_zuletzt: dict[str, float] = {} # Hinweis → Beginn der letzten Untersuchung (nach einem Neustart von vorn: harmlos)
|
||||
_heute: list[float] = []
|
||||
|
||||
|
||||
def angebot(aktion: dict) -> str | None:
|
||||
"""Was ein Knopf des Hinweises tut, als Satzende nach „dann …“."""
|
||||
aid, label = aktion.get("id"), str(aktion.get("label") or "")
|
||||
if aid == "neustart":
|
||||
dienst = aktion.get("dienst") or "den Dienst"
|
||||
if label.startswith("Jetzt erneut"):
|
||||
return f"lasse ich {dienst} jetzt noch einmal laufen"
|
||||
return f"starte ich {dienst}" if label == "Starten" else f"starte ich {dienst} neu"
|
||||
if aid == "job-wiederholen":
|
||||
return "führe ich den Job noch einmal aus"
|
||||
if aid == "freigeben":
|
||||
return f"gebe ich {aktion.get('baustein') or 'den Baustein'} wieder für Updates frei"
|
||||
return None
|
||||
|
||||
|
||||
def _hinweis_pfad(bereich: str, hid: str, aid: str) -> str:
|
||||
basis = "/api/hinweise" if bereich == "box" else f"{rueckfragen.HL}/hinweise"
|
||||
return f"{basis}/{quote(hid, safe=':@-._')}/aktion/{aid}"
|
||||
|
||||
|
||||
def befund_gemeldet(b: object, jetzt: float | None = None) -> dict:
|
||||
"""POST /api/lucy/befund — vom Wächter bei einem roten Hinweis (neu oder als Erinnerung). Antwort: Zeilen und
|
||||
Antwort-Knöpfe für seine Nachricht. Bei einer Erinnerung bietet er nur den Knopf wieder an (dieselbe Nummer)."""
|
||||
d = b if isinstance(b, dict) else {}
|
||||
bereich, hid = d.get("bereich"), str(d.get("id") or "")
|
||||
if bereich not in ("box", "homelab") or not hid:
|
||||
return {"ok": False, "detail": "bereich (box/homelab) und id fehlen."}
|
||||
titel = " ".join(str(d.get("titel") or hid).split())[:200]
|
||||
text = " ".join(str(d.get("text") or "").split())[:600]
|
||||
befund = {"bereich": bereich, "id": hid, "titel": titel}
|
||||
zeilen: list[str] = []
|
||||
knoepfe: list[str] = []
|
||||
aktion = next((a for a in d.get("aktionen") or [] if isinstance(a, dict) and a.get("id") in _ANGEBOT), None)
|
||||
if aktion and (tut := angebot(aktion)):
|
||||
r = rueckfragen.stellen(f"{titel}: Soll ich das beheben? Dann {tut}.", _hinweis_pfad(bereich, hid, aktion["id"]),
|
||||
von="waechter", still=True, befund=befund, jetzt=jetzt,
|
||||
schluessel=f"hinweis {bereich} {hid} {aktion['id']}")
|
||||
if r["ok"]:
|
||||
nr = r["rueckfrage"]["nr"]
|
||||
zeilen.append(f"Antworte „ja {nr}“, dann {tut}.")
|
||||
knoepfe += rueckfragen.knoepfe(nr)
|
||||
if d.get("erinnerung"):
|
||||
return {"ok": True, "zeilen": zeilen, "knoepfe": knoepfe}
|
||||
stufe = lucy_autonomie.stufe("ermitteln")
|
||||
if stufe == "selbst":
|
||||
if starten({**befund, "text": text}, jetzt=jetzt)["ok"]:
|
||||
zeilen.append("Ich sehe nach und melde mich mit Ursache und Weg.")
|
||||
elif stufe == "fragen" and not knoepfe:
|
||||
r = rueckfragen.stellen(f"Soll ich nachsehen, was hinter „{titel}“ steckt?", "/api/lucy/ermitteln",
|
||||
{**befund, "text": text}, von="waechter", still=True, befund=befund, jetzt=jetzt,
|
||||
schluessel=f"ermitteln {bereich} {hid}")
|
||||
if r["ok"]:
|
||||
nr = r["rueckfrage"]["nr"]
|
||||
zeilen.append(f"Antworte „ja {nr}“, dann sehe ich nach, was dahintersteckt.")
|
||||
knoepfe += rueckfragen.knoepfe(nr)
|
||||
return {"ok": True, "zeilen": zeilen, "knoepfe": knoepfe}
|
||||
|
||||
|
||||
def starten(befund: object, jetzt: float | None = None) -> dict:
|
||||
"""Eine Untersuchung im Hintergrund (auch POST /api/lucy/ermitteln, das „ja“ einer Rückfrage). Das Ergebnis kommt
|
||||
als Nachricht; hier nur, ob sie beginnt."""
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
d = befund if isinstance(befund, dict) else {}
|
||||
if d.get("bereich") not in ("box", "homelab") or not d.get("id"):
|
||||
return {"ok": False, "detail": "bereich (box/homelab) und id fehlen."}
|
||||
if not HERMES_API_KEY:
|
||||
return {"ok": False, "detail": "Lucys eigener Zugang (API_SERVER_KEY in ~/.hermes/.env) fehlt."}
|
||||
schluessel = f"{d['bereich']}:{d['id']}"
|
||||
with _zaehler_lock:
|
||||
if jetzt - _zuletzt.get(schluessel, 0.0) < JE_HINWEIS_S:
|
||||
return {"ok": False, "detail": "Diesen Hinweis habe ich vor Kurzem schon untersucht."}
|
||||
_heute[:] = [t for t in _heute if jetzt - t < 86400]
|
||||
if len(_heute) >= JE_TAG:
|
||||
return {"ok": False, "detail": f"Heute habe ich schon {JE_TAG} Hinweise untersucht; weitere erst morgen."}
|
||||
_zuletzt[schluessel] = jetzt
|
||||
_heute.append(jetzt)
|
||||
befund = {"bereich": d["bereich"], "id": str(d["id"])[:200], "titel": str(d.get("titel") or d["id"])[:200],
|
||||
"text": str(d.get("text") or "")[:600]}
|
||||
threading.Thread(target=_untersuchen, args=(befund,), name=f"ermittlung-{befund['id']}", daemon=True).start()
|
||||
return {"ok": True, "text": "Lucy sieht nach; Ursache und Lösungsweg kommen als Nachricht."}
|
||||
|
||||
|
||||
def auftrag(befund: dict) -> str:
|
||||
if befund["bereich"] == "box":
|
||||
wo, logs = "auf der KI-Box", "die Protokolle des Dienstes (dienst_logs() oder dein Terminal)"
|
||||
else:
|
||||
wo, logs = "im Homelab", "die Protokolle des Geräts (geraet_logs())"
|
||||
return AUFTRAG.format(wo=wo, titel=befund["titel"], text=befund["text"] or "ohne weiteren Text",
|
||||
id=befund["id"], logs=logs)
|
||||
|
||||
|
||||
def _frag_lucy(text: str) -> str | None:
|
||||
"""Eine Anfrage an Lucy über den api_server von Hermes (dieselbe Persona, alle Werkzeuge). None = keine Antwort."""
|
||||
try:
|
||||
antwort = httpx.post(f"{HERMES_API_URL}/v1/chat/completions",
|
||||
json={"model": HERMES_API_MODEL, "stream": False,
|
||||
"messages": [{"role": "user", "content": text}]},
|
||||
headers={"Authorization": f"Bearer {HERMES_API_KEY}"}, timeout=ZEITLIMIT_S)
|
||||
if antwort.status_code != 200:
|
||||
log.warning("ermittlung: api_server antwortete mit HTTP %s", antwort.status_code)
|
||||
return None
|
||||
inhalt = antwort.json()["choices"][0]["message"]["content"]
|
||||
except (httpx.HTTPError, ValueError, KeyError, IndexError, TypeError):
|
||||
log.warning("ermittlung: api_server nicht erreichbar", exc_info=True)
|
||||
return None
|
||||
inhalt = re.sub(r"<think>.*?</think>", "", str(inhalt or ""), flags=re.DOTALL).strip()
|
||||
return inhalt[:3000] or None
|
||||
|
||||
|
||||
def nachricht(befund: dict, antwort: str | None, gesammelt: list[dict]) -> tuple[str, list[str]]:
|
||||
"""Lucys Antwort und alles, was sie dabei ausgelöst hat, als eine Nachricht mit den Antwort-Knöpfen."""
|
||||
teile = [antwort or f"„{befund['titel']}“: Ich konnte gerade nicht nachsehen — mein eigener Zugang antwortete "
|
||||
"nicht. Frag mich im Chat, dann versuche ich es dort."]
|
||||
teile += [m["text"] for m in gesammelt]
|
||||
knoepfe = [k for m in gesammelt for k in m.get("knoepfe") or []][-6:]
|
||||
return "\n\n".join(teile), knoepfe
|
||||
|
||||
|
||||
def _untersuchen(befund: dict) -> None:
|
||||
with _lock:
|
||||
log.info("ermittlung: %s:%s beginnt", befund["bereich"], befund["id"])
|
||||
with rueckfragen.gesammelt() as gesammelt:
|
||||
antwort = _frag_lucy(auftrag(befund))
|
||||
text, knoepfe = nachricht(befund, antwort, gesammelt)
|
||||
rueckfragen.melden(BETREFF, text, knoepfe=knoepfe)
|
||||
log.info("ermittlung: %s:%s fertig (%s)", befund["bereich"], befund["id"], "Antwort" if antwort else "ohne")
|
||||
@@ -99,8 +99,3 @@ def max_ctx_for(params_b: float, quant: str, sys_ram_gb: float) -> int:
|
||||
return max_ctx_in_budget(params_b, quant, max(sys_ram_gb - 4.0, 0) * 0.8)
|
||||
|
||||
|
||||
def recommend_ctx(params_b: float, quant: str, sys_ram_gb: float) -> dict:
|
||||
ctx = max_ctx_for(params_b, quant, sys_ram_gb)
|
||||
k = ctx // 1024
|
||||
return {"ctx": ctx, "k": k,
|
||||
"note": f"Bis ~{k}k Kontext passt komfortabel auf deine Hardware ({round(sys_ram_gb)} GB)."}
|
||||
|
||||
@@ -7,6 +7,7 @@ verstreute, still scheiternde String-Suche durch einen testbaren SSE-Zeilenparse
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
from services.token_stats import increment_tokens
|
||||
@@ -37,12 +38,17 @@ def warn_truncation(model: str, max_tokens: int | None = None) -> None:
|
||||
return
|
||||
_trunc_last[model] = now
|
||||
log.warning("gateway: finish_reason=length bei %s — Antwort am Kontext-/Token-Limit abgerissen", model)
|
||||
# Im eigenen Thread abliefern: announce.add spricht im Gateway-Prozess per HTTP mit MC2 (bis 5 s).
|
||||
# Synchron hätte das den Event-Loop des Gateways und damit jeden LLM-Strom der Box angehalten.
|
||||
threading.Thread(target=_melde_abriss, args=(model,), daemon=True).start()
|
||||
|
||||
|
||||
def _melde_abriss(model: str) -> None:
|
||||
try:
|
||||
from services import announce
|
||||
announce.add(
|
||||
f"Eine Antwort von „{model}“ ist am Token-/Kontext-Limit abgerissen "
|
||||
f"(finish_reason=length). War das ein Nacht-Worker, ist seine Aufgabe zu groß "
|
||||
f"geschnitten — besser in Etappen teilen (eine Etappe = ein Worker-Lauf).",
|
||||
f"Eine Antwort von „{model}“ ist am Token- oder Kontext-Limit abgerissen "
|
||||
f"(finish_reason=length). Meist war die Aufgabe zu groß für einen Durchgang.",
|
||||
"[Kontext-Limit]", "gateway", "silent")
|
||||
except Exception:
|
||||
log.warning("gateway: Kontext-Limit-Warnung nicht zustellbar", exc_info=True)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Geheimnis-Ablage auf der Box (v3-Umbau P1).
|
||||
|
||||
WARUM ES DAS GIBT: Bis zum 28.08.2026 lagen das Box-Sudo-Passwort und der
|
||||
HuggingFace-Token im `localStorage` des Browsers und reisten bei jedem mutierenden
|
||||
Request mit (Header `X-Sudo-Password` **und** im JSON-Rumpf). Da der Dienst-Nutzer
|
||||
laut `/etc/sudoers` mit `NOPASSWD: ALL` läuft, wäre ein einziger XSS in der SPA
|
||||
gleichbedeutend mit Root auf der Box gewesen.
|
||||
|
||||
Das Sudo-Passwort ist ersatzlos entfallen — auf der Box gemessen: `sudo -n true`
|
||||
läuft durch, es wurde also nie gebraucht. Bleibt der HF-Token; der liegt jetzt hier:
|
||||
eine Datei neben den anderen `mc2-*.json` unter MODELS_DIR, Rechte 0600, und er wird
|
||||
**nie** an den Browser zurückgegeben. Die Oberfläche erfährt nur, OB einer gesetzt ist.
|
||||
|
||||
Absichtlich kein Verschlüsseln: Der Schlüssel müsste auf derselben Maschine liegen und
|
||||
wäre damit Theater. Der Gewinn ist, dass das Geheimnis den Browser gar nicht erst
|
||||
erreicht — nicht, dass die Datei unlesbar wäre.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from config import MODELS_DIR
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
PFAD = Path(os.environ.get("MC_GEHEIMNISSE_PFAD", str(MODELS_DIR / "mc2-geheimnisse.json")))
|
||||
|
||||
# Was hier abgelegt werden darf. Neue Schlüssel bewusst eintragen — so kann ein
|
||||
# fehlgeleiteter Request keine beliebigen Felder in die Datei schreiben.
|
||||
ERLAUBT = frozenset({"hf_token"})
|
||||
|
||||
|
||||
def _lesen() -> dict[str, str]:
|
||||
"""Ganze Ablage. Fehlt die Datei (frische Box, Windows-Entwicklungsrechner ohne
|
||||
/srv/models), ist das kein Fehler, sondern schlicht 'nichts gesetzt'."""
|
||||
try:
|
||||
daten = json.loads(PFAD.read_text(encoding="utf-8"))
|
||||
return {k: v for k, v in daten.items() if k in ERLAUBT and isinstance(v, str)}
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _schreiben(daten: dict[str, str]) -> bool:
|
||||
"""Atomar über eine Nachbardatei, damit ein Absturz mittendrin keine halbe Datei
|
||||
hinterlässt. Rechte 0600 werden VOR dem Umbenennen gesetzt — sonst gäbe es ein
|
||||
Zeitfenster, in dem das Geheimnis world-readable auf der Platte liegt."""
|
||||
try:
|
||||
PFAD.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = PFAD.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(daten, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
try:
|
||||
os.chmod(tmp, 0o600)
|
||||
except OSError:
|
||||
pass # Windows kennt keine Unix-Rechte — lokal harmlos, auf der Box greift es
|
||||
tmp.replace(PFAD)
|
||||
return True
|
||||
except OSError as exc:
|
||||
log.warning("Geheimnis-Ablage nicht schreibbar (%s): %s", PFAD, exc)
|
||||
return False
|
||||
|
||||
|
||||
def hf_token() -> str | None:
|
||||
"""Der HF-Token für Modell-Downloads. Reihenfolge: Prozess-Env schlägt Datei —
|
||||
so kann die systemd-Unit ihn setzen, ohne dass jemand die Oberfläche anfassen muss."""
|
||||
return os.environ.get("HF_TOKEN") or _lesen().get("hf_token") or None
|
||||
|
||||
|
||||
def setzen(schluessel: str, wert: str | None) -> bool:
|
||||
"""Setzt oder löscht (wert=None oder leer) ein Geheimnis."""
|
||||
if schluessel not in ERLAUBT:
|
||||
return False
|
||||
daten = _lesen()
|
||||
if wert:
|
||||
daten[schluessel] = wert
|
||||
else:
|
||||
daten.pop(schluessel, None)
|
||||
return _schreiben(daten)
|
||||
|
||||
|
||||
def status() -> dict[str, bool]:
|
||||
"""Was die Oberfläche erfahren darf: nur, OB etwas gesetzt ist — nie der Wert.
|
||||
`aus_env` sagt dem Nutzer, warum ein Löschen in der Oberfläche wirkungslos bliebe."""
|
||||
daten = _lesen()
|
||||
return {
|
||||
"hf_token_gesetzt": bool(daten.get("hf_token") or os.environ.get("HF_TOKEN")),
|
||||
"hf_token_aus_env": bool(os.environ.get("HF_TOKEN")),
|
||||
"schreibbar": _schreibbar(),
|
||||
}
|
||||
|
||||
|
||||
def _schreibbar() -> bool:
|
||||
"""Ehrlich melden, wenn die Ablage nicht beschreibbar ist (z. B. lokal auf Windows
|
||||
ohne /srv/models) — sonst speichert die Oberfläche scheinbar erfolgreich ins Leere."""
|
||||
try:
|
||||
PFAD.parent.mkdir(parents=True, exist_ok=True)
|
||||
return os.access(PFAD.parent, os.W_OK)
|
||||
except OSError:
|
||||
return False
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Herkunftsprüfung für Knöpfe (24.09.2026, User-Entscheid: keine Anmeldung).
|
||||
|
||||
Knöpfe schicken POST/PUT/DELETE an /api. Ein Browser setzt dabei den Origin-Header — kommt er von
|
||||
einer fremden Webseite, wird die Anfrage abgelehnt. Das verhindert, dass eine fremde Seite im
|
||||
Browser eines Heimnetz-Geräts heimlich Updates, Neustarts oder Löschen auslöst.
|
||||
|
||||
Unverändert erlaubt: Anfragen ohne Origin (Skripte, curl, Lucy-Watchdog), Origin „null“/„file://“
|
||||
(Desktop-Lucy aus Electron) und alles unter /v1 (OpenChamber, Hermes).
|
||||
"""
|
||||
|
||||
ENTWICKLUNG = {"localhost:5173", "127.0.0.1:5173", "localhost:5180", "localhost:5181"}
|
||||
SCHREIBEND = {"POST", "PUT", "PATCH", "DELETE"}
|
||||
|
||||
|
||||
def erlaubt(methode: str, pfad: str, origin: str | None, host: str | None) -> bool:
|
||||
if methode.upper() not in SCHREIBEND or not pfad.startswith("/api/"):
|
||||
return True
|
||||
if not origin or origin in ("null", "file://"):
|
||||
return True
|
||||
wirt = origin.split("://", 1)[-1].rstrip("/")
|
||||
return wirt == (host or "") or wirt in ENTWICKLUNG
|
||||
+25
-15
@@ -17,10 +17,12 @@ def normalize_repo(s: str) -> str:
|
||||
return s.strip("/")
|
||||
|
||||
|
||||
def list_quants(repo: str) -> list[str]:
|
||||
"""Verfügbare Quant-Stufen eines Repos (aus den GGUF-Dateinamen, ohne mmproj)."""
|
||||
def list_quants(repo: str) -> list[dict]:
|
||||
"""Verfügbare Quant-Stufen eines Repos mit Downloadgröße (alle Teile + mmproj), ohne mmproj
|
||||
als eigene Stufe. Ein Aufruf der Hugging-Face-API für alle Stufen."""
|
||||
baum = _tree(repo)
|
||||
quants: set[str] = set()
|
||||
for e in _tree(repo):
|
||||
for e in baum:
|
||||
p = str(e.get("path", ""))
|
||||
if p.lower().endswith(".gguf") and "mmproj" not in p.lower():
|
||||
m = re.search(r"(I?Q\d[\w]*|F16|BF16|FP16|F32)", p, re.IGNORECASE)
|
||||
@@ -28,18 +30,18 @@ def list_quants(repo: str) -> list[str]:
|
||||
quants.add(m.group(1).upper())
|
||||
# gängige Reihenfolge zuerst
|
||||
order = {"Q4_K_M": 0, "Q4_K_S": 1, "Q5_K_M": 2, "Q6_K": 3, "Q8_0": 4, "Q3_K_M": 5, "Q2_K": 6}
|
||||
return sorted(quants, key=lambda q: (order.get(q, 99), q))
|
||||
return [{"quant": q, "total_bytes": auswahl(baum, q)["total_bytes"]}
|
||||
for q in sorted(quants, key=lambda q: (order.get(q, 99), q))]
|
||||
|
||||
|
||||
def search(q: str = "", limit: int = 24) -> list[dict]:
|
||||
"""Freie HF-Suche nach GGUF-Repos. Ohne q → Top-GGUF nach Downloads (Stöbern)."""
|
||||
url = (f"https://huggingface.co/api/models?filter=gguf"
|
||||
f"&sort=downloads&direction=-1&limit={limit}")
|
||||
params: dict[str, str | int] = {"filter": "gguf", "sort": "downloads", "direction": -1, "limit": limit}
|
||||
if q and q.strip():
|
||||
url += f"&search={q.strip()}"
|
||||
params["search"] = q.strip() # von httpx kodiert (vorher roh an die URL gehängt)
|
||||
try:
|
||||
with httpx.Client(timeout=12.0) as c:
|
||||
data = c.get(url).json()
|
||||
data = c.get("https://huggingface.co/api/models", params=params).json()
|
||||
except Exception:
|
||||
return []
|
||||
out = []
|
||||
@@ -69,26 +71,34 @@ def _size(entry: dict) -> int:
|
||||
|
||||
|
||||
def resolve_gguf(repo: str, quant: str = "Q4_K_M") -> dict:
|
||||
"""Beste GGUF-Auswahl eines Repos für einen Quant. Behandelt Split-GGUFs
|
||||
"""Beste GGUF-Auswahl eines Repos für einen Quant (siehe auswahl)."""
|
||||
return auswahl(_tree(repo), quant)
|
||||
|
||||
|
||||
def auswahl(tree: list[dict], quant: str = "Q4_K_M") -> dict:
|
||||
"""GGUF-Auswahl aus dem Dateibaum eines Repos für einen Quant. Behandelt Split-GGUFs
|
||||
(-00001-of-000NN) als Gruppe. Liefert die Datei-/Pattern-Infos für den Download.
|
||||
|
||||
Rückgabe: {files:[paths], first:path, total_bytes:int, mmproj:path|None, split:bool}
|
||||
"""
|
||||
tree = _tree(repo)
|
||||
ggufs = [e for e in tree if str(e.get("path", "")).lower().endswith(".gguf")]
|
||||
q = quant.lower()
|
||||
# mmproj separat (Vision-Projektor)
|
||||
mmproj = next((e["path"] for e in ggufs if "mmproj" in e["path"].lower()), None)
|
||||
model = [e for e in ggufs if "mmproj" not in e["path"].lower()]
|
||||
# bevorzugt den gewünschten Quant
|
||||
pref = [e for e in model if q in e["path"].lower()]
|
||||
chosen = pref or model
|
||||
# Nur der gewünschte Quant. Bis 24.09.2026 fiel die Auswahl sonst auf ALLE Modelldateien zurück —
|
||||
# bei aufgeteilten Repos lud der Download dann alle Teile aller Varianten (hunderte GB).
|
||||
chosen = [e for e in model if q in e["path"].lower()]
|
||||
if not chosen:
|
||||
return {"files": [], "first": None, "total_bytes": 0, "mmproj": mmproj, "split": False}
|
||||
# Split? Wenn die gewählten Dateien -of- enthalten → alle Teile dieser Gruppe.
|
||||
# Split? Wenn die gewählten Dateien -of- enthalten → alle Teile EINER Gruppe (gleicher Präfix).
|
||||
split = any("-of-" in e["path"].lower() for e in chosen)
|
||||
if split:
|
||||
parts = sorted([e for e in chosen if "-of-" in e["path"].lower()], key=lambda e: e["path"])
|
||||
def _gruppe(pfad: str) -> str:
|
||||
return re.sub(r"-\d{5}-of-\d{5}\.gguf$", "", pfad, flags=re.IGNORECASE)
|
||||
erste = min(e["path"] for e in chosen if "-of-" in e["path"].lower())
|
||||
parts = sorted([e for e in chosen if "-of-" in e["path"].lower() and _gruppe(e["path"]) == _gruppe(erste)],
|
||||
key=lambda e: e["path"])
|
||||
files = [e["path"] for e in parts]
|
||||
first = files[0]
|
||||
total = sum(_size(e) for e in parts)
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Der Homelab-Teil (Rolle homelab, Phase 3/4): Proxmox-Host, Container und Arcane.
|
||||
|
||||
apps.py was in welchem Container steckt, wo seine Oberfläche liegt, woher die neueste Version kommt
|
||||
kanal.py Aufträge und Berichte des Ausführers auf dem Proxmox-Host (gemeinsames Geheimnis)
|
||||
inventar.py Bericht + neueste Versionen + Erreichbarkeit → Ziele im gemeinsamen Modell (kern/ziele.py)
|
||||
karenz.py Wartezeit nach einer Änderung am Update-Skript (community-scripts, ungepinnt von GitHub)
|
||||
updates.py „Jetzt updaten“: Snapshot bzw. Sicherung → Update → Prüfung → bei Rot zurück, mit Meldung
|
||||
sammellauf.py „Alle aktualisieren“: alle Updates mit Knopf nacheinander, bei Rot Schluss
|
||||
protokoll.py was wann geschah (Aufträge, Läufe, Hinweise, Meldungen, Pflege), ohne Geheimnisse
|
||||
einstellungen.py Arcane-Schlüssel und Docker-echt-Schalter aus der Oberfläche, Stand für „Einstellungen“
|
||||
pflege.py wöchentliches Suchen (Paketlisten der Gäste), im Wächter-Takt des Stewards
|
||||
"""
|
||||
@@ -0,0 +1,108 @@
|
||||
"""AdGuard Home: der Anteil geblockter Anfragen für die Kachel (Ausbauplan Welle 1, seit 25.09.2026).
|
||||
|
||||
Die Statistik (GET /control/stats) gibt AdGuard nur mit Anmeldung heraus. Den Zugang trägt der User selbst unter
|
||||
Einstellungen ein; vor dem Speichern fragt dieser Teil AdGuard damit einmal — lehnt es ab oder antwortet nicht, wird
|
||||
nichts gespeichert. Er liegt in <Datenordner>/adguard.json (0600) und wird bei jedem Zugriff gelesen: ein neuer gilt
|
||||
ohne Neustart. Das Passwort steht nie in einer Antwort, einem Protokoll oder einer Fehlermeldung; den Benutzernamen
|
||||
zeigt die Oberfläche, damit man weiß, welcher hinterlegt ist. Am besten ein eigener Benutzer nur dafür.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from kern.einstellungen import einstellungen
|
||||
|
||||
from services.homelab import apps, kanal
|
||||
|
||||
ZEITLIMIT = httpx.Timeout(10.0, connect=5.0)
|
||||
CACHE_S = 120
|
||||
_lock = threading.Lock()
|
||||
_cache: dict[str, tuple[float, dict | None]] = {}
|
||||
|
||||
|
||||
def _pfad() -> Path:
|
||||
return einstellungen().daten_dir / "adguard.json"
|
||||
|
||||
|
||||
def zugang() -> tuple[str, str] | None:
|
||||
"""(Benutzer, Passwort) oder None."""
|
||||
try:
|
||||
daten = json.loads(_pfad().read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError, UnicodeDecodeError):
|
||||
return None
|
||||
if not isinstance(daten, dict):
|
||||
return None
|
||||
benutzer, passwort = daten.get("benutzer"), daten.get("passwort")
|
||||
return (benutzer, passwort) if isinstance(benutzer, str) and isinstance(passwort, str) and benutzer else None
|
||||
|
||||
|
||||
def zugang_speichern(benutzer: str, passwort: str) -> None:
|
||||
"""Atomar und von Anfang an nur für den Dienst lesbar (0600)."""
|
||||
pfad = _pfad()
|
||||
tmp = pfad.with_name(pfad.name + ".tmp")
|
||||
with _lock:
|
||||
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp.unlink(missing_ok=True)
|
||||
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump({"benutzer": benutzer, "passwort": passwort}, f)
|
||||
os.replace(tmp, pfad)
|
||||
_cache.clear()
|
||||
|
||||
|
||||
def zugang_loeschen() -> bool:
|
||||
with _lock:
|
||||
pfad = _pfad()
|
||||
da = pfad.exists()
|
||||
pfad.unlink(missing_ok=True)
|
||||
_cache.clear()
|
||||
return da
|
||||
|
||||
|
||||
def url() -> str | None:
|
||||
"""Die Oberfläche von AdGuard aus dem Bericht des Ausführers."""
|
||||
for gast in ((kanal.bericht() or {}).get("bericht") or {}).get("gaeste") or []:
|
||||
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
|
||||
if app and app.kennung == "adguard" and (adresse := apps.adresse(app, gast.get("ip"))):
|
||||
return adresse.rstrip("/")
|
||||
return None
|
||||
|
||||
|
||||
def statistik(adresse: str, benutzer: str, passwort: str) -> dict:
|
||||
"""{"ok": True, "anfragen", "geblockt", "anteil"} oder {"ok": False, "status", "art": abgelehnt|http|netz|antwort}.
|
||||
Nichts davon enthält das Passwort."""
|
||||
try:
|
||||
r = httpx.get(f"{adresse}/control/stats", auth=(benutzer, passwort), timeout=ZEITLIMIT)
|
||||
r.raise_for_status()
|
||||
daten = r.json()
|
||||
anfragen, geblockt = int(daten["num_dns_queries"]), int(daten["num_blocked_filtering"])
|
||||
except httpx.HTTPStatusError as exc:
|
||||
status = exc.response.status_code
|
||||
return {"ok": False, "status": status, "art": "abgelehnt" if status in (401, 403) else "http"}
|
||||
except httpx.HTTPError:
|
||||
return {"ok": False, "status": None, "art": "netz"}
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return {"ok": False, "status": None, "art": "antwort"}
|
||||
return {"ok": True, "anfragen": anfragen, "geblockt": geblockt,
|
||||
"anteil": round(geblockt / anfragen * 100, 1) if anfragen else None}
|
||||
|
||||
|
||||
def geblockt() -> dict | None:
|
||||
"""Für die Kachel: die Statistik mit dem hinterlegten Zugang, höchstens alle CACHE_S neu gefragt. None ohne Zugang,
|
||||
ohne AdGuard im Bericht oder wenn AdGuard nicht antwortet."""
|
||||
z, adresse = zugang(), url()
|
||||
if not z or not adresse:
|
||||
return None
|
||||
jetzt = time.time()
|
||||
with _lock:
|
||||
if (eintrag := _cache.get(adresse)) and jetzt - eintrag[0] < CACHE_S:
|
||||
return eintrag[1]
|
||||
ergebnis = statistik(adresse, *z)
|
||||
wert = ergebnis if ergebnis["ok"] else None
|
||||
with _lock:
|
||||
_cache[adresse] = (jetzt, wert)
|
||||
return wert
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Was in welchem Container steckt (Community-Scripts-Kennung aus /usr/bin/update im Gast).
|
||||
|
||||
Stand der Bestandsaufnahme vom 24.09.2026: sechs Container, alle mit dem Etikett community-script.
|
||||
|
||||
Wie eine App aktualisiert wird, ist je Community-Script verschieden (nachgelesen am 24.09. in ct/<app>.sh):
|
||||
skript `update` im Gast spielt die App wirklich neu ein (Gitea: neues Programm von GitHub,
|
||||
NetBird: apt aus dem NetBird-Repository, NPMplus: neues Docker-Image)
|
||||
eigene-oberflaeche das Skript verweist auf den eingebauten Updater der App (AdGuard, PVE Scripts Local)
|
||||
pakete die App kommt als Paket; das Update der Pakete im Gast ist ihr Update (PBS)
|
||||
Nur beim Weg „skript“ bietet die Übersicht „Jetzt updaten“ für die App an.
|
||||
Neue Container mit dem Etikett erscheinen von selbst. Fehlt ihre Kennung hier, liest katalog.py (seit 25.09.2026)
|
||||
Name, GitHub-Projekt, Port und Weg aus ihrem Update-Skript; was hier von Hand steht, geht vor (etwa ein geänderter
|
||||
Port wie bei AdGuard).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class App:
|
||||
kennung: str
|
||||
name: str
|
||||
quelle: str | None = None # GitHub owner/repo für die neueste Version; None = über die Pakete des Gasts
|
||||
vergleich: str = "version" # „version“ oder „datum“ (Docker-Images ohne Versionsnummer)
|
||||
port: int | None = None # Weboberfläche; None = keine (z. B. NetBird-Client)
|
||||
https: bool = False
|
||||
pfad: str = "/"
|
||||
weg: str = "skript" # „skript“, „eigene-oberflaeche“ oder „pakete“ (siehe oben)
|
||||
|
||||
|
||||
KATALOG: dict[str, App] = {a.kennung: a for a in (
|
||||
App("adguard", "AdGuard Home", "AdguardTeam/AdGuardHome", port=8080, weg="eigene-oberflaeche"),
|
||||
App("npmplus", "NPMplus", "ZoeyVid/NPMplus", vergleich="datum", port=81, https=True),
|
||||
App("netbird", "NetBird", "netbirdio/netbird"),
|
||||
App("pve-scripts-local", "PVE Scripts Local", "community-scripts/ProxmoxVE-Local", port=3000,
|
||||
weg="eigene-oberflaeche"),
|
||||
App("gitea", "Gitea", "go-gitea/gitea", port=3000),
|
||||
App("proxmox-backup-server", "Proxmox Backup Server", port=8007, https=True, weg="pakete"),
|
||||
)}
|
||||
|
||||
# Gäste, die keine Community-Scripts sind, erkennt man am Namen.
|
||||
NACH_NAME: dict[str, App] = {
|
||||
"arcane": App("arcane", "Arcane (Docker)", port=3552, weg="eigene-oberflaeche"),
|
||||
}
|
||||
|
||||
|
||||
def app_fuer(kennung: str | None, name: str | None) -> App | None:
|
||||
if kennung and kennung in KATALOG:
|
||||
return KATALOG[kennung]
|
||||
if kennung:
|
||||
from services.homelab import katalog # der Katalog baut Apps aus diesem Modul — erst hier
|
||||
if gefunden := katalog.app(kennung):
|
||||
return gefunden
|
||||
return NACH_NAME.get(str(name or "").lower())
|
||||
|
||||
|
||||
def adresse(app: App | None, ip: str | None) -> str | None:
|
||||
"""Adresse der Weboberfläche, falls es eine gibt und die IP bekannt ist."""
|
||||
if not app or not app.port or not ip:
|
||||
return None
|
||||
return f"{'https' if app.https else 'http'}://{ip}:{app.port}{app.pfad}"
|
||||
@@ -0,0 +1,298 @@
|
||||
"""Arcane und Docker auf der Arcane-VM (Phase 3/4, 24.09.2026).
|
||||
|
||||
Arcane (getarcaneapp/arcane) verwaltet die Docker-Container der VM (NerdQuiz, Rippy …) und hat eine
|
||||
eigene Schnittstelle (OpenAPI unter /api/openapi.json). Öffentlich ist nur die eigene Version
|
||||
(/api/app-version mit currentVersion, newestVersion, updateAvailable). Alles über Images braucht einen
|
||||
API-Schlüssel (Kopfzeile X-API-Key), den man in Arcane anlegt.
|
||||
|
||||
Der Schlüssel (seit 24.09.2026 auch über die Oberfläche): MC_ARCANE_KEY in /etc/mc2/homelab.env geht vor, sonst
|
||||
<Datenordner>/arcane.key (0600, schreibt nur die Oberfläche nach einer Prüfung gegen Arcane, einstellungen.py).
|
||||
Beides wird bei jedem Zugriff gelesen: Ein neuer Schlüssel gilt ohne Neustart. Er steht nie in einer Antwort,
|
||||
einem Protokoll oder einer Fehlermeldung.
|
||||
|
||||
Docker-Updates laufen über Arcanes eigenen Updater (POST /environments/{id}/updater/run). User-Entscheid
|
||||
24.09.: zuerst nur als Probelauf (dryRun); echt erst, wenn der Schalter in den Einstellungen an ist
|
||||
(<Datenordner>/homelab-einstellungen.json). Setzt die Umgebung MC_ARCANE_ECHT, gewinnt sie („1“ = echt).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from kern.einstellungen import einstellungen
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
ZEITLIMIT = httpx.Timeout(20.0, connect=5.0)
|
||||
CACHE_S = 120
|
||||
_cache: dict[str, tuple[float, object]] = {}
|
||||
_lock = threading.Lock()
|
||||
_datei_lock = threading.Lock()
|
||||
|
||||
|
||||
def _gemerkt(schluessel: str, holen):
|
||||
"""Arcane merkt sich den Update-Stand selbst; die Übersicht fragt ihn höchstens alle zwei Minuten ab."""
|
||||
jetzt = time.time()
|
||||
with _lock:
|
||||
if (eintrag := _cache.get(schluessel)) and jetzt - eintrag[0] < CACHE_S:
|
||||
return eintrag[1]
|
||||
wert = holen()
|
||||
with _lock:
|
||||
_cache[schluessel] = (jetzt, wert)
|
||||
return wert
|
||||
|
||||
|
||||
def cache_leeren() -> None:
|
||||
"""Nach einem neuen oder gelöschten Schlüssel: Die Übersicht fragt Arcane gleich neu."""
|
||||
with _lock:
|
||||
_cache.clear()
|
||||
|
||||
|
||||
# --- Schlüssel ----------------------------------------------------------------------------------------
|
||||
|
||||
def _schluessel_pfad() -> Path:
|
||||
return einstellungen().daten_dir / "arcane.key"
|
||||
|
||||
|
||||
def _schluessel_datei() -> str:
|
||||
try:
|
||||
return _schluessel_pfad().read_text(encoding="utf-8").strip()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return ""
|
||||
|
||||
|
||||
def schluessel() -> str:
|
||||
"""Der geltende Schlüssel: MC_ARCANE_KEY, sonst die Datei, sonst leer."""
|
||||
return os.environ.get("MC_ARCANE_KEY", "").strip() or _schluessel_datei()
|
||||
|
||||
|
||||
def schluessel_quelle() -> str:
|
||||
"""„umgebung“ (MC_ARCANE_KEY), „hinterlegt“ (Datei im Datenordner) oder „fehlt“."""
|
||||
if os.environ.get("MC_ARCANE_KEY", "").strip():
|
||||
return "umgebung"
|
||||
return "hinterlegt" if _schluessel_datei() else "fehlt"
|
||||
|
||||
|
||||
def schluessel_speichern(wert: str) -> None:
|
||||
"""Atomar und von Anfang an nur für den Dienst lesbar (0600): erst eine Nachbardatei, dann ersetzen."""
|
||||
pfad = _schluessel_pfad()
|
||||
tmp = pfad.with_name(pfad.name + ".tmp")
|
||||
with _datei_lock:
|
||||
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp.unlink(missing_ok=True)
|
||||
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(wert.strip() + "\n")
|
||||
os.replace(tmp, pfad)
|
||||
cache_leeren()
|
||||
|
||||
|
||||
def schluessel_loeschen() -> bool:
|
||||
"""Die Datei weg. True, wenn es eine gab."""
|
||||
with _datei_lock:
|
||||
pfad = _schluessel_pfad()
|
||||
da = pfad.exists()
|
||||
pfad.unlink(missing_ok=True)
|
||||
cache_leeren()
|
||||
return da
|
||||
|
||||
|
||||
# --- Echt statt Probelauf -----------------------------------------------------------------------------
|
||||
|
||||
def _einstellungen_pfad() -> Path:
|
||||
return einstellungen().daten_dir / "homelab-einstellungen.json"
|
||||
|
||||
|
||||
def _einstellungen_lesen() -> dict:
|
||||
try:
|
||||
daten = json.loads(_einstellungen_pfad().read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return daten if isinstance(daten, dict) else {}
|
||||
|
||||
|
||||
def _echt_eingestellt() -> bool | None:
|
||||
arcane = _einstellungen_lesen().get("arcane")
|
||||
wert = arcane.get("echt") if isinstance(arcane, dict) else None
|
||||
return wert if isinstance(wert, bool) else None
|
||||
|
||||
|
||||
def _echt_umgebung() -> str:
|
||||
return os.environ.get("MC_ARCANE_ECHT", "").strip()
|
||||
|
||||
|
||||
def echt() -> bool:
|
||||
"""Echte Docker-Updates statt Probelauf? Die Umgebung (MC_ARCANE_ECHT) gewinnt, dann der Schalter, sonst nein."""
|
||||
if umgebung := _echt_umgebung():
|
||||
return umgebung == "1"
|
||||
return bool(_echt_eingestellt())
|
||||
|
||||
|
||||
def echt_quelle() -> str:
|
||||
"""„umgebung“, „einstellung“ oder „standard“ (Probelauf)."""
|
||||
if _echt_umgebung():
|
||||
return "umgebung"
|
||||
return "einstellung" if _echt_eingestellt() is not None else "standard"
|
||||
|
||||
|
||||
def echt_speichern(an: bool) -> None:
|
||||
pfad = _einstellungen_pfad()
|
||||
with _datei_lock:
|
||||
daten = _einstellungen_lesen()
|
||||
arcane = daten.get("arcane") if isinstance(daten.get("arcane"), dict) else {}
|
||||
daten.update(arcane={**arcane, "echt": bool(an)},
|
||||
geaendert=datetime.now(LOCAL_TZ).isoformat(timespec="seconds"))
|
||||
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = pfad.with_name(pfad.name + ".tmp")
|
||||
tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
os.replace(tmp, pfad)
|
||||
|
||||
|
||||
# --- Schnittstelle ------------------------------------------------------------------------------------
|
||||
|
||||
def app_version(url: str) -> dict | None:
|
||||
"""Arcanes eigene Version (öffentlich): {"installiert", "neu", "update"} oder None."""
|
||||
return _gemerkt(f"version:{url}", lambda: _app_version(url))
|
||||
|
||||
|
||||
def _app_version(url: str) -> dict | None:
|
||||
try:
|
||||
d = httpx.get(f"{url}/api/app-version", timeout=ZEITLIMIT).json()
|
||||
except (httpx.HTTPError, ValueError):
|
||||
return None
|
||||
if not isinstance(d, dict) or not d.get("currentVersion"):
|
||||
return None
|
||||
return {"installiert": str(d["currentVersion"]).lstrip("v"), "neu": str(d.get("newestVersion") or "").lstrip("v") or None,
|
||||
"update": bool(d.get("updateAvailable"))}
|
||||
|
||||
|
||||
def _get(url: str, pfad: str, mit_schluessel: str | None = None) -> object:
|
||||
r = httpx.get(f"{url}/api{pfad}", headers={"X-API-Key": mit_schluessel or schluessel()}, timeout=ZEITLIMIT)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def umgebungen(url: str, mit_schluessel: str | None = None) -> list[dict]:
|
||||
daten = _get(url, "/environments?limit=50", mit_schluessel)
|
||||
return [e for e in (daten or {}).get("data") or [] if isinstance(e, dict) and e.get("id") is not None]
|
||||
|
||||
|
||||
def images_mit_update(url: str) -> list[dict]:
|
||||
"""Images mit einem neueren Stand in der Registry, über alle Umgebungen: {"name", "aktuell", "neu", "benutzt"}."""
|
||||
return _gemerkt(f"images:{url}", lambda: _images_mit_update(url))
|
||||
|
||||
|
||||
def _images_mit_update(url: str) -> list[dict]:
|
||||
ergebnis = []
|
||||
for umgebung in umgebungen(url):
|
||||
daten = _get(url, f"/environments/{umgebung['id']}/images?limit=500")
|
||||
for image in (daten or {}).get("data") or []:
|
||||
info = image.get("updateInfo") or {}
|
||||
if not info.get("hasUpdate"):
|
||||
continue
|
||||
name = f"{image.get('repo') or '?'}:{image.get('tag') or 'latest'}"
|
||||
ergebnis.append({"name": name, "aktuell": info.get("currentVersion") or None,
|
||||
"neu": info.get("latestVersion") or None, "umgebung": umgebung["id"],
|
||||
"benutzt": [str(u.get("name") if isinstance(u, dict) else u) for u in image.get("usedBy") or []]})
|
||||
return ergebnis
|
||||
|
||||
|
||||
def container_liste(url: str) -> list[dict] | None:
|
||||
"""Alle Container über alle Umgebungen, wie Arcane sie liefert (Docker-Dienste einzeln, seit 25.09.2026). None
|
||||
ohne Schlüssel oder wenn Arcane nicht antwortet."""
|
||||
if not schluessel():
|
||||
return None
|
||||
return _gemerkt(f"container-liste:{url}", lambda: _container_liste(url))
|
||||
|
||||
|
||||
def _container_liste(url: str) -> list[dict] | None:
|
||||
ergebnis: list[dict] = []
|
||||
try:
|
||||
for umgebung in umgebungen(url):
|
||||
daten = _get(url, f"/environments/{umgebung['id']}/containers?limit=500")
|
||||
ergebnis += [c for c in (daten or {}).get("data") or [] if isinstance(c, dict)]
|
||||
except (httpx.HTTPError, ValueError, TypeError, AttributeError):
|
||||
return None
|
||||
return ergebnis
|
||||
|
||||
|
||||
def luecken(url: str, image_ids: tuple[str, ...]) -> dict[str, dict]:
|
||||
"""Arcanes Lücken-Scan (Trivy) je Image: {imageId: {"kritisch", "hoch", "mittel", "gesamt", "zeit"}} — nur für
|
||||
gescannte Images. Leer ohne Schlüssel, ohne Scan oder wenn Arcane nicht antwortet (seit 25.09.2026)."""
|
||||
if not schluessel() or not image_ids:
|
||||
return {}
|
||||
return _gemerkt(f"luecken:{url}:{','.join(image_ids)}", lambda: _luecken(url, list(image_ids))) or {}
|
||||
|
||||
|
||||
def _luecken(url: str, image_ids: list[str]) -> dict[str, dict]:
|
||||
ergebnis: dict[str, dict] = {}
|
||||
try:
|
||||
for umgebung in umgebungen(url):
|
||||
r = httpx.post(f"{url}/api/environments/{umgebung['id']}/images/vulnerabilities/summaries",
|
||||
headers={"X-API-Key": schluessel()}, json={"imageIds": image_ids}, timeout=ZEITLIMIT)
|
||||
r.raise_for_status()
|
||||
for image_id, s in (((r.json() or {}).get("data") or {}).get("summaries") or {}).items():
|
||||
zahlen = (s or {}).get("summary") or {}
|
||||
if s and s.get("scanTime") and zahlen:
|
||||
ergebnis[image_id] = {"kritisch": int(zahlen.get("critical") or 0), "hoch": int(zahlen.get("high") or 0),
|
||||
"mittel": int(zahlen.get("medium") or 0), "gesamt": int(zahlen.get("total") or 0),
|
||||
"zeit": s.get("scanTime")}
|
||||
except (httpx.HTTPError, ValueError, TypeError, AttributeError):
|
||||
return {}
|
||||
return ergebnis
|
||||
|
||||
|
||||
def container_zahlen(url: str) -> dict | None:
|
||||
"""Laufende und alle Container über alle Umgebungen, für die Kachel (seit 25.09.2026): {"laufend", "gesamt"}.
|
||||
None ohne Schlüssel oder wenn Arcane nicht antwortet."""
|
||||
if not schluessel():
|
||||
return None
|
||||
return _gemerkt(f"container:{url}", lambda: _container_zahlen(url))
|
||||
|
||||
|
||||
def _container_zahlen(url: str) -> dict | None:
|
||||
laufend = gesamt = 0
|
||||
try:
|
||||
for umgebung in umgebungen(url):
|
||||
daten = (_get(url, f"/environments/{umgebung['id']}/containers/counts") or {}).get("data") or {}
|
||||
laufend += int(daten.get("runningContainers") or 0)
|
||||
gesamt += int(daten.get("totalContainers") or 0)
|
||||
except (httpx.HTTPError, ValueError, TypeError, AttributeError):
|
||||
return None
|
||||
return {"laufend": laufend, "gesamt": gesamt}
|
||||
|
||||
|
||||
def schluessel_pruefen(url: str, wert: str) -> dict:
|
||||
"""Nimmt Arcane diesen Schlüssel an? Geprüft wird mit denselben Aufrufen, die die Übersicht braucht: die
|
||||
Umgebungen, dann ihre Images. {"ok": True, "umgebungen", "images", "mit_update"} oder {"ok": False, "status":
|
||||
HTTP-Code bzw. None, "art": "abgelehnt" | "http" | "netz" | "antwort"}. Nichts davon enthält den Schlüssel."""
|
||||
try:
|
||||
liste = umgebungen(url, wert)
|
||||
images = mit_update = 0
|
||||
for umgebung in liste:
|
||||
daten = _get(url, f"/environments/{umgebung['id']}/images?limit=500", wert)
|
||||
eintraege = [i for i in (daten or {}).get("data") or [] if isinstance(i, dict)]
|
||||
images += len(eintraege)
|
||||
mit_update += sum(1 for i in eintraege if (i.get("updateInfo") or {}).get("hasUpdate"))
|
||||
except httpx.HTTPStatusError as exc:
|
||||
status = exc.response.status_code
|
||||
return {"ok": False, "status": status, "art": "abgelehnt" if status in (401, 403) else "http"}
|
||||
except httpx.HTTPError:
|
||||
return {"ok": False, "status": None, "art": "netz"}
|
||||
except (ValueError, AttributeError, TypeError):
|
||||
return {"ok": False, "status": None, "art": "antwort"}
|
||||
return {"ok": True, "umgebungen": len(liste), "images": images, "mit_update": mit_update}
|
||||
|
||||
|
||||
def updater(url: str, probelauf: bool) -> dict:
|
||||
"""Arcanes Updater für alle Umgebungen. Rückgabe: je Umgebung das Ergebnis (geprüft, aktualisiert, Fehler)."""
|
||||
ergebnisse = {}
|
||||
for umgebung in umgebungen(url):
|
||||
r = httpx.post(f"{url}/api/environments/{umgebung['id']}/updater/run", headers={"X-API-Key": schluessel()},
|
||||
json={"dryRun": probelauf}, timeout=httpx.Timeout(600.0, connect=5.0))
|
||||
r.raise_for_status()
|
||||
ergebnisse[str(umgebung.get("name") or umgebung["id"])] = (r.json() or {}).get("data") or {}
|
||||
return ergebnisse
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Docker-Dienste unter Arcane einzeln (Ausbauplan Welle 2, Punkt 8, seit 25.09.2026).
|
||||
|
||||
Bis zum 25.09. stand im Orchestrator nur „Arcane (Docker) · 11 Images“. Jetzt fragt er Arcane nach jedem Container
|
||||
(GET /environments/{id}/containers, mit dem Schlüssel aus den Einstellungen, höchstens alle zwei Minuten) und zeigt je
|
||||
Stack (Compose-Projekt) Zustand, Gesundheit, Image und Update-Stand:
|
||||
• „Update verfügbar“, wenn Arcane für das Image einen neueren Stand kennt,
|
||||
• „lokal gebaut“, wenn das Image aus keiner Registry kommt (Arcane: updateType „local“, oder die Registry kennt es
|
||||
nicht — NerdQuiz und Rippy baut der User selbst) — dort gibt es keinen Abgleich,
|
||||
• sonst „aktuell“.
|
||||
Seit 25.09.2026 (Punkt 11) steht je Container auch, was Arcanes Lücken-Scan (Trivy, sonntags 02:00) im Image gefunden
|
||||
hat — kritische und hohe zuerst; ungescannte Images haben keine Angabe.
|
||||
Der Wächter meldet gelb, wenn ein Container ungesund ist (Healthcheck) oder immer wieder neu startet; beendete
|
||||
Container (etwa einmalige Aufgaben) sind kein Befund. Neue Stacks erscheinen unter „Neu entdeckt“ (entdecken.py).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from services.homelab import apps, arcane, einstellungen, kanal
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_GESUND = re.compile(r"\((healthy|unhealthy|health: starting)\)")
|
||||
_LOKAL = re.compile(r"distribution inspect|manifest unknown|pull access denied|repository does not exist", re.IGNORECASE)
|
||||
|
||||
|
||||
def container_zeile(c: dict) -> dict:
|
||||
"""Ein Container aus Arcanes Liste in der Form der Oberfläche."""
|
||||
labels = c.get("labels") if isinstance(c.get("labels"), dict) else {}
|
||||
info = c.get("updateInfo") if isinstance(c.get("updateInfo"), dict) else {}
|
||||
gesund = _GESUND.search(str(c.get("status") or ""))
|
||||
fehler = str(info.get("error") or "")
|
||||
if info.get("hasUpdate"):
|
||||
update = "neu"
|
||||
elif info.get("updateType") == "local" or _LOKAL.search(fehler):
|
||||
update = "lokal"
|
||||
elif fehler:
|
||||
update = "unbekannt"
|
||||
else:
|
||||
update = "aktuell"
|
||||
name = str((c.get("names") or ["?"])[0]).lstrip("/")
|
||||
return {"id": str(c.get("id") or name)[:12], "image_id": c.get("imageId"), "luecken": None,
|
||||
"name": name, "stack": labels.get("com.docker.compose.project"),
|
||||
"dienst": labels.get("com.docker.compose.service"), "image": c.get("image"),
|
||||
"zustand": str(c.get("state") or "unbekannt"), "status": c.get("status"),
|
||||
"gesund": {"healthy": True, "unhealthy": False}.get(gesund.group(1)) if gesund else None,
|
||||
"update": update, "aktuell": info.get("currentVersion") or None, "neu": info.get("latestVersion") or None}
|
||||
|
||||
|
||||
def lage() -> dict:
|
||||
"""Für GET /api/homelab/docker: {"verfuegbar", "grund", "stacks": [{"name", "container": […]}]}."""
|
||||
url = einstellungen.arcane_url()
|
||||
if not url:
|
||||
return {"verfuegbar": False, "grund": "Arcane steht nicht im Bericht des Ausführers.", "stacks": []}
|
||||
if not arcane.schluessel():
|
||||
return {"verfuegbar": False, "grund": "Es fehlt der Arcane-API-Schlüssel (Einstellungen → Homelab).", "stacks": []}
|
||||
roh = arcane.container_liste(url)
|
||||
if roh is None:
|
||||
return {"verfuegbar": False, "grund": "Arcane antwortet gerade nicht.", "stacks": []}
|
||||
stacks: dict[str, list[dict]] = {}
|
||||
zeilen = [container_zeile(c) for c in roh]
|
||||
befunde = arcane.luecken(url, tuple(sorted({z["image_id"] for z in zeilen if z["image_id"]})))
|
||||
for zeile in zeilen:
|
||||
zeile["luecken"] = befunde.get(zeile["image_id"] or "")
|
||||
stacks.setdefault(zeile["stack"] or zeile["name"], []).append(zeile)
|
||||
return {"verfuegbar": True, "grund": None,
|
||||
"stacks": [{"name": n, "container": sorted(cs, key=lambda z: z["name"])} for n, cs in sorted(stacks.items())]}
|
||||
|
||||
|
||||
def arcane_ziel() -> str:
|
||||
"""Die Ziel-ID der Arcane-VM (für die Hinweise), aus dem Bericht; sonst „arcane“."""
|
||||
for g in ((kanal.bericht() or {}).get("bericht") or {}).get("gaeste") or []:
|
||||
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
|
||||
if app and app.kennung == "arcane":
|
||||
return f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g['vmid']}"
|
||||
return "arcane"
|
||||
|
||||
|
||||
def pruefe_docker() -> list:
|
||||
"""Wächter: gelb, wenn ein Container ungesund ist oder ständig neu startet. Ohne Arcane oder Schlüssel: nichts."""
|
||||
from services.waechter import Befund # der Wächter registriert diese Prüfung, also erst hier
|
||||
befunde = []
|
||||
quelle = None
|
||||
for s in lage()["stacks"]:
|
||||
for c in s["container"]:
|
||||
if c["gesund"] is False:
|
||||
titel, text = (f"Docker-Dienst {c['name']} ist ungesund",
|
||||
f"Sein Healthcheck schlägt fehl ({c['status']}). In Arcane: Container → {c['name']} → Logs.")
|
||||
elif c["zustand"] == "restarting":
|
||||
titel, text = (f"Docker-Dienst {c['name']} startet immer wieder neu",
|
||||
f"Er kommt nicht hoch ({c['status']}). In Arcane: Container → {c['name']} → Logs.")
|
||||
else:
|
||||
continue
|
||||
quelle = quelle or arcane_ziel()
|
||||
befunde.append(Befund(id=f"docker:{c['name']}", stufe="gelb", titel=titel, text=text, quelle=quelle))
|
||||
return befunde
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Einstellungen des Homelab-Teils (24.09.2026): was sich unter „Einstellungen“ für das Homelab einstellen lässt.
|
||||
|
||||
• Arcane-API-Schlüssel eintragen oder löschen. Vor dem Speichern prüft Arcane ihn mit denselben Aufrufen, die die
|
||||
Übersicht braucht; lehnt es ab oder antwortet nicht, wird nichts gespeichert. Er liegt in
|
||||
<Datenordner>/arcane.key (0600); MC_ARCANE_KEY in /etc/mc2/homelab.env geht vor (arcane.py).
|
||||
• Docker echt echte Docker-Updates über Arcane statt Probelauf (<Datenordner>/homelab-einstellungen.json);
|
||||
MC_ARCANE_ECHT in der Umgebung gewinnt.
|
||||
• AdGuard-Zugang (seit 25.09.2026) Benutzer und Passwort für die Statistik („% geblockt“ auf der Kachel). Vor
|
||||
dem Speichern fragt AdGuard einmal damit; lehnt es ab, wird nichts gespeichert (adguard.py).
|
||||
stand() zeigt dazu die Wartezeit nach Skriptänderungen (karenz.py) und den Ausführer (verbunden, Nur-Lesen,
|
||||
Sicherungsspeicher). Der Schlüssel verlässt diesen Teil nie: Keine Antwort, kein Protokolleintrag und keine
|
||||
Fehlermeldung enthält ihn, auch kein Stück davon.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
from services.homelab import adguard, apps, arcane, inventar, kanal, karenz
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
UMGEBUNG_SCHLUESSEL = ("Der Arcane-Schlüssel kommt aus der Umgebung (MC_ARCANE_KEY in /etc/mc2/homelab.env) und "
|
||||
"gilt vor einem hier eingetragenen. Erst dort entfernen, dann hier eintragen.")
|
||||
_FORM = re.compile(r"[\x21-\x7e]{8,512}") # druckbares ASCII ohne Leerzeichen, wie ihn Arcane ausgibt
|
||||
|
||||
|
||||
def arcane_url() -> str | None:
|
||||
"""Adresse der Arcane-Oberfläche aus dem Bericht des Ausführers (die VM, deren Name „arcane“ ist)."""
|
||||
eingang = kanal.bericht()
|
||||
for gast in ((eingang or {}).get("bericht") or {}).get("gaeste") or []:
|
||||
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
|
||||
if app and app.kennung == "arcane" and (url := apps.adresse(app, gast.get("ip"))):
|
||||
return url.rstrip("/")
|
||||
return None
|
||||
|
||||
|
||||
def stand() -> dict:
|
||||
"""GET /api/homelab/einstellungen."""
|
||||
zuletzt = kanal.zuletzt()
|
||||
host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}
|
||||
sicherung = host.get("sicherung") if isinstance(host.get("sicherung"), dict) else {}
|
||||
return {
|
||||
"arcane": {"url": arcane_url() or "", "schluessel": arcane.schluessel_quelle(), "echt": arcane.echt(),
|
||||
"echt_quelle": arcane.echt_quelle()},
|
||||
"adguard": {"url": adguard.url() or "", "zugang": "hinterlegt" if (z := adguard.zugang()) else "fehlt",
|
||||
"benutzer": z[0] if z else None},
|
||||
"karenz_h": round(karenz.karenz_s() / 3600),
|
||||
"ausfuehrer": {"verbunden": bool(zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S),
|
||||
"zuletzt": zuletzt, "nur_lesen": bool(host.get("nur_lesen")),
|
||||
"sicherung_speicher": sicherung.get("speicher") or None},
|
||||
}
|
||||
|
||||
|
||||
def schluessel_aus_body(roh: bytes) -> str | None:
|
||||
"""Der Schlüssel aus dem Body {"schluessel": "…"} — ohne Pydantic, dessen Fehlermeldung (422) die Eingabe
|
||||
wiederholen würde. None, wenn der Body nicht passt."""
|
||||
try:
|
||||
daten = json.loads(roh.decode("utf-8")) if roh else None
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
return None
|
||||
wert = daten.get("schluessel") if isinstance(daten, dict) else None
|
||||
return wert if isinstance(wert, str) else None
|
||||
|
||||
|
||||
def _abgelehnt(ergebnis: dict, url: str) -> str:
|
||||
status, art = ergebnis.get("status"), ergebnis.get("art")
|
||||
if art == "abgelehnt":
|
||||
text = f"Arcane lehnt den Schlüssel ab (HTTP {status})."
|
||||
if status == 403:
|
||||
text += " Ihm fehlt das Recht, Umgebungen und Images zu lesen."
|
||||
return text
|
||||
if art == "http":
|
||||
return f"Arcane antwortet mit HTTP {status}. Der Schlüssel ist nicht gespeichert."
|
||||
if art == "netz":
|
||||
return f"Arcane ist nicht erreichbar ({url}). Der Schlüssel ist nicht gespeichert."
|
||||
return "Arcane antwortet nicht wie erwartet. Der Schlüssel ist nicht gespeichert."
|
||||
|
||||
|
||||
def arcane_schluessel_setzen(wert: str | None) -> dict:
|
||||
"""POST /api/homelab/einstellungen/arcane-schluessel: erst gegen Arcane prüfen, dann speichern."""
|
||||
if arcane.schluessel_quelle() == "umgebung":
|
||||
return {"ok": False, "detail": UMGEBUNG_SCHLUESSEL}
|
||||
wert = (wert or "").strip()
|
||||
if not _FORM.fullmatch(wert):
|
||||
return {"ok": False, "detail": "Das sieht nicht wie ein Arcane-API-Schlüssel aus (leer, zu kurz oder mit "
|
||||
"Leerzeichen). Nichts gespeichert."}
|
||||
url = arcane_url()
|
||||
if not url:
|
||||
return {"ok": False, "detail": "Arcane steht nicht im Bericht des Ausführers, die Adresse ist unbekannt. "
|
||||
"Nichts gespeichert."}
|
||||
ergebnis = arcane.schluessel_pruefen(url, wert)
|
||||
if not ergebnis["ok"]:
|
||||
log.info("homelab: Arcane-Schlüssel nicht übernommen (%s, HTTP %s)", ergebnis.get("art"), ergebnis.get("status"))
|
||||
return {"ok": False, "detail": _abgelehnt(ergebnis, url)}
|
||||
try:
|
||||
arcane.schluessel_speichern(wert)
|
||||
except OSError as exc:
|
||||
return {"ok": False, "detail": f"Arcane nimmt den Schlüssel an, aber er ließ sich nicht speichern "
|
||||
f"({exc.__class__.__name__})."}
|
||||
images, neu, anzahl = ergebnis["images"], ergebnis["mit_update"], ergebnis["umgebungen"]
|
||||
log.info("homelab: Arcane-Schlüssel gespeichert (%d Images geprüft)", images)
|
||||
return {"ok": True, "text": (f"Arcane nimmt den Schlüssel an: {images} Images in {anzahl} "
|
||||
f"{'Umgebung' if anzahl == 1 else 'Umgebungen'} geprüft, {neu} davon mit neuerem "
|
||||
"Stand. Die Übersicht zeigt die Docker-Images ab sofort.")}
|
||||
|
||||
|
||||
def arcane_schluessel_loeschen() -> dict:
|
||||
"""DELETE /api/homelab/einstellungen/arcane-schluessel: die Datei weg."""
|
||||
try:
|
||||
da = arcane.schluessel_loeschen()
|
||||
except OSError as exc:
|
||||
return {"ok": False, "detail": f"Der Schlüssel ließ sich nicht löschen ({exc.__class__.__name__})."}
|
||||
text = "Der Arcane-Schlüssel ist gelöscht." if da else "Es war kein Arcane-Schlüssel hinterlegt."
|
||||
if arcane.schluessel_quelle() == "umgebung":
|
||||
text += " Die Umgebung setzt weiter einen (MC_ARCANE_KEY); der gilt."
|
||||
return {"ok": True, "text": text}
|
||||
|
||||
|
||||
def arcane_echt_setzen(an: bool) -> dict:
|
||||
"""POST /api/homelab/einstellungen/arcane-echt: echte Docker-Updates statt Probelauf."""
|
||||
if arcane.echt_quelle() == "umgebung":
|
||||
return {"ok": False, "detail": f"Die Umgebung legt das fest (MC_ARCANE_ECHT in /etc/mc2/homelab.env: Docker-"
|
||||
f"Updates laufen {'echt' if arcane.echt() else 'als Probelauf'}). Sie gewinnt "
|
||||
"vor diesem Schalter."}
|
||||
try:
|
||||
arcane.echt_speichern(an)
|
||||
except OSError as exc:
|
||||
return {"ok": False, "detail": f"Der Schalter ließ sich nicht speichern ({exc.__class__.__name__})."}
|
||||
return {"ok": True, "text": ("Docker-Updates über Arcane laufen ab jetzt echt: Arcane holt die neuen Images und "
|
||||
"startet die Container neu." if an else
|
||||
"Docker-Updates über Arcane laufen wieder nur als Probelauf; geändert wird nichts.")}
|
||||
|
||||
|
||||
# --- AdGuard-Zugang (seit 25.09.2026) --------------------------------------------------------------------------------
|
||||
|
||||
def zugang_aus_body(roh: bytes) -> tuple[str, str] | None:
|
||||
"""(Benutzer, Passwort) aus dem Body {"benutzer", "passwort"} — ohne Pydantic, dessen 422 die Eingabe wiederholte."""
|
||||
try:
|
||||
daten = json.loads(roh.decode("utf-8")) if roh else None
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
return None
|
||||
if not isinstance(daten, dict):
|
||||
return None
|
||||
benutzer, passwort = daten.get("benutzer"), daten.get("passwort")
|
||||
return (benutzer, passwort) if isinstance(benutzer, str) and isinstance(passwort, str) else None
|
||||
|
||||
|
||||
def _adguard_abgelehnt(ergebnis: dict, adresse: str) -> str:
|
||||
art, status = ergebnis.get("art"), ergebnis.get("status")
|
||||
if art == "abgelehnt":
|
||||
return f"AdGuard lehnt Benutzer oder Passwort ab (HTTP {status}). Nichts gespeichert."
|
||||
if art == "http":
|
||||
return f"AdGuard antwortet mit HTTP {status}. Nichts gespeichert."
|
||||
if art == "netz":
|
||||
return f"AdGuard ist nicht erreichbar ({adresse}). Nichts gespeichert."
|
||||
return "AdGuard antwortet nicht wie erwartet. Nichts gespeichert."
|
||||
|
||||
|
||||
def adguard_zugang_setzen(werte: tuple[str, str] | None) -> dict:
|
||||
"""POST /api/homelab/einstellungen/adguard-zugang: erst bei AdGuard die Statistik holen, dann speichern."""
|
||||
benutzer, passwort = (werte[0].strip(), werte[1]) if werte else ("", "")
|
||||
if not benutzer or not passwort or len(benutzer) > 200 or len(passwort) > 500:
|
||||
return {"ok": False, "detail": "Benutzer und Passwort eintragen. Nichts gespeichert."}
|
||||
adresse = adguard.url()
|
||||
if not adresse:
|
||||
return {"ok": False, "detail": "AdGuard steht nicht im Bericht des Ausführers, die Adresse ist unbekannt. "
|
||||
"Nichts gespeichert."}
|
||||
ergebnis = adguard.statistik(adresse, benutzer, passwort)
|
||||
if not ergebnis["ok"]:
|
||||
log.info("homelab: AdGuard-Zugang nicht übernommen (%s, HTTP %s)", ergebnis.get("art"), ergebnis.get("status"))
|
||||
return {"ok": False, "detail": _adguard_abgelehnt(ergebnis, adresse)}
|
||||
try:
|
||||
adguard.zugang_speichern(benutzer, passwort)
|
||||
except OSError as exc:
|
||||
return {"ok": False, "detail": f"AdGuard nimmt den Zugang an, aber er ließ sich nicht speichern "
|
||||
f"({exc.__class__.__name__})."}
|
||||
anteil = ergebnis["anteil"]
|
||||
return {"ok": True, "text": (f"AdGuard nimmt den Zugang an: {ergebnis['anfragen']} Anfragen"
|
||||
+ (f", davon {str(anteil).replace('.', ',')} % geblockt" if anteil is not None else "")
|
||||
+ ". Die Kachel zeigt den Anteil ab sofort.")}
|
||||
|
||||
|
||||
def adguard_zugang_loeschen() -> dict:
|
||||
"""DELETE /api/homelab/einstellungen/adguard-zugang."""
|
||||
try:
|
||||
da = adguard.zugang_loeschen()
|
||||
except OSError as exc:
|
||||
return {"ok": False, "detail": f"Der Zugang ließ sich nicht löschen ({exc.__class__.__name__})."}
|
||||
return {"ok": True, "text": "Der AdGuard-Zugang ist gelöscht." if da else "Es war kein AdGuard-Zugang hinterlegt."}
|
||||
@@ -0,0 +1,218 @@
|
||||
"""„Neu entdeckt“: Geräte, die der Orchestrator noch nicht kennt (Ausbauplan Welle 2, Punkt 7, seit 25.09.2026).
|
||||
|
||||
Jeder Bericht des Ausführers wird mit dem Register <Datenordner>/geraete-bekannt.json verglichen. Taucht ein Gast auf,
|
||||
den es noch nicht gibt (oder dessen Nummer jetzt einen anderen Namen trägt), wird er „neu“: Lucy meldet ihn einmal
|
||||
über Telegram, und das Homelab-Cockpit zeigt ihn als Karte „Neu entdeckt“ mit Vorschlägen — welche App darin steckt
|
||||
(katalog.py), ob ihn ein Sicherungsauftrag erfasst, ob seine Oberfläche geprüft wird. Ein Klick übernimmt ihn:
|
||||
• „Für Updates freigeben“ setzt das Etikett watcher (Ausführer: etikett_setzen) — damit darf der Orchestrator
|
||||
Snapshots anlegen und Updates einspielen. Hat er schon community-script, genügt Übernehmen.
|
||||
• „Nur beobachten“ kein Etikett; er steht in der Liste, Updates gibt es für ihn nicht.
|
||||
• „Nicht anfassen“ setzt watcher-aus: auch ein Community-Script bleibt dann unberührt.
|
||||
Neue Docker-Stacks unter Arcane (Compose-Projekte, docker.py) kommen genauso dazu, mit eigenem Register
|
||||
(docker-bekannt.json) und nur „Übernehmen“ — Etiketten gibt es dort nicht.
|
||||
Beim allerersten Bericht (noch kein Register) gilt alles als bekannt — sonst stünden alle Geräte auf einmal da.
|
||||
Verschwindet ein Gast, fällt er aus dem Register; kommt unter derselben Nummer ein neuer, ist er wieder „neu“.
|
||||
Der eigene Container (Etikett mc2) erscheint nie.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from kern.einstellungen import einstellungen
|
||||
|
||||
from services import announce
|
||||
from services.homelab import apps, kanal
|
||||
from services.homelab.inventar import EIGENES_ETIKETT
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
BETREFF = "[Homelab]"
|
||||
WAHL = {"freigeben": ("watcher", True), "ausschliessen": ("watcher-aus", True), "beobachten": (None, None)}
|
||||
ZEITLIMIT_S = 60
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _pfad(name: str = "geraete-bekannt.json") -> Path:
|
||||
return einstellungen().daten_dir / name
|
||||
|
||||
|
||||
DOCKER = "docker-bekannt.json"
|
||||
|
||||
|
||||
def _lesen(name: str = "geraete-bekannt.json") -> dict | None:
|
||||
try:
|
||||
daten = json.loads(_pfad(name).read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return daten if isinstance(daten, dict) else None
|
||||
|
||||
|
||||
def _schreiben(daten: dict, name: str = "geraete-bekannt.json") -> None:
|
||||
pfad = _pfad(name)
|
||||
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = pfad.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
tmp.replace(pfad)
|
||||
|
||||
|
||||
def _zid(g: dict) -> str:
|
||||
return f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g['vmid']}"
|
||||
|
||||
|
||||
def _anzeigename(g: dict) -> str:
|
||||
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
|
||||
return app.name if app else str(g.get("name") or _zid(g))
|
||||
|
||||
|
||||
def bericht_gesehen(bericht: dict, jetzt: float | None = None, melden=None) -> list[str]:
|
||||
"""Den Bericht mit dem Register abgleichen. Rückgabe: die Ziel-IDs, die gerade neu dazukamen."""
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
melden = melden or _melden
|
||||
gaeste = {_zid(g): g for g in bericht.get("gaeste") or []
|
||||
if isinstance(g, dict) and "vmid" in g and EIGENES_ETIKETT not in (g.get("etiketten") or [])}
|
||||
if not gaeste:
|
||||
return [] # ein leerer oder kaputter Bericht löscht nie das Register
|
||||
with _lock:
|
||||
register = _lesen()
|
||||
if register is None:
|
||||
_schreiben({zid: {"name": g.get("name"), "erstmals": jetzt, "zustand": "bekannt"} for zid, g in gaeste.items()})
|
||||
return []
|
||||
neu = []
|
||||
for zid, g in gaeste.items():
|
||||
alt = register.get(zid)
|
||||
if alt and alt.get("name") == g.get("name"):
|
||||
continue
|
||||
register[zid] = {"name": g.get("name"), "erstmals": jetzt, "zustand": "neu"}
|
||||
neu.append(zid)
|
||||
for zid in [z for z in register if z not in gaeste]:
|
||||
register.pop(zid)
|
||||
_schreiben(register)
|
||||
for zid in neu:
|
||||
g = gaeste[zid]
|
||||
art = "Container" if g.get("art") == "lxc" else "VM"
|
||||
melden(f"Neu im Homelab: {art} {g['vmid']} „{g.get('name') or zid}“ ({_anzeigename(g)}). Ich habe ihn noch "
|
||||
"nicht übernommen — im Homelab-Cockpit unter „Neu entdeckt“ entscheidest du, ob der Orchestrator ihn "
|
||||
"aktualisieren darf.")
|
||||
return neu
|
||||
|
||||
|
||||
def _melden(text: str) -> None:
|
||||
try:
|
||||
announce.notify_telegram(BETREFF, text)
|
||||
except Exception:
|
||||
log.warning("entdecken: Meldung ging nicht raus", exc_info=True)
|
||||
|
||||
|
||||
def neue() -> list[dict]:
|
||||
"""Für GET /api/homelab/neu: je neu entdecktem Gerät, was der Orchestrator darüber weiß und vorschlägt."""
|
||||
register = _lesen() or {}
|
||||
eingang = (kanal.bericht() or {}).get("bericht") or {}
|
||||
host = eingang.get("host") or {}
|
||||
ohne_auftrag = set(host.get("nicht_gesichert") or [])
|
||||
liste = []
|
||||
for g in eingang.get("gaeste") or []:
|
||||
zid = _zid(g) if isinstance(g, dict) and "vmid" in g else None
|
||||
if not zid or (register.get(zid) or {}).get("zustand") != "neu":
|
||||
continue
|
||||
kennung = (g.get("app") or {}).get("kennung")
|
||||
app = apps.app_fuer(kennung, g.get("name"))
|
||||
etiketten = list(g.get("etiketten") or [])
|
||||
liste.append({
|
||||
"id": zid, "vmid": g["vmid"], "name": app.name if app else str(g.get("name") or zid),
|
||||
"hostname": g.get("name"), "art": "container" if g.get("art") == "lxc" else "vm",
|
||||
"status": g.get("status"), "erstmals": register[zid].get("erstmals"), "etiketten": etiketten,
|
||||
"freigegeben": bool(g.get("erlaubt")), "ausgeschlossen": "watcher-aus" in etiketten,
|
||||
"app": ({"kennung": kennung, "name": app.name, "quelle": app.quelle, "weg": app.weg,
|
||||
"adresse": apps.adresse(app, g.get("ip"))} if app else None),
|
||||
"community_script": "community-script" in etiketten, "ip": g.get("ip"),
|
||||
"ohne_sicherung": g["vmid"] in ohne_auftrag})
|
||||
return liste
|
||||
|
||||
|
||||
def _markieren(zid: str, name: str = "geraete-bekannt.json") -> None:
|
||||
with _lock:
|
||||
register = _lesen(name) or {}
|
||||
if zid in register:
|
||||
register[zid]["zustand"] = "bekannt"
|
||||
_schreiben(register, name)
|
||||
|
||||
|
||||
# --- Docker-Stacks (seit 25.09.2026) ----------------------------------------------------------------------------------
|
||||
|
||||
def docker_gesehen(stacks: list[dict], jetzt: float | None = None, melden=None) -> list[str]:
|
||||
"""Die Stacks aus docker.lage() mit dem Register abgleichen. Rückgabe: die IDs („docker:<stack>“), die neu sind."""
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
melden = melden or _melden
|
||||
aktuell = {f"docker:{s['name']}": s for s in stacks if s.get("name")}
|
||||
if not aktuell:
|
||||
return []
|
||||
with _lock:
|
||||
register = _lesen(DOCKER)
|
||||
if register is None:
|
||||
_schreiben({sid: {"erstmals": jetzt, "zustand": "bekannt"} for sid in aktuell}, DOCKER)
|
||||
return []
|
||||
neu = [sid for sid in aktuell if sid not in register]
|
||||
for sid in neu:
|
||||
register[sid] = {"erstmals": jetzt, "zustand": "neu"}
|
||||
for sid in [s for s in register if s not in aktuell]:
|
||||
register.pop(sid)
|
||||
_schreiben(register, DOCKER)
|
||||
for sid in neu:
|
||||
namen = ", ".join(c["name"] for c in aktuell[sid].get("container") or [])
|
||||
melden(f"Neu unter Arcane: der Docker-Stack „{aktuell[sid]['name']}“ ({namen}). Im Homelab-Cockpit unter „Neu "
|
||||
"entdeckt“ übernehmen.")
|
||||
return neu
|
||||
|
||||
|
||||
def docker_abgleichen() -> None:
|
||||
"""Nach jedem Bericht im eigenen Faden: Arcane fragen (gemerkt) und neue Stacks eintragen."""
|
||||
from services.homelab import docker
|
||||
try:
|
||||
lage = docker.lage()
|
||||
if lage["verfuegbar"]:
|
||||
docker_gesehen(lage["stacks"])
|
||||
except Exception:
|
||||
log.warning("entdecken: Docker-Abgleich gescheitert", exc_info=True)
|
||||
|
||||
|
||||
def neue_docker() -> list[dict]:
|
||||
"""Neue Docker-Stacks für GET /api/homelab/neu."""
|
||||
from services.homelab import docker
|
||||
register = _lesen(DOCKER) or {}
|
||||
offen = {sid: e for sid, e in register.items() if e.get("zustand") == "neu"}
|
||||
if not offen:
|
||||
return []
|
||||
lage = docker.lage()
|
||||
return [{"id": f"docker:{s['name']}", "name": s["name"], "erstmals": offen[f"docker:{s['name']}"].get("erstmals"),
|
||||
"container": [c["name"] for c in s["container"]]}
|
||||
for s in lage["stacks"] if f"docker:{s['name']}" in offen]
|
||||
|
||||
|
||||
def uebernehmen(zid: str, wahl: str) -> dict:
|
||||
"""POST /api/homelab/neu/{zid}: „freigeben“, „beobachten“ oder „ausschliessen“."""
|
||||
if wahl not in WAHL:
|
||||
return {"ok": False, "detail": "Unbekannte Wahl."}
|
||||
if zid.startswith("docker:"):
|
||||
if wahl != "beobachten" or zid not in {s["id"] for s in neue_docker()}:
|
||||
return {"ok": False, "detail": "Dieser Docker-Stack steht nicht (mehr) unter „Neu entdeckt“."}
|
||||
_markieren(zid, DOCKER)
|
||||
return {"ok": True, "text": f"Der Stack {zid.split(':', 1)[1]} ist übernommen."}
|
||||
geraet = next((g for g in neue() if g["id"] == zid), None)
|
||||
if not geraet:
|
||||
return {"ok": False, "detail": "Dieses Gerät steht nicht (mehr) unter „Neu entdeckt“."}
|
||||
etikett, an = WAHL[wahl]
|
||||
schon = (wahl == "freigeben" and geraet["freigegeben"]) or (wahl == "ausschliessen" and geraet["ausgeschlossen"])
|
||||
if etikett and not schon:
|
||||
auftrag = kanal.anlegen("etikett_setzen", {"vmid": geraet["vmid"], "etikett": etikett, "an": an})
|
||||
ergebnis = kanal.warten(auftrag, ZEITLIMIT_S)
|
||||
if not ergebnis or ergebnis.get("status") != "fertig":
|
||||
grund = (ergebnis or {}).get("text") or "Der Ausführer hat nicht rechtzeitig geantwortet."
|
||||
return {"ok": False, "detail": f"Das Etikett ließ sich nicht setzen: {str(grund)[-300:]}"}
|
||||
_markieren(zid)
|
||||
text = {"freigeben": f"{geraet['name']} ist übernommen und für Updates freigegeben.",
|
||||
"beobachten": f"{geraet['name']} ist übernommen; der Orchestrator beobachtet ihn nur.",
|
||||
"ausschliessen": f"{geraet['name']} ist übernommen und bleibt unberührt (Etikett watcher-aus)."}[wahl]
|
||||
return {"ok": True, "text": text + (" Der nächste Bericht zeigt es in der Geräteliste." if etikett else "")}
|
||||
@@ -0,0 +1,356 @@
|
||||
"""Vom Bericht des Ausführers zu Zielen im gemeinsamen Modell (kern/ziele.py) — Phase 3, 24.09.2026.
|
||||
|
||||
Je Gerät ein Ziel: der Proxmox-Host (Pakete, Neustart) und jeder freigegebene Gast (Etikett
|
||||
community-script oder watcher, nicht watcher-aus) mit seinen Bausteinen „App“ (Community-Script,
|
||||
Vergleich mit der neuesten Veröffentlichung auf GitHub) und „Pakete“ (Betriebssystem im Gast).
|
||||
Dazu prüft dieser Teil selbst, ob die Weboberfläche jedes Gasts antwortet.
|
||||
|
||||
Wie ehrlich der Stand ist, steht dabei: Sind die Paketlisten im Gast älter als zwei Wochen, heißt es
|
||||
„unbekannt“ (mit Knopf zum Suchen) statt „aktuell“; ist der Bericht älter als eine halbe Stunde, sagt
|
||||
die Übersicht, dass der Ausführer schweigt. Ist das Update-Skript einer App frisch geändert, bleibt sie
|
||||
„neu“, aber ohne Knopf (karenz.py). Rückweg und Rückfrage sagen, was vor dem Update geschieht: Snapshot,
|
||||
wo keiner geht eine Sicherung, sonst keiner. Ohne Freigabe (Etikett) legt der Ausführer weder das eine noch
|
||||
das andere an — das betrifft Arcane, dessen Docker-Updates auch ohne Etikett über Arcane selbst laufen.
|
||||
"""
|
||||
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
from kern.github import neueste_version
|
||||
from kern.zeit import LOCAL_TZ
|
||||
from kern.ziele import Aktion, BausteinStand, ZielStand
|
||||
|
||||
from services.homelab import apps, arcane, kanal, karenz, sicherheit, wartung
|
||||
|
||||
BERICHT_ALT_S = 30 * 60
|
||||
LISTEN_ALT_S = 14 * 24 * 3600
|
||||
EIGENES_ETIKETT = "mc2"
|
||||
ERREICHBAR_CACHE_S = 60
|
||||
OHNE_FREIGABE = "Das Etikett „watcher“ fehlt, der Ausführer darf hier keinen Snapshot anlegen"
|
||||
|
||||
_erreichbar_cache: dict[str, tuple[float, bool]] = {}
|
||||
_erreichbar_lock = threading.Lock()
|
||||
|
||||
|
||||
def erreichbar(url: str) -> bool:
|
||||
"""Antwortet die Weboberfläche? Alles unter 500 zählt (eine Anmeldeseite ist eine Antwort)."""
|
||||
jetzt = time.time()
|
||||
with _erreichbar_lock:
|
||||
if (eintrag := _erreichbar_cache.get(url)) and jetzt - eintrag[0] < ERREICHBAR_CACHE_S:
|
||||
return eintrag[1]
|
||||
try:
|
||||
# Selbstsignierte Zertifikate sind im Heimnetz üblich (PBS, NPMplus); geprüft wird nur, ob jemand antwortet.
|
||||
ok = httpx.get(url, timeout=5.0, verify=False, follow_redirects=False).status_code < 500
|
||||
except httpx.HTTPError:
|
||||
ok = False
|
||||
with _erreichbar_lock:
|
||||
_erreichbar_cache[url] = (jetzt, ok)
|
||||
return ok
|
||||
|
||||
|
||||
def erreichbar_frisch(url: str) -> bool:
|
||||
"""Wie erreichbar(), aber ohne den Zwischenspeicher — für die Prüfung direkt nach einem Update."""
|
||||
with _erreichbar_lock:
|
||||
_erreichbar_cache.pop(url, None)
|
||||
return erreichbar(url)
|
||||
|
||||
|
||||
def _teile(version: str) -> tuple[int, ...]:
|
||||
return tuple(int(x) for x in re.findall(r"\d+", version.split("-")[0])[:4])
|
||||
|
||||
|
||||
def neuer(verfuegbar: str, installiert: str) -> bool | None:
|
||||
"""Ist verfuegbar neuer als installiert? None, wenn sich das nicht sagen lässt."""
|
||||
a, b = _teile(verfuegbar), _teile(installiert)
|
||||
if not a or not b:
|
||||
return None
|
||||
return a > b
|
||||
|
||||
|
||||
def _datum(ts: float | None) -> str:
|
||||
return datetime.fromtimestamp(ts, LOCAL_TZ).strftime("%d.%m.%Y") if ts else "?"
|
||||
|
||||
|
||||
def _rueckweg(gast: dict) -> str:
|
||||
"""Die Zeile „Rückweg“ der Karte: was vor jedem Update geschieht."""
|
||||
if not gast.get("erlaubt"):
|
||||
return f"Kein automatischer Rückweg: {OHNE_FREIGABE}."
|
||||
if gast.get("snapshot_moeglich"):
|
||||
return "Snapshot vor jedem Update"
|
||||
grund = gast.get("snapshot_grund") or "kein Snapshot möglich"
|
||||
if gast.get("sicherung_moeglich"):
|
||||
return f"Sicherung auf „{gast.get('sicherung_speicher') or 'dem Host'}“ vor jedem Update ({grund})"
|
||||
if gast.get("sicherung_grund"):
|
||||
return f"Kein automatischer Rückweg: {grund}. Auch keine Sicherung: {gast['sicherung_grund']}"
|
||||
return grund
|
||||
|
||||
|
||||
def _rueckweg_art(gast: dict) -> str:
|
||||
"""Dasselbe in einem Wort — die Oberfläche fasst den Rückweg in der Update-Liste kurz."""
|
||||
if not gast.get("erlaubt"):
|
||||
return "keiner"
|
||||
if gast.get("snapshot_moeglich"):
|
||||
return "snapshot"
|
||||
return "sicherung" if gast.get("sicherung_moeglich") else "keiner"
|
||||
|
||||
|
||||
def _rueckweg_frage(gast: dict) -> str:
|
||||
"""Der Satz zum Rückweg in der Rückfrage vor „Jetzt updaten“."""
|
||||
if not gast.get("erlaubt"):
|
||||
return f"{OHNE_FREIGABE} — scheitert das Update, gibt es keinen automatischen Rückweg."
|
||||
if gast.get("snapshot_moeglich"):
|
||||
return "Vorher wird ein Snapshot angelegt; ist die Prüfung danach rot, geht es von selbst zurück."
|
||||
if gast.get("sicherung_moeglich"):
|
||||
return "Vorher wird eine Sicherung angelegt; ist die Prüfung danach rot, wird sie zurückgespielt."
|
||||
satz = "Ein Snapshot ist hier nicht möglich — scheitert das Update, gibt es keinen automatischen Rückweg."
|
||||
if gast.get("sicherung_grund"):
|
||||
satz += f" Eine Sicherung geht auch nicht: {gast['sicherung_grund']}"
|
||||
return satz
|
||||
|
||||
|
||||
def _app_baustein(gast: dict, app: apps.App, zid: str) -> BausteinStand:
|
||||
installiert = (gast.get("app") or {}).get("version")
|
||||
stand = BausteinStand(id="app", name=app.name, zustand="aktuell", installiert=installiert)
|
||||
if not app.quelle:
|
||||
return stand
|
||||
try:
|
||||
neu = neueste_version(app.quelle)
|
||||
except Exception:
|
||||
neu = None
|
||||
if not neu:
|
||||
stand.zustand, stand.grund = "unbekannt", "Die neueste Version ist gerade nicht abrufbar (GitHub)."
|
||||
return stand
|
||||
stand.verfuegbar = neu["version"]
|
||||
if installiert and installiert.startswith("untagged-"):
|
||||
# Manche Projekte veröffentlichen ohne Versions-Tag; die App kennt dann nur dieses Tag (PVE Scripts Local).
|
||||
if installiert == neu.get("tag_roh"):
|
||||
stand.installiert = neu["version"]
|
||||
return stand
|
||||
stand.zustand, stand.grund = "unbekannt", "Die installierte Fassung lässt sich keiner Version zuordnen."
|
||||
return stand
|
||||
if app.vergleich == "datum":
|
||||
# Docker-Image ohne Versionsnummer: neuer, wenn die Veröffentlichung jünger ist als das Image.
|
||||
bild = re.search(r"\d{4}-\d{2}-\d{2}", installiert or "")
|
||||
ist_neuer = (neu["datum"] or "") > bild.group(0) if bild else None
|
||||
else:
|
||||
ist_neuer = neuer(neu["version"], installiert) if installiert else None
|
||||
if ist_neuer is None:
|
||||
stand.zustand, stand.grund = "unbekannt", "Die installierte Version ließ sich nicht lesen."
|
||||
elif ist_neuer and app.weg != "skript":
|
||||
stand.zustand, stand.kurz = "neu", f"{installiert} → {neu['version']}"
|
||||
stand.grund = f"{app.name} aktualisiert sich über die eigene Oberfläche; das Community-Script tut es nicht."
|
||||
elif ist_neuer and wartung.uebersprungen(zid, "app") == neu["version"]:
|
||||
# „Version überspringen“ (seit 25.09.2026): gilt als aktuell, bis eine neuere kommt.
|
||||
stand.uebersprungen, stand.grund = neu["version"], f"Version {neu['version']} wird übersprungen."
|
||||
elif ist_neuer:
|
||||
stand.zustand, stand.kurz = "neu", f"{installiert} → {neu['version']}"
|
||||
# Frisch geänderte Update-Skripte erst nach der Wartezeit (karenz.py): „neu“, aber ohne Knopf.
|
||||
wartezeit = karenz.pruefen(app.kennung)
|
||||
if wartezeit["gesperrt"]:
|
||||
stand.grund = wartezeit["gesperrt"]
|
||||
return stand
|
||||
frage = f"{app.name} jetzt aktualisieren? {_rueckweg_frage(gast)}"
|
||||
if not wartezeit["geprueft"]:
|
||||
frage += f" {karenz.NICHT_GEPRUEFT}"
|
||||
stand.aktion = Aktion("POST", f"/api/homelab/ziele/{zid}/update", frage)
|
||||
return stand
|
||||
|
||||
|
||||
def _os_baustein(gast: dict, zid: str, jetzt: float) -> BausteinStand:
|
||||
os_stand = gast.get("os_updates") or {}
|
||||
stand = BausteinStand(id="os", name="Pakete", zustand="aktuell")
|
||||
anzahl, listen = os_stand.get("anzahl"), os_stand.get("listen_stand")
|
||||
suchen = Aktion("POST", f"/api/homelab/ziele/{zid}/suchen", "Die Paketlisten im Gast jetzt erneuern?",
|
||||
label="Nach Updates suchen")
|
||||
if anzahl is None:
|
||||
stand.zustand, stand.grund = "unbekannt", "Die Pakete ließen sich nicht lesen."
|
||||
elif listen and jetzt - listen > LISTEN_ALT_S:
|
||||
stand.zustand, stand.aktion = "unbekannt", suchen
|
||||
stand.grund = f"Die Paketlisten sind vom {_datum(listen)}; was fehlt, weiß erst eine neue Suche."
|
||||
elif anzahl > 0:
|
||||
# Seit 25.09.2026: wie viele davon eine Sicherheitslücke schließen (Debian: Suite „-security“, Alpine: secdb).
|
||||
stand.sicherheit = os_stand.get("sicherheit") if "sicherheit" in os_stand else sicherheit.alpine_sicherheit(os_stand)
|
||||
davon = f" (davon {stand.sicherheit} Sicherheitsupdate{'s' if stand.sicherheit != 1 else ''})" \
|
||||
if stand.sicherheit else ""
|
||||
stand.zustand, stand.kurz = "neu", f"{anzahl} Pakete"
|
||||
stand.aktion = Aktion("POST", f"/api/homelab/ziele/{zid}/os-update",
|
||||
f"{anzahl} Pakete{davon} im Gast jetzt einspielen? {_rueckweg_frage(gast)}")
|
||||
return stand
|
||||
|
||||
|
||||
def _arcane_bausteine(basis: str | None, zid: str, gast: dict) -> list[BausteinStand]:
|
||||
"""Arcane selbst (öffentliche Version) und die Docker-Images (mit API-Schlüssel)."""
|
||||
app = arcane.app_version(basis) if basis else None
|
||||
selbst = BausteinStand(id="arcane", name="Arcane", zustand="unbekannt",
|
||||
installiert=app["installiert"] if app else None, verfuegbar=app["neu"] if app else None)
|
||||
if not app:
|
||||
selbst.grund = "Arcane antwortet nicht."
|
||||
elif app["update"]:
|
||||
selbst.zustand, selbst.kurz = "neu", f"{app['installiert']} → {app['neu']}"
|
||||
selbst.grund = "Arcane spielt sein eigenes Update über die eigene Oberfläche ein (Einstellungen)."
|
||||
else:
|
||||
selbst.zustand = "aktuell"
|
||||
docker = BausteinStand(id="docker", name="Docker-Images", zustand="unbekannt")
|
||||
if not arcane.schluessel():
|
||||
docker.grund = ("Es fehlt ein Arcane-API-Schlüssel: Ohne ihn sieht der Orchestrator die Images nicht. "
|
||||
"Eintragen unter Einstellungen (Arcane-API-Schlüssel).")
|
||||
elif basis:
|
||||
try:
|
||||
images = arcane.images_mit_update(basis)
|
||||
except Exception:
|
||||
docker.grund = "Arcane verweigert die Auskunft oder antwortet nicht (Schlüssel prüfen)."
|
||||
else:
|
||||
# Nur Images, die ein Container benutzt: Arcanes Updater tauscht Container aus. Ein veraltetes Image ohne
|
||||
# Container (Basis-Images für Builds, CI) bliebe sonst für immer „neu“ (25.09.2026: 9 von 11 nach dem Lauf).
|
||||
ohne = [i for i in images if not i.get("benutzt")]
|
||||
images = [i for i in images if i.get("benutzt")]
|
||||
if ohne:
|
||||
docker.grund = (f"{len(ohne)} ältere Images ohne Container ({', '.join(i['name'] for i in ohne[:4])}"
|
||||
+ (" …" if len(ohne) > 4 else "") + ") betreffen nichts; aufräumen in Arcane unter Images.")
|
||||
if images:
|
||||
docker.zustand, docker.kurz = "neu", f"{len(images)} Images"
|
||||
docker.grund = ", ".join(i["name"] for i in images[:6]) + (" …" if len(images) > 6 else "")
|
||||
probe = not arcane.echt()
|
||||
docker.aktion = Aktion(
|
||||
"POST", f"/api/homelab/ziele/{zid}/docker",
|
||||
("Arcane prüft im Probelauf, welche Container es aktualisieren würde — geändert wird noch nichts."
|
||||
if probe else "Arcane aktualisiert jetzt die Container mit neuen Images; Daten-Volumes bleiben. "
|
||||
+ _rueckweg_frage(gast)),
|
||||
label="Probelauf" if probe else "Jetzt updaten")
|
||||
else:
|
||||
docker.zustand = "aktuell"
|
||||
return [selbst, docker]
|
||||
|
||||
|
||||
def _status_text(status: object) -> str:
|
||||
"""Warum ein Gast keine Bausteine hat — auf Deutsch. „unknown“ kommt kurz nach dem Hochfahren des Hosts, bevor
|
||||
Proxmox den Zustand kennt (der Ausführer berichtet dann nach einer Minute erneut)."""
|
||||
if status == "stopped":
|
||||
return "Der Gast ist gestoppt."
|
||||
if status == "paused":
|
||||
return "Der Gast ist angehalten."
|
||||
return "Proxmox kennt seinen Zustand gerade nicht (etwa kurz nach dem Hochfahren des Hosts)."
|
||||
|
||||
|
||||
def _gast_ziel(gast: dict, jetzt: float) -> ZielStand:
|
||||
art = "container" if gast["art"] == "lxc" else "vm"
|
||||
zid = f"{'ct' if art == 'container' else 'vm'}-{gast['vmid']}"
|
||||
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
|
||||
url = apps.adresse(app, gast.get("ip"))
|
||||
laeuft = gast.get("status") == "running"
|
||||
ziel = ZielStand(id=zid, name=app.name if app else str(gast.get("name") or zid), art=art, instanz="homelab",
|
||||
erreichbar=(erreichbar(url) if laeuft else False) if url else (laeuft or None),
|
||||
rueckweg=_rueckweg(gast), rueckweg_art=_rueckweg_art(gast), platte=gast.get("platte"))
|
||||
if not laeuft:
|
||||
ziel.bausteine.append(BausteinStand(id="status", name="Status", zustand="unbekannt",
|
||||
grund=_status_text(gast.get("status"))))
|
||||
return ziel
|
||||
if app and app.kennung == "arcane":
|
||||
# Arcane läuft über seine eigene Schnittstelle, nicht über den Ausführer: kein Etikett nötig — nur für den
|
||||
# Snapshot vorher (Rückweg), den legt der Ausführer an.
|
||||
ziel.bausteine += _arcane_bausteine(url.rstrip("/") if url else None, zid, gast)
|
||||
return ziel
|
||||
if not gast.get("erlaubt"):
|
||||
ziel.bausteine.append(BausteinStand(
|
||||
id="freigabe", name="Freigabe", zustand="unbekannt",
|
||||
grund="Nicht freigegeben: Das Etikett „watcher“ fehlt (oder „watcher-aus“ ist gesetzt)."))
|
||||
return ziel
|
||||
if app and app.kennung in apps.KATALOG:
|
||||
ziel.bausteine.append(_app_baustein(gast, app, zid))
|
||||
if gast["art"] == "lxc":
|
||||
ziel.bausteine.append(_os_baustein(gast, zid, jetzt))
|
||||
return ziel
|
||||
|
||||
|
||||
def _host_ziel(host: dict) -> ZielStand:
|
||||
ziel = ZielStand(id="pve", name="Proxmox-Host", art="proxmox-host", instanz="homelab", erreichbar=True,
|
||||
rueckweg="Kein Snapshot möglich; der Host bleibt beim Update in Betrieb, neu gestartet wird getrennt.",
|
||||
rueckweg_art="keiner")
|
||||
updates = host.get("updates")
|
||||
pakete = BausteinStand(id="pakete", name="Proxmox VE und Debian", zustand="aktuell",
|
||||
installiert=host.get("version"))
|
||||
if updates is None:
|
||||
pakete.zustand, pakete.grund = "unbekannt", host.get("fehler") or "Die Update-Liste fehlt im Bericht."
|
||||
elif updates:
|
||||
pakete.zustand, pakete.kurz = "neu", f"{len(updates)} Pakete"
|
||||
if isinstance(host.get("updates_sicherheit"), list):
|
||||
pakete.sicherheit = len(host["updates_sicherheit"])
|
||||
pakete.aktion = Aktion("POST", "/api/homelab/ziele/pve/update",
|
||||
f"{len(updates)} Pakete auf dem Proxmox-Host einspielen? Alle Gäste laufen weiter. "
|
||||
"Einen Neustart löst das nicht aus — der ist ein eigener Knopf.")
|
||||
ziel.bausteine.append(pakete)
|
||||
if host.get("neustart_noetig"):
|
||||
ziel.bausteine.append(BausteinStand(
|
||||
id="neustart", name="Neustart", zustand="neu", kurz="steht an",
|
||||
installiert=host.get("kernel"), verfuegbar=host.get("kernel_neu"),
|
||||
grund=(f"Neuer Kernel {host['kernel_neu']} ist installiert, es läuft noch {host.get('kernel')}."
|
||||
if host.get("kernel_neu") else "Ein Update verlangt einen Neustart."),
|
||||
aktion=Aktion("POST", "/api/homelab/ziele/pve/neustart",
|
||||
"Den Proxmox-Host jetzt neu starten? ALLE Container und die Arcane-VM sind dann ein paar "
|
||||
"Minuten weg. Die KI-Box prüft danach von außen, ob alles wiederkommt.",
|
||||
label="Jetzt neu starten")))
|
||||
return ziel
|
||||
|
||||
|
||||
def ziele() -> dict:
|
||||
"""Alle Ziele des Homelabs aus dem letzten Bericht, dazu wie frisch er ist."""
|
||||
eingang = kanal.bericht()
|
||||
jetzt = time.time()
|
||||
zuletzt = kanal.zuletzt()
|
||||
ausfuehrer = {"verbunden": bool(zuletzt and jetzt - zuletzt < BERICHT_ALT_S), "zuletzt": zuletzt}
|
||||
if not eingang:
|
||||
return {"ziele": [], "bericht": None, "ausfuehrer": ausfuehrer}
|
||||
b = eingang["bericht"]
|
||||
liste = [_host_ziel(b.get("host") or {})]
|
||||
# Der Container des Orchestrators selbst (Etikett „mc2“) ist kein Gerät, das er pflegt.
|
||||
liste += [_gast_ziel(g, jetzt) for g in b.get("gaeste") or [] if EIGENES_ETIKETT not in (g.get("etiketten") or [])]
|
||||
zahlen = _kennzahlen(b)
|
||||
for z in liste:
|
||||
z.geprueft = eingang["empfangen"]
|
||||
z.kennzahl = zahlen.get(z.id)
|
||||
return {"ziele": [z.als_dict() for z in liste],
|
||||
"bericht": {"empfangen": eingang["empfangen"], "veraltet": jetzt - eingang["empfangen"] > BERICHT_ALT_S},
|
||||
"ausfuehrer": ausfuehrer,
|
||||
# Seit 25.09.2026: Läuft das Wartungsfenster, zeigt das Cockpit „Wartung“ statt „Störung“.
|
||||
"wartung": {"laeuft": wartung.laeuft(), "fenster": wartung.fenster_text()}}
|
||||
|
||||
|
||||
def _kennzahlen(bericht: dict) -> dict:
|
||||
"""Die Live-Zahlen der Kacheln (kennzahlen.py). Scheitert etwas, zeigen die Kacheln eben keine — die Liste der
|
||||
Ziele darf daran nie hängen."""
|
||||
from services.homelab import kennzahlen # braucht zertifikate, adguard und arcane — erst hier
|
||||
try:
|
||||
return kennzahlen.fuer_bericht(bericht)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def erreichbarkeit() -> list[dict]:
|
||||
"""Für den Wächter: jede Weboberfläche laufender, freigegebener Gäste — ohne GitHub-Abfragen. Gäste mitten in
|
||||
einem Update-Lauf bleiben außen vor: Sicherung, Update und Zurückspielen legen sie kurz still, und was dabei
|
||||
herauskommt, meldet der Lauf selbst (sonst käme ein zweiter Alarm samt „wieder ok“)."""
|
||||
from services.homelab import updates # updates importiert dieses Modul
|
||||
eingang = kanal.bericht()
|
||||
im_update = updates.laufende_ziele()
|
||||
ergebnis = []
|
||||
for g in (eingang or {}).get("bericht", {}).get("gaeste") or []:
|
||||
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
|
||||
url = apps.adresse(app, g.get("ip"))
|
||||
zid = f"{'ct' if g['art'] == 'lxc' else 'vm'}-{g['vmid']}"
|
||||
if url and g.get("erlaubt") and zid not in im_update:
|
||||
ergebnis.append({"id": zid, "name": app.name if app else g.get("name"), "url": url,
|
||||
"laeuft": g.get("status") == "running",
|
||||
"ok": g.get("status") == "running" and erreichbar(url)})
|
||||
return ergebnis
|
||||
|
||||
|
||||
def gast(zid: str) -> dict | None:
|
||||
"""Der Gast zu einer Ziel-ID aus dem letzten Bericht (ct-104 → vmid 104)."""
|
||||
m = re.fullmatch(r"(ct|vm)-(\d+)", zid)
|
||||
eingang = kanal.bericht()
|
||||
if not m or not eingang:
|
||||
return None
|
||||
return next((g for g in eingang["bericht"].get("gaeste") or [] if g.get("vmid") == int(m.group(2))), None)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Das Protokoll eines Containers für Lucy (seit 25.09.2026, Ausbauplan Welle 3, Punkt 14: „Lucy liest Logs und
|
||||
Messwerte, erklärt den Befund“).
|
||||
|
||||
Über den Ausführer (Aktion journal, nur lesend): Warnungen und Fehler der letzten 24 Stunden aus dem Journal, ohne
|
||||
systemd (Alpine) die letzten Zeilen von /var/log/messages. Nur für freigegebene Container — in eine VM kommt der
|
||||
Ausführer nicht hinein. Lucys Werkzeug geraet_logs() liest es über GET /api/homelab/ziele/{id}/journal.
|
||||
"""
|
||||
|
||||
from services.homelab import inventar, kanal
|
||||
|
||||
ZEITLIMIT_S = 90
|
||||
|
||||
|
||||
def lesen(ziel_id: str, zeilen: int = 80) -> dict:
|
||||
gast = inventar.gast(ziel_id)
|
||||
if not gast:
|
||||
return {"ok": False, "detail": "Dieses Gerät steht nicht im Bericht des Ausführers."}
|
||||
if gast.get("art") != "lxc":
|
||||
return {"ok": False, "detail": "Protokolle gibt es nur von Containern; in eine VM kommt der Ausführer nicht."}
|
||||
if not gast.get("erlaubt"):
|
||||
return {"ok": False, "detail": "Dieses Gerät ist nicht freigegeben (Etikett watcher fehlt)."}
|
||||
auftrag = kanal.anlegen("journal", {"vmid": gast["vmid"], "zeilen": max(20, min(int(zeilen), 300))})
|
||||
ergebnis = kanal.warten(auftrag, ZEITLIMIT_S)
|
||||
if not ergebnis:
|
||||
return {"ok": False, "detail": "Der Ausführer hat nicht rechtzeitig geantwortet."}
|
||||
if ergebnis.get("status") != "fertig":
|
||||
return {"ok": False, "detail": str(ergebnis.get("text") or "Das Protokoll ließ sich nicht lesen.")[:500]}
|
||||
return {"ok": True, "text": str(ergebnis.get("text") or "").strip() or "(keine Warnungen oder Fehler)"}
|
||||
@@ -0,0 +1,242 @@
|
||||
"""Kanal zum Ausführer auf dem Proxmox-Host (Phase 3/4, 24.09.2026).
|
||||
|
||||
Der Ausführer (deploy/homelab/ausfuehrer.py) holt sich Aufträge hier ab, liefert die Ergebnisse und
|
||||
alle zehn Minuten einen Bericht. Er weist sich mit einem gemeinsamen Geheimnis aus (Kopfzeile
|
||||
X-MC2-Ausfuehrer). Das erzeugt dieser Teil beim ersten Bedarf in <Datenordner>/ausfuehrer.token (nur
|
||||
für den Dienst lesbar); die Einrichtung auf dem Proxmox-Host kopiert es in /etc/mc2-ausfuehrer.json.
|
||||
|
||||
Aufträge liegen in <Datenordner>/ausfuehrer-auftraege.json und überleben so einen Neustart dieses Teils.
|
||||
Zwei Prozesse schreiben hinein: die Oberfläche (Knöpfe, Abholen durch den Ausführer) und seit 24.09.2026 der
|
||||
Steward (wöchentliches Suchen, pflege.py). Lesen, ändern, schreiben geschieht deshalb unter einer Dateisperre.
|
||||
|
||||
Erledigte Aufträge bleiben (seit 24.09.2026) bis zu BEHALTEN Stück stehen — das Protokoll der Oberfläche
|
||||
(protokoll.py) liest sie. Die jüngsten VOLLTEXT behalten ihre ganze Ausgabe, ältere nur deren Ende (TEXT_KURZ
|
||||
Zeichen, so viel zeigt das Protokoll): Die Datei wird bei jedem Auftrag gelesen und geschrieben.
|
||||
"""
|
||||
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from kern.einstellungen import einstellungen
|
||||
|
||||
try:
|
||||
import fcntl # Linux: Sperre über Prozessgrenzen
|
||||
except ImportError: # Windows (Entwicklung): nur die Thread-Sperre
|
||||
fcntl = None
|
||||
|
||||
AKTIONEN = {"bericht", "snapshot", "update", "os_update", "suchen", "zurueck", "snapshot_loeschen",
|
||||
"host_update", "host_neustart", "sichern", "sicherung_zurueck", "sicherung_loeschen",
|
||||
"kernel_aufraeumen", "sicherung_probe", "etikett_setzen", "changelog", "journal"}
|
||||
VERLOREN_S = 3 * 3600 # abgeholt, aber nie beantwortet (Ausführer abgestürzt, Host neu gestartet)
|
||||
BEHALTEN = 500 # so viele erledigte Aufträge bleiben sichtbar (Protokoll; bis 24.09.2026: 60)
|
||||
VOLLTEXT = 60 # die jüngsten erledigten behalten ihre ganze Ausgabe …
|
||||
TEXT_KURZ = 4000 # … ältere nur deren letzte Zeichen
|
||||
BERICHTE_BEHALTEN = 150 # Eingänge der Berichte, die der Ausführer von sich aus schickt (alle 10 min: gut ein Tag)
|
||||
|
||||
_lock = threading.Lock()
|
||||
_kontakt: dict = {"zuletzt": None}
|
||||
|
||||
|
||||
def _dir() -> Path:
|
||||
return einstellungen().daten_dir
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _sperre() -> Iterator[None]:
|
||||
"""Für Lesen-ändern-schreiben der Auftragsliste: Thread-Sperre, unter Linux zusätzlich flock. Ohne sie
|
||||
überschrieben sich Oberfläche und Steward gegenseitig (ein abgeholter Auftrag stünde wieder auf „wartet“)."""
|
||||
with _lock:
|
||||
if fcntl is None:
|
||||
yield
|
||||
return
|
||||
pfad = _dir() / "ausfuehrer-auftraege.lock"
|
||||
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(pfad, "a", encoding="utf-8") as f:
|
||||
fcntl.flock(f, fcntl.LOCK_EX)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(f, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def _json_schreiben(pfad: Path, daten: object) -> None:
|
||||
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = pfad.with_suffix(pfad.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(daten, ensure_ascii=False), encoding="utf-8")
|
||||
tmp.replace(pfad)
|
||||
|
||||
|
||||
def _json_lesen(pfad: Path, leer: object) -> object:
|
||||
try:
|
||||
return json.loads(pfad.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return leer
|
||||
|
||||
|
||||
# --- Gemeinsames Geheimnis ---------------------------------------------------------------
|
||||
|
||||
def token() -> str:
|
||||
"""Das Geheimnis des Kanals; beim ersten Aufruf erzeugt (0600). MC_AUSFUEHRER_TOKEN geht vor."""
|
||||
if wert := os.environ.get("MC_AUSFUEHRER_TOKEN", "").strip():
|
||||
return wert
|
||||
pfad = _dir() / "ausfuehrer.token"
|
||||
try:
|
||||
return pfad.read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
pass
|
||||
wert = secrets.token_urlsafe(32)
|
||||
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
fd = os.open(pfad, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
except FileExistsError: # gleichzeitig erzeugt: den anderen nehmen
|
||||
return pfad.read_text(encoding="utf-8").strip()
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(wert + "\n")
|
||||
return wert
|
||||
|
||||
|
||||
def token_gueltig(wert: str | None) -> bool:
|
||||
return bool(wert) and hmac.compare_digest(str(wert), token())
|
||||
|
||||
|
||||
def kontakt() -> None:
|
||||
_kontakt["zuletzt"] = time.time()
|
||||
|
||||
|
||||
def zuletzt() -> float | None:
|
||||
"""Letztes Lebenszeichen des Ausführers (nach einem Neustart: Zeit des letzten Berichts)."""
|
||||
return _kontakt["zuletzt"] or (bericht() or {}).get("empfangen")
|
||||
|
||||
|
||||
# --- Bericht ------------------------------------------------------------------------------
|
||||
|
||||
def bericht_speichern(daten: dict, aus_auftrag: bool = False) -> None:
|
||||
"""Den neuesten Bericht ablegen. Schickt ihn der Ausführer von sich aus (alle 10 min und nach jedem Auftrag),
|
||||
wird sein Eingang kurz vermerkt — das Protokoll zeigt ihn auf Wunsch. aus_auftrag: ein Lauf hat ihn als Auftrag
|
||||
„bericht“ geholt (updates.py); dann steht er dort schon."""
|
||||
jetzt = time.time()
|
||||
with _lock:
|
||||
_json_schreiben(_dir() / "pve-bericht.json", {"empfangen": jetzt, "bericht": daten})
|
||||
if aus_auftrag:
|
||||
return
|
||||
host = daten.get("host") if isinstance(daten.get("host"), dict) else {}
|
||||
gaeste = [g for g in daten.get("gaeste") or [] if isinstance(g, dict)]
|
||||
eingang = {"empfangen": jetzt, "host_version": host.get("version"), "gaeste": len(gaeste),
|
||||
"laufend": sum(1 for g in gaeste if g.get("status") == "running"),
|
||||
"host_updates": len(host["updates"]) if isinstance(host.get("updates"), list) else None}
|
||||
pfad = _dir() / "ausfuehrer-berichte.json"
|
||||
eingaenge = _json_lesen(pfad, [])
|
||||
eingaenge = eingaenge if isinstance(eingaenge, list) else []
|
||||
_json_schreiben(pfad, (eingaenge + [eingang])[-BERICHTE_BEHALTEN:])
|
||||
|
||||
|
||||
def berichte() -> list[dict]:
|
||||
"""Die vermerkten Eingänge von Berichten (älteste zuerst): Zeit, Host-Version, Zahl der Gäste und Host-Updates."""
|
||||
with _lock:
|
||||
daten = _json_lesen(_dir() / "ausfuehrer-berichte.json", [])
|
||||
return [e for e in daten if isinstance(e, dict)] if isinstance(daten, list) else []
|
||||
|
||||
|
||||
def bericht() -> dict | None:
|
||||
"""{"empfangen": Unix-Sekunden, "bericht": {...}} oder None, solange nie einer kam."""
|
||||
with _lock:
|
||||
daten = _json_lesen(_dir() / "pve-bericht.json", None)
|
||||
return daten if isinstance(daten, dict) and isinstance(daten.get("bericht"), dict) else None
|
||||
|
||||
|
||||
# --- Aufträge -------------------------------------------------------------------------------
|
||||
|
||||
def _pfad() -> Path:
|
||||
return _dir() / "ausfuehrer-auftraege.json"
|
||||
|
||||
|
||||
def _alle() -> list[dict]:
|
||||
daten = _json_lesen(_pfad(), [])
|
||||
return daten if isinstance(daten, list) else []
|
||||
|
||||
|
||||
def _merken(auftraege: list[dict]) -> None:
|
||||
offen = [a for a in auftraege if a["status"] in ("wartet", "laeuft")]
|
||||
fertig = [a for a in auftraege if a["status"] not in ("wartet", "laeuft")][-BEHALTEN:]
|
||||
for a in fertig[:-VOLLTEXT]:
|
||||
text = a.get("text")
|
||||
if isinstance(text, str) and len(text) > TEXT_KURZ:
|
||||
a["text"] = "…" + text[-(TEXT_KURZ - 1):]
|
||||
_json_schreiben(_pfad(), sorted(offen + fertig, key=lambda a: a["erstellt"]))
|
||||
|
||||
|
||||
def anlegen(aktion: str, parameter: dict | None = None) -> str:
|
||||
if aktion not in AKTIONEN:
|
||||
raise ValueError(f"Unbekannte Aktion: {aktion}")
|
||||
auftrag = {"id": uuid.uuid4().hex[:12], "aktion": aktion, "parameter": parameter or {},
|
||||
"status": "wartet", "erstellt": time.time(), "abgeholt": None, "fertig": None,
|
||||
"code": None, "text": None}
|
||||
with _sperre():
|
||||
auftraege = _alle()
|
||||
auftraege.append(auftrag)
|
||||
_merken(auftraege)
|
||||
return auftrag["id"]
|
||||
|
||||
|
||||
def naechster() -> dict | None:
|
||||
"""Für den Ausführer: der älteste wartende Auftrag, ab jetzt „läuft“. Hängengebliebene werden
|
||||
vorher als verloren abgeschlossen. Geschrieben wird nur, wenn sich etwas ändert: Der Ausführer fragt alle
|
||||
fünf Sekunden."""
|
||||
jetzt = time.time()
|
||||
with _sperre():
|
||||
auftraege = _alle()
|
||||
geaendert = False
|
||||
for a in auftraege:
|
||||
if a["status"] == "laeuft" and jetzt - (a["abgeholt"] or jetzt) > VERLOREN_S:
|
||||
a.update(status="verloren", fertig=jetzt, text="Der Ausführer hat nie geantwortet.")
|
||||
geaendert = True
|
||||
auftrag = next((a for a in auftraege if a["status"] == "wartet"), None)
|
||||
if auftrag:
|
||||
auftrag.update(status="laeuft", abgeholt=jetzt)
|
||||
geaendert = True
|
||||
if geaendert:
|
||||
_merken(auftraege)
|
||||
return {k: auftrag[k] for k in ("id", "aktion", "parameter")} if auftrag else None
|
||||
|
||||
|
||||
def ergebnis(auftrag_id: str, code: int, text: str) -> bool:
|
||||
with _sperre():
|
||||
auftraege = _alle()
|
||||
auftrag = next((a for a in auftraege if a["id"] == auftrag_id), None)
|
||||
if auftrag is None or auftrag["status"] != "laeuft":
|
||||
return False
|
||||
auftrag.update(status="fertig" if code == 0 else "fehler", fertig=time.time(), code=code,
|
||||
text=str(text)[-20000:])
|
||||
_merken(auftraege)
|
||||
return True
|
||||
|
||||
|
||||
def auftrag(auftrag_id: str) -> dict | None:
|
||||
# Lesen unter derselben Sperre wie das Schreiben: Unter Windows scheitert das atomare Ersetzen,
|
||||
# solange ein anderer Thread die Datei offen hat.
|
||||
with _lock:
|
||||
return next((a for a in _alle() if a["id"] == auftrag_id), None)
|
||||
|
||||
|
||||
def warten(auftrag_id: str, zeitlimit_s: float, takt_s: float = 2.0) -> dict | None:
|
||||
"""Bis der Auftrag fertig ist (oder das Zeitlimit abläuft). Rückgabe: der Auftrag, None bei Zeitablauf."""
|
||||
ende = time.time() + zeitlimit_s
|
||||
while time.time() < ende:
|
||||
a = auftrag(auftrag_id)
|
||||
if a and a["status"] not in ("wartet", "laeuft"):
|
||||
return a
|
||||
time.sleep(takt_s)
|
||||
return None
|
||||
|
||||
|
||||
def liste() -> list[dict]:
|
||||
with _lock:
|
||||
return list(reversed(_alle()))
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Wartezeit nach einer Änderung am Update-Skript (24.09.2026).
|
||||
|
||||
„Jetzt updaten“ lässt im Gast `update` laufen. Das lädt ct/<kennung>.sh ungepinnt von GitHub
|
||||
(community-scripts/ProxmoxVE, Zweig main) und führt es als root aus. Eine Änderung, die erst ein paar Stunden
|
||||
alt ist, hat noch kaum jemand im Betrieb gesehen. Deshalb gilt für Apps mit dem Weg „skript“: Ist die letzte
|
||||
Änderung an ct/<kennung>.sh jünger als MC_HOMELAB_KARENZ_H Stunden (Standard 48), bleibt der Baustein „neu“,
|
||||
aber ohne Knopf, und updates.starten() lehnt ab — beide mit demselben Satz.
|
||||
|
||||
Wann das Skript zuletzt geändert wurde, sagt die GitHub-API (kern/github.py, 15 Minuten gemerkt). Lässt sich das
|
||||
nicht abfragen, wird nicht blockiert; die Rückfrage vor dem Update sagt es dann (NICHT_GEPRUEFT).
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from kern.github import github_json
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
REPO = "community-scripts/ProxmoxVE"
|
||||
STANDARD_H = 48.0
|
||||
NICHT_GEPRUEFT = "Ob das Skript kürzlich geändert wurde, ließ sich nicht prüfen."
|
||||
_KENNUNG = re.compile(r"^[a-z0-9-]+$")
|
||||
|
||||
|
||||
def karenz_s() -> float:
|
||||
"""Die Wartezeit in Sekunden (MC_HOMELAB_KARENZ_H; 0 schaltet sie ab)."""
|
||||
try:
|
||||
stunden = float(os.environ.get("MC_HOMELAB_KARENZ_H", "") or STANDARD_H)
|
||||
except ValueError:
|
||||
stunden = STANDARD_H
|
||||
return max(0.0, stunden) * 3600
|
||||
|
||||
|
||||
def skript_geaendert(kennung: str) -> float:
|
||||
"""Wann ct/<kennung>.sh auf main zuletzt geändert wurde (Unix-Sekunden). Wirft, wenn es sich nicht sagen lässt."""
|
||||
if not _KENNUNG.match(kennung or ""):
|
||||
raise ValueError(f"ungültige Kennung {kennung!r}")
|
||||
daten = github_json(f"repos/{REPO}/commits?path=ct/{kennung}.sh&sha=main&per_page=1")
|
||||
# Das Datum, an dem die Änderung auf main landete (committer, nicht author: ein Pull-Request kann älter sein).
|
||||
datum = str(daten[0]["commit"]["committer"]["date"])
|
||||
return datetime.fromisoformat(datum.replace("Z", "+00:00")).timestamp()
|
||||
|
||||
|
||||
def sperrtext(geaendert: float, frei_ab: float) -> str:
|
||||
a, b = datetime.fromtimestamp(geaendert, LOCAL_TZ), datetime.fromtimestamp(frei_ab, LOCAL_TZ)
|
||||
return f"Das Update-Skript wurde am {a:%d.%m.} geändert; zur Sicherheit erst ab {b:%d.%m. %H:%M}."
|
||||
|
||||
|
||||
def pruefen(kennung: str, jetzt: float | None = None) -> dict:
|
||||
"""{"gesperrt": Satz oder None, "geprueft": bool}. Gesperrt, solange die letzte Änderung am Skript jünger ist
|
||||
als die Wartezeit. Scheitert die Abfrage: nicht gesperrt, aber geprueft=False."""
|
||||
try:
|
||||
geaendert = skript_geaendert(kennung)
|
||||
except Exception:
|
||||
return {"gesperrt": None, "geprueft": False}
|
||||
frei_ab = geaendert + karenz_s()
|
||||
if (time.time() if jetzt is None else jetzt) >= frei_ab:
|
||||
return {"gesperrt": None, "geprueft": True}
|
||||
return {"gesperrt": sperrtext(geaendert, frei_ab), "geprueft": True}
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Der App-Katalog pflegt sich selbst (Ausbauplan Welle 2, Punkt 7, seit 25.09.2026).
|
||||
|
||||
Bis zum 25.09. kannte der Orchestrator nur die Apps, die in apps.KATALOG von Hand stehen; ein neuer Container zeigte
|
||||
sich ohne Versionsvergleich und ohne Webprüfung. Jetzt liest er für jede unbekannte Community-Scripts-Kennung einmal
|
||||
deren Update-Skript (ct/<kennung>.sh, öffentlich auf GitHub, derselbe Ort, den `update` im Gast lädt) und nimmt daraus:
|
||||
• den Namen (APP="…"),
|
||||
• das GitHub-Projekt für die neueste Version (Kopfzeile „Github: https://github.com/<owner>/<repo>“),
|
||||
• die Adresse der Oberfläche (die letzte Zeile „http(s)://${IP}:<port>“, die das Skript am Ende ausgibt),
|
||||
• den Weg des Updates: sagt das Skript, die App lasse sich nur über ihre eigene Oberfläche aktualisieren, dann
|
||||
„eigene-oberflaeche“ (kein Knopf), sonst „skript“ (`update` im Gast, mit der Wartezeit aus karenz.py).
|
||||
Den Port prüft er gleich mit: Antwortet dort nichts (der User hat ihn geändert), bleibt die Webprüfung aus — ein
|
||||
geratener Port darf keinen roten Hinweis „antwortet nicht“ auslösen. Die Angaben liegen in
|
||||
<Datenordner>/app-katalog.json; gelungene werden nach 7 Tagen neu gelesen, gescheiterte nach einem Tag. Die Angaben
|
||||
der Community-Scripts selbst (JSON) sind im Sommer 2026 aus dem Repository verschwunden, das Skript bleibt.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from kern.einstellungen import einstellungen
|
||||
|
||||
from services.homelab import apps
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
ROH = "https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/{kennung}.sh"
|
||||
NEU_LESEN_S, FEHLER_NEU_S = 7 * 86400, 86400
|
||||
ZEITLIMIT = httpx.Timeout(15.0, connect=5.0)
|
||||
_KENNUNG = re.compile(r"[a-z0-9][a-z0-9-]{0,62}")
|
||||
_GITHUB = re.compile(r"Github:\s*https?://github\.com/([\w.-]+/[\w.-]+?)(?:\.git)?/?\s*$", re.IGNORECASE | re.MULTILINE)
|
||||
_APP = re.compile(r'^APP="([^"]+)"', re.MULTILINE)
|
||||
_ADRESSE = re.compile(r"(https?)://\$\{IP\}:(\d{2,5})(/[^\s$\"']*)?")
|
||||
_NUR_OBERFLAECHE = re.compile(r"only be updated via|update(?:d)? (?:it )?(?:via|through|from) the (?:web ?)?(?:ui|user interface|"
|
||||
r"web interface|dashboard)", re.IGNORECASE)
|
||||
|
||||
_lock = threading.Lock()
|
||||
_stand: dict = {"mtime": None, "daten": {}}
|
||||
_laufend: set[str] = set()
|
||||
|
||||
|
||||
def _pfad() -> Path:
|
||||
return einstellungen().daten_dir / "app-katalog.json"
|
||||
|
||||
|
||||
def _lesen() -> dict:
|
||||
"""Der Katalog aus der Datei, gemerkt solange sie sich nicht ändert (app_fuer fragt oft)."""
|
||||
pfad = _pfad()
|
||||
try:
|
||||
mtime = pfad.stat().st_mtime_ns
|
||||
except OSError:
|
||||
return {}
|
||||
with _lock:
|
||||
if _stand["mtime"] == mtime:
|
||||
return _stand["daten"]
|
||||
try:
|
||||
daten = json.loads(pfad.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
daten = {}
|
||||
daten = daten if isinstance(daten, dict) else {}
|
||||
with _lock:
|
||||
_stand.update(mtime=mtime, daten=daten)
|
||||
return daten
|
||||
|
||||
|
||||
def _schreiben(daten: dict) -> None:
|
||||
pfad = _pfad()
|
||||
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = pfad.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
tmp.replace(pfad)
|
||||
|
||||
|
||||
def aus_skript(kennung: str, text: str) -> dict:
|
||||
"""Was das Update-Skript über die App verrät: {"name", "quelle", "port", "https", "pfad", "weg"}."""
|
||||
name = _APP.search(text)
|
||||
quelle = _GITHUB.search(text)
|
||||
adressen = _ADRESSE.findall(text)
|
||||
https, port, pfad = (adressen[-1][0] == "https", int(adressen[-1][1]), adressen[-1][2] or "/") if adressen \
|
||||
else (False, None, "/")
|
||||
return {"name": name.group(1).strip() if name else kennung, "quelle": quelle.group(1) if quelle else None,
|
||||
"port": port, "https": https, "pfad": pfad,
|
||||
"weg": "eigene-oberflaeche" if _NUR_OBERFLAECHE.search(text) else "skript"}
|
||||
|
||||
|
||||
def _antwortet(ip: str, port: int) -> bool:
|
||||
try:
|
||||
with socket.create_connection((ip, port), timeout=3):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def app(kennung: str | None) -> apps.App | None:
|
||||
"""Die App zu einer Kennung aus dem selbst gepflegten Katalog (None, wenn unbekannt oder das Lesen scheiterte)."""
|
||||
e = _lesen().get(kennung or "")
|
||||
if not isinstance(e, dict) or e.get("fehler") or not e.get("name"):
|
||||
return None
|
||||
return apps.App(kennung, str(e["name"]), e.get("quelle"), port=e.get("port"), https=bool(e.get("https")),
|
||||
pfad=str(e.get("pfad") or "/"), weg=str(e.get("weg") or "skript"))
|
||||
|
||||
|
||||
def eintragen(kennung: str, ip: str | None, holen=None, jetzt: float | None = None) -> dict:
|
||||
"""Eine Kennung (neu) eintragen: Skript lesen, auswerten, Port prüfen. Rückgabe: der Eintrag."""
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
holen = holen or (lambda k: httpx.get(ROH.format(kennung=k), timeout=ZEITLIMIT, follow_redirects=True))
|
||||
try:
|
||||
antwort = holen(kennung)
|
||||
antwort.raise_for_status()
|
||||
eintrag = {**aus_skript(kennung, antwort.text), "geholt": jetzt}
|
||||
except Exception as exc:
|
||||
eintrag = {"fehler": f"{exc.__class__.__name__}: {exc}"[:200], "geholt": jetzt}
|
||||
if eintrag.get("port") and ip and not _antwortet(ip, eintrag["port"]):
|
||||
eintrag.update(port_skript=eintrag["port"], port=None)
|
||||
daten = dict(_lesen())
|
||||
daten[kennung] = eintrag
|
||||
_schreiben(daten)
|
||||
log.info("katalog: %s eingetragen (%s)", kennung, eintrag.get("fehler") or eintrag.get("name"))
|
||||
return eintrag
|
||||
|
||||
|
||||
def fehlende(bericht: dict, jetzt: float | None = None) -> list[tuple[str, str | None]]:
|
||||
"""(Kennung, IP) der Apps im Bericht, die weder von Hand im Katalog stehen noch frisch gelesen sind."""
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
daten = _lesen()
|
||||
ergebnis = []
|
||||
for g in bericht.get("gaeste") or []:
|
||||
kennung = ((g.get("app") if isinstance(g.get("app"), dict) else {}) or {}).get("kennung")
|
||||
if not kennung or not _KENNUNG.fullmatch(kennung) or kennung in apps.KATALOG:
|
||||
continue
|
||||
e = daten.get(kennung)
|
||||
alter = jetzt - float((e or {}).get("geholt") or 0)
|
||||
if e is None or alter > (FEHLER_NEU_S if e.get("fehler") else NEU_LESEN_S):
|
||||
ergebnis.append((kennung, g.get("ip")))
|
||||
return ergebnis
|
||||
|
||||
|
||||
def ergaenzen(bericht: dict) -> None:
|
||||
"""Nach jedem Bericht: fehlende Apps im eigenen Faden nachtragen — der Bericht wartet nie auf GitHub."""
|
||||
for kennung, ip in fehlende(bericht):
|
||||
with _lock:
|
||||
if kennung in _laufend:
|
||||
continue
|
||||
_laufend.add(kennung)
|
||||
|
||||
def los(k: str = kennung, adresse: str | None = ip) -> None:
|
||||
try:
|
||||
eintragen(k, adresse)
|
||||
except Exception:
|
||||
log.warning("katalog: %s ließ sich nicht eintragen", k, exc_info=True)
|
||||
finally:
|
||||
with _lock:
|
||||
_laufend.discard(k)
|
||||
|
||||
threading.Thread(target=los, name=f"katalog-{kennung}", daemon=True).start()
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Eine Live-Zahl je Gerät für die Kacheln des Homelab-Cockpits und der Übersicht (Ausbauplan Welle 1, seit 25.09.2026).
|
||||
|
||||
Proxmox-Host wie viele Gäste laufen (aus dem Bericht)
|
||||
PBS wann zuletzt gesichert wurde (jüngste Sicherung auf den Zielen der Aufträge)
|
||||
NPMplus wie es um seine Zertifikate steht (Zertifikats-Wache, zertifikate.py)
|
||||
Arcane laufende von allen Containern (Arcane-API mit dem Schlüssel aus den Einstellungen)
|
||||
AdGuard Home Anteil geblockter Anfragen (nur mit hinterlegtem Zugang, Einstellungen; adguard.py)
|
||||
Je Ziel-ID {"text", "zeit"?, "stufe"?}: „zeit“ setzt die Oberfläche als „vor …“ dahinter, „stufe“ (gelb/rot) färbt die
|
||||
Zeile. Was sich nicht ermitteln lässt, fehlt — die Kachel zeigt dann nur ihren Stand. Arcane und AdGuard werden
|
||||
höchstens alle zwei Minuten gefragt.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from services.homelab import adguard, apps, arcane, zertifikate
|
||||
from services.homelab.inventar import EIGENES_ETIKETT
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
NPM = {"npmplus", "nginxproxymanager"}
|
||||
|
||||
|
||||
def _komma(zahl: float) -> str:
|
||||
return f"{zahl:.1f}".replace(".", ",") if zahl < 10 else str(round(zahl))
|
||||
|
||||
|
||||
def _npm(vmid: int) -> dict | None:
|
||||
eigene = [z for z in zertifikate.lage()["zertifikate"] if z["quelle"] == "npm" and z["gast"] == vmid]
|
||||
if not eigene:
|
||||
return None
|
||||
probleme = [z for z in eigene if z["stufe"] != "ok"]
|
||||
if not probleme:
|
||||
return {"text": f"{len(eigene)} Zertifikat{'e' if len(eigene) != 1 else ''} gültig"}
|
||||
stufe = "rot" if any(z["stufe"] == "rot" for z in probleme) else "gelb"
|
||||
return {"text": f"{len(probleme)} von {len(eigene)} Zertifikaten mit Problem", "stufe": stufe}
|
||||
|
||||
|
||||
def fuer_bericht(bericht: dict) -> dict[str, dict]:
|
||||
"""Ziel-ID → Kennzahl. Ein Fehler bei einem Gerät kostet nur dessen Zahl."""
|
||||
zahlen: dict[str, dict] = {}
|
||||
# Der eigene Container (Etikett „mc2“) zählt nicht mit — wie überall, wo die Geräte des Homelabs stehen.
|
||||
gaeste = [g for g in bericht.get("gaeste") or [] if isinstance(g, dict) and "vmid" in g
|
||||
and EIGENES_ETIKETT not in (g.get("etiketten") or [])]
|
||||
if gaeste:
|
||||
laufend = sum(1 for g in gaeste if g.get("status") == "running")
|
||||
zahlen["pve"] = {"text": f"{laufend} von {len(gaeste)} Gästen an"}
|
||||
ziele = (bericht.get("host") or {}).get("sicherungsziele") or {}
|
||||
for g in gaeste:
|
||||
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
|
||||
if not app or g.get("status") != "running":
|
||||
continue
|
||||
zid = f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g['vmid']}"
|
||||
try:
|
||||
if app.kennung == "proxmox-backup-server":
|
||||
zuletzt = max((t for z in ziele.values() if isinstance(z, dict)
|
||||
for t in (z.get("neueste") or {}).values() if isinstance(t, int)), default=None)
|
||||
if zuletzt:
|
||||
zahlen[zid] = {"text": "letzte Sicherung", "zeit": zuletzt}
|
||||
elif app.kennung in NPM:
|
||||
if zahl := _npm(g["vmid"]):
|
||||
zahlen[zid] = zahl
|
||||
elif app.kennung == "arcane" and (url := apps.adresse(app, g.get("ip"))):
|
||||
container = arcane.container_zahlen(url.rstrip("/"))
|
||||
if container and container["gesamt"]:
|
||||
zahlen[zid] = {"text": f"{container['laufend']} von {container['gesamt']} Containern an"}
|
||||
elif app.kennung == "adguard":
|
||||
statistik = adguard.geblockt()
|
||||
if statistik and statistik.get("anteil") is not None:
|
||||
zahlen[zid] = {"text": f"{_komma(statistik['anteil'])} % geblockt"}
|
||||
except Exception:
|
||||
log.warning("kennzahlen: %s ließ sich nicht ermitteln", zid, exc_info=True)
|
||||
return zahlen
|
||||
@@ -0,0 +1,249 @@
|
||||
"""Messwerte des Homelabs mit Verlauf (Monitoring, seit 24.09.2026).
|
||||
|
||||
Der Ausführer auf dem Proxmox-Host schickt jede Minute POST /api/homelab/ausfuehrer/messwerte (eigener Faden, unabhängig
|
||||
vom 10-min-Bericht und von Aufträgen):
|
||||
{"zeit": Unix-Sekunden der Messung,
|
||||
"host": {"cpu": 0–1 (Mittel der Minute), "speicher": {"belegt", "gesamt"}, "rootfs": {"belegt", "gesamt"}, "load1",
|
||||
"uptime", "temp_cpu" (°C oder null), "netz": {"rx", "tx"} (kumulativ, Bytes)},
|
||||
"gaeste": [{"vmid", "art" (lxc|qemu), "name", "etiketten", "status", "cpu" (0–1 der eigenen Kerne), "maxcpu", "mem",
|
||||
"maxmem", "disk", "maxdisk", "netin", "netout" (kumulativ, Bytes), "uptime"}]}
|
||||
Hier wird daraus je Gerät ein Punkt über kern/messreihen.py (Quellen „pve“, „ct-<vmid>“, „vm-<vmid>“ wie im Inventar):
|
||||
CPU, RAM und Platte in %, Netz in Bytes/s aus der Differenz zum vorigen Zählerstand. Ist ein Zähler kleiner geworden
|
||||
oder die Laufzeit (Neustart des Gasts oder Hosts), fehlt der vorige Stand (Neustart dieses Teils) oder liegt er mehr als
|
||||
fünf Minuten zurück, bleibt die Rate dieser Minute null. Gestoppte Gäste haben keine Messwerte (null), keine Nullen.
|
||||
Der eigene Container (Etikett „mc2“) wird wie im Inventar nicht erfasst. Die Platte von VMs sieht Proxmox nicht (null).
|
||||
|
||||
GET /api/homelab/messwerte liefert je Gerät die Reihen (1h, 24h, 7d) und den letzten Punkt; pruefe_host() meldet dem
|
||||
Wächter, wenn der Host zehn Minuten lang über 95 % RAM oder 90 °C liegt.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
|
||||
from kern import messreihen
|
||||
|
||||
from services.homelab import apps, inventar, kanal, speicher
|
||||
|
||||
HOST = "pve"
|
||||
_GAST = re.compile(r"(ct|vm)-\d+")
|
||||
ZEITRAEUME = messreihen.ZEITRAEUME
|
||||
HOST_REIHEN = ("cpu", "ram", "platte", "netz_rx", "netz_tx", "temp_cpu")
|
||||
GAST_REIHEN = ("cpu", "ram", "platte", "netz_rx", "netz_tx")
|
||||
AKTUELL = ("cpu", "ram", "ram_used", "ram_total", "platte", "netz_rx", "netz_tx", "temp_cpu", "load1", "uptime_s")
|
||||
AKTUELL_MAX_S = 180 # älter ist der letzte Punkt nicht „aktuell“ (der Ausführer schickt gerade nichts)
|
||||
ZEIT_TOLERANZ_S = 120 # die Messzeit des Ausführers gilt, wenn sie so nah an der eigenen Uhr liegt
|
||||
|
||||
# Wächter: der Host zehn Minuten lang über diesen Schwellen → gelber Hinweis.
|
||||
WACHE_S = 600
|
||||
WACHE_MIN_PUNKTE = 8 # so viele Minutenpunkte müssen in den zehn Minuten liegen (ein, zwei dürfen fehlen)
|
||||
RAM_GELB = float(os.environ.get("MC_WAECHTER_HOST_RAM", "95"))
|
||||
TEMP_GELB = float(os.environ.get("MC_WAECHTER_HOST_TEMP", "90"))
|
||||
|
||||
_lock = threading.Lock()
|
||||
# Vorige Zählerstände je Gerät (für die Raten) und die Geräte der letzten Meldung (für Namen und die Geräteliste).
|
||||
# Nur im Speicher: Nach einem Neustart dieses Teils fehlt für eine Minute die Netz-Rate, sonst nichts.
|
||||
_zustand: dict = {"zaehler": {}, "geraete": {}}
|
||||
|
||||
|
||||
def _zahl(wert: object) -> float | None:
|
||||
return float(wert) if messreihen.ist_zahl(wert) else None
|
||||
|
||||
|
||||
def _ganz(wert: object) -> int | None:
|
||||
return int(wert) if messreihen.ist_zahl(wert) else None
|
||||
|
||||
|
||||
def _prozent(anteil: object) -> float | None:
|
||||
"""0–1 → %, eine Nachkommastelle, auf 0–100 begrenzt."""
|
||||
return round(min(100.0, max(0.0, float(anteil) * 100)), 1) if messreihen.ist_zahl(anteil) else None
|
||||
|
||||
|
||||
def _anteil(teil: object, ganz: object) -> float | None:
|
||||
if not (messreihen.ist_zahl(teil) and messreihen.ist_zahl(ganz)) or float(ganz) <= 0:
|
||||
return None
|
||||
return _prozent(float(teil) / float(ganz))
|
||||
|
||||
|
||||
def _dict(wert: object) -> dict:
|
||||
return wert if isinstance(wert, dict) else {}
|
||||
|
||||
|
||||
def gast_id(gast: dict) -> str | None:
|
||||
"""ct-<vmid> bzw. vm-<vmid> wie im Inventar; None bei allem, was kein Gast ist."""
|
||||
vmid, art = gast.get("vmid"), gast.get("art")
|
||||
if not isinstance(vmid, int) or isinstance(vmid, bool) or not 100 <= vmid <= 999_999_999:
|
||||
return None
|
||||
return {"lxc": f"ct-{vmid}", "qemu": f"vm-{vmid}"}.get(art)
|
||||
|
||||
|
||||
def _art(geraet_id: str) -> str:
|
||||
return "proxmox-host" if geraet_id == HOST else "container" if geraet_id.startswith("ct-") else "vm"
|
||||
|
||||
|
||||
# --- Zähler → Bytes/s ------------------------------------------------------------------------------
|
||||
|
||||
def raten(vorher: dict | None, jetzt: dict) -> tuple[int | None, int | None]:
|
||||
"""Empfangen und gesendet in Bytes/s zwischen zwei Zählerständen {"t", "rx", "tx", "uptime", "laeuft"}. None, wenn
|
||||
es keinen vorigen Stand gibt, einer der beiden nicht lief, die Laufzeit kleiner wurde (neu gestartet: die Zähler
|
||||
begannen von vorn) oder messreihen.rate() den Zuwachs verwirft (Zähler kleiner, Lücke, Sprung)."""
|
||||
if not vorher or not vorher.get("laeuft") or not jetzt.get("laeuft"):
|
||||
return None, None
|
||||
alt_lauf, neu_lauf = vorher.get("uptime"), jetzt.get("uptime")
|
||||
if messreihen.ist_zahl(alt_lauf) and messreihen.ist_zahl(neu_lauf) and neu_lauf < alt_lauf:
|
||||
return None, None
|
||||
dt = jetzt["t"] - vorher["t"]
|
||||
rx, tx = messreihen.rate(vorher.get("rx"), jetzt.get("rx"), dt), messreihen.rate(vorher.get("tx"), jetzt.get("tx"), dt)
|
||||
return (None if rx is None else round(rx)), (None if tx is None else round(tx))
|
||||
|
||||
|
||||
def _zaehlen(geraet_id: str, t: float, rx: object, tx: object, uptime: object, laeuft: bool) -> tuple:
|
||||
"""Den neuen Zählerstand merken und die Raten gegen den vorigen liefern (unter _lock aufrufen)."""
|
||||
stand = {"t": t, "rx": rx, "tx": tx, "uptime": uptime, "laeuft": laeuft}
|
||||
ergebnis = raten(_zustand["zaehler"].get(geraet_id), stand)
|
||||
_zustand["zaehler"][geraet_id] = stand
|
||||
return ergebnis
|
||||
|
||||
|
||||
def _host_punkt(host: dict, t: float) -> dict:
|
||||
speicher, rootfs, netz = _dict(host.get("speicher")), _dict(host.get("rootfs")), _dict(host.get("netz"))
|
||||
rx, tx = _zaehlen(HOST, t, netz.get("rx"), netz.get("tx"), host.get("uptime"), True)
|
||||
temp, last = _zahl(host.get("temp_cpu")), _zahl(host.get("load1"))
|
||||
return {"cpu": _prozent(host.get("cpu")),
|
||||
"ram": _anteil(speicher.get("belegt"), speicher.get("gesamt")),
|
||||
"ram_used": _ganz(speicher.get("belegt")), "ram_total": _ganz(speicher.get("gesamt")),
|
||||
"platte": _anteil(rootfs.get("belegt"), rootfs.get("gesamt")),
|
||||
"netz_rx": rx, "netz_tx": tx,
|
||||
"temp_cpu": None if temp is None else round(temp, 1),
|
||||
"load1": None if last is None else round(last, 2),
|
||||
"uptime_s": _ganz(host.get("uptime"))}
|
||||
|
||||
|
||||
def _gast_punkt(geraet_id: str, gast: dict, t: float) -> dict:
|
||||
laeuft = gast.get("status") == "running"
|
||||
rx, tx = _zaehlen(geraet_id, t, gast.get("netin"), gast.get("netout"), gast.get("uptime"), laeuft)
|
||||
if not laeuft:
|
||||
return {"cpu": None, "ram": None, "ram_used": None, "ram_total": _ganz(gast.get("maxmem")), "platte": None,
|
||||
"netz_rx": None, "netz_tx": None, "uptime_s": _ganz(gast.get("uptime"))}
|
||||
# Proxmox rechnet die CPU eines Gasts schon auf seine eigenen Kerne (1,0 = alle voll). Die Belegung der Platte
|
||||
# kennt es nur bei Containern; bei VMs steht dort immer 0.
|
||||
disk = gast.get("disk")
|
||||
platte = _anteil(disk, gast.get("maxdisk")) if gast.get("art") == "lxc" and messreihen.ist_zahl(disk) and disk else None
|
||||
return {"cpu": _prozent(gast.get("cpu")),
|
||||
"ram": _anteil(gast.get("mem"), gast.get("maxmem")),
|
||||
"ram_used": _ganz(gast.get("mem")), "ram_total": _ganz(gast.get("maxmem")),
|
||||
"platte": platte, "netz_rx": rx, "netz_tx": tx, "uptime_s": _ganz(gast.get("uptime"))}
|
||||
|
||||
|
||||
def annehmen(daten: dict, jetzt: float | None = None) -> int:
|
||||
"""Einen Minutenpunkt des Ausführers speichern. Rückgabe: für wie viele Geräte. Kaputte Einträge fallen still weg."""
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
zeit = daten.get("zeit")
|
||||
t = float(zeit) if messreihen.ist_zahl(zeit) and abs(float(zeit) - jetzt) <= ZEIT_TOLERANZ_S else jetzt
|
||||
punkte: list[tuple[str, dict]] = []
|
||||
with _lock:
|
||||
if isinstance(daten.get("host"), dict):
|
||||
punkte.append((HOST, _host_punkt(daten["host"], t)))
|
||||
gaeste = daten.get("gaeste") if isinstance(daten.get("gaeste"), list) else []
|
||||
geraete = {}
|
||||
for gast in gaeste:
|
||||
if not isinstance(gast, dict) or inventar.EIGENES_ETIKETT in (gast.get("etiketten") or []):
|
||||
continue
|
||||
if not (gid := gast_id(gast)):
|
||||
continue
|
||||
punkte.append((gid, _gast_punkt(gid, gast, t)))
|
||||
geraete[gid] = str(gast.get("name") or gid)
|
||||
if gaeste:
|
||||
_zustand["geraete"] = geraete
|
||||
for quelle, werte in punkte:
|
||||
messreihen.schreiben(quelle, werte, t)
|
||||
return len(punkte)
|
||||
|
||||
|
||||
# --- Lesen ------------------------------------------------------------------------------------------
|
||||
|
||||
def _geraete(von: float, bis: float) -> list[dict]:
|
||||
"""Die Geräte wie im Inventar: der Host und die Gäste aus dem letzten Bericht (Namen aus apps.py), dazu Gäste, die
|
||||
erst die Messwerte kennen. Ohne beides: alle Quellen mit Dateien im Zeitraum."""
|
||||
eingang = kanal.bericht()
|
||||
geraete: dict[str, dict] = {}
|
||||
eigene: set[str] = set()
|
||||
if eingang:
|
||||
geraete[HOST] = {"id": HOST, "name": "Proxmox-Host", "art": "proxmox-host"}
|
||||
for gast in (eingang or {}).get("bericht", {}).get("gaeste") or []:
|
||||
if not (gid := gast_id(gast)):
|
||||
continue
|
||||
if inventar.EIGENES_ETIKETT in (gast.get("etiketten") or []):
|
||||
eigene.add(gid)
|
||||
continue
|
||||
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
|
||||
geraete[gid] = {"id": gid, "name": app.name if app else str(gast.get("name") or gid), "art": _art(gid)}
|
||||
with _lock:
|
||||
gemeldet = dict(_zustand["geraete"])
|
||||
host_gemeldet = HOST in _zustand["zaehler"]
|
||||
if host_gemeldet:
|
||||
geraete.setdefault(HOST, {"id": HOST, "name": "Proxmox-Host", "art": "proxmox-host"})
|
||||
for gid, name in gemeldet.items():
|
||||
if gid not in geraete and gid not in eigene:
|
||||
app = apps.app_fuer(None, name)
|
||||
geraete[gid] = {"id": gid, "name": app.name if app else name, "art": _art(gid)}
|
||||
if not geraete:
|
||||
for quelle in messreihen.quellen(von, bis):
|
||||
if quelle == HOST or _GAST.fullmatch(quelle):
|
||||
geraete[quelle] = {"id": quelle, "name": "Proxmox-Host" if quelle == HOST else quelle,
|
||||
"art": _art(quelle)}
|
||||
# Reihenfolge wie im Inventar: der Host, dann die Gäste nach Nummer.
|
||||
return sorted(geraete.values(), key=lambda g: (g["id"] != HOST, int(g["id"].split("-")[1]) if g["id"] != HOST else 0))
|
||||
|
||||
|
||||
def abfrage(zeitraum: str, jetzt: float | None = None) -> dict:
|
||||
"""Für GET /api/homelab/messwerte: {"zeitraum", "schritt_s", "geraete": [{"id", "name", "art", "reihen",
|
||||
"aktuell", "prognose"}]}. temp_cpu gibt es als Reihe nur beim Host; in „aktuell“ stehen temp_cpu und load1 bei
|
||||
Gästen auf null. prognose.platte: „voll in etwa N Tagen“ (kern/prognose.py, seit 25.09.2026)."""
|
||||
dauer, schritt = messreihen.zeitraum_pruefen(zeitraum)
|
||||
zeit = time.time() if jetzt is None else jetzt
|
||||
geraete = []
|
||||
for geraet in _geraete(zeit - dauer, zeit):
|
||||
host = geraet["id"] == HOST
|
||||
daten = messreihen.lesen(geraet["id"], zeitraum, HOST_REIHEN if host else GAST_REIHEN, zeit)
|
||||
letzter = messreihen.letzter_punkt(geraet["id"], zeit, AKTUELL_MAX_S) or {}
|
||||
aktuell = {name: letzter.get(name) for name in AKTUELL}
|
||||
if not host:
|
||||
aktuell["temp_cpu"] = aktuell["load1"] = None
|
||||
geraete.append({**geraet, "reihen": daten["reihen"], "aktuell": aktuell,
|
||||
"prognose": {"platte": speicher.platten_vorhersage(geraet["id"], jetzt)}})
|
||||
return {"zeitraum": zeitraum, "schritt_s": schritt, "geraete": geraete}
|
||||
|
||||
|
||||
# --- Wächter (Rolle homelab, registriert in services/waechter.py) -------------------------------------------
|
||||
|
||||
def _gb(anzahl: object) -> str:
|
||||
return f"{float(anzahl) / 1e9:.1f}".replace(".", ",") if messreihen.ist_zahl(anzahl) else "?"
|
||||
|
||||
|
||||
def pruefe_host(jetzt: float | None = None) -> list:
|
||||
"""Gelb, wenn der Proxmox-Host zehn Minuten lang über RAM_GELB % Arbeitsspeicher oder TEMP_GELB °C CPU-Temperatur
|
||||
liegt. Ohne genug Messungen (Ausführer schweigt, gerade erst gestartet) kein Befund — das Schweigen meldet
|
||||
pruefe_ausfuehrer()."""
|
||||
from services.waechter import Befund # der Wächter registriert diese Prüfung, also erst hier
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
punkte = messreihen.punkte(HOST, jetzt - WACHE_S, jetzt + 1)
|
||||
befunde = []
|
||||
ram = [p["ram"] for p in punkte if messreihen.ist_zahl(p.get("ram"))]
|
||||
if len(ram) >= WACHE_MIN_PUNKTE and min(ram) > RAM_GELB:
|
||||
letzter = next(p for p in reversed(punkte) if messreihen.ist_zahl(p.get("ram")))
|
||||
befunde.append(Befund(
|
||||
id="host-ram", stufe="gelb", titel=f"Proxmox-Host: Arbeitsspeicher seit 10 Minuten über {RAM_GELB:.0f} %",
|
||||
text=(f"Zuletzt {ram[-1]:.0f} % belegt ({_gb(letzter.get('ram_used'))} von {_gb(letzter.get('ram_total'))} "
|
||||
"GB). Wird es noch enger, beendet der Kernel Prozesse, auch in den Gästen. Die Messwerte zeigen, "
|
||||
"welcher Gast wie viel braucht; einem davon weniger Speicher geben oder ihn stoppen."),
|
||||
quelle=HOST, sofort=True))
|
||||
temp = [p["temp_cpu"] for p in punkte if messreihen.ist_zahl(p.get("temp_cpu"))]
|
||||
if len(temp) >= WACHE_MIN_PUNKTE and min(temp) > TEMP_GELB:
|
||||
befunde.append(Befund(
|
||||
id="host-temp", stufe="gelb", titel=f"Proxmox-Host: CPU seit 10 Minuten über {TEMP_GELB:.0f} °C",
|
||||
text=(f"Zuletzt {temp[-1]:.0f} °C. So heiß drosselt sich die CPU, und auf Dauer leidet die Hardware. "
|
||||
"Lüfter und Luftwege prüfen (Staub) und in den Messwerten nachsehen, welcher Gast die Last macht."),
|
||||
quelle=HOST, sofort=True))
|
||||
return befunde
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Was ein Update bringt, vor dem Knopf (Ausbauplan Welle 2, Punkt 9, seit 25.09.2026).
|
||||
|
||||
App (Community-Script mit GitHub-Projekt) die Release Notes aller Versionen zwischen installiert und neu
|
||||
(GitHub-API, höchstens zehn, je auf 4 000 Zeichen gekürzt)
|
||||
Proxmox-Host (Pakete) die Debian-Änderungslisten der wichtigsten Pakete (Proxmox, Kernel,
|
||||
QEMU, LXC, ZFS …, höchstens acht) — der Ausführer holt sie nur lesend
|
||||
(Aktion changelog), jeweils bis zur installierten Version
|
||||
Lucy fasst das auf Deutsch zusammen (Modell `fast` der KI-Box über deren /v1, Aufbau wie bei den Updates der Box):
|
||||
eine erste Zeile, ob der User selbst etwas tun muss („Handarbeit“), dann höchstens fünf Stichpunkte, Brüche und
|
||||
geänderte Einstellungen zuerst und mit ⚠️. Fakten kommen aus den Notes, die Worte vom Modell. Antwortet die Box
|
||||
nicht, stehen die Notes ohne Kurzfassung da. Ergebnisse werden je (Gerät, Baustein, von, nach) gemerkt
|
||||
(<Datenordner>/neuerungen.json) — dieselbe Version fasst Lucy nur einmal zusammen.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from kern.einstellungen import einstellungen
|
||||
from kern.github import github_json
|
||||
|
||||
from services.homelab import apps, inventar, kanal
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
MAX_VERSIONEN = 10
|
||||
MAX_ZEICHEN = 4000
|
||||
MAX_PAKETE = 8
|
||||
WICHTIG = re.compile(r"pve|proxmox|qemu|lxc|kernel|zfs|ceph|corosync|spiceterm|novnc|libpve")
|
||||
ZEITLIMIT_LLM = httpx.Timeout(120.0, connect=5.0)
|
||||
FEHLER_MERKEN_S = 600
|
||||
_AKTION = re.compile(r"^\s*AKTION:\s*(JA|NEIN)\b\s*[—–-]?\s*(.*)$", re.IGNORECASE)
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _pfad() -> Path:
|
||||
return einstellungen().daten_dir / "neuerungen.json"
|
||||
|
||||
|
||||
def _gemerkt() -> dict:
|
||||
try:
|
||||
daten = json.loads(_pfad().read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return daten if isinstance(daten, dict) else {}
|
||||
|
||||
|
||||
def _merken(schluessel: str, ergebnis: dict) -> None:
|
||||
with _lock:
|
||||
daten = _gemerkt()
|
||||
daten[schluessel] = {"zeit": time.time(), "ergebnis": ergebnis}
|
||||
# Nur die jüngsten 100 Einträge: alte Versionen braucht niemand mehr.
|
||||
daten = dict(sorted(daten.items(), key=lambda t: t[1].get("zeit", 0))[-100:])
|
||||
pfad = _pfad()
|
||||
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = pfad.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(daten, ensure_ascii=False), encoding="utf-8")
|
||||
tmp.replace(pfad)
|
||||
|
||||
|
||||
# --- Quellen -----------------------------------------------------------------------------------------------------
|
||||
|
||||
def zwischen(releases: object, installiert: str, neu: str) -> list[dict]:
|
||||
"""Die Releases mit installiert < Version <= neu, neueste zuerst (ohne Entwürfe und Vorabversionen)."""
|
||||
von, bis = inventar._teile(installiert), inventar._teile(neu)
|
||||
if not von or not bis:
|
||||
return []
|
||||
auswahl = []
|
||||
for r in releases if isinstance(releases, list) else []:
|
||||
if not isinstance(r, dict) or r.get("draft") or r.get("prerelease"):
|
||||
continue
|
||||
version = inventar._teile(str(r.get("tag_name") or ""))
|
||||
if version and von < version <= bis:
|
||||
auswahl.append((version, {"version": str(r.get("tag_name")), "titel": r.get("name") or r.get("tag_name"),
|
||||
"datum": r.get("published_at"), "url": r.get("html_url"),
|
||||
"text": str(r.get("body") or "").strip()[:MAX_ZEICHEN]}))
|
||||
auswahl.sort(key=lambda t: t[0], reverse=True)
|
||||
return [r for _, r in auswahl[:MAX_VERSIONEN]]
|
||||
|
||||
|
||||
def wichtige_pakete(updates: list[dict]) -> list[str]:
|
||||
"""Die Pakete, deren Änderungsliste zählt: Proxmox, Kernel und Co. zuerst, höchstens MAX_PAKETE."""
|
||||
namen = [str(u.get("paket")) for u in updates if isinstance(u, dict) and u.get("paket")]
|
||||
return sorted(namen, key=lambda n: (not WICHTIG.search(n), n))[:MAX_PAKETE]
|
||||
|
||||
|
||||
def _changelogs(pakete: list[str]) -> dict[str, str]:
|
||||
"""Über den Ausführer (Aktion changelog, nur lesend). Leer, wenn er nicht antwortet."""
|
||||
auftrag = kanal.anlegen("changelog", {"pakete": pakete})
|
||||
ergebnis = kanal.warten(auftrag, 120)
|
||||
if not ergebnis or ergebnis.get("status") != "fertig":
|
||||
return {}
|
||||
try:
|
||||
daten = json.loads(ergebnis.get("text") or "{}")
|
||||
except ValueError:
|
||||
return {}
|
||||
return {k: str(v) for k, v in daten.items()} if isinstance(daten, dict) else {}
|
||||
|
||||
|
||||
# --- Lucys Kurzfassung ---------------------------------------------------------------------------------------------
|
||||
|
||||
def auswerten(text: str) -> dict:
|
||||
"""Die Antwort des Modells: {"aktion": bool|None, "aktion_text", "punkte": […]}. Tolerant: Die AKTION-Zeile darf
|
||||
irgendwo stehen, Stichpunkte beginnen mit „-“ oder „•“."""
|
||||
aktion, aktion_text, punkte = None, "", []
|
||||
for zeile in text.splitlines():
|
||||
if aktion is None and (m := _AKTION.match(zeile)):
|
||||
aktion, aktion_text = m.group(1).upper() == "JA", m.group(2).strip()
|
||||
elif zeile.strip().startswith(("-", "•", "*")):
|
||||
punkte.append(zeile.strip().lstrip("-•* ").strip())
|
||||
return {"aktion": aktion, "aktion_text": aktion_text, "punkte": [p for p in punkte if p][:5]}
|
||||
|
||||
|
||||
def zusammenfassen(was: str, von: str, nach: str, quellen: str) -> dict | None:
|
||||
"""Lucy fasst zusammen (Modell `fast` der KI-Box). None, wenn es keine Box gibt oder sie nicht antwortet."""
|
||||
box = einstellungen().partner_url
|
||||
if not box or not quellen.strip():
|
||||
return None
|
||||
prompt = (
|
||||
f"Du bist Lucy und betreust ein Homelab. Vor einem Update von {was} ({von} → {nach}) liest du die "
|
||||
"Änderungen und sagst dem Besitzer, was ihn betrifft. Die Änderungen:\n\n" + quellen[:14000] + "\n\n"
|
||||
"Antworte auf DEUTSCH, knapp und klar, GENAU in diesem Format:\n"
|
||||
"Zeile 1 beginnt mit 'AKTION: ':\n"
|
||||
" • 'AKTION: NEIN' — wenn er nichts selbst tun muss (der Orchestrator legt vorher einen Snapshot an und geht "
|
||||
"bei Problemen von allein zurück). Das ist der Normalfall.\n"
|
||||
" • 'AKTION: JA — <was er konkret tun muss>' — NUR bei Handarbeit: Migration, geänderte oder entfernte "
|
||||
"Einstellungen, neue Pflichtangaben, Brüche.\n"
|
||||
"Danach höchstens 5 kurze Stichpunkte (je mit '- '), Wichtigstes zuerst: Brüche und geänderte Einstellungen "
|
||||
"ZUERST und mit '⚠️', dann Sicherheitskorrekturen, dann lohnende Neuerungen. Keine Einleitung, keine "
|
||||
"Überschrift, nichts erfinden, was nicht in den Änderungen steht.")
|
||||
try:
|
||||
r = httpx.post(f"{box}/v1/chat/completions", timeout=ZEITLIMIT_LLM, json={
|
||||
"model": "fast", "max_tokens": 600, "temperature": 0.2,
|
||||
"chat_template_kwargs": {"enable_thinking": False},
|
||||
"messages": [{"role": "user", "content": prompt}]})
|
||||
r.raise_for_status()
|
||||
wahl = (r.json().get("choices") or [{}])[0]
|
||||
text = ((wahl.get("message") or {}).get("content") or "").strip()
|
||||
except (httpx.HTTPError, ValueError, AttributeError):
|
||||
log.warning("neuerungen: Lucy war nicht zu erreichen", exc_info=True)
|
||||
return None
|
||||
if wahl.get("finish_reason") == "length":
|
||||
text = text.rpartition("\n")[0]
|
||||
ergebnis = auswerten(text)
|
||||
return ergebnis if ergebnis["aktion"] is not None or ergebnis["punkte"] else None
|
||||
|
||||
|
||||
# --- Für die Oberfläche --------------------------------------------------------------------------------------------
|
||||
|
||||
def _baustein(zid: str, baustein: str) -> tuple[dict | None, dict | None]:
|
||||
ziel = next((z for z in inventar.ziele()["ziele"] if z["id"] == zid), None)
|
||||
b = next((x for x in (ziel or {}).get("bausteine") or [] if x["id"] == baustein), None)
|
||||
return ziel, b
|
||||
|
||||
|
||||
def fuer(zid: str, baustein: str) -> dict:
|
||||
"""GET /api/homelab/ziele/{zid}/neuerungen?baustein=…: {"verfuegbar", "grund", "von", "nach", "versionen" |
|
||||
"pakete", "zusammenfassung", "quelle_url"}."""
|
||||
ziel, b = _baustein(zid, baustein)
|
||||
if not ziel or not b or b.get("zustand") != "neu":
|
||||
return {"verfuegbar": False, "grund": "Für diesen Baustein steht kein Update an."}
|
||||
von, nach = str(b.get("installiert") or ""), str(b.get("verfuegbar") or b.get("kurz") or "")
|
||||
schluessel = f"{zid}:{baustein}:{von}:{nach}"
|
||||
alt = _gemerkt().get(schluessel)
|
||||
if alt and (alt["ergebnis"].get("zusammenfassung") or time.time() - alt.get("zeit", 0) < FEHLER_MERKEN_S):
|
||||
return alt["ergebnis"]
|
||||
if zid == "pve" and baustein == "pakete":
|
||||
ergebnis = _host(von, nach)
|
||||
elif baustein == "app":
|
||||
ergebnis = _app(ziel, von, nach)
|
||||
else:
|
||||
return {"verfuegbar": False, "grund": "Für Paket-Updates im Gast und Docker-Images gibt es keine Release Notes."}
|
||||
if ergebnis.get("verfuegbar"):
|
||||
_merken(schluessel, ergebnis)
|
||||
return ergebnis
|
||||
|
||||
|
||||
def _app(ziel: dict, von: str, nach: str) -> dict:
|
||||
gast = inventar.gast(ziel["id"]) or {}
|
||||
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
|
||||
if not app or not app.quelle:
|
||||
return {"verfuegbar": False, "grund": "Zu dieser App kennt der Orchestrator kein GitHub-Projekt."}
|
||||
url = f"https://github.com/{app.quelle}/releases"
|
||||
try:
|
||||
versionen = zwischen(github_json(f"repos/{app.quelle}/releases?per_page=40"), von, nach)
|
||||
except Exception:
|
||||
return {"verfuegbar": False, "grund": "GitHub antwortet gerade nicht.", "quelle_url": url}
|
||||
if not versionen:
|
||||
return {"verfuegbar": False, "grund": "Zu diesen Versionen stehen auf GitHub keine Release Notes.", "quelle_url": url}
|
||||
quellen = "\n\n".join(f"## {v['titel']} ({v['version']})\n{v['text']}" for v in versionen)
|
||||
return {"verfuegbar": True, "grund": None, "von": von, "nach": nach, "versionen": versionen, "quelle_url": url,
|
||||
"zusammenfassung": zusammenfassen(app.name, von, nach, quellen)}
|
||||
|
||||
|
||||
def _host(von: str, nach: str) -> dict:
|
||||
host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}
|
||||
pakete = wichtige_pakete(host.get("updates") or [])
|
||||
if not pakete:
|
||||
return {"verfuegbar": False, "grund": "Der Bericht nennt keine Pakete."}
|
||||
logs = _changelogs(pakete)
|
||||
if not logs:
|
||||
return {"verfuegbar": False, "grund": "Der Ausführer hat die Änderungslisten nicht geliefert."}
|
||||
alle = len(host.get("updates") or [])
|
||||
quellen = "\n\n".join(f"## {name}\n{text}" for name, text in logs.items())
|
||||
return {"verfuegbar": True, "grund": None, "von": von, "nach": nach,
|
||||
"pakete": [{"name": n, "text": t} for n, t in logs.items()], "weitere": max(0, alle - len(logs)),
|
||||
"zusammenfassung": zusammenfassen(f"Proxmox VE ({alle} Pakete)", von or "installiert", nach or "neu",
|
||||
quellen)}
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Pflege im Hintergrund: wöchentlich nach Updates suchen (24.09.2026).
|
||||
|
||||
Die Übersicht ist nur so ehrlich wie die Paketlisten in den Gästen, und die erneuert dort niemand von selbst
|
||||
(AdGuard stand im September 2026 auf Listen vom Oktober 2025). Deshalb stößt der Homelab-Teil den Auftrag
|
||||
„suchen“ (apt-get update bzw. apk update im Gast, ändert keine Pakete) selbst an, sobald die Listen eines Gasts
|
||||
älter als sieben Tage sind:
|
||||
|
||||
• nur freigegebene, laufende Container — der Ausführer prüft das Etikett ohnehin selbst noch einmal
|
||||
• höchstens ein Auftrag je Gast und Tag; nie während eines Update-Laufs für diesen Gast und nie, solange für
|
||||
ihn ein anderer Auftrag offen ist; nur, wenn der Ausführer gerade berichtet
|
||||
• bevorzugt nachts 02:00–05:00 (Berlin); war die Instanz oder der Ausführer in der letzten Nacht nicht da,
|
||||
eben gleich
|
||||
• keine Meldungen. Scheitert die Suche für einen Gast FEHLSCHLAEGE_HINWEIS-mal hintereinander, wird daraus
|
||||
ein gelber Hinweis des Wächters (gelb geht nicht an Telegram).
|
||||
|
||||
Der Steward der Rolle homelab ruft pruefe_paketlisten() in jedem Wächter-Takt auf (services/waechter.py,
|
||||
PRUEFUNGEN). Zustand in <Datenordner>/homelab-pflege.json; den schreibt nur der Steward. Die Aufträge selbst
|
||||
landen im Kanal (kanal.py, unter Dateisperre, weil auch die Oberfläche dort schreibt). Seit 24.09.2026 steht dort
|
||||
auch eine kurze Ereignisliste (angestoßen, gescheitert, wieder ok; die letzten EREIGNISSE_MAX) — das Protokoll der
|
||||
Oberfläche liest sie (protokoll.py).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from kern.einstellungen import einstellungen
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
from services.homelab import apps, inventar, kanal, updates
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
LISTEN_ALT_S = 7 * 24 * 3600
|
||||
NACHT_VON, NACHT_BIS = 2, 5 # volle Stunden, Europe/Berlin
|
||||
FEHLSCHLAEGE_HINWEIS = 2
|
||||
EREIGNISSE_MAX = 200
|
||||
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _pfad() -> Path:
|
||||
return einstellungen().daten_dir / "homelab-pflege.json"
|
||||
|
||||
|
||||
def zustand() -> dict:
|
||||
"""{"nacht": Datum der letzten Nacht, in der gesucht werden konnte, "gaeste": {vmid: {…}}}"""
|
||||
try:
|
||||
daten = json.loads(_pfad().read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
daten = {}
|
||||
if not isinstance(daten, dict):
|
||||
daten = {}
|
||||
if not isinstance(daten.get("gaeste"), dict):
|
||||
daten["gaeste"] = {}
|
||||
return daten
|
||||
|
||||
|
||||
def _schreiben(daten: dict) -> None:
|
||||
_pfad().parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = _pfad().with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
tmp.replace(_pfad())
|
||||
|
||||
|
||||
def faellig(gaeste: list[dict], jetzt: float) -> list[dict]:
|
||||
"""Die Container, deren Paketlisten eine neue Suche brauchen: freigegeben, laufend, Listen älter als 7 Tage."""
|
||||
ergebnis = []
|
||||
for g in gaeste:
|
||||
listen = (g.get("os_updates") or {}).get("listen_stand")
|
||||
if (g.get("art") == "lxc" and g.get("status") == "running" and g.get("erlaubt")
|
||||
and inventar.EIGENES_ETIKETT not in (g.get("etiketten") or [])
|
||||
and listen and jetzt - listen > LISTEN_ALT_S):
|
||||
ergebnis.append(g)
|
||||
return ergebnis
|
||||
|
||||
|
||||
def _fehlerzeile(text: str | None) -> str:
|
||||
"""Die aussagekräftigste Zeile einer gescheiterten Suche (apt meldet Fehler mit „E:“)."""
|
||||
zeilen = [z.strip() for z in str(text or "").splitlines() if z.strip()]
|
||||
wichtig = [z for z in zeilen if z.startswith(("E:", "ERROR", "Abgelehnt", "Zeitlimit", "Fehler"))]
|
||||
return (wichtig or zeilen or ["ohne Angabe"])[-1][:200]
|
||||
|
||||
|
||||
def _ereignis(daten: dict, ts: float, vmid: object, name: object, art: str, text: str) -> None:
|
||||
"""Für das Protokoll der Oberfläche; die Liste bleibt kurz."""
|
||||
try:
|
||||
vmid = int(vmid)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
liste = daten["ereignisse"] if isinstance(daten.get("ereignisse"), list) else []
|
||||
liste.append({"ts": ts, "vmid": vmid, "name": str(name or vmid), "art": art, "text": text})
|
||||
daten["ereignisse"] = liste[-EREIGNISSE_MAX:]
|
||||
|
||||
|
||||
def _einsammeln(daten: dict) -> bool:
|
||||
"""Ergebnisse der eigenen Aufträge übernehmen: fertig → Zähler auf null, gescheitert → eins mehr."""
|
||||
geaendert = False
|
||||
for vmid, eintrag in daten["gaeste"].items():
|
||||
aid = eintrag.get("auftrag")
|
||||
if not aid:
|
||||
continue
|
||||
a = kanal.auftrag(aid)
|
||||
if a is not None and a.get("status") in ("wartet", "laeuft"):
|
||||
continue
|
||||
if a is not None and a.get("status") == "fertig":
|
||||
if eintrag.get("fehlschlaege"):
|
||||
_ereignis(daten, a.get("fertig") or time.time(), vmid, eintrag.get("name"), "wieder_ok",
|
||||
"Die Paketlisten ließen sich wieder erneuern.")
|
||||
eintrag["fehlschlaege"], eintrag["fehler"] = 0, None
|
||||
elif a is not None: # fehler, verloren
|
||||
eintrag["fehlschlaege"] = int(eintrag.get("fehlschlaege") or 0) + 1
|
||||
eintrag["fehler"] = _fehlerzeile(a.get("text"))
|
||||
_ereignis(daten, a.get("fertig") or time.time(), vmid, eintrag.get("name"), "gescheitert",
|
||||
f"{eintrag['fehlschlaege']}. Fehlschlag in Folge: {eintrag['fehler']}")
|
||||
eintrag["auftrag"] = None # nicht mehr in der Liste: vergessen, nicht zählen
|
||||
geaendert = True
|
||||
return geaendert
|
||||
|
||||
|
||||
def _offene_gaeste() -> set:
|
||||
"""Gäste mit einem offenen Auftrag gleich welcher Art (auch von Hand oder aus einem Update-Lauf)."""
|
||||
return {(a.get("parameter") or {}).get("vmid") for a in kanal.liste() if a.get("status") in ("wartet", "laeuft")}
|
||||
|
||||
|
||||
def _befunde(daten: dict, faellige: list[dict]) -> list:
|
||||
"""Gelbe Hinweise für Gäste, deren Listen weiter alt sind und deren Suche wiederholt scheiterte."""
|
||||
from services.waechter import Befund # der Wächter registriert diese Prüfung, also erst hier
|
||||
befunde = []
|
||||
for g in faellige:
|
||||
eintrag = daten["gaeste"].get(str(g["vmid"])) or {}
|
||||
anzahl = int(eintrag.get("fehlschlaege") or 0)
|
||||
if anzahl < FEHLSCHLAEGE_HINWEIS:
|
||||
continue
|
||||
name = eintrag.get("name") or str(g.get("name") or g["vmid"])
|
||||
befunde.append(Befund(
|
||||
id=f"pflege:ct-{g['vmid']}", stufe="gelb", titel=f"{name}: Die Suche nach Updates scheitert",
|
||||
text=(f"Die Paketlisten ließen sich {anzahl}-mal hintereinander nicht erneuern (wöchentliche Suche). "
|
||||
f"Zuletzt: {eintrag.get('fehler') or 'ohne Angabe'}"),
|
||||
quelle=f"ct-{g['vmid']}", sofort=True))
|
||||
return befunde
|
||||
|
||||
|
||||
def pruefe_paketlisten(jetzt: float | None = None) -> list:
|
||||
"""Im Wächter-Takt: Ergebnisse einsammeln, fällige Suchen anstoßen. Rückgabe: Befunde für den Wächter —
|
||||
nur, wenn die Suche für einen Gast wiederholt scheitert (gelb)."""
|
||||
from services import waechter
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
lokal = datetime.fromtimestamp(jetzt, LOCAL_TZ)
|
||||
heute = lokal.date().isoformat()
|
||||
nachts = NACHT_VON <= lokal.hour < NACHT_BIS
|
||||
# Die zuletzt begonnene Nacht: vor 02:00 die von gestern.
|
||||
letzte_nacht = (lokal.date() - timedelta(days=0 if lokal.hour >= NACHT_VON else 1)).isoformat()
|
||||
|
||||
eingang = kanal.bericht()
|
||||
bericht = (eingang or {}).get("bericht") or {}
|
||||
gaeste = bericht.get("gaeste") or []
|
||||
zuletzt = kanal.zuletzt()
|
||||
# Aufträge nur, wenn der Ausführer gerade berichtet und nicht im Nur-Lesen-Modus läuft (dann lehnte er ab).
|
||||
verbunden = bool(eingang and zuletzt and jetzt - zuletzt < inventar.BERICHT_ALT_S
|
||||
and not (bericht.get("host") or {}).get("nur_lesen"))
|
||||
faellige = faellig(gaeste, jetzt)
|
||||
|
||||
with _lock:
|
||||
daten = zustand()
|
||||
geaendert = _einsammeln(daten)
|
||||
faellig_ids = {str(g["vmid"]) for g in faellige}
|
||||
for vmid, eintrag in daten["gaeste"].items():
|
||||
# Listen wieder frisch (auch von Hand gesucht) oder Gast nicht mehr dabei: Zähler vergessen.
|
||||
if gaeste and eintrag.get("fehlschlaege") and vmid not in faellig_ids:
|
||||
eintrag["fehlschlaege"], eintrag["fehler"] = 0, None
|
||||
geaendert = True
|
||||
if verbunden and nachts and daten.get("nacht") != heute:
|
||||
daten["nacht"] = heute
|
||||
geaendert = True
|
||||
# Nachts sowieso; tagsüber nur, wenn die letzte Nacht verpasst wurde (Instanz oder Ausführer war weg).
|
||||
if verbunden and (nachts or daten.get("nacht") != letzte_nacht):
|
||||
offen = _offene_gaeste()
|
||||
im_update = updates.laufende_ziele()
|
||||
for g in faellige:
|
||||
vmid = g["vmid"]
|
||||
eintrag = daten["gaeste"].setdefault(str(vmid), {})
|
||||
if eintrag.get("tag") == heute or eintrag.get("auftrag") or vmid in offen \
|
||||
or f"ct-{vmid}" in im_update:
|
||||
continue
|
||||
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
|
||||
eintrag["name"] = app.name if app else str(g.get("name") or vmid)
|
||||
if waechter.TROCKEN:
|
||||
log.info("pflege (trocken): würde die Paketlisten von %s erneuern", eintrag["name"])
|
||||
continue
|
||||
eintrag.update(tag=heute, auftrag=kanal.anlegen("suchen", {"vmid": vmid}), angestossen=jetzt)
|
||||
listen = (g.get("os_updates") or {}).get("listen_stand")
|
||||
_ereignis(daten, jetzt, vmid, eintrag["name"], "angestossen",
|
||||
f"Die Paketlisten sind vom {datetime.fromtimestamp(listen, LOCAL_TZ):%d.%m.%Y}.")
|
||||
geaendert = True
|
||||
log.info("pflege: Paketlisten von %s sind alt → suchen", eintrag["name"])
|
||||
if geaendert:
|
||||
_schreiben(daten)
|
||||
return _befunde(daten, faellige)
|
||||
@@ -0,0 +1,588 @@
|
||||
"""Protokoll des Homelab-Teils (24.09.2026): was wann geschah, aus allen Quellen, neueste zuerst.
|
||||
|
||||
Quellen, alle nur gelesen:
|
||||
auftrag Aufträge an den Ausführer (kanal.py): Aktion, Gerät, Status, Code; die Ausgabe als Einzelheiten.
|
||||
Berichte nur auf Wunsch (berichte=1): die Aufträge „bericht“ der Update-Läufe und die Berichte, die
|
||||
der Ausführer von sich aus schickt (alle zehn Minuten; kanal.py vermerkt ihren Eingang).
|
||||
lauf „Jetzt updaten“-Läufe mit ihren Schritten (updates.py)
|
||||
snapshot Aktionen der Seite Snapshots (snapshots.py, seit 25.09.2026): anlegen, löschen, zurücksetzen, Sicherung
|
||||
löschen — Läufe wie die Updates, mit eigenen Titeln
|
||||
sammellauf „Alle aktualisieren“ (sammellauf.py)
|
||||
hinweis Hinweise des Wächters: erschienen, erledigt, Selbstreparatur (der Verlauf in mc2-waechter.json;
|
||||
ein Hinweis, der dort schon herausgefallen ist, erscheint mit seinem Beginn)
|
||||
meldung was notify.sh an Telegram gab oder für die Morgenmeldung zurücklegte (Melde-Log des Containers,
|
||||
MC_NOTIFY_LOG). Den Betreff schreibt notify.sh nicht mit; er folgt aus dem Absender: Lauf,
|
||||
Sammellauf, Wächter, Telegram-Test oder Morgenmeldung.
|
||||
pflege das wöchentliche Suchen (pflege.py): angestoßen, gescheitert, wieder ok
|
||||
|
||||
Kein Eintrag enthält Geheimnisse: schwaerzen() nimmt die bekannten Schlüssel dieser Instanz (Arcane, Ausführer) und
|
||||
alles, was wie ein Zugangs-Token aussieht, aus Titeln, Texten und Ausgaben; Steuerzeichen der Konsole fallen weg.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from kern.einstellungen import einstellungen
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
from services.einstellungen import melde_log
|
||||
from services.homelab import apps, arcane, kanal, pflege, sammellauf, snapshots, updates
|
||||
|
||||
GRENZE_MAX = 1000
|
||||
DETAILS_MAX = 4000
|
||||
TEXT_MAX = 300
|
||||
MELDELOG_MAX = 512 * 1024 # so viel vom Ende des Melde-Logs
|
||||
STUFE_HINWEIS = {"rot": "fehler", "gelb": "warn"}
|
||||
# Betreffzeilen der Absender im Homelab-Teil (notify.sh schreibt sie nicht ins Melde-Log).
|
||||
BETREFF_WAECHTER = ("[Homelab-Problem]", "[Homelab wieder ok]") # waechter.BETREFF_* in der Rolle homelab
|
||||
BETREFF_TEST = "[Test]" # services/einstellungen.telegram_test
|
||||
BETREFF_MORGEN = "[Morgenmeldung Homelab]" # deploy/homelab/mc2-homelab-morgenmeldung.service
|
||||
LUCY_ZUSATZ = " (Diese Meldung kam auch an Lucy.)" # hängt waechter._telegram an
|
||||
|
||||
# Aktion → (erledigt, gescheitert, offen, kurz). Titel sind „Was · Gerät“.
|
||||
_AKTION = {
|
||||
"bericht": ("Bericht geholt", "Bericht gescheitert", "Bericht angefordert", "Bericht"),
|
||||
"snapshot": ("Snapshot angelegt", "Snapshot gescheitert", "Snapshot wird angelegt", "Snapshot"),
|
||||
"update": ("Update-Skript gelaufen", "Update-Skript gescheitert", "Update-Skript läuft", "Update-Skript"),
|
||||
"os_update": ("Pakete eingespielt", "Pakete einspielen gescheitert", "Pakete werden eingespielt",
|
||||
"Pakete einspielen"),
|
||||
"suchen": ("Paketlisten erneuert", "Paketlisten erneuern gescheitert", "Paketlisten werden erneuert",
|
||||
"Paketlisten erneuern"),
|
||||
"zurueck": ("Auf den Snapshot zurückgesetzt", "Zurücksetzen gescheitert", "Wird auf den Snapshot zurückgesetzt",
|
||||
"Zurücksetzen"),
|
||||
# Seit 25.09.2026 nicht mehr „Alter …“: Gelöscht wird auch auf der Seite Snapshots, nicht nur nach einem Update.
|
||||
"snapshot_loeschen": ("Snapshot gelöscht", "Snapshot löschen gescheitert", "Snapshot wird gelöscht",
|
||||
"Snapshot löschen"),
|
||||
"sichern": ("Sicherung angelegt", "Sicherung gescheitert", "Sicherung läuft", "Sicherung"),
|
||||
"sicherung_zurueck": ("Sicherung zurückgespielt", "Zurückspielen gescheitert", "Sicherung wird zurückgespielt",
|
||||
"Zurückspielen"),
|
||||
"sicherung_loeschen": ("Sicherung gelöscht", "Sicherung löschen gescheitert", "Sicherung wird gelöscht",
|
||||
"Sicherung löschen"),
|
||||
"host_update": ("Host-Pakete eingespielt", "Host-Pakete gescheitert", "Host-Pakete werden eingespielt",
|
||||
"Host-Pakete"),
|
||||
"host_neustart": ("Neustart ausgelöst", "Neustart gescheitert", "Neustart angefordert", "Neustart"),
|
||||
"kernel_aufraeumen": ("Alte Kernel entfernt", "Kernel aufräumen gescheitert", "Alte Kernel werden entfernt",
|
||||
"Kernel aufräumen"),
|
||||
"sicherung_probe": ("Probe-Wiederherstellung gelungen", "Probe-Wiederherstellung gescheitert",
|
||||
"Probe-Wiederherstellung läuft", "Probe-Wiederherstellung"),
|
||||
"etikett_setzen": ("Etikett gesetzt", "Etikett setzen gescheitert", "Etikett wird gesetzt", "Etikett setzen"),
|
||||
"changelog": ("Änderungslisten gelesen", "Änderungslisten nicht gelesen", "Änderungslisten werden gelesen",
|
||||
"Änderungslisten"),
|
||||
# Seit 25.09.2026: Lucy liest das Protokoll eines Containers, wenn sie einen Hinweis untersucht.
|
||||
"journal": ("Protokoll gelesen (Lucy)", "Protokoll lesen gescheitert", "Protokoll wird gelesen", "Protokoll lesen"),
|
||||
}
|
||||
_HOST_AKTIONEN = {"bericht", "host_update", "host_neustart", "kernel_aufraeumen", "sicherung_probe", "changelog"}
|
||||
_LAUF_WAS = {"app": "Update", "os": "Paket-Update", "pakete": "Paket-Update", "docker": "Docker-Update",
|
||||
"sicherheit": "Sicherheitsupdate",
|
||||
"neustart": "Neustart", "kernel": "Kernel-Aufräumen", "probe": "Rückweg-Probe"}
|
||||
_SCHRITT_WORT = {"wartet": "wartet", "laeuft": "läuft", "eingespielt": "eingespielt",
|
||||
"zurueckgerollt": "zurückgerollt", "fehler": "gescheitert", "uebersprungen": "übersprungen"}
|
||||
_MELDUNG = re.compile(r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) "
|
||||
r"(OK telegram direkt|OK telegram|QUEUED für Morgen-Digest|FALLBACK \((.*?)\)): ?(.*)$")
|
||||
_WEG = {"OK telegram direkt": ("Meldung raus", "info", "Direkt an die Telegram-Bot-API gesendet."),
|
||||
"OK telegram": ("Meldung raus", "info", "Über Hermes an Telegram gesendet."),
|
||||
"QUEUED für Morgen-Digest": ("Meldung zurückgelegt", "info",
|
||||
"Nachtruhe: Sie kommt mit der Morgenmeldung um 07:00.")}
|
||||
_FEHLERZEILE = ("E:", "ERROR", "Error", "error:", "FATAL", "Fehler", "Abgelehnt", "Zeitlimit", "Traceback")
|
||||
|
||||
|
||||
# --- Geheimnisse und Konsolen-Zeichen -------------------------------------------------------------------
|
||||
|
||||
_ANSI = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\-_])")
|
||||
_STEUER = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
||||
_GEHEIM = (
|
||||
# Telegram-Bot-Token, frei oder in Adressen (…/bot<Token>/sendMessage: dort ohne Wortgrenze davor)
|
||||
(re.compile(r"(?<!\d)\d{6,12}:[A-Za-z0-9_-]{30,}"), "***"),
|
||||
# Zugangsdaten in Adressen: https://nutzer:passwort@host
|
||||
(re.compile(r"(?i)\b([a-z][a-z0-9+.-]*://)[^/\s:@]+:[^/\s@]+@"), r"\1***@"),
|
||||
# Kopfzeilen: Authorization: Bearer …, X-API-Key: …, X-MC2-Ausfuehrer: …
|
||||
(re.compile(r"(?i)\b(authorization|proxy-authorization|x-api-key|x-mc2-ausfuehrer|cookie|set-cookie)"
|
||||
r"([\"']?\s*[:=]\s*[\"']?)(?:(?:bearer|basic|token)\s+)?[^\s\"',;]+"), r"\1\2***"),
|
||||
# Name=Wert bzw. "name": "wert", wenn der Name verrät, dass der Wert geheim ist
|
||||
(re.compile(r"(?i)\b([\w.-]*(?:token|passwor[dt]|secret|api[_-]?key|apikey|kennwort|passphrase|"
|
||||
r"private[_-]?key|access[_-]?key)[\w.-]*)([\"']?\s*[:=]\s*[\"']?)[^\s\"',;&]+"), r"\1\2***"),
|
||||
(re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]{8,}"), "Bearer ***"),
|
||||
# bekannte Token-Formen: GitHub, GitLab, Slack, JWT
|
||||
(re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{20,}|"
|
||||
r"xox[abpr]-[A-Za-z0-9-]{10,})"), "***"),
|
||||
(re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}"), "***"),
|
||||
)
|
||||
|
||||
|
||||
def _ausfuehrer_token() -> str:
|
||||
"""Das Geheimnis des Kanals, falls es schon eines gibt — ohne es zu erzeugen (kanal.token() täte das)."""
|
||||
if wert := os.environ.get("MC_AUSFUEHRER_TOKEN", "").strip():
|
||||
return wert
|
||||
try:
|
||||
return (einstellungen().daten_dir / "ausfuehrer.token").read_text(encoding="utf-8").strip()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return ""
|
||||
|
||||
|
||||
def _schwaerzer() -> Callable[[str | None], str | None]:
|
||||
"""Eine Schwärz-Funktion mit den bekannten Schlüsseln dieser Instanz (einmal je Protokoll gelesen)."""
|
||||
bekannt = sorted({w for w in (arcane.schluessel(), _ausfuehrer_token()) if len(w) >= 8}, key=len, reverse=True)
|
||||
|
||||
def schwaerzen(text: str | None) -> str | None:
|
||||
if text is None:
|
||||
return None
|
||||
text = _ANSI.sub("", str(text))
|
||||
# Fortschrittsbalken überschreiben ihre Zeile mit \r: Stehen bleibt, was die Konsole zuletzt zeigte.
|
||||
zeilen = []
|
||||
for zeile in text.split("\n"):
|
||||
teile = [t for t in zeile.split("\r") if t.strip()]
|
||||
zeilen.append(teile[-1] if teile else "")
|
||||
text = _STEUER.sub("", "\n".join(zeilen))
|
||||
for wert in bekannt:
|
||||
text = text.replace(wert, "***")
|
||||
for muster, ersatz in _GEHEIM:
|
||||
text = muster.sub(ersatz, text)
|
||||
return text
|
||||
|
||||
return schwaerzen
|
||||
|
||||
|
||||
def schwaerzen(text: str | None) -> str | None:
|
||||
"""Geheimnisse und Steuerzeichen aus einem Text (für Tests und Einzelfälle; eintraege() liest einmal)."""
|
||||
return _schwaerzer()(text)
|
||||
|
||||
|
||||
def _kuerzen(text: str | None, laenge: int = DETAILS_MAX) -> str | None:
|
||||
"""Das Ende langer Ausgaben (dort stehen Ergebnis und Fehler), wenn möglich ab einem Zeilenanfang."""
|
||||
if not text or not text.strip():
|
||||
return None
|
||||
text = text.strip("\n")
|
||||
if len(text) <= laenge:
|
||||
return text
|
||||
rest = text[-(laenge - 2):]
|
||||
if "\n" in rest[:300]:
|
||||
rest = rest.split("\n", 1)[1]
|
||||
return "…\n" + rest
|
||||
|
||||
|
||||
def _zeile(text: str | None) -> str | None:
|
||||
"""Ein Text für das Feld „text“: eine Zeile, höchstens TEXT_MAX Zeichen."""
|
||||
if not text or not str(text).strip():
|
||||
return None
|
||||
ganz = str(text).strip()
|
||||
erste = ganz.splitlines()[0].strip()
|
||||
if len(erste) > TEXT_MAX:
|
||||
return erste[:TEXT_MAX - 1].rstrip() + "…"
|
||||
return erste + ("…" if "\n" in ganz else "")
|
||||
|
||||
|
||||
# --- Geräte ---------------------------------------------------------------------------------------------
|
||||
|
||||
def _geraete() -> dict[int, tuple[str, str]]:
|
||||
"""VMID → (Ziel-ID, Name) aus dem letzten Bericht des Ausführers."""
|
||||
geraete: dict[int, tuple[str, str]] = {}
|
||||
for g in ((kanal.bericht() or {}).get("bericht") or {}).get("gaeste") or []:
|
||||
if not isinstance(g, dict) or not isinstance(g.get("vmid"), int):
|
||||
continue
|
||||
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
|
||||
zid = f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g['vmid']}"
|
||||
geraete[g["vmid"]] = (zid, app.name if app else str(g.get("name") or zid))
|
||||
return geraete
|
||||
|
||||
|
||||
def _eintrag(zeit: object, art: str, titel: str, text: str | None, stufe: str, ziel: str | None,
|
||||
details: str | None, schw: Callable) -> dict | None:
|
||||
if not isinstance(zeit, (int, float)):
|
||||
return None
|
||||
return {"zeit": float(zeit), "art": art, "titel": schw(titel), "text": schw(text), "stufe": stufe,
|
||||
"ziel": ziel, "details": schw(details)}
|
||||
|
||||
|
||||
# --- Aufträge an den Ausführer --------------------------------------------------------------------------
|
||||
|
||||
def _dauer(bis: object, von: object) -> str | None:
|
||||
if not (isinstance(bis, (int, float)) and isinstance(von, (int, float))) or bis < von:
|
||||
return None
|
||||
s = round(bis - von)
|
||||
if s < 60:
|
||||
return f"{s} s"
|
||||
if s < 3600:
|
||||
return f"{s // 60} min"
|
||||
return f"{s // 3600} h {s % 3600 // 60} min"
|
||||
|
||||
|
||||
def _gegenstand(aktion: str, p: dict, ausgabe: str) -> str | None:
|
||||
"""Worum es ging: der Snapshot bzw. die Sicherung."""
|
||||
if aktion in ("zurueck", "snapshot_loeschen"):
|
||||
return str(p.get("snapshot") or "") or None
|
||||
if aktion in ("sicherung_zurueck", "sicherung_loeschen"):
|
||||
return str(p.get("sicherung") or "").rsplit("/", 1)[-1] or None
|
||||
if aktion == "snapshot" and (m := re.search(r"snapshot=(\S+)", ausgabe)):
|
||||
return m.group(1)
|
||||
if aktion == "sichern" and (m := re.search(r"^sicherung=(\S+)$", ausgabe, re.MULTILINE)):
|
||||
return m.group(1).rsplit("/", 1)[-1]
|
||||
return None
|
||||
|
||||
|
||||
def _fehlerzeile(ausgabe: str) -> str | None:
|
||||
zeilen = [z.strip() for z in ausgabe.splitlines() if z.strip()]
|
||||
wichtig = [z for z in zeilen if z.startswith(_FEHLERZEILE)]
|
||||
zeile = (wichtig or zeilen or [None])[-1]
|
||||
return zeile[:200] if zeile else None
|
||||
|
||||
|
||||
def _bericht_satz(version: object, gaeste: object, laufend: object, host_updates: object) -> str:
|
||||
teile = [f"Proxmox VE {version}" if version else None, f"{gaeste} Gäste, davon {laufend} laufend",
|
||||
f"{host_updates} Paket-Updates auf dem Host" if isinstance(host_updates, int) else None]
|
||||
return ", ".join(t for t in teile if t) + "."
|
||||
|
||||
|
||||
def _bericht_kurz(ausgabe: str) -> str | None:
|
||||
"""Ein Bericht ist JSON; statt seines Endes eine Zusammenfassung."""
|
||||
try:
|
||||
b = json.loads(ausgabe)
|
||||
except ValueError:
|
||||
return None
|
||||
if not isinstance(b, dict):
|
||||
return None
|
||||
host = b.get("host") if isinstance(b.get("host"), dict) else {}
|
||||
gaeste = [g for g in b.get("gaeste") or [] if isinstance(g, dict)]
|
||||
return _bericht_satz(host.get("version"), len(gaeste), sum(1 for g in gaeste if g.get("status") == "running"),
|
||||
len(host["updates"]) if isinstance(host.get("updates"), list) else None)
|
||||
|
||||
|
||||
def _bericht_eingang(e: dict, schw: Callable) -> dict | None:
|
||||
"""Ein Bericht, den der Ausführer von sich aus geschickt hat (kein Auftrag)."""
|
||||
return _eintrag(e.get("empfangen"), "auftrag", "Bericht empfangen · Proxmox-Host",
|
||||
_bericht_satz(e.get("host_version"), e.get("gaeste"), e.get("laufend"), e.get("host_updates")),
|
||||
"info", "pve", None, schw)
|
||||
|
||||
|
||||
def _auftrag(a: dict, geraete: dict, schw: Callable) -> dict | None:
|
||||
aktion = str(a.get("aktion") or "?")
|
||||
erledigt, gescheitert, offen, kurz = _AKTION.get(aktion, (aktion, f"{aktion} gescheitert", f"{aktion} läuft",
|
||||
aktion))
|
||||
p = a.get("parameter") if isinstance(a.get("parameter"), dict) else {}
|
||||
ziel, name = None, None
|
||||
if aktion in _HOST_AKTIONEN:
|
||||
ziel, name = "pve", "Proxmox-Host"
|
||||
elif isinstance(p.get("vmid"), int):
|
||||
ziel, name = geraete.get(p["vmid"], (None, f"Gast {p['vmid']}"))
|
||||
status, code = a.get("status"), a.get("code")
|
||||
ausgabe = str(a.get("text") or "")
|
||||
gegenstand = _gegenstand(aktion, p, ausgabe)
|
||||
dauer = _dauer(a.get("fertig"), a.get("abgeholt"))
|
||||
kopf = ", ".join(t for t in (gegenstand, f"nach {dauer}" if dauer else None,
|
||||
f"Code {code}" if code is not None else None) if t)
|
||||
if status == "fertig":
|
||||
titel, stufe, text = erledigt, "ok", kopf or None
|
||||
elif status == "fehler" and code == 2 and ausgabe.startswith("Abgelehnt"):
|
||||
titel, stufe, text = f"{kurz} abgelehnt", "warn", _fehlerzeile(ausgabe)
|
||||
elif status == "fehler":
|
||||
grund = _fehlerzeile(ausgabe)
|
||||
titel, stufe, text = gescheitert, "fehler", f"{kopf}: {grund}" if grund else kopf or None
|
||||
elif status == "verloren":
|
||||
titel, stufe, text = f"{kurz}: keine Antwort", "fehler", "Der Ausführer hat nie geantwortet."
|
||||
elif status == "laeuft":
|
||||
titel, stufe, text = offen, "info", "Der Ausführer ist dran."
|
||||
else:
|
||||
titel, stufe, text = offen, "info", "Wartet auf den Ausführer."
|
||||
details = _bericht_kurz(ausgabe) if aktion == "bericht" else None
|
||||
return _eintrag(a.get("fertig") or a.get("abgeholt") or a.get("erstellt"), "auftrag",
|
||||
f"{titel} · {name}" if name else titel, text, stufe, ziel,
|
||||
details or _kuerzen(schw(ausgabe)), schw)
|
||||
|
||||
|
||||
# --- Läufe und Sammelläufe ------------------------------------------------------------------------------
|
||||
|
||||
def _anzahl_pakete(n: object, wort: str) -> str:
|
||||
return f"{n} {wort}{'' if n == 1 else 'e'} eingespielt" if isinstance(n, int) and n > 0 else f"{wort}e eingespielt"
|
||||
|
||||
|
||||
# Läufe der Seite Snapshots: (Baustein, Ergebnis) → (Titel, Stufe). Gescheitert heißt dort meist: Am Gerät hat sich
|
||||
# nichts geändert (warn) — nur ein gescheitertes Zurücksetzen ist rot.
|
||||
_SNAPSHOT_TITEL = {
|
||||
("snapshot-anlegen", "angelegt"): ("Snapshot angelegt", "ok"),
|
||||
("snapshot-anlegen", "fehler"): ("Snapshot anlegen gescheitert", "warn"),
|
||||
("snapshot-loeschen", "geloescht"): ("Snapshot gelöscht", "ok"),
|
||||
("snapshot-loeschen", "fehler"): ("Snapshot löschen gescheitert", "warn"),
|
||||
("snapshot-zurueck", "zurueckgesetzt"): ("Zurückgesetzt, Prüfung grün", "ok"),
|
||||
("snapshot-zurueck", "fehler"): ("Zurücksetzen gescheitert", "fehler"),
|
||||
("sicherung-loeschen", "geloescht"): ("Sicherung gelöscht", "ok"),
|
||||
("sicherung-loeschen", "fehler"): ("Sicherung löschen gescheitert", "warn"),
|
||||
}
|
||||
|
||||
|
||||
def _snapshot_lauf(x: dict, schw: Callable) -> dict | None:
|
||||
"""Eine Aktion der Seite Snapshots (snapshots.py): eigene Art „snapshot“, eigene Titel."""
|
||||
name = str(x.get("name") or x.get("ziel") or "?")
|
||||
baustein, status, text = str(x.get("baustein")), x.get("status"), x.get("text")
|
||||
was = snapshots.WAS.get(baustein, "Snapshot-Aktion")
|
||||
if status == "laeuft":
|
||||
titel, stufe = f"{snapshots.LAEUFT.get(baustein, was)} · {name}", "info"
|
||||
elif status == "unterbrochen":
|
||||
titel, stufe = f"{was} unterbrochen · {name}", "fehler"
|
||||
text = text or ("Der Homelab-Teil wurde währenddessen neu gestartet; was am Gerät geschah, zeigt der nächste "
|
||||
"Bericht.")
|
||||
elif baustein == "snapshot-zurueck" and x.get("ergebnis") == "fehler" and x.get("zurueckgesetzt"):
|
||||
titel, stufe = f"Zurückgesetzt, Prüfung rot · {name}", "fehler"
|
||||
else:
|
||||
wort, stufe = _SNAPSHOT_TITEL.get((baustein, str(x.get("ergebnis"))), (f"{was} beendet", "info"))
|
||||
titel = f"{wort} · {name}"
|
||||
zeilen = [str(s) for s in x.get("schritte") or []]
|
||||
if status == "fertig" and x.get("gemeldet") is False:
|
||||
zeilen.append("Ohne Meldung: Das Ergebnis steht auf der Seite Snapshots.")
|
||||
return _eintrag(x.get("ende") or x.get("start"), "snapshot", titel, text, stufe, x.get("ziel"),
|
||||
"\n".join(zeilen) or None, schw)
|
||||
|
||||
|
||||
def _lauf(x: dict, schw: Callable) -> dict | None:
|
||||
if str(x.get("baustein")) in snapshots.LAEUFT:
|
||||
return _snapshot_lauf(x, schw)
|
||||
name = str(x.get("name") or x.get("ziel") or "?")
|
||||
baustein, ergebnis, status, text = x.get("baustein"), x.get("ergebnis"), x.get("status"), x.get("text")
|
||||
was = _LAUF_WAS.get(str(baustein), "Update")
|
||||
if status == "laeuft":
|
||||
titel, stufe = f"{was} läuft · {name}", "info"
|
||||
elif status == "unterbrochen":
|
||||
titel, stufe = f"{was} unterbrochen · {name}", "fehler"
|
||||
text = text or "Der Homelab-Teil wurde während des Laufs neu gestartet; ob das Update durchlief, zeigt der " \
|
||||
"nächste Bericht."
|
||||
elif ergebnis == "eingespielt":
|
||||
stufe = "ok"
|
||||
if baustein == "app":
|
||||
# Seit 25.09.2026 „von → nach“, wo der Lauf beide kennt (Zeitleiste je Gerät).
|
||||
von_nach = (f" {x['version_alt']} → {x['version_neu']}" if x.get("version_alt") and x.get("version_neu")
|
||||
else f" {x['version_neu']}" if x.get("version_neu") else "")
|
||||
titel = f"Update eingespielt · {name}{von_nach}"
|
||||
elif baustein == "docker":
|
||||
titel = f"Docker-Images aktualisiert · {name}"
|
||||
elif baustein == "pakete":
|
||||
titel = f"{_anzahl_pakete(x.get('pakete'), 'Host-Paket')} · {name}"
|
||||
elif baustein == "sicherheit":
|
||||
titel = f"{_anzahl_pakete(x.get('pakete'), 'Sicherheitspaket')} · {name}"
|
||||
else:
|
||||
titel = f"{_anzahl_pakete(x.get('pakete'), 'Paket')} · {name}"
|
||||
elif ergebnis == "zurueckgerollt":
|
||||
titel, stufe = f"{was} zurückgerollt · {name}", "warn"
|
||||
elif ergebnis == "fehler" and ("Update nicht begonnen" in str(text or "") or "Probe nicht begonnen" in str(text or "")):
|
||||
titel, stufe = f"{was} nicht begonnen · {name}", "warn"
|
||||
elif ergebnis == "fehler":
|
||||
titel, stufe = f"{was} gescheitert · {name}", "fehler"
|
||||
elif ergebnis == "neustart":
|
||||
titel, stufe = f"Neustart ausgelöst · {name}", "info"
|
||||
elif ergebnis == "aufgeraeumt":
|
||||
titel, stufe = f"Alte Kernel entfernt · {name}", "ok"
|
||||
elif ergebnis == "probe-gruen":
|
||||
titel, stufe = f"Rückweg-Probe grün · {name}", "ok"
|
||||
elif ergebnis == "offen":
|
||||
titel, stufe = f"Docker-Probelauf · {name}", "info"
|
||||
else:
|
||||
titel, stufe = f"{was} beendet · {name}", "info"
|
||||
zeilen = [str(s) for s in x.get("schritte") or []]
|
||||
if x.get("sammellauf"):
|
||||
zeilen.append("Teil von „Alle aktualisieren“.")
|
||||
if x.get("gemeldet") is False:
|
||||
zeilen.append("Keine eigene Meldung: Die Sammelmeldung am Ende sagt es.")
|
||||
return _eintrag(x.get("ende") or x.get("start"), "lauf", titel, text, stufe, x.get("ziel"),
|
||||
"\n".join(zeilen) or None, schw)
|
||||
|
||||
|
||||
def _sammellauf(sl: dict, schw: Callable) -> dict | None:
|
||||
schritte = [s for s in sl.get("schritte") or [] if isinstance(s, dict)]
|
||||
anzahl = len(schritte)
|
||||
eingespielt = sum(1 for s in schritte if s.get("status") == "eingespielt")
|
||||
status = sl.get("status")
|
||||
if status == "laeuft":
|
||||
i = sl.get("aktuell")
|
||||
titel = "Alle aktualisieren läuft" + (f" · Schritt {i + 1} von {anzahl}" if isinstance(i, int) else "")
|
||||
stufe = "info"
|
||||
elif status == "abgebrochen":
|
||||
bei = next((s.get("name") for s in schritte if s.get("status") in ("fehler", "zurueckgerollt")), None)
|
||||
titel, stufe = "Alle aktualisieren abgebrochen" + (f" · {bei}" if bei else ""), "fehler"
|
||||
else:
|
||||
titel = f"Alle aktualisieren fertig · {eingespielt} von {anzahl} eingespielt"
|
||||
stufe = "ok" if eingespielt == anzahl else "info"
|
||||
details = "\n".join(f"{s.get('name')} ({s.get('was')}): {_SCHRITT_WORT.get(str(s.get('status')), s.get('status'))}"
|
||||
+ (f" – {s['text']}" if s.get("text") else "") for s in schritte)
|
||||
return _eintrag(sl.get("ende") or sl.get("start"), "sammellauf", titel, sl.get("text"), stufe, None,
|
||||
details or None, schw)
|
||||
|
||||
|
||||
# --- Hinweise des Wächters ------------------------------------------------------------------------------
|
||||
|
||||
def _waechter_pfad() -> Path:
|
||||
"""Wie waechter.STORE_PATH — hier gelesen, ohne den Wächter zu laden (der zieht Module der KI-Box nach)."""
|
||||
return Path(os.environ.get("MC_WAECHTER_STORE", str(einstellungen().daten_dir / "mc2-waechter.json")))
|
||||
|
||||
|
||||
_GERAET = re.compile(r"pve|(?:ct|vm)-\d+")
|
||||
|
||||
|
||||
def _ziel_aus(hinweis_id: str, quelle: object = None) -> str | None:
|
||||
"""Das Gerät eines Hinweises: seit 25.09.2026 aus seiner Quelle, wenn sie ein Gerät ist, sonst aus der ID."""
|
||||
if isinstance(quelle, str) and _GERAET.fullmatch(quelle):
|
||||
return quelle
|
||||
if m := re.search(r"\b(ct|vm)-(\d+)\b", hinweis_id):
|
||||
return f"{m.group(1)}-{m.group(2)}"
|
||||
if m := re.fullmatch(r"gast-platte:(\d+)", hinweis_id):
|
||||
return f"ct-{m.group(1)}"
|
||||
return "pve" if hinweis_id == "ausfuehrer" else None
|
||||
|
||||
|
||||
def _hinweise(schw: Callable) -> list[dict]:
|
||||
try:
|
||||
daten = json.loads(_waechter_pfad().read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return []
|
||||
daten = daten if isinstance(daten, dict) else {}
|
||||
aktuell = daten.get("hinweise") if isinstance(daten.get("hinweise"), dict) else {}
|
||||
eintraege, erschienen = [], set()
|
||||
for v in daten.get("verlauf") or []:
|
||||
if not isinstance(v, dict):
|
||||
continue
|
||||
art, hid = v.get("art"), str(v.get("id") or "")
|
||||
titel, h = str(v.get("text") or hid), aktuell.get(hid) if isinstance(aktuell.get(hid), dict) else {}
|
||||
ziel = _ziel_aus(hid, v.get("quelle") or h.get("quelle"))
|
||||
if art == "neu":
|
||||
erschienen.add(hid)
|
||||
e = _eintrag(v.get("ts"), "hinweis", f"Hinweis: {titel}", h.get("text"),
|
||||
STUFE_HINWEIS.get(str(v.get("stufe") or h.get("stufe")), "warn"), ziel, None, schw)
|
||||
elif art == "erledigt":
|
||||
e = _eintrag(v.get("ts"), "hinweis", f"Erledigt: {titel}", None, "ok", ziel, None, schw)
|
||||
elif art == "auto":
|
||||
e = _eintrag(v.get("ts"), "hinweis", titel, "Selbstreparatur des Wächters.", "info", ziel, None, schw)
|
||||
else:
|
||||
continue
|
||||
eintraege.append(e)
|
||||
for hid, h in aktuell.items():
|
||||
if hid not in erschienen and isinstance(h, dict):
|
||||
eintraege.append(_eintrag(h.get("seit"), "hinweis", f"Hinweis: {h.get('titel') or hid}", h.get("text"),
|
||||
STUFE_HINWEIS.get(str(h.get("stufe")), "warn"), _ziel_aus(hid), None, schw))
|
||||
return eintraege
|
||||
|
||||
|
||||
# --- Meldungen ------------------------------------------------------------------------------------------
|
||||
|
||||
def _meldelog() -> list[list]:
|
||||
"""[Zeit, Weg, Grund, Text] je Eintrag des Melde-Logs; Zeilen ohne Zeitstempel gehören zur Meldung davor."""
|
||||
try:
|
||||
with melde_log().open("rb") as f:
|
||||
f.seek(0, os.SEEK_END)
|
||||
groesse = f.tell()
|
||||
f.seek(max(0, groesse - MELDELOG_MAX))
|
||||
roh = f.read().decode("utf-8", "replace")
|
||||
except OSError:
|
||||
return []
|
||||
if groesse > MELDELOG_MAX:
|
||||
roh = roh.split("\n", 1)[-1] # angeschnittene erste Zeile
|
||||
eintraege: list[list] = []
|
||||
for zeile in roh.splitlines():
|
||||
if m := _MELDUNG.match(zeile):
|
||||
try:
|
||||
zeit = datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S").replace(tzinfo=LOCAL_TZ).timestamp()
|
||||
except ValueError:
|
||||
continue
|
||||
weg = "FALLBACK" if m.group(2).startswith("FALLBACK") else m.group(2)
|
||||
eintraege.append([zeit, weg, m.group(3), m.group(4)])
|
||||
elif eintraege:
|
||||
eintraege[-1][3] += "\n" + zeile
|
||||
return eintraege
|
||||
|
||||
|
||||
def _betreffe(laeufe: list[dict], sammellaeufe: list[dict]) -> dict[str, str]:
|
||||
"""Meldungstext → Betreff für das, was Läufe und Sammelläufe gemeldet haben."""
|
||||
bekannt = {}
|
||||
for x in laeufe:
|
||||
if x.get("text") and x.get("gemeldet") is not False:
|
||||
if x.get("betreff"): # die Läufe der Seite Snapshots merken sich ihren Betreff selbst
|
||||
bekannt[x["text"]] = x["betreff"]
|
||||
continue
|
||||
dringend = x.get("dringend")
|
||||
if dringend is None: # Läufe von vor dem 24.09.2026 abends: wie updates._ende es entschied
|
||||
dringend = x.get("ergebnis") in ("zurueckgerollt", "fehler") and "nicht begonnen" not in x["text"]
|
||||
bekannt[x["text"]] = updates.BETREFF_ALARM if dringend else updates.BETREFF
|
||||
for sl in sammellaeufe:
|
||||
if sl.get("meldung"):
|
||||
bekannt[sl["meldung"]] = updates.BETREFF_ALARM if sl.get("dringend") else updates.BETREFF
|
||||
return bekannt
|
||||
|
||||
|
||||
def _betreff(text: str, bekannt: dict[str, str]) -> str | None:
|
||||
if text in bekannt:
|
||||
return bekannt[text]
|
||||
if text.endswith(LUCY_ZUSATZ):
|
||||
return BETREFF_WAECHTER[1] if text.startswith("Erledigt:") else BETREFF_WAECHTER[0]
|
||||
if text.startswith("Testmeldung aus den Einstellungen"):
|
||||
return BETREFF_TEST
|
||||
if text.startswith("Guten Morgen, Commander."):
|
||||
return BETREFF_MORGEN
|
||||
return None
|
||||
|
||||
|
||||
def _meldungen(bekannt: dict[str, str], schw: Callable) -> list[dict]:
|
||||
eintraege = []
|
||||
for zeit, weg, grund, text in _meldelog():
|
||||
betreff = _betreff(text, bekannt)
|
||||
if weg == "FALLBACK":
|
||||
titel, stufe, wie = "Meldung ging nicht raus", "fehler", f"Telegram ging nicht: {grund or 'ohne Angabe'}"
|
||||
else:
|
||||
titel, stufe, wie = _WEG[weg]
|
||||
kurz = _zeile(text)
|
||||
details = wie if kurz == text.strip() else f"{wie}\n\n{text.strip()}"
|
||||
eintraege.append(_eintrag(zeit, "meldung", f"{titel} · {betreff}" if betreff else titel, kurz, stufe, None,
|
||||
details, schw))
|
||||
return eintraege
|
||||
|
||||
|
||||
# --- Pflege ---------------------------------------------------------------------------------------------
|
||||
|
||||
_PFLEGE = {"angestossen": ("Wöchentliche Suche angestoßen", "info"),
|
||||
"gescheitert": ("Wöchentliche Suche gescheitert", "warn"),
|
||||
"wieder_ok": ("Wöchentliche Suche klappt wieder", "ok")}
|
||||
|
||||
|
||||
def _pflege(geraete: dict, schw: Callable) -> list[dict]:
|
||||
daten = pflege.zustand()
|
||||
ereignisse = [e for e in daten.get("ereignisse") or [] if isinstance(e, dict)]
|
||||
eintraege = []
|
||||
for e in ereignisse:
|
||||
if e.get("art") not in _PFLEGE or not isinstance(e.get("vmid"), int):
|
||||
continue
|
||||
titel, stufe = _PFLEGE[e["art"]]
|
||||
name = e.get("name") or geraete.get(e["vmid"], (None, f"Gast {e['vmid']}"))[1]
|
||||
eintraege.append(_eintrag(e.get("ts"), "pflege", f"{titel} · {name}", e.get("text"), stufe,
|
||||
f"ct-{e['vmid']}", None, schw))
|
||||
# Stände von vor dem 24.09.2026 abends haben keine Ereignisliste: dann wenigstens der letzte Anstoß je Gast.
|
||||
schon = {(e.get("vmid"), e.get("ts")) for e in ereignisse if e.get("art") == "angestossen"}
|
||||
for vmid, g in (daten.get("gaeste") or {}).items():
|
||||
if not (isinstance(g, dict) and isinstance(g.get("angestossen"), (int, float)) and str(vmid).isdigit()):
|
||||
continue
|
||||
if (int(vmid), g["angestossen"]) not in schon:
|
||||
name = g.get("name") or geraete.get(int(vmid), (None, f"Gast {vmid}"))[1]
|
||||
eintraege.append(_eintrag(g["angestossen"], "pflege", f"{_PFLEGE['angestossen'][0]} · {name}",
|
||||
"Die Paketlisten waren älter als 7 Tage.", "info", f"ct-{vmid}", None, schw))
|
||||
return eintraege
|
||||
|
||||
|
||||
# --- Zusammen -------------------------------------------------------------------------------------------
|
||||
|
||||
def eintraege(grenze: int = 200, berichte: bool = False, ziel: str | None = None) -> list[dict]:
|
||||
"""Alle Einträge, neueste zuerst, höchstens `grenze` (1 bis GRENZE_MAX). Mit ziel (seit 25.09.2026) nur die eines
|
||||
Geräts — die Zeitleiste je Gerät."""
|
||||
grenze = max(1, min(int(grenze), GRENZE_MAX))
|
||||
schw = _schwaerzer()
|
||||
geraete = _geraete()
|
||||
laeufe = updates.laeufe(100)
|
||||
sammellaeufe = sammellauf.liste()
|
||||
alle: list[dict | None] = []
|
||||
alle += [_auftrag(a, geraete, schw) for a in kanal.liste() if berichte or a.get("aktion") != "bericht"]
|
||||
if berichte:
|
||||
alle += [_bericht_eingang(e, schw) for e in kanal.berichte()]
|
||||
alle += [_lauf(x, schw) for x in laeufe if isinstance(x, dict)]
|
||||
alle += [_sammellauf(sl, schw) for sl in sammellaeufe]
|
||||
alle += _hinweise(schw)
|
||||
alle += _meldungen(_betreffe(laeufe, sammellaeufe), schw)
|
||||
alle += _pflege(geraete, schw)
|
||||
liste = [e for e in alle if e is not None and (ziel is None or e.get("ziel") == ziel)]
|
||||
liste.sort(key=lambda e: e["zeit"], reverse=True)
|
||||
return liste[:grenze]
|
||||
@@ -0,0 +1,344 @@
|
||||
"""„Alle aktualisieren“ im Homelab (24.09.2026): alle Updates mit Knopf nacheinander.
|
||||
|
||||
Jeder Schritt ist ein gewöhnlicher Lauf von „Jetzt updaten“ (updates.starten) und wartet, bis dieser fertig ist —
|
||||
mit Rückweg, Prüfung und Zurückrollen wie beim einzelnen Knopf. Reihenfolge (User-Entscheid 24.09.2026):
|
||||
1. die Gäste nach VMID, außer NPMplus und AdGuard Home
|
||||
2. NPMplus, dann AdGuard Home: Nadelöhre (Proxy, DNS) — fällt einer aus, hängt anderes mit
|
||||
3. ganz zuletzt die Pakete des Proxmox-Hosts; neu gestartet wird der Host dabei nie
|
||||
Dabei ist nur, was „neu“ ist und einen Knopf hat. Was in der Wartezeit nach einer Skriptänderung steht (karenz.py)
|
||||
oder sich über die eigene Oberfläche aktualisiert, hat keinen Knopf und fehlt deshalb. Docker über Arcane ist nur
|
||||
dabei, wenn die Updates echt laufen: Ein Probelauf ändert nichts.
|
||||
|
||||
Endet ein Schritt mit „zurueckgerollt“ oder „fehler“, hört der Sammellauf auf: Die übrigen Schritte sind
|
||||
„uebersprungen“, er selbst ist „abgebrochen“, und es geht eine dringende Meldung raus. Läuft alles durch, kommt eine
|
||||
Sammelmeldung; die Erfolgsmeldungen der einzelnen Schritte hält updates._ende() so lange zurück, Fehlermeldungen
|
||||
nicht. Lehnt updates.starten einen Schritt ab (am Gerät ändert sich dann nichts, etwa weil die Wartezeit inzwischen
|
||||
greift), wird er übersprungen, und es geht weiter.
|
||||
|
||||
Es läuft höchstens ein Sammellauf, und solange er läuft, lehnt der einzelne Knopf ab (updates.SAMMELLAUF_SPERRE).
|
||||
Stand in <Datenordner>/homelab-sammellauf.json (die letzten BEHALTEN). Startet der Homelab-Teil mitten im Lauf neu,
|
||||
gilt er als abgebrochen, „unterbrochen (Neustart)“ (über updates.unterbrochene_abschliessen beim Start).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from kern.einstellungen import einstellungen
|
||||
|
||||
from services import announce
|
||||
from services.homelab import apps, arcane, inventar, kanal, updates
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
NADELOEHRE = ("npmplus", "adguard") # nach allen anderen Gästen, in dieser Reihenfolge
|
||||
GAST_BAUSTEINE = ("app", "os", "docker")
|
||||
TAKT_S = 2.0
|
||||
WARTEN_MAX_S = 4 * 3600 # Sicherheitsnetz; ein Lauf hat je Schritt eigene Zeitlimits (höchstens 45 min)
|
||||
BEHALTEN = 20
|
||||
UNTERBROCHEN = "unterbrochen (Neustart)"
|
||||
HOST_HINWEIS = "Der Proxmox-Host kommt zuletzt und hat keinen Rückweg; neu gestartet wird er nicht."
|
||||
# Was ein Lauf als Ergebnis hat → Stand des Schritts. „offen“ ist ein Arcane-Probelauf: geändert wurde nichts.
|
||||
STATUS = {"eingespielt": "eingespielt", "zurueckgerollt": "zurueckgerollt", "fehler": "fehler",
|
||||
"offen": "uebersprungen"}
|
||||
# Die Felder, die die Oberfläche bekommt (Vertrag GET /api/homelab/sammellauf); der Rest ist für das Protokoll.
|
||||
FELDER = ("id", "status", "start", "ende", "aktuell", "schritte", "text")
|
||||
SCHRITT_FELDER = ("ziel", "name", "baustein", "was", "status", "lauf", "text")
|
||||
|
||||
_lock = threading.RLock() # _merken liest und schreibt unter derselben Sperre
|
||||
|
||||
|
||||
# --- Stand ------------------------------------------------------------------------------------------
|
||||
|
||||
def _pfad() -> Path:
|
||||
return einstellungen().daten_dir / "homelab-sammellauf.json"
|
||||
|
||||
|
||||
def _alle() -> list[dict]:
|
||||
with _lock:
|
||||
try:
|
||||
daten = json.loads(_pfad().read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return []
|
||||
return [x for x in daten if isinstance(x, dict) and x.get("id")] if isinstance(daten, list) else []
|
||||
|
||||
|
||||
def _merken(sl: dict) -> None:
|
||||
with _lock:
|
||||
alle = [x for x in _alle() if x["id"] != sl["id"]] + [sl]
|
||||
_pfad().parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = _pfad().with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(alle[-BEHALTEN:], ensure_ascii=False), encoding="utf-8")
|
||||
tmp.replace(_pfad())
|
||||
|
||||
|
||||
def oeffentlich(sl: dict | None) -> dict | None:
|
||||
"""Ein Sammellauf, wie ihn die Oberfläche bekommt (nur die Felder des Vertrags, als Kopie)."""
|
||||
if sl is None:
|
||||
return None
|
||||
return {**{k: sl.get(k) for k in FELDER},
|
||||
"schritte": [{k: s.get(k) for k in SCHRITT_FELDER} for s in sl.get("schritte") or []]}
|
||||
|
||||
|
||||
def liste() -> list[dict]:
|
||||
"""Alle aufbewahrten Sammelläufe, neueste zuerst, mit allen Feldern (für das Protokoll)."""
|
||||
return list(reversed(_alle()))
|
||||
|
||||
|
||||
def aktueller() -> dict | None:
|
||||
"""Der laufende oder, wenn keiner läuft, der letzte Sammellauf — solange ihn niemand quittiert hat (seit
|
||||
25.09.2026: vorher stand der letzte für immer auf der Update-Seite)."""
|
||||
alle = _alle()
|
||||
if laufend := next((x for x in reversed(alle) if x.get("status") == "laeuft"), None):
|
||||
return oeffentlich(laufend)
|
||||
letzter = alle[-1] if alle else None
|
||||
return None if not letzter or letzter.get("quittiert") else oeffentlich(letzter)
|
||||
|
||||
|
||||
def quittieren(sl_id: str) -> bool:
|
||||
"""Einen beendeten Sammellauf als gesehen markieren; das Protokoll behält ihn."""
|
||||
with _lock:
|
||||
sl = next((x for x in _alle() if x["id"] == sl_id), None)
|
||||
if not sl or sl.get("status") == "laeuft":
|
||||
return False
|
||||
sl["quittiert"] = True
|
||||
_merken(sl)
|
||||
return True
|
||||
|
||||
|
||||
def laeuft() -> bool:
|
||||
return any(x.get("status") == "laeuft" for x in _alle())
|
||||
|
||||
|
||||
def _aufzaehlung(namen: list[str]) -> str:
|
||||
"""„A“, „A und B“, „A, B und C“."""
|
||||
namen = list(dict.fromkeys(namen))
|
||||
return namen[0] if len(namen) == 1 else ", ".join(namen[:-1]) + " und " + namen[-1]
|
||||
|
||||
|
||||
def _melden(text: str, dringend: bool = False) -> None:
|
||||
"""Derselbe Meldeweg und Betreff wie beim einzelnen Knopf (updates.py)."""
|
||||
try:
|
||||
announce.notify_telegram(updates.BETREFF_ALARM if dringend else updates.BETREFF, text)
|
||||
except Exception:
|
||||
log.warning("homelab: Meldung zu „Alle aktualisieren“ ging nicht raus", exc_info=True)
|
||||
|
||||
|
||||
# --- Plan -------------------------------------------------------------------------------------------
|
||||
|
||||
def _kennung(ziel_id: str) -> str | None:
|
||||
gast = inventar.gast(ziel_id) or {}
|
||||
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
|
||||
return app.kennung if app else None
|
||||
|
||||
|
||||
def _rang(schritt: dict) -> tuple[int, int]:
|
||||
"""Erst alle anderen Gäste nach VMID, dann die Nadelöhre in ihrer Reihenfolge."""
|
||||
kennung = _kennung(schritt["ziel"])
|
||||
nadel = NADELOEHRE.index(kennung) + 1 if kennung in NADELOEHRE else 0
|
||||
return nadel, int(schritt["ziel"].split("-", 1)[1])
|
||||
|
||||
|
||||
def plan() -> dict:
|
||||
"""Was „Alle aktualisieren“ jetzt täte, in dieser Reihenfolge, samt Hinweis auf das, was keinen Rückweg hat.
|
||||
{"schritte": [{"ziel", "name", "baustein", "was", "rueckweg_art"}], "hinweis": str | None}"""
|
||||
gaeste: list[dict] = []
|
||||
host: list[dict] = []
|
||||
docker_probe = False
|
||||
for z in inventar.ziele()["ziele"]:
|
||||
for b in z.get("bausteine") or []:
|
||||
if b.get("zustand") != "neu" or not b.get("aktion"):
|
||||
continue
|
||||
schritt = {"ziel": z["id"], "name": z["name"], "baustein": b["id"], "was": b.get("kurz") or b["name"],
|
||||
"rueckweg_art": z.get("rueckweg_art") or "keiner"}
|
||||
if z["id"] == "pve":
|
||||
if b["id"] == "pakete": # der Neustart ist nie dabei
|
||||
host.append({**schritt, "rueckweg_art": "keiner"})
|
||||
elif b["id"] == "docker":
|
||||
if not arcane.echt():
|
||||
docker_probe = True # ein Probelauf ist kein Update
|
||||
else:
|
||||
gaeste.append(schritt) # Snapshot der VM nur mit Etikett watcher — rueckweg_art sagt es
|
||||
elif b["id"] in GAST_BAUSTEINE:
|
||||
gaeste.append(schritt)
|
||||
gaeste.sort(key=_rang) # sort ist stabil: je Gast bleibt die Reihenfolge seiner Bausteine
|
||||
schritte = gaeste + host
|
||||
teile = []
|
||||
ohne = [s["name"] for s in gaeste if s["rueckweg_art"] == "keiner"]
|
||||
if ohne:
|
||||
teile.append(f"Ohne Rückweg: {_aufzaehlung(ohne)} – scheitert dort das Update, geht es nicht von selbst "
|
||||
"zurück.")
|
||||
if host:
|
||||
teile.append(HOST_HINWEIS)
|
||||
if docker_probe:
|
||||
teile.append("Die Docker-Images (Arcane) sind nicht dabei, solange Docker-Updates nur als Probelauf laufen.")
|
||||
return {"schritte": schritte, "hinweis": " ".join(teile) or None}
|
||||
|
||||
|
||||
# --- Starten ----------------------------------------------------------------------------------------
|
||||
|
||||
def _ausfuehrer_grund() -> str | None:
|
||||
"""Warum der Ausführer gerade keine Updates annimmt — oder None."""
|
||||
zuletzt = kanal.zuletzt()
|
||||
if not (zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S):
|
||||
return "Der Ausführer auf dem Proxmox-Host ist nicht verbunden. Ohne ihn geht kein Update."
|
||||
host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}
|
||||
if host.get("nur_lesen"):
|
||||
return "Der Ausführer auf dem Proxmox-Host läuft im Nur-Lesen-Modus und lehnt Updates ab."
|
||||
return None
|
||||
|
||||
|
||||
def starten() -> dict:
|
||||
"""Knopf „Alle aktualisieren“. {"ok": True, "sammellauf": {…}} oder {"ok": False, "detail": …}."""
|
||||
if laeuft():
|
||||
return {"ok": False, "detail": updates.SAMMELLAUF_SPERRE}
|
||||
schritte = plan()["schritte"]
|
||||
if not schritte:
|
||||
return {"ok": False, "detail": "Gerade gibt es kein Update mit Knopf, also nichts zu tun."}
|
||||
if any(s["baustein"] != "docker" for s in schritte) and (grund := _ausfuehrer_grund()):
|
||||
return {"ok": False, "detail": grund}
|
||||
with updates.START_SPERRE:
|
||||
if laeuft():
|
||||
return {"ok": False, "detail": updates.SAMMELLAUF_SPERRE}
|
||||
if laufende := [x.get("name") or x.get("ziel") for x in updates.laeufe(100) if x.get("status") == "laeuft"]:
|
||||
return {"ok": False, "detail": f"Gerade läuft ein einzelnes Update ({_aufzaehlung(laufende)}). "
|
||||
"„Alle aktualisieren“ geht, sobald es fertig ist."}
|
||||
sl = {"id": uuid.uuid4().hex[:12], "status": "laeuft", "start": time.time(), "ende": None, "aktuell": None,
|
||||
"schritte": [{"ziel": s["ziel"], "name": s["name"], "baustein": s["baustein"], "was": s["was"],
|
||||
"status": "wartet", "lauf": None, "text": None} for s in schritte],
|
||||
"text": None, "meldung": None, "dringend": False}
|
||||
_merken(sl)
|
||||
antwort = oeffentlich(sl) # eine Kopie: Der Faden ändert sl gleich weiter
|
||||
threading.Thread(target=_ablauf, args=(sl,), name="homelab-alle", daemon=True).start()
|
||||
log.info("homelab: „Alle aktualisieren“ %s mit %d Schritten gestartet", sl["id"], len(schritte))
|
||||
return {"ok": True, "sammellauf": antwort}
|
||||
|
||||
|
||||
# --- Ablauf -----------------------------------------------------------------------------------------
|
||||
|
||||
def _warten(lauf_id: str) -> dict | None:
|
||||
"""Bis der Lauf fertig ist; None, wenn er sich in WARTEN_MAX_S nicht zurückmeldet."""
|
||||
ende = time.time() + WARTEN_MAX_S
|
||||
while time.time() < ende:
|
||||
lauf = updates.lauf_lesen(lauf_id)
|
||||
if lauf is not None and lauf.get("status") != "laeuft":
|
||||
return lauf
|
||||
time.sleep(TAKT_S)
|
||||
return None
|
||||
|
||||
|
||||
def _schritt(sl: dict, schritt: dict) -> None:
|
||||
"""Einen Schritt gehen: den Lauf anstoßen, auf ihn warten, sein Ergebnis übernehmen."""
|
||||
if schritt["baustein"] == "docker" and not arcane.echt():
|
||||
schritt.update(status="uebersprungen", text="Docker-Updates über Arcane stehen inzwischen auf Probelauf.")
|
||||
return
|
||||
antwort = updates.starten(schritt["ziel"], schritt["baustein"], sammellauf={"id": sl["id"], "start": sl["start"]})
|
||||
if not antwort.get("ok"):
|
||||
schritt.update(status="uebersprungen", text=str(antwort.get("detail") or "abgelehnt"))
|
||||
return
|
||||
schritt["lauf"] = antwort["lauf"]
|
||||
_merken(sl)
|
||||
lauf = _warten(antwort["lauf"])
|
||||
if lauf is None:
|
||||
schritt.update(status="fehler", text=f"Der Lauf hat sich in {WARTEN_MAX_S // 3600} Stunden nicht "
|
||||
"zurückgemeldet.")
|
||||
return
|
||||
schritt.update(status=STATUS.get(str(lauf.get("ergebnis")), "fehler"), text=lauf.get("text"))
|
||||
|
||||
|
||||
def _ablauf(sl: dict) -> None:
|
||||
try:
|
||||
for i, schritt in enumerate(sl["schritte"]):
|
||||
sl["aktuell"] = i
|
||||
schritt["status"] = "laeuft"
|
||||
_merken(sl)
|
||||
_schritt(sl, schritt)
|
||||
_merken(sl)
|
||||
if schritt["status"] in ("zurueckgerollt", "fehler"):
|
||||
_abbrechen(sl, i)
|
||||
return
|
||||
_abschliessen(sl)
|
||||
except Exception as exc:
|
||||
# Sonst hieße der Sammellauf bis zum nächsten Neustart „läuft“, und jeder einzelne Knopf wäre gesperrt.
|
||||
log.exception("homelab: „Alle aktualisieren“ %s ist abgestürzt", sl.get("id"))
|
||||
_beenden(sl, f"Interner Fehler: {exc.__class__.__name__}.",
|
||||
f"„Alle aktualisieren“ ist mit einem internen Fehler stehen geblieben ({exc.__class__.__name__}).")
|
||||
|
||||
|
||||
def _abschliessen(sl: dict) -> None:
|
||||
eingespielt = [s for s in sl["schritte"] if s["status"] == "eingespielt"]
|
||||
uebersprungen = [s for s in sl["schritte"] if s["status"] == "uebersprungen"]
|
||||
anzahl = len(eingespielt)
|
||||
if anzahl:
|
||||
text = f"{anzahl} {'Update' if anzahl == 1 else 'Updates'} eingespielt, alle Prüfungen grün."
|
||||
else:
|
||||
text = "Kein Update eingespielt."
|
||||
if uebersprungen:
|
||||
text += " Nicht gelaufen: " + "; ".join(f"{s['name']} ({str(s.get('text') or 'abgelehnt').rstrip('.')})"
|
||||
for s in uebersprungen) + "."
|
||||
sl.update(status="fertig", ende=time.time(), aktuell=None, text=text, meldung=f"Homelab: {text}", dringend=False)
|
||||
_merken(sl)
|
||||
log.info("homelab: „Alle aktualisieren“ %s fertig: %s", sl["id"], text)
|
||||
_melden(sl["meldung"])
|
||||
|
||||
|
||||
def _beenden(sl: dict, text: str, anlass: str) -> None:
|
||||
"""Abbrechen: offene Schritte „uebersprungen“, Status „abgebrochen“, dringende Meldung."""
|
||||
rest = [s for s in sl["schritte"] if s["status"] in ("wartet", "laeuft")]
|
||||
for s in rest:
|
||||
s.update(status="uebersprungen", text="Nicht mehr gelaufen: „Alle aktualisieren“ wurde abgebrochen.")
|
||||
eingespielt = [s["name"] for s in sl["schritte"] if s["status"] == "eingespielt"]
|
||||
meldung = f"Homelab: {anlass}"
|
||||
if eingespielt:
|
||||
meldung += f" Vorher eingespielt: {_aufzaehlung(eingespielt)}."
|
||||
if rest:
|
||||
meldung += f" Nicht mehr gelaufen: {_aufzaehlung([s['name'] for s in rest])}."
|
||||
sl.update(status="abgebrochen", ende=time.time(), aktuell=None, text=text, meldung=meldung, dringend=True)
|
||||
_merken(sl)
|
||||
log.warning("homelab: „Alle aktualisieren“ %s abgebrochen: %s", sl["id"], text)
|
||||
_melden(meldung, dringend=True)
|
||||
|
||||
|
||||
def _abbrechen(sl: dict, i: int) -> None:
|
||||
schritt = sl["schritte"][i]
|
||||
wie = "zurückgerollt" if schritt["status"] == "zurueckgerollt" else "gescheitert"
|
||||
_beenden(sl, f"Abgebrochen bei {schritt['name']}: Update {wie}.",
|
||||
f"„Alle aktualisieren“ abgebrochen bei {schritt['name']} (Update {wie}).")
|
||||
|
||||
|
||||
def unterbrochene_abschliessen() -> None:
|
||||
"""Beim Start (über updates.unterbrochene_abschliessen): Ein Sammellauf, der noch „läuft“, gehörte zum vorigen
|
||||
Prozess. Er gilt als abgebrochen; die Meldung geht nebenher raus, damit der Start nicht auf Telegram wartet."""
|
||||
for sl in _alle():
|
||||
if sl.get("status") != "laeuft":
|
||||
continue
|
||||
bei = None
|
||||
for s in sl["schritte"]:
|
||||
if s.get("status") != "laeuft":
|
||||
continue
|
||||
bei = s["name"]
|
||||
# War sein Lauf noch fertig geworden, zählt dessen Ergebnis; sonst ist offen, was am Gerät geschah.
|
||||
lauf = updates.lauf_lesen(s["lauf"]) if s.get("lauf") else None
|
||||
if lauf is not None and lauf.get("status") == "fertig" and lauf.get("ergebnis") in STATUS:
|
||||
s.update(status=STATUS[lauf["ergebnis"]], text=lauf.get("text"))
|
||||
else:
|
||||
s.update(status="fehler", text=UNTERBROCHEN)
|
||||
rest = [s for s in sl["schritte"] if s.get("status") == "wartet"]
|
||||
for s in rest:
|
||||
s.update(status="uebersprungen", text="Nicht mehr gelaufen: „Alle aktualisieren“ wurde abgebrochen.")
|
||||
eingespielt = [s["name"] for s in sl["schritte"] if s.get("status") == "eingespielt"]
|
||||
meldung = ("Homelab: „Alle aktualisieren“ wurde unterbrochen: Der Homelab-Teil ist mitten im Lauf neu "
|
||||
"gestartet" + (f" (bei {bei})" if bei else "") + ".")
|
||||
if eingespielt:
|
||||
meldung += f" Vorher eingespielt: {_aufzaehlung(eingespielt)}."
|
||||
if rest:
|
||||
meldung += f" Nicht mehr gelaufen: {_aufzaehlung([s['name'] for s in rest])}."
|
||||
meldung += " Bitte den Stand in der Übersicht prüfen."
|
||||
sl.update(status="abgebrochen", ende=time.time(), aktuell=None, text=UNTERBROCHEN, meldung=meldung,
|
||||
dringend=True)
|
||||
_merken(sl)
|
||||
log.warning("homelab: „Alle aktualisieren“ %s war beim Start noch offen → abgebrochen", sl["id"])
|
||||
threading.Thread(target=_melden, args=(meldung, True), name="homelab-alle-meldung", daemon=True).start()
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Sicherheits-Markierung (Ausbauplan Welle 2, Punkt 11, seit 25.09.2026).
|
||||
|
||||
Welche offenen Paket-Updates schließen eine Sicherheitslücke?
|
||||
• Debian und Ubuntu (Gäste und Proxmox-Host) liefern sie aus eigenen Suiten („trixie-security“); das zählt der
|
||||
Ausführer selbst (os_updates.sicherheit, host.updates_sicherheit).
|
||||
• Alpine (NPMplus) kennt keine solche Suite. Dafür führt Alpine eine Sicherheitsdatenbank (secdb.alpinelinux.org,
|
||||
je Zweig main und community): je Paket die Versionen, die Lücken schließen. Ein Update zählt, wenn eine davon
|
||||
nach der installierten und höchstens bei der neuen Version liegt. Die Datenbank lädt dieser Teil im Hintergrund
|
||||
und merkt sie zwölf Stunden (<Datenordner>/alpine-secdb-v<zweig>.json); bis sie da ist, heißt es „unbekannt“.
|
||||
Docker-Images prüft Arcane selbst (Trivy, sonntags 02:00): docker.py holt dessen Befunde je Image.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from kern.einstellungen import einstellungen
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
SECDB = "https://secdb.alpinelinux.org/v{zweig}/{repo}.json"
|
||||
NEU_LADEN_S = 12 * 3600
|
||||
_SUFFIX = {"alpha": -4, "beta": -3, "pre": -2, "rc": -1, "cvs": 1, "svn": 1, "git": 1, "hg": 1, "p": 2}
|
||||
_lock = threading.Lock()
|
||||
_laedt: set[str] = set()
|
||||
_gemerkt: dict[str, tuple[float, dict]] = {}
|
||||
|
||||
|
||||
def apk_teile(version: str) -> tuple:
|
||||
"""Eine Alpine-Version („1.37.0-r18“, „3.3.2_rc1-r0“, „1.2a-r1“) als vergleichbares Tupel: Zahlen (Buchstaben
|
||||
als kleine Zahlen), Zusätze (_alpha < _beta < _pre < _rc < ohne < _p), zuletzt die Paketrevision -rN."""
|
||||
haupt, _, revision = version.partition("-r")
|
||||
kern, *zusaetze = haupt.split("_")
|
||||
teile = tuple(int(x) if x.isdigit() else ord(x[0]) - 96 for x in re.findall(r"\d+|[a-z]+", kern.lower()))
|
||||
suffixe = []
|
||||
for z in zusaetze:
|
||||
m = re.fullmatch(r"([a-z]+)(\d*)", z)
|
||||
suffixe.append((_SUFFIX.get(m.group(1), 0), int(m.group(2) or 0)) if m else (0, 0))
|
||||
return (teile, tuple(suffixe) or ((0, 0),), int(revision) if revision.isdigit() else 0)
|
||||
|
||||
|
||||
def _pfad(zweig: str) -> Path:
|
||||
return einstellungen().daten_dir / f"alpine-secdb-v{zweig}.json"
|
||||
|
||||
|
||||
def _laden(zweig: str) -> None:
|
||||
"""Beide Repos des Zweigs holen und als {paket: [Versionen mit Fixes]} ablegen (eigener Faden)."""
|
||||
try:
|
||||
fixes: dict[str, list[str]] = {}
|
||||
for repo in ("main", "community"):
|
||||
r = httpx.get(SECDB.format(zweig=zweig, repo=repo), timeout=30, follow_redirects=True)
|
||||
r.raise_for_status()
|
||||
for eintrag in r.json().get("packages") or []:
|
||||
pkg = (eintrag or {}).get("pkg") or {}
|
||||
if pkg.get("name") and isinstance(pkg.get("secfixes"), dict):
|
||||
fixes.setdefault(pkg["name"], []).extend(v for v in pkg["secfixes"] if v != "0")
|
||||
pfad = _pfad(zweig)
|
||||
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = pfad.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps({"geholt": time.time(), "fixes": fixes}), encoding="utf-8")
|
||||
tmp.replace(pfad)
|
||||
log.info("sicherheit: Alpine-Sicherheitsdatenbank v%s geladen (%d Pakete)", zweig, len(fixes))
|
||||
except Exception:
|
||||
log.warning("sicherheit: Alpine-Sicherheitsdatenbank v%s nicht geladen", zweig, exc_info=True)
|
||||
finally:
|
||||
with _lock:
|
||||
_laedt.discard(zweig)
|
||||
|
||||
|
||||
def secdb(zweig: str, jetzt: float | None = None) -> dict | None:
|
||||
"""{paket: [Versionen mit Fixes]} — aus der Datei, im Speicher gemerkt; ist sie alt oder fehlt, lädt ein Faden
|
||||
sie nach (bis dahin die alte oder None). Blockiert nie."""
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
pfad = _pfad(zweig)
|
||||
daten = None
|
||||
try:
|
||||
mtime = pfad.stat().st_mtime
|
||||
with _lock:
|
||||
alt = _gemerkt.get(zweig)
|
||||
if alt and alt[0] == mtime:
|
||||
daten = alt[1]
|
||||
else:
|
||||
daten = json.loads(pfad.read_text(encoding="utf-8"))
|
||||
with _lock:
|
||||
_gemerkt[zweig] = (mtime, daten)
|
||||
except (OSError, ValueError):
|
||||
daten = None
|
||||
if daten is None or jetzt - float(daten.get("geholt") or 0) > NEU_LADEN_S:
|
||||
with _lock:
|
||||
starten = zweig not in _laedt
|
||||
_laedt.add(zweig)
|
||||
if starten:
|
||||
threading.Thread(target=_laden, args=(zweig,), name=f"secdb-{zweig}", daemon=True).start()
|
||||
return daten.get("fixes") if daten else None
|
||||
|
||||
|
||||
def alpine_sicherheit(os_stand: dict, fixes: dict | None = None) -> int | None:
|
||||
"""Wie viele der offenen Alpine-Updates eine Lücke schließen (None, solange die Datenbank fehlt)."""
|
||||
namen = alpine_pakete(os_stand, fixes)
|
||||
return None if namen is None else len(namen)
|
||||
|
||||
|
||||
def alpine_pakete(os_stand: dict, fixes: dict | None = None) -> list[str] | None:
|
||||
"""Die offenen Alpine-Updates, die eine Lücke schließen (Namen; None, solange die Datenbank fehlt)."""
|
||||
pakete = os_stand.get("pakete")
|
||||
if not isinstance(pakete, list):
|
||||
return None
|
||||
if not pakete:
|
||||
return []
|
||||
if fixes is None:
|
||||
zweig = ".".join(str(os_stand.get("alpine") or "").split(".")[:2])
|
||||
if not re.fullmatch(r"\d+\.\d+", zweig):
|
||||
return None
|
||||
fixes = secdb(zweig)
|
||||
if fixes is None:
|
||||
return None
|
||||
namen = []
|
||||
for p in pakete:
|
||||
alt, neu = apk_teile(str(p.get("alt") or "")), apk_teile(str(p.get("neu") or ""))
|
||||
if any(alt < apk_teile(v) <= neu for v in fixes.get(str(p.get("name")), [])):
|
||||
namen.append(str(p.get("name")))
|
||||
return namen
|
||||
@@ -0,0 +1,594 @@
|
||||
"""Snapshots und Sicherungen verwalten — die Seite Homelab → Snapshots (seit 25.09.2026, User-Wunsch).
|
||||
|
||||
Liste (liste): je Gerät aus dem Bericht des Ausführers die Snapshots mit Zeitpunkt, Beschreibung und Herkunft, dazu die
|
||||
Sicherungen des Orchestrators (vzdump-Archive, heute nur beim PBS, der keinen Snapshot kann) und ob für das Gerät
|
||||
gerade ein Lauf läuft. Ein Ausführer vor dem 25.09.2026 liefert nur die Namen (snapshots, sicherungen): Dann kommt der
|
||||
Zeitpunkt aus dem Namen (mc2-JJJJMMTT-HHMMSS bzw. …-JJJJ_MM_TT-HH_MM_SS, Ortszeit des Hosts), von Hand angelegte
|
||||
Snapshots stehen ohne Zeit da. Wie viel ein Snapshot belegt, sagt Proxmox bei LVM-Thin nicht — hier steht deshalb keine
|
||||
Zahl, die Seite sagt es so.
|
||||
|
||||
Herkunft eines Snapshots:
|
||||
update Rückweg vor einem Update (Name mc2-…)
|
||||
knopf „Snapshot anlegen“ auf dieser Seite (Name mc2-…; der Ausführer schreibt „Auf Knopfdruck …“ in die
|
||||
Beschreibung, und der Lauf hier merkt sich den Namen). Solche räumt kein grünes Update weg
|
||||
(updates._aufraeumen fragt knopf_namen) — die löscht nur der User.
|
||||
proxmox von Hand in Proxmox angelegt: nur zur Ansicht, nie gelöscht oder zurückgesetzt (der Ausführer lehnt das
|
||||
ohnehin ab, er fasst nur mc2-… an).
|
||||
|
||||
Die Aktionen laufen als Läufe wie „Jetzt updaten“ (Mechanik aus updates.py, bewusst mitbenutzt): in
|
||||
homelab-laeufe.json, je Gerät höchstens einer, nie während „Alle aktualisieren“ (SAMMELLAUF_SPERRE) oder eines Updates
|
||||
irgendwo (_sperre: der Ausführer arbeitet einen Auftrag nach dem anderen ab) und nur, wenn der Ausführer verbunden ist.
|
||||
So stehen sie im Protokoll, der Wächter lässt ein Gerät mitten im Zurücksetzen in Ruhe, und nichts kommt einem Update
|
||||
in die Quere. Neue Befehle braucht der Ausführer nicht:
|
||||
snapshot-anlegen snapshot (Anlass „knopf“) → frischer Bericht. Eine Meldung nur, wenn es scheitert.
|
||||
snapshot-loeschen snapshot_loeschen (nur mc2-…) → frischer Bericht. Eine Meldung nur, wenn es scheitert.
|
||||
snapshot-zurueck zurueck (nur mc2-…; der Ausführer stoppt, setzt zurück, startet) → 20 s warten → frischer Bericht
|
||||
→ Prüfung wie nach einem Update (läuft, Weboberfläche antwortet). Immer mit Meldung, dringend, wenn
|
||||
es scheitert oder die Prüfung rot ist.
|
||||
sicherung-loeschen sicherung_loeschen (nur Sicherungen des Orchestrators) → frischer Bericht. Meldung nur bei Fehler.
|
||||
Im Update-Verlauf stehen sie nicht: Das sind keine Updates.
|
||||
|
||||
Seit dem 25.09.2026 außerdem (User-Klärung): je Gerät „Rückweg testen“ (updates.starten(…, "probe"): Snapshot bzw.
|
||||
Sicherung, absichtlich rot, zurück, Prüfung) und oben „Alle Update-Rückwege löschen“ (rueckwege_loeschen): löscht die
|
||||
Snapshots mit Herkunft „update“ und die Sicherungen des Orchestrators nacheinander über dieselben Läufe wie die
|
||||
einzelnen Knöpfe. Per Knopf und von Hand angelegte Snapshots bleiben. Endgültiges Löschen geschieht nur auf den Klick
|
||||
des Users hin — nie von selbst.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
from services import announce
|
||||
from services.homelab import apps, inventar, kanal, sammellauf, updates
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Wie SNAPSHOT_NAME im Ausführer: Nur solche Snapshots fasst er an. Der Zeitpunkt im Namen ist die Ortszeit des
|
||||
# Proxmox-Hosts (Europe/Berlin, wie LOCAL_TZ).
|
||||
SNAPSHOT_NAME = re.compile(r"^mc2-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$")
|
||||
ARCHIV_ZEIT = re.compile(r"-(\d{4})_(\d{2})_(\d{2})-(\d{2})_(\d{2})_(\d{2})\.")
|
||||
KNOPF_BESCHREIBUNG = "Auf Knopfdruck" # so beginnt die Beschreibung, die der Ausführer beim Anlass „knopf“ setzt
|
||||
# Die Läufe dieser Seite: was gerade geschieht (für „Läuft: …“) und wie die Aktion heißt.
|
||||
LAEUFT = {"snapshot-anlegen": "Snapshot wird angelegt", "snapshot-loeschen": "Snapshot wird gelöscht",
|
||||
"snapshot-zurueck": "Wird zurückgesetzt", "sicherung-loeschen": "Sicherung wird gelöscht"}
|
||||
WAS = {"snapshot-anlegen": "Snapshot anlegen", "snapshot-loeschen": "Snapshot löschen",
|
||||
"snapshot-zurueck": "Zurücksetzen", "sicherung-loeschen": "Sicherung löschen"}
|
||||
GUT = {"angelegt", "geloescht", "zurueckgesetzt"}
|
||||
ZULETZT_S = 6 * 3600 # so lange steht das Ergebnis der letzten Aktion beim Gerät
|
||||
BETREFF = "[Homelab-Snapshot]"
|
||||
BETREFF_ALARM = "[Alarm] Homelab-Snapshot"
|
||||
|
||||
|
||||
# --- Kleine Helfer -------------------------------------------------------------------------------------------------
|
||||
|
||||
def _zid(g: dict) -> str:
|
||||
return f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g['vmid']}"
|
||||
|
||||
|
||||
def _wo(g: dict) -> str:
|
||||
return f"{'Container' if g.get('art') == 'lxc' else 'VM'} {g['vmid']}"
|
||||
|
||||
|
||||
def _name(g: dict) -> str:
|
||||
"""Der Name wie in der Update-Liste (Gitea, Arcane (Docker) …)."""
|
||||
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
|
||||
return app.name if app else str(g.get("name") or _zid(g))
|
||||
|
||||
|
||||
def _ortszeit(m: re.Match | None) -> float | None:
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
return datetime(*(int(x) for x in m.groups()), tzinfo=LOCAL_TZ).timestamp()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _wann(ts: float | None) -> str:
|
||||
"""„vom 25.09.2026 11:26“ — leer, wo der Zeitpunkt fehlt."""
|
||||
return f"vom {datetime.fromtimestamp(ts, LOCAL_TZ):%d.%m.%Y %H:%M}" if ts else ""
|
||||
|
||||
|
||||
def _groesse(n: int | None) -> str | None:
|
||||
if not n:
|
||||
return None
|
||||
return f"{n / 1e6:.0f} MB" if n < 1e9 else f"{n / 1e9:.1f} GB".replace(".", ",")
|
||||
|
||||
|
||||
def _aktion(pfad: str, frage: str, label: str) -> dict:
|
||||
"""Ein Knopf wie im Ziel-Modell (kern/ziele.py: Aktion): Methode, Pfad, Rückfrage, Beschriftung."""
|
||||
return {"methode": "POST", "pfad": pfad, "frage": frage, "label": label}
|
||||
|
||||
|
||||
def _grund(exc: Exception) -> str:
|
||||
return str(exc).rstrip(".")
|
||||
|
||||
|
||||
# --- Snapshots und Sicherungen eines Geräts ---------------------------------------------------------------------------
|
||||
|
||||
def _knopf(laeufe: list[dict]) -> set[tuple[str, str]]:
|
||||
"""(Gerät, Snapshot) aus den Läufen „Snapshot anlegen“ — so erkennt die Seite ihre Snapshots auch mit einem
|
||||
Ausführer, der die Beschreibung „Auf Knopfdruck …“ noch nicht schreibt."""
|
||||
return {(str(x.get("ziel")), str(x["snapshot"])) for x in laeufe
|
||||
if x.get("baustein") == "snapshot-anlegen" and x.get("snapshot")}
|
||||
|
||||
|
||||
def knopf_namen(ziel_id: str, gast: dict) -> set[str]:
|
||||
"""Die Snapshots eines Gasts, die jemand auf der Seite Snapshots angelegt hat. Kein grünes Update räumt sie weg
|
||||
(updates._aufraeumen) — die löscht nur der User."""
|
||||
namen = {name for ziel, name in _knopf(updates.laeufe(100)) if ziel == ziel_id}
|
||||
for d in gast.get("snapshot_details") or []:
|
||||
if isinstance(d, dict) and str(d.get("beschreibung") or "").startswith(KNOPF_BESCHREIBUNG):
|
||||
namen.add(str(d.get("name")))
|
||||
return namen
|
||||
|
||||
|
||||
def _snapshots(g: dict, zid: str, knopf: set[tuple[str, str]]) -> list[dict]:
|
||||
"""Die Snapshots eines Gasts, ältester zuerst: Name, Zeitpunkt, Beschreibung, Herkunft, ob vom Orchestrator."""
|
||||
details = g.get("snapshot_details")
|
||||
roh = ([d for d in details if isinstance(d, dict) and d.get("name")] if isinstance(details, list)
|
||||
else [{"name": n} for n in g.get("snapshots") or [] if n])
|
||||
liste = []
|
||||
for d in roh:
|
||||
name = str(d["name"])
|
||||
m = SNAPSHOT_NAME.match(name)
|
||||
zeit = d.get("zeit") if isinstance(d.get("zeit"), (int, float)) else _ortszeit(m)
|
||||
beschreibung = str(d.get("beschreibung") or "").strip() or None
|
||||
if not m:
|
||||
herkunft = "proxmox"
|
||||
elif (zid, name) in knopf or (beschreibung or "").startswith(KNOPF_BESCHREIBUNG):
|
||||
herkunft = "knopf"
|
||||
else:
|
||||
herkunft = "update"
|
||||
liste.append({"name": name, "zeit": zeit, "beschreibung": beschreibung, "herkunft": herkunft,
|
||||
"eigen": bool(m)})
|
||||
return sorted(liste, key=lambda s: (s["zeit"] or 0, s["name"]))
|
||||
|
||||
|
||||
def _sicherungen(g: dict) -> list[dict]:
|
||||
"""Die Sicherungen des Orchestrators für diesen Gast (der Ausführer nennt nur eigene), älteste zuerst."""
|
||||
details = g.get("sicherung_details")
|
||||
roh = ([d for d in details if isinstance(d, dict) and d.get("volid")] if isinstance(details, list)
|
||||
else [{"volid": v} for v in g.get("sicherungen") or [] if v])
|
||||
liste = []
|
||||
for d in roh:
|
||||
volid = str(d["volid"])
|
||||
datei = volid.rsplit("/", 1)[-1]
|
||||
zeit = d.get("zeit") if isinstance(d.get("zeit"), (int, float)) else _ortszeit(ARCHIV_ZEIT.search(datei))
|
||||
groesse = d.get("groesse") if isinstance(d.get("groesse"), int) else None
|
||||
liste.append({"datei": datei, "speicher": volid.split(":", 1)[0], "zeit": zeit, "groesse": groesse})
|
||||
return sorted(liste, key=lambda e: (e["zeit"] or 0, e["datei"]))
|
||||
|
||||
|
||||
def _anlegen_grund(g: dict) -> str | None:
|
||||
"""Warum es für diesen Gast keinen Knopf „Snapshot anlegen“ gibt — oder None."""
|
||||
if not g.get("erlaubt"):
|
||||
return ("Nicht freigegeben: Das Etikett „watcher“ fehlt (oder „watcher-aus“ ist gesetzt). Der Orchestrator fasst "
|
||||
"dieses Gerät nicht an; Snapshots gehen hier nur von Hand in Proxmox.")
|
||||
if "snapshot_moeglich" not in g:
|
||||
return "Ob ein Snapshot geht, ließ sich nicht lesen" + (f": {g['fehler']}" if g.get("fehler") else ".")
|
||||
if not g["snapshot_moeglich"]:
|
||||
satz = f"Kein Snapshot möglich: {str(g.get('snapshot_grund') or 'Der Speicher kann keine').rstrip('.')}."
|
||||
if g.get("sicherung_moeglich"):
|
||||
satz += " Vor einem Update legt der Orchestrator stattdessen eine Sicherung an."
|
||||
return satz
|
||||
return None
|
||||
|
||||
|
||||
# --- Rückfragen (die Oberfläche zeigt sie wörtlich) ---------------------------------------------------------------
|
||||
|
||||
def _frage_anlegen(g: dict, name: str) -> str:
|
||||
vm = g.get("art") != "lxc"
|
||||
saetze = [f"Jetzt einen Snapshot von {name} ({_wo(g)}) anlegen?"]
|
||||
if g.get("status") == "running":
|
||||
saetze.append(f"{'Die VM' if vm else 'Der Container'} läuft dabei weiter.")
|
||||
saetze.append("Diesen Snapshot räumt kein Update weg — du löschst ihn hier, wenn du ihn nicht mehr brauchst.")
|
||||
saetze.append("Die VM schreibt laufend; ihr Snapshot wächst im Speicherpool schnell mit, also nicht lange stehen "
|
||||
"lassen." if vm else "Im Speicherpool belegt er so viel, wie sich seitdem im Container ändert.")
|
||||
return " ".join(saetze)
|
||||
|
||||
|
||||
def _frage_loeschen(name: str, s: dict) -> str:
|
||||
wie = "Rückweg eines Updates" if s["herkunft"] == "update" else "auf Knopfdruck angelegt"
|
||||
return (f"Den Snapshot {s['name']} von {name} ({', '.join(t for t in (_wann(s['zeit']), wie) if t)}) endgültig "
|
||||
"löschen? Am Gerät selbst ändert sich nichts; nur der Weg zurück auf diesen Stand ist danach weg.")
|
||||
|
||||
|
||||
def _frage_zurueck(g: dict, name: str, s: dict) -> str:
|
||||
vm = g.get("art") != "lxc"
|
||||
wann = _wann(s["zeit"])
|
||||
stand = f"den Snapshot {wann} ({s['name']})" if wann else f"den Snapshot {s['name']}"
|
||||
saetze = [f"{name} ({_wo(g)}) auf {stand} zurücksetzen?",
|
||||
(f"Alles, was sich seitdem {'in der VM' if vm else 'im Container'} geändert hat — Daten, Einstellungen, "
|
||||
"Updates —, ist danach verloren. Rückgängig machen lässt sich das nicht."),
|
||||
(f"{'Die VM' if vm else 'Der Container'} wird dafür gestoppt und wieder gestartet; "
|
||||
f"{name} ist ein paar Minuten nicht erreichbar.")]
|
||||
if g.get("status") != "running":
|
||||
saetze.append(f"Gerade {'ist die VM' if vm else 'ist der Container'} gestoppt — danach läuft "
|
||||
f"{'sie' if vm else 'er'}.")
|
||||
saetze.append(f"Danach prüft der Orchestrator, ob {name} wieder antwortet, und meldet das Ergebnis. Willst du den "
|
||||
"jetzigen Stand behalten, lege vorher einen Snapshot an.")
|
||||
return " ".join(saetze)
|
||||
|
||||
|
||||
def _frage_probe(g: dict, name: str) -> str:
|
||||
vm = g.get("art") != "lxc"
|
||||
snapshot = bool(g.get("snapshot_moeglich"))
|
||||
return (f"Den Rückweg von {name} ({_wo(g)}) echt testen? Der Orchestrator legt "
|
||||
f"{'einen Snapshot' if snapshot else 'eine Sicherung'} an, wertet die Prüfung absichtlich als rot und setzt "
|
||||
f"{'die VM' if vm else 'den Container'} darauf zurück — ein Update gibt es dabei nicht. {name} ist ein paar "
|
||||
"Minuten nicht erreichbar; danach prüft er, ob es wieder läuft und antwortet, und meldet das Ergebnis. "
|
||||
f"{'Der Snapshot' if snapshot else 'Die Sicherung'} bleibt danach liegen — wegräumen kannst du hier.")
|
||||
|
||||
|
||||
def _frage_sicherung(name: str, e: dict) -> str:
|
||||
teile = [t for t in (_wann(e["zeit"]), _groesse(e["groesse"])) if t] + [f"auf „{e['speicher']}“"]
|
||||
return (f"Die Sicherung {e['datei']} von {name} ({', '.join(teile)}) endgültig löschen? Der Orchestrator hat sie "
|
||||
"vor einem Update als Rückweg angelegt. Am Gerät selbst ändert sich nichts, und die nächtlichen Sicherungen "
|
||||
"(Proxmox → Backup) bleiben, wie sie sind.")
|
||||
|
||||
|
||||
# --- Liste für die Oberfläche ------------------------------------------------------------------------------------------
|
||||
|
||||
def _laeuft_text(x: dict) -> str:
|
||||
"""Was für ein Gerät gerade läuft, in Worten — auch ein Update, das die Knöpfe hier sperrt."""
|
||||
baustein = str(x.get("baustein"))
|
||||
if baustein == "snapshot-zurueck" and "zurueck: ok" in (x.get("schritte") or []):
|
||||
return "Zurückgesetzt, wird geprüft"
|
||||
return LAEUFT.get(baustein, "Update läuft")
|
||||
|
||||
|
||||
def _zuletzt(x: dict | None, jetzt: float) -> dict | None:
|
||||
"""Das Ergebnis der letzten Aktion dieser Seite für ein Gerät, solange es frisch ist (ZULETZT_S)."""
|
||||
ende = (x or {}).get("ende") or (x or {}).get("start")
|
||||
if not isinstance(ende, (int, float)) or jetzt - ende > ZULETZT_S:
|
||||
return None
|
||||
if x.get("status") == "unterbrochen":
|
||||
return {"ok": False, "ende": ende,
|
||||
"text": f"{WAS.get(str(x.get('baustein')), 'Die Aktion')} unterbrochen: Der Homelab-Teil wurde "
|
||||
"währenddessen neu gestartet. Was am Gerät geschah, zeigt die Liste."}
|
||||
return {"ok": x.get("ergebnis") in GUT, "ende": ende, "text": str(x.get("text") or "")}
|
||||
|
||||
|
||||
def _geraet(g: dict, laufend: dict[str, dict], fertig: dict[str, dict], knopf: set, jetzt: float) -> dict:
|
||||
zid, name = _zid(g), _name(g)
|
||||
grund = _anlegen_grund(g)
|
||||
snapshots = _snapshots(g, zid, knopf)
|
||||
for s in snapshots:
|
||||
darf = s["eigen"] and bool(g.get("erlaubt"))
|
||||
pfad = f"/api/homelab/snapshots/{zid}/{s['name']}"
|
||||
s["loeschen"] = _aktion(f"{pfad}/loeschen", _frage_loeschen(name, s), "Snapshot löschen") if darf else None
|
||||
s["zuruecksetzen"] = (_aktion(f"{pfad}/zuruecksetzen", _frage_zurueck(g, name, s), "Zurücksetzen")
|
||||
if darf else None)
|
||||
sicherungen = _sicherungen(g)
|
||||
for e in sicherungen:
|
||||
e["loeschen"] = (_aktion(f"/api/homelab/sicherungen/{zid}/{e['datei']}/loeschen", _frage_sicherung(name, e),
|
||||
"Sicherung löschen") if g.get("erlaubt") else None)
|
||||
lauf = laufend.get(zid)
|
||||
probe_geht = bool(g.get("erlaubt")) and bool(g.get("snapshot_moeglich") or g.get("sicherung_moeglich"))
|
||||
return {"id": zid, "vmid": g["vmid"], "art": "container" if g.get("art") == "lxc" else "vm", "name": name,
|
||||
"wo": _wo(g), "status": g.get("status"), "erlaubt": bool(g.get("erlaubt")),
|
||||
"anlegen": None if grund else _aktion(f"/api/homelab/snapshots/{zid}/anlegen", _frage_anlegen(g, name),
|
||||
"Snapshot anlegen"),
|
||||
"probe": (_aktion(f"/api/homelab/ziele/{zid}/rueckweg-probe", _frage_probe(g, name), "Rückweg testen")
|
||||
if probe_geht else None),
|
||||
"grund": grund, "laeuft": _laeuft_text(lauf) if lauf else None, "zuletzt": _zuletzt(fertig.get(zid), jetzt),
|
||||
"snapshots": snapshots, "sicherungen": sicherungen}
|
||||
|
||||
|
||||
def liste(jetzt: float | None = None) -> dict:
|
||||
"""GET /api/homelab/snapshots: je Gerät (ohne den Container des Orchestrators selbst) Snapshots und Sicherungen."""
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
eingang = kanal.bericht()
|
||||
bericht = (eingang or {}).get("bericht") or {}
|
||||
zuletzt = kanal.zuletzt()
|
||||
gaeste = [g for g in bericht.get("gaeste") or [] if isinstance(g, dict) and isinstance(g.get("vmid"), int)
|
||||
and inventar.EIGENES_ETIKETT not in (g.get("etiketten") or [])]
|
||||
laeufe = updates.laeufe(100) # neueste zuerst
|
||||
laufend: dict[str, dict] = {}
|
||||
fertig: dict[str, dict] = {}
|
||||
for x in laeufe:
|
||||
if x.get("status") == "laeuft":
|
||||
laufend.setdefault(str(x.get("ziel")), x)
|
||||
elif x.get("baustein") in LAEUFT:
|
||||
fertig.setdefault(str(x.get("ziel")), x)
|
||||
knopf = _knopf(laeufe)
|
||||
geraete = [_geraet(g, laufend, fertig, knopf, jetzt) for g in sorted(gaeste, key=lambda g: g["vmid"])]
|
||||
alle = [s for g in geraete for s in g["snapshots"]]
|
||||
sicherungen = [e for g in geraete for e in g["sicherungen"]]
|
||||
empfangen = (eingang or {}).get("empfangen")
|
||||
rueckwege = _rueckwege(geraete)
|
||||
return {
|
||||
"jetzt": jetzt,
|
||||
"bericht": {"empfangen": empfangen, "veraltet": jetzt - empfangen > inventar.BERICHT_ALT_S} if eingang else None,
|
||||
"ausfuehrer": {"verbunden": bool(zuletzt and jetzt - zuletzt < inventar.BERICHT_ALT_S), "zuletzt": zuletzt,
|
||||
"nur_lesen": bool((bericht.get("host") or {}).get("nur_lesen"))},
|
||||
# Warum gerade keine Aktion geht („Alle aktualisieren“, ein Update, der Ausführer) — wie die Knöpfe es sagten.
|
||||
"sperre": _sperre(),
|
||||
# Ausführer vor dem 25.09.2026: nur Namen, Zeitpunkte aus dem Namen, keine Beschreibung.
|
||||
"details": any("snapshot_details" in g for g in gaeste),
|
||||
"geraete": geraete,
|
||||
"summe": {"snapshots": len(alle), "eigene": sum(1 for s in alle if s["eigen"]), "sicherungen": len(sicherungen),
|
||||
"sicherungen_groesse": sum(e["groesse"] or 0 for e in sicherungen) or None},
|
||||
# „Alle Update-Rückwege löschen“: nur, wenn es welche gibt und das Aufräumen nicht schon läuft.
|
||||
"aufraeumen": (_aktion("/api/homelab/snapshots/rueckwege-loeschen", _frage_rueckwege(rueckwege),
|
||||
f"Alle {len(rueckwege)} Update-Rückwege löschen")
|
||||
if rueckwege and not _AUFRAEUMEN.is_set() else None),
|
||||
"aufraeumen_laeuft": _AUFRAEUMEN.is_set(),
|
||||
}
|
||||
|
||||
|
||||
# --- Alle Update-Rückwege auf einmal (User-Klick) ---------------------------------------------------------------------
|
||||
|
||||
_AUFRAEUMEN = threading.Event() # gesetzt, solange rueckwege_loeschen seine Liste abarbeitet
|
||||
AUFRAEUMEN_ZEITLIMIT_S = 10 * 60 # je Löschung; der Ausführer braucht Sekunden
|
||||
|
||||
|
||||
def _rueckwege(geraete: list[dict]) -> list[tuple[str, str, str, str]]:
|
||||
"""(Gerät, Name, Art, Eintrag) aller Rückwege von Updates, die sich löschen lassen: Snapshots mit Herkunft „update“
|
||||
und die Sicherungen des Orchestrators. Per Knopf oder von Hand angelegte gehören nicht dazu."""
|
||||
liste = []
|
||||
for g in geraete:
|
||||
liste += [(g["id"], g["name"], "snapshot", s["name"]) for s in g["snapshots"]
|
||||
if s["herkunft"] == "update" and s.get("loeschen")]
|
||||
liste += [(g["id"], g["name"], "sicherung", e["datei"]) for e in g["sicherungen"] if e.get("loeschen")]
|
||||
return liste
|
||||
|
||||
|
||||
def _frage_rueckwege(rueckwege: list[tuple[str, str, str, str]]) -> str:
|
||||
snaps = sum(1 for r in rueckwege if r[2] == "snapshot")
|
||||
sich = len(rueckwege) - snaps
|
||||
teile = []
|
||||
if snaps:
|
||||
teile.append(f"{snaps} {'Snapshot' if snaps == 1 else 'Snapshots'}")
|
||||
if sich:
|
||||
teile.append(f"{sich} {'Sicherung' if sich == 1 else 'Sicherungen'}")
|
||||
geraete = ", ".join(dict.fromkeys(r[1] for r in rueckwege))
|
||||
return (f"{' und '.join(teile)} endgültig löschen ({geraete})? Das sind die Rückwege, die der Orchestrator vor den "
|
||||
"letzten Updates angelegt hat. An den Geräten ändert sich nichts; nur der Weg zurück auf die Stände vor "
|
||||
"diesen Updates ist danach weg. Snapshots, die du hier per Knopf oder von Hand in Proxmox angelegt hast, "
|
||||
"bleiben. Gelöscht wird nacheinander; am Ende kommt eine Meldung.")
|
||||
|
||||
|
||||
def _warten_auf(lauf_id: str) -> dict | None:
|
||||
ende = time.monotonic() + AUFRAEUMEN_ZEITLIMIT_S
|
||||
while time.monotonic() < ende:
|
||||
lauf = updates.lauf_lesen(lauf_id)
|
||||
if lauf and lauf.get("status") != "laeuft":
|
||||
return lauf
|
||||
time.sleep(ABFRAGE_TAKT_S)
|
||||
return None
|
||||
|
||||
|
||||
ABFRAGE_TAKT_S = 1.0
|
||||
|
||||
|
||||
def _rueckwege_abarbeiten(rueckwege: list[tuple[str, str, str, str]]) -> None:
|
||||
geloescht, fehler = 0, []
|
||||
try:
|
||||
for zid, name, art, eintrag in rueckwege:
|
||||
antwort = loeschen(zid, eintrag) if art == "snapshot" else sicherung_loeschen(zid, eintrag)
|
||||
if not antwort.get("ok"):
|
||||
fehler.append(f"{name} {eintrag}: {antwort.get('detail')}")
|
||||
continue
|
||||
lauf = _warten_auf(antwort["lauf"])
|
||||
if lauf and lauf.get("ergebnis") == "geloescht":
|
||||
geloescht += 1
|
||||
else:
|
||||
fehler.append(f"{name} {eintrag}: {(lauf or {}).get('text') or 'keine Antwort'}")
|
||||
finally:
|
||||
_AUFRAEUMEN.clear()
|
||||
text = f"Update-Rückwege aufgeräumt: {geloescht} von {len(rueckwege)} gelöscht."
|
||||
if fehler:
|
||||
text += " Nicht gelöscht: " + "; ".join(fehler[:5])
|
||||
try:
|
||||
announce.notify_telegram(BETREFF_ALARM if fehler else BETREFF, text)
|
||||
except Exception:
|
||||
log.warning("homelab: Meldung zum Aufräumen ging nicht raus", exc_info=True)
|
||||
|
||||
|
||||
def rueckwege_loeschen() -> dict:
|
||||
"""Knopf „Alle Update-Rückwege löschen“: startet das Abarbeiten im eigenen Faden und kehrt sofort zurück."""
|
||||
if _AUFRAEUMEN.is_set():
|
||||
return {"ok": False, "detail": "Das Aufräumen läuft schon."}
|
||||
if grund := _sperre():
|
||||
return {"ok": False, "detail": grund}
|
||||
rueckwege = _rueckwege(liste()["geraete"])
|
||||
if not rueckwege:
|
||||
return {"ok": False, "detail": "Es gibt keine Rückwege von Updates zu löschen."}
|
||||
_AUFRAEUMEN.set()
|
||||
threading.Thread(target=_rueckwege_abarbeiten, args=(rueckwege,), name="homelab-rueckwege", daemon=True).start()
|
||||
return {"ok": True, "anzahl": len(rueckwege)}
|
||||
|
||||
|
||||
# --- Aktionen ------------------------------------------------------------------------------------------------------
|
||||
|
||||
def _sperre() -> str | None:
|
||||
"""Warum gerade gar keine Aktion dieser Seite geht — oder None. Derselbe Satz steht auf der Seite über der Liste.
|
||||
|
||||
Läuft irgendwo ein Update, warten alle: Der Ausführer arbeitet einen Auftrag nach dem anderen ab. Hinter einem
|
||||
Update (bis zu einer Stunde) liefe eine Aktion in ihr Zeitlimit — und käme danach doch noch dran, ohne dass ein
|
||||
Lauf hinsieht; beim Zurücksetzen wäre das gefährlich. Vor Aktionen dieser Seite stehen so nur kurze Aufträge.
|
||||
Umgekehrt darf ein Update neben ihnen starten: Es wartet höchstens ein paar Sekunden."""
|
||||
if sammellauf.laeuft():
|
||||
return f"{updates.SAMMELLAUF_SPERRE} Bis es fertig ist, ruhen die Knöpfe hier."
|
||||
im_update = [str(x.get("name") or x.get("ziel")) for x in updates.laeufe(100)
|
||||
if x.get("status") == "laeuft" and x.get("baustein") not in LAEUFT]
|
||||
if im_update:
|
||||
return (f"Gerade läuft ein Update ({', '.join(dict.fromkeys(im_update))}). Anlegen, löschen und zurücksetzen "
|
||||
"geht, sobald es fertig ist — der Ausführer arbeitet einen Auftrag nach dem anderen ab.")
|
||||
zuletzt = kanal.zuletzt()
|
||||
if not (zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S):
|
||||
return ("Der Ausführer auf dem Proxmox-Host meldet sich nicht. Die Liste zeigt den letzten bekannten Stand; "
|
||||
"anlegen, löschen und zurücksetzen geht erst, wenn er wieder da ist.")
|
||||
if (((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}).get("nur_lesen"):
|
||||
return "Der Ausführer läuft im Nur-Lesen-Modus: Er zeigt alles, ändert aber nichts."
|
||||
return None
|
||||
|
||||
|
||||
def _vorab(ziel_id: str) -> tuple[dict | None, str | None]:
|
||||
"""Was vor jeder Aktion gelten muss (unter updates.START_SPERRE geprüft): (Gast, None) oder (None, warum nicht)."""
|
||||
if grund := _sperre():
|
||||
return None, grund
|
||||
if updates.laeuft(ziel_id):
|
||||
return None, ("Für dieses Gerät läuft gerade schon etwas (ein Update oder eine Snapshot-Aktion). Das geht "
|
||||
"erst, wenn es fertig ist.")
|
||||
gast = inventar.gast(ziel_id)
|
||||
if not gast or inventar.EIGENES_ETIKETT in (gast.get("etiketten") or []):
|
||||
return None, "Dieses Gerät steht nicht im Bericht des Ausführers."
|
||||
if not gast.get("erlaubt"):
|
||||
return None, "Dieses Gerät ist nicht freigegeben (Etikett „watcher“ fehlt); der Ausführer fasst es nicht an."
|
||||
return gast, None
|
||||
|
||||
|
||||
def _starten(ziel_id: str, baustein: str, vorbereiten: Callable[[dict], tuple[str | None, dict]],
|
||||
schritte: Callable[[dict, dict], None]) -> dict:
|
||||
"""Prüfen und den Lauf anlegen am Stück (wie updates.starten), dann im eigenen Faden ablaufen lassen.
|
||||
Rückgabe {"ok": True, "lauf": id} oder {"ok": False, "detail": …}."""
|
||||
with updates.START_SPERRE:
|
||||
gast, grund = _vorab(ziel_id)
|
||||
mehr: dict = {}
|
||||
if gast is not None:
|
||||
grund, mehr = vorbereiten(gast)
|
||||
if grund or gast is None:
|
||||
return {"ok": False, "detail": grund or "Das geht gerade nicht."}
|
||||
lauf = updates._neuer_lauf(ziel_id, baustein, _name(gast), **mehr)
|
||||
threading.Thread(target=_ablauf, args=(schritte, lauf, gast), name=f"homelab-{ziel_id}", daemon=True).start()
|
||||
return {"ok": True, "lauf": lauf["id"]}
|
||||
|
||||
|
||||
def _eigener_snapshot(gast: dict, name: str) -> tuple[str | None, dict]:
|
||||
if not SNAPSHOT_NAME.match(name):
|
||||
return ("Nur Snapshots des Orchestrators (mc2-…) lassen sich hier löschen oder zurücksetzen; von Hand "
|
||||
"angelegte verwaltest du in Proxmox."), {}
|
||||
s = next((s for s in _snapshots(gast, "", set()) if s["name"] == name), None)
|
||||
if s is None:
|
||||
return f"Den Snapshot {name} gibt es laut dem letzten Bericht nicht (mehr).", {}
|
||||
return None, {"snapshot": name, "snapshot_zeit": s["zeit"]}
|
||||
|
||||
|
||||
def anlegen(ziel_id: str) -> dict:
|
||||
"""Knopf „Snapshot anlegen“: nur freigegebene Gäste, auf deren Speicher ein Snapshot geht."""
|
||||
return _starten(ziel_id, "snapshot-anlegen", lambda g: (_anlegen_grund(g), {}), _anlegen)
|
||||
|
||||
|
||||
def loeschen(ziel_id: str, name: str) -> dict:
|
||||
"""Knopf „Löschen“ an einem Snapshot des Orchestrators."""
|
||||
return _starten(ziel_id, "snapshot-loeschen", lambda g: _eigener_snapshot(g, name), _loeschen)
|
||||
|
||||
|
||||
def zuruecksetzen(ziel_id: str, name: str) -> dict:
|
||||
"""Knopf „Zurücksetzen“ an einem Snapshot des Orchestrators."""
|
||||
return _starten(ziel_id, "snapshot-zurueck", lambda g: _eigener_snapshot(g, name), _zurueck)
|
||||
|
||||
|
||||
def sicherung_loeschen(ziel_id: str, datei: str) -> dict:
|
||||
"""Knopf „Löschen“ an einer Sicherung des Orchestrators (nur, was der Bericht als eigene nennt)."""
|
||||
def vorbereiten(gast: dict) -> tuple[str | None, dict]:
|
||||
volid = next((str(v) for v in gast.get("sicherungen") or [] if str(v).rsplit("/", 1)[-1] == datei), None)
|
||||
if volid is None:
|
||||
return f"Die Sicherung {datei} gibt es laut dem letzten Bericht nicht (mehr).", {}
|
||||
return None, {"sicherung": volid}
|
||||
return _starten(ziel_id, "sicherung-loeschen", vorbereiten, _sicherung_loeschen)
|
||||
|
||||
|
||||
# --- Abläufe (im eigenen Faden) --------------------------------------------------------------------------------------
|
||||
|
||||
def _ende(lauf: dict, ergebnis: str, text: str, melden: bool = False, dringend: bool = False) -> None:
|
||||
betreff = (BETREFF_ALARM if dringend else BETREFF) if melden else None
|
||||
lauf.update(status="fertig", ergebnis=ergebnis, text=text, ende=time.time(), dringend=dringend, gemeldet=melden,
|
||||
betreff=betreff)
|
||||
updates._merken(lauf)
|
||||
if betreff:
|
||||
try:
|
||||
announce.notify_telegram(betreff, text)
|
||||
except Exception:
|
||||
log.warning("homelab: Meldung zu %s ging nicht raus", lauf.get("baustein"), exc_info=True)
|
||||
|
||||
|
||||
def _ablauf(schritte: Callable[[dict, dict], None], lauf: dict, gast: dict) -> None:
|
||||
try:
|
||||
schritte(lauf, gast)
|
||||
except Exception as exc:
|
||||
# Sonst hieße der Lauf bis zum nächsten Neustart „läuft“, und das Gerät wäre für alles gesperrt.
|
||||
log.exception("homelab: %s für %s ist abgestürzt", lauf.get("baustein"), lauf.get("ziel"))
|
||||
_ende(lauf, "fehler", f"{lauf['name']}: {WAS.get(lauf['baustein'], 'Die Aktion')} ist mit einem internen "
|
||||
f"Fehler stehen geblieben ({exc.__class__.__name__}).", melden=True)
|
||||
|
||||
|
||||
def _anlegen(lauf: dict, gast: dict) -> None:
|
||||
vmid, name = gast["vmid"], lauf["name"]
|
||||
try:
|
||||
a = updates._auftrag(lauf, "snapshot", {"vmid": vmid, "anlass": "knopf"}, updates.ZEITLIMIT_KURZ_S)
|
||||
except RuntimeError as exc:
|
||||
_ende(lauf, "fehler", f"{name}: Snapshot nicht angelegt — {_grund(exc)}.", melden=True)
|
||||
return
|
||||
m = re.search(r"snapshot=(mc2-\d{8}-\d{6})", a.get("text") or "")
|
||||
lauf["snapshot"] = m.group(1) if m else None
|
||||
nachher = updates._frischer_gast(lauf, vmid)
|
||||
if lauf["snapshot"] and nachher is not None and lauf["snapshot"] not in (nachher.get("snapshots") or []):
|
||||
_ende(lauf, "fehler", f"{name}: Der Ausführer meldete den Snapshot {lauf['snapshot']}, im neuen Bericht fehlt "
|
||||
"er aber.", melden=True)
|
||||
return
|
||||
_ende(lauf, "angelegt", f"{name}: Snapshot {lauf['snapshot']} angelegt." if lauf["snapshot"]
|
||||
else f"{name}: Snapshot angelegt.")
|
||||
|
||||
|
||||
def _loeschen(lauf: dict, gast: dict) -> None:
|
||||
vmid, name, snapshot = gast["vmid"], lauf["name"], lauf["snapshot"]
|
||||
try:
|
||||
updates._auftrag(lauf, "snapshot_loeschen", {"vmid": vmid, "snapshot": snapshot}, updates.ZEITLIMIT_KURZ_S)
|
||||
except RuntimeError as exc:
|
||||
_ende(lauf, "fehler", f"{name}: Snapshot {snapshot} ließ sich nicht löschen — {_grund(exc)}.", melden=True)
|
||||
return
|
||||
updates._frischer_gast(lauf, vmid)
|
||||
_ende(lauf, "geloescht", f"{name}: Snapshot {snapshot} gelöscht.")
|
||||
|
||||
|
||||
def _lage(name: str, gast: dict | None) -> str:
|
||||
"""Wie es dem Gerät nach einem gescheiterten Zurücksetzen geht, laut frischem Bericht."""
|
||||
if gast is None:
|
||||
return f"Ob {name} läuft, ließ sich nicht prüfen — bitte in Proxmox nachsehen."
|
||||
if gast.get("status") == "running":
|
||||
return f"{name} läuft."
|
||||
return f"{name} läuft nicht ({gast.get('status')}) — bitte in Proxmox nachsehen."
|
||||
|
||||
|
||||
def _zurueck(lauf: dict, gast: dict) -> None:
|
||||
vmid, name, snapshot = gast["vmid"], lauf["name"], lauf["snapshot"]
|
||||
wann = _wann(lauf.get("snapshot_zeit"))
|
||||
stand = f"den Snapshot {wann} ({snapshot})" if wann else f"den Snapshot {snapshot}"
|
||||
try:
|
||||
updates._auftrag(lauf, "zurueck", {"vmid": vmid, "snapshot": snapshot}, updates.ZEITLIMIT_KURZ_S)
|
||||
except RuntimeError as exc:
|
||||
nachher = updates._frischer_gast(lauf, vmid)
|
||||
_ende(lauf, "fehler", f"{name}: Zurücksetzen auf {stand} gescheitert — {_grund(exc)}. {_lage(name, nachher)}",
|
||||
melden=True, dringend=True)
|
||||
return
|
||||
lauf["zurueckgesetzt"] = True
|
||||
time.sleep(updates.WARTEN_NACH_UPDATE_S)
|
||||
nachher = updates._frischer_gast(lauf, vmid)
|
||||
fehler = updates.pruefen(gast, nachher, "zurueck")
|
||||
if fehler:
|
||||
_ende(lauf, "fehler", f"{name}: auf {stand} zurückgesetzt, aber die Prüfung ist rot — {' '.join(fehler)}",
|
||||
melden=True, dringend=True)
|
||||
return
|
||||
_ende(lauf, "zurueckgesetzt", f"{name}: auf {stand} zurückgesetzt, Prüfung grün.", melden=True)
|
||||
|
||||
|
||||
def _sicherung_loeschen(lauf: dict, gast: dict) -> None:
|
||||
vmid, name, volid = gast["vmid"], lauf["name"], lauf["sicherung"]
|
||||
datei = volid.rsplit("/", 1)[-1]
|
||||
try:
|
||||
updates._auftrag(lauf, "sicherung_loeschen", {"vmid": vmid, "sicherung": volid}, updates.ZEITLIMIT_KURZ_S)
|
||||
except RuntimeError as exc:
|
||||
_ende(lauf, "fehler", f"{name}: Sicherung {datei} ließ sich nicht löschen — {_grund(exc)}.", melden=True)
|
||||
return
|
||||
updates._frischer_gast(lauf, vmid)
|
||||
_ende(lauf, "geloescht", f"{name}: Sicherung {datei} gelöscht.")
|
||||
@@ -0,0 +1,633 @@
|
||||
"""Speicher und Sicherungen des Homelabs (seit 25.09.2026, User-Wunsch).
|
||||
|
||||
Alles aus dem Bericht des Ausführers (alle 10 Minuten), dazu eine eigene Prüfung, ob das NAS antwortet:
|
||||
• Speicherpool (Thin-LVM, heute local-lvm): Läuft er voll, stehen alle Gäste darauf still. Gelb ab 80 %, rot ab
|
||||
90 % — also bei 10 % frei (User-Vorgabe) —, für Daten wie Metadaten. Dazu Empfehlungen, was Platz bringt,
|
||||
der größte Gewinn zuerst: Platz, den ein Gast längst freigegeben hat (TRIM), ungenutzte und verwaiste Platten,
|
||||
Snapshots (seit 25.09.2026 in einem Vorschlag, verwaltet auf der Seite Homelab → Snapshots).
|
||||
• NAS: jedes Netzlaufwerk des Proxmox-Hosts (heute das QNAP per NFS, Ziel der nächtlichen Sicherungen): antwortet,
|
||||
ist eingebunden, hängt nicht, hat Platz (80/90 % wie oben).
|
||||
• Sicherungen: Für jeden Gast, den ein Sicherungsauftrag erfasst, liegt auf dessen Ziel eine frische Sicherung
|
||||
(täglicher Auftrag: höchstens 26 Stunden alt, sonst 8 Tage), und das Ziel ist erreichbar.
|
||||
• Abdeckung (seit 25.09.2026): Gäste ohne Sicherungsauftrag (wie Proxmox sie unter Rechenzentrum → Backup meldet)
|
||||
sofort gelb; je Gerät die jüngste Prüfung durch den PBS (beschädigt = rot) und die Probe-Wiederherstellung des
|
||||
Ausführers (jede Woche die jüngste Sicherung probeweise öffnen; gescheitert = gelb). Prüft der PBS seit über
|
||||
15 Tagen nichts mehr, obwohl es ältere Sicherungen gibt: gelb. Beim Sicherungsziel zählt die Schätzung des PBS,
|
||||
wann es voll ist.
|
||||
• Systemplatte des Proxmox-Hosts (Stufen wie oben): Dort liegen die Kernel, die Sicherungen vor Updates (local) und
|
||||
alles vom Host selbst. Alte Kernel lassen sich per Knopf entfernen (Ausführer: kernel_aufraeumen).
|
||||
• Tempo (seit 25.09.2026, kern/prognose.py): Läuft etwas in weniger als 14 Tagen voll, ist es gelb, unter 3 Tagen
|
||||
rot — auch wenn der Füllstand selbst noch unauffällig ist. Den Verlauf dafür schreibt jeder Bericht in die
|
||||
Messreihe VERLAUF (verlauf_merken); Host und Container haben ihn schon in den Minutenwerten.
|
||||
Hinweise gehen über den Wächter (pruefe_speicher), die Oberfläche zeigt alles als Karte „Speicher und Sicherungen“ (lage).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from kern import messreihen, prognose
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
from services.homelab import apps, kanal
|
||||
from services.homelab.inventar import BERICHT_ALT_S, EIGENES_ETIKETT
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
GELB, ROT = 0.80, 0.90
|
||||
EMPFEHLUNG_AB = 1_000_000_000 # kleinere Gewinne sind keine eigene Empfehlung wert
|
||||
TRIM_AB = 250_000_000 # ein Container zählt beim gemeinsamen TRIM erst ab hier mit
|
||||
TAEGLICH_S, SONST_S = 26 * 3600, 8 * 86400
|
||||
PORTS = {"nfs": 2049, "nfs4": 2049, "cifs": 445, "smb3": 445}
|
||||
ANTWORT_CACHE_S = 30
|
||||
RANG = {"ok": 0, "gelb": 1, "rot": 2}
|
||||
|
||||
_antwort_cache: dict[tuple[str, int], tuple[float, bool]] = {}
|
||||
_antwort_lock = threading.Lock()
|
||||
|
||||
|
||||
def _stufe(anteil: float | None) -> str:
|
||||
if anteil is None:
|
||||
return "ok"
|
||||
return "rot" if anteil >= ROT else "gelb" if anteil >= GELB else "ok"
|
||||
|
||||
|
||||
def _schlimmer(*stufen: str) -> str:
|
||||
return max(stufen, key=lambda s: RANG.get(s, 0))
|
||||
|
||||
|
||||
def _gb(n: float | None) -> str:
|
||||
return "–" if n is None else f"{n / 1e9:.1f}".replace(".", ",")
|
||||
|
||||
|
||||
def _groesse(n: float | None) -> str:
|
||||
"""„125 GB“, „2,3 TB“ — für Texte."""
|
||||
if n is None:
|
||||
return "–"
|
||||
if n >= 1e12:
|
||||
return f"{n / 1e12:.1f}".replace(".", ",") + " TB"
|
||||
return f"{n / 1e9:.0f} GB" if n >= 10e9 else f"{_gb(n)} GB"
|
||||
|
||||
|
||||
def _name(g: dict) -> str:
|
||||
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
|
||||
return app.name if app else str(g.get("name") or g["vmid"])
|
||||
|
||||
|
||||
def _wo(g: dict) -> str:
|
||||
return f"{'Container' if g.get('art') == 'lxc' else 'VM'} {g['vmid']}"
|
||||
|
||||
|
||||
def _zid(g: dict) -> str:
|
||||
return f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g['vmid']}"
|
||||
|
||||
|
||||
def _datum(ts: float) -> str:
|
||||
return datetime.fromtimestamp(ts, LOCAL_TZ).strftime("%d.%m. %H:%M")
|
||||
|
||||
|
||||
def _bericht() -> dict:
|
||||
return (kanal.bericht() or {}).get("bericht") or {}
|
||||
|
||||
|
||||
# --- Speicherpool ----------------------------------------------------------------------------------------------
|
||||
|
||||
def pools(bericht: dict) -> list[dict]:
|
||||
ergebnis = []
|
||||
for p in (bericht.get("host") or {}).get("pools") or []:
|
||||
gesamt, belegt = int(p.get("gesamt") or 0), int(p.get("belegt") or 0)
|
||||
if not gesamt:
|
||||
continue
|
||||
anteil, meta = belegt / gesamt, p.get("meta_anteil")
|
||||
ergebnis.append({"speicher": p.get("speicher"), "belegt": belegt, "gesamt": gesamt, "frei": gesamt - belegt,
|
||||
"anteil": anteil, "meta_anteil": meta, "aktiv": p.get("aktiv", True),
|
||||
"stufe": _schlimmer(_stufe(anteil), _stufe(meta))})
|
||||
return ergebnis
|
||||
|
||||
|
||||
def empfehlungen(bericht: dict) -> list[dict]:
|
||||
"""Was im Speicherpool Platz bringt, der größte Gewinn zuerst; Snapshots (Größe unbekannt) danach.
|
||||
Je Eintrag: art, titel, text (was zu tun ist, mit Klickweg), kurz (für Meldungen), platz (Bytes oder None)."""
|
||||
gaeste = bericht.get("gaeste") or []
|
||||
liste: list[dict] = []
|
||||
container: list[tuple[dict, int]] = []
|
||||
for g in gaeste:
|
||||
platte, im_pool = g.get("platte") or {}, g.get("pool_belegt")
|
||||
if im_pool and platte.get("belegt") is not None:
|
||||
gewinn = int(im_pool) - int(platte["belegt"])
|
||||
if g.get("art") == "lxc":
|
||||
if gewinn >= TRIM_AB:
|
||||
container.append((g, gewinn))
|
||||
elif gewinn >= EMPFEHLUNG_AB:
|
||||
liste.append(_trim_vm(g, int(im_pool), int(platte["belegt"]), gewinn))
|
||||
for u in g.get("ungenutzt") or []:
|
||||
liste.append({
|
||||
"art": "ungenutzt", "platz": u.get("belegt"), "titel": f"{_name(g)}: ungenutzte Platte {u['volume']}",
|
||||
"kurz": f"ungenutzte Platte bei {_name(g)}", "ort": "pool",
|
||||
"text": (f"Hängt an keinem Laufwerk mehr. Wird sie nicht mehr gebraucht: in Proxmox {_wo(g)} → Hardware "
|
||||
"→ „Unbenutzte Festplatte“ → Entfernen (löscht sie endgültig).")})
|
||||
if (summe := sum(x for _, x in container)) >= EMPFEHLUNG_AB:
|
||||
container.sort(key=lambda t: -t[1])
|
||||
einzeln = ", ".join(f"{_name(g)} {_gb(x)} GB" for g, x in container)
|
||||
befehle = ", ".join(f"`pct fstrim {g['vmid']} --ignore-mountpoints`" for g, _ in container)
|
||||
liste.append({
|
||||
"art": "trim", "platz": summe, "titel": "Container: freigegebenen Platz an den Pool zurückgeben",
|
||||
"kurz": f"{_groesse(summe)} per TRIM in den Containern", "ort": "pool",
|
||||
"text": (f"Die Container belegen im Pool {_gb(summe)} GB mehr, als sie nutzen ({einzeln}). In Proxmox: pve → "
|
||||
f"Shell → {befehle} — im laufenden Betrieb und ohne Verlust.")})
|
||||
for p in (bericht.get("host") or {}).get("pools") or []:
|
||||
for v in p.get("verwaist") or []:
|
||||
liste.append({
|
||||
"art": "verwaist", "platz": v.get("belegt"), "titel": f"Verwaiste Platte {v['volume']}",
|
||||
"kurz": f"verwaiste Platte {v['volume']}", "ort": "pool",
|
||||
"text": (f"Gehört zu keinem Gast mehr. Wird sie nicht mehr gebraucht: in Proxmox pve → {p.get('speicher')} "
|
||||
"→ Datenträger → Entfernen (löscht sie endgültig).")})
|
||||
if kernel := _kernel_vorschlag(bericht.get("host") or {}):
|
||||
liste.append(kernel)
|
||||
liste.sort(key=lambda e: -(e["platz"] or 0))
|
||||
if snapshots := _snapshot_vorschlag(gaeste):
|
||||
liste.append(snapshots)
|
||||
return liste
|
||||
|
||||
|
||||
def _geraete_text(namen: list[str]) -> str:
|
||||
"""„Gitea (2), NetBird und AdGuard Home“ — je Gerät einmal, mit der Zahl, wo es mehrere sind."""
|
||||
teile = [f"{n} ({namen.count(n)})" if namen.count(n) > 1 else n for n in dict.fromkeys(namen)]
|
||||
return teile[0] if len(teile) == 1 else ", ".join(teile[:-1]) + " und " + teile[-1]
|
||||
|
||||
|
||||
def _snapshot_vorschlag(gaeste: list[dict]) -> dict | None:
|
||||
"""Alle Snapshots in EINEM Vorschlag (seit 25.09.2026; vorher einer je Snapshot): Verwalten lassen sie sich auf der
|
||||
Seite Homelab → Snapshots, mit Zeitpunkt und Knöpfen. Der Container des Orchestrators selbst fehlt dort wie hier.
|
||||
Wie viel sie belegen, sagt Proxmox nicht — deshalb ohne Größe und zuletzt."""
|
||||
eigene: list[str] = []
|
||||
von_hand: list[str] = []
|
||||
for g in gaeste:
|
||||
if EIGENES_ETIKETT in (g.get("etiketten") or []):
|
||||
continue
|
||||
for s in g.get("snapshots") or []:
|
||||
(eigene if re.fullmatch(r"mc2-\d{8}-\d{6}", str(s)) else von_hand).append(_name(g))
|
||||
anzahl = len(eigene) + len(von_hand)
|
||||
if not anzahl:
|
||||
return None
|
||||
if eigene:
|
||||
titel = (f"{len(eigene)} Snapshot{'s' if len(eigene) != 1 else ''} des Orchestrators — verwalten unter "
|
||||
"Homelab → Snapshots")
|
||||
text = (f"Bei {_geraete_text(eigene)}. Läuft ein Gerät seit seinem Snapshot gut, kann der weg — auf der Seite "
|
||||
"Snapshots per Knopf, dort auch mit Zeitpunkt und dem Weg zurück.")
|
||||
else:
|
||||
titel = (f"{len(von_hand)} von Hand angelegte{'r' if len(von_hand) == 1 else ''} "
|
||||
f"Snapshot{'s' if len(von_hand) != 1 else ''} — ansehen unter Homelab → Snapshots")
|
||||
text = f"Bei {_geraete_text(von_hand)}."
|
||||
if eigene and von_hand:
|
||||
text += f" Dazu {len(von_hand)} von Hand in Proxmox angelegt ({_geraete_text(von_hand)})."
|
||||
if von_hand:
|
||||
text += " Von Hand angelegte löscht der Orchestrator nicht; das geht in Proxmox."
|
||||
text += (" Wie viel ein Snapshot belegt, sagt Proxmox nicht — bei Containern meist wenig, er wächst mit jeder "
|
||||
"Änderung seitdem.")
|
||||
return {"art": "snapshot", "platz": None, "ort": "pool", "titel": titel, "text": text,
|
||||
"kurz": f"{anzahl} Snapshot{'s' if anzahl != 1 else ''} (Homelab → Snapshots)"}
|
||||
|
||||
|
||||
def _kernel_vorschlag(host: dict) -> dict | None:
|
||||
"""Alte Kernel auf der Systemplatte des Hosts — mit Knopf. Die Liste stammt vom Ausführer (was apt für entbehrlich
|
||||
hält, nie der laufende oder der nächste); beim Klick bestimmt er sie noch einmal selbst."""
|
||||
alt = host.get("kernel_alt") or []
|
||||
if not alt:
|
||||
return None
|
||||
platz = sum(int(k.get("groesse") or 0) for k in alt)
|
||||
bleiben = ", ".join(str(v).removesuffix("-pve") for v in host.get("kernel_bleiben") or []) or "der laufende"
|
||||
return {
|
||||
"art": "kernel", "ort": "systemplatte", "platz": platz,
|
||||
"titel": f"Proxmox-Host: {len(alt)} alte Kernel entfernen",
|
||||
"kurz": f"{_groesse(platz)} alte Kernel auf dem Proxmox-Host",
|
||||
"text": (f"Liegen auf der Systemplatte des Hosts, nicht im Speicherpool. Entfernt wird nur, was Proxmox selbst für "
|
||||
f"entbehrlich hält; es bleiben {bleiben} — darunter der laufende und der für den nächsten Start. Die "
|
||||
"Gäste laufen weiter, ein Neustart ist dafür nicht nötig."),
|
||||
"aktion": {"methode": "POST", "pfad": "/api/homelab/ziele/pve/kernel-aufraeumen", "label": "Alte Kernel entfernen",
|
||||
"frage": (f"{len(alt)} alte Kernel vom Proxmox-Host entfernen (etwa {_groesse(platz)})? Es bleiben "
|
||||
f"{bleiben}. Die Gäste laufen weiter, ein Neustart ist nicht nötig.")},
|
||||
}
|
||||
|
||||
|
||||
def systemplatte(bericht: dict) -> dict | None:
|
||||
p = (bericht.get("host") or {}).get("systemplatte") or {}
|
||||
gesamt, belegt = p.get("gesamt"), p.get("belegt")
|
||||
if not gesamt or belegt is None:
|
||||
return None
|
||||
anteil = belegt / gesamt
|
||||
return {"belegt": belegt, "gesamt": gesamt, "frei": gesamt - belegt, "anteil": anteil, "stufe": _stufe(anteil)}
|
||||
|
||||
|
||||
def _trim_vm(g: dict, im_pool: int, genutzt: int, gewinn: int) -> dict:
|
||||
kopf = f"Im Pool belegt die VM {_gb(im_pool)} GB, genutzt sind darin {_gb(genutzt)} GB."
|
||||
if g.get("discard"):
|
||||
text = (f"{kopf} In der VM `sudo fstrim -av` ausführen; Linux-Gäste tun das meist ohnehin jede Woche "
|
||||
"(fstrim.timer).")
|
||||
else:
|
||||
text = (f"{kopf} Discard ist aus, deshalb kommt Platz, den die VM freigibt, nie im Pool an. In Proxmox: "
|
||||
f"VM {g['vmid']} → Hardware → Festplatte → Bearbeiten → „Discard“ anhaken, dann die VM einmal neu "
|
||||
"starten (die Dienste darin sind kurz weg). Danach gibt sie freien Platz jede Woche von selbst zurück; "
|
||||
"sofort mit `sudo fstrim -av` in der VM.")
|
||||
return {"art": "trim-vm", "platz": gewinn, "titel": f"{_name(g)}: {_groesse(gewinn)} an den Pool zurückgeben",
|
||||
"kurz": f"{_groesse(gewinn)} bei {_name(g)} per TRIM" + ("" if g.get("discard") else " (Discard einschalten)"),
|
||||
"ort": "pool",
|
||||
"text": text}
|
||||
|
||||
|
||||
# --- NAS --------------------------------------------------------------------------------------------------------
|
||||
|
||||
def antwortet(server: str, port: int) -> bool:
|
||||
"""Nimmt das NAS auf dem Port seines Dienstes Verbindungen an? (30 s gemerkt: Wächter und Oberfläche fragen oft.)"""
|
||||
jetzt = time.monotonic()
|
||||
with _antwort_lock:
|
||||
if (e := _antwort_cache.get((server, port))) and jetzt - e[0] < ANTWORT_CACHE_S:
|
||||
return e[1]
|
||||
try:
|
||||
with socket.create_connection((server, port), timeout=3):
|
||||
ok = True
|
||||
except OSError:
|
||||
ok = False
|
||||
with _antwort_lock:
|
||||
_antwort_cache[(server, port)] = (jetzt, ok)
|
||||
return ok
|
||||
|
||||
|
||||
def nas(bericht: dict, pruefen=None) -> list[dict]:
|
||||
pruefen = pruefen or antwortet
|
||||
ergebnis = []
|
||||
for m in (bericht.get("host") or {}).get("netzlaufwerke") or []:
|
||||
server, port = m.get("server"), PORTS.get(str(m.get("art")))
|
||||
erreichbar = pruefen(server, port) if server and port else None
|
||||
platz = m.get("platz") or {}
|
||||
gesamt, belegt = platz.get("gesamt"), platz.get("belegt")
|
||||
anteil = belegt / gesamt if gesamt and belegt is not None else None
|
||||
eintrag = {"server": server, "ziel": m.get("ziel"), "quelle": m.get("quelle"), "art": m.get("art"),
|
||||
"eingebunden": bool(m.get("eingebunden")), "zustand": m.get("zustand"), "erreichbar": erreichbar,
|
||||
"belegt": belegt, "gesamt": gesamt, "anteil": anteil, "stufe": "ok", "problem": None}
|
||||
if erreichbar is False:
|
||||
eintrag.update(stufe="rot", problem=f"Das NAS ({server}) antwortet nicht.")
|
||||
elif not m.get("eingebunden"):
|
||||
eintrag.update(stufe="rot", problem=f"{m.get('ziel')} ist auf dem Proxmox-Host nicht eingebunden.")
|
||||
elif m.get("zustand") == "haengt":
|
||||
eintrag.update(stufe="rot", problem=f"Die Einbindung {m.get('ziel')} hängt: Der Proxmox-Host bekommt keine "
|
||||
"Antwort vom NAS.")
|
||||
elif m.get("zustand") == "fehler":
|
||||
eintrag.update(stufe="gelb", problem=f"Die Belegung von {m.get('ziel')} ließ sich nicht lesen.")
|
||||
else:
|
||||
eintrag["stufe"] = _stufe(anteil)
|
||||
ergebnis.append(eintrag)
|
||||
return ergebnis
|
||||
|
||||
|
||||
# --- Sicherungen ------------------------------------------------------------------------------------------------
|
||||
|
||||
def max_alter_s(zeitplan: object) -> int:
|
||||
"""Wie alt die jüngste Sicherung höchstens sein darf: täglich (nur Uhrzeit, etwa „02:30“) 26 Stunden, bei
|
||||
Wochentagen oder allem Ausgefalleneren 8 Tage."""
|
||||
return TAEGLICH_S if re.fullmatch(r"\s*(\d{1,2}|\*)(:\d{2})?\s*", str(zeitplan or "")) else SONST_S
|
||||
|
||||
|
||||
def _erfasst(job: dict, vmid: int) -> bool:
|
||||
"""Sichert dieser Auftrag den Gast? (alle außer den ausgenommenen, oder die aufgezählten)"""
|
||||
if vmid in (job.get("ausgenommen") or []):
|
||||
return False
|
||||
return bool(job.get("alle")) or vmid in (job.get("vmids") or [])
|
||||
|
||||
|
||||
def sicherungen(bericht: dict, jetzt: float) -> dict:
|
||||
host = bericht.get("host") or {}
|
||||
jobs = [j for j in host.get("sicherung_jobs") or [] if j.get("an")]
|
||||
ziele = host.get("sicherungsziele") or {}
|
||||
probleme: list[str] = []
|
||||
for name in sorted({j.get("speicher") for j in jobs if j.get("speicher")}):
|
||||
z = ziele.get(name) or {}
|
||||
if not z.get("aktiv"):
|
||||
probleme.append(f"Das Sicherungsziel {name} ist nicht erreichbar.")
|
||||
elif z.get("fehler"):
|
||||
probleme.append(f"Die Sicherungen auf {name} ließen sich nicht lesen.")
|
||||
erfasst, fehlend, zuletzt = 0, [], None
|
||||
for g in bericht.get("gaeste") or []:
|
||||
eigene = [j for j in jobs if _erfasst(j, g["vmid"])]
|
||||
if not eigene:
|
||||
continue
|
||||
erfasst += 1
|
||||
stand = [((ziele.get(j.get("speicher")) or {}).get("neueste") or {}).get(str(g["vmid"])) for j in eigene]
|
||||
juengste = max((t for t in stand if t), default=None)
|
||||
if juengste:
|
||||
zuletzt = max(zuletzt or 0, juengste)
|
||||
if not any(t and jetzt - t <= max_alter_s(j.get("zeitplan")) for t, j in zip(stand, eigene, strict=True)):
|
||||
fehlend.append({"ziel": _zid(g), "name": _name(g), "zuletzt": juengste})
|
||||
stufe = "keine" if not jobs else "ok"
|
||||
if fehlend:
|
||||
stufe = "rot" if len(fehlend) == erfasst else "gelb"
|
||||
if probleme:
|
||||
stufe = "rot"
|
||||
frische = stufe
|
||||
ohne = _nicht_erfasst(bericht)
|
||||
probe = host.get("sicherung_probe") if isinstance(host.get("sicherung_probe"), dict) else None
|
||||
geraete = _je_geraet(bericht, ziele, {f["ziel"] for f in fehlend}, ohne, probe)
|
||||
pbs = [d for z in ziele.values() if z.get("art") == "pbs" for d in (z.get("details") or {}).values()]
|
||||
aelteste = min((d["aelteste"] for d in pbs if d.get("aelteste")), default=None)
|
||||
pruefung = {"zuletzt": max((g["geprueft"]["zeit"] for g in geraete if g["geprueft"]), default=None),
|
||||
"noetig": bool(aelteste and jetzt - aelteste >= PRUEFUNG_AB_S)}
|
||||
if stufe != "rot" and (any(g["defekt"] and g["geprueft"] and g["geprueft"]["zustand"] == "failed" for g in geraete)):
|
||||
stufe = "rot"
|
||||
elif stufe in ("ok", "keine") and (ohne & {g["vmid"] for g in bericht.get("gaeste") or []}
|
||||
or any(g["probe"] and not g["probe"]["ok"] for g in geraete)):
|
||||
stufe = "gelb"
|
||||
return {"jobs": [{"zeitplan": j.get("zeitplan"), "speicher": j.get("speicher"), "alle": j.get("alle")} for j in jobs],
|
||||
"ziele": [{"speicher": n, "art": z.get("art"), "aktiv": z.get("aktiv"), "belegt": z.get("belegt"),
|
||||
"gesamt": z.get("gesamt"), "schaetzung": z.get("schaetzung")} for n, z in sorted(ziele.items())],
|
||||
"erfasst": erfasst, "fehlend": fehlend, "zuletzt": zuletzt, "probleme": probleme, "stufe": stufe,
|
||||
"stufe_frische": frische, "ohne_auftrag": [{"ziel": g["ziel"], "name": g["name"]} for g in geraete if not g["auftrag"]],
|
||||
"geraete": geraete, "pruefung": pruefung, "probe": {"zeit": probe.get("zeit")} if probe else None}
|
||||
|
||||
|
||||
# --- Abdeckung je Gerät (seit 25.09.2026) --------------------------------------------------------------------------
|
||||
|
||||
PRUEFUNG_AB_S = 8 * 86400 # gibt es Sicherungen, die so alt sind, hätte ein wöchentlicher Prüfauftrag sie gesehen …
|
||||
PRUEFUNG_ALT_S = 15 * 86400 # … und so lange darf die jüngste Prüfung dann höchstens zurückliegen
|
||||
|
||||
|
||||
def _nicht_erfasst(bericht: dict) -> set[int]:
|
||||
"""Gäste ohne Sicherungsauftrag, wie Proxmox sie meldet (Rechenzentrum → Backup; im Bericht seit 25.09.2026). Ein
|
||||
älterer Ausführer liefert das nicht: dann selbst ausgerechnet, ausgeschaltete Aufträge zählen mit."""
|
||||
host = bericht.get("host") or {}
|
||||
if isinstance(host.get("nicht_gesichert"), list):
|
||||
return {int(v) for v in host["nicht_gesichert"] if str(v).isdigit()}
|
||||
if not isinstance(host.get("sicherung_jobs"), list):
|
||||
return set()
|
||||
jobs = host["sicherung_jobs"]
|
||||
return {g["vmid"] for g in bericht.get("gaeste") or [] if not any(_erfasst(j, g["vmid"]) for j in jobs)}
|
||||
|
||||
|
||||
def _je_geraet(bericht: dict, ziele: dict, fehlend: set[str], ohne: set[int], probe: dict | None) -> list[dict]:
|
||||
"""Je Gerät: ob ein Auftrag es erfasst, die jüngste Sicherung (über alle Ziele), ob sie frisch ist, die jüngste
|
||||
Prüfung durch den PBS, beschädigte Sicherungen und das Ergebnis der Probe-Wiederherstellung."""
|
||||
zeilen = []
|
||||
for g in sorted(bericht.get("gaeste") or [], key=lambda g: g["vmid"]):
|
||||
vmid = str(g["vmid"])
|
||||
details = [d for z in ziele.values() if (d := (z.get("details") or {}).get(vmid))]
|
||||
zuletzt = max([t for z in ziele.values() if (t := (z.get("neueste") or {}).get(vmid))], default=None)
|
||||
pruefungen = [d["geprueft"] for d in details if d.get("geprueft")]
|
||||
eigene_probe = ((probe or {}).get("gaeste") or {}).get(vmid)
|
||||
eigener = EIGENES_ETIKETT in (g.get("etiketten") or [])
|
||||
zeilen.append({
|
||||
"ziel": _zid(g), "name": "Homelab Orchestrator (dieser Container)" if eigener else _name(g),
|
||||
"vmid": g["vmid"], "auftrag": g["vmid"] not in ohne,
|
||||
"zuletzt": zuletzt, "frisch": bool(zuletzt) and g["vmid"] not in ohne and _zid(g) not in fehlend,
|
||||
"geprueft": max(pruefungen, key=lambda p: (p["zeit"], p["sicherung"]), default=None),
|
||||
"defekt": sorted({t for d in details for t in d.get("defekt") or []}),
|
||||
"probe": {**eigene_probe, "zeit": probe.get("zeit")} if eigene_probe else None})
|
||||
return zeilen
|
||||
|
||||
|
||||
def probe_starten() -> dict:
|
||||
"""Knopf „Jetzt prüfen“: die Probe-Wiederherstellung als Auftrag an den Ausführer (nur lesend). Das Ergebnis
|
||||
bringt der Bericht gleich danach."""
|
||||
zuletzt = kanal.zuletzt()
|
||||
if not zuletzt or time.time() - zuletzt > BERICHT_ALT_S:
|
||||
return {"ok": False, "detail": "Der Ausführer auf dem Proxmox-Host meldet sich gerade nicht."}
|
||||
if any(a.get("aktion") == "sicherung_probe" and a.get("status") in ("wartet", "laeuft") for a in kanal.liste()):
|
||||
return {"ok": False, "detail": "Die Probe läuft schon."}
|
||||
return {"ok": True, "auftrag": kanal.anlegen("sicherung_probe")}
|
||||
|
||||
|
||||
def _aus_schaetzung(z: dict, jetzt: float) -> dict | None:
|
||||
"""Die Schätzung des PBS als Vorhersage (wie kern/prognose.vorhersage, mit „quelle“: „pbs“)."""
|
||||
s = z.get("schaetzung")
|
||||
if not isinstance(s, dict):
|
||||
return None
|
||||
anteil = _prozent(s.get("belegt") or z.get("belegt"), s.get("gesamt") or z.get("gesamt"))
|
||||
voll_am = s.get("voll_am")
|
||||
if not messreihen.ist_zahl(voll_am) or voll_am <= jetzt:
|
||||
return {"tage": None, "rate": 0.0, "anteil": anteil, "grund": "gleich", "quelle": "pbs"}
|
||||
tage = (voll_am - jetzt) / 86400
|
||||
rate = round((100 - anteil) / tage, 2) if anteil is not None and tage > 0 else None
|
||||
return {"tage": round(tage, 1), "rate": rate, "anteil": anteil, "grund": None, "quelle": "pbs"}
|
||||
|
||||
|
||||
# --- Vorhersage „voll in etwa N Tagen“ (seit 25.09.2026) --------------------------------------------------------
|
||||
|
||||
VERLAUF = "speicher" # Quelle in kern/messreihen.py: die Belegungen aus jedem Bericht (alle zehn Minuten)
|
||||
|
||||
|
||||
def _prozent(belegt: object, gesamt: object) -> float | None:
|
||||
if not (messreihen.ist_zahl(belegt) and messreihen.ist_zahl(gesamt)) or gesamt <= 0:
|
||||
return None
|
||||
return round(belegt / gesamt * 100, 2)
|
||||
|
||||
|
||||
def verlauf_merken(bericht: dict, jetzt: float | None = None) -> None:
|
||||
"""Die Belegungen eines Berichts als ein Punkt der Messreihe VERLAUF (in %): je Speicherpool Daten
|
||||
(„pool:<name>“) und Metadaten („pool-meta:<name>“), je Netzlaufwerk („nas:<ziel>“), je Sicherungsziel
|
||||
(„ziel:<name>“) und die Platte jeder VM („vm-<vmid>“ — die Minutenwerte kennen sie nicht). Ein kaputter Bericht
|
||||
kostet nur diesen Punkt."""
|
||||
try:
|
||||
host = bericht.get("host") if isinstance(bericht.get("host"), dict) else {}
|
||||
werte: dict[str, float | None] = {}
|
||||
for p in pools(bericht):
|
||||
werte[f"pool:{p['speicher']}"] = round(p["anteil"] * 100, 2)
|
||||
if messreihen.ist_zahl(p["meta_anteil"]):
|
||||
werte[f"pool-meta:{p['speicher']}"] = round(p["meta_anteil"] * 100, 2)
|
||||
for m in host.get("netzlaufwerke") or []:
|
||||
platz = m.get("platz") or {}
|
||||
werte[f"nas:{m.get('ziel')}"] = _prozent(platz.get("belegt"), platz.get("gesamt"))
|
||||
for name, z in (host.get("sicherungsziele") or {}).items():
|
||||
werte[f"ziel:{name}"] = _prozent(z.get("belegt"), z.get("gesamt"))
|
||||
for g in bericht.get("gaeste") or []:
|
||||
if g.get("art") == "qemu":
|
||||
platte = g.get("platte") or {}
|
||||
werte[f"vm-{g['vmid']}"] = _prozent(platte.get("belegt"), platte.get("gesamt"))
|
||||
werte = {name: wert for name, wert in werte.items() if wert is not None}
|
||||
if werte:
|
||||
messreihen.schreiben(VERLAUF, werte, jetzt)
|
||||
except Exception:
|
||||
log.warning("speicher: Verlauf der Belegungen nicht geschrieben", exc_info=True)
|
||||
|
||||
|
||||
def platten_reihe(geraet_id: str) -> tuple[str, str, float] | None:
|
||||
"""Wo der Verlauf der Platte eines Geräts liegt: (Quelle, Name, voll bei %). Host und Container in den
|
||||
Minutenwerten; VMs im VERLAUF, denn ihre Platte kennt nur der Bericht (über den Gast-Agenten)."""
|
||||
if geraet_id == "pve":
|
||||
return "pve", "platte", prognose.VOLL_EXT4
|
||||
if re.fullmatch(r"ct-\d+", geraet_id):
|
||||
return geraet_id, "platte", prognose.VOLL_EXT4
|
||||
if re.fullmatch(r"vm-\d+", geraet_id):
|
||||
return VERLAUF, geraet_id, prognose.VOLL_EXT4
|
||||
return None
|
||||
|
||||
|
||||
def platten_vorhersage(geraet_id: str, jetzt: float | None = None) -> dict | None:
|
||||
reihe = platten_reihe(geraet_id)
|
||||
return prognose.aus_messreihe(*reihe, jetzt=jetzt) if reihe else None
|
||||
|
||||
|
||||
def _vorhersage(name: str, jetzt: float | None) -> dict:
|
||||
return prognose.aus_messreihe(VERLAUF, name, jetzt=jetzt)
|
||||
|
||||
|
||||
def pool_vorhersage(speicher: str, jetzt: float | None = None) -> tuple[dict, bool]:
|
||||
"""Die Vorhersage eines Pools — Daten oder Metadaten, was früher voll ist — und ob es die Metadaten sind."""
|
||||
daten, meta = _vorhersage(f"pool:{speicher}", jetzt), _vorhersage(f"pool-meta:{speicher}", jetzt)
|
||||
return (meta, True) if prognose.bis_voll(meta) < prognose.bis_voll(daten) else (daten, False)
|
||||
|
||||
|
||||
# --- Für Oberfläche und Wächter ----------------------------------------------------------------------------------
|
||||
|
||||
def lage(jetzt: float | None = None) -> dict:
|
||||
"""Für GET /api/homelab/speicher. Pools, Systemplatte, NAS und Sicherungsziele tragen seit 25.09.2026 je eine
|
||||
„vorhersage“ (kern/prognose.vorhersage). jetzt nur für Tests: Dann wird die Vorhersage nicht gemerkt."""
|
||||
b = _bericht()
|
||||
zeit = time.time() if jetzt is None else jetzt
|
||||
ergebnis = {"bericht": bool(b), "pools": pools(b), "systemplatte": systemplatte(b), "nas": nas(b),
|
||||
"sicherungen": sicherungen(b, zeit), "empfehlungen": empfehlungen(b)}
|
||||
for p in ergebnis["pools"]:
|
||||
p["vorhersage"] = pool_vorhersage(p["speicher"], jetzt)[0]
|
||||
if ergebnis["systemplatte"]:
|
||||
ergebnis["systemplatte"]["vorhersage"] = platten_vorhersage("pve", jetzt)
|
||||
for n in ergebnis["nas"]:
|
||||
n["vorhersage"] = None if n["problem"] else _vorhersage(f"nas:{n['ziel']}", jetzt)
|
||||
for z in ergebnis["sicherungen"]["ziele"]:
|
||||
z["vorhersage"] = _aus_schaetzung(z, zeit) or _vorhersage(f"ziel:{z['speicher']}", jetzt)
|
||||
return ergebnis
|
||||
|
||||
|
||||
def pruefe_speicher(jetzt: float | None = None) -> list:
|
||||
"""Befunde für den Wächter. Ohne Bericht keine — das Schweigen des Ausführers meldet pruefe_ausfuehrer().
|
||||
Pool, Systemplatte und NAS melden sich auch nach dem Tempo (siehe oben); bestimmt die Vorhersage die Stufe, steht
|
||||
„in etwa N Tagen voll“ im Titel, sonst hängt der Text sie an."""
|
||||
from services.waechter import Befund # der Wächter registriert diese Prüfung, also erst hier
|
||||
b = _bericht()
|
||||
if not b:
|
||||
return []
|
||||
zeit = time.time() if jetzt is None else jetzt
|
||||
befunde = []
|
||||
tipps = empfehlungen(b)
|
||||
platz_tipp = "; ".join([e["kurz"] for e in tipps if e.get("ort") == "pool"][:3])
|
||||
for p in pools(b):
|
||||
v, meta = pool_vorhersage(p["speicher"], jetzt)
|
||||
stufe, kommend = prognose.schlimmer(p["stufe"], v)
|
||||
if stufe == "ok":
|
||||
continue
|
||||
rest = ("Läuft er voll, stehen alle Gäste darauf still. " + (f"Platz bringt: {platz_tipp}. " if platz_tipp else "")
|
||||
+ "Alles dazu im Homelab-Cockpit unter „Speicher und Sicherungen“.")
|
||||
if kommend:
|
||||
was = "Metadaten des Speicherpools" if meta else "Speicherpool"
|
||||
titel = f"{was} {p['speicher']} {prognose.in_tagen(v['tage'])} voll"
|
||||
text = f"{prognose.tempo(v)} Noch {_groesse(p['frei'])} frei. {rest}"
|
||||
else:
|
||||
anteil = max(p["anteil"], p["meta_anteil"] or 0)
|
||||
was = "Metadaten des Speicherpools" if (p["meta_anteil"] or 0) > p["anteil"] else "Speicherpool"
|
||||
titel = f"{was} {p['speicher']} zu {min(100, round(anteil * 100))} % voll"
|
||||
text = f"Noch {_groesse(p['frei'])} frei.{prognose.dazu(v)} {rest}"
|
||||
befunde.append(Befund(id=f"pool:{p['speicher']}", stufe=stufe, titel=titel, text=text, quelle="pve"))
|
||||
sp = systemplatte(b)
|
||||
v = platten_vorhersage("pve", jetzt) if sp else None
|
||||
stufe, kommend = prognose.schlimmer(sp["stufe"], v) if sp else ("ok", False)
|
||||
if sp and stufe != "ok":
|
||||
kernel = next((e for e in tipps if e["art"] == "kernel"), None)
|
||||
befunde.append(Befund(
|
||||
id="host-platte", stufe=stufe,
|
||||
titel=(f"Systemplatte des Proxmox-Hosts {prognose.in_tagen(v['tage'])} voll" if kommend
|
||||
else f"Systemplatte des Proxmox-Hosts zu {min(100, round(sp['anteil'] * 100))} % voll"),
|
||||
text=((f"{prognose.tempo(v)} " if kommend else "") + f"Noch {_groesse(sp['frei'])} frei."
|
||||
+ ("" if kommend else prognose.dazu(v))
|
||||
+ " Läuft sie voll, scheitern Updates und die Sicherungen vor Updates. "
|
||||
+ (f"Platz bringt: {kernel['kurz']} (Knopf im Homelab-Cockpit unter „Speicher und Sicherungen“)."
|
||||
if kernel else "Alte Sicherungen unter /var/lib/vz/dump und ISO-Abbilder prüfen.")),
|
||||
quelle="pve"))
|
||||
for n in nas(b):
|
||||
v = None if n["problem"] else _vorhersage(f"nas:{n['ziel']}", jetzt)
|
||||
stufe, kommend = prognose.schlimmer(n["stufe"], v)
|
||||
if stufe == "ok":
|
||||
continue
|
||||
frei = _groesse((n["gesamt"] or 0) - (n["belegt"] or 0))
|
||||
platz = ("Die Sicherungen brauchen jede Nacht Platz: im PBS die Aufbewahrung kürzen (Datastore → Prune) oder "
|
||||
"alte Dateien auf dem NAS aufräumen.")
|
||||
if n["problem"]:
|
||||
titel, text = n["problem"], ("Die nächtlichen Sicherungen landen dort; bis das behoben ist, scheitern sie. "
|
||||
"Strom und Netz des NAS prüfen, dann in dessen Oberfläche nachsehen.")
|
||||
elif kommend:
|
||||
titel = f"NAS ({n['server']}) {prognose.in_tagen(v['tage'])} voll"
|
||||
text = f"{prognose.tempo(v)} Noch {frei} frei. {platz}"
|
||||
else:
|
||||
titel = f"NAS ({n['server']}) zu {min(100, round((n['anteil'] or 0) * 100))} % voll"
|
||||
text = f"Noch {frei} frei.{prognose.dazu(v)} {platz}"
|
||||
befunde.append(Befund(id=f"nas:{n['ziel']}", stufe=stufe, titel=titel, text=text, quelle="nas"))
|
||||
s = sicherungen(b, zeit)
|
||||
if s["stufe_frische"] in ("gelb", "rot"):
|
||||
teile = list(s["probleme"])
|
||||
if s["fehlend"]:
|
||||
namen = ", ".join(f"{f['name']} ({'zuletzt ' + _datum(f['zuletzt']) if f['zuletzt'] else 'noch nie'})"
|
||||
for f in s["fehlend"][:6])
|
||||
teile.append(f"Keine frische Sicherung: {namen}.")
|
||||
befunde.append(Befund(
|
||||
id="sicherungen", stufe=s["stufe_frische"],
|
||||
titel=("Sicherungen scheitern" if s["stufe_frische"] == "rot" else f"Sicherung fehlt bei {len(s['fehlend'])} Gerät"
|
||||
+ ("en" if len(s["fehlend"]) != 1 else "")),
|
||||
text=" ".join(teile) + " In Proxmox: Rechenzentrum → Backup und die Aufgaben-Liste unten zeigen, woran es lag.",
|
||||
quelle="pve"))
|
||||
befunde += _abdeckung_befunde(s, zeit)
|
||||
for z in s["ziele"]:
|
||||
v = _aus_schaetzung(z, zeit)
|
||||
if prognose.stufe(v) != "ok":
|
||||
befunde.append(Befund(
|
||||
id=f"ziel:{z['speicher']}", stufe=prognose.stufe(v),
|
||||
titel=f"Sicherungsziel {z['speicher']} {prognose.in_tagen(v['tage'])} voll",
|
||||
text=(f"So schätzt es der PBS selbst. Noch {_groesse((z['gesamt'] or 0) - (z['belegt'] or 0))} frei. Im PBS "
|
||||
"die Aufbewahrung kürzen (Datastore → Prune) oder Platz auf dem NAS schaffen."),
|
||||
quelle="pve"))
|
||||
return befunde
|
||||
|
||||
|
||||
def _tag(ts: float) -> str:
|
||||
return datetime.fromtimestamp(ts, LOCAL_TZ).strftime("%d.%m.")
|
||||
|
||||
|
||||
def _abdeckung_befunde(s: dict, jetzt: float) -> list:
|
||||
"""Die Hinweise zur Abdeckung: ohne Auftrag, beschädigt, lange nicht geprüft, Probe gescheitert."""
|
||||
from services.waechter import Befund
|
||||
befunde = []
|
||||
if ohne := s["ohne_auftrag"]:
|
||||
namen = ", ".join(g["name"] for g in ohne)
|
||||
befunde.append(Befund(
|
||||
id="sicherung:ohne-auftrag", stufe="gelb", sofort=True,
|
||||
titel=(f"{namen} ist in keinem Sicherungsauftrag" if len(ohne) == 1
|
||||
else f"{len(ohne)} Geräte sind in keinem Sicherungsauftrag"),
|
||||
text=(("Proxmox sichert es nie. " if len(ohne) == 1 else f"Proxmox sichert sie nie: {namen}. ")
|
||||
+ "In Proxmox: Rechenzentrum → Backup → den Auftrag bearbeiten und die Geräte anhaken (oder „Alle“)."),
|
||||
quelle="pve"))
|
||||
if defekt := [g for g in s["geraete"] if g["defekt"]]:
|
||||
akut = any(g["geprueft"] and g["geprueft"]["zustand"] == "failed" for g in defekt)
|
||||
teile = ", ".join(f"{g['name']} (Sicherung vom {_tag(g['defekt'][-1])})" for g in defekt[:6])
|
||||
befunde.append(Befund(
|
||||
id="sicherung:defekt", stufe="rot" if akut else "gelb",
|
||||
titel=("Der PBS fand eine beschädigte Sicherung" if len(defekt) == 1
|
||||
else f"Der PBS fand beschädigte Sicherungen bei {len(defekt)} Geräten"),
|
||||
text=(f"Beim Prüfen stimmten Daten nicht: {teile}. Die nächste nächtliche Sicherung sollte wieder "
|
||||
"vollständig sein — danach die beschädigte im PBS löschen (Datastore → Inhalt) und am NAS nach "
|
||||
"Plattenfehlern sehen."),
|
||||
quelle="pve"))
|
||||
pruefung = s["pruefung"]
|
||||
if pruefung["noetig"] and (not pruefung["zuletzt"] or jetzt - pruefung["zuletzt"] > PRUEFUNG_ALT_S):
|
||||
befunde.append(Befund(
|
||||
id="sicherung:pruefung", stufe="gelb",
|
||||
titel=("Der PBS prüft die Sicherungen nicht" if not pruefung["zuletzt"]
|
||||
else f"Der PBS hat seit {int((jetzt - pruefung['zuletzt']) // 86400)} Tagen keine Sicherung geprüft"),
|
||||
text=("Ob eine Sicherung noch lesbar ist, zeigt erst die Prüfung. Im PBS: Datastore → Verify Jobs — läuft "
|
||||
"dort ein Auftrag? Wenn keiner da ist: „Add“, etwa einmal die Woche."),
|
||||
quelle="pve"))
|
||||
if gescheitert := [g for g in s["geraete"] if g["probe"] and not g["probe"]["ok"]]:
|
||||
befunde.append(Befund(
|
||||
id="sicherung:probe", stufe="gelb",
|
||||
titel=(f"Probe-Wiederherstellung gescheitert: {gescheitert[0]['name']}" if len(gescheitert) == 1
|
||||
else f"Probe-Wiederherstellung bei {len(gescheitert)} Geräten gescheitert"),
|
||||
text=(" ".join(f"{g['name']}: {g['probe']['fehler']}." for g in gescheitert[:4])
|
||||
+ " Die Probe öffnet die jüngste Sicherung nur lesend und läuft in einer Stunde wieder."),
|
||||
quelle="pve"))
|
||||
return befunde
|
||||
@@ -0,0 +1,501 @@
|
||||
"""„Jetzt updaten“ im Homelab (Phase 4, User-Entscheide 24.09.2026).
|
||||
|
||||
Nur per Knopf, mit Erfolgsmeldung und Erreichbarkeits-Check; ist er rot, geht es automatisch zurück
|
||||
und es wird gemeldet. Host-Updates gibt es mit Warnung, der Neustart ist ein eigener Knopf.
|
||||
|
||||
Ablauf für einen Gast:
|
||||
1. Rückweg anlegen: ein Snapshot; wo keiner geht (PBS: Bind-Mount), eine Sicherung (vzdump auf einen lokalen
|
||||
Speicher des Hosts, nie auf den PBS); geht beides nicht, ohne Rückweg — die Rückfrage sagt es. Scheitert
|
||||
dieser Schritt, beginnt das Update gar nicht.
|
||||
2. Update: die App per Community-Script (`update`, still) oder die Pakete des Gasts. Ein frisch geändertes
|
||||
Update-Skript wartet erst MC_HOMELAB_KARENZ_H Stunden (karenz.py).
|
||||
3. 20 s warten, frischen Bericht holen
|
||||
4. Prüfen: läuft der Gast, antwortet die Weboberfläche, ist die App-Version jetzt neuer?
|
||||
5. Rot → zurück auf den Snapshot bzw. die Sicherung zurückspielen → dringende Meldung.
|
||||
Grün → ältere Snapshots bzw. Sicherungen des Orchestrators für diesen Gast weg (nicht die, die jemand auf der
|
||||
Seite Snapshots per Knopf angelegt hat, snapshots.py) → Meldung „eingespielt“.
|
||||
Docker-Images (Arcane, VM 106) gehen über Arcanes eigenen Updater: bis zum Schalter „echt“ nur als Probelauf,
|
||||
danach genauso mit Snapshot vorher und Rückweg bei Rot — sofern die VM freigegeben ist (Etikett watcher, seit
|
||||
25.09.), sonst ohne; Rückfrage und Update-Liste sagen es. Nach grüner Prüfung geht dieser Snapshot gleich wieder weg.
|
||||
Jeder Lauf steht in <Datenordner>/homelab-laeufe.json und im strukturierten Update-Verlauf. Pro Ziel
|
||||
läuft höchstens ein Lauf; startet dieser Teil neu, gilt ein offener Lauf als unterbrochen.
|
||||
|
||||
„Rückweg testen“ (Baustein „probe“, seit 25.09.2026): Snapshot bzw. Sicherung anlegen, die Prüfung absichtlich als
|
||||
rot werten, zurückgehen und nachprüfen, ob das Gerät danach läuft und antwortet. Ein Update gibt es dabei nicht; so
|
||||
lässt sich der Weg zurück jederzeit echt beweisen, ohne auf ein gescheitertes Update zu warten.
|
||||
|
||||
„Alle aktualisieren“ (sammellauf.py, seit 24.09.2026) stößt dieselben Läufe nacheinander an. Solange es läuft,
|
||||
lehnt der einzelne Knopf ab (SAMMELLAUF_SPERRE), und die Erfolgsmeldung eines Schritts entfällt — die Sammelmeldung
|
||||
am Ende sagt es; Fehler werden weiter sofort gemeldet.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from kern.einstellungen import einstellungen
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
from services import announce, update_verlauf
|
||||
from services.homelab import apps, arcane, inventar, kanal, karenz, sicherheit
|
||||
|
||||
try:
|
||||
import fcntl # Linux: Sperre über Prozessgrenzen
|
||||
except ImportError: # Windows (Entwicklung): nur die Thread-Sperre
|
||||
fcntl = None
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
WARTEN_NACH_UPDATE_S = 20
|
||||
ZEITLIMIT_UPDATE_S = 45 * 60
|
||||
ZEITLIMIT_KURZ_S = 15 * 60
|
||||
ZEITLIMIT_SICHERUNG_S = 40 * 60 # Sicherung und Zurückspielen (der Ausführer gibt nach 30 min auf)
|
||||
ARCANE_FRIST_S = 180 # so lange darf Arcane nach einem Docker-Update brauchen, bis es wieder antwortet
|
||||
BETREFF = "[Homelab-Update]"
|
||||
BETREFF_ALARM = "[Alarm] Homelab-Update"
|
||||
SAMMELLAUF_SPERRE = "Es läuft gerade ‚Alle aktualisieren‘."
|
||||
|
||||
_lock = threading.RLock() # _merken liest und schreibt unter derselben Sperre
|
||||
# Prüfen und Anlegen eines Laufs am Stück — auch „Alle aktualisieren“ legt sich unter ihr an. Sonst könnte ein
|
||||
# einzelner Knopf genau zwischen Prüfung und Anlegen des Sammellaufs durchrutschen.
|
||||
START_SPERRE = threading.Lock()
|
||||
|
||||
|
||||
def _pfad() -> Path:
|
||||
return einstellungen().daten_dir / "homelab-laeufe.json"
|
||||
|
||||
|
||||
def _laeufe() -> list[dict]:
|
||||
with _lock:
|
||||
try:
|
||||
daten = json.loads(_pfad().read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return []
|
||||
return daten if isinstance(daten, list) else []
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _datei_sperre() -> Iterator[None]:
|
||||
"""Seit 25.09.2026 schreiben zwei Prozesse Läufe: die Oberfläche (Knöpfe) und der Steward (Wartungsfenster). Ohne
|
||||
flock könnte einer den Lauf des anderen mit einer älteren Liste überschreiben."""
|
||||
if fcntl is None:
|
||||
yield
|
||||
return
|
||||
pfad = _pfad().with_suffix(".lock")
|
||||
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(pfad, "a", encoding="utf-8") as f:
|
||||
fcntl.flock(f, fcntl.LOCK_EX)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(f, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def _merken(lauf: dict) -> None:
|
||||
with _lock, _datei_sperre():
|
||||
laeufe = [x for x in _laeufe() if x["id"] != lauf["id"]] + [lauf]
|
||||
_pfad().parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = _pfad().with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(laeufe[-100:], ensure_ascii=False), encoding="utf-8")
|
||||
tmp.replace(_pfad())
|
||||
|
||||
|
||||
def laeufe(anzahl: int = 20) -> list[dict]:
|
||||
return list(reversed(_laeufe()))[:anzahl]
|
||||
|
||||
|
||||
def lauf_lesen(lauf_id: str) -> dict | None:
|
||||
return next((x for x in _laeufe() if x.get("id") == lauf_id), None)
|
||||
|
||||
|
||||
def laufende_ziele() -> set[str]:
|
||||
"""Ziele mit einem laufenden Update. Liest nur die Datei — geht also auch im Steward (Wächter, Pflege)."""
|
||||
return {str(x.get("ziel")) for x in _laeufe() if x.get("status") == "laeuft"}
|
||||
|
||||
|
||||
def laeuft(ziel_id: str) -> bool:
|
||||
return ziel_id in laufende_ziele()
|
||||
|
||||
|
||||
def unterbrochene_abschliessen() -> None:
|
||||
"""Beim Start: Läufe, die noch „läuft“ heißen, gehörten zum vorigen Prozess — ebenso ein laufendes
|
||||
„Alle aktualisieren“ (danach, damit es den unterbrochenen Lauf schon als solchen sieht)."""
|
||||
for lauf in _laeufe():
|
||||
if lauf.get("status") == "laeuft":
|
||||
lauf.update(status="unterbrochen", ende=time.time())
|
||||
lauf["schritte"].append("Der Homelab-Teil wurde während des Laufs neu gestartet.")
|
||||
_merken(lauf)
|
||||
from services.homelab import sammellauf # sammellauf importiert dieses Modul
|
||||
sammellauf.unterbrochene_abschliessen()
|
||||
|
||||
|
||||
def _sammellauf_laeuft() -> bool:
|
||||
from services.homelab import sammellauf
|
||||
return sammellauf.laeuft()
|
||||
|
||||
|
||||
def _melden(text: str, dringend: bool = False) -> None:
|
||||
try:
|
||||
announce.notify_telegram(BETREFF_ALARM if dringend else BETREFF, text)
|
||||
except Exception:
|
||||
log.warning("homelab: Meldung ging nicht raus", exc_info=True)
|
||||
|
||||
|
||||
def _auftrag(lauf: dict, aktion: str, parameter: dict, zeitlimit_s: float) -> dict:
|
||||
"""Auftrag an den Ausführer und auf das Ergebnis warten. Wirft RuntimeError bei Fehler/Zeitablauf."""
|
||||
aid = kanal.anlegen(aktion, parameter)
|
||||
lauf["schritte"].append(f"{aktion} …")
|
||||
_merken(lauf)
|
||||
a = kanal.warten(aid, zeitlimit_s)
|
||||
if a is None:
|
||||
raise RuntimeError(f"{aktion}: keine Antwort des Ausführers in {int(zeitlimit_s // 60)} Minuten")
|
||||
if a["status"] != "fertig":
|
||||
raise RuntimeError(f"{aktion} gescheitert: {str(a.get('text') or '')[-300:]}")
|
||||
lauf["schritte"][-1] = f"{aktion}: ok"
|
||||
return a
|
||||
|
||||
|
||||
def _frischer_gast(lauf: dict, vmid: int) -> dict | None:
|
||||
try:
|
||||
a = _auftrag(lauf, "bericht", {}, 5 * 60)
|
||||
bericht = json.loads(a.get("text") or "{}")
|
||||
kanal.bericht_speichern(bericht, aus_auftrag=True)
|
||||
except (RuntimeError, ValueError):
|
||||
return None
|
||||
return next((g for g in bericht.get("gaeste") or [] if g.get("vmid") == vmid), None)
|
||||
|
||||
|
||||
def pruefen(vorher: dict, nachher: dict | None, baustein: str) -> list[str]:
|
||||
"""Was nach dem Update nicht stimmt (leer = grün)."""
|
||||
if nachher is None:
|
||||
return ["Der Gast fehlt im neuen Bericht."]
|
||||
fehler = []
|
||||
if nachher.get("status") != "running":
|
||||
fehler.append(f"Der Gast läuft nicht ({nachher.get('status')}).")
|
||||
app = apps.app_fuer((nachher.get("app") or {}).get("kennung"), nachher.get("name"))
|
||||
url = apps.adresse(app, nachher.get("ip"))
|
||||
if url and not inventar.erreichbar_frisch(url):
|
||||
fehler.append(f"Die Weboberfläche ({url}) antwortet nicht.")
|
||||
if baustein == "app":
|
||||
alt, neu = (vorher.get("app") or {}).get("version"), (nachher.get("app") or {}).get("version")
|
||||
if alt and neu and alt == neu:
|
||||
fehler.append(f"Die App-Version ist unverändert ({alt}).")
|
||||
return fehler
|
||||
|
||||
|
||||
def _rueckweg_anlegen(lauf: dict, gast: dict) -> tuple[str, str] | None:
|
||||
"""Vor dem Update: ("snapshot", Name), wo keiner geht ("sicherung", Archiv), sonst None. Wirft RuntimeError,
|
||||
wenn der Schritt scheitert (etwa zu wenig Platz) — dann beginnt das Update gar nicht."""
|
||||
vmid = gast["vmid"]
|
||||
if gast.get("snapshot_moeglich"):
|
||||
a = _auftrag(lauf, "snapshot", {"vmid": vmid}, ZEITLIMIT_KURZ_S)
|
||||
m = re.search(r"snapshot=(mc2-\d{8}-\d{6})", a.get("text") or "")
|
||||
return ("snapshot", m.group(1)) if m else None
|
||||
if gast.get("sicherung_moeglich"):
|
||||
a = _auftrag(lauf, "sichern", {"vmid": vmid}, ZEITLIMIT_SICHERUNG_S)
|
||||
m = re.search(r"^sicherung=(\S+)$", a.get("text") or "", re.MULTILINE)
|
||||
if not m:
|
||||
raise RuntimeError("sichern: Der Ausführer nannte keine Sicherung")
|
||||
return ("sicherung", m.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def _zurueck(lauf: dict, vmid: int, rueckweg: tuple[str, str]) -> str:
|
||||
"""Den Rückweg gehen; Rückgabe: was geschah (für die Meldung). Wirft RuntimeError, wenn er scheitert."""
|
||||
art, name = rueckweg
|
||||
if art == "snapshot":
|
||||
_auftrag(lauf, "zurueck", {"vmid": vmid, "snapshot": name}, ZEITLIMIT_KURZ_S)
|
||||
return f"automatisch zurück auf den Snapshot {name}"
|
||||
_auftrag(lauf, "sicherung_zurueck", {"vmid": vmid, "sicherung": name}, ZEITLIMIT_SICHERUNG_S)
|
||||
return f"automatisch die Sicherung von vorher zurückgespielt ({name.rsplit('/', 1)[-1]})"
|
||||
|
||||
|
||||
def _gast_lauf(lauf: dict, gast: dict) -> None:
|
||||
vmid, baustein = gast["vmid"], lauf["baustein"]
|
||||
try:
|
||||
rueckweg = _rueckweg_anlegen(lauf, gast)
|
||||
except RuntimeError as exc:
|
||||
_ende(lauf, "fehler", f"{lauf['name']}: Update nicht begonnen — {str(exc).rstrip('.')}. "
|
||||
"Am Gerät wurde nichts geändert.")
|
||||
return
|
||||
try:
|
||||
# Seit 25.09.2026 „sicherheit“: nur die Pakete, die eine Lücke schließen (Wartungsfenster, Lucy).
|
||||
mehr = {"pakete": lauf["sicherheit_pakete"]} if baustein == "sicherheit" else {}
|
||||
_auftrag(lauf, "update" if baustein == "app" else "os_update", {"vmid": vmid, **mehr}, ZEITLIMIT_UPDATE_S)
|
||||
time.sleep(WARTEN_NACH_UPDATE_S)
|
||||
nachher = _frischer_gast(lauf, vmid)
|
||||
if baustein == "app" and nachher:
|
||||
lauf["version_neu"] = (nachher.get("app") or {}).get("version") # fürs Protokoll
|
||||
fehler = pruefen(gast, nachher, baustein)
|
||||
except RuntimeError as exc:
|
||||
fehler = [str(exc)]
|
||||
if not fehler:
|
||||
_aufraeumen(lauf, vmid, rueckweg)
|
||||
_ende(lauf, "eingespielt", f"{lauf['name']}: eingespielt, Prüfung grün.")
|
||||
return
|
||||
if rueckweg:
|
||||
try:
|
||||
wie = _zurueck(lauf, vmid, rueckweg)
|
||||
_ende(lauf, "zurueckgerollt",
|
||||
f"{lauf['name']}: Update gescheitert ({' '.join(fehler)}) — {wie}. Läuft wieder wie vorher.",
|
||||
dringend=True)
|
||||
return
|
||||
except RuntimeError as exc:
|
||||
fehler.append(f"Auch der Rückweg scheiterte: {exc}")
|
||||
if rueckweg[0] == "sicherung":
|
||||
fehler.append(f"Die Sicherung {rueckweg[1]} liegt weiter auf dem Proxmox-Host.")
|
||||
_ende(lauf, "fehler", f"{lauf['name']}: Update gescheitert — {' '.join(fehler)}"
|
||||
+ ("" if rueckweg else " Es gab weder Snapshot noch Sicherung, also keinen automatischen "
|
||||
"Rückweg."),
|
||||
dringend=True)
|
||||
|
||||
|
||||
def _probe_lauf(lauf: dict, gast: dict) -> None:
|
||||
"""Rückweg testen: anlegen, absichtlich rot werten, zurückgehen, nachprüfen. Der Snapshot bzw. die Sicherung
|
||||
bleibt danach liegen (wie nach einem echten Rückweg) — wegräumen geht auf der Seite Snapshots."""
|
||||
vmid = gast["vmid"]
|
||||
try:
|
||||
rueckweg = _rueckweg_anlegen(lauf, gast)
|
||||
except RuntimeError as exc:
|
||||
_ende(lauf, "fehler", f"{lauf['name']}: Rückweg-Probe nicht begonnen — {str(exc).rstrip('.')}. "
|
||||
"Am Gerät wurde nichts geändert.")
|
||||
return
|
||||
if not rueckweg:
|
||||
_ende(lauf, "fehler", f"{lauf['name']}: Für dieses Gerät gibt es keinen Rückweg (weder Snapshot noch "
|
||||
"Sicherung) — die Probe ist nicht möglich.")
|
||||
return
|
||||
lauf["schritte"].append("Prüfung absichtlich auf Rot gesetzt (Probe, kein Update).")
|
||||
_merken(lauf)
|
||||
try:
|
||||
wie = _zurueck(lauf, vmid, rueckweg)
|
||||
time.sleep(WARTEN_NACH_UPDATE_S)
|
||||
fehler = pruefen(gast, _frischer_gast(lauf, vmid), "probe")
|
||||
except RuntimeError as exc:
|
||||
wie, fehler = "", [str(exc)]
|
||||
if fehler:
|
||||
_ende(lauf, "fehler", f"{lauf['name']}: Rückweg-Probe rot — {' '.join(fehler)}", dringend=True)
|
||||
return
|
||||
_ende(lauf, "probe-gruen", f"{lauf['name']}: Rückweg-Probe grün — {wie}, danach läuft das Gerät und antwortet.")
|
||||
|
||||
|
||||
def _aufraeumen(lauf: dict, vmid: int, rueckweg: tuple[str, str] | None) -> None:
|
||||
"""Ältere Snapshots des Orchestrators für diesen Gast löschen, nach einer Sicherung auch ältere Sicherungen;
|
||||
der Rückweg dieses Laufs (der neueste) bleibt. Ebenso bleiben Snapshots, die jemand auf der Seite Snapshots per
|
||||
Knopf angelegt hat (seit 25.09.2026): Die löscht nur der User."""
|
||||
from services.homelab import snapshots # snapshots importiert dieses Modul
|
||||
art, behalten = rueckweg or (None, None)
|
||||
gast = inventar.gast(lauf["ziel"]) or {}
|
||||
per_knopf = snapshots.knopf_namen(lauf["ziel"], gast)
|
||||
for name in gast.get("snapshots") or []:
|
||||
if name.startswith("mc2-") and name != behalten and name not in per_knopf:
|
||||
try:
|
||||
_auftrag(lauf, "snapshot_loeschen", {"vmid": vmid, "snapshot": name}, ZEITLIMIT_KURZ_S)
|
||||
except RuntimeError:
|
||||
lauf["schritte"].append(f"Alter Snapshot {name} blieb stehen.")
|
||||
if art != "sicherung":
|
||||
return
|
||||
for volid in gast.get("sicherungen") or []:
|
||||
if volid != behalten:
|
||||
try:
|
||||
_auftrag(lauf, "sicherung_loeschen", {"vmid": vmid, "sicherung": volid}, ZEITLIMIT_KURZ_S)
|
||||
except RuntimeError:
|
||||
lauf["schritte"].append(f"Alte Sicherung {volid} blieb stehen.")
|
||||
|
||||
|
||||
def _antwortet_wieder(url: str) -> bool:
|
||||
"""Nach dem Update starten Container neu: Arcane darf sich ARCANE_FRIST_S lang sammeln, bevor es rot heißt."""
|
||||
ende = time.monotonic() + ARCANE_FRIST_S
|
||||
while not inventar.erreichbar_frisch(url):
|
||||
if time.monotonic() >= ende:
|
||||
return False
|
||||
time.sleep(10)
|
||||
return True
|
||||
|
||||
|
||||
def _docker_lauf(lauf: dict, basis: str, gast: dict) -> None:
|
||||
"""Docker über Arcanes Updater — ein Probelauf, bis der Schalter in den Einstellungen auf „echt“ steht
|
||||
(User-Entscheid 24.09.2026). Echt wie bei den Containern: vorher ein Snapshot der VM, wenn der Ausführer sie
|
||||
anfassen darf (Etikett watcher); ist die Prüfung danach rot, geht es darauf zurück."""
|
||||
probe = not arcane.echt()
|
||||
rueckweg = None
|
||||
if not probe and gast.get("erlaubt"):
|
||||
try:
|
||||
rueckweg = _rueckweg_anlegen(lauf, gast)
|
||||
except RuntimeError as exc:
|
||||
_ende(lauf, "fehler", f"Arcane: Update nicht begonnen — {str(exc).rstrip('.')}. "
|
||||
"Am Gerät wurde nichts geändert.")
|
||||
return
|
||||
try:
|
||||
ergebnisse = arcane.updater(basis, probelauf=probe)
|
||||
except Exception as exc:
|
||||
if probe:
|
||||
_ende(lauf, "fehler", f"Arcane: Probelauf gescheitert — {exc}")
|
||||
return
|
||||
ergebnisse, fehler = {}, [f"Der Updater brach ab ({exc})."]
|
||||
else:
|
||||
fehler = []
|
||||
gesamt = {k: sum(int((e or {}).get(k) or 0) for e in ergebnisse.values()) for k in ("checked", "updated", "failed")}
|
||||
namen = [str(i.get("resourceName")) for e in ergebnisse.values() for i in (e or {}).get("items") or []
|
||||
if isinstance(i, dict) and i.get("updateAvailable")]
|
||||
if probe:
|
||||
_ende(lauf, "offen", f"Arcane-Probelauf: {len(namen)} von {gesamt['checked']} Containern würden aktualisiert"
|
||||
+ (f" ({', '.join(namen[:8])})" if namen else "") + ". Geändert wurde nichts.")
|
||||
return
|
||||
if gesamt["failed"]:
|
||||
fehler.append(f"{gesamt['failed']} Container ließen sich nicht aktualisieren, {gesamt['updated']} schon.")
|
||||
if not _antwortet_wieder(basis + "/"):
|
||||
fehler.append("Arcane antwortet danach nicht.")
|
||||
if not fehler:
|
||||
if rueckweg:
|
||||
_aufraeumen(lauf, gast["vmid"], rueckweg)
|
||||
# Anders als bei den Containern bleibt auch dieser nicht stehen: Die VM schreibt laufend (Docker, Rippy),
|
||||
# ein Snapshot wüchse im Thin-Pool bis zum nächsten Update mit. Sein Zweck war der Weg zurück bei Rot.
|
||||
try:
|
||||
_auftrag(lauf, "snapshot_loeschen", {"vmid": gast["vmid"], "snapshot": rueckweg[1]}, ZEITLIMIT_KURZ_S)
|
||||
except RuntimeError:
|
||||
lauf["schritte"].append(f"Snapshot {rueckweg[1]} blieb stehen.")
|
||||
_ende(lauf, "eingespielt", f"Arcane: {gesamt['updated']} Container aktualisiert, Prüfung grün.")
|
||||
return
|
||||
if rueckweg:
|
||||
try:
|
||||
wie = _zurueck(lauf, gast["vmid"], rueckweg)
|
||||
_ende(lauf, "zurueckgerollt", f"Arcane: Update gescheitert ({' '.join(fehler)}) — {wie}. "
|
||||
"Läuft wieder wie vorher.", dringend=True)
|
||||
return
|
||||
except RuntimeError as exc:
|
||||
fehler.append(f"Auch der Rückweg scheiterte: {exc}")
|
||||
_ende(lauf, "fehler", f"Arcane: Update gescheitert — {' '.join(fehler)}"
|
||||
+ ("" if rueckweg else " Es gab keinen Snapshot, also keinen automatischen Rückweg."),
|
||||
dringend=True)
|
||||
|
||||
|
||||
def _host_lauf(lauf: dict) -> None:
|
||||
try:
|
||||
if lauf["baustein"] == "kernel":
|
||||
# Welche Kernel gehen, bestimmt der Ausführer selbst (nie der laufende oder der nächste).
|
||||
a = _auftrag(lauf, "kernel_aufraeumen", {}, ZEITLIMIT_SICHERUNG_S)
|
||||
werte = dict(z.split("=", 1) for z in str(a.get("text") or "").splitlines()[:2] if "=" in z)
|
||||
anzahl, frei = int(werte.get("entfernt") or 0), int(werte.get("frei") or 0)
|
||||
_auftrag(lauf, "bericht", {}, 5 * 60)
|
||||
_ende(lauf, "aufgeraeumt", f"Proxmox-Host: {anzahl} alte Kernel entfernt, "
|
||||
f"{frei / 1e9:.1f} GB frei geworden.".replace(".", ",", 1)
|
||||
if anzahl else "Proxmox-Host: kein alter Kernel zu entfernen.")
|
||||
return
|
||||
if lauf["baustein"] == "neustart":
|
||||
_auftrag(lauf, "host_neustart", {}, 60)
|
||||
_ende(lauf, "neustart", "Der Proxmox-Host startet neu. Die KI-Box meldet, ob alles wiederkommt.")
|
||||
return
|
||||
sicherheit = lauf["baustein"] == "sicherheit"
|
||||
_auftrag(lauf, "host_update", {"pakete": lauf["sicherheit_pakete"]} if sicherheit else {}, 60 * 60)
|
||||
_auftrag(lauf, "bericht", {}, 5 * 60)
|
||||
_ende(lauf, "eingespielt", f"Proxmox-Host: {'Sicherheitsupdates' if sicherheit else 'Pakete'} eingespielt. "
|
||||
"Ein Neustart ist ein eigener Knopf.")
|
||||
except RuntimeError as exc:
|
||||
was = "Kernel aufräumen" if lauf["baustein"] == "kernel" else "Update"
|
||||
_ende(lauf, "fehler", f"Proxmox-Host: {was} gescheitert — {exc}", dringend=True)
|
||||
|
||||
|
||||
def _ende(lauf: dict, ergebnis: str, text: str, dringend: bool = False) -> None:
|
||||
# Schritt von „Alle aktualisieren“: Den Erfolg sagt die Sammelmeldung am Ende; Fehler gehen sofort raus.
|
||||
still = bool(lauf.get("sammellauf")) and ergebnis == "eingespielt" and not dringend
|
||||
lauf.update(status="fertig", ergebnis=ergebnis, text=text, ende=time.time(), dringend=dringend,
|
||||
gemeldet=not still)
|
||||
_merken(lauf)
|
||||
# Eine Rückweg-Probe ist kein Update: Sie steht im Protokoll, nicht im Update-Verlauf.
|
||||
if lauf.get("baustein") != "probe":
|
||||
try:
|
||||
# Die Schritte eines Sammellaufs stehen im Update-Verlauf als EIN Lauf (gleicher Beginn, eigener Anlass).
|
||||
start = datetime.fromtimestamp(lauf.get("sammellauf_start") or lauf["start"], LOCAL_TZ)
|
||||
anlass = lauf.get("anlass") or ("Alle aktualisieren" if lauf.get("sammellauf") else "Update von Hand")
|
||||
update_verlauf.eintragen(start.isoformat(timespec="seconds"), anlass, lauf["ziel"], ergebnis, text)
|
||||
except Exception:
|
||||
log.warning("homelab: Verlauf nicht geschrieben", exc_info=True)
|
||||
if not still:
|
||||
_melden(text, dringend=dringend)
|
||||
|
||||
|
||||
def _neuer_lauf(ziel_id: str, baustein: str, name: str, sammellauf: dict | None = None, **mehr: object) -> dict:
|
||||
lauf = {"id": uuid.uuid4().hex[:12], "ziel": ziel_id, "baustein": baustein, "name": name, "status": "laeuft",
|
||||
"start": time.time(), "ende": None, "ergebnis": None, "text": None, "schritte": [], **mehr}
|
||||
if sammellauf:
|
||||
lauf.update(sammellauf=sammellauf["id"], sammellauf_start=sammellauf["start"])
|
||||
if sammellauf.get("anlass"): # „Wartungsfenster“ (seit 25.09.2026)
|
||||
lauf["anlass"] = sammellauf["anlass"]
|
||||
_merken(lauf)
|
||||
return lauf
|
||||
|
||||
|
||||
def starten(ziel_id: str, baustein: str, sammellauf: dict | None = None) -> dict:
|
||||
"""Knopf „Jetzt updaten“. Rückgabe {"ok": True, "lauf": id} oder {"ok": False, "detail": …}.
|
||||
|
||||
sammellauf ({"id", "start"}) setzt nur „Alle aktualisieren“ (sammellauf.py) für seine Schritte; ohne ihn lehnt
|
||||
der Knopf ab, solange ein Sammellauf läuft."""
|
||||
if baustein not in ("app", "os", "pakete", "neustart", "docker", "kernel", "probe", "sicherheit"):
|
||||
return {"ok": False, "detail": "Unbekannter Baustein."}
|
||||
with START_SPERRE:
|
||||
if sammellauf is None and _sammellauf_laeuft():
|
||||
return {"ok": False, "detail": SAMMELLAUF_SPERRE}
|
||||
if laeuft(ziel_id):
|
||||
return {"ok": False, "detail": "Für dieses Gerät läuft schon ein Update."}
|
||||
if ziel_id == "pve" and baustein == "probe":
|
||||
return {"ok": False, "detail": "Für den Proxmox-Host gibt es keinen Rückweg zum Testen."}
|
||||
if ziel_id == "pve":
|
||||
name, gast = "Proxmox-Host", None
|
||||
host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}
|
||||
mehr = ({"pakete": len(host.get("updates") or [])} if baustein == "pakete"
|
||||
else {"kernel": len(host.get("kernel_alt") or [])} if baustein == "kernel" else {})
|
||||
if baustein == "sicherheit":
|
||||
namen = [str(n) for n in host.get("updates_sicherheit") or []]
|
||||
if not namen:
|
||||
return {"ok": False, "detail": "Auf dem Proxmox-Host steht kein Sicherheitsupdate an."}
|
||||
mehr = {"pakete": len(namen), "sicherheit_pakete": namen}
|
||||
else:
|
||||
gast = inventar.gast(ziel_id)
|
||||
if not gast:
|
||||
return {"ok": False, "detail": "Dieses Gerät steht nicht im Bericht des Ausführers."}
|
||||
if baustein == "docker":
|
||||
app = apps.app_fuer(None, gast.get("name"))
|
||||
basis = (apps.adresse(app, gast.get("ip")) or "").rstrip("/")
|
||||
if not (app and app.kennung == "arcane" and basis and arcane.schluessel()):
|
||||
return {"ok": False, "detail": "Docker-Updates gehen nur über Arcane mit API-Schlüssel."}
|
||||
lauf = _neuer_lauf(ziel_id, baustein, app.name, sammellauf)
|
||||
threading.Thread(target=_docker_lauf, args=(lauf, basis, gast), name=f"homelab-{ziel_id}",
|
||||
daemon=True).start()
|
||||
return {"ok": True, "lauf": lauf["id"]}
|
||||
if not gast.get("erlaubt"):
|
||||
return {"ok": False, "detail": "Dieses Gerät ist nicht freigegeben (Etikett watcher fehlt)."}
|
||||
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
|
||||
name = app.name if app else str(gast.get("name"))
|
||||
if baustein == "probe":
|
||||
if not (gast.get("snapshot_moeglich") or gast.get("sicherung_moeglich")):
|
||||
return {"ok": False, "detail": f"{name} hat keinen Rückweg (weder Snapshot noch Sicherung)."}
|
||||
lauf = _neuer_lauf(ziel_id, baustein, name, None)
|
||||
threading.Thread(target=_probe_lauf, args=(lauf, gast), name=f"homelab-{ziel_id}",
|
||||
daemon=True).start()
|
||||
return {"ok": True, "lauf": lauf["id"]}
|
||||
if baustein == "app" and (not app or app.weg != "skript"):
|
||||
return {"ok": False, "detail": f"{name} aktualisiert sich nicht über das Community-Script "
|
||||
"(eigene Oberfläche oder Pakete)."}
|
||||
if baustein == "app" and (sperre := karenz.pruefen(app.kennung)["gesperrt"]):
|
||||
return {"ok": False, "detail": sperre}
|
||||
# Was vorher war, fürs Protokoll: die App-Version bzw. die Zahl der Pakete.
|
||||
mehr = ({"version_alt": (gast.get("app") or {}).get("version")} if baustein == "app"
|
||||
else {"pakete": (gast.get("os_updates") or {}).get("anzahl")})
|
||||
if baustein == "sicherheit":
|
||||
os_stand = gast.get("os_updates") or {}
|
||||
namen = os_stand.get("sicherheit_pakete") if "sicherheit_pakete" in os_stand \
|
||||
else sicherheit.alpine_pakete(os_stand)
|
||||
if not namen:
|
||||
return {"ok": False, "detail": f"Bei {name} steht kein Sicherheitsupdate an."}
|
||||
mehr = {"pakete": len(namen), "sicherheit_pakete": [str(n) for n in namen]}
|
||||
lauf = _neuer_lauf(ziel_id, baustein, name, sammellauf, **mehr)
|
||||
ziel = _host_lauf if gast is None else (lambda lf: _gast_lauf(lf, gast))
|
||||
threading.Thread(target=ziel, args=(lauf,), name=f"homelab-{ziel_id}", daemon=True).start()
|
||||
return {"ok": True, "lauf": lauf["id"]}
|
||||
@@ -0,0 +1,281 @@
|
||||
"""Wartungsfenster und Update-Regel je Gerät (Ausbauplan Welle 3, Punkt 17, seit 25.09.2026).
|
||||
|
||||
User-Entscheid 25.09.2026: Fenster sonntags 03:00–04:15; zu Beginn gilt für jedes Gerät „Sicherheitsupdates
|
||||
automatisch“, alles andere wird nur gemeldet. Je Gerät einstellbar (Einstellungen → Homelab):
|
||||
melden nichts von selbst — das Update steht an und wartet auf den Knopf
|
||||
sicherheit im Fenster nur die Pakete, die eine Sicherheitslücke schließen (Debian-Suite „-security“, Alpine über
|
||||
die Sicherheitsdatenbank, beim Proxmox-Host ebenso — neu gestartet wird er nie von selbst)
|
||||
alles im Fenster alles, was einen Knopf hat: App, Pakete, Docker-Images (nie der Neustart des Hosts)
|
||||
Dazu je App „Version überspringen“: Diese Version gilt als aktuell, bis eine neuere kommt.
|
||||
|
||||
Der Steward (Rolle homelab) ruft jede Minute takt(): Ist das Fenster offen und heute noch nicht gelaufen, arbeitet er
|
||||
den Plan Gerät für Gerät ab — mit den Update-Läufen aus updates.py (Rückweg vorher, Prüfung danach, bei Rot zurück).
|
||||
Nach dem Ende des Fensters beginnt kein Schritt mehr. Einzelne Erfolge bleiben still (wie bei „Alle aktualisieren“),
|
||||
Fehler und Rückwege gehen sofort raus; am Ende kommt eine Zusammenfassung. Solange er läuft, zeigt das Cockpit
|
||||
„Wartung“ statt „Störung“.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from kern.einstellungen import einstellungen
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
from services import announce
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
REGELN = ("melden", "sicherheit", "alles")
|
||||
REGEL_TEXT = {"melden": "nur melden", "sicherheit": "Sicherheitsupdates automatisch", "alles": "alles automatisch"}
|
||||
TAGE = ("Mo", "Di", "Mi", "Do", "Fr", "Sa", "So")
|
||||
STANDARD = {"fenster": {"tag": 6, "von": "03:00", "bis": "04:15"}, "standard": "sicherheit", "regeln": {},
|
||||
"uebersprungen": {}}
|
||||
IM_FENSTER = {"app", "os", "pakete", "docker"} # nie: neustart, kernel, probe
|
||||
BETREFF = "[Homelab-Wartung]"
|
||||
TAKT_S = 60
|
||||
LAUF_ZEITLIMIT_S = 70 * 60
|
||||
_UHR = re.compile(r"([01]\d|2[0-3]):[0-5]\d")
|
||||
_ZIEL = re.compile(r"pve|(?:ct|vm)-\d+")
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _pfad(name: str) -> Path:
|
||||
return einstellungen().daten_dir / name
|
||||
|
||||
|
||||
def _json_lesen(name: str, leer: dict) -> dict:
|
||||
try:
|
||||
daten = json.loads(_pfad(name).read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return json.loads(json.dumps(leer))
|
||||
return daten if isinstance(daten, dict) else json.loads(json.dumps(leer))
|
||||
|
||||
|
||||
def _json_schreiben(name: str, daten: dict) -> None:
|
||||
pfad = _pfad(name)
|
||||
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = pfad.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
tmp.replace(pfad)
|
||||
|
||||
|
||||
def lesen() -> dict:
|
||||
daten = _json_lesen("homelab-wartung.json", STANDARD)
|
||||
return {**json.loads(json.dumps(STANDARD)), **daten}
|
||||
|
||||
|
||||
def stand_lesen() -> dict:
|
||||
return _json_lesen("homelab-wartung-stand.json", {})
|
||||
|
||||
|
||||
# --- Regeln, Fenster, Überspringen --------------------------------------------------------------------------------
|
||||
|
||||
def regel(zid: str) -> str:
|
||||
e = lesen()
|
||||
return e["regeln"].get(zid) or e["standard"]
|
||||
|
||||
|
||||
def uebersprungen(zid: str, baustein: str) -> str | None:
|
||||
return lesen()["uebersprungen"].get(f"{zid}:{baustein}")
|
||||
|
||||
|
||||
def ueberspringen(zid: str, baustein: str, version: str | None) -> dict:
|
||||
"""POST/DELETE /api/homelab/ziele/{zid}/ueberspringen: eine Version überspringen oder wieder anbieten."""
|
||||
if not _ZIEL.fullmatch(zid) or baustein != "app":
|
||||
return {"ok": False, "detail": "Überspringen geht nur für die App eines Geräts."}
|
||||
with _lock:
|
||||
e = lesen()
|
||||
schluessel = f"{zid}:{baustein}"
|
||||
if version:
|
||||
e["uebersprungen"][schluessel] = str(version)[:80]
|
||||
else:
|
||||
e["uebersprungen"].pop(schluessel, None)
|
||||
_json_schreiben("homelab-wartung.json", e)
|
||||
return {"ok": True, "text": (f"Version {version} wird übersprungen, bis eine neuere kommt." if version
|
||||
else "Die Version wird wieder angeboten.")}
|
||||
|
||||
|
||||
def _minuten(uhr: str) -> int:
|
||||
h, m = uhr.split(":")
|
||||
return int(h) * 60 + int(m)
|
||||
|
||||
|
||||
def im_fenster(jetzt: float | None = None, fenster: dict | None = None) -> bool:
|
||||
f = fenster or lesen()["fenster"]
|
||||
t = datetime.fromtimestamp(time.time() if jetzt is None else jetzt, LOCAL_TZ)
|
||||
minute = t.hour * 60 + t.minute
|
||||
return t.weekday() == int(f["tag"]) and _minuten(f["von"]) <= minute < _minuten(f["bis"])
|
||||
|
||||
|
||||
def naechstes(jetzt: float | None = None, fenster: dict | None = None) -> float:
|
||||
"""Beginn des nächsten Fensters (Unix-Sekunden); läuft eins gerade, dessen Beginn."""
|
||||
f = fenster or lesen()["fenster"]
|
||||
t = datetime.fromtimestamp(time.time() if jetzt is None else jetzt, LOCAL_TZ)
|
||||
von = _minuten(f["von"])
|
||||
for tage in range(8):
|
||||
tag = (t + timedelta(days=tage)).replace(hour=von // 60, minute=von % 60, second=0, microsecond=0)
|
||||
if tag.weekday() == int(f["tag"]) and (tag + timedelta(minutes=_minuten(f["bis"]) - von)) > t:
|
||||
return tag.timestamp()
|
||||
return t.timestamp()
|
||||
|
||||
|
||||
def fenster_text(fenster: dict | None = None) -> str:
|
||||
f = fenster or lesen()["fenster"]
|
||||
return f"{TAGE[int(f['tag'])]} {f['von']}–{f['bis']}"
|
||||
|
||||
|
||||
def setzen(neu: dict) -> dict:
|
||||
"""POST /api/homelab/wartung: Fenster, Standardregel und Regeln je Gerät (nur was mitkommt, wird geändert)."""
|
||||
with _lock:
|
||||
e = lesen()
|
||||
if "fenster" in neu:
|
||||
f = neu["fenster"] if isinstance(neu["fenster"], dict) else {}
|
||||
tag, von, bis = f.get("tag"), str(f.get("von") or ""), str(f.get("bis") or "")
|
||||
if not (isinstance(tag, int) and 0 <= tag <= 6 and _UHR.fullmatch(von) and _UHR.fullmatch(bis)
|
||||
and _minuten(von) < _minuten(bis)):
|
||||
return {"ok": False, "detail": "Das Fenster braucht einen Tag und eine Uhrzeit von–bis am selben Tag."}
|
||||
e["fenster"] = {"tag": tag, "von": von, "bis": bis}
|
||||
if "standard" in neu:
|
||||
if neu["standard"] not in REGELN:
|
||||
return {"ok": False, "detail": "Unbekannte Regel."}
|
||||
e["standard"] = neu["standard"]
|
||||
if "regeln" in neu:
|
||||
regeln = neu["regeln"] if isinstance(neu["regeln"], dict) else {}
|
||||
if not all(_ZIEL.fullmatch(str(k)) and (v in REGELN or v is None) for k, v in regeln.items()):
|
||||
return {"ok": False, "detail": "Unbekanntes Gerät oder unbekannte Regel."}
|
||||
for k, v in regeln.items():
|
||||
if v is None:
|
||||
e["regeln"].pop(k, None)
|
||||
else:
|
||||
e["regeln"][k] = v
|
||||
_json_schreiben("homelab-wartung.json", e)
|
||||
return {"ok": True, "text": f"Gespeichert. Wartungsfenster {fenster_text(e['fenster'])}."}
|
||||
|
||||
|
||||
# --- Plan und Lauf -------------------------------------------------------------------------------------------------
|
||||
|
||||
def plan(ziele: list[dict]) -> list[dict]:
|
||||
"""Was im Fenster laufen soll: je Gerät nach seiner Regel, in der Reihenfolge der Geräteliste (Host zuerst)."""
|
||||
schritte = []
|
||||
for z in ziele:
|
||||
r = regel(z["id"])
|
||||
if r == "melden":
|
||||
continue
|
||||
for b in z.get("bausteine") or []:
|
||||
if b.get("zustand") != "neu" or not b.get("aktion") or b.get("id") not in IM_FENSTER:
|
||||
continue
|
||||
if r == "alles":
|
||||
schritte.append({"ziel": z["id"], "name": z["name"], "baustein": b["id"]})
|
||||
elif b["id"] in ("os", "pakete") and b.get("sicherheit"):
|
||||
schritte.append({"ziel": z["id"], "name": z["name"], "baustein": "sicherheit",
|
||||
"anzahl": b["sicherheit"]})
|
||||
return schritte
|
||||
|
||||
|
||||
def _warten_auf(lauf_id: str, updates) -> dict | None:
|
||||
ende = time.monotonic() + LAUF_ZEITLIMIT_S
|
||||
while time.monotonic() < ende:
|
||||
lauf = updates.lauf_lesen(lauf_id)
|
||||
if lauf and lauf.get("status") != "laeuft":
|
||||
return lauf
|
||||
time.sleep(5)
|
||||
return None
|
||||
|
||||
|
||||
def _abarbeiten(stand: dict) -> None:
|
||||
from services.homelab import updates
|
||||
kennung = {"id": f"wartung-{stand['datum']}", "start": stand["start"], "anlass": "Wartungsfenster"}
|
||||
for schritt in stand["schritte"]:
|
||||
if not im_fenster():
|
||||
schritt.update(ergebnis="nicht-mehr", text="Das Fenster war zu Ende.")
|
||||
continue
|
||||
antwort = updates.starten(schritt["ziel"], schritt["baustein"], sammellauf=kennung)
|
||||
if not antwort.get("ok"):
|
||||
schritt.update(ergebnis="fehler", text=antwort.get("detail"))
|
||||
else:
|
||||
lauf = _warten_auf(antwort["lauf"], updates) or {}
|
||||
schritt.update(lauf=antwort["lauf"], ergebnis=lauf.get("ergebnis") or "offen", text=lauf.get("text"))
|
||||
_json_schreiben("homelab-wartung-stand.json", stand)
|
||||
stand.update(status="fertig", ende=time.time())
|
||||
_json_schreiben("homelab-wartung-stand.json", stand)
|
||||
_melden(zusammenfassung(stand))
|
||||
|
||||
|
||||
def zusammenfassung(stand: dict) -> str:
|
||||
"""Die eine Meldung am Ende des Fensters."""
|
||||
def namen(ergebnis: str) -> str:
|
||||
return ", ".join(f"{s['name']} ({'Sicherheitsupdates' if s['baustein'] == 'sicherheit' else s['baustein']})"
|
||||
for s in stand["schritte"] if s.get("ergebnis") == ergebnis)
|
||||
teile = []
|
||||
if eingespielt := namen("eingespielt"):
|
||||
teile.append(f"Eingespielt: {eingespielt}.")
|
||||
if zurueck := namen("zurueckgerollt"):
|
||||
teile.append(f"Zurückgerollt (läuft wie vorher): {zurueck}.")
|
||||
if gescheitert := namen("fehler"):
|
||||
teile.append(f"Gescheitert: {gescheitert}.")
|
||||
if offen := namen("nicht-mehr"):
|
||||
teile.append(f"Nicht mehr im Fenster: {offen} — nächste Woche.")
|
||||
kopf = f"Wartung {fenster_text()}: "
|
||||
return kopf + (" ".join(teile) if teile else "nichts zu tun.")
|
||||
|
||||
|
||||
def _melden(text: str) -> None:
|
||||
try:
|
||||
announce.notify_telegram(BETREFF, text)
|
||||
except Exception:
|
||||
log.warning("wartung: Meldung ging nicht raus", exc_info=True)
|
||||
|
||||
|
||||
def takt(jetzt: float | None = None) -> bool:
|
||||
"""Jede Minute (Steward): im offenen Fenster einmal je Tag den Plan starten. Rückgabe: ob ein Lauf begann."""
|
||||
from services.homelab import inventar, sammellauf, updates
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
if not im_fenster(jetzt):
|
||||
return False
|
||||
heute = datetime.fromtimestamp(jetzt, LOCAL_TZ).date().isoformat()
|
||||
with _lock:
|
||||
if stand_lesen().get("datum") == heute:
|
||||
return False
|
||||
# Läuft gerade ein Update von Hand oder „Alle aktualisieren“, wartet das Fenster eine Minute.
|
||||
if sammellauf.laeuft() or updates.laufende_ziele():
|
||||
return False
|
||||
schritte = plan(inventar.ziele()["ziele"])
|
||||
stand = {"datum": heute, "status": "laeuft" if schritte else "fertig", "start": jetzt,
|
||||
"ende": None if schritte else jetzt, "schritte": schritte}
|
||||
_json_schreiben("homelab-wartung-stand.json", stand)
|
||||
if not schritte:
|
||||
log.info("wartung: Fenster offen, nichts zu tun")
|
||||
return False
|
||||
log.info("wartung: Fenster offen, %d Schritte", len(schritte))
|
||||
threading.Thread(target=_abarbeiten, args=(stand,), name="homelab-wartung", daemon=True).start()
|
||||
return True
|
||||
|
||||
|
||||
def laeuft() -> bool:
|
||||
s = stand_lesen()
|
||||
return s.get("status") == "laeuft" and time.time() - float(s.get("start") or 0) < 6 * 3600
|
||||
|
||||
|
||||
async def wartung_loop() -> None:
|
||||
"""Im Steward der Rolle homelab: jede Minute takt(). Ein Fehler kostet eine Minute, nie die Schleife."""
|
||||
log.info("wartung: aktiv (Fenster %s)", fenster_text())
|
||||
while True:
|
||||
try:
|
||||
await asyncio.to_thread(takt)
|
||||
except Exception:
|
||||
log.warning("wartung: Takt gescheitert", exc_info=True)
|
||||
await asyncio.sleep(TAKT_S)
|
||||
|
||||
|
||||
def lage(jetzt: float | None = None) -> dict:
|
||||
"""Für GET /api/homelab/wartung."""
|
||||
e = lesen()
|
||||
return {"fenster": e["fenster"], "fenster_text": fenster_text(e["fenster"]), "standard": e["standard"],
|
||||
"regeln": e["regeln"], "uebersprungen": e["uebersprungen"], "naechstes": naechstes(jetzt, e["fenster"]),
|
||||
"laeuft": laeuft(), "stand": stand_lesen() or None}
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Zertifikats-Wache (Ausbauplan Welle 1, seit 25.09.2026).
|
||||
|
||||
Der Ausführer liest höchstens alle 30 Minuten die https-Zertifikate (host.zertifikate im Bericht): die von NPMplus —
|
||||
Let's Encrypt im kurzlebigen Profil, am 25.09.2026 gut 6,7 Tage Laufzeit — samt dem, was NPMplus für den Namen gerade
|
||||
ausliefert, das des Proxmox-Hosts (vom Cluster ausgestellt, zwei Jahre) und das des PBS (selbst signiert). Eine feste
|
||||
30-Tage-Grenze passt zu so verschiedenen Laufzeiten nicht; die Grenzen richten sich nach der Laufzeit:
|
||||
• gelb „wird nicht erneuert“: Bei kurzlebigen Zertifikaten (bis 100 Tage) ist weniger als ein Drittel der Laufzeit
|
||||
übrig — NPMplus erneuert lange vorher. Bei langlebigen ab 30 Tagen vor Ablauf.
|
||||
• rot: weniger als ein Sechstel übrig, dabei höchstens 7 und mindestens 1 Tag vor Ablauf; abgelaufen sowieso.
|
||||
• gelb: NPMplus liefert ein älteres Zertifikat aus, als es hat (nach der Erneuerung nicht neu geladen).
|
||||
Hinweise gehen über den Wächter (pruefe_zertifikate), je Quelle einer; die Liste zeigt das Homelab-Cockpit.
|
||||
"""
|
||||
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
from services.homelab import kanal
|
||||
|
||||
TAG_S = 86400
|
||||
KURZLEBIG_S = 100 * TAG_S
|
||||
LANG_GELB_S = 30 * TAG_S
|
||||
ROT_MAX_S, ROT_MIN_S = 7 * TAG_S, TAG_S
|
||||
AUSGELIEFERT_TOLERANZ_S = 60
|
||||
RANG = {"ok": 0, "gelb": 1, "rot": 2}
|
||||
QUELLE = {"npm": "NPMplus", "pve": "Proxmox-Host", "pbs": "Proxmox Backup Server"}
|
||||
WESSEN = {"pve": "des Proxmox-Hosts", "pbs": "des PBS"}
|
||||
|
||||
|
||||
def _datum(ts: float) -> str:
|
||||
return datetime.fromtimestamp(ts, LOCAL_TZ).strftime("%d.%m. %H:%M")
|
||||
|
||||
|
||||
def _zeitraum(sekunden: float) -> tuple[int, str, str]:
|
||||
"""(Anzahl, Einheit im Akkusativ, Einheit im Dativ) — 20 Stunden, 5 Tage, 3 Monate, 2 Jahre."""
|
||||
if sekunden < TAG_S:
|
||||
return max(1, round(sekunden / 3600)), "Stunden", "Stunden"
|
||||
tage = round(sekunden / TAG_S)
|
||||
if tage < 60:
|
||||
return tage, "Tage", "Tagen"
|
||||
return (round(tage / 30), "Monate", "Monaten") if tage < 730 else (round(tage / 365), "Jahre", "Jahren")
|
||||
|
||||
|
||||
def bis_ablauf(sekunden: float) -> str:
|
||||
"""„in 20 Stunden“, „in einem Tag“, „in 5 Tagen“, „in 3 Monaten“."""
|
||||
n, _, dativ = _zeitraum(sekunden)
|
||||
if n == 1:
|
||||
return {"Stunden": "in einer Stunde", "Tagen": "in einem Tag", "Monaten": "in einem Monat",
|
||||
"Jahren": "in einem Jahr"}[dativ]
|
||||
return f"in {n} {dativ}"
|
||||
|
||||
|
||||
def dauer(sekunden: float) -> str:
|
||||
"""„20 Stunden“, „einen Tag“, „5 Tage“ — für „noch … gültig“."""
|
||||
n, akkusativ, _ = _zeitraum(sekunden)
|
||||
if n == 1:
|
||||
return {"Stunden": "eine Stunde", "Tage": "einen Tag", "Monate": "einen Monat", "Jahre": "ein Jahr"}[akkusativ]
|
||||
return f"{n} {akkusativ}"
|
||||
|
||||
|
||||
def bewerten(z: dict, jetzt: float) -> dict:
|
||||
"""Ein Zertifikat aus dem Bericht mit Stufe, Art des Problems (abgelaufen, ablauf, haengt, alt) und einem Satz."""
|
||||
von, bis = z.get("von"), z["bis"]
|
||||
laufzeit = bis - von if von else None
|
||||
rest = bis - jetzt
|
||||
kurzlebig = laufzeit is not None and laufzeit <= KURZLEBIG_S
|
||||
rot_ab = max(ROT_MIN_S, min(ROT_MAX_S, laufzeit / 6)) if laufzeit else ROT_MAX_S
|
||||
ausgeliefert = z.get("ausgeliefert_bis")
|
||||
alt = bool(ausgeliefert and ausgeliefert < bis - AUSGELIEFERT_TOLERANZ_S)
|
||||
stufe, art, satz = "ok", None, None
|
||||
if rest <= 0:
|
||||
stufe, art, satz = "rot", "abgelaufen", f"abgelaufen am {_datum(bis)}"
|
||||
elif rest < rot_ab or (not kurzlebig and rest < LANG_GELB_S):
|
||||
stufe, art, satz = "rot" if rest < rot_ab else "gelb", "ablauf", f"läuft {bis_ablauf(rest)} ab ({_datum(bis)})"
|
||||
elif kurzlebig and rest < laufzeit / 3:
|
||||
stufe, art, satz = "gelb", "haengt", f"wird nicht erneuert, noch {dauer(rest)} gültig (bis {_datum(bis)})"
|
||||
elif alt:
|
||||
stufe, art, satz = "gelb", "alt", f"ausgeliefert wird noch das alte (bis {_datum(ausgeliefert)})"
|
||||
namen = [n for n in z.get("namen") or [] if n]
|
||||
# Anzeigename: bei Proxmox-Host und PBS der Rechnername (ihr erster Name ist „localhost“), sonst der erste Name.
|
||||
eigener = z.get("inhaber") if z.get("quelle") in WESSEN else None
|
||||
name = eigener or next((n for n in namen if n != "localhost"), None) or z.get("inhaber") or z.get("name")
|
||||
return {"quelle": z.get("quelle"), "wo": QUELLE.get(z.get("quelle"), z.get("quelle")), "gast": z.get("gast"),
|
||||
"name": name, "namen": namen, "datei": z.get("name"),
|
||||
"aussteller": z.get("aussteller"), "von": von, "bis": bis, "rest_s": int(rest),
|
||||
"laufzeit_s": int(laufzeit) if laufzeit else None, "stufe": stufe, "art": art, "problem": satz}
|
||||
|
||||
|
||||
def lage(jetzt: float | None = None) -> dict:
|
||||
"""Für GET /api/homelab/zertifikate: die Zertifikate, Probleme zuerst, dann das am frühesten ablaufende.
|
||||
„gelesen“: ob der Ausführer schon Zertifikate meldet (einer von vor dem 25.09.2026 tut es nicht)."""
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}
|
||||
roh = host.get("zertifikate")
|
||||
liste = [bewerten(z, jetzt) for z in roh or [] if isinstance(z, dict) and isinstance(z.get("bis"), int)]
|
||||
liste.sort(key=lambda z: (-RANG[z["stufe"]], z["bis"]))
|
||||
return {"gelesen": isinstance(roh, list), "zertifikate": liste}
|
||||
|
||||
|
||||
RAT = {
|
||||
"npm": ("In NPMplus: SSL Certificates → beim Zertifikat „…“ → Renew. Klappt das nicht, steht der Grund im Protokoll "
|
||||
"von NPMplus (Container {gast}) — oft ist die Domain von außen nicht erreichbar."),
|
||||
"pve": "In Proxmox: pve → System → Zertifikate. Das vom Cluster ausgestellte erneuert `pvecm updatecerts --force`.",
|
||||
"pbs": "Im PBS: Konfiguration → Zertifikate — dort ein neues ausstellen oder hochladen.",
|
||||
}
|
||||
|
||||
|
||||
def _titel(quelle: str, faelle: list[dict], stufe: str) -> str:
|
||||
if len(faelle) > 1:
|
||||
return (f"{len(faelle)} Zertifikate von NPMplus laufen bald ab" if stufe == "rot"
|
||||
else f"NPMplus erneuert {len(faelle)} Zertifikate nicht")
|
||||
z = faelle[0]
|
||||
wessen = f"für {z['name']}" if quelle == "npm" else WESSEN.get(quelle, z["name"])
|
||||
if z["art"] == "alt":
|
||||
return f"NPMplus liefert für {z['name']} noch das alte Zertifikat aus"
|
||||
if z["art"] == "abgelaufen":
|
||||
return f"Zertifikat {wessen} ist abgelaufen"
|
||||
if z["art"] == "haengt":
|
||||
return f"Zertifikat {wessen} wird nicht erneuert"
|
||||
return f"Zertifikat {wessen} läuft {bis_ablauf(z['rest_s'])} ab"
|
||||
|
||||
|
||||
def pruefe_zertifikate(jetzt: float | None = None) -> list:
|
||||
"""Befunde für den Wächter: je Quelle (NPMplus, Proxmox-Host, PBS) einer mit der ernstesten Stufe."""
|
||||
from services.waechter import Befund # der Wächter registriert diese Prüfung, also erst hier
|
||||
befunde = []
|
||||
liste = lage(jetzt)["zertifikate"]
|
||||
for quelle in QUELLE:
|
||||
faelle = [z for z in liste if z["quelle"] == quelle and z["stufe"] != "ok"]
|
||||
if not faelle:
|
||||
continue
|
||||
stufe = max((z["stufe"] for z in faelle), key=RANG.get)
|
||||
nur_alt = all(z["art"] == "alt" for z in faelle)
|
||||
rat = ("NPMplus einmal neu starten, dann liefert es das neue aus." if nur_alt
|
||||
else "Läuft es ab, warnen Browser, und Apps verbinden sich nicht mehr. "
|
||||
+ RAT[quelle].format(gast=faelle[0]["gast"]))
|
||||
einzeln = "; ".join(f"{z['name']} {z['problem']}" for z in faelle[:6])
|
||||
befunde.append(Befund(id=f"zertifikat:{quelle}", stufe=stufe, titel=_titel(quelle, faelle, stufe),
|
||||
text=f"{einzeln}. {rat}",
|
||||
quelle=f"ct-{faelle[0]['gast']}" if faelle[0]["gast"] else "pve"))
|
||||
return befunde
|
||||
File diff suppressed because it is too large
Load Diff
+505
-109
@@ -1,118 +1,388 @@
|
||||
"""
|
||||
Mini-Job-System: Hintergrund-Prozesse mit Live-Log + Download-Fortschritt.
|
||||
Portiert aus Mission Control v1 (jobengine.py). In-Memory, ein Daemon-Thread je Job.
|
||||
Auftrags-System: Hintergrund-Aufträge (Updates, Modell-Downloads) mit Protokoll und Fortschritt.
|
||||
Ursprünglich aus Mission Control v1 portiert.
|
||||
|
||||
Seit Phase 2 (24.09.2026) überleben Aufträge einen Neustart von MC2:
|
||||
- Jeder Auftrag läuft als eigene systemd-Einheit „mc2-job-<id>“ (systemd-run), nicht mehr als
|
||||
Kindprozess von MC2. Ein Deploy oder Absturz von MC2 würgt ihn nicht mehr ab.
|
||||
- Akte (<id>.json), Protokoll (<id>.log) und Exit-Code (<id>.exit) liegen unter JOBS_DIR.
|
||||
MC2 liest die Akten beim Start wieder ein (wiederaufnehmen()) und beobachtet weiter.
|
||||
- Nacharbeiten („Rolle setzen, wenn der Download fertig ist“) sind benannt (@nacharbeit) und
|
||||
stehen mit ihren Daten in der Akte — sie laufen auch, wenn MC2 zwischendurch neu gestartet hat.
|
||||
- Geheimnisse (z. B. HF_TOKEN) gehen über eine Umgebungsdatei (nur für den Nutzer lesbar), die
|
||||
der Auftrag beim Start liest und löscht — nicht über die Befehlszeile oder die Einheit.
|
||||
- Ohne systemd (Entwicklungsrechner, Tests: MC_JOBS_ART=prozess) läuft der Auftrag als
|
||||
Kindprozess; dann überlebt er einen Neustart nicht.
|
||||
|
||||
Weiterhin: „Abbrechen“ beendet den ganzen Auftrag samt Kindern, jede Auftragsart hat ein
|
||||
Zeitlimit, start_job_exklusiv prüft und trägt unter EINER Sperre ein, beendete Aufträge
|
||||
verschwinden nach einem Tag bzw. ab 40 Stück.
|
||||
|
||||
Nur MC2 selbst beobachtet Aufträge (wiederaufnehmen() im Lebenszyklus der App); andere Prozesse
|
||||
starten keine.
|
||||
"""
|
||||
|
||||
import glob
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
from kern.einstellungen import einstellungen
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
JOBS: dict[str, dict] = {}
|
||||
_PROCS: dict[str, subprocess.Popen] = {}
|
||||
_LOCK = threading.Lock()
|
||||
_LOG_CAP = 400
|
||||
_LOG_LESEN_MAX = 256_000 # so viel vom Protokollende wird für die Anzeige gelesen
|
||||
BEHALTEN_MAX = 40 # so viele beendete Aufträge bleiben sichtbar
|
||||
BEHALTEN_S = 24 * 3600 # beendete Aufträge verschwinden nach einem Tag
|
||||
STANDARD_ZEITLIMIT_S = 3 * 3600
|
||||
_ENDE = ("done", "failed", "canceled")
|
||||
_TAKT_S = 1.0 # wie oft ein Beobachter nach dem Exit-Code schaut
|
||||
_EINHEIT_PRUEFEN_S = 5.0 # wie oft er zusätzlich fragt, ob die Einheit noch lebt
|
||||
|
||||
# Nur im Speicher (nicht in der Akte): Fortschritt eines Downloads.
|
||||
_LAUFZEIT = {"progress", "done_bytes", "rate_bps", "eta_s"}
|
||||
# Nicht an die Oberfläche: Verwaltung des Auftrags.
|
||||
_INTERN = {"art", "nacharbeit", "nacharbeit_daten", "nacharbeit_erledigt", "abschluss", "abschluss_daten",
|
||||
"abschluss_erledigt", "fortschritt", "zeitlimit_s"}
|
||||
# Nicht in die Umgebung des Auftrags: gehört zur Einheit von MC2 oder ist in bash schreibgeschützt.
|
||||
_UMGEBUNG_OHNE = {"INVOCATION_ID", "JOURNAL_STREAM", "SYSTEMD_EXEC_PID", "MANAGERPID", "NOTIFY_SOCKET",
|
||||
"LISTEN_PID", "LISTEN_FDS", "LISTEN_FDNAMES", "WATCHDOG_PID", "WATCHDOG_USEC", "MAINPID",
|
||||
"_", "SHLVL", "PWD", "OLDPWD", "SHELLOPTS", "BASHOPTS", "BASH_VERSINFO", "EUID", "UID",
|
||||
"PPID", "GROUPS"}
|
||||
_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
|
||||
# Die Hülle um den eigentlichen Befehl: Umgebung lesen (und die Datei löschen), Ausgabe ins
|
||||
# Protokoll, Exit-Code atomar ablegen. $1 = Pfadpräfix der Akte, danach der Befehl.
|
||||
_HUELLE = ('akte="$1"; shift; '
|
||||
'if [ -f "$akte.env" ]; then . "$akte.env"; rm -f "$akte.env"; fi; '
|
||||
'exec >>"$akte.log" 2>&1 </dev/null; '
|
||||
'"$@"; code=$?; echo "$code" > "$akte.exit.tmp" && mv -f "$akte.exit.tmp" "$akte.exit"')
|
||||
|
||||
_NACHARBEITEN: dict[str, Callable[..., None]] = {}
|
||||
_ABSCHLUESSE: dict[str, Callable[[dict], None]] = {}
|
||||
_geladen = False
|
||||
|
||||
|
||||
def _append_log(job: dict, line: str) -> None:
|
||||
job["log"].append(line)
|
||||
if len(job["log"]) > _LOG_CAP:
|
||||
del job["log"][0]
|
||||
def nacharbeit(name: str) -> Callable[[Callable[..., None]], Callable[..., None]]:
|
||||
"""Eine Nacharbeit unter festem Namen anmelden. Sie bekommt die nacharbeit_daten des Auftrags
|
||||
als Schlüsselwort-Argumente und läuft nur, wenn der Auftrag erfolgreich war."""
|
||||
def anmelden(fn: Callable[..., None]) -> Callable[..., None]:
|
||||
_NACHARBEITEN[name] = fn
|
||||
return fn
|
||||
return anmelden
|
||||
|
||||
|
||||
def _pump_output(job: dict, stream) -> None:
|
||||
"""Liest byteweise; `\\r` (tqdm/hf-Fortschritt) überschreibt die letzte Zeile."""
|
||||
buf = b""
|
||||
overwrite = False
|
||||
pending_cr = False
|
||||
|
||||
def commit():
|
||||
line = buf.decode("utf-8", "replace")
|
||||
if overwrite and job["log"]:
|
||||
job["log"][-1] = line
|
||||
else:
|
||||
_append_log(job, line)
|
||||
|
||||
while True:
|
||||
ch = stream.read(1)
|
||||
if not ch:
|
||||
break
|
||||
if pending_cr:
|
||||
pending_cr = False
|
||||
if ch == b"\n":
|
||||
commit(); overwrite = False; buf = b""
|
||||
continue
|
||||
commit(); overwrite = True; buf = b""
|
||||
if ch == b"\r":
|
||||
pending_cr = True
|
||||
elif ch == b"\n":
|
||||
commit(); overwrite = False; buf = b""
|
||||
else:
|
||||
buf += ch
|
||||
if pending_cr:
|
||||
commit(); overwrite = True; buf = b""
|
||||
if buf:
|
||||
commit()
|
||||
def abschluss(name: str) -> Callable[[Callable[[dict], None]], Callable[[dict], None]]:
|
||||
"""Einen Abschluss unter festem Namen anmelden. Anders als die Nacharbeit läuft er bei JEDEM Ende
|
||||
(fertig, gescheitert, abgebrochen) und bekommt eine Kopie der Akte — etwa um das Ergebnis in den
|
||||
Update-Verlauf zu schreiben."""
|
||||
def anmelden(fn: Callable[[dict], None]) -> Callable[[dict], None]:
|
||||
_ABSCHLUESSE[name] = fn
|
||||
return fn
|
||||
return anmelden
|
||||
|
||||
|
||||
def _run_job(job_id: str, args: list[str], env: dict | None = None, sudo_password: str | None = None):
|
||||
job = JOBS[job_id]
|
||||
job["state"] = "running"
|
||||
# --- Ablage ------------------------------------------------------------------------
|
||||
|
||||
def _jobs_dir() -> Path:
|
||||
return Path(os.environ.get("MC_JOBS_DIR", str(einstellungen().daten_dir / "mc2-jobs")))
|
||||
|
||||
|
||||
def _pfad(job_id: str, endung: str) -> Path:
|
||||
return _jobs_dir() / f"{job_id}{endung}"
|
||||
|
||||
|
||||
def _speichern(job: dict) -> None:
|
||||
"""Akte ohne Laufzeitfelder atomar schreiben."""
|
||||
daten = {k: v for k, v in job.items() if k not in _LAUFZEIT and not k.startswith("_")}
|
||||
try:
|
||||
actual_args = list(args)
|
||||
if sudo_password is not None:
|
||||
for i, arg in enumerate(actual_args):
|
||||
if isinstance(arg, str):
|
||||
actual_args[i] = arg.replace("sudo -n", "sudo -S").replace("sudo ", "sudo -S ")
|
||||
_jobs_dir().mkdir(parents=True, exist_ok=True)
|
||||
tmp = _pfad(job["id"], ".json.tmp")
|
||||
tmp.write_text(json.dumps(daten, ensure_ascii=False), encoding="utf-8")
|
||||
tmp.replace(_pfad(job["id"], ".json"))
|
||||
except OSError:
|
||||
log.warning("jobengine: Akte %s nicht schreibbar", job.get("id"), exc_info=True)
|
||||
|
||||
proc = subprocess.Popen(
|
||||
actual_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
stdin=subprocess.PIPE if sudo_password is not None else None,
|
||||
bufsize=0,
|
||||
env={**os.environ, **(env or {})},
|
||||
)
|
||||
|
||||
def _protokoll(job_id: str, zeile: str) -> None:
|
||||
"""Eine Zeile von MC2 ins Protokoll des Auftrags schreiben."""
|
||||
try:
|
||||
_jobs_dir().mkdir(parents=True, exist_ok=True)
|
||||
with _pfad(job_id, ".log").open("a", encoding="utf-8", newline="\n") as f:
|
||||
f.write(zeile.rstrip("\n") + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def zeilen_aus(text: str) -> list[str]:
|
||||
"""Protokolltext in Anzeigezeilen: `\\r` (Fortschrittsbalken von hf/tqdm/apt) überschreibt die Zeile."""
|
||||
zeilen = []
|
||||
for roh in text.split("\n"):
|
||||
if "\r" in roh:
|
||||
teile = [t for t in roh.split("\r") if t]
|
||||
roh = teile[-1] if teile else ""
|
||||
zeilen.append(roh)
|
||||
if zeilen and zeilen[-1] == "":
|
||||
zeilen.pop()
|
||||
return zeilen[-_LOG_CAP:]
|
||||
|
||||
|
||||
def _protokoll_lesen(job_id: str) -> list[str]:
|
||||
try:
|
||||
with _pfad(job_id, ".log").open("rb") as f:
|
||||
f.seek(0, os.SEEK_END)
|
||||
groesse = f.tell()
|
||||
f.seek(max(0, groesse - _LOG_LESEN_MAX))
|
||||
roh = f.read()
|
||||
except OSError:
|
||||
return []
|
||||
text = roh.decode("utf-8", "replace")
|
||||
if groesse > _LOG_LESEN_MAX:
|
||||
text = text.split("\n", 1)[-1] # angeschnittene erste Zeile verwerfen
|
||||
return zeilen_aus(text)
|
||||
|
||||
|
||||
def protokoll(job_id: str) -> list[str]:
|
||||
"""Das Protokoll eines Auftrags (Ende, Fortschrittszeilen zusammengefasst)."""
|
||||
return _protokoll_lesen(job_id)
|
||||
|
||||
|
||||
def _exit_code(job_id: str) -> int | None:
|
||||
try:
|
||||
return int(_pfad(job_id, ".exit").read_text(encoding="utf-8").strip())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _exit_ablegen(job_id: str, code: int) -> None:
|
||||
try:
|
||||
tmp = _pfad(job_id, ".exit.tmp")
|
||||
tmp.write_text(str(code), encoding="utf-8")
|
||||
tmp.replace(_pfad(job_id, ".exit"))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _umgebungsdatei(job_id: str, env: dict | None) -> None:
|
||||
"""Umgebung des Auftrags (MC2-Umgebung + env) als nur für den Nutzer lesbare Datei."""
|
||||
werte = {**os.environ, **(env or {})}
|
||||
zeilen = [f"export {k}={shlex.quote(str(v))}" for k, v in werte.items()
|
||||
if _NAME.match(k) and k not in _UMGEBUNG_OHNE]
|
||||
fd = os.open(_pfad(job_id, ".env"), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write("\n".join(zeilen) + "\n")
|
||||
|
||||
|
||||
# --- Ausführung ----------------------------------------------------------------------
|
||||
|
||||
def _art() -> str:
|
||||
"""„systemd“, wenn Aufträge als eigene Einheiten laufen können, sonst „prozess“."""
|
||||
gewuenscht = os.environ.get("MC_JOBS_ART", "").strip().lower()
|
||||
if gewuenscht in ("systemd", "prozess"):
|
||||
return gewuenscht
|
||||
if os.name != "posix" or not shutil.which("systemd-run"):
|
||||
return "prozess"
|
||||
if os.geteuid() != 0 and not os.environ.get("XDG_RUNTIME_DIR"):
|
||||
return "prozess"
|
||||
return "systemd"
|
||||
|
||||
|
||||
def _systemd(programm: str, *args: str) -> list[str]:
|
||||
"""systemctl/systemd-run für den richtigen Manager: als root der System-, sonst der Nutzer-Manager."""
|
||||
als_root = os.name == "posix" and os.geteuid() == 0
|
||||
return [programm, *(() if als_root else ("--user",)), *args]
|
||||
|
||||
|
||||
def _einheit(job_id: str) -> str:
|
||||
return f"mc2-job-{job_id}"
|
||||
|
||||
|
||||
def _einheit_lebt(job_id: str) -> bool:
|
||||
try:
|
||||
r = subprocess.run(_systemd("systemctl", "is-active", _einheit(job_id)),
|
||||
capture_output=True, text=True, timeout=10)
|
||||
except Exception:
|
||||
return True # Im Zweifel weiter beobachten statt einen laufenden Auftrag totzusagen.
|
||||
return r.stdout.strip() in ("active", "activating", "deactivating", "reloading")
|
||||
|
||||
|
||||
def _starte_systemd(job: dict, args: list[str], env: dict | None) -> None:
|
||||
job_id = job["id"]
|
||||
_umgebungsdatei(job_id, env)
|
||||
befehl = _systemd("systemd-run", "--unit", _einheit(job_id), "--collect", "--quiet",
|
||||
"--working-directory", os.getcwd(),
|
||||
"--property", f"RuntimeMaxSec={int(job['zeitlimit_s'])}",
|
||||
"--", "/bin/bash", "-c", _HUELLE, "mc2-job", str(_jobs_dir() / job_id), *args)
|
||||
r = subprocess.run(befehl, capture_output=True, text=True, timeout=30)
|
||||
if r.returncode != 0:
|
||||
_pfad(job_id, ".env").unlink(missing_ok=True)
|
||||
raise RuntimeError((r.stderr or r.stdout or "systemd-run scheiterte").strip()[:300])
|
||||
threading.Thread(target=_beobachten, args=(job_id,), daemon=True).start()
|
||||
|
||||
|
||||
def _starte_prozess(job: dict, args: list[str], env: dict | None) -> None:
|
||||
"""Rückfall ohne systemd: Kindprozess, Ausgabe ins Protokoll; ein Warte-Thread schließt ab."""
|
||||
job_id = job["id"]
|
||||
with _pfad(job_id, ".log").open("ab") as protokoll:
|
||||
proc = subprocess.Popen(list(args), stdout=protokoll, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
|
||||
env={**os.environ, **(env or {})}, start_new_session=(os.name == "posix"))
|
||||
_PROCS[job_id] = proc
|
||||
grenze = int(job["zeitlimit_s"])
|
||||
|
||||
if sudo_password is not None and proc.stdin:
|
||||
proc.stdin.write((sudo_password + "\n").encode("utf-8"))
|
||||
proc.stdin.flush()
|
||||
proc.stdin.close()
|
||||
def zu_lang() -> None:
|
||||
job["zeitlimit"] = True
|
||||
_protokoll(job_id, f"[mc] Zeitlimit ({grenze // 60} min) überschritten, Auftrag wird beendet.")
|
||||
_beende_gruppe(proc)
|
||||
|
||||
_pump_output(job, proc.stdout)
|
||||
proc.wait()
|
||||
job["returncode"] = proc.returncode
|
||||
job["state"] = "canceled" if job.get("canceled") else ("done" if proc.returncode == 0 else "failed")
|
||||
wecker = threading.Timer(grenze, zu_lang)
|
||||
wecker.daemon = True
|
||||
wecker.start()
|
||||
|
||||
# Check if failed due to sudo authorization failure
|
||||
if proc.returncode != 0 and job["log"]:
|
||||
log_str = "\n".join(job["log"])
|
||||
if "a password is required" in log_str or "password" in log_str.lower() or "sudo:" in log_str:
|
||||
job["sudo_failed"] = True
|
||||
except Exception as exc:
|
||||
_append_log(job, f"[mc] Fehler: {exc}")
|
||||
job["state"] = "failed"
|
||||
job["returncode"] = -1
|
||||
finally:
|
||||
_PROCS.pop(job_id, None)
|
||||
job["finished_at"] = time.time()
|
||||
cb = job.pop("_on_done", None)
|
||||
if cb and job["state"] == "done":
|
||||
def warten() -> None:
|
||||
code = proc.wait()
|
||||
wecker.cancel()
|
||||
_exit_ablegen(job_id, code)
|
||||
_abschliessen(job, code)
|
||||
|
||||
threading.Thread(target=warten, daemon=True).start()
|
||||
|
||||
|
||||
def _beende_gruppe(proc: subprocess.Popen) -> None:
|
||||
"""Den Auftragsprozess samt Kindern beenden (posix: ganze Prozessgruppe, sonst nur den Prozess)."""
|
||||
try:
|
||||
cb()
|
||||
except Exception as exc:
|
||||
_append_log(job, f"[mc] Nachbearbeitung-Fehler: {exc}")
|
||||
if os.name == "posix":
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||
else:
|
||||
proc.terminate()
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
def _abschliessen(job: dict, code: int | None) -> None:
|
||||
"""Endzustand festhalten (genau einmal). Bei Erfolg läuft die Nacharbeit VOR dem Endzustand:
|
||||
Wer „done“ sieht, sieht auch schon ihre Wirkung (gesetzte Rolle, geleerte Zwischenspeicher)."""
|
||||
with _LOCK:
|
||||
if job["state"] in _ENDE or job.get("_schliesst"):
|
||||
return
|
||||
job["_schliesst"] = True
|
||||
felder: dict = {"returncode": code}
|
||||
if job.get("canceled"):
|
||||
felder["state"] = "canceled"
|
||||
elif job.get("zeitlimit") or (code is None and time.time() - job["started_at"] >= job["zeitlimit_s"] - 5):
|
||||
felder.update(state="failed", zeitlimit=True, error="Zeitlimit überschritten")
|
||||
elif code is None:
|
||||
felder.update(state="failed", error=job.get("error") or "Der Auftrag endete ohne Rückmeldung.")
|
||||
else:
|
||||
felder["state"] = "done" if code == 0 else "failed"
|
||||
if felder["state"] == "done":
|
||||
_nacharbeit_ausfuehren(job)
|
||||
with _LOCK:
|
||||
job.update(felder, finished_at=time.time())
|
||||
job.pop("_schliesst", None)
|
||||
_PROCS.pop(job["id"], None)
|
||||
_speichern(job)
|
||||
_abschluss_ausfuehren(job)
|
||||
|
||||
|
||||
def _nacharbeit_ausfuehren(job: dict) -> None:
|
||||
name = job.get("nacharbeit")
|
||||
if not name or job.get("nacharbeit_erledigt"):
|
||||
return
|
||||
job["nacharbeit_erledigt"] = True
|
||||
_speichern(job)
|
||||
try:
|
||||
fn = _NACHARBEITEN.get(name)
|
||||
if fn is None:
|
||||
raise RuntimeError(f"Nacharbeit „{name}“ ist in dieser Instanz unbekannt")
|
||||
fn(**(job.get("nacharbeit_daten") or {}))
|
||||
except Exception as exc:
|
||||
_protokoll(job["id"], f"[mc] Nachbearbeitung-Fehler: {exc}")
|
||||
log.warning("jobengine: Nacharbeit %s von %s gescheitert", name, job["id"], exc_info=True)
|
||||
|
||||
|
||||
def _abschluss_ausfuehren(job: dict) -> None:
|
||||
name = job.get("abschluss")
|
||||
if not name or job.get("abschluss_erledigt"):
|
||||
return
|
||||
job["abschluss_erledigt"] = True
|
||||
_speichern(job)
|
||||
try:
|
||||
fn = _ABSCHLUESSE.get(name)
|
||||
if fn is None:
|
||||
raise RuntimeError(f"Abschluss „{name}“ ist in dieser Instanz unbekannt")
|
||||
fn({k: v for k, v in job.items() if not k.startswith("_")})
|
||||
except Exception as exc:
|
||||
_protokoll(job["id"], f"[mc] Abschluss-Fehler: {exc}")
|
||||
log.warning("jobengine: Abschluss %s von %s gescheitert", name, job["id"], exc_info=True)
|
||||
|
||||
|
||||
def _beobachten(job_id: str) -> None:
|
||||
"""Wartet auf den Exit-Code einer systemd-Einheit. Endet sie ohne einen (Abbruch, Zeitlimit,
|
||||
Absturz), schließt er den Auftrag trotzdem ab."""
|
||||
naechste_pruefung = time.time() + _EINHEIT_PRUEFEN_S
|
||||
while True:
|
||||
job = JOBS.get(job_id)
|
||||
if job is None or job["state"] in _ENDE:
|
||||
return
|
||||
if (code := _exit_code(job_id)) is not None:
|
||||
_abschliessen(job, code)
|
||||
return
|
||||
if time.time() >= naechste_pruefung:
|
||||
if not _einheit_lebt(job_id):
|
||||
time.sleep(0.5) # der Exit-Code kann gerade erst geschrieben werden
|
||||
_abschliessen(job, _exit_code(job_id))
|
||||
return
|
||||
naechste_pruefung = time.time() + _EINHEIT_PRUEFEN_S
|
||||
time.sleep(_TAKT_S)
|
||||
|
||||
|
||||
def _starten(job: dict, args: list[str], env: dict | None) -> None:
|
||||
job["state"] = "running"
|
||||
_speichern(job)
|
||||
try:
|
||||
if job["art"] == "systemd":
|
||||
_starte_systemd(job, args, env)
|
||||
else:
|
||||
_starte_prozess(job, args, env)
|
||||
except Exception as exc:
|
||||
_protokoll(job["id"], f"[mc] Fehler: {exc}")
|
||||
job["error"] = str(exc)
|
||||
_abschliessen(job, -1)
|
||||
|
||||
|
||||
# --- Fortschritt (Downloads) ------------------------------------------------------------
|
||||
|
||||
def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> None:
|
||||
"""Fortschritt in % aus wachsenden *.incomplete-Dateien (hf schreibt sie)."""
|
||||
if not total_bytes or total_bytes <= 0:
|
||||
return
|
||||
job = JOBS.get(job_id)
|
||||
if job is not None:
|
||||
job["progress"] = 0
|
||||
if job is None or not total_bytes or total_bytes <= 0:
|
||||
return
|
||||
job["fortschritt"] = {"ordner": local_dir, "gesamt": total_bytes}
|
||||
job["total_bytes"] = total_bytes
|
||||
job["progress"] = 0
|
||||
_speichern(job)
|
||||
_fortschritt_beobachten(job_id)
|
||||
|
||||
|
||||
def _fortschritt_beobachten(job_id: str) -> None:
|
||||
ziel = (JOBS.get(job_id) or {}).get("fortschritt") or {}
|
||||
local_dir, total_bytes = ziel.get("ordner"), ziel.get("gesamt")
|
||||
if not local_dir or not total_bytes:
|
||||
return
|
||||
|
||||
def _watch():
|
||||
pat = os.path.join(local_dir, ".cache", "huggingface", "download", "**", "*.incomplete")
|
||||
@@ -120,7 +390,7 @@ def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> N
|
||||
rate = 0.0
|
||||
while True:
|
||||
j = JOBS.get(job_id)
|
||||
if not j or j["state"] in ("done", "failed", "canceled"):
|
||||
if not j or j["state"] in _ENDE:
|
||||
break
|
||||
try:
|
||||
inc = glob.glob(pat, recursive=True)
|
||||
@@ -147,49 +417,175 @@ def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> N
|
||||
threading.Thread(target=_watch, daemon=True).start()
|
||||
|
||||
|
||||
def start_job(args: list[str], label: str, env: dict | None = None, on_done=None,
|
||||
sudo_password: str | None = None, group: str | None = None) -> str:
|
||||
# --- Verwaltung -------------------------------------------------------------------
|
||||
|
||||
def _laden() -> None:
|
||||
"""Akten von der Platte einlesen (einmal je Prozess; unter _LOCK aufrufen)."""
|
||||
global _geladen
|
||||
if _geladen:
|
||||
return
|
||||
_geladen = True
|
||||
for datei in sorted(_jobs_dir().glob("*.json")):
|
||||
try:
|
||||
akte = json.loads(datei.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if isinstance(akte, dict) and akte.get("id") and akte["id"] not in JOBS:
|
||||
JOBS[akte["id"]] = akte
|
||||
|
||||
|
||||
def _aufraeumen() -> None:
|
||||
"""Alte beendete Aufträge samt Dateien entfernen (unter _LOCK aufrufen)."""
|
||||
jetzt = time.time()
|
||||
fertig = sorted((j for j in JOBS.values() if j["state"] in _ENDE),
|
||||
key=lambda j: j.get("finished_at") or 0, reverse=True)
|
||||
for i, j in enumerate(fertig):
|
||||
if i >= BEHALTEN_MAX or jetzt - (j.get("finished_at") or jetzt) > BEHALTEN_S:
|
||||
JOBS.pop(j["id"], None)
|
||||
for endung in (".json", ".log", ".exit", ".env", ".exit.tmp", ".json.tmp"):
|
||||
_pfad(j["id"], endung).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _eintragen(args: list[str], label: str, group: str | None, zeitlimit_s: int | None,
|
||||
nacharbeit_name: str | None, nacharbeit_daten: dict | None,
|
||||
abschluss_name: str | None = None, abschluss_daten: dict | None = None) -> dict:
|
||||
"""Neue Akte anlegen (unter _LOCK aufrufen)."""
|
||||
if nacharbeit_name and nacharbeit_name not in _NACHARBEITEN:
|
||||
raise ValueError(f"Unbekannte Nacharbeit: {nacharbeit_name}")
|
||||
if abschluss_name and abschluss_name not in _ABSCHLUESSE:
|
||||
raise ValueError(f"Unbekannter Abschluss: {abschluss_name}")
|
||||
job_id = uuid.uuid4().hex[:12]
|
||||
# Mask password in log if present in args
|
||||
log_args = list(args)
|
||||
JOBS[job_id] = {
|
||||
"id": job_id, "label": label, "state": "queued", "group": group,
|
||||
"log": ["$ " + " ".join(shlex.quote(a) for a in log_args)],
|
||||
job = {
|
||||
"id": job_id, "label": label, "state": "queued", "group": group, "art": _art(),
|
||||
"returncode": None, "started_at": time.time(), "finished_at": None,
|
||||
"zeitlimit_s": int(zeitlimit_s or STANDARD_ZEITLIMIT_S),
|
||||
"nacharbeit": nacharbeit_name, "nacharbeit_daten": nacharbeit_daten or {},
|
||||
"abschluss": abschluss_name, "abschluss_daten": abschluss_daten or {},
|
||||
}
|
||||
if on_done:
|
||||
JOBS[job_id]["_on_done"] = on_done
|
||||
threading.Thread(target=_run_job, args=(job_id, args, env, sudo_password), daemon=True).start()
|
||||
return job_id
|
||||
JOBS[job_id] = job
|
||||
_protokoll(job_id, "$ " + " ".join(shlex.quote(a) for a in args))
|
||||
_speichern(job)
|
||||
return job
|
||||
|
||||
|
||||
def active_in_group(group: str) -> dict | None:
|
||||
"""Erster laufender/wartender Job einer Gruppe (z.B. 'maintenance'), sonst None.
|
||||
Basis für den Wartungs-Riegel: nur EIN System-Update gleichzeitig."""
|
||||
for j in JOBS.values():
|
||||
def _aktiv_in(group: str) -> dict | None:
|
||||
for j in list(JOBS.values()):
|
||||
if j.get("group") == group and j.get("state") in ("running", "queued"):
|
||||
return j
|
||||
return None
|
||||
|
||||
|
||||
def start_job(args: list[str], label: str, env: dict | None = None, group: str | None = None,
|
||||
zeitlimit_s: int | None = None, nacharbeit: str | None = None,
|
||||
nacharbeit_daten: dict | None = None, abschluss: str | None = None,
|
||||
abschluss_daten: dict | None = None) -> str:
|
||||
with _LOCK:
|
||||
_laden()
|
||||
_aufraeumen()
|
||||
job = _eintragen(list(args), label, group, zeitlimit_s, nacharbeit, nacharbeit_daten,
|
||||
abschluss, abschluss_daten)
|
||||
_starten(job, list(args), env)
|
||||
return job["id"]
|
||||
|
||||
|
||||
def start_job_exklusiv(group: str, args: list[str], label: str, env: dict | None = None,
|
||||
zeitlimit_s: int | None = None, nacharbeit: str | None = None,
|
||||
nacharbeit_daten: dict | None = None, abschluss: str | None = None,
|
||||
abschluss_daten: dict | None = None) -> tuple[str | None, dict | None]:
|
||||
"""Wie start_job, aber nur, wenn in der Gruppe nichts läuft — Prüfen und Eintragen unter einer
|
||||
Sperre. Rückgabe: (neue Auftrags-ID, None) oder (None, der schon laufende Auftrag)."""
|
||||
with _LOCK:
|
||||
_laden()
|
||||
if (laeuft := _aktiv_in(group)) is not None:
|
||||
return None, laeuft
|
||||
_aufraeumen()
|
||||
job = _eintragen(list(args), label, group, zeitlimit_s, nacharbeit, nacharbeit_daten,
|
||||
abschluss, abschluss_daten)
|
||||
_starten(job, list(args), env)
|
||||
return job["id"], None
|
||||
|
||||
|
||||
def active_in_group(group: str) -> dict | None:
|
||||
"""Erster laufender/wartender Auftrag einer Gruppe (z. B. 'maintenance'), sonst None.
|
||||
Basis für den Wartungs-Riegel: nur EIN System-Update gleichzeitig."""
|
||||
with _LOCK:
|
||||
_laden()
|
||||
return _aktiv_in(group)
|
||||
|
||||
|
||||
def wiederaufnehmen() -> int:
|
||||
"""Beim Start von MC2: Akten einlesen und laufende Aufträge weiter beobachten.
|
||||
Rückgabe: Zahl der wieder aufgenommenen Aufträge."""
|
||||
with _LOCK:
|
||||
_laden()
|
||||
offen = [j for j in JOBS.values() if j["state"] in ("queued", "running")]
|
||||
nacharbeit_offen = [j for j in JOBS.values() if j["state"] == "done" and j.get("nacharbeit")
|
||||
and not j.get("nacharbeit_erledigt")]
|
||||
abschluss_offen = [j for j in JOBS.values() if j["state"] in _ENDE and j.get("abschluss")
|
||||
and not j.get("abschluss_erledigt")]
|
||||
for job in offen:
|
||||
if job.get("art") == "systemd":
|
||||
threading.Thread(target=_beobachten, args=(job["id"],), daemon=True).start()
|
||||
_fortschritt_beobachten(job["id"])
|
||||
elif (code := _exit_code(job["id"])) is not None:
|
||||
_abschliessen(job, code)
|
||||
else:
|
||||
# Kindprozess des vorigen MC2: sein Warte-Thread ist mit ihm gegangen.
|
||||
_protokoll(job["id"], "[mc] MC2 wurde neu gestartet; der Auftrag lief als Kindprozess mit.")
|
||||
job["error"] = "MC2 wurde während des Auftrags neu gestartet."
|
||||
_abschliessen(job, None)
|
||||
for job in nacharbeit_offen:
|
||||
_nacharbeit_ausfuehren(job)
|
||||
for job in abschluss_offen:
|
||||
_abschluss_ausfuehren(job)
|
||||
if offen:
|
||||
log.info("jobengine: %d laufende Aufträge wieder aufgenommen", len(offen))
|
||||
return len(offen)
|
||||
|
||||
|
||||
def cancel_job(job_id: str) -> bool:
|
||||
with _LOCK:
|
||||
_laden()
|
||||
job = JOBS.get(job_id)
|
||||
if not job or job["state"] in ("done", "failed", "canceled"):
|
||||
if not job or job["state"] in _ENDE:
|
||||
return False
|
||||
job["canceled"] = True
|
||||
_append_log(job, "[mc] Abbruch angefordert…")
|
||||
proc = _PROCS.get(job_id)
|
||||
if proc is not None:
|
||||
_speichern(job)
|
||||
_protokoll(job_id, "[mc] Abbruch angefordert…")
|
||||
if job["state"] == "queued":
|
||||
_abschliessen(job, None)
|
||||
elif job.get("art") == "systemd":
|
||||
try:
|
||||
proc.terminate()
|
||||
subprocess.run(_systemd("systemctl", "stop", "--no-block", _einheit(job_id)),
|
||||
capture_output=True, timeout=15)
|
||||
except Exception:
|
||||
pass
|
||||
log.warning("jobengine: Abbruch von %s gescheitert", job_id, exc_info=True)
|
||||
elif (proc := _PROCS.get(job_id)) is not None:
|
||||
_beende_gruppe(proc)
|
||||
else:
|
||||
job["state"] = "canceled"
|
||||
job["finished_at"] = time.time()
|
||||
_abschliessen(job, None)
|
||||
return True
|
||||
|
||||
|
||||
def public_jobs() -> list[dict]:
|
||||
"""Jobs ohne interne Felder (_on_done) für die API."""
|
||||
return [{k: v for k, v in j.items() if not k.startswith("_")} for j in JOBS.values()]
|
||||
def quittieren(job_id: str) -> bool:
|
||||
"""Einen beendeten Auftrag als gesehen markieren — die Oberfläche blendet ihn dann aus (seit 25.09.2026;
|
||||
vorher blieben beendete Aufträge 24 Stunden stehen, ohne dass man sie wegklicken konnte)."""
|
||||
with _LOCK:
|
||||
_laden()
|
||||
job = JOBS.get(job_id)
|
||||
if not job or job["state"] not in _ENDE:
|
||||
return False
|
||||
job["quittiert"] = True
|
||||
_speichern(job)
|
||||
return True
|
||||
|
||||
|
||||
def public_jobs(mit_log: bool = True) -> list[dict]:
|
||||
"""Aufträge ohne interne Felder für die API; das Protokoll kommt aus der Datei (Ende)."""
|
||||
with _LOCK:
|
||||
_laden()
|
||||
_aufraeumen()
|
||||
jobs = [{k: v for k, v in j.items() if k not in _INTERN and not k.startswith("_")} for j in JOBS.values()]
|
||||
for j in jobs:
|
||||
j["log"] = _protokoll_lesen(j["id"]) if mit_log else []
|
||||
return sorted(jobs, key=lambda j: j.get("started_at") or 0)
|
||||
|
||||
+229
-79
@@ -6,9 +6,13 @@ NEU in 2.0: `groups` für Ko-Residenz (schnell + schwer gleichzeitig geladen,
|
||||
`swap:false`) → Multi-Model-Delegation ohne Nachlade-Latenz.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from config import (
|
||||
@@ -17,12 +21,18 @@ from config import (
|
||||
DEFAULT_TTL,
|
||||
DRAFTS_DIR,
|
||||
LLAMA_SWAP_URL,
|
||||
MODELS_DIR,
|
||||
SPEC_DRAFT_MODEL_PATH,
|
||||
SPEC_DRAFT_N_MAX,
|
||||
SPEC_TYPE,
|
||||
)
|
||||
from ruamel.yaml.scalarstring import LiteralScalarString
|
||||
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError: # Windows (Entwicklung): nur die Prozess-Sperre
|
||||
fcntl = None
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Kanonische Serving-Rollen — EINE Quelle der Wahrheit (identisch zu sources.ROLE_IDS,
|
||||
@@ -53,8 +63,77 @@ def _gguf_total_size(path: str) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
# --- Sperre ------------------------------------------------------------------
|
||||
# Die llama-swap-Config hat mehrere Schreiber: die Oberfläche, das Radar, das Aufräumen und die
|
||||
# Hirn-Umstellung. Ohne Sperre gingen gleichzeitige Änderungen verloren (lesen, ändern, schreiben
|
||||
# überlappten). Seit 24.09.2026 läuft jedes Lesen-Ändern-Schreiben unter dieser Sperre: im
|
||||
# Prozess per RLock, zwischen Prozessen per flock auf einer Datei neben der Config.
|
||||
_SPERRE = threading.RLock()
|
||||
_SPERR_TIEFE = threading.local()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def config_sperre():
|
||||
with _SPERRE:
|
||||
tiefe = getattr(_SPERR_TIEFE, "n", 0)
|
||||
_SPERR_TIEFE.n = tiefe + 1
|
||||
datei = None
|
||||
try:
|
||||
if tiefe == 0 and fcntl is not None:
|
||||
try:
|
||||
datei = open(CONFIG_PATH.with_name(".mc2-config.lock"), "a+")
|
||||
fcntl.flock(datei, fcntl.LOCK_EX)
|
||||
except OSError:
|
||||
datei = None # ohne Sperrdatei (z. B. fehlende Rechte) bleibt die Prozess-Sperre
|
||||
yield
|
||||
finally:
|
||||
_SPERR_TIEFE.n = tiefe
|
||||
if datei is not None:
|
||||
fcntl.flock(datei, fcntl.LOCK_UN)
|
||||
datei.close()
|
||||
|
||||
|
||||
# Sammel-Schreiben (24.09.2026): Jeder Schreibvorgang lässt llama-swap neu laden und ALLE Modelle
|
||||
# entladen. Ein Modelltausch bestand aus bis zu sieben Schreibvorgängen (Eintragen, Befehl, Zwilling,
|
||||
# Alias, Gruppe, ttl …). Innerhalb von sammeln() lesen alle Änderungen dieselbe Config aus dem
|
||||
# Speicher, und geschrieben wird einmal am Ende — oder gar nicht, wenn etwas scheitert.
|
||||
_SAMMEL = threading.local()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def sammeln():
|
||||
with config_sperre():
|
||||
if getattr(_SAMMEL, "aktiv", False): # verschachtelt: der äußere schreibt
|
||||
yield
|
||||
return
|
||||
_SAMMEL.aktiv, _SAMMEL.cfg = True, None
|
||||
try:
|
||||
yield
|
||||
cfg = _SAMMEL.cfg
|
||||
finally:
|
||||
_SAMMEL.aktiv, _SAMMEL.cfg = False, None
|
||||
if cfg is not None:
|
||||
_schreiben(cfg)
|
||||
|
||||
|
||||
def _mit_sperre(fn):
|
||||
@functools.wraps(fn)
|
||||
def huelle(*args, **kwargs):
|
||||
with config_sperre():
|
||||
return fn(*args, **kwargs)
|
||||
return huelle
|
||||
|
||||
|
||||
# --- Lesen -------------------------------------------------------------------
|
||||
def read_config() -> dict:
|
||||
if getattr(_SAMMEL, "aktiv", False):
|
||||
if _SAMMEL.cfg is None:
|
||||
_SAMMEL.cfg = _lesen()
|
||||
return _SAMMEL.cfg
|
||||
return _lesen()
|
||||
|
||||
|
||||
def _lesen() -> dict:
|
||||
if not CONFIG_PATH.exists():
|
||||
return {"models": {}}
|
||||
from ruamel.yaml import YAML
|
||||
@@ -212,7 +291,15 @@ def _augment_vision(cmd: str, model_path: str, mmproj_path: str | None) -> str:
|
||||
|
||||
def write_config(cfg: dict) -> None:
|
||||
"""Atomar schreiben (tmp + os.replace), damit llama-swap mit -watch-config nie
|
||||
eine halbe Datei sieht. Fehlende Schreibrechte → klare Meldung."""
|
||||
eine halbe Datei sieht. Fehlende Schreibrechte → klare Meldung. Innerhalb von sammeln()
|
||||
wird nur vorgemerkt; geschrieben wird am Ende einmal."""
|
||||
if getattr(_SAMMEL, "aktiv", False):
|
||||
_SAMMEL.cfg = cfg
|
||||
return
|
||||
_schreiben(cfg)
|
||||
|
||||
|
||||
def _schreiben(cfg: dict) -> None:
|
||||
try:
|
||||
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = CONFIG_PATH.with_name(CONFIG_PATH.name + ".tmp")
|
||||
@@ -236,7 +323,7 @@ def write_config(cfg: dict) -> None:
|
||||
) from exc
|
||||
|
||||
|
||||
|
||||
@_mit_sperre
|
||||
def register_model(model_path: str, role: str | None = None, ctx: int = 8192,
|
||||
ttl: int | None = None, mmproj_path: str | None = None,
|
||||
jinja: bool = False, set_alias: bool = True) -> str:
|
||||
@@ -261,7 +348,7 @@ def register_model(model_path: str, role: str | None = None, ctx: int = 8192,
|
||||
if "--cache-reuse" not in cmd and "--mmproj" not in cmd:
|
||||
cmd += " --cache-reuse 256 -cram 16384"
|
||||
# IDE-Coding profitiert von Nebenläufigkeit; sonst Default 1 Slot = voller Kontext/Anfrage
|
||||
# (--parallel teilt den Kontext HART auf die Slots auf, s. docs/OPTIMIZATION_PLAN.md §9.4 V6).
|
||||
# (--parallel teilt den Kontext HART auf die Slots auf, s. docs/archiv/2026-06-30-optimierungsplan.md §9.4 V6).
|
||||
if role_lower == "coder" and "--parallel" not in cmd:
|
||||
cmd += " --parallel 2"
|
||||
# Vocab-kompatiblen Draft automatisch anhängen — klassisch (DRAFTS_DIR) ODER MTP-Kopf neben
|
||||
@@ -368,70 +455,8 @@ def spec_draft_flags(target_path: str) -> str:
|
||||
return _spec_flags_for_draft(d) if d else ""
|
||||
|
||||
|
||||
def drafts_for(target_path: str) -> dict:
|
||||
"""Für die UI: alle Drafts + ihre Kompatibilität zum Ziel-Modell. Schließt MTP-Köpfe
|
||||
NEBEN dem Zielmodell ein (DRAFTS_DIR kennt sie nicht). `mtp:true` markiert MTP-Drafts.
|
||||
compatible=None heißt 'nicht prüfbar' (Ziel- oder Draft-GGUF fehlt)."""
|
||||
from services import gguf_meta
|
||||
exists = bool(target_path and os.path.exists(target_path))
|
||||
drafts = list_drafts()
|
||||
seen = {d["path"] for d in drafts}
|
||||
for p in _sibling_mtp_drafters(target_path):
|
||||
if p not in seen:
|
||||
drafts.append({"path": p, "filename": os.path.basename(p),
|
||||
"size_bytes": os.path.getsize(p) if os.path.exists(p) else None,
|
||||
"vocab": gguf_meta.fingerprint(p)})
|
||||
for d in drafts:
|
||||
d["compatible"] = gguf_meta.compatible(target_path, d["path"]) if exists else None
|
||||
d["mtp"] = _is_mtp_draft(d["path"])
|
||||
return {
|
||||
"target_path": target_path,
|
||||
"target_exists": exists,
|
||||
"target_vocab": gguf_meta.fingerprint(target_path) if exists else None,
|
||||
"drafts": drafts,
|
||||
}
|
||||
|
||||
|
||||
def set_spec_draft(model_id: str, draft_path: str | None) -> dict:
|
||||
"""Setzt (oder entfernt mit draft_path=None) den Spec-Draft eines Modells.
|
||||
Validiert die Vocab-Kompatibilität — ein inkompatibler/unprüfbarer Draft wird
|
||||
abgelehnt (idiotensicher). Returns {ok, reason}."""
|
||||
cfg = read_config()
|
||||
spec = (cfg.get("models") or {}).get(model_id)
|
||||
if not isinstance(spec, dict):
|
||||
return {"ok": False, "reason": "Modell nicht gefunden"}
|
||||
cmd = str(spec.get("cmd", ""))
|
||||
# vorhandene Spec-Flags entfernen (idempotent) — klassisch UND MTP.
|
||||
cmd = re.sub(r"\s+--(?:spec-draft-model|model-draft)\s+\S+", "", cmd)
|
||||
cmd = re.sub(r"\s+-md\s+\S+", "", cmd)
|
||||
cmd = re.sub(r"\s+--spec-type\s+\S+", "", cmd)
|
||||
cmd = re.sub(r"\s+--spec-draft-n-(?:max|min)\s+\S+", "", cmd)
|
||||
|
||||
if draft_path:
|
||||
# relative Angabe (nur Dateiname) gegen DRAFTS_DIR auflösen
|
||||
if not os.path.isabs(draft_path) and "/" not in draft_path:
|
||||
draft_path = str(DRAFTS_DIR / draft_path)
|
||||
if not os.path.exists(draft_path):
|
||||
return {"ok": False, "reason": "Draft-Datei nicht gefunden"}
|
||||
from services import gguf_meta
|
||||
target = ""
|
||||
if (mt := _PATH_RE.search(cmd)):
|
||||
target = mt.group(1).replace("'", "").replace('"', "")
|
||||
comp = gguf_meta.compatible(target, draft_path) if os.path.exists(target) else None
|
||||
if comp is not True:
|
||||
reason = ("Draft ist NICHT vocab-kompatibel zum Modell — Speculative Decoding "
|
||||
"würde beim Laden scheitern."
|
||||
if comp is False else
|
||||
"Kompatibilität nicht prüfbar (Modell-GGUF fehlt) — Draft nicht gesetzt.")
|
||||
return {"ok": False, "reason": reason}
|
||||
cmd = cmd.rstrip() + _spec_flags_for_draft(draft_path)
|
||||
|
||||
spec["cmd"] = LiteralScalarString(cmd.rstrip() + "\n")
|
||||
write_config(cfg)
|
||||
return {"ok": True, "reason": ""}
|
||||
|
||||
|
||||
# --- Groups (Ko-Residenz) ----------------------------------------------------
|
||||
@_mit_sperre
|
||||
def set_group(group: str, members: list[str], swap: bool = False, persist: bool = False) -> None:
|
||||
"""llama-swap-`groups`-Eintrag setzen. swap=False → alle Mitglieder dürfen
|
||||
GLEICHZEITIG laufen (Ko-Residenz, keine Nachlade-Latenz). persist=True →
|
||||
@@ -444,6 +469,10 @@ def set_group(group: str, members: list[str], swap: bool = False, persist: bool
|
||||
cfg = read_config()
|
||||
groups = cfg.setdefault("groups", {})
|
||||
groups[group] = {"swap": swap, "persist": persist, "persistent": persist,
|
||||
# Eine immer-warme Gruppe darf nichts verdrängen. llama-swap macht Gruppen sonst
|
||||
# `exclusive`, und JEDE Anfrage ans Hirn entlud den Coder samt Prompt-Cache
|
||||
# (live gefunden 24.09.2026: Lucy-Anfrage → OpenChamber-Coder weg).
|
||||
**({"exclusive": False} if persist else {}),
|
||||
"members": list(members)}
|
||||
# Härtung: eine persistente (immer-warme) Gruppe verlangt ttl:0 je Mitglied.
|
||||
# `persistent` schützt NUR gegen Verdrängung durch andere Modelle, NICHT gegen
|
||||
@@ -463,6 +492,7 @@ def list_groups() -> dict:
|
||||
return read_config().get("groups") or {}
|
||||
|
||||
|
||||
@_mit_sperre
|
||||
def set_role(model_id: str, role: str | None) -> bool:
|
||||
"""Rolle (llama-swap-Alias) eines bestehenden Modells setzen/ändern. So tauscht man
|
||||
z.B. das `fast`-Hirn: Rolle `fast` auf ein anderes Modell legen (Alias wandert)."""
|
||||
@@ -474,22 +504,23 @@ def set_role(model_id: str, role: str | None) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def set_ctx(model_id: str, ctx: int) -> bool:
|
||||
"""Kontextlänge (-c) eines bestehenden Modells ändern."""
|
||||
@_mit_sperre
|
||||
def add_role(model_id: str, role: str) -> bool:
|
||||
"""Wie set_role, aber die übrigen Aliase des Ziel-Modells bleiben — für Modelle mit zwei Rollen
|
||||
(Coder: coder UND heavy). set_role behielte nur die geschützten Aliase und würfe coder wieder weg."""
|
||||
cfg = read_config()
|
||||
spec = (cfg.get("models") or {}).get(model_id)
|
||||
if not spec:
|
||||
models = cfg.get("models") or {}
|
||||
rolle = (role or "").strip().lower()
|
||||
if model_id not in models or not rolle or not isinstance(models[model_id], dict):
|
||||
return False
|
||||
cmd = str(spec.get("cmd", ""))
|
||||
if _CTX_RE.search(cmd):
|
||||
cmd = re.sub(r"-(?:c|-ctx-size)\s+\d+", f"-c {ctx}", cmd)
|
||||
else:
|
||||
cmd = cmd.rstrip() + f" -c {ctx}"
|
||||
spec["cmd"] = LiteralScalarString(cmd if cmd.endswith("\n") else cmd + "\n")
|
||||
bisher = [a for a in (models[model_id].get("aliases") or []) if str(a).lower() != rolle]
|
||||
set_role_alias(cfg, model_id, rolle) # nimmt die Rolle allen anderen Modellen weg
|
||||
models[model_id]["aliases"] = bisher + [rolle]
|
||||
write_config(cfg)
|
||||
return True
|
||||
|
||||
|
||||
@_mit_sperre
|
||||
def set_ttl(model_id: str, ttl: int) -> bool:
|
||||
"""Idle-TTL (Sekunden) eines bestehenden Modells setzen. ttl=0 → nie automatisch
|
||||
entladen (für das Agent-Hirn, das dauerhaft warm bleiben muss)."""
|
||||
@@ -502,6 +533,15 @@ def set_ttl(model_id: str, ttl: int) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
@_mit_sperre
|
||||
def im_modellordner(pfad: str) -> bool:
|
||||
"""Liegt der Pfad (aufgelöst) unter MODELS_DIR? Grenze für Löschen und Eintragen (24.09.2026)."""
|
||||
try:
|
||||
return Path(pfad).resolve().is_relative_to(MODELS_DIR.resolve())
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def delete_model(model_id: str) -> bool:
|
||||
"""Entfernt einen Modell-Eintrag aus der config.yaml, löscht die zugehörigen
|
||||
GGUF-Dateien (auch Splits) vom Datenträger und bereinigt leere Ordner.
|
||||
@@ -513,8 +553,17 @@ def delete_model(model_id: str) -> bool:
|
||||
|
||||
model_spec = models[model_id] or {}
|
||||
cmd = str(model_spec.get("cmd", "")).strip()
|
||||
if (m := _PATH_RE.search(cmd)):
|
||||
# Seit 24.09.2026 teilen sich die Bild-Zwillinge ihre Gewichte (und ggf. Projektoren) mit Hirn und Coder.
|
||||
# Dateien, die ein anderer Eintrag noch nennt, bleiben liegen — sonst risse das Löschen eines Zwillings
|
||||
# den Coder mit. Dann wird nur der Eintrag entfernt.
|
||||
andere = " ".join(str((s or {}).get("cmd", "")) for mid, s in models.items() if mid != model_id)
|
||||
if (m := _PATH_RE.search(cmd)) and m.group(1).replace("'", "").replace('"', "") in andere:
|
||||
m = None
|
||||
if m:
|
||||
path = m.group(1).replace("'", "").replace('"', "")
|
||||
if path and not im_modellordner(path):
|
||||
log.warning("delete_model: %s liegt außerhalb von %s — nur der Eintrag wird entfernt", path, MODELS_DIR)
|
||||
path = ""
|
||||
if path:
|
||||
# 1. Haupt-GGUF-Datei löschen
|
||||
if os.path.exists(path):
|
||||
@@ -542,7 +591,7 @@ def delete_model(model_id: str) -> bool:
|
||||
mmproj_match = re.search(r'--mmproj\s+[\'"]?([^\s\'"]+)[\'"]?', cmd)
|
||||
if mmproj_match:
|
||||
m_path = mmproj_match.group(1)
|
||||
if os.path.exists(m_path):
|
||||
if os.path.exists(m_path) and m_path not in andere and im_modellordner(m_path):
|
||||
try:
|
||||
os.remove(m_path)
|
||||
except Exception:
|
||||
@@ -550,7 +599,7 @@ def delete_model(model_id: str) -> bool:
|
||||
|
||||
# 3. Eltern-Ordner löschen, falls er leer ist und nicht der Modelle-Wurzelordner selbst ist
|
||||
try:
|
||||
if not os.listdir(dirname) and os.path.basename(dirname) != "models":
|
||||
if not os.listdir(dirname) and Path(dirname).resolve() != MODELS_DIR.resolve():
|
||||
os.rmdir(dirname)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -610,3 +659,104 @@ def get_running_models() -> list[str]:
|
||||
except Exception:
|
||||
log.warning("get_running_models fehlgeschlagen", exc_info=True)
|
||||
return []
|
||||
|
||||
|
||||
def modell_zustaende() -> dict[str, str] | None:
|
||||
"""Zustand je Modell aus /running: Name → "ready" | "starting" | "stopping". Wer fehlt, ist nicht geladen.
|
||||
llama-swap v257 listet dort jedes Modell, das weder „stopped“ noch „shutdown“ ist (internal/router/base.go,
|
||||
RunningModels) — also auch eines, das gerade lädt. Ältere Fassungen lieferten nur Namen; die gelten als bereit.
|
||||
None = /running nicht lesbar."""
|
||||
try:
|
||||
with httpx.Client(timeout=3.0) as c:
|
||||
r = c.get(f"{LLAMA_SWAP_URL}/running")
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
eintraege = r.json().get("running") or []
|
||||
except Exception:
|
||||
log.warning("modell_zustaende: /running nicht lesbar", exc_info=True)
|
||||
return None
|
||||
zustaende: dict[str, str] = {}
|
||||
for x in eintraege:
|
||||
if isinstance(x, dict):
|
||||
if x.get("model"):
|
||||
zustaende[str(x["model"])] = str(x.get("state") or "ready")
|
||||
elif x:
|
||||
zustaende[str(x)] = "ready"
|
||||
return zustaende
|
||||
|
||||
|
||||
def hirn_zustand() -> dict:
|
||||
"""Wie steht Lucys Hirn (Modell mit dem Alias/der Rolle „hermes“) in llama-swap? Gezielt dieses eine Modell —
|
||||
bis 24.09.2026 galt das Hirn als bereit, sobald IRGENDEIN Modell lief (ein abgestürztes Hirn neben einem
|
||||
geladenen Coder blieb unbemerkt).
|
||||
Rückgabe {"modell": Name oder None, "zustand": "bereit" | "laedt" | "fehlt" | "unbekannt"};
|
||||
„unbekannt“ heißt: /running antwortet nicht. Trägt kein Modell die Rolle, ist modell None und zustand "fehlt"."""
|
||||
name = brain_model_name()
|
||||
zustaende = modell_zustaende()
|
||||
if zustaende is None:
|
||||
return {"modell": name, "zustand": "unbekannt"}
|
||||
zustand = zustaende.get(name or "")
|
||||
if zustand == "ready":
|
||||
return {"modell": name, "zustand": "bereit"}
|
||||
if zustand == "starting":
|
||||
return {"modell": name, "zustand": "laedt"}
|
||||
return {"modell": name, "zustand": "fehlt"}
|
||||
|
||||
|
||||
# Wie lange „Jetzt laden“ auf die Engine wartet. Große Modelle brauchen eine Weile (Lesen + Hochladen in den Speicher);
|
||||
# was danach noch lädt, meldet lade_modell als „lädt noch“ statt als Fehler.
|
||||
LADEN_WARTE_S = float(os.environ.get("MC_LADEN_WARTE_S", "90"))
|
||||
|
||||
|
||||
def _engine_grund(r: httpx.Response) -> str:
|
||||
"""Die Fehlermeldung der Engine aus ihrer Antwort (OpenAI-Format {"error": {"message"}}, {"error": "…"},
|
||||
{"detail": "…"} oder reiner Text), auf eine Zeile gekürzt."""
|
||||
text = ""
|
||||
try:
|
||||
daten = r.json()
|
||||
except ValueError:
|
||||
daten = None
|
||||
if isinstance(daten, dict):
|
||||
fehler = daten.get("error")
|
||||
if isinstance(fehler, dict):
|
||||
text = str(fehler.get("message") or "")
|
||||
elif fehler:
|
||||
text = str(fehler)
|
||||
elif daten.get("detail"):
|
||||
text = str(daten["detail"])
|
||||
text = " ".join((text or r.text or "").split())
|
||||
return text[:200] or "ohne Begründung"
|
||||
|
||||
|
||||
def lade_modell(model_id: str, warte_s: float | None = None) -> dict:
|
||||
"""Ein Modell laden und das ECHTE Ergebnis melden (seit 24.09.2026 — vorher hieß jede Antwort der Engine „ok“).
|
||||
llama-swap lädt ein Modell mit der ersten Anfrage; dafür reicht eine Mini-Anfrage mit einem Token. Ob das Modell
|
||||
danach wirklich geladen ist, entscheidet /running — so zählen auch Modelle, die gar nicht chatten (embed,
|
||||
reranker), und ein Fehler der Anfrage selbst macht ein geladenes Modell nicht zum Fehlschlag.
|
||||
Rückgabe: {"ok": True, "text": …} (bei "laedt": True lädt es nach der Wartezeit noch) oder
|
||||
{"ok": False, "detail": deutscher Grund, mit der Meldung der Engine}."""
|
||||
warte = LADEN_WARTE_S if warte_s is None else warte_s
|
||||
anfrage = {"model": model_id, "messages": [{"role": "user", "content": "ping"}], "max_tokens": 1}
|
||||
try:
|
||||
with httpx.Client(timeout=warte) as c:
|
||||
r = c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=anfrage)
|
||||
except (httpx.ConnectError, httpx.ConnectTimeout) as exc:
|
||||
return {"ok": False, "detail": f"{model_id} ließ sich nicht laden: Die Engine (llama-swap) ist nicht "
|
||||
f"erreichbar ({exc.__class__.__name__})."}
|
||||
except httpx.TimeoutException:
|
||||
if (modell_zustaende() or {}).get(model_id) == "starting":
|
||||
return {"ok": True, "laedt": True,
|
||||
"text": f"{model_id} lädt noch. Große Modelle brauchen etwas; der Stand erscheint gleich unter Modelle."}
|
||||
return {"ok": False, "detail": f"{model_id} ließ sich nicht laden: Die Engine hat nach {warte:.0f} Sekunden "
|
||||
"nicht geantwortet, und das Modell lädt auch nicht."}
|
||||
except httpx.HTTPError as exc:
|
||||
return {"ok": False, "detail": f"{model_id} ließ sich nicht laden: {exc.__class__.__name__}: {exc}"[:300]}
|
||||
if r.status_code == 200:
|
||||
return {"ok": True, "text": f"{model_id} ist geladen."}
|
||||
zustand = (modell_zustaende() or {}).get(model_id)
|
||||
if zustand == "ready":
|
||||
return {"ok": True, "text": f"{model_id} ist geladen."}
|
||||
if zustand == "starting":
|
||||
return {"ok": True, "laedt": True, "text": f"{model_id} lädt noch."}
|
||||
return {"ok": False, "detail": f"{model_id} ließ sich nicht laden. Die Engine meldet (HTTP {r.status_code}): "
|
||||
f"{_engine_grund(r)}"}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Wie selbstständig Lucy handelt — je Aktionsart (Ausbauplan Welle 3, Punkt 14, seit 25.09.2026).
|
||||
|
||||
User-Entscheid 25.09.2026 „Mehr Freiheit“: Lucy untersucht und erklärt von selbst und startet Dienste selbst neu; sie
|
||||
darf zusätzlich Sicherheitsupdates einspielen und bei Rot auf den Snapshot zurückgehen — mit Meldung danach. Andere
|
||||
Updates, das Aufräumen, Modell-Tests und den Neustart des Proxmox-Hosts gibt es erst nach dem Ja des Commanders.
|
||||
Je Aktionsart umstellbar (Einstellungen → Lucy):
|
||||
|
||||
lesen nur lesen und erklären: Sie handelt nicht und fragt nicht — außer der Commander bittet sie ausdrücklich
|
||||
fragen erst fragen: eine Rückfrage (services/rueckfragen.py); sein „ja“ löst genau diese Aktion aus
|
||||
selbst selbst handeln, Meldung danach (gehandelt())
|
||||
|
||||
Bittet der Commander Lucy im Gespräch ausdrücklich um etwas, ist das sein Ja. Die Stufen gelten für alles, was sie aus
|
||||
eigenem Antrieb tut — auch in geplanten Läufen und wenn sie einen roten Hinweis untersucht. Gelesen werden sie von
|
||||
Lucys Werkzeugen (mcp/orchestrator_werkzeuge.py) und beim Untersuchen roter Hinweise (services/ermittlung.py).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from kern.einstellungen import einstellungen
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
STUFEN = {"lesen": "Nur lesen und erklären", "fragen": "Erst fragen", "selbst": "Selbst handeln, Meldung danach"}
|
||||
STUFE_KURZ = {"lesen": "nur lesen", "fragen": "erst fragen", "selbst": "selbst handeln"}
|
||||
BETREFF_GEHANDELT = "[Lucy selbst]"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Art:
|
||||
id: str
|
||||
name: str
|
||||
text: str
|
||||
standard: str
|
||||
|
||||
|
||||
ARTEN: tuple[Art, ...] = (
|
||||
Art("ermitteln", "Rote Hinweise untersuchen",
|
||||
"Lucy liest Protokolle und Messwerte und schickt dir Ursache und Lösungsweg. Selbst: sobald ein Hinweis rot "
|
||||
"wird. Erst fragen: nach deinem Ja. Nur lesen: wenn du sie fragst.", "selbst"),
|
||||
Art("neustart", "Dienste neu starten",
|
||||
"Dienste der KI-Box (Hermes, Motor, Steuerpult …), die hängen oder abgestürzt sind. Bewusst gestoppte startet "
|
||||
"sie nie von selbst.", "selbst"),
|
||||
Art("sicherheit", "Sicherheitsupdates einspielen",
|
||||
"Nur Pakete, die eine Sicherheitslücke schließen, mit Snapshot vorher und Rückweg bei Rot. Der Proxmox-Host "
|
||||
"startet dabei nicht neu.", "selbst"),
|
||||
Art("rueckweg", "Bei Rot auf den Snapshot zurück",
|
||||
"Nur für ein Gerät, das gerade rot ist. Was seit dem Snapshot dort geschrieben wurde, geht verloren.", "selbst"),
|
||||
Art("suchen", "Nach Updates und Modellen suchen", "Fragt nur nach neuen Versionen, ändert nichts.", "selbst"),
|
||||
Art("updates", "Andere Updates einspielen",
|
||||
"Apps, Pakete, Docker, Motor, llama-swap, Hermes und „Alle aktualisieren“.", "fragen"),
|
||||
Art("aufraeumen", "Alte Kernel entfernen", "Auf dem Proxmox-Host; der laufende und der nächste Kernel bleiben.",
|
||||
"fragen"),
|
||||
Art("radar", "Modelle im Radar testen", "Ein Test belegt die KI-Box einige Minuten; Lucy antwortet dann langsamer.",
|
||||
"fragen"),
|
||||
Art("host_neustart", "Proxmox-Host neu starten",
|
||||
"Alle Container und die Arcane-VM sind ein paar Minuten weg, auch AdGuard (DNS im Heimnetz).", "fragen"),
|
||||
)
|
||||
_ARTEN = {a.id: a for a in ARTEN}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _pfad() -> Path:
|
||||
return einstellungen().daten_dir / "lucy-autonomie.json"
|
||||
|
||||
|
||||
def _gespeichert() -> dict[str, str]:
|
||||
try:
|
||||
daten = json.loads(_pfad().read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
werte = daten.get("stufen") if isinstance(daten, dict) else None
|
||||
return {k: v for k, v in (werte or {}).items() if k in _ARTEN and v in STUFEN}
|
||||
|
||||
|
||||
def stufen() -> dict[str, str]:
|
||||
return {a.id: a.standard for a in ARTEN} | _gespeichert()
|
||||
|
||||
|
||||
def stufe(art: str) -> str:
|
||||
"""Die Stufe einer Aktionsart. Eine unbekannte Art gilt als „fragen“: im Zweifel nie ungefragt."""
|
||||
return stufen().get(art, "fragen")
|
||||
|
||||
|
||||
def stand() -> dict:
|
||||
"""Für GET /api/lucy/autonomie (Einstellungen → Lucy) und Lucys Werkzeug freiheiten()."""
|
||||
aktuell = stufen()
|
||||
return {"arten": [{"id": a.id, "name": a.name, "text": a.text, "stufe": aktuell[a.id], "standard": a.standard}
|
||||
for a in ARTEN],
|
||||
"stufen": STUFEN}
|
||||
|
||||
|
||||
def setzen(neu: object) -> dict:
|
||||
"""POST /api/lucy/autonomie {"stufen": {art: stufe}} — nur bekannte Arten und Stufen. Gespeichert wird, was der
|
||||
Commander gewählt hat, auch wenn es dem Standard entspricht (ändert sich ein Standard, bleibt seine Wahl)."""
|
||||
werte = neu.get("stufen") if isinstance(neu, dict) else None
|
||||
if not isinstance(werte, dict) or not werte:
|
||||
return {"ok": False, "detail": "Es fehlt, was sich ändern soll."}
|
||||
for art, s in werte.items():
|
||||
if art not in _ARTEN:
|
||||
return {"ok": False, "detail": f"Die Aktionsart „{art}“ gibt es nicht."}
|
||||
if s not in STUFEN:
|
||||
return {"ok": False, "detail": f"„{s}“ ist keine Stufe (lesen, fragen oder selbst)."}
|
||||
with _lock:
|
||||
gespeichert = _gespeichert() | werte
|
||||
pfad = _pfad()
|
||||
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = pfad.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps({"stufen": gespeichert}, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
tmp.replace(pfad)
|
||||
teile = [f"{_ARTEN[a].name}: {STUFE_KURZ[s]}" for a, s in werte.items()]
|
||||
log.info("lucy-autonomie: %s", "; ".join(teile))
|
||||
return {"ok": True, "text": "Gespeichert — " + "; ".join(teile) + "."}
|
||||
|
||||
|
||||
def gehandelt(daten: object) -> dict:
|
||||
"""POST /api/lucy/gehandelt — die Meldung danach: Lucys Werkzeuge melden hier, was sie auf der Stufe „selbst“ aus
|
||||
eigenem Antrieb getan hat (was, warum, Ergebnis). Nachts kommt das mit der Morgenmeldung."""
|
||||
from services import rueckfragen
|
||||
|
||||
d = daten if isinstance(daten, dict) else {}
|
||||
was = " ".join(str(d.get("was") or "").split())[:300].rstrip(".")
|
||||
if not was:
|
||||
return {"ok": False, "detail": "Es fehlt, was Lucy getan hat."}
|
||||
grund = " ".join(str(d.get("grund") or "").split())[:500]
|
||||
ergebnis = " ".join(str(d.get("ergebnis") or "").split())[:500]
|
||||
text = f"{was}." + (f" Warum: {grund}" if grund else "") + (f"\n{ergebnis}" if ergebnis else "")
|
||||
rueckfragen.melden(BETREFF_GEHANDELT, text)
|
||||
log.info("lucy-autonomie: selbst gehandelt — %s", was)
|
||||
return {"ok": True, "text": "Gemeldet."}
|
||||
+247
-249
@@ -11,16 +11,31 @@ import re
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import psutil
|
||||
from kern.github import github_json
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
from services import catalog, discover, jobengine, llamaswap, system
|
||||
from services import jobengine, system, update_verlauf
|
||||
|
||||
# System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user).
|
||||
SYSTEM_SERVICES = {"llama-swap"}
|
||||
# projekte-sync/mc2-backup sind Einmal-Dienste hinter Timern: „restart“ heißt dort „jetzt
|
||||
# erneut laufen lassen“ — der Wächter bietet das als Knopf an (services/waechter.py).
|
||||
USER_SERVICES = {"mission-control-2", "mc2-gateway", "mc2-steward", "box-console",
|
||||
"hermes-gateway", "hermes-builtin-ui", "voice-service"}
|
||||
"hermes-gateway", "hermes-builtin-ui", "voice-service", "lucy-stimme",
|
||||
"projekte-sync", "mc2-backup", "mc2-radar", "mc2-morgenmeldung", "mc2-autoupdate",
|
||||
"pbs-backup", "mc2-probe-wiederherstellung"}
|
||||
|
||||
# Warum eine Update-Prüfung nicht klappte (None = geprüft). Bis 24.09.2026 verschluckten die
|
||||
# Prüfungen Netz-, API- und apt-Fehler und meldeten „kein Update“ — das Cockpit zeigte dann
|
||||
# „aktuell“, obwohl gar nicht geprüft werden konnte.
|
||||
PRUEF_FEHLER: dict[str, str | None] = {"os": None, "engine": None, "swap": None, "hermes": None}
|
||||
|
||||
# Zählt abgeschlossene Updates hoch; Zwischenspeicher (z. B. im Box-Wart-Start) vergleichen ihn,
|
||||
# damit „Aktualisieren“ direkt nach einem Update verschwindet statt erst nach 10 Minuten.
|
||||
update_stand = 0
|
||||
|
||||
# Engine-Update: lädt den neuesten Vulkan-Build (deploy/update-engine.sh, läuft als root).
|
||||
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
@@ -38,10 +53,13 @@ _engine_cache = {"ts": 0.0, "avail": False}
|
||||
_ENGINE_ASSET_RX = re.compile(r"ubuntu-vulkan-x64\.tar\.gz$", re.IGNORECASE)
|
||||
|
||||
|
||||
# GitHub-Abfragen mit 15 Minuten Zwischenspeicher (kern/github.py, seit 24.09.2026 für beide Rollen).
|
||||
_github_json = github_json
|
||||
|
||||
|
||||
def _latest_engine_asset_release() -> dict | None:
|
||||
try:
|
||||
rels = httpx.get(f"https://api.github.com/repos/{ENGINE_REPO}/releases?per_page=15",
|
||||
timeout=8, headers={"User-Agent": "MissionControl2"}).json()
|
||||
rels = _github_json(f"repos/{ENGINE_REPO}/releases?per_page=15")
|
||||
for rel in (rels if isinstance(rels, list) else []):
|
||||
if any(_ENGINE_ASSET_RX.search(a.get("name", "")) for a in (rel.get("assets") or [])):
|
||||
return rel
|
||||
@@ -84,19 +102,21 @@ def _installed_engine_build() -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
def _ram_gb() -> float:
|
||||
return psutil.virtual_memory().total / (1024 ** 3)
|
||||
|
||||
|
||||
def _os_upgradable() -> int:
|
||||
"""Wie viele Pakete `apt-get upgrade` jetzt wirklich einspielen würde (die Zeilen „Inst“ der Simulation). Nicht
|
||||
mitgezählt ist, was Ubuntu gestaffelt verteilt (phasing) oder zurückhält (kept back) — sonst stand das
|
||||
Betriebssystem nie auf „aktuell“ (25.09.2026: 5 Pakete, die das Update gar nicht einspielen durfte).
|
||||
LC_ALL=C: englische Ausgabe, damit „Inst“ auch auf einer deutschen Box so heißt."""
|
||||
try:
|
||||
# LC_ALL=C erzwingt englische apt-Ausgabe ("[upgradable from: ...]") — sonst zählt
|
||||
# grep auf einer deutschen Box ("[aktualisierbar von:]") nichts und meldet faelschlich 0.
|
||||
out = subprocess.run(
|
||||
["bash", "-c", "LC_ALL=C apt list --upgradable 2>/dev/null | grep -c upgradable || true"],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
return int((out.stdout or "0").strip() or 0)
|
||||
except Exception:
|
||||
out = subprocess.run(["bash", "-c", "LC_ALL=C apt-get -s upgrade 2>/dev/null"],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
if out.returncode != 0:
|
||||
PRUEF_FEHLER["os"] = f"Paketliste nicht lesbar (apt-get endete mit {out.returncode})"
|
||||
return 0
|
||||
PRUEF_FEHLER["os"] = None
|
||||
return sum(1 for zeile in (out.stdout or "").splitlines() if zeile.startswith("Inst "))
|
||||
except Exception as exc:
|
||||
PRUEF_FEHLER["os"] = f"Paketliste nicht lesbar ({exc.__class__.__name__})"
|
||||
return 0
|
||||
|
||||
|
||||
@@ -105,8 +125,12 @@ def _engine_update_available() -> bool:
|
||||
if now - _engine_cache["ts"] < 3600:
|
||||
return _engine_cache["avail"]
|
||||
avail = False
|
||||
fehler = None
|
||||
try:
|
||||
rel = _latest_engine_asset_release() or {}
|
||||
rel = _latest_engine_asset_release()
|
||||
if rel is None:
|
||||
fehler = "Neueste Engine-Version bei GitHub nicht abrufbar"
|
||||
rel = rel or {}
|
||||
tag = str(rel.get("tag_name", ""))
|
||||
latest = int(m.group(1)) if (m := re.search(r"(\d{3,})", tag)) else None
|
||||
installed = _installed_engine_build()
|
||||
@@ -115,8 +139,12 @@ def _engine_update_available() -> bool:
|
||||
elif rel.get("published_at"): # Fallback: Release-Datum vs. Engine-mtime
|
||||
pub = datetime.fromisoformat(rel["published_at"].replace("Z", "+00:00")).timestamp()
|
||||
avail = pub > os.path.getmtime(ENGINE_PATH) + 86400
|
||||
except Exception:
|
||||
elif fehler is None:
|
||||
fehler = "Installierte Engine-Version nicht lesbar"
|
||||
except Exception as exc:
|
||||
avail = False
|
||||
fehler = f"Engine-Prüfung gescheitert ({exc.__class__.__name__})"
|
||||
PRUEF_FEHLER["engine"] = fehler
|
||||
_engine_cache.update(ts=now, avail=avail)
|
||||
return avail
|
||||
|
||||
@@ -140,16 +168,22 @@ def _swap_update_available() -> bool:
|
||||
if now - _swap_cache["ts"] < 3600:
|
||||
return _swap_cache["avail"]
|
||||
avail = False
|
||||
fehler = None
|
||||
try:
|
||||
rel = httpx.get(f"https://api.github.com/repos/{SWAP_REPO}/releases/latest",
|
||||
timeout=6, headers={"User-Agent": "MissionControl2"}).json()
|
||||
tag = str(rel.get("tag_name", ""))
|
||||
rel = _github_json(f"repos/{SWAP_REPO}/releases/latest", timeout=6)
|
||||
tag = str(rel.get("tag_name", "")) if isinstance(rel, dict) else ""
|
||||
latest = int(m.group(1)) if (m := re.search(r"(\d{2,})", tag)) else None
|
||||
installed = _installed_swap_version()
|
||||
if latest is not None and installed is not None:
|
||||
avail = latest > installed
|
||||
except Exception:
|
||||
elif latest is None:
|
||||
fehler = "Neueste llama-swap-Version bei GitHub nicht abrufbar"
|
||||
else:
|
||||
fehler = "Installierte llama-swap-Version nicht lesbar"
|
||||
except Exception as exc:
|
||||
avail = False
|
||||
fehler = f"llama-swap-Prüfung gescheitert ({exc.__class__.__name__})"
|
||||
PRUEF_FEHLER["swap"] = fehler
|
||||
_swap_cache.update(ts=now, avail=avail)
|
||||
return avail
|
||||
|
||||
@@ -172,7 +206,7 @@ def _hermes_agent_update() -> dict:
|
||||
branch = (subprocess.run(["git", "-C", path, "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
capture_output=True, text=True, timeout=8).stdout.strip() or "main")
|
||||
fetch = subprocess.run(["git", "-C", path, "fetch", "-q", "origin", branch],
|
||||
capture_output=True, text=True, timeout=25)
|
||||
capture_output=True, text=True, timeout=60)
|
||||
info["reachable"] = (fetch.returncode == 0)
|
||||
if fetch.returncode == 0:
|
||||
cnt = subprocess.run(["git", "-C", path, "rev-list", "--count", f"HEAD..origin/{branch}"],
|
||||
@@ -194,109 +228,12 @@ def _components_cached() -> list[dict]:
|
||||
if now - _comp_cache["ts"] < 3600 and _comp_cache["data"]:
|
||||
return _comp_cache["data"]
|
||||
data = [_hermes_agent_update()]
|
||||
PRUEF_FEHLER["hermes"] = ("Hermes-Quelle nicht erreichbar (git fetch)"
|
||||
if data[0].get("reachable") is False else None)
|
||||
_comp_cache.update(ts=now, data=data)
|
||||
return data
|
||||
|
||||
|
||||
def _params_of(m: dict) -> float:
|
||||
"""Größen-bewusste Parameterzahl eines installierten Modells: max aus Namens-Schätzung
|
||||
und Dateigröße (fängt namenlose wie 'Qwen3-Coder-Next' UND Split-GGUFs ab)."""
|
||||
from services.fit import QUANT_BYTES_PER_PARAM
|
||||
caps = m.get("capabilities") or {}
|
||||
bpp = QUANT_BYTES_PER_PARAM.get((m.get("quant") or "Q4_K_M").upper(), 0.55)
|
||||
size_gb = (m.get("size_bytes") or 0) / (1024 ** 3)
|
||||
pb_size = (size_gb / bpp) if size_gb > 1.0 else 0.0
|
||||
return max(float(caps.get("params_b") or 0), pb_size, 0.0)
|
||||
|
||||
|
||||
# Familien-Subtyp + Generations-Version aus dem Modellnamen (für „echtes Upgrade?").
|
||||
_FAM_PATS = (("qwen", r"qwen(\d+(?:\.\d+)?)"), ("gemma", r"gemma[-_ ]?(\d+(?:\.\d+)?)"),
|
||||
("llama", r"llama[-_ ]?(\d+(?:\.\d+)?)"), ("phi", r"phi[-_ ]?(\d+(?:\.\d+)?)"),
|
||||
("mistral", r"mistral"), ("hermes", r"hermes[-_ ]?(\d+(?:\.\d+)?)"))
|
||||
|
||||
|
||||
def _gen_key(name: str):
|
||||
"""(Familie+Subtyp, Generations-Version) oder None. Z.B. 'Qwen3-VL-2B' → ('qwen-vl', 3.0),
|
||||
'Qwen2.5-VL-7B' → ('qwen-vl', 2.5). Nur gleiche Familie ist sinnvoll vergleichbar."""
|
||||
low = (name or "").lower()
|
||||
sub = "-vl" if any(k in low for k in ("-vl", "vl-", "vision", "llava", "pixtral")) else \
|
||||
"-coder" if ("coder" in low or "-code" in low) else ""
|
||||
for fam, pat in _FAM_PATS:
|
||||
m = re.search(pat, low)
|
||||
if m:
|
||||
ver = float(m.group(1)) if (m.groups() and m.group(1)) else 0.0
|
||||
return (fam + sub, ver)
|
||||
return None
|
||||
|
||||
|
||||
def _meta(name: str, model_dict: dict | None = None, im: dict | None = None) -> dict:
|
||||
"""Metadaten (family, gen, total, active, moe) — bevorzugt den kuratierten Katalog,
|
||||
sonst die Felder eines Discover-/Modell-Dicts, sonst Namens-/Größen-Heuristik."""
|
||||
cm = catalog.meta_for_name(name)
|
||||
if cm:
|
||||
return {"family": cm.get("family"), "gen": cm.get("generation"),
|
||||
"total": float(cm.get("total_params_b") or 0),
|
||||
"active": cm.get("active_params_b"), "moe": bool(cm.get("moe"))}
|
||||
d = model_dict or {}
|
||||
g = _gen_key(name)
|
||||
total = float(d.get("params_b") or 0) or (_params_of(im) if im else 0.0)
|
||||
return {"family": (d.get("family") or (g[0] if g else None)),
|
||||
"gen": (d.get("generation") if d.get("generation") is not None else (g[1] if g else None)),
|
||||
"total": total, "active": d.get("active_b"), "moe": bool(d.get("moe"))}
|
||||
|
||||
|
||||
def model_upgrades() -> list[dict]:
|
||||
"""Je Rolle ein ECHTES Upgrade — nur wenn die Empfehlung wirklich besser ist:
|
||||
gleiche Familie UND (neuere Generation ODER deutlich größer) UND kein Tempo-Downgrade
|
||||
(MoE-first für die bandbreiten-limitierte Box: dense ersetzt MoE nur bei großem Wissens-
|
||||
Sprung). Metadaten kommen aus dem kuratierten Katalog → keine Namens-Raterei."""
|
||||
disc = discover.safe_discover(_ram_gb())
|
||||
if not disc:
|
||||
return []
|
||||
|
||||
installed = llamaswap.list_models()
|
||||
inst_by_role = {m["role"]: m for m in installed if m.get("role")}
|
||||
cmds = " ".join(str(s.get("cmd", "")).lower()
|
||||
for s in (llamaswap.read_config().get("models") or {}).values())
|
||||
out = []
|
||||
|
||||
for c in disc.get("categories", []):
|
||||
role = c["role"]
|
||||
im = inst_by_role.get(role)
|
||||
if im is None:
|
||||
continue
|
||||
rec = c.get("recommended")
|
||||
if not rec:
|
||||
continue
|
||||
rec_model = next((x for x in c.get("models", []) if x.get("repo") == rec), None)
|
||||
|
||||
i = _meta(im["name"], im=im)
|
||||
r = _meta(rec, model_dict=rec_model)
|
||||
|
||||
if not i["family"] or not r["family"] or i["family"] != r["family"]:
|
||||
continue # andere/unbekannte Familie → kein Upgrade
|
||||
if r["gen"] is not None and i["gen"] is not None and r["gen"] < i["gen"] - 1e-6:
|
||||
continue # ältere Generation → niemals
|
||||
same_gen = (r["gen"] is None or i["gen"] is None or abs(r["gen"] - i["gen"]) < 1e-6)
|
||||
if same_gen:
|
||||
if r["total"] and i["total"] and r["total"] < i["total"] * 1.05:
|
||||
continue # gleiche Gen, nicht größer → kein Upgrade
|
||||
# MoE-first: ein MoE durch dense ersetzen nur bei deutlichem Wissens-Sprung
|
||||
if i["moe"] and not r["moe"] and r["total"] < i["total"] * 1.5:
|
||||
continue
|
||||
# Tempo nicht verschlechtern (aktive Params), außer großer Wissens-Gewinn
|
||||
ia, ra = (i["active"] or i["total"]), (r["active"] or r["total"])
|
||||
if ia and ra > ia * 1.3 and r["total"] < i["total"] * 1.3:
|
||||
continue
|
||||
|
||||
base = rec.split("/")[-1].lower()
|
||||
stem = base.removesuffix("-gguf")
|
||||
if base in cmds or (stem and stem in cmds):
|
||||
continue # schon installiert
|
||||
out.append({"role": role, "title": c["title"], "repo": rec})
|
||||
return out
|
||||
|
||||
|
||||
def _last_apt_update() -> float | None:
|
||||
for path in ["/var/lib/apt/periodic/update-success-stamp", "/var/cache/apt/pkgcache.bin"]:
|
||||
if os.path.exists(path):
|
||||
@@ -308,11 +245,58 @@ def _last_apt_update() -> float | None:
|
||||
|
||||
|
||||
def updates() -> dict:
|
||||
ups = model_upgrades()
|
||||
return {"os": _os_upgradable(), "engine": 1 if _engine_update_available() else 0,
|
||||
"""Offene Updates je Baustein. `pruef_fehler` nennt je Baustein, warum nicht geprüft werden
|
||||
konnte (None = geprüft). Die frühere Modell-Upgrade-Empfehlung ist raus (24.09.2026): das
|
||||
Modell-Radar hat die Aufgabe übernommen, und die Oberfläche zeigte sie nicht mehr."""
|
||||
daten = {"os": _os_upgradable(), "engine": 1 if _engine_update_available() else 0,
|
||||
"swap": 1 if _swap_update_available() else 0,
|
||||
"models": len(ups), "model_list": ups, "last_check": _last_apt_update(),
|
||||
"components": _components_cached()}
|
||||
"last_check": _last_apt_update(), "components": _components_cached()}
|
||||
daten["pruef_fehler"] = dict(PRUEF_FEHLER)
|
||||
return daten
|
||||
|
||||
|
||||
# „════════ [2/3] Router (llama-swap) ════════" — so markiert update_all_job jeden Teil der Kette.
|
||||
_TEIL_MARKE = re.compile(r"^═+ \[(\d+)/(\d+)\] ")
|
||||
|
||||
|
||||
@jobengine.abschluss("wartung:verlauf")
|
||||
def _update_im_verlauf(job: dict) -> None:
|
||||
"""Updates per Knopf landen im strukturierten Update-Verlauf (seit 24.09.2026). Vorher sah man sie
|
||||
dort gar nicht: Nur der Sonntags-Lauf schrieb Meldungen, aus denen der Verlauf gelesen wurde."""
|
||||
bausteine = list((job.get("abschluss_daten") or {}).get("bausteine") or [])
|
||||
if not bausteine:
|
||||
return
|
||||
lauf = datetime.fromtimestamp(job["started_at"], LOCAL_TZ).isoformat(timespec="seconds")
|
||||
zustand, code = job.get("state"), job.get("returncode")
|
||||
# Scheitert „Alle aktualisieren“, zeigt die letzte Teil-Marke im Protokoll, wo die Kette stand.
|
||||
erreicht = len(bausteine)
|
||||
if zustand != "done" and len(bausteine) > 1:
|
||||
marken = [int(m.group(1)) for z in jobengine.protokoll(job["id"]) if (m := _TEIL_MARKE.match(z.strip()))]
|
||||
erreicht = marken[-1] if marken else 1
|
||||
for nr, baustein in enumerate(bausteine, start=1):
|
||||
if zustand == "done" or nr < erreicht:
|
||||
ergebnis, text = "eingespielt", "Per Knopf eingespielt, Prüfung grün."
|
||||
elif nr > erreicht:
|
||||
ergebnis, text = "offen", "Nicht mehr gelaufen, weil ein Teil davor scheiterte."
|
||||
elif zustand == "canceled":
|
||||
ergebnis, text = "offen", "Abgebrochen."
|
||||
elif job.get("zeitlimit"):
|
||||
ergebnis, text = "fehler", "Zeitlimit überschritten."
|
||||
elif baustein in ("engine", "swap") and code == 1:
|
||||
ergebnis, text = "zurueckgerollt", "Prüfung rot, automatisch zurückgerollt."
|
||||
else:
|
||||
ergebnis, text = "fehler", f"Fehlgeschlagen (Exit {code}). Das Protokoll steht beim Auftrag."
|
||||
update_verlauf.eintragen(lauf, "Update von Hand", baustein, ergebnis, text)
|
||||
|
||||
|
||||
@jobengine.nacharbeit("wartung:nach_update")
|
||||
def _nach_update() -> None:
|
||||
"""Nach einem abgeschlossenen Update: Zwischenspeicher leeren und den Stand hochzählen."""
|
||||
global update_stand
|
||||
_engine_cache.update(ts=0.0, avail=False)
|
||||
_swap_cache.update(ts=0.0, avail=False)
|
||||
_comp_cache.update(ts=0.0, data=[])
|
||||
update_stand += 1
|
||||
|
||||
|
||||
# ── Update-Details (was genau wird aktualisiert) — on-demand beim Öffnen des Fensters ──
|
||||
@@ -332,7 +316,7 @@ def _os_held_back() -> list[dict]:
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["bash", "-c", "LC_ALL=C apt-get -s upgrade 2>/dev/null"],
|
||||
capture_output=True, text=True, timeout=25)
|
||||
capture_output=True, text=True, timeout=60)
|
||||
reason: str | None = None
|
||||
for line in (out.stdout or "").splitlines():
|
||||
hdr = sections.get(line.strip())
|
||||
@@ -367,8 +351,11 @@ def os_update_details() -> dict:
|
||||
out_pkgs.append({"name": m.group(1), "candidate": m.group(2),
|
||||
"current": m.group(3).strip()})
|
||||
out_pkgs.sort(key=lambda p: p["name"])
|
||||
return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs,
|
||||
"held_back": _os_held_back()}
|
||||
# Was Ubuntu zurückhält, spielt das Update nicht ein — es steht getrennt da, nicht in der Liste und der Zahl.
|
||||
held = _os_held_back()
|
||||
zurueck = {p["name"] for p in held}
|
||||
out_pkgs = [p for p in out_pkgs if p["name"] not in zurueck]
|
||||
return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs, "held_back": held}
|
||||
except Exception as exc:
|
||||
return {"kind": "os", "count": len(out_pkgs), "packages": out_pkgs, "error": str(exc)}
|
||||
|
||||
@@ -405,8 +392,7 @@ def swap_update_details() -> dict:
|
||||
"latest_build": None, "latest_tag": None, "name": None,
|
||||
"url": None, "body": None}
|
||||
try:
|
||||
rel = httpx.get(f"https://api.github.com/repos/{SWAP_REPO}/releases/latest",
|
||||
timeout=8, headers={"User-Agent": "MissionControl2"}).json()
|
||||
rel = _github_json(f"repos/{SWAP_REPO}/releases/latest")
|
||||
tag = str(rel.get("tag_name", ""))
|
||||
info["latest_tag"] = tag
|
||||
info["latest_build"] = int(m.group(1)) if (m := re.search(r"(\d{2,})", tag)) else None
|
||||
@@ -505,11 +491,32 @@ def _summarize_release(kind: str, key: str, context: str, changes: str) -> dict:
|
||||
def _summarize_hermes_commits(commits: list[dict]) -> dict:
|
||||
subjects = "\n".join(f"- {c['subject']}" for c in commits[:100])
|
||||
context = ("Es geht um ein Update des Hermes-Agenten (das Gehirn/Werkzeug-System der Box auf "
|
||||
"Strix Halo; genutzt werden: api_server/Gateway, memory-provider-Plugin 'mc2-memory', "
|
||||
"terminal-/web-Tools, approvals, cron).")
|
||||
"Strix Halo; genutzt werden: api_server/Gateway, Telegram, eigenes Gedächtnis, "
|
||||
"terminal-/web-Tools, Plugins, cron).")
|
||||
return _summarize_release("hermes", commits[0]["hash"] if commits else "", context, subjects)
|
||||
|
||||
|
||||
def _hermes_version(path: str) -> str | None:
|
||||
"""Versionsnummer des Hermes-Checkouts: bis 0.21.x aus der pyproject.toml (z. B. „0.21.3“). Seit dem neuen
|
||||
Paketmanager (Update vom 25.09.2026) steht dort „0.0.0“, und die Veröffentlichungen heißen nach dem Datum
|
||||
(v2026.8.31) — dann das Datum des ausgecheckten Commits im selben Stil („2026.9.25“)."""
|
||||
try:
|
||||
text = (Path(path) / "pyproject.toml").read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
m = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
|
||||
if m and m.group(1) != "0.0.0":
|
||||
return m.group(1)
|
||||
try:
|
||||
datum = subprocess.run(["git", "-C", path, "log", "-1", "--format=%cs"], capture_output=True, text=True,
|
||||
timeout=5).stdout.strip()
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
datum = ""
|
||||
if re.fullmatch(r"\d{4}-\d{2}-\d{2}", datum):
|
||||
return ".".join(str(int(t)) for t in datum.split("-"))
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def hermes_update_details() -> dict:
|
||||
"""Commits, die ein Hermes-Update einspielen würde (HEAD..origin/<branch>) + LLM-Zusammenfassung."""
|
||||
info: dict = {"kind": "hermes", "branch": None, "behind": 0, "commits": []}
|
||||
@@ -518,12 +525,14 @@ def hermes_update_details() -> dict:
|
||||
info["error"] = "Hermes-Agent-Repo nicht gefunden."
|
||||
return info
|
||||
path = git["path"]
|
||||
# Vor dem Abruf: scheitert der (Zeitlimit, Netz), zeigt die Seite trotzdem die laufende Version.
|
||||
info["installed_version"] = _hermes_version(path)
|
||||
try:
|
||||
branch = (subprocess.run(["git", "-C", path, "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
capture_output=True, text=True, timeout=8).stdout.strip() or "main")
|
||||
info["branch"] = branch
|
||||
subprocess.run(["git", "-C", path, "fetch", "-q", "origin", branch],
|
||||
capture_output=True, text=True, timeout=25)
|
||||
capture_output=True, text=True, timeout=60)
|
||||
log = subprocess.run(["git", "-C", path, "log", "--pretty=format:%h\x1f%s\x1f%cr",
|
||||
f"HEAD..origin/{branch}"], capture_output=True, text=True, timeout=10)
|
||||
commits = []
|
||||
@@ -546,59 +555,59 @@ def update_details(kind: str) -> dict:
|
||||
"hermes": hermes_update_details}.get(kind, lambda: {"error": "unbekannt"})()
|
||||
|
||||
|
||||
def _run(cmd: list[str], sudo_password: str | None = None) -> dict:
|
||||
actual_cmd = list(cmd)
|
||||
has_sudo = False
|
||||
def _run(cmd: list[str]) -> dict:
|
||||
"""Befehl ausfuehren. sudo laeuft IMMER mit `-n` (never prompt).
|
||||
|
||||
v3-Umbau P1 (28.08.2026): Frueher konnte hier ein Sudo-Passwort durchgereicht und per
|
||||
`-S` an stdin gefuettert werden — das Passwort kam aus dem `localStorage` des Browsers
|
||||
und reiste bei jedem mutierenden Request mit. Auf der Box gemessen: `sudo -n true`
|
||||
laeuft durch, weil `/etc/sudoers` den Dienst-Nutzer mit `NOPASSWD: ALL` fuehrt. Der
|
||||
ganze Pfad war also totes Risiko ohne Gegenwert.
|
||||
|
||||
`-n` heisst: Braucht sudo je doch ein Passwort, scheitert der Befehl SOFORT und sauber,
|
||||
statt auf eine Eingabe zu warten, die es hier nicht gibt. Das meldet die Funktion
|
||||
darunter als `password_required` — ein ehrliches Konfigurations-Signal, das man auf der
|
||||
Box loest und nicht mit einem im Browser geparkten Geheimnis uebertuencht."""
|
||||
actual_cmd = list(cmd)
|
||||
if cmd and cmd[0] == "sudo":
|
||||
has_sudo = True
|
||||
# If we have a password, use -S instead of -n
|
||||
if sudo_password is not None:
|
||||
if "-n" in actual_cmd:
|
||||
actual_cmd = [x for x in actual_cmd if x != "-n"]
|
||||
if "-S" not in actual_cmd:
|
||||
actual_cmd.insert(1, "-S")
|
||||
else:
|
||||
# Force -n to fail cleanly if password is required
|
||||
if "-S" in actual_cmd:
|
||||
actual_cmd = [x for x in actual_cmd if x != "-S"]
|
||||
if "-n" not in actual_cmd:
|
||||
actual_cmd.insert(1, "-n")
|
||||
|
||||
try:
|
||||
input_data = (sudo_password + "\n") if (has_sudo and sudo_password is not None) else None
|
||||
p = subprocess.run(actual_cmd, input=input_data, capture_output=True, text=True, timeout=120)
|
||||
p = subprocess.run(actual_cmd, capture_output=True, text=True, timeout=120)
|
||||
|
||||
err_msg = p.stderr or ""
|
||||
if p.returncode != 0 and ("a password is required" in err_msg or "password" in err_msg.lower() or "sudo:" in err_msg):
|
||||
if sudo_password is not None:
|
||||
return {"ok": False, "status": "incorrect_password", "out": p.stdout or "", "err": "Falsches Sudo-Passwort."}
|
||||
return {"ok": False, "status": "password_required", "out": p.stdout or "", "err": "Sudo-Passwort erforderlich."}
|
||||
return {"ok": False, "status": "password_required", "out": p.stdout or "",
|
||||
"err": "sudo verlangt hier ein Passwort. Das ist eine Konfigurations-Frage "
|
||||
"auf der Box (sudoers), nichts, was die Oberflaeche liefern kann."}
|
||||
|
||||
return {"ok": p.returncode == 0, "out": (p.stdout or "")[-4000:], "err": (p.stderr or "")[-2000:]}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "out": "", "err": str(exc)}
|
||||
|
||||
|
||||
def check_sudo_needs_password(sudo_password: str | None = None) -> dict | None:
|
||||
"""Checks if sudo needs a password. Returns error dict if password required/incorrect, else None."""
|
||||
res = _run(["sudo", "true"], sudo_password=sudo_password)
|
||||
def check_sudo_needs_password() -> dict | None:
|
||||
"""Laeuft sudo hier passwortlos? Fehler-Dict wenn nein, sonst None."""
|
||||
res = _run(["sudo", "true"])
|
||||
if not res["ok"]:
|
||||
return res
|
||||
return None
|
||||
|
||||
|
||||
def restart_service(name: str, sudo_password: str | None = None) -> dict:
|
||||
def restart_service(name: str) -> dict:
|
||||
if name in SYSTEM_SERVICES:
|
||||
if err := check_sudo_needs_password(sudo_password):
|
||||
if err := check_sudo_needs_password():
|
||||
return err
|
||||
return _run(["sudo", "systemctl", "restart", name], sudo_password=sudo_password)
|
||||
return _run(["sudo", "systemctl", "restart", name])
|
||||
if name in USER_SERVICES:
|
||||
return _run(["systemctl", "--user", "restart", name])
|
||||
return {"ok": False, "err": f"Dienst '{name}' nicht erlaubt."}
|
||||
|
||||
|
||||
def logs(service: str, lines: int = 200, sudo_password: str | None = None) -> dict:
|
||||
def logs(service: str, lines: int = 200) -> dict:
|
||||
lines = max(1, min(lines, 1000))
|
||||
if service in USER_SERVICES:
|
||||
r = _run(["journalctl", "--user", "-u", service, "-n", str(lines), "--no-pager"])
|
||||
@@ -609,38 +618,37 @@ def logs(service: str, lines: int = 200, sudo_password: str | None = None) -> di
|
||||
r = _run(["journalctl", "-u", service, "-n", str(lines), "--no-pager"])
|
||||
if r["ok"]:
|
||||
return {"ok": True, "text": r["out"] or "(keine Log-Einträge)"}
|
||||
if err := check_sudo_needs_password(sudo_password):
|
||||
if err := check_sudo_needs_password():
|
||||
return err
|
||||
r = _run(["sudo", "journalctl", "-u", service, "-n", str(lines), "--no-pager"],
|
||||
sudo_password=sudo_password)
|
||||
r = _run(["sudo", "journalctl", "-u", service, "-n", str(lines), "--no-pager"])
|
||||
return {"ok": r["ok"], "text": r["out"] or r["err"]}
|
||||
return {"ok": False, "text": "", "err": "Dienst nicht erlaubt."}
|
||||
|
||||
def check_updates_job(sudo_password: str | None = None) -> dict:
|
||||
if err := check_sudo_needs_password(sudo_password):
|
||||
return err
|
||||
|
||||
def on_done():
|
||||
_engine_cache.update(ts=0.0, avail=False)
|
||||
_comp_cache.update(ts=0.0, data=[]) # Hermes-Status ebenfalls neu berechnen lassen
|
||||
|
||||
cmd = "sudo apt-get update"
|
||||
job_id = jobengine.start_job(["bash", "-c", cmd], "Nach Updates suchen", on_done=on_done, sudo_password=sudo_password)
|
||||
def _starten(args: list[str], label: str, zeitlimit_s: int | None = None,
|
||||
bausteine: list[str] | None = None) -> dict:
|
||||
"""Wartungs-Riegel: nur EIN binär-/dienst-veränderndes Job gleichzeitig. Prüfen und Eintragen
|
||||
passieren unter einer Sperre (jobengine.start_job_exklusiv) — vorher lag zwischen Prüfung und
|
||||
Start ein langsamer Aufruf, und zwei Klicks konnten zwei Updates starten."""
|
||||
job_id, laeuft = jobengine.start_job_exklusiv(
|
||||
"maintenance", args, label, zeitlimit_s=zeitlimit_s, nacharbeit="wartung:nach_update",
|
||||
abschluss="wartung:verlauf" if bausteine else None, abschluss_daten={"bausteine": bausteine or []})
|
||||
if job_id is None:
|
||||
return {"ok": False, "status": "busy", "running": (laeuft or {}).get("label"),
|
||||
"detail": f"Es läuft schon: {(laeuft or {}).get('label', 'ein Update')}."}
|
||||
return {"ok": True, "job_id": job_id}
|
||||
|
||||
|
||||
def _maintenance_busy() -> dict | None:
|
||||
"""Wartungs-Riegel: nur EIN binär-/dienst-veränderndes Update gleichzeitig. Verhindert
|
||||
Doppelklick UND parallele Updates aus zwei Tabs/Sessions (racende .bak-Sicherung/Restarts)."""
|
||||
if j := jobengine.active_in_group("maintenance"):
|
||||
return {"ok": False, "status": "busy", "running": j.get("label")}
|
||||
return None
|
||||
def check_updates_job() -> dict:
|
||||
if err := check_sudo_needs_password():
|
||||
return err
|
||||
# apt-get update gehört in den Wartungs-Riegel: parallel zu einem apt upgrade kollidiert es mit
|
||||
# der dpkg-Sperre. In der Gruppe taucht der Job auch in der Oberfläche auf.
|
||||
return _starten(["bash", "-c", "sudo apt-get update"], "Nach Updates suchen",
|
||||
zeitlimit_s=15 * 60)
|
||||
|
||||
|
||||
def os_update_job(sudo_password: str | None = None) -> dict:
|
||||
if busy := _maintenance_busy():
|
||||
return busy
|
||||
if err := check_sudo_needs_password(sudo_password):
|
||||
def os_update_job() -> dict:
|
||||
if err := check_sudo_needs_password():
|
||||
return err
|
||||
# Nach dem apt-Upgrade den Stack funktional prüfen (Job wird rot, wenn etwas kaputt ging).
|
||||
# DEBIAN_FRONTEND wird INNERHALB von `sudo bash -c` gesetzt (nicht als `sudo VAR=… cmd`) —
|
||||
@@ -648,49 +656,26 @@ def os_update_job(sudo_password: str | None = None) -> dict:
|
||||
cmd = ("sudo apt-get update && "
|
||||
"sudo bash -c 'DEBIAN_FRONTEND=noninteractive apt-get upgrade -y' "
|
||||
f"&& bash {STACK_POSTCHECK}")
|
||||
job_id = jobengine.start_job(["bash", "-c", cmd], "OS-Update (apt)",
|
||||
group="maintenance", sudo_password=sudo_password)
|
||||
return {"ok": True, "job_id": job_id}
|
||||
return _starten(["bash", "-c", cmd], "OS-Update (apt)", zeitlimit_s=90 * 60, bausteine=["os"])
|
||||
|
||||
|
||||
def engine_update_job(sudo_password: str | None = None) -> dict | None:
|
||||
def engine_update_job() -> dict | None:
|
||||
if not ENGINE_UPDATE_CMD:
|
||||
return None
|
||||
if busy := _maintenance_busy():
|
||||
return busy
|
||||
# KEIN sudo-Passwort-Gate: update-engine.sh ist per sudoers NOPASSWD freigegeben
|
||||
# (sudoers-mc2-autonomie) und läuft passwortlos. Das frühere `sudo true`-Gate hat das
|
||||
# Update fälschlich blockiert, wenn (noch) kein Box-Passwort hinterlegt war.
|
||||
|
||||
def on_done():
|
||||
_engine_cache.update(ts=0.0, avail=False) # Cache leeren → frischer Build-Vergleich
|
||||
|
||||
# update-engine.sh sichert den alten Build, aktualisiert, startet llama-swap neu, prüft den
|
||||
# Stack (stack-postcheck.sh) und rollt bei Fehler selbst zurück. Exit 0 nur bei verifiziertem
|
||||
# neuen Build → on_done (Cache leeren) läuft nur dann; bei Rollback (Exit 1/2) bleibt das Badge.
|
||||
job_id = jobengine.start_job(["bash", "-c", ENGINE_UPDATE_CMD],
|
||||
"Engine-Update (llama.cpp Vulkan)",
|
||||
group="maintenance", on_done=on_done)
|
||||
return {"ok": True, "job_id": job_id}
|
||||
# neuen Build. KEIN sudo-Passwort-Gate: das Skript ist per sudoers NOPASSWD freigegeben.
|
||||
return _starten(["bash", "-c", ENGINE_UPDATE_CMD], "Engine-Update (llama.cpp Vulkan)",
|
||||
zeitlimit_s=60 * 60, bausteine=["engine"])
|
||||
|
||||
|
||||
def swap_update_job(sudo_password: str | None = None) -> dict | None:
|
||||
def swap_update_job() -> dict | None:
|
||||
if not SWAP_UPDATE_CMD:
|
||||
return None
|
||||
if busy := _maintenance_busy():
|
||||
return busy
|
||||
# KEIN sudo-Passwort-Gate: update-swap.sh ist per sudoers NOPASSWD freigegeben (wie die Engine).
|
||||
|
||||
def on_done():
|
||||
_swap_cache.update(ts=0.0, avail=False) # Cache leeren → frischer Versions-Vergleich
|
||||
|
||||
# update-swap.sh sichert die alte Binary, aktualisiert, startet llama-swap neu, prüft den Stack
|
||||
# (stack-postcheck.sh) und rollt bei Fehler selbst zurück. Exit 0 nur bei verifizierter neuer
|
||||
# Version → on_done (Cache leeren) läuft nur dann; bei Rollback (Exit 1/2) bleibt das Badge.
|
||||
job_id = jobengine.start_job(["bash", "-c", SWAP_UPDATE_CMD],
|
||||
"Router-Update (llama-swap)",
|
||||
group="maintenance", on_done=on_done)
|
||||
return {"ok": True, "job_id": job_id}
|
||||
# und rollt bei Fehler selbst zurück. Exit 0 nur bei verifizierter neuer Version.
|
||||
return _starten(["bash", "-c", SWAP_UPDATE_CMD], "Router-Update (llama-swap)",
|
||||
zeitlimit_s=30 * 60, bausteine=["swap"])
|
||||
|
||||
|
||||
def _hermes_update_cmd() -> str:
|
||||
@@ -718,55 +703,77 @@ def _hermes_update_cmd() -> str:
|
||||
# grün sein. Früher schluckte das `;` vor dem Neustart jeden Update-Fehler: Job rc=0,
|
||||
# Badge blieb, User sah „done aber nichts passiert" (Update scheiterte real an einem
|
||||
# verwaisten .git/index.lock). Jetzt: RC festhalten, Dienste immer starten, RC melden.
|
||||
# --no-gateway-restart (17.09.2026): `hermes update` startet das Gateway sonst selbst neu und
|
||||
# endet mit Exit 1, wenn sein Fleet-Check danach keine Zeilen sieht (#93406) — unter systemd
|
||||
# bei uns der Normalfall. Die &&-Kette brach ab (kein doctor, kein /hermes-ui/-Build), der
|
||||
# Job war rot, autoupdate.sh rollte zurück und pinnte — bei GRÜNEM Gehirn-Check (06./17.09.).
|
||||
# Der Neustart passiert unten ohnehin; Richter bleibt der Postcheck.
|
||||
# Seit dem neuen Paketmanager von Hermes (Update vom 25.09.2026):
|
||||
# • Hermes startet über seinen eigenen Starter (.hermes/bin/hermes, eigene Umgebung mit Python 3.14); das alte
|
||||
# venv bleibt nur für den Rückweg. Der Updater schreibt dabei die Unit neu → daemon-reload vor dem Neustart,
|
||||
# sonst lief das Gateway weiter aus dem alten venv (neuer Code, alte Pakete).
|
||||
# • python-olm (Matrix-Extra) baut nur mit gcc (das mitgelieferte Python ist mit clang gebaut) und mit der
|
||||
# CMake-Kompatibilität für CMake 4.
|
||||
# • `doctor` endet mit 1, sobald es Hinweise gibt (fehlende optionale API-Schlüssel) — das ist kein Fehler.
|
||||
# • hermes-plugin-deps.sh legt die Pakete unserer Plugins (trafilatura für mc2-web-lesen) nach.
|
||||
starter = os.path.join(path, ".hermes", "bin", "hermes")
|
||||
hermes = starter if os.path.exists(starter) else f"{py} -m hermes_cli.main"
|
||||
bauen = "CC=gcc CXX=g++ CMAKE_POLICY_VERSION_MINIMUM=3.5"
|
||||
plugin_deps = os.path.join(_REPO_ROOT, "deploy", "hermes-plugin-deps.sh")
|
||||
return ("RC=0; "
|
||||
f"ALT=$(git -C {path} rev-parse HEAD 2>/dev/null); "
|
||||
f"bash {backup} || true; "
|
||||
f"systemctl --user stop hermes-builtin-ui || true; "
|
||||
f"{{ cd {path} && {py} -m hermes_cli.main update --yes "
|
||||
f"&& {py} -m hermes_cli.main doctor "
|
||||
f"{{ cd {path} && {bauen} {hermes} update --yes --no-gateway-restart "
|
||||
f"&& {{ {bauen} {hermes} doctor || echo '(doctor meldet Hinweise — Richter bleibt der Gehirn-Check)'; }} "
|
||||
f"&& {build_cmd}; }} || RC=1; "
|
||||
f"bash {plugin_deps} || true; "
|
||||
f"systemctl --user daemon-reload; "
|
||||
f"systemctl --user reset-failed hermes-builtin-ui 2>/dev/null; "
|
||||
f"systemctl --user restart hermes-gateway hermes-builtin-ui || RC=1; "
|
||||
f"sleep 6; bash {postcheck} || RC=1; "
|
||||
f"if [ $RC -ne 0 ]; then echo '✗ HERMES-UPDATE FEHLGESCHLAGEN (Dienste laufen wieder, aber alter Stand)'; fi; "
|
||||
f"if [ $RC -ne 0 ]; then if [ \"$(git -C {path} rev-parse HEAD 2>/dev/null)\" != \"$ALT\" ]; then "
|
||||
f"echo '✗ HERMES-UPDATE UNVOLLSTÄNDIG (neuer Code, der Rest scheiterte — Dienste laufen, Protokoll prüfen)'; "
|
||||
f"else echo '✗ HERMES-UPDATE FEHLGESCHLAGEN (Dienste laufen wieder, aber alter Stand)'; fi; fi; "
|
||||
f"exit $RC")
|
||||
|
||||
|
||||
def hermes_update_job() -> dict:
|
||||
def hermes_update_job(verlauf: bool = True) -> dict:
|
||||
"""Hermes-Agent aktualisieren wie die CLI (`hermes update` = git pull + Deps), danach
|
||||
den Gateway neu starten. Davor ein Sicherheits-Backup (unser deploy/backup.sh). Kein sudo
|
||||
(alles im User-Space). Läuft als Hintergrund-Job (kann ~1 Min dauern)."""
|
||||
if busy := _maintenance_busy():
|
||||
return busy
|
||||
|
||||
def on_done():
|
||||
_comp_cache.update(ts=0.0, data=[]) # Update-Status neu berechnen lassen
|
||||
|
||||
job_id = jobengine.start_job(["bash", "-c", _hermes_update_cmd()], "Hermes-Agent-Update",
|
||||
group="maintenance", on_done=on_done)
|
||||
return {"ok": True, "job_id": job_id}
|
||||
(alles im User-Space). Läuft als Hintergrund-Job (kann einige Minuten dauern).
|
||||
verlauf=False: autoupdate.sh startet den Job und trägt das Ergebnis selbst in seinen Lauf ein."""
|
||||
return _starten(["bash", "-c", _hermes_update_cmd()], "Hermes-Agent-Update",
|
||||
zeitlimit_s=45 * 60, bausteine=["hermes"] if verlauf else None)
|
||||
|
||||
|
||||
def update_all_job(sudo_password: str | None = None) -> dict:
|
||||
def update_all_job() -> dict:
|
||||
"""„Alle aktualisieren": kettet die AUSSTEHENDEN Updates sequenziell in EINEM Job —
|
||||
Engine → Router → Hermes → OS. Bewusst nur Wiederverwendung: jeder Teil ist exakt der
|
||||
Befehl des Einzel-Updates (mit eigenem Backup/Postcheck/Rollback). `&&`-Kette = bei
|
||||
Fehler stoppt der Rest (das Log zeigt, wo). OS zuletzt, weil apt am breitesten eingreift;
|
||||
es braucht als einziges das Box-Passwort — fehlt es, laufen die sudo-freien Teile trotzdem."""
|
||||
if busy := _maintenance_busy():
|
||||
return busy
|
||||
es ist das einzige mit sudo — scheitert das, laufen die sudo-freien Teile trotzdem."""
|
||||
if laeuft := jobengine.active_in_group("maintenance"):
|
||||
return {"ok": False, "status": "busy", "running": laeuft.get("label"),
|
||||
"detail": f"Es läuft schon: {laeuft.get('label', 'ein Update')}."}
|
||||
upd = updates()
|
||||
parts: list[tuple[str, str]] = []
|
||||
bausteine: list[str] = []
|
||||
if upd.get("engine") and ENGINE_UPDATE_CMD:
|
||||
parts.append(("Engine (llama.cpp)", ENGINE_UPDATE_CMD))
|
||||
bausteine.append("engine")
|
||||
if upd.get("swap") and SWAP_UPDATE_CMD:
|
||||
parts.append(("Router (llama-swap)", SWAP_UPDATE_CMD))
|
||||
bausteine.append("swap")
|
||||
if any(c.get("update") is True for c in upd.get("components") or []):
|
||||
parts.append(("Hermes-Agent", _hermes_update_cmd()))
|
||||
bausteine.append("hermes")
|
||||
os_pending = (upd.get("os") or 0) > 0
|
||||
if os_pending:
|
||||
if err := check_sudo_needs_password(sudo_password):
|
||||
# Ohne Passwort: OS auslassen statt alles zu blockieren — aber nur, wenn
|
||||
# es überhaupt sudo-freie Teile gibt; sonst ehrlich das Passwort verlangen.
|
||||
if err := check_sudo_needs_password():
|
||||
# sudo verlangt wider Erwarten ein Passwort (sudoers geaendert?): OS auslassen
|
||||
# statt alles zu blockieren — aber nur, wenn es sudo-freie Teile gibt; sonst
|
||||
# den Fehler ehrlich durchreichen.
|
||||
if not parts:
|
||||
return err
|
||||
os_pending = False
|
||||
@@ -775,6 +782,7 @@ def update_all_job(sudo_password: str | None = None) -> dict:
|
||||
"sudo apt-get update && "
|
||||
"sudo bash -c 'DEBIAN_FRONTEND=noninteractive apt-get upgrade -y' "
|
||||
f"&& bash {STACK_POSTCHECK}")))
|
||||
bausteine.append("os")
|
||||
if not parts:
|
||||
return {"ok": False, "status": "nothing", "detail": "Keine Updates ausstehend."}
|
||||
|
||||
@@ -784,19 +792,9 @@ def update_all_job(sudo_password: str | None = None) -> dict:
|
||||
if not os_pending:
|
||||
cmd += f" && bash {STACK_POSTCHECK}" # Abschluss-Check, falls apt ihn nicht schon lieferte
|
||||
|
||||
def on_done():
|
||||
_engine_cache.update(ts=0.0, avail=False)
|
||||
_swap_cache.update(ts=0.0, avail=False)
|
||||
_comp_cache.update(ts=0.0, data=[])
|
||||
|
||||
labels = " → ".join(label for label, _ in parts)
|
||||
job_id = jobengine.start_job(["bash", "-c", cmd], f"Alle aktualisieren ({labels})",
|
||||
group="maintenance", on_done=on_done,
|
||||
sudo_password=sudo_password)
|
||||
return {"ok": True, "job_id": job_id, "parts": [label for label, _ in parts]}
|
||||
|
||||
|
||||
def reboot(sudo_password: str | None = None) -> dict:
|
||||
if err := check_sudo_needs_password(sudo_password):
|
||||
return err
|
||||
return _run(["sudo", "reboot"], sudo_password=sudo_password)
|
||||
ergebnis = _starten(["bash", "-c", cmd], f"Alle aktualisieren ({labels})",
|
||||
zeitlimit_s=3 * 3600, bausteine=bausteine)
|
||||
if ergebnis.get("ok"):
|
||||
ergebnis["parts"] = [label for label, _ in parts]
|
||||
return ergebnis
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Messwerte der KI-Box mit Verlauf (Monitoring, seit 24.09.2026).
|
||||
|
||||
Der Ereignisstrom (/api/stream) zeigt nur den Augenblick. Für den Verlauf schreibt der Steward (Rolle box, eigener
|
||||
Prozess, übersteht MC2-Neustarts) jede Minute zur halben Minute einen Punkt über kern/messreihen.py (Quelle „box“):
|
||||
|
||||
cpu Mittel der Minute in % (Rechnung wie psutil.cpu_percent(interval=None), aber aus eigenen
|
||||
cpu_times-Ständen: psutil merkt sich den letzten Aufruf je Thread, und die Messung läuft in wechselnden
|
||||
Threads des Event-Loops)
|
||||
ram, platte Belegung in % beim Messen (platte = das Modell-Laufwerk, wie im Cockpit)
|
||||
gpu Mittel der Proben alle PROBE_S Sekunden in %: gpu_busy_percent zeigt nur den Augenblick, eine Probe je
|
||||
Minute träfe die kurzen Antworten der Modelle kaum
|
||||
temp_cpu/_gpu Mittel derselben Proben in °C
|
||||
netz_rx/_tx Bytes/s über die Minute, alle Schnittstellen außer lo
|
||||
tokens Prompt- plus Antwort-Tokens pro Minute (Differenz der Gesamtzähler aus services.token_stats)
|
||||
|
||||
Ein Zähler, der kleiner wird (Neustart des Gateways, neue Token-Datei), ergibt für diese Minute keine Rate (null).
|
||||
GET /api/messwerte (routers/messwerte.py) liefert daraus die Reihen für 1h, 24h und 7d und den letzten Punkt.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
import psutil
|
||||
from config import MODELS_DIR
|
||||
from kern import messreihen, prognose
|
||||
|
||||
from services import system, token_stats
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
QUELLE = "box"
|
||||
REIHEN = ("cpu", "ram", "gpu", "temp_cpu", "temp_gpu", "platte", "netz_rx", "netz_tx", "tokens")
|
||||
# Ein Trocken- oder Probelauf neben dem echten Steward schreibt nicht mit (sonst stünden zwei Punkte je Minute da).
|
||||
ENABLED = (os.environ.get("MC_MESSWERTE_ENABLED", "1") != "0" and os.environ.get("MC_WAECHTER_TROCKEN", "") != "1"
|
||||
and os.environ.get("MC_PROBELAUF", "") != "1")
|
||||
PROBE_S = float(os.environ.get("MC_MESSWERTE_PROBE_S", "5"))
|
||||
PHASE_S = 30 # gemessen wird zur halben Minute: jeder 60-s-Schritt bekommt genau einen Punkt
|
||||
AKTUELL_MAX_S = 180 # älter ist der letzte Punkt nicht „aktuell“ (der Steward schreibt gerade nicht)
|
||||
|
||||
|
||||
# --- Rohwerte ------------------------------------------------------------------------------------
|
||||
|
||||
def _cpu_zeiten() -> tuple[float, float] | None:
|
||||
"""(beschäftigt, gesamt) in Sekunden seit dem Start — gezählt wie psutil.cpu_percent (guest steckt in user)."""
|
||||
try:
|
||||
z = psutil.cpu_times()
|
||||
except Exception:
|
||||
return None
|
||||
gesamt = sum(z) - getattr(z, "guest", 0.0) - getattr(z, "guest_nice", 0.0)
|
||||
return gesamt - z.idle - getattr(z, "iowait", 0.0), gesamt
|
||||
|
||||
|
||||
def cpu_prozent(vorher: tuple[float, float] | None, jetzt: tuple[float, float] | None) -> float | None:
|
||||
if not vorher or not jetzt or jetzt[1] - vorher[1] <= 0:
|
||||
return None
|
||||
anteil = (jetzt[0] - vorher[0]) / (jetzt[1] - vorher[1])
|
||||
return round(min(100.0, max(0.0, anteil * 100)), 1)
|
||||
|
||||
|
||||
def _netz() -> tuple[int, int] | None:
|
||||
"""Empfangene und gesendete Bytes aller Schnittstellen außer lo (Gesamtzähler)."""
|
||||
try:
|
||||
zaehler = psutil.net_io_counters(pernic=True)
|
||||
except Exception:
|
||||
return None
|
||||
return (sum(z.bytes_recv for name, z in zaehler.items() if name != "lo"),
|
||||
sum(z.bytes_sent for name, z in zaehler.items() if name != "lo"))
|
||||
|
||||
|
||||
def _tokens() -> int | None:
|
||||
"""Prompt- plus Antwort-Tokens seit Beginn der Zählung (schreibt der Gateway-Prozess)."""
|
||||
try:
|
||||
stand = token_stats.get_stats()
|
||||
return int(stand.get("prompt_tokens") or 0) + int(stand.get("completion_tokens") or 0)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _platte() -> float | None:
|
||||
try:
|
||||
return round(psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd()).percent, 1)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _mittel(werte: list[float]) -> float | None:
|
||||
return round(sum(werte) / len(werte), 1) if werte else None
|
||||
|
||||
|
||||
def _runden(wert: float | None, stellen: int | None = None) -> float | int | None:
|
||||
return None if wert is None else round(wert, stellen)
|
||||
|
||||
|
||||
# --- Die Minute --------------------------------------------------------------------------------------
|
||||
|
||||
class Messer:
|
||||
"""Was zwischen zwei Minutenpunkten mitläuft: die Stände der Zähler und die Proben."""
|
||||
|
||||
def __init__(self, uhr: Callable[[], float] = time.monotonic) -> None:
|
||||
self._uhr = uhr
|
||||
self._seit = uhr()
|
||||
self._cpu = _cpu_zeiten()
|
||||
self._netz = _netz()
|
||||
self._tokens = _tokens()
|
||||
self._proben: dict[str, list[float]] = {"gpu": [], "temp_cpu": [], "temp_gpu": []}
|
||||
|
||||
def probe(self) -> None:
|
||||
"""GPU-Last und Temperaturen einmal ablesen (sysfs, Bruchteile einer Millisekunde)."""
|
||||
gpu = system._gpu_sysfs() or {}
|
||||
temp = system._temps() or {}
|
||||
for name, wert in (("gpu", gpu.get("busy_percent")), ("temp_cpu", temp.get("cpu")),
|
||||
("temp_gpu", temp.get("gpu"))):
|
||||
if messreihen.ist_zahl(wert):
|
||||
self._proben[name].append(float(wert))
|
||||
|
||||
def punkt(self) -> dict[str, float | int | None]:
|
||||
"""Der Punkt dieser Minute; danach beginnt die nächste."""
|
||||
self.probe()
|
||||
jetzt = self._uhr()
|
||||
dt = jetzt - self._seit
|
||||
cpu, netz, tokens = _cpu_zeiten(), _netz(), _tokens()
|
||||
try:
|
||||
ram = round(psutil.virtual_memory().percent, 1)
|
||||
except Exception:
|
||||
ram = None
|
||||
tok_s = messreihen.rate(self._tokens, tokens, dt)
|
||||
werte = {
|
||||
"cpu": cpu_prozent(self._cpu, cpu),
|
||||
"ram": ram,
|
||||
"gpu": _mittel(self._proben["gpu"]),
|
||||
"temp_cpu": _mittel(self._proben["temp_cpu"]),
|
||||
"temp_gpu": _mittel(self._proben["temp_gpu"]),
|
||||
"platte": _platte(),
|
||||
"netz_rx": _runden(messreihen.rate(self._netz[0], netz[0], dt)) if self._netz and netz else None,
|
||||
"netz_tx": _runden(messreihen.rate(self._netz[1], netz[1], dt)) if self._netz and netz else None,
|
||||
"tokens": None if tok_s is None else round(tok_s * 60, 1),
|
||||
}
|
||||
self._seit, self._cpu, self._netz, self._tokens = jetzt, cpu, netz, tokens
|
||||
for proben in self._proben.values():
|
||||
proben.clear()
|
||||
return werte
|
||||
|
||||
|
||||
def naechste_messung(jetzt: float) -> float:
|
||||
"""Die nächste halbe Minute, mindestens eine Sekunde entfernt."""
|
||||
ziel = jetzt - jetzt % 60 + PHASE_S
|
||||
return ziel if ziel > jetzt + 1 else ziel + 60
|
||||
|
||||
|
||||
async def messwerte_loop() -> None:
|
||||
"""Im mc2-steward (Rolle box): Proben alle PROBE_S Sekunden, ein Punkt je Minute. Fehler kosten einen Punkt, nie
|
||||
die Schleife."""
|
||||
messer = await asyncio.to_thread(Messer)
|
||||
naechste = naechste_messung(time.time())
|
||||
log.info("messwerte: aktiv (ein Punkt je Minute nach %s, Proben alle %ss)", messreihen.ordner(), PROBE_S)
|
||||
while True:
|
||||
await asyncio.sleep(max(0.2, min(PROBE_S, naechste - time.time())))
|
||||
try:
|
||||
if time.time() >= naechste:
|
||||
werte = await asyncio.to_thread(messer.punkt)
|
||||
await asyncio.to_thread(messreihen.schreiben, QUELLE, werte)
|
||||
naechste = naechste_messung(time.time())
|
||||
else:
|
||||
await asyncio.to_thread(messer.probe)
|
||||
except Exception:
|
||||
log.warning("messwerte: Messung fehlgeschlagen", exc_info=True)
|
||||
naechste = naechste_messung(time.time())
|
||||
|
||||
|
||||
# --- Lesen (MC2-Prozess) -------------------------------------------------------------------------------
|
||||
|
||||
ZEITRAEUME = messreihen.ZEITRAEUME
|
||||
|
||||
|
||||
def abfrage(zeitraum: str, jetzt: float | None = None) -> dict:
|
||||
"""Für GET /api/messwerte: {"zeitraum", "schritt_s", "reihen": {…}, "aktuell": {…}, "prognose": {"platte": …}}.
|
||||
aktuell ist der letzte Minutenpunkt, solange er höchstens AKTUELL_MAX_S alt ist, sonst lauter null; prognose
|
||||
die Vorhersage „voll in etwa N Tagen“ des Modell-Laufwerks (kern/prognose.py, seit 25.09.2026)."""
|
||||
zeit = time.time() if jetzt is None else jetzt
|
||||
daten = messreihen.lesen(QUELLE, zeitraum, REIHEN, zeit)
|
||||
letzter = messreihen.letzter_punkt(QUELLE, zeit, AKTUELL_MAX_S) or {}
|
||||
return {**daten, "aktuell": {name: letzter.get(name) for name in REIHEN},
|
||||
"prognose": {"platte": prognose.aus_messreihe(QUELLE, "platte", jetzt=jetzt)}}
|
||||
@@ -1,128 +0,0 @@
|
||||
"""24-h-Metrik-Verlauf für die Cockpit-Zeitachse (User-Wunsch 16.07.: „WANN war Last?").
|
||||
|
||||
Ein leichter Sammler (Lifespan-Task in app.py) legt alle SAMPLE_S Sekunden einen
|
||||
kompakten Punkt in einen Ringpuffer: CPU/RAM/GPU/Disk in Prozent + die Token-
|
||||
GESAMTZÄHLER (das Frontend rechnet Raten aus den Deltas). Der Puffer hält exakt
|
||||
24 h — Älteres fällt automatisch raus (deque maxlen), nichts wächst unbegrenzt.
|
||||
Periodisch wird auf Platte persistiert (übersteht MC2-Restarts/Deploys); beim
|
||||
Laden fliegt alles raus, was älter als 24 h ist.
|
||||
|
||||
Bewusst NICHT im Steward: die Historie ist reine UI-Ware, und ein paar Sekunden
|
||||
Lücke pro Deploy sind egal.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
from config import MODELS_DIR
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
SAMPLE_S = 10 # Abtast-Takt
|
||||
RETENTION_S = 24 * 3600 # 24 h — danach löschen und neu bauen (Ringpuffer)
|
||||
MAXLEN = RETENTION_S // SAMPLE_S # 8640 Punkte
|
||||
FLUSH_S = 300 # höchstens alle 5 min auf Platte
|
||||
MAX_RETURN_POINTS = 300 # Endpoint downsampled auf ~diese Punktzahl
|
||||
|
||||
HISTORY_PATH = MODELS_DIR / "mc2-metrics-history.json"
|
||||
|
||||
# Punkt = [t, cpu, ram, gpu, disk, tp, tc] (t = Epoch-Sekunden; tp/tc = Token-Totale)
|
||||
_points: deque = deque(maxlen=MAXLEN)
|
||||
_loaded = False
|
||||
_last_flush = 0.0
|
||||
|
||||
|
||||
def _load() -> None:
|
||||
global _loaded
|
||||
if _loaded:
|
||||
return
|
||||
_loaded = True
|
||||
try:
|
||||
raw = json.loads(HISTORY_PATH.read_text(encoding="utf-8"))
|
||||
cutoff = time.time() - RETENTION_S
|
||||
for p in raw if isinstance(raw, list) else []:
|
||||
if isinstance(p, list) and len(p) == 7 and isinstance(p[0], (int, float)) and p[0] >= cutoff:
|
||||
_points.append(p)
|
||||
if _points:
|
||||
log.info("metrics_history: %d Punkte aus %s geladen", len(_points), HISTORY_PATH)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception:
|
||||
log.warning("metrics_history: %s nicht lesbar — starte leer", HISTORY_PATH, exc_info=True)
|
||||
|
||||
|
||||
def _flush() -> None:
|
||||
global _last_flush
|
||||
_last_flush = time.time()
|
||||
try:
|
||||
tmp = HISTORY_PATH.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(list(_points), separators=(",", ":")), encoding="utf-8")
|
||||
tmp.replace(HISTORY_PATH)
|
||||
except OSError:
|
||||
log.warning("metrics_history: %s nicht schreibbar", HISTORY_PATH, exc_info=True)
|
||||
|
||||
|
||||
def _sample() -> None:
|
||||
import psutil
|
||||
|
||||
from services.system import _gpu_sysfs
|
||||
from services.token_stats import get_stats
|
||||
|
||||
vm = psutil.virtual_memory()
|
||||
disk = None
|
||||
try:
|
||||
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd())
|
||||
disk = du.percent
|
||||
except Exception:
|
||||
pass
|
||||
gpu = None
|
||||
try:
|
||||
g = _gpu_sysfs()
|
||||
gpu = g.get("busy_percent") if g else None
|
||||
except Exception:
|
||||
pass
|
||||
tp = tc = None
|
||||
try:
|
||||
ts = get_stats()
|
||||
tp, tc = ts.get("prompt_tokens"), ts.get("completion_tokens")
|
||||
except Exception:
|
||||
pass
|
||||
# interval=None: nicht-blockierende CPU-Messung seit dem letzten Aufruf (10 s her — ideal).
|
||||
_points.append([int(time.time()), psutil.cpu_percent(interval=None), vm.percent, gpu, disk, tp, tc])
|
||||
|
||||
|
||||
async def sampler_loop() -> None:
|
||||
"""Dauer-Sammler fürs App-Lifespan. Fehler eines Ticks reißen die Schleife nie."""
|
||||
_load()
|
||||
while True:
|
||||
try:
|
||||
await asyncio.to_thread(_sample)
|
||||
except Exception:
|
||||
log.debug("metrics_history: Sample fehlgeschlagen", exc_info=True)
|
||||
if time.time() - _last_flush >= FLUSH_S:
|
||||
try:
|
||||
await asyncio.to_thread(_flush)
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(SAMPLE_S)
|
||||
|
||||
|
||||
def history(minutes: int = 60) -> dict:
|
||||
"""Punkte der letzten N Minuten, auf ≤ MAX_RETURN_POINTS ausgedünnt (Stride)."""
|
||||
_load()
|
||||
minutes = max(1, min(int(minutes), RETENTION_S // 60))
|
||||
cutoff = time.time() - minutes * 60
|
||||
pts = [p for p in _points if p[0] >= cutoff]
|
||||
stride = max(1, len(pts) // MAX_RETURN_POINTS)
|
||||
pts = pts[::stride]
|
||||
return {
|
||||
"sample_s": SAMPLE_S * stride,
|
||||
"points": [
|
||||
{"t": p[0], "cpu": p[1], "ram": p[2], "gpu": p[3], "disk": p[4], "tp": p[5], "tc": p[6]}
|
||||
for p in pts
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
Wer nutzt die Modelle? (Box-Wart, Umbau 09/2026; ausgebaut am 25.09.2026)
|
||||
|
||||
llama-swap schreibt jede Anfrage ins Journal — Absender-IP, Pfad, Status, Antwortgröße, Programm und
|
||||
am Zeilenende die Dauer als Go-Dauer ("388.09ms", "3.41s", "1m2.3s"):
|
||||
|
||||
... [INFO] Request 192.168.178.28 "POST /v1/chat/completions HTTP/1.1" 200 806 "Go-http-client/1.1" 388.09ms
|
||||
|
||||
Daraus rechnen wir für die letzten TAGE Kalendertage (heute zählt bis jetzt):
|
||||
|
||||
- je Absender die Chat-Anfragen, die Fehler (Status nicht 2xx, je Code), die typische Antwortzeit
|
||||
(Median), den Wert, unter dem 9 von 10 Antworten blieben (90-%-Wert), die Summe aller Antwortzeiten
|
||||
("Rechenzeit"; gleichzeitige Anfragen zählen einzeln) und die letzte Anfrage;
|
||||
- den Verlauf je Tag und je Stunde der letzten 24 h, beides je Absender;
|
||||
- wann welches Modell geladen wurde ("Health check passed").
|
||||
|
||||
Welches Modell eine Anfrage bedient hat, steht nicht in diesen Zeilen — das zeigen wir darum nicht.
|
||||
|
||||
Warum das Journal und nicht /api/metrics/activity: Letzteres hält nur die letzten rund 25
|
||||
Anfragen. NerdQuiz schickt nachts in einer Stunde fast 600 — das wäre längst überschrieben,
|
||||
bevor jemand nachsieht (gemessen am 23.09.2026).
|
||||
|
||||
Die Zuordnung IP → Name kommt aus MC_NUTZER_NAMEN ("ip=Name;ip=Name"); Loopback ist alles,
|
||||
was über MC2 und den Gateway kommt (Lucy, OpenChamber, MC2 selbst). Die Reihenfolge der Namen
|
||||
legt die Farbe im Cockpit fest ("reihe"): Ein Absender behält seine Farbe, auch wenn sich die
|
||||
Rangfolge nach Anfragen ändert.
|
||||
"""
|
||||
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import statistics
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import time as uhrzeit
|
||||
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
CACHE_S = 600
|
||||
TAGE = 7
|
||||
LADEZEITEN = 10 # so viele Ladezeitpunkte je Modell (neueste zuerst) reichen der Anzeige
|
||||
|
||||
# Die Reihenfolge ist die Farbe (Blau, Magenta, Gelb): Der größte Verbraucher bekommt das ruhige Blau.
|
||||
_STANDARD_NAMEN = {
|
||||
"192.168.178.28": "NerdQuiz auf Arcane",
|
||||
"127.0.0.1": "Lucy und OpenChamber über MC2",
|
||||
"::1": "Lucy und OpenChamber über MC2",
|
||||
"192.168.178.22": "NerdQuiz-Tests vom PC",
|
||||
}
|
||||
|
||||
# Status ist optional: Fehlt er (anderes Zeilenformat), zählt die Anfrage, aber nicht als Fehler.
|
||||
_ANFRAGE = re.compile(r'\] Request (\S+) "POST (/v1/[a-z/_]+)[^"]*"(?: (\d{3}))?')
|
||||
_GELADEN = re.compile(r"<([^>]+)> Health check passed")
|
||||
# Go schreibt Mikrosekunden mit dem Mikro-Zeichen (U+00B5); das griechische My und "us" gehen auch.
|
||||
_DAUER_TEIL = re.compile(r"(\d+(?:\.\d+)?)(h|ms|m|s|µs|μs|us|ns)")
|
||||
_DAUER_FAKTOR = {"h": 3600.0, "m": 60.0, "s": 1.0, "ms": 1e-3, "µs": 1e-6, "μs": 1e-6, "us": 1e-6, "ns": 1e-9}
|
||||
|
||||
_lock = threading.Lock()
|
||||
_cache: dict = {"ts": 0.0, "daten": None}
|
||||
|
||||
|
||||
def _namen() -> dict[str, str]:
|
||||
namen = dict(_STANDARD_NAMEN)
|
||||
for paar in os.environ.get("MC_NUTZER_NAMEN", "").split(";"):
|
||||
if "=" in paar:
|
||||
ip, name = paar.split("=", 1)
|
||||
namen[ip.strip()] = name.strip()
|
||||
return namen
|
||||
|
||||
|
||||
def _journal(seit: str) -> list[str]:
|
||||
"""Nur die Zeilen, die uns interessieren — llama-swap loggt sonst ~6.000 Status-Abfragen
|
||||
am Tag. Ohne systemd (Windows-Dev) leer."""
|
||||
cmd = (f"journalctl -u llama-swap --since '{seit}' -o short-iso --no-pager 2>/dev/null"
|
||||
" | grep -E 'POST /v1/|Health check passed'")
|
||||
try:
|
||||
out = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, timeout=60)
|
||||
except Exception:
|
||||
return []
|
||||
return out.stdout.splitlines()
|
||||
|
||||
|
||||
def dauer_s(text: str) -> float | None:
|
||||
"""Go-Dauer → Sekunden: "388.09ms" → 0.38809, "1m2.5s" → 62.5, "850µs" → 0.00085. Sonst None."""
|
||||
teile = list(_DAUER_TEIL.finditer(text))
|
||||
if not teile or "".join(t.group(0) for t in teile) != text:
|
||||
return None
|
||||
return sum(float(t.group(1)) * _DAUER_FAKTOR[t.group(2)] for t in teile)
|
||||
|
||||
|
||||
def _kennwerte(dauern: list[float], summe: float) -> dict:
|
||||
"""Typisch (Median) und 9 von 10 höchstens (90-%-Wert als nächster Rang, also eine echte
|
||||
Antwortzeit), dazu die Summe — alles in Sekunden."""
|
||||
werte = sorted(dauern)
|
||||
return {
|
||||
"dauer_typisch_s": round(statistics.median(werte), 3) if werte else None,
|
||||
"dauer_p90_s": round(werte[math.ceil(0.9 * len(werte)) - 1], 3) if werte else None,
|
||||
"dauer_summe_s": round(summe, 1),
|
||||
}
|
||||
|
||||
|
||||
def werte_aus(zeilen: list[str], jetzt: datetime, namen: dict[str, str]) -> dict:
|
||||
"""Reine Auswertung (testbar): Journal-Zeilen → Nutzung je Absender, Tag, Stunde und Modell."""
|
||||
jetzt = jetzt.astimezone(LOCAL_TZ)
|
||||
tage = [jetzt.date() - timedelta(days=TAGE - 1 - i) for i in range(TAGE)]
|
||||
beginn = datetime.combine(tage[0], uhrzeit(0), tzinfo=LOCAL_TZ)
|
||||
# Stunden in UTC gerechnet, damit die Zeitumstellung keine Stunde verschluckt oder verdoppelt.
|
||||
stunde0 = jetzt.astimezone(timezone.utc).replace(minute=0, second=0, microsecond=0) - timedelta(hours=23)
|
||||
grenze_24h = jetzt - timedelta(hours=24)
|
||||
|
||||
anfragen: Counter = Counter()
|
||||
fehler: dict[str, Counter] = defaultdict(Counter) # Absender → Status → Anzahl
|
||||
dauern: dict[str, list[float]] = defaultdict(list) # nur erfolgreiche Antworten
|
||||
summe: Counter = Counter() # alle Antwortzeiten in Sekunden
|
||||
zuletzt: dict[str, datetime] = {}
|
||||
je_tag: dict[str, Counter] = {t.isoformat(): Counter() for t in tage}
|
||||
je_stunde = [Counter() for _ in range(24)] # chronologisch, die letzte ist die laufende
|
||||
stunden_alt: dict[str, Counter] = defaultdict(Counter) # "HH" → Absender (für den Flugplan)
|
||||
geladen: dict[str, list[datetime]] = defaultdict(list)
|
||||
|
||||
for z in zeilen:
|
||||
try:
|
||||
ts = datetime.fromisoformat(z.split(" ", 1)[0])
|
||||
except ValueError:
|
||||
continue
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=LOCAL_TZ)
|
||||
if ts < beginn:
|
||||
continue
|
||||
if (m := _GELADEN.search(z)):
|
||||
geladen[m.group(1)].append(ts)
|
||||
continue
|
||||
m = _ANFRAGE.search(z)
|
||||
if not m or not m.group(2).startswith("/v1/chat/completions"):
|
||||
continue
|
||||
wer = namen.get(m.group(1), m.group(1))
|
||||
anfragen[wer] += 1
|
||||
ok = m.group(3) is None or m.group(3).startswith("2")
|
||||
if not ok:
|
||||
fehler[wer][m.group(3)] += 1
|
||||
if (d := dauer_s(z.rsplit(None, 1)[-1])) is not None:
|
||||
summe[wer] += d
|
||||
if ok:
|
||||
dauern[wer].append(d)
|
||||
if wer not in zuletzt or ts > zuletzt[wer]:
|
||||
zuletzt[wer] = ts
|
||||
if (tag := ts.astimezone(LOCAL_TZ).date().isoformat()) in je_tag:
|
||||
je_tag[tag][wer] += 1
|
||||
if 0 <= (i := int((ts - stunde0).total_seconds() // 3600)) < 24:
|
||||
je_stunde[i][wer] += 1
|
||||
if ts >= grenze_24h:
|
||||
stunden_alt[f"{ts.astimezone(LOCAL_TZ).hour:02d}"][wer] += 1
|
||||
|
||||
bekannt = list(dict.fromkeys(namen.values()))
|
||||
fremde = [n for n, _ in anfragen.most_common() if n not in bekannt]
|
||||
|
||||
def reihe(name: str) -> int:
|
||||
return bekannt.index(name) if name in bekannt else len(bekannt) + fremde.index(name)
|
||||
|
||||
alle_fehler: Counter = sum(fehler.values(), Counter())
|
||||
zuletzt_geladen = {modell: max(zeiten) for modell, zeiten in geladen.items()}
|
||||
return {
|
||||
"tage": TAGE,
|
||||
"seit": beginn.isoformat(),
|
||||
"stand": jetzt.isoformat(timespec="seconds"),
|
||||
"absender": [
|
||||
{"name": n, "anfragen": c, "reihe": reihe(n),
|
||||
"fehler": sum(fehler[n].values()), "fehler_codes": dict(fehler[n]),
|
||||
**_kennwerte(dauern[n], summe[n]),
|
||||
"zuletzt": zuletzt[n].isoformat()}
|
||||
for n, c in anfragen.most_common()
|
||||
],
|
||||
"gesamt": {
|
||||
"anfragen": sum(anfragen.values()),
|
||||
"fehler": sum(alle_fehler.values()), "fehler_codes": dict(alle_fehler),
|
||||
**_kennwerte([d for liste in dauern.values() for d in liste], sum(summe.values())),
|
||||
},
|
||||
"je_tag": {tag: dict(c) for tag, c in je_tag.items()},
|
||||
"stunden": [{"beginn": (stunde0 + timedelta(hours=i)).astimezone(LOCAL_TZ).isoformat(),
|
||||
"je_absender": dict(c)} for i, c in enumerate(je_stunde)],
|
||||
"letzte_24h": [{"stunde": f"{h:02d}", "je_absender": dict(stunden_alt.get(f"{h:02d}", {}))}
|
||||
for h in range(24)],
|
||||
"zuletzt_geladen": {modell: t.isoformat() for modell, t in zuletzt_geladen.items()},
|
||||
"ladevorgaenge": [
|
||||
{"modell": modell, "anzahl": len(geladen[modell]),
|
||||
"zeiten": [t.isoformat() for t in sorted(geladen[modell], reverse=True)[:LADEZEITEN]]}
|
||||
for modell in sorted(zuletzt_geladen, key=zuletzt_geladen.__getitem__, reverse=True)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def nutzung() -> dict:
|
||||
"""Gecachte Auswertung der letzten TAGE Kalendertage (10 min)."""
|
||||
with _lock:
|
||||
if _cache["daten"] is not None and time.time() - _cache["ts"] < CACHE_S:
|
||||
return _cache["daten"]
|
||||
jetzt = datetime.now(LOCAL_TZ)
|
||||
seit = datetime.combine(jetzt.date() - timedelta(days=TAGE - 1), uhrzeit(0))
|
||||
daten = werte_aus(_journal(seit.strftime("%Y-%m-%d %H:%M:%S")), jetzt, _namen())
|
||||
with _lock:
|
||||
_cache.update(ts=time.time(), daten=daten)
|
||||
return daten
|
||||
@@ -0,0 +1,415 @@
|
||||
"""
|
||||
Monatliche Probe-Wiederherstellung (seit 24.09.2026): Eine Sicherung ist erst eine, wenn sie sich zurückspielen lässt.
|
||||
|
||||
Packt die jüngste Sicherung (deploy/backup.sh → /srv/models/mc2-backups) probeweise in einen Tmp-Ordner aus, prüft die
|
||||
Pflichtinhalte und räumt den Ordner wieder weg. Lebende Dateien fasst sie nie an; geschrieben werden nur der Tmp-Ordner
|
||||
und die Zustandsdatei ZUSTAND_PATH. Die liest der Wächter (Rolle box): gelb, wenn die letzte Probe rot war oder älter
|
||||
als WARN_TAGE ist. Bei Rot geht zusätzlich eine Meldung über deploy/notify.sh raus, in normaler Dringlichkeit — nachts
|
||||
sammelt notify.sh sie für die Morgenmeldung um 07:00.
|
||||
|
||||
Pflichtinhalte (was restore.sh zurückspielt oder was ohne Sicherung verloren wäre):
|
||||
• Lucys Gedächtnis (hermes/memories) — dazu ein Abgleich mit dem lebenden Gedächtnis: Was schon vor der Sicherung so
|
||||
dastand, muss darin unverändert stehen. Ebenso SOUL.md.
|
||||
• Skills (SKILL.md) und Cron-Skripte (hermes/scripts), die Hermes-Cron-Jobs (cron/jobs.json)
|
||||
• Hermes-Config (config.yaml lesbar; .env vorhanden) und die llama-swap-Config (Modelle eingetragen)
|
||||
• Box-Wart-Zustand (box-wart/mc2-*.json lesbar) und die systemd-Units (samt llama-swap-Drop-ins)
|
||||
Geheimnisse (.env, Token-Dateien) und Verknüpfungen werden nicht ausgepackt, nur ihr Vorhandensein geprüft.
|
||||
|
||||
Aufruf: mc2-probe-wiederherstellung.service (Timer: erster Montag im Monat, 05:15) oder von Hand auf der Box:
|
||||
cd ~/mission-control-v2/backend && .venv/bin/python -m services.probe_wiederherstellung
|
||||
Exit 0 = grün, 1 = rot.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
import zlib
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from config import HERMES_HOME
|
||||
from kern.einstellungen import einstellungen
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
from services import backup as sicherung
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
EINHEIT = "mc2-probe-wiederherstellung"
|
||||
ZUSTAND_PATH = Path(os.environ.get("MC_PROBE_ZUSTAND",
|
||||
str(einstellungen().daten_dir / "mc2-probe-wiederherstellung.json")))
|
||||
SICHERUNGEN_DIR = sicherung.BACKUP_DIR
|
||||
# Die Sicherung läuft täglich um 03:30. Ist die jüngste älter, steht sie — das ist ein roter Befund.
|
||||
MAX_ALTER_H = float(os.environ.get("MC_PROBE_MAX_ALTER_H", "48"))
|
||||
# Der Wächter zeigt gelb, wenn die letzte Probe älter ist (monatlicher Takt plus Spielraum).
|
||||
WARN_TAGE = 40
|
||||
# Lebender Stand, gegen den verglichen wird (Tests setzen eigene Pfade).
|
||||
HERMES_LIVE = HERMES_HOME
|
||||
DATEN_LIVE = einstellungen().daten_dir
|
||||
LLAMA_SWAP_DROPINS = Path("/etc/systemd/system/llama-swap.service.d")
|
||||
NOTIFY_SH = Path(__file__).resolve().parents[2] / "deploy" / "notify.sh"
|
||||
|
||||
# Nicht auspacken: Geheimnisse. Geprüft wird nur, dass sie in der Sicherung stehen.
|
||||
_GEHEIM = {".env", "desktop-gateway-token", "session-token.conf"}
|
||||
_PRAEFIX = "mc2-probe-"
|
||||
_NAME_ZEIT = re.compile(r"mc2-state-(\d{8}-\d{6})\.tar\.gz$")
|
||||
# Eine Datei, die bis so kurz vor dem Start der Sicherung geändert wurde, zählt als „danach geändert“.
|
||||
_SPIELRAUM_S = 5.0
|
||||
|
||||
|
||||
# --- Hilfen ------------------------------------------------------------------------------------
|
||||
|
||||
def _tmp_basis() -> str | None:
|
||||
"""Wohin ausgepackt wird: MC_PROBE_TMP, sonst /var/tmp (Platte; /tmp ist auf der Box ein RAM-Laufwerk)."""
|
||||
if (basis := os.environ.get("MC_PROBE_TMP", "").strip()):
|
||||
return basis
|
||||
return "/var/tmp" if os.path.isdir("/var/tmp") else None
|
||||
|
||||
|
||||
def _alte_tmp_ordner_entfernen() -> None:
|
||||
"""Reste einer abgebrochenen Probe (nur eigene Ordner mit unserem Präfix, älter als ein Tag)."""
|
||||
basis = Path(_tmp_basis() or tempfile.gettempdir())
|
||||
try:
|
||||
kandidaten = list(basis.glob(f"{_PRAEFIX}*"))
|
||||
except OSError:
|
||||
return
|
||||
for ordner in kandidaten:
|
||||
try:
|
||||
if ordner.is_dir() and not ordner.is_symlink() and time.time() - ordner.stat().st_mtime > 86400:
|
||||
_entfernen(ordner)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _entfernen(ordner: Path) -> bool:
|
||||
"""Tmp-Ordner löschen, auch wenn die Sicherung schreibgeschützte Ordner mitbrachte. True = er ist weg."""
|
||||
def schreibbar_machen(funktion, pfad, _fehler) -> None:
|
||||
try:
|
||||
os.chmod(os.path.dirname(pfad), 0o700)
|
||||
os.chmod(pfad, 0o700)
|
||||
funktion(pfad)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
shutil.rmtree(ordner, onexc=schreibbar_machen)
|
||||
return not ordner.exists()
|
||||
|
||||
|
||||
def _rel(name: str) -> str:
|
||||
"""Name im Archiv ohne führendes „./“ („./hermes/SOUL.md“ → „hermes/SOUL.md“)."""
|
||||
while name.startswith("./"):
|
||||
name = name[2:]
|
||||
return name.rstrip("/")
|
||||
|
||||
|
||||
def _dateien(ordner: Path) -> list[Path]:
|
||||
if not ordner.is_dir():
|
||||
return []
|
||||
return sorted(p for p in ordner.rglob("*") if p.is_file() and "__pycache__" not in p.parts)
|
||||
|
||||
|
||||
def _geaendert(pfad: Path) -> float:
|
||||
"""Letzte Änderung einer lebenden Datei. ctime zählt mit: Wer eine Datei samt alter mtime auspackt (Skill-Paket,
|
||||
Restore), erzeugt sie trotzdem neu — sie kann dann gar nicht in einer älteren Sicherung stehen."""
|
||||
st = pfad.stat()
|
||||
return max(st.st_mtime, st.st_ctime)
|
||||
|
||||
|
||||
def sicherungs_zeit(tarball: Path) -> float:
|
||||
"""Wann die Sicherung begann: Zeitstempel im Namen (Ortszeit der Box), sonst die Dateizeit."""
|
||||
if (m := _NAME_ZEIT.search(tarball.name)):
|
||||
try:
|
||||
return datetime.strptime(m.group(1), "%Y%m%d-%H%M%S").replace(tzinfo=LOCAL_TZ).timestamp()
|
||||
except ValueError:
|
||||
pass
|
||||
return tarball.stat().st_mtime
|
||||
|
||||
|
||||
def juengste_sicherung(ordner: Path | None = None) -> Path | None:
|
||||
ordner = SICHERUNGEN_DIR if ordner is None else ordner
|
||||
try:
|
||||
liste = sorted(ordner.glob("mc2-state-*.tar.gz"), key=lambda p: p.name, reverse=True)
|
||||
except OSError:
|
||||
return None
|
||||
return liste[0] if liste else None
|
||||
|
||||
|
||||
def _yaml(pfad: Path):
|
||||
from ruamel.yaml import YAML
|
||||
return YAML(typ="safe").load(pfad.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
# --- Die Prüfung ---------------------------------------------------------------------------------
|
||||
|
||||
def _sammelzeile(bereich: str, namen: list[str], einzeln: str, mehrere: str) -> str:
|
||||
"""Eine Fehlerzeile je Art und Bereich, auch wenn hunderte Dateien betroffen sind."""
|
||||
if len(namen) == 1:
|
||||
return f"{bereich}: {namen[0]} {einzeln}."
|
||||
beispiele = ", ".join(namen[:3]) + (" …" if len(namen) > 3 else "")
|
||||
return f"{bereich}: {len(namen)} Dateien {mehrere} ({beispiele})."
|
||||
|
||||
|
||||
def _pruefe_abgleich(bereich: str, kopie: Path, live: Path | None, beginn: float, fehler: list[str],
|
||||
inhalt_gleich: bool = False) -> None:
|
||||
"""Jede lebende Datei, die schon vor Beginn der Sicherung so dastand, muss in der Sicherung stehen
|
||||
(bei inhalt_gleich auch mit demselben Inhalt). Leere Dateien und Sperrdateien (*.lock) zählen nicht."""
|
||||
if live is None or not live.exists():
|
||||
return
|
||||
fehlend: list[str] = []
|
||||
anders: list[str] = []
|
||||
unlesbar: list[str] = []
|
||||
for datei in [live] if live.is_file() else _dateien(live):
|
||||
name = datei.name if live.is_file() else datei.relative_to(live).as_posix()
|
||||
try:
|
||||
# Verknüpfungen packt die Probe nicht aus (backup.sh sichert sie als Verknüpfung) — nicht vergleichen.
|
||||
if (datei.is_symlink() or datei.name.endswith(".lock") or datei.stat().st_size == 0
|
||||
or _geaendert(datei) >= beginn - _SPIELRAUM_S):
|
||||
continue
|
||||
gegenstueck = kopie if live.is_file() else kopie / datei.relative_to(live)
|
||||
if not gegenstueck.is_file():
|
||||
fehlend.append(name)
|
||||
elif inhalt_gleich and gegenstueck.read_bytes() != datei.read_bytes():
|
||||
anders.append(name)
|
||||
except OSError:
|
||||
unlesbar.append(name)
|
||||
if fehlend:
|
||||
fehler.append(_sammelzeile(bereich, fehlend, "fehlt in der Sicherung", "fehlen in der Sicherung"))
|
||||
if anders:
|
||||
fehler.append(_sammelzeile(bereich, anders, "steht in der Sicherung anders als auf der Box",
|
||||
"stehen in der Sicherung anders als auf der Box"))
|
||||
if unlesbar:
|
||||
fehler.append(_sammelzeile(bereich, unlesbar, "ließ sich nicht vergleichen", "ließen sich nicht vergleichen"))
|
||||
|
||||
|
||||
def _pruefe_inhalt(wurzel: Path, mitglieder: dict[str, tarfile.TarInfo], beginn: float,
|
||||
geprueft: list[str], fehler: list[str]) -> None:
|
||||
"""Die Pflichtinhalte im ausgepackten Ordner (Geheimnisse nur über die Liste der Mitglieder)."""
|
||||
hermes = wurzel / "hermes"
|
||||
|
||||
# Lucys Gedächtnis: das Wertvollste, deshalb auch Inhalt gegen den lebenden Stand.
|
||||
gedaechtnis = [p for p in _dateien(hermes / "memories") if p.stat().st_size > 0 and not p.name.endswith(".lock")]
|
||||
if gedaechtnis:
|
||||
geprueft.append(f"Gedächtnis: {len(gedaechtnis)} Dateien ({', '.join(p.name for p in gedaechtnis[:4])})")
|
||||
else:
|
||||
fehler.append("Lucys Gedächtnis (hermes/memories) fehlt in der Sicherung oder ist leer.")
|
||||
_pruefe_abgleich("Gedächtnis", hermes / "memories", HERMES_LIVE / "memories", beginn, fehler, inhalt_gleich=True)
|
||||
|
||||
seele = hermes / "SOUL.md"
|
||||
if seele.is_file() and seele.stat().st_size > 0:
|
||||
geprueft.append("SOUL.md")
|
||||
else:
|
||||
fehler.append("SOUL.md fehlt in der Sicherung oder ist leer.")
|
||||
_pruefe_abgleich("SOUL.md", seele, HERMES_LIVE / "SOUL.md", beginn, fehler, inhalt_gleich=True)
|
||||
|
||||
skills = [p for p in _dateien(hermes / "skills") if p.name == "SKILL.md"]
|
||||
if skills:
|
||||
geprueft.append(f"Skills: {len(skills)}")
|
||||
else:
|
||||
fehler.append("Keine Skills (hermes/skills/…/SKILL.md) in der Sicherung.")
|
||||
_pruefe_abgleich("Skills", hermes / "skills", HERMES_LIVE / "skills", beginn, fehler)
|
||||
|
||||
skripte = _dateien(hermes / "scripts")
|
||||
if skripte:
|
||||
geprueft.append(f"Cron-Skripte: {len(skripte)}")
|
||||
else:
|
||||
fehler.append("Keine Cron-Skripte (hermes/scripts) in der Sicherung.")
|
||||
_pruefe_abgleich("Cron-Skripte", hermes / "scripts", HERMES_LIVE / "scripts", beginn, fehler)
|
||||
|
||||
try:
|
||||
jobs = json.loads((hermes / "cron" / "jobs.json").read_text(encoding="utf-8"))
|
||||
liste = jobs.get("jobs", jobs) if isinstance(jobs, dict) else jobs
|
||||
geprueft.append(f"Hermes-Cron-Jobs: {len(liste)}")
|
||||
except (OSError, ValueError, TypeError) as exc:
|
||||
fehler.append(f"Die Hermes-Cron-Jobs (cron/jobs.json) fehlen oder sind unlesbar ({exc.__class__.__name__}).")
|
||||
|
||||
try:
|
||||
cfg = _yaml(hermes / "config.yaml")
|
||||
if not isinstance(cfg, dict) or not cfg:
|
||||
raise ValueError("leer")
|
||||
geprueft.append("Hermes-Config lesbar")
|
||||
except Exception as exc:
|
||||
fehler.append(f"Die Hermes-Config (config.yaml) fehlt oder ist unlesbar ({exc.__class__.__name__}).")
|
||||
env = mitglieder.get("hermes/.env")
|
||||
if env is not None and env.isfile() and env.size > 0:
|
||||
geprueft.append("Zugangsdaten (.env) enthalten (nicht ausgepackt)")
|
||||
else:
|
||||
fehler.append("Die Zugangsdaten (hermes/.env) fehlen in der Sicherung.")
|
||||
|
||||
try:
|
||||
modelle = (_yaml(wurzel / "llama-swap" / "config.yaml") or {}).get("models")
|
||||
if not isinstance(modelle, dict) or not modelle:
|
||||
raise ValueError("ohne Modelle")
|
||||
geprueft.append(f"llama-swap-Config: {len(modelle)} Modelle")
|
||||
except Exception as exc:
|
||||
fehler.append(f"Die llama-swap-Config fehlt oder ist unbrauchbar ({exc.__class__.__name__}: {exc})"[:200])
|
||||
|
||||
zustand = sorted((wurzel / "box-wart").glob("mc2-*.json")) if (wurzel / "box-wart").is_dir() else []
|
||||
kaputt = []
|
||||
for datei in zustand:
|
||||
try:
|
||||
json.loads(datei.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
kaputt.append(datei.name)
|
||||
if not zustand:
|
||||
fehler.append("Der Box-Wart-Zustand (box-wart/mc2-*.json) fehlt in der Sicherung.")
|
||||
elif kaputt:
|
||||
fehler.append(f"Box-Wart-Zustand unlesbar: {', '.join(kaputt)}")
|
||||
else:
|
||||
geprueft.append(f"Box-Wart-Zustand: {len(zustand)} Dateien")
|
||||
fehlend = []
|
||||
for datei in sorted(DATEN_LIVE.glob("mc2-*.json")) if DATEN_LIVE.is_dir() else []:
|
||||
try:
|
||||
vorher = _geaendert(datei) < beginn - _SPIELRAUM_S and datei.stat().st_size > 0
|
||||
except OSError:
|
||||
continue
|
||||
if vorher and not (wurzel / "box-wart" / datei.name).is_file():
|
||||
fehlend.append(datei.name)
|
||||
if fehlend:
|
||||
fehler.append(_sammelzeile("Box-Wart-Zustand", fehlend, "fehlt in der Sicherung", "fehlen in der Sicherung"))
|
||||
|
||||
units = sorted(p.name for p in (wurzel / "systemd" / "user").glob("*.service")) \
|
||||
if (wurzel / "systemd" / "user").is_dir() else []
|
||||
if "mission-control-2.service" in units:
|
||||
geprueft.append(f"systemd-Units: {len(units)} Dienste")
|
||||
else:
|
||||
fehler.append("Die systemd-Units (systemd/user, mindestens mission-control-2.service) fehlen in der Sicherung.")
|
||||
if LLAMA_SWAP_DROPINS.is_dir() and not (wurzel / "systemd" / LLAMA_SWAP_DROPINS.name).is_dir():
|
||||
fehler.append(f"Die llama-swap-Drop-ins ({LLAMA_SWAP_DROPINS.name}) fehlen in der Sicherung.")
|
||||
|
||||
|
||||
def pruefe_sicherung(tarball: Path) -> tuple[list[str], list[str]]:
|
||||
"""Eine Sicherung probeweise auspacken und prüfen. Rückgabe (Geprüftes, Fehler); der Tmp-Ordner ist danach weg."""
|
||||
geprueft: list[str] = []
|
||||
fehler: list[str] = []
|
||||
beginn = sicherungs_zeit(tarball)
|
||||
try:
|
||||
with tarfile.open(tarball, "r:gz") as tar:
|
||||
alle = tar.getmembers() # liest das ganze Archiv: ein abgeschnittenes fällt hier auf
|
||||
mitglieder = {_rel(m.name): m for m in alle}
|
||||
auspacken = [m for m in alle if _rel(m.name) and (m.isfile() or m.isdir())
|
||||
and Path(_rel(m.name)).name not in _GEHEIM]
|
||||
tmp = Path(tempfile.mkdtemp(prefix=_PRAEFIX, dir=_tmp_basis()))
|
||||
try:
|
||||
tar.extractall(tmp, members=auspacken, filter="data")
|
||||
geprueft.append(f"ausgepackt: {sum(1 for m in auspacken if m.isfile())} Dateien")
|
||||
_pruefe_inhalt(tmp, mitglieder, beginn, geprueft, fehler)
|
||||
finally:
|
||||
if not _entfernen(tmp):
|
||||
fehler.append(f"Der Tmp-Ordner {tmp} ließ sich nicht entfernen.")
|
||||
except (tarfile.TarError, EOFError, zlib.error, OSError) as exc:
|
||||
fehler.append(f"Die Sicherung lässt sich nicht auspacken: {exc.__class__.__name__}: {exc}"[:240])
|
||||
return geprueft, fehler
|
||||
|
||||
|
||||
def probe() -> dict:
|
||||
"""Die ganze Probe: jüngste Sicherung finden, Alter prüfen, auspacken und prüfen, Ergebnis ablegen, bei Rot melden."""
|
||||
start = time.time()
|
||||
geprueft: list[str] = []
|
||||
fehler: list[str] = []
|
||||
tarball = None
|
||||
try:
|
||||
_alte_tmp_ordner_entfernen()
|
||||
tarball = juengste_sicherung()
|
||||
if tarball is None:
|
||||
fehler.append(f"Keine Sicherung gefunden ({SICHERUNGEN_DIR}).")
|
||||
else:
|
||||
alter_h = (time.time() - tarball.stat().st_mtime) / 3600
|
||||
if alter_h > MAX_ALTER_H:
|
||||
fehler.append(f"Die jüngste Sicherung ist {alter_h / 24:.0f} Tage alt: Die tägliche Sicherung "
|
||||
"(mc2-backup.timer) läuft nicht.")
|
||||
else:
|
||||
geprueft.append(f"jüngste Sicherung {alter_h:.0f} Stunden alt")
|
||||
g, f = pruefe_sicherung(tarball)
|
||||
geprueft += g
|
||||
fehler += f
|
||||
except Exception as exc: # die Probe selbst darf nie still scheitern
|
||||
log.exception("Probe-Wiederherstellung abgestürzt")
|
||||
fehler.append(f"Die Probe ist abgestürzt: {exc.__class__.__name__}: {exc}"[:240])
|
||||
stand = {
|
||||
"version": 1,
|
||||
"zeit": start,
|
||||
"datum": datetime.fromtimestamp(start, LOCAL_TZ).isoformat(timespec="seconds"),
|
||||
"ergebnis": "rot" if fehler else "gruen",
|
||||
"sicherung": tarball.name if tarball else None,
|
||||
"dauer_s": round(time.time() - start, 1),
|
||||
"geprueft": geprueft,
|
||||
"fehler": fehler,
|
||||
}
|
||||
speichere(stand)
|
||||
if fehler:
|
||||
melden(stand)
|
||||
return stand
|
||||
|
||||
|
||||
# --- Ablage und Meldung ------------------------------------------------------------------------------
|
||||
|
||||
def speichere(stand: dict) -> None:
|
||||
try:
|
||||
ZUSTAND_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = ZUSTAND_PATH.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(stand, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
os.replace(tmp, ZUSTAND_PATH)
|
||||
except OSError:
|
||||
log.warning("Probe-Wiederherstellung: %s nicht schreibbar", ZUSTAND_PATH, exc_info=True)
|
||||
|
||||
|
||||
def lese_stand() -> dict | None:
|
||||
"""Letztes Ergebnis für den Wächter. None = noch nie gelaufen (oder Datei unlesbar)."""
|
||||
try:
|
||||
daten = json.loads(ZUSTAND_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if not isinstance(daten, dict) or not isinstance(daten.get("zeit"), (int, float)):
|
||||
return None
|
||||
return daten
|
||||
|
||||
|
||||
def meldetext(stand: dict) -> str:
|
||||
fehler = stand.get("fehler") or []
|
||||
text = (f"Die Probe-Wiederherstellung ist rot ({stand.get('sicherung') or 'keine Sicherung'}): "
|
||||
+ " ".join(fehler[:3]))
|
||||
if len(fehler) > 3:
|
||||
text += f" (und {len(fehler) - 3} weitere)"
|
||||
return text + " Der Wächter zeigt es auch im Cockpit."
|
||||
|
||||
|
||||
def _bash() -> str | None:
|
||||
"""bash für notify.sh; auf Windows (Entwicklung) gibt es keinen Meldeweg."""
|
||||
return shutil.which("bash") if os.name == "posix" else None
|
||||
|
||||
|
||||
def melden(stand: dict) -> None:
|
||||
"""Rot → Meldung über notify.sh in normaler Dringlichkeit: kein -d, kein „Alarm“ im Betreff — nachts sammelt
|
||||
notify.sh sie für die Morgenmeldung um 07:00."""
|
||||
if not (bash := _bash()):
|
||||
log.warning("Probe-Wiederherstellung rot, aber hier gibt es keinen Meldeweg: %s", meldetext(stand))
|
||||
return
|
||||
try:
|
||||
subprocess.run([bash, str(NOTIFY_SH), "-s", "[Sicherung]", meldetext(stand)], timeout=120, check=False,
|
||||
stdin=subprocess.DEVNULL, capture_output=True)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
log.warning("Probe-Wiederherstellung: Meldung ging nicht raus", exc_info=True)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
logging.basicConfig(level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
|
||||
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s")
|
||||
stand = probe()
|
||||
print(f"Probe-Wiederherstellung von {stand['sicherung'] or '–'}: "
|
||||
f"{'grün' if stand['ergebnis'] == 'gruen' else 'ROT'} ({stand['dauer_s']} s)")
|
||||
for zeile in stand["geprueft"]:
|
||||
print(f" ok {zeile}")
|
||||
for zeile in stand["fehler"]:
|
||||
print(f" ! {zeile}")
|
||||
return 0 if stand["ergebnis"] == "gruen" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user