Compare commits
408
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
371dcc7ce3 | ||
|
|
d063f745f6 | ||
|
|
ad8dbbc1b3 | ||
|
|
53ea0cd9b6 | ||
|
|
afa3a476b5 | ||
|
|
f7a9b8ad50 | ||
|
|
34360c9644 | ||
|
|
4b57a12257 | ||
|
|
5c6e73c69c | ||
|
|
0d2687436e | ||
|
|
db452aa38a | ||
|
|
d524e03c68 | ||
|
|
9d56de457d | ||
|
|
e443c52d8e | ||
|
|
18fb446aa9 | ||
|
|
23cecd3abc | ||
|
|
77ecb35a6d | ||
|
|
948e55e4f2 | ||
|
|
b816fe6d32 | ||
|
|
9e588dc88c | ||
|
|
450c782ed3 | ||
|
|
8aa3f7defc | ||
|
|
d0b3f92857 | ||
|
|
73ed6e1cc3 | ||
|
|
cf01a5bb85 | ||
|
|
b3bc1a399e | ||
|
|
deaf35e953 | ||
|
|
dfe0971180 | ||
|
|
7834de9592 | ||
|
|
44f33c31d3 | ||
|
|
c31ef827fc | ||
|
|
2784c0ea32 | ||
|
|
54a3649d5e | ||
|
|
0df593c05a | ||
|
|
24bf88a679 | ||
|
|
3594de485c | ||
|
|
7a456c8efa | ||
|
|
28888890ec | ||
|
|
17d4ee3ea8 | ||
|
|
903fbbfbb9 | ||
|
|
9867affc5a | ||
|
|
5fcac05642 | ||
|
|
67ed0458a9 | ||
|
|
c37c0dccbb | ||
|
|
85400453c2 | ||
|
|
ff95f3f997 | ||
|
|
5af3ca75db | ||
|
|
8ec261b8b2 | ||
|
|
82598e0a46 | ||
|
|
f840995078 | ||
|
|
704c21d839 | ||
|
|
bceb5247ef | ||
|
|
7f1bc6055e | ||
|
|
8cb6f6f878 | ||
|
|
1104200a30 | ||
|
|
bf1153cb66 | ||
|
|
6a5134ef79 | ||
|
|
c08a144006 | ||
|
|
eed36e9764 | ||
|
|
bcf878c242 | ||
|
|
d2068da97f | ||
|
|
1b9eb38dc3 | ||
|
|
954a5e74bb | ||
|
|
b615835a73 | ||
|
|
9341b6cb37 | ||
|
|
43c058d7cc | ||
|
|
3f05263b63 | ||
|
|
2e0ad70a85 | ||
|
|
93f6613146 | ||
|
|
d835d41610 | ||
|
|
bff8478203 | ||
|
|
3d8df1c6fd | ||
|
|
d2f699ac69 | ||
|
|
37179b8864 | ||
|
|
6afec70640 | ||
|
|
8e3fecbe42 | ||
|
|
f2733f44fe | ||
|
|
a32f55645c | ||
|
|
40d2840b16 | ||
|
|
37133fd5ed | ||
|
|
4a2b1723f4 | ||
|
|
528198de60 | ||
|
|
56df849455 | ||
|
|
160381d8b3 | ||
|
|
83c33584e4 | ||
|
|
39320b446d | ||
|
|
b452b5e672 | ||
|
|
209549f0f3 | ||
|
|
5e9227c4a3 | ||
|
|
a47489fa85 | ||
|
|
589c14d5e9 | ||
|
|
22bb8c672b | ||
|
|
ee2bb9d03c | ||
|
|
0e961f112b | ||
|
|
d1ddafe721 | ||
|
|
97ba6420e2 | ||
|
|
8eb2fec2ea | ||
|
|
bc1106ddc8 | ||
|
|
1389b46846 | ||
|
|
45048c60b7 | ||
|
|
b1bc9395cf | ||
|
|
e1d7d499f8 | ||
|
|
424aaada27 | ||
|
|
f4bbdad311 | ||
|
|
83941847ab | ||
|
|
29ec6f2ccf | ||
|
|
75611be9a9 | ||
|
|
bd12667231 | ||
|
|
24e6dd81ba | ||
|
|
a091ccb3b8 | ||
|
|
634ad27302 | ||
|
|
59631601b9 | ||
|
|
99ba6a78ab | ||
|
|
739f27a7e5 | ||
|
|
3c1d8bf88f | ||
|
|
b0c9549a29 | ||
|
|
4cd856bd34 | ||
|
|
5ddc952ecd | ||
|
|
e9f488b56c | ||
|
|
c8718fcde0 | ||
|
|
45b5bf275c | ||
|
|
648be33d21 | ||
|
|
4a05bc2a05 | ||
|
|
42363229d4 | ||
|
|
133a491aca | ||
|
|
91aa16eee1 | ||
|
|
2cc1e1cc47 | ||
|
|
292a9d2b6b | ||
|
|
8d46adfd86 | ||
|
|
177c9a311c | ||
|
|
4b226d5d22 | ||
|
|
ed6948429a | ||
|
|
7135042752 | ||
|
|
790de19f1a | ||
|
|
f3af2adec3 | ||
|
|
bfb07a92c3 | ||
|
|
907289d7dc | ||
|
|
3d2c9549fb | ||
|
|
12dadfe6ef | ||
|
|
3669a6e7dc | ||
|
|
cd54fe4a0f | ||
|
|
1e7a6d974f | ||
|
|
61f226e86d | ||
|
|
6809d357c0 | ||
|
|
502a6010f1 | ||
|
|
f9f2e116e0 | ||
|
|
479fecd2c6 | ||
|
|
df8d088fac | ||
|
|
b570e9410d | ||
|
|
e9c2599542 | ||
|
|
a8a6ce3b29 | ||
|
|
0944b1d93e | ||
|
|
1c8f285151 | ||
|
|
041ede7f8d | ||
|
|
33032f8fb0 | ||
|
|
3249c8e942 | ||
|
|
24a0694b80 | ||
|
|
a494d320d7 | ||
|
|
7957cda438 | ||
|
|
dcee0096fe | ||
|
|
b74e98ffbb | ||
|
|
5aa5a484a7 | ||
|
|
464b0d20b8 | ||
|
|
ae69c5ce31 | ||
|
|
8aa22e6591 | ||
|
|
3d1881f4bc | ||
|
|
2935752613 | ||
|
|
a969898a16 | ||
|
|
57492d6759 | ||
|
|
ba9ea7743f | ||
|
|
84dc34c0a3 | ||
|
|
34a9862591 | ||
|
|
f3139d2b5d | ||
|
|
6e573d5551 | ||
|
|
2210bf5c40 | ||
|
|
dc78114102 | ||
|
|
259e1b2ca8 | ||
|
|
148a039545 | ||
|
|
25dbfb2b61 | ||
|
|
fa69edc1aa | ||
|
|
9cf04c427e | ||
|
|
f446da8ae6 | ||
|
|
c2a54bd25d | ||
|
|
56668ee43d | ||
|
|
c8e5a7162b | ||
|
|
00b64b9fdc | ||
|
|
7c23ebf382 | ||
|
|
e04ace242c | ||
|
|
d880a76b6a | ||
|
|
ff319ff291 | ||
|
|
6e3440379b | ||
|
|
5f35f0d580 | ||
|
|
43fa747bc3 | ||
|
|
83c6ecc613 | ||
|
|
b316ad40ba | ||
|
|
8c371b946b | ||
|
|
1a2051e988 | ||
|
|
5d93822a7e | ||
|
|
22868795c9 | ||
|
|
9a35be91f9 | ||
|
|
241b1d54c6 | ||
|
|
eb4bcfc4b3 | ||
|
|
d2a175b0ad | ||
|
|
bf145a3f97 | ||
|
|
5b0909089f | ||
|
|
4b6c6ebfd5 | ||
|
|
0a6b009cb6 | ||
|
|
adc73c817f | ||
|
|
ff795ba026 | ||
|
|
46edbe50c1 | ||
|
|
cd7e1bc114 | ||
|
|
600d0caad6 | ||
|
|
733582900a | ||
|
|
8b14dfdc14 | ||
|
|
dced44548d | ||
|
|
4c0af92b3c | ||
|
|
5cc1e6bea1 | ||
|
|
102149a5d6 | ||
|
|
0c849f4dcd | ||
|
|
94490f114d | ||
|
|
c360bbe14e | ||
|
|
6cb29ee70e | ||
|
|
a6e2f78c27 | ||
|
|
a201c20bf2 | ||
|
|
56676fe83c | ||
|
|
77bead5ba4 | ||
|
|
ee456de713 | ||
|
|
e16f0140f7 | ||
|
|
637c21387a | ||
|
|
da03de18cb | ||
|
|
1b0184eed8 | ||
|
|
4fb8387f79 | ||
|
|
cb884abdc8 | ||
|
|
0a6b6fd18d | ||
|
|
8f359ef4c9 | ||
|
|
327d5a495e | ||
|
|
69925c6cde | ||
|
|
a5410642fa | ||
|
|
c3851f8e25 | ||
|
|
29f8797fbf | ||
|
|
9886d80b1c | ||
|
|
5bc709221e | ||
|
|
14f8a5f405 | ||
|
|
16d2a06d95 | ||
|
|
45f799c57d | ||
|
|
e814122c73 | ||
|
|
d45f2fc6cb | ||
|
|
c2319739e2 | ||
|
|
1e68f62499 | ||
|
|
5504918a61 | ||
|
|
17a129038b | ||
|
|
93c17fe653 | ||
|
|
ae622dfa88 | ||
|
|
6637332b3e | ||
|
|
76d2d7c74b | ||
|
|
7638f0244d | ||
|
|
4985977492 | ||
|
|
f65ff48599 | ||
|
|
87f446c5ae | ||
|
|
f524ef4375 | ||
|
|
605c9d7dd1 | ||
|
|
9791f44644 | ||
|
|
1e3e0066af | ||
|
|
2365f7aac6 | ||
|
|
56b374a0aa | ||
|
|
eef7fdb4df | ||
|
|
cd60974ff9 | ||
|
|
20d4f40ed9 | ||
|
|
cb649aac34 | ||
|
|
d18aac0e1b | ||
|
|
6c14705798 | ||
|
|
991c9a2f82 | ||
|
|
ad49dd65ca | ||
|
|
c28aa4a6a2 | ||
|
|
8b76c0f749 | ||
|
|
a934deee41 | ||
|
|
543eee95d0 | ||
|
|
59d6be8c4a | ||
|
|
7fac17ed9a | ||
|
|
00f2b71a63 | ||
|
|
2cecd06c5b | ||
|
|
775e862652 | ||
|
|
47f7a85510 | ||
|
|
e6502f676a | ||
|
|
554c87aaee | ||
|
|
798de0be8f | ||
|
|
c13cfd2bd0 | ||
|
|
080eaa5ff5 | ||
|
|
7dd44a3630 | ||
|
|
554db10a6f | ||
|
|
ce030d6817 | ||
|
|
8996c0d012 | ||
|
|
a1de1ec2ac | ||
|
|
8e735df32d | ||
|
|
057cc2bb47 | ||
|
|
e686f95545 | ||
|
|
8a90b7507e | ||
|
|
59b09e4a9a | ||
|
|
5894c24703 | ||
|
|
3f279fc7f2 | ||
|
|
af28a75338 | ||
|
|
a121ca7dc0 | ||
|
|
cce32810fc | ||
|
|
682be9b63b | ||
|
|
96c4e417ec | ||
|
|
90ab873924 | ||
|
|
6d3a0bf3a6 | ||
|
|
c3fe6dbe8d | ||
|
|
584f257853 | ||
|
|
73513f5e5b | ||
|
|
18f5522612 | ||
|
|
0cea01c699 | ||
|
|
94dc3fba9b | ||
|
|
3cdd5cdd10 | ||
|
|
9307c8b752 | ||
|
|
204238925a | ||
|
|
a16cff39ce | ||
|
|
11c3416571 | ||
|
|
7389386791 | ||
|
|
40c8ed4754 | ||
|
|
656c5045da | ||
|
|
828dbe13b7 | ||
|
|
82a3bc3c59 | ||
|
|
d5da853db2 | ||
|
|
7b9752261a | ||
|
|
f77c09295a | ||
|
|
cfcb5d2db5 | ||
|
|
efca31c21f | ||
|
|
a57e971607 | ||
|
|
bf9c22ab78 | ||
|
|
ff8125d2ac | ||
|
|
4bb6b28297 | ||
|
|
e3b3725b6f | ||
|
|
77ae504b58 | ||
|
|
8153d14d9f | ||
|
|
a91d70d9b7 | ||
|
|
eb1f333bba | ||
|
|
24e2017d5b | ||
|
|
2383dd26fa | ||
|
|
aebe10306d | ||
|
|
6b03a2c3cf | ||
|
|
a1d7a4b6b9 | ||
|
|
903f3bc074 | ||
|
|
331aafa25d | ||
|
|
cab748f32e | ||
|
|
d91bd5a139 | ||
|
|
f21fe90090 | ||
|
|
7b826f66c4 | ||
|
|
e556dcc853 | ||
|
|
b26fb40cbc | ||
|
|
bc60c6fefc | ||
|
|
de21e1ac68 | ||
|
|
9c03316755 | ||
|
|
f5e362e902 | ||
|
|
2be599bfe9 | ||
|
|
b0dce954e9 | ||
|
|
8579fe9934 | ||
|
|
32c24d7b60 | ||
|
|
547851f1c3 | ||
|
|
1daacea468 | ||
|
|
5aded967ef | ||
|
|
a22f27bc60 | ||
|
|
31fde2a68e | ||
|
|
0890698750 | ||
|
|
77dfac79ee | ||
|
|
fa22435d7c | ||
|
|
4df6a3ecf9 | ||
|
|
d697f3cc0d | ||
|
|
fd241aca51 | ||
|
|
d2d726c105 | ||
|
|
71192b3db0 | ||
|
|
26224804ed | ||
|
|
bc60f9254b | ||
|
|
0c69c491e5 | ||
|
|
b36561990e | ||
|
|
41b0edba53 | ||
|
|
b7e6cf4acc | ||
|
|
f271b04754 | ||
|
|
78bff28acf | ||
|
|
ff6ef3eb1f | ||
|
|
21ea7e1dd3 | ||
|
|
e91f6b7ba8 | ||
|
|
d0a967e765 | ||
|
|
87670dd3a6 | ||
|
|
41e569cf99 | ||
|
|
d2398b3624 | ||
|
|
202e97333a | ||
|
|
0eb2b37abc | ||
|
|
0f13a4f18d | ||
|
|
5d0a61c38e | ||
|
|
5dde4c9f5c | ||
|
|
bc69cf5cc9 | ||
|
|
7223ca96c1 | ||
|
|
3cf111d973 | ||
|
|
3468bc9c80 | ||
|
|
ba0458c0af | ||
|
|
fdabe1768e | ||
|
|
bf9fd808d8 | ||
|
|
a9c0239f8a | ||
|
|
ccc9a25a25 | ||
|
|
a3d9c745cd | ||
|
|
b0eec715c8 | ||
|
|
86bd72838a | ||
|
|
734cafd98f | ||
|
|
abc8a365d2 | ||
|
|
1cd492eb33 | ||
|
|
3abc133118 | ||
|
|
0354ce999f |
-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*
|
||||
+29
-2
@@ -18,11 +18,38 @@
|
||||
{
|
||||
"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"
|
||||
],
|
||||
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
|
||||
"env": {
|
||||
"MC_API_TARGET": "http://127.0.0.1:9000"
|
||||
},
|
||||
"autoPort": false,
|
||||
"port": 5181
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,10 @@
|
||||
# sonst bricht `#!/usr/bin/env python3\r`. (Nur die Hooks + Deploy-Helfer, nicht der ganze Baum.)
|
||||
deploy/agent-hooks/*.py text eol=lf
|
||||
deploy/*.py text eol=lf
|
||||
# `deploy/*.py` greift NUR eine Ebene tief (* matcht kein /). Alles darunter
|
||||
# (z. B. deploy/governor/governor.py) braucht ein eigenes Muster — sonst kommt es
|
||||
# nach einem Windows-Checkout mit CRLF zurück und der Shebang auf der Box bricht.
|
||||
deploy/**/*.py text eol=lf
|
||||
|
||||
# Windows-Batch-Wrapper bleiben CRLF.
|
||||
*.cmd text eol=crlf
|
||||
|
||||
+17
-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
|
||||
@@ -20,3 +15,20 @@ frontend/dist/avatar.vrm
|
||||
box_recon*
|
||||
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/`.
|
||||
@@ -6,7 +6,13 @@ Kurz gehalten, nur die Wahrheiten, die man sonst schmerzhaft lernt. Details: `RE
|
||||
## Was das ist
|
||||
Lokaler Local-AI-Stack für die Box (Bosgame M5, Strix Halo, Vulkan/RADV). Schichten:
|
||||
Engine (llama-swap) · Builtin-Routing-Gateway (`model: auto`) · MC2 (FastAPI + React/shadcn) ·
|
||||
Hermes-Agent. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadcn/Tailwind.
|
||||
**Hermes-Agent (das autonome Gehirn "Lucy")**. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadcn/Tailwind.
|
||||
|
||||
**Hardware-Spezifikationen der Box (WICHTIG für alle Agenten):**
|
||||
- **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") **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,
|
||||
@@ -18,11 +24,22 @@ Hermes-Agent. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadc
|
||||
- **`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 **OpenChamber** (mit eigener OpenCode-CLI).
|
||||
- Es gibt keinen Zed-Workflow, keine OpenCode-CLI-Mittelschicht und keinen Governor mehr.
|
||||
- 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.
|
||||
Naive/lokale Zeiten immer über `MC_LOCAL_TZ` (= `Europe/Berlin`) auflösen, nie `datetime.now()` ohne TZ annehmen
|
||||
@@ -33,9 +50,17 @@ Hermes-Agent. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadc
|
||||
(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)
|
||||
@@ -44,5 +69,6 @@ Hermes-Agent. Backend Python (Box läuft Python 3.14), Frontend Vite/React/shadc
|
||||
- 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,72 +1,90 @@
|
||||
# Mission Control 2.0
|
||||
# Homelab Orchestrator
|
||||
|
||||
Komponierbarer Local-AI-Stack für den Bosgame M5 (Strix Halo). Läuft **live auf der Box**
|
||||
als sudo-freier systemd-User-Dienst (`:9001`) und ist als **Final-Version eingefroren** —
|
||||
Pflege übernehmen ab jetzt die selbst-wartenden Box-KIs (Auto-Update mit Fangnetz + die
|
||||
„Werkstatt"), nicht mehr manuelle Releases.
|
||||
Ein Steuerpult fürs Heimnetz, das sich selbst wartet. Zwei Bereiche, eine Oberfläche:
|
||||
|
||||
**Schichten:** Engine (llama.cpp **Vulkan/RADV** auf Strix Halo) · Router (**llama-swap**,
|
||||
Ko-Residenz-Warm-Set) · **Builtin-Routing-Gateway** in MC2 (`model: auto`, kein externer
|
||||
LiteLLM — scheitert auf Python 3.14) · Mission Control 2.0 (FastAPI + React/shadcn) ·
|
||||
**Hermes Agent** (api_server-Gateway :8642, Tools/MCP/Telegram/cron) · auto-lernendes
|
||||
**Gedächtnis** (Mem0-Sidecar, semantisch). Jede Schicht hinter stabilem Vertrag austauschbar.
|
||||
- **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)).
|
||||
|
||||
> **Sprachassistentin Lucy** (Voice + 3D-Avatar) ist in ein **eigenes Repo** ausgelagert
|
||||
> (`Hitonabi/lucy`) und spricht über den Hermes-Gateway. Aus MC2 selbst sind die Voice-/
|
||||
> Web-Reste entfernt — MC2 ist die Steuerzentrale, kein Sprach-Frontend.
|
||||
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.
|
||||
|
||||
## Status: **live + eingefroren (MC2 Final)**
|
||||
## Oberfläche
|
||||
|
||||
Der ganze Stack ist auf der Box in Betrieb und auf **Autonomie** ausgelegt (Ziel: läuft auch
|
||||
ohne Betreuung monatelang weiter):
|
||||
Im Heimnetz `http://192.168.178.151:9001`, am PC oder am Handy.
|
||||
|
||||
- **Modelle & Routing** — Discover (live HF + Fit/Caps), Engine-Write (register, `groups`/
|
||||
Ko-Residenz, vocab-geprüfte Spec-Drafts), Gateway `model: auto` + Fallbacks.
|
||||
- **System & Wartung** — Status (CPU/RAM/GPU/Disk), **ehrliche Speicher-Zahlen** (echte KV-Bytes
|
||||
aus GGUF-Architektur), Logs mit Fehler-Filter, Dienst-Neustart (sudo-frei).
|
||||
- **Updates mit Fangnetz** — OS/Engine/Router/Hermes per Timer: Backup → Update → Postcheck →
|
||||
bei Fehler **Auto-Rollback + Pin**; verständliche Zusammenfassung („Musst du etwas tun?") in
|
||||
Lucys Stimme. Eigene Software wird eingefroren, nicht geupdatet.
|
||||
- **Gedächtnis** — Mem0-Sidecar (auto-lernend, semantisch), 4 Kategorien, Auto-Dedupe.
|
||||
- **Selbsterhaltung** — Health-Wächter (`sentry`), Warm-Set-Wächter (`warmer`), tägliche
|
||||
Self-Smokes, Lucy-Watchdog + Alarmkette; **Werkstatt** (Hermes wartet den Stack via Gitea-
|
||||
Branch → Gate → Deploy selbst).
|
||||
| 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 |
|
||||
|
||||
API: `health · models · discover · fit · models/register · groups · routing · system/* ·
|
||||
maintenance/* · connect · memory/* · agent/* · voice/announce · reminders/*`. MCP: `mcp/`.
|
||||
Box-Runbooks: [`docs/HERMES_SETUP.md`](docs/HERMES_SETUP.md), [`docs/RUNBOOK.md`](docs/RUNBOOK.md),
|
||||
[`docs/BEDIENUNG.md`](docs/BEDIENUNG.md) + `deploy/` (Units, `deploy.sh`, `backup.sh`, `warmup.sh`).
|
||||
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 |
|
||||
|---|---|---|
|
||||
| `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.) |
|
||||
|
||||
Dazu die Timer `mc2-radar`, `mc2-backup`, `mc2-morgenmeldung`, `mc2-autoupdate` und `projekte-sync`. Die Unit-Dateien
|
||||
liegen in [`deploy/`](deploy/).
|
||||
|
||||
## Entwickeln
|
||||
|
||||
**Backend:**
|
||||
```bash
|
||||
# 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 (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 (Dev, proxyt /api → :9000):**
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # http://localhost:5173
|
||||
```
|
||||
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).
|
||||
|
||||
**Frontend (Build → wird vom Backend ausgeliefert):**
|
||||
```bash
|
||||
cd frontend && npm run build # → frontend/dist
|
||||
```
|
||||
## Doku
|
||||
|
||||
## Env-Vars (Auswahl)
|
||||
|
||||
| Variable | Default | Zweck |
|
||||
| Dokument | Für | Inhalt |
|
||||
|---|---|---|
|
||||
| `MC_LLAMA_SWAP_URL` | `http://127.0.0.1:8080` | Engine |
|
||||
| `MC_CONFIG_PATH` | `/etc/llama-swap/config.yaml` | llama-swap Config |
|
||||
| `MC_GATEWAY_URL` | `http://127.0.0.1:$MC_PORT` | Builtin-Gateway (Teil von MC2, kein externer Dienst) |
|
||||
| `MC_PORT` | `9000` | MC-Backend-Port |
|
||||
| `MC_ENGINE_PATH` | `/opt/llamacpp-vulkan` | Aktive Engine-Binary (Vulkan-Build) |
|
||||
| `MC_ENGINE_REPO` | `ggml-org/llama.cpp` | Quelle für Engine-Update-Check |
|
||||
| `MC_DRAFTS_DIR` | `$MODELS/drafts` | Spec-Draft-Modelle (vocab-geprüft) |
|
||||
| `MC_SPEC_TYPE` | `draft-simple` | Speculative-Decoding-Typ (llama.cpp) |
|
||||
| [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 |
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
# Savepoint — Mission Control 2.0 (MC2)
|
||||
|
||||
_Stand: 2026-07-05. Zustands-Snapshot für einen externen Code-Review (Antigravity). Zuerst
|
||||
`AGENTS.md` lesen (Projekt-Regeln), dann diese Datei (wo wir stehen), dann `README.md`/`docs/`._
|
||||
|
||||
## Was das ist (in einem Satz)
|
||||
MC2 ist die **Web-Admin-Konsole** einer 100 % lokalen KI-Appliance („die Box", Bosgame M5 / AMD
|
||||
Strix Halo, 128 GB Unified RAM, llama.cpp-Vulkan). Sie verwaltet Engine, Modelle, Gedächtnis,
|
||||
Agenten-Status, Updates & Wartung — und dient teils als Gedächtnis-Oberfläche für die Sprach-
|
||||
Begleiterin „Lucy".
|
||||
|
||||
## Architektur (Kurzform — Details in AGENTS.md/docs/)
|
||||
- **Backend** `backend/` — FastAPI, `:9001` als systemd-**User-Dienst** (reboot-fest, sudo-frei).
|
||||
Router (`routers/`, dünn) → Logik (`services/`, Single Source of Truth).
|
||||
- **Frontend** `frontend/` — React/Vite/Tailwind/shadcn. **`frontend/dist` ist ABSICHT im git**
|
||||
(Box hat kein Node; Backend liefert die gebauten Assets aus).
|
||||
- **Eingebautes Gateway** `:9001/v1` (OpenAI-kompatibel) → llama-swap `:8080` (Engine, Vulkan/RADV).
|
||||
- **Sidecars:** `mem0_service/` (Mem0-Gedächtnis), `voice_service/` (STT/TTS für den „Sprechen"-Tab),
|
||||
`mcp/` (MCP-Server), `hermes/` (Hermes-Agent-Plugin), `client/hermes-pc` (PC-Executor).
|
||||
|
||||
## Aktueller Zustand
|
||||
- **Stabil, in Produktion, „MC2 Final".** Ein Neubau („MC3") wurde bewusst VERWORFEN — MC2 wird
|
||||
sauber gehalten und eingefroren, nicht neu erfunden.
|
||||
- **Autonomie-Ausbau abgeschlossen** (Observability-Latenztrace, Fremd-Modell-Kritiker/Härtung,
|
||||
nächtliches „Dreaming" mit Wissens-Vault) — jeweils propose-only, Mensch = Gate.
|
||||
- **Zuletzt (Commit `ff1b9a0`, 05.07.):** `coder-lite` (Qwen3-Coder-30B) entfernt; der Verbinden-Tab
|
||||
(`services/connect.py`) empfiehlt IDEs jetzt die realen Direkt-Modelle `coder`/`heavy`/`vision`
|
||||
statt der virtuellen „coding"-Lane. Davor u. a. Engine-Update-Fix, Hermes-Terminal same-origin.
|
||||
- **Modelle live:** hermes=Qwen3.6-35B-A3B (Agent-Hirn, immer warm) · coder=Qwen3-Coder-Next ·
|
||||
heavy=gpt-oss-120b · vision=Qwen3-VL-30B · scout=GLM-4.6V · embed. Warm-Set = hermes+vision+embed.
|
||||
|
||||
## Nordstern & Randbedingungen (WICHTIG für den Review)
|
||||
- **Zieht ohne Wartung über JAHRE durch.** Robustheit + Einfachheit schlagen Features. Der Betreiber
|
||||
ist **kein Entwickler** — alles muss reboot-/update-fest und selbsterklärend sein.
|
||||
- **100 % lokal, kein Cloud-Zwang** (Privatsphäre ist der Sinn). Hardware-Decke: ~124 GB, ein Topf.
|
||||
- **Lucy ist ein SEPARATES Repo** (`F:\Coding Stuff\lucy`) — **nicht Teil dieses Reviews.**
|
||||
|
||||
## Bekannte raue Kanten (Kandidaten — gern bestätigen/erweitern)
|
||||
- Doku-Drift möglich: `AGENTS.md` nennt „Box läuft in UTC" — die Box wurde inzwischen auf
|
||||
`Europe/Berlin` umgestellt.
|
||||
- Alte, disabled v1-System-Unit `mission-control.service` liegt kosmetisch herum (sudo zum Entfernen).
|
||||
- Ein paar frühere Frontend-Monolithen wurden entflochten; Rest-Altlasten möglich.
|
||||
|
||||
## Was NICHT empfohlen werden soll (bereits entschieden)
|
||||
Cloud-Migration · Voll-Rewrite · schwere neue Frameworks · Engine-/Modell-Wechsel, die nicht in
|
||||
124 GB passen · `frontend/dist` aus git nehmen (Absicht) · Security-Config anfassen. Erwünscht sind
|
||||
**Bugs, Fragilität, tote Pfade, Sicherheitslücken, Vereinfachung (KISS/DRY), Wartbarkeit.**
|
||||
|
||||
## Letzter Sicherungspunkt
|
||||
- git `ff1b9a0` „coder-lite raus + Verbinden-Tab auf echte Direkt-Modelle" (auf `main`, deployt, live).
|
||||
+109
-67
@@ -1,47 +1,37 @@
|
||||
"""
|
||||
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
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any, AsyncGenerator, Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI
|
||||
from config import FRONTEND_DIST, V1_UPSTREAM, VERSION
|
||||
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 kern.einstellungen import einstellungen
|
||||
from routers import health, partner
|
||||
from services.herkunft import erlaubt
|
||||
from starlette.requests import Request
|
||||
|
||||
from config import FRONTEND_DIST, VERSION
|
||||
from routers import (
|
||||
agent,
|
||||
auftragsbuch,
|
||||
chronik,
|
||||
connect,
|
||||
console,
|
||||
gateway_proxy,
|
||||
health,
|
||||
hermes_ui,
|
||||
ideen,
|
||||
maintenance,
|
||||
memory,
|
||||
models,
|
||||
routing,
|
||||
system,
|
||||
voice,
|
||||
wissen,
|
||||
zeitmaschine,
|
||||
)
|
||||
from routers import reminders as reminders_router
|
||||
from services import memory as memory_svc
|
||||
from services import reminders, sentry, warmer
|
||||
ROLLE = einstellungen().rolle
|
||||
|
||||
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
|
||||
# für alle Module (logging.getLogger(__name__)).
|
||||
@@ -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,16 +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()))
|
||||
tasks.append(asyncio.create_task(reminders.reminders_loop()))
|
||||
if memory_svc.AUTO_DEDUPE_ENABLED:
|
||||
tasks.append(asyncio.create_task(memory_svc.auto_dedupe_loop()))
|
||||
log.info(
|
||||
"Mem0-Auto-Dedupe aktiv (alle %ss, Schwelle %s)",
|
||||
memory_svc.AUTO_DEDUPE_INTERVAL,
|
||||
memory_svc.AUTO_DEDUPE_THRESHOLD,
|
||||
)
|
||||
# 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()))
|
||||
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(
|
||||
@@ -87,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(
|
||||
@@ -98,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]]
|
||||
@@ -108,32 +119,59 @@ async def no_cache(
|
||||
return resp
|
||||
|
||||
|
||||
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,
|
||||
maintenance,
|
||||
messwerte,
|
||||
models,
|
||||
radar,
|
||||
routing,
|
||||
system,
|
||||
voice,
|
||||
zeitmaschine,
|
||||
)
|
||||
from routers import reminders as reminders_router
|
||||
|
||||
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
|
||||
# /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:
|
||||
app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
|
||||
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(models.router)
|
||||
app.include_router(routing.router)
|
||||
app.include_router(system.router)
|
||||
app.include_router(connect.router)
|
||||
app.include_router(memory.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
|
||||
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(wissen.router) # Wissens-Vault (Traum-Notizen) read-only
|
||||
app.include_router(zeitmaschine.router) # Snapshots ansehen + Ein-Klick-Restore (detached)
|
||||
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(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.
|
||||
@@ -144,6 +182,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:
|
||||
|
||||
+26
-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:
|
||||
@@ -61,13 +57,18 @@ HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"}
|
||||
# MC2 IST der Gateway (services/gateway.py + routers/gateway_proxy.py). KEIN externer
|
||||
# LiteLLM-Dienst (scheitert auf Python 3.14). Daher keine Gateway-Config-Datei mehr.
|
||||
GATEWAY_URL = os.environ.get("MC_GATEWAY_URL", f"http://127.0.0.1:{os.environ.get('MC_PORT', '9000')}").rstrip("/")
|
||||
# Gateway-Auszug (UMBAU v3 P1): Ist MC_V1_UPSTREAM gesetzt (Unit-Env, z. B.
|
||||
# http://127.0.0.1:9010), bedient der eigenständige mc2-gateway-Prozess den /v1-Pfad
|
||||
# und MC2 reicht /v1 nur noch roh durch (routers/gateway_forward.py). Leer = altes
|
||||
# Verhalten, MC2 bedient /v1 selbst — die Zeile aus der Unit nehmen ist der Rollback.
|
||||
V1_UPSTREAM = os.environ.get("MC_V1_UPSTREAM", "").rstrip("/")
|
||||
|
||||
# --- Hermes Agent (eigener Dienst auf der Box) -------------------------------
|
||||
# Gateway (OpenAI-API des Agenten) + interaktives Web-Terminal (ttyd → `hermes chat`).
|
||||
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."""
|
||||
@@ -92,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.
|
||||
@@ -125,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,63 @@
|
||||
"""
|
||||
MC2-Gateway — der /v1-Datenpfad als EIGENER Prozess (UMBAU v3, P1).
|
||||
|
||||
Befund der Live-Inspektion 15.07.2026: Jeder LLM-Aufruf der Box (Lucys Haupt-Hirn,
|
||||
Nacht-Crons, Worker-Delegation, Vision, Decomposer/Specifier/Curator) lief durch den
|
||||
Steuerpult-Prozess auf :9001 — den am häufigsten neu gestarteten Dienst des Stacks.
|
||||
Dieser Einstieg hebt denselben Gateway-Router (routers/gateway_proxy.py) UNVERÄNDERT
|
||||
in einen bewusst winzigen, langweiligen Prozess: Unit mc2-gateway.service, Loopback
|
||||
:9010, Restart=always. Das Steuerpult darf beliebig neu starten — die Wirbelsäule steht.
|
||||
|
||||
Bewusst NICHT hier: weitere Router, Hintergrund-Loops, CORS, Frontend-Auslieferung.
|
||||
LAN-Clients (IDE-Lane) erreichen /v1 weiter über MC2 :9001, das roh hierher
|
||||
durchreicht (routers/gateway_forward.py, MC_V1_UPSTREAM).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import httpx
|
||||
from config import LLAMA_SWAP_URL, VERSION
|
||||
from fastapi import FastAPI, Request
|
||||
from routers import gateway_proxy
|
||||
|
||||
logging.basicConfig(
|
||||
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
|
||||
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
||||
)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
# Gleiche Client-Parameter wie zuvor in app.py: Keep-Alive/Pooling statt neuer
|
||||
# Client pro Anfrage (Sockets/TIME_WAIT unter parallelen Agent-Strömen).
|
||||
app.state.gw_client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(connect=10.0, read=None, write=None, pool=10.0),
|
||||
limits=httpx.Limits(max_keepalive_connections=100, max_connections=200),
|
||||
)
|
||||
log.info("mc2-gateway bereit (Engine: %s)", LLAMA_SWAP_URL)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await app.state.gw_client.aclose()
|
||||
|
||||
|
||||
app = FastAPI(title="MC2 Gateway", version=VERSION, lifespan=lifespan)
|
||||
app.include_router(gateway_proxy.router)
|
||||
|
||||
|
||||
@app.get("/gw/health")
|
||||
async def health(request: Request):
|
||||
"""Eigener Health-Pfad (nicht /api/health — das gehört dem Steuerpult):
|
||||
beweist Prozess UND Engine-Erreichbarkeit, für deploy.sh/stack-postcheck.sh."""
|
||||
engine = False
|
||||
try:
|
||||
r = await request.app.state.gw_client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=5.0)
|
||||
engine = r.status_code == 200
|
||||
except httpx.HTTPError:
|
||||
pass
|
||||
return {"status": "ok", "service": "mc2-gateway", "version": VERSION,
|
||||
"engine_reachable": engine}
|
||||
@@ -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,63 @@
|
||||
"""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)
|
||||
|
||||
|
||||
@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,56 +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 services.llamaswap import read_config, write_config, spec_draft_flags, _PATH_RE
|
||||
from config import CONFIG_PATH
|
||||
|
||||
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()
|
||||
@@ -2,6 +2,16 @@
|
||||
"_comment": "Kuratierter Modell-Katalog (Cookbook) für Strix Halo / Ryzen AI MAX+ 395 — 128GB unified, bandbreiten-limitiert (256 GB/s). MoE-first. EINE Quelle der Wahrheit für KORREKTE Metadaten (total/active params, moe, generation) → präzise Empfehlungen ohne Namens-Raterei. Inspiriert vom Odysseus-Cookbook (statischer, validierter Katalog statt Live-Scraping). Erweiterbar: neue Modelle hier eintragen. Felder: name (Match-Identifier), repo (HF org/name für Install), family (+Subtyp), generation (numerisch, für Upgrade-Vergleich), total_params_b, active_params_b (=total bei dense), moe, quant, ctx (empfohlen), tools, vision.",
|
||||
"version": "2026-07-03",
|
||||
"models": [
|
||||
{
|
||||
"role": "debugger", "name": "Muse-Glimmer-30B", "repo": "unsloth/Muse-Glimmer-30B-GGUF",
|
||||
"family": "muse", "generation": 1.0, "total_params_b": 30, "active_params_b": 30,
|
||||
"moe": false, "quant": "Q4_K_XL", "ctx": 65536, "tools": true, "vision": true
|
||||
},
|
||||
{
|
||||
"role": "coder", "name": "Qwen3.8-27B", "repo": "unsloth/Qwen3.8-27B-GGUF",
|
||||
"family": "qwen", "generation": 3.8, "total_params_b": 27, "active_params_b": 27,
|
||||
"moe": false, "quant": "Q4_K_M", "ctx": 65536, "tools": true, "vision": true
|
||||
},
|
||||
{
|
||||
"role": "fast", "name": "Qwen3.6-35B-A3B", "repo": "Qwen/Qwen3.6-35B-A3B-GGUF",
|
||||
"family": "qwen", "generation": 3.6, "total_params_b": 35, "active_params_b": 3,
|
||||
@@ -55,6 +65,17 @@
|
||||
"role": "scout", "name": "GLM-4.6V-Flash", "repo": "ggml-org/GLM-4.6V-Flash-GGUF",
|
||||
"family": "glm", "generation": 4.6, "total_params_b": 9, "active_params_b": 3,
|
||||
"moe": true, "quant": "Q4_K_M", "ctx": 32768, "tools": true, "vision": true
|
||||
},
|
||||
|
||||
{
|
||||
"role": "kritiker", "name": "Devstral-Small-2-24B", "repo": "mistralai/Devstral-Small-2-24B-Instruct-2512",
|
||||
"family": "mistral-devstral", "generation": 2.0, "total_params_b": 24, "active_params_b": 24,
|
||||
"moe": false, "quant": "Q4_K_M", "ctx": 65536, "tools": true, "vision": false
|
||||
},
|
||||
{
|
||||
"role": "kritiker", "name": "GLM-4.7-Flash", "repo": "zai-org/GLM-4.7-Flash",
|
||||
"family": "glm", "generation": 4.7, "total_params_b": 30, "active_params_b": 3,
|
||||
"moe": true, "quant": "Q4_K_XL", "ctx": 65536, "tools": true, "vision": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,44 +0,0 @@
|
||||
"""Agent-Endpoint: Hermes-Status + WebUI-Link (MC verlinkt nur, betreibt nicht)."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
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,64 +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,228 @@
|
||||
"""
|
||||
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()
|
||||
rot = sum(1 for h in stand["hinweise"] if h.get("stufe") == "rot")
|
||||
anzahl = len(stand["hinweise"])
|
||||
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,15 +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 services import announce
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
@router.get("/chronik")
|
||||
def chronik(limit: int = 150) -> dict:
|
||||
return {"items": announce.list_recent(limit)}
|
||||
@@ -1,21 +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,88 +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 fastapi import APIRouter, Request, WebSocket
|
||||
from starlette.responses import Response
|
||||
|
||||
from config import BOX_CONSOLE_UPSTREAM
|
||||
|
||||
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: # noqa: ANN001 — Closure je base
|
||||
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()
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Ereignisstrom — ein Kanal sagt der Zentrale, WANN neu laden lohnt, und schickt Metriken.
|
||||
|
||||
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).
|
||||
|
||||
Der alte Kanal /api/events ist mit dem Box-Wart-Umbau (09/2026) entfallen.
|
||||
|
||||
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()`.
|
||||
|
||||
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
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _mtime(p: Path) -> float:
|
||||
try:
|
||||
return p.stat().st_mtime
|
||||
except OSError:
|
||||
return 0.0
|
||||
|
||||
|
||||
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: # 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
|
||||
from services import llamaswap
|
||||
fp["models"] = json.dumps(sorted(str(m) for m in llamaswap.get_running_models()))
|
||||
except Exception:
|
||||
pass
|
||||
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
|
||||
|
||||
|
||||
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(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
|
||||
|
||||
if seit_ping >= KEEPALIVE_S:
|
||||
yield ": ping\n\n"
|
||||
seit_ping = 0.0
|
||||
|
||||
|
||||
_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)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
Dünner /v1-Roh-Weiterleiter (UMBAU v3, P1).
|
||||
|
||||
Wenn MC_V1_UPSTREAM gesetzt ist (Unit-Env), reicht das Steuerpult /v1 unangefasst an
|
||||
den eigenständigen mc2-gateway-Prozess (Loopback :9010) durch — LAN-Clients wie die
|
||||
IDE-Lane erreichen den Gateway sonst nicht. Hier passiert BEWUSST nichts: kein
|
||||
Routing, keine Bild-Weiche, keine Token-Zählung — all das macht genau einmal der
|
||||
Gateway-Prozess (routers/gateway_proxy.py). Rollback = Env-Zeile aus der Unit
|
||||
entfernen → app.py bindet wieder den lokalen Gateway ein.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from config import V1_UPSTREAM
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/v1")
|
||||
|
||||
# Hop-by-hop-Header dürfen nicht blind weitergereicht werden; content-length wird von
|
||||
# httpx (Request) bzw. Starlette-Chunking (Response) neu bestimmt.
|
||||
_HOP_HEADERS = {
|
||||
"host", "content-length", "connection", "keep-alive", "transfer-encoding",
|
||||
"upgrade", "proxy-authenticate", "proxy-authorization", "te", "trailer",
|
||||
}
|
||||
|
||||
|
||||
def _clean(headers) -> dict:
|
||||
return {k: v for k, v in headers.items() if k.lower() not in _HOP_HEADERS}
|
||||
|
||||
|
||||
@router.api_route("/{path:path}", methods=["GET", "POST"])
|
||||
async def forward(path: str, request: Request):
|
||||
client = request.app.state.gw_client # geteilter Keep-Alive-Client (app.py lifespan)
|
||||
url = f"{V1_UPSTREAM}/v1/{path}"
|
||||
if request.url.query:
|
||||
url = f"{url}?{request.url.query}"
|
||||
body = await request.body()
|
||||
req = client.build_request(
|
||||
request.method, url, content=body or None, headers=_clean(request.headers),
|
||||
timeout=None,
|
||||
)
|
||||
try:
|
||||
upstream = await client.send(req, stream=True)
|
||||
except Exception as exc: # Gateway-Prozess weg → ehrlicher 502 statt Hänger
|
||||
log.warning("/v1-Weiterleitung an %s fehlgeschlagen: %s", V1_UPSTREAM, exc)
|
||||
return JSONResponse(
|
||||
{"error": {"message": f"mc2-gateway ({V1_UPSTREAM}) nicht erreichbar: {exc}",
|
||||
"type": "gateway_unavailable"}},
|
||||
status_code=502,
|
||||
)
|
||||
|
||||
async def gen():
|
||||
try:
|
||||
async for chunk in upstream.aiter_raw():
|
||||
yield chunk
|
||||
finally:
|
||||
await upstream.aclose()
|
||||
|
||||
# Streaming-Passthrough für BEIDE Fälle (SSE + normale JSON-Antwort): Starlette
|
||||
# chunkt selbst, Status/Header (inkl. x-mc-routed-to) kommen vom Gateway.
|
||||
return StreamingResponse(
|
||||
gen(), status_code=upstream.status_code, headers=_clean(upstream.headers)
|
||||
)
|
||||
@@ -1,13 +1,19 @@
|
||||
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 config import LLAMA_SWAP_URL
|
||||
from services.gateway_stream import record_stream_chunk, record_usage
|
||||
from services.router_logic import VISION_CAPABLE, choose_for_lane, has_image
|
||||
from services.gateway_stream import record_stream_chunk, record_usage, warn_truncation
|
||||
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__)
|
||||
|
||||
router = APIRouter(prefix="/v1")
|
||||
|
||||
# Antwortsprache für IDE-/Lane-Traffic: die Coding-Modelle antworten sonst englisch
|
||||
@@ -19,6 +25,125 @@ _LANG_DIRECTIVE = os.environ.get(
|
||||
"Quellcode, Bezeichner und Shell-Befehle bleiben unverändert.")
|
||||
|
||||
|
||||
# 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, "
|
||||
"Code, Zahlen/Daten, UI-Elemente, Layout, Farben und alles Auffaellige. "
|
||||
"Sichtbaren Code und Fehlermeldungen gib WOERTLICH wieder.")
|
||||
|
||||
|
||||
# Lucy-Voice-Schnellspur (16.07.): Lucys Sprach-Turns tragen diesen Marker im System-Prompt.
|
||||
# Er schaltet das Qwen-Denken für GENAU diese Requests ab (gemessen direkt am Hirn: 9,9 s -> 0,8 s
|
||||
# bis zur Antwort; reasoning_content 2500 Zeichen -> 0) — die „Hirn"-Latenz der Voice-Turns war
|
||||
# fast vollständig Reasoning-Generierung VOR dem ersten sprechbaren Wort. Der Marker wird VOR dem
|
||||
# Modell aus allen Messages entfernt (konstanter Text -> Prefix-Cache bleibt stabil). Requests ohne
|
||||
# Marker (Crons, Telegram, Werkstatt) bleiben unangetastet. Leerer Env-Wert schaltet die Spur ab.
|
||||
_NO_THINK_MARKER = os.environ.get("MC_NO_THINK_MARKER", "[[MC:NO_THINK]]")
|
||||
|
||||
|
||||
def _apply_no_think_marker(body: dict) -> None:
|
||||
"""Marker aus allen Message-Inhalten strippen; war er da, Thinking abschalten
|
||||
(sofern der Client chat_template_kwargs nicht selbst gesetzt hat)."""
|
||||
if not _NO_THINK_MARKER:
|
||||
return
|
||||
found = False
|
||||
for m in body.get("messages") or []:
|
||||
if not isinstance(m, dict):
|
||||
continue
|
||||
c = m.get("content")
|
||||
if isinstance(c, str) and _NO_THINK_MARKER in c:
|
||||
m["content"] = c.replace(_NO_THINK_MARKER, "").strip()
|
||||
found = True
|
||||
elif isinstance(c, list):
|
||||
for part in c:
|
||||
if (isinstance(part, dict) and isinstance(part.get("text"), str)
|
||||
and _NO_THINK_MARKER in part["text"]):
|
||||
part["text"] = part["text"].replace(_NO_THINK_MARKER, "").strip()
|
||||
found = True
|
||||
if found and "chat_template_kwargs" not in body:
|
||||
body["chat_template_kwargs"] = {"enable_thinking": False}
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
def _bild_schluessel(part: dict) -> str:
|
||||
return hashlib.sha1(json.dumps(part, sort_keys=True).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
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,
|
||||
# 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}]}],
|
||||
}
|
||||
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=_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 (Versuch %s): Beschreibung leer/HTTP %s — %.200s",
|
||||
versuch, r.status_code, r.text)
|
||||
except Exception:
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
def _inject_language(body: dict, alias: str) -> None:
|
||||
"""Deutsch-Direktive anhängen. An die ERSTE System-Message (viele Chat-Templates
|
||||
erwarten nur eine), sonst als neue System-Message. `hermes` ausgenommen — Lucys
|
||||
@@ -36,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)
|
||||
r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0)
|
||||
data = r.json()
|
||||
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.
|
||||
@@ -89,7 +232,21 @@ async def models(request: Request):
|
||||
|
||||
|
||||
async def _proxy(path: str, request: Request):
|
||||
body = await request.json()
|
||||
# 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"):
|
||||
lane = requested.lower()
|
||||
@@ -101,26 +258,37 @@ async def _proxy(path: str, request: Request):
|
||||
routed = {"x-mc-routed-to": requested}
|
||||
|
||||
pol = load_policy()
|
||||
# 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).
|
||||
client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan)
|
||||
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):
|
||||
alias = vision_alias
|
||||
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:
|
||||
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 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}"
|
||||
|
||||
client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan)
|
||||
if body.get("stream"):
|
||||
req = client.build_request("POST", url, json=body, timeout=None)
|
||||
r = await client.send(req, stream=True)
|
||||
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:
|
||||
@@ -132,15 +300,26 @@ async def _proxy(path: str, request: Request):
|
||||
async def gen():
|
||||
try:
|
||||
async for chunk in r.aiter_raw():
|
||||
record_stream_chunk(chunk, alias)
|
||||
record_stream_chunk(chunk, alias, body.get("max_tokens"))
|
||||
yield chunk
|
||||
finally:
|
||||
await r.aclose()
|
||||
return StreamingResponse(gen(), media_type="text/event-stream", headers=routed)
|
||||
|
||||
r = await client.post(url, json=body, timeout=600.0)
|
||||
resp_json = r.json()
|
||||
record_usage(resp_json.get("usage") if isinstance(resp_json, dict) else None, alias)
|
||||
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"
|
||||
for c in resp_json.get("choices") or []):
|
||||
warn_truncation(alias, body.get("max_tokens"))
|
||||
return JSONResponse(resp_json, status_code=r.status_code, headers=routed)
|
||||
|
||||
|
||||
|
||||
+30
-13
@@ -1,21 +1,38 @@
|
||||
"""Health-/Status-Endpoint — schlanker Lebenszeichen-Check für MC 2.0."""
|
||||
"""Health-/Status-Endpoint — schlanker Lebenszeichen-Check einer Instanz.
|
||||
|
||||
from fastapi import APIRouter
|
||||
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 services import gateway, llamaswap
|
||||
from fastapi import APIRouter
|
||||
from kern.einstellungen import einstellungen
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
version: str
|
||||
rolle: str
|
||||
instanz: str
|
||||
engine_reachable: bool | None = None
|
||||
gateway_reachable: bool | None = None
|
||||
brain: dict | None = None
|
||||
|
||||
|
||||
@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
|
||||
@@ -22,11 +27,10 @@ import re
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
from config import HERMES_BUILTIN_UI_UPSTREAM
|
||||
from fastapi import APIRouter, Request, WebSocket
|
||||
from starlette.responses import RedirectResponse, Response
|
||||
|
||||
from config import HERMES_BUILTIN_UI_UPSTREAM
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
@@ -92,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:
|
||||
@@ -114,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,353 @@
|
||||
"""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
|
||||
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/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 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
|
||||
|
||||
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}/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.get("/laeufe")
|
||||
def laeufe() -> dict:
|
||||
return {"laeufe": updates.laeufe()}
|
||||
|
||||
|
||||
# --- „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) -> dict:
|
||||
from services.homelab import protokoll
|
||||
return {"eintraege": protokoll.eintraege(grenze, berichte)}
|
||||
|
||||
|
||||
@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("/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 speicher
|
||||
kanal.bericht_speichern(daten)
|
||||
speicher.verlauf_merken(daten) # Verlauf für „voll in etwa N Tagen“ (seit 25.09.2026)
|
||||
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,57 +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 = ""
|
||||
|
||||
|
||||
class TaskIn(BaseModel):
|
||||
id: str
|
||||
|
||||
|
||||
class AntwortIn(BaseModel):
|
||||
id: str
|
||||
antwort: 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)
|
||||
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
|
||||
|
||||
|
||||
@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.get("/ideen/{id}/log")
|
||||
def get_log(id: str) -> dict:
|
||||
return ideen.log_of(id)
|
||||
@@ -1,20 +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, HTTPException, Header
|
||||
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")
|
||||
@@ -29,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()}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
"""Memory-Endpoints (geteiltes Gedächtnis). LAN-only, kein Token in 2.0-Phase 3."""
|
||||
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from services import memory
|
||||
from services.voice_metrics import park, record_stage
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
|
||||
class MemIn(BaseModel):
|
||||
content: str
|
||||
category: str = "knowledge"
|
||||
source: str = "manual"
|
||||
|
||||
|
||||
class MemUp(BaseModel):
|
||||
content: str | None = None
|
||||
category: str | None = None
|
||||
|
||||
|
||||
class DedupeIn(BaseModel):
|
||||
apply: bool = False
|
||||
threshold: float = 0.85
|
||||
|
||||
|
||||
class LearnIn(BaseModel):
|
||||
text: str | None = None
|
||||
messages: list[dict] | None = None
|
||||
source: str = "auto"
|
||||
category: str = "knowledge"
|
||||
|
||||
|
||||
@router.get("/memory/export")
|
||||
def export() -> dict:
|
||||
return memory.export_text()
|
||||
|
||||
|
||||
@router.get("/memory/graph")
|
||||
def graph(min_score: float = 0.45, top_k: int = 3) -> dict:
|
||||
"""Fakten als Ähnlichkeits-Graph (Knoten + semantische Kanten) für die Visualisierung."""
|
||||
return memory.graph(min_score=min_score, top_k=top_k)
|
||||
|
||||
|
||||
@router.post("/memory/learn", status_code=201)
|
||||
def learn(body: LearnIn) -> dict:
|
||||
"""Auto-Lernen: Gesprächs-Turns/Text durchreichen → Mem0 extrahiert Fakten selbst."""
|
||||
return memory.learn(text=body.text, messages=body.messages,
|
||||
source=body.source, category=body.category)
|
||||
|
||||
|
||||
@router.post("/memory/dedupe")
|
||||
def dedupe(body: DedupeIn) -> dict:
|
||||
return memory.dedupe(apply=body.apply, threshold=body.threshold)
|
||||
|
||||
|
||||
@router.get("/memory")
|
||||
def list_mem(q: str = "", category: str = "") -> list[dict]:
|
||||
# Semantischer Retrieve (q gesetzt) = u.a. Hermes' Mem0-Prefetch VOR jedem Turn. Dauer messen
|
||||
# + parken, damit der laufende Voice-Chat-Turn sie als Unter-Detail seiner Hirn-Zeit einsammelt.
|
||||
if q:
|
||||
_t0 = time.perf_counter()
|
||||
res = memory.list_memories(q=q, category=category)
|
||||
_ms = (time.perf_counter() - _t0) * 1000.0
|
||||
record_stage("memory_retrieve", _ms)
|
||||
park("retrieve", _ms)
|
||||
return res
|
||||
return memory.list_memories(q=q, category=category)
|
||||
|
||||
|
||||
@router.post("/memory", status_code=201)
|
||||
def add(body: MemIn) -> dict:
|
||||
if body.category not in memory.CATEGORIES:
|
||||
raise HTTPException(400, f"Kategorie '{body.category}' unbekannt.")
|
||||
return memory.add_memory(body.content, body.category, body.source)
|
||||
|
||||
|
||||
@router.put("/memory/{mid}")
|
||||
def update(mid: str, body: MemUp) -> dict:
|
||||
res = memory.update_memory(mid, content=body.content, category=body.category)
|
||||
if not res:
|
||||
raise HTTPException(404, "Eintrag nicht gefunden")
|
||||
return res
|
||||
|
||||
|
||||
@router.delete("/memory/{mid}")
|
||||
def delete(mid: str) -> dict:
|
||||
if not memory.delete_memory(mid):
|
||||
raise HTTPException(404, "Eintrag nicht gefunden")
|
||||
return {"ok": True}
|
||||
@@ -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)
|
||||
+60
-127
@@ -1,12 +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 config import HF_DOWNLOAD_ENV, MODELS_DIR
|
||||
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")
|
||||
|
||||
@@ -30,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
|
||||
@@ -62,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,
|
||||
@@ -72,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")
|
||||
@@ -114,24 +113,33 @@ def install(req: InstallReq) -> dict:
|
||||
# SETUP-BEWUSST: größter ctx, der neben Hirn/warmem Set passt (nicht nur Modell allein).
|
||||
ctx = budget.setup_aware_ctx(budget.params_b_for(repo), req.quant, role=req.role)["ctx"]
|
||||
|
||||
# Sofort registrieren (Datei kommt gleich) — robust gegen -watch-config.
|
||||
# Sofort registrieren (robust gegen -watch-config) — aber OHNE den Rollen-Alias
|
||||
# umzuhängen: der zeigte sonst minutenlang auf eine noch ladende Datei (Lane kalt;
|
||||
# bei role=hermes wäre Lucy bis Download-Ende tot gewesen — Review 16.07.).
|
||||
try:
|
||||
model_id = llamaswap.register_model(
|
||||
model_path, role=req.role, ctx=ctx, mmproj_path=mmproj_path, jinja=req.jinja)
|
||||
model_path, role=req.role, ctx=ctx, mmproj_path=mmproj_path, jinja=req.jinja,
|
||||
set_alias=False)
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(500, str(exc))
|
||||
|
||||
# Rolle erst NACH erfolgreichem Download übernehmen (_rolle_uebernehmen).
|
||||
role = (req.role or "").strip().lower()
|
||||
|
||||
# Download-Job: alle GGUF-Teile (+ mmproj) per --include holen.
|
||||
args = [hf.hf_bin(), "download", repo]
|
||||
for f in info["files"]:
|
||||
args.append(f)
|
||||
args.extend(info["files"])
|
||||
if info["mmproj"]:
|
||||
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)
|
||||
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"])}
|
||||
@@ -147,23 +155,30 @@ 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()
|
||||
# Guard: Hält das Modell einen LEBENSWICHTIGEN Alias (hermes/embed), darf die Rolle
|
||||
# hier nicht weggeklickt werden — sonst verliert Lucy Hirn/Gedächtnis mit einem Klick.
|
||||
# Weg: die geschützte Rolle zuerst einem ANDEREN Modell zuweisen (Alias zieht um).
|
||||
prot = llamaswap.protected_alias_of(model_id)
|
||||
if prot and new_role != prot:
|
||||
raise HTTPException(400,
|
||||
f"Dieses Modell hält die lebenswichtige Rolle '{prot}'. Weise '{prot}' zuerst "
|
||||
f"einem anderen Modell zu — danach lässt sich die Rolle hier ändern.")
|
||||
# Das Agent-Hirn (Rolle 'hermes') braucht den warm-bewussten Flow (Alias + brains-Gruppe +
|
||||
# ttl 0 + Hermes config.default + Gateway-Restart) — Single Source of Truth UI ↔ Hermes.
|
||||
if (body.role or "").strip().lower() == "hermes":
|
||||
if new_role == "hermes":
|
||||
from services.agent import set_agent_brain
|
||||
res = set_agent_brain(model_id)
|
||||
if not res.get("ok"):
|
||||
@@ -174,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
|
||||
@@ -248,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}")
|
||||
@@ -270,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))
|
||||
@@ -4,7 +4,6 @@ LAN-only wie alle MC2-Endpoints."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from services import reminders
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
@@ -16,7 +15,20 @@ class ReminderIn(BaseModel):
|
||||
repeat: str = "" # '' einmalig | daily | weekdays | weekly
|
||||
|
||||
|
||||
@router.get("/reminders")
|
||||
class ReminderOut(BaseModel):
|
||||
id: int
|
||||
text: str
|
||||
next_ts: float
|
||||
repeat: str
|
||||
created_ts: float
|
||||
when_local: str
|
||||
|
||||
|
||||
class ReminderListResponse(BaseModel):
|
||||
items: list[ReminderOut]
|
||||
|
||||
|
||||
@router.get("/reminders", response_model=ReminderListResponse)
|
||||
def list_reminders() -> dict:
|
||||
return {"items": reminders.list_all()}
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"""Routing-Endpoints: Lane-Summary (chat/coding) + UI-editierbare Policy (hot-reload)."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
"""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
|
||||
from services import gateway
|
||||
from services.routing_policy import policy_meta, save_policy
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
@@ -12,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}
|
||||
|
||||
+13
-65
@@ -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,20 +6,12 @@ zurückgegeben statt zu crashen.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
import httpx
|
||||
|
||||
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, VOICE_SERVICE_URL
|
||||
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
|
||||
@@ -28,49 +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()
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@router.get("/system/services")
|
||||
def services() -> dict:
|
||||
"""Aggregierte Erreichbarkeit aller Stack-Dienste (für die Health-Anzeige)."""
|
||||
a = agent_status()
|
||||
gw_url = f"{GATEWAY_URL}/v1"
|
||||
return {
|
||||
"services": [
|
||||
{"name": "Engine (llama-swap)", "unit": "llama-swap", "url": LLAMA_SWAP_URL, "ok": engine_reachable()},
|
||||
{"name": "Gateway (integriert)", "unit": "mission-control-2", "url": gw_url, "ok": gateway_reachable()},
|
||||
{"name": "Hermes-Gateway", "unit": "hermes-gateway", "url": HERMES_API_URL, "ok": a["gateway_reachable"]},
|
||||
{"name": "Hermes-GUI (Desktop-Gateway)", "unit": "hermes-builtin-ui", "url": a["hermes_ui_url"], "ok": a["hermes_ui_reachable"]},
|
||||
{"name": "Mem0 (Gedächtnis)", "unit": "mem0-service", "url": MEM0_SERVICE_URL, "ok": _mem0_reachable()},
|
||||
{"name": "Voice (STT/TTS)", "unit": "voice-service", "url": VOICE_SERVICE_URL, "ok": _voice_reachable()},
|
||||
],
|
||||
"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")
|
||||
@@ -83,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: # noqa: BLE001
|
||||
return {"ok": False, "code": -1, "out": "", "err": str(exc)}
|
||||
|
||||
|
||||
class RestartReq(BaseModel):
|
||||
service: str
|
||||
|
||||
@@ -106,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)."""
|
||||
|
||||
+136
-204
@@ -1,36 +1,30 @@
|
||||
"""
|
||||
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
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL
|
||||
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,
|
||||
)
|
||||
|
||||
# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv).
|
||||
import sys as _sys
|
||||
|
||||
import httpx
|
||||
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
|
||||
|
||||
_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:
|
||||
_sys.path.insert(0, _GUARD_DIR)
|
||||
@@ -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: # noqa: BLE001
|
||||
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: # noqa: BLE001
|
||||
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,8 +160,7 @@ 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 = await client.post(f"{VOICE_SERVICE_URL}/turn", files=files)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
except httpx.HTTPError as exc:
|
||||
@@ -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: # noqa: BLE001
|
||||
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,69 +186,104 @@ 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()
|
||||
|
||||
# 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]
|
||||
if imgs:
|
||||
yield b'event: hermes.vision.progress\ndata: {"note": "Bildschirm wird angeschaut"}\n\n'
|
||||
desc = await _describe_images(imgs, body.text)
|
||||
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}"
|
||||
messages = []
|
||||
if body.system:
|
||||
messages.append({"role": "system", "content": body.system})
|
||||
messages.append({"role": "user", "content": user_text})
|
||||
payload = {"model": body.model or HERMES_API_MODEL, "messages": messages, "stream": True}
|
||||
# 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:
|
||||
# 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.
|
||||
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}"
|
||||
messages = []
|
||||
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.
|
||||
if os.environ.get("MC_VOICE_NO_THINK", "1") not in ("0", "false", "False"):
|
||||
payload["chat_template_kwargs"] = {"enable_thinking": False}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0)) as client:
|
||||
async with client.stream(
|
||||
"POST", f"{HERMES_API_URL}/v1/chat/completions", json=payload, headers=headers,
|
||||
) 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()
|
||||
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)
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0)) as client:
|
||||
async with client.stream(
|
||||
"POST", f"{HERMES_API_URL}/v1/chat/completions", json=payload, headers=headers,
|
||||
) as r:
|
||||
if r.status_code != 200:
|
||||
detail = (await r.aread()).decode("utf-8", "replace")[:500]
|
||||
yield _sse_fehler(f"Hermes {r.status_code}: {detail}")
|
||||
return
|
||||
async for chunk in r.aiter_raw():
|
||||
yield chunk
|
||||
except httpx.HTTPError as exc:
|
||||
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,71 +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 time
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
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
|
||||
|
||||
|
||||
@router.get("/wissen")
|
||||
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:
|
||||
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")
|
||||
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.")
|
||||
@@ -13,7 +13,6 @@ from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from services import backup as backup_svc
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
@@ -32,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
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"""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"),
|
||||
)
|
||||
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
|
||||
+75
-116
@@ -1,32 +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
|
||||
import psutil
|
||||
|
||||
from config import (BOX_CONSOLE_UPSTREAM,
|
||||
BOX_CONSOLE_PATH, HERMES_BUILTIN_UI_UPSTREAM, HERMES_BUILTIN_UI_PATH,
|
||||
HERMES_API_URL, HERMES_HOME, PC_EXECUTOR_URL)
|
||||
from config import (
|
||||
HERMES_API_URL,
|
||||
HERMES_BUILTIN_UI_PATH,
|
||||
HERMES_BUILTIN_UI_UPSTREAM,
|
||||
HERMES_HOME,
|
||||
)
|
||||
|
||||
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:
|
||||
@@ -102,74 +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"),
|
||||
}
|
||||
|
||||
|
||||
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:
|
||||
@@ -179,26 +124,30 @@ 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
|
||||
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)
|
||||
set_ttl(model_id, 0)
|
||||
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)
|
||||
set_ttl(model_id, 0)
|
||||
except PermissionError as exc:
|
||||
return {"ok": False, "reason": str(exc)}
|
||||
return {"ok": True, "old": old, "new": model_id, "note": "ist bereits das Agent-Hirn"}
|
||||
try:
|
||||
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 set_ttl, DEFAULT_TTL
|
||||
set_ttl(model_id, 0)
|
||||
if old:
|
||||
set_ttl(old, DEFAULT_TTL)
|
||||
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.
|
||||
set_ttl(model_id, 0)
|
||||
if old:
|
||||
set_ttl(old, DEFAULT_TTL)
|
||||
except PermissionError as exc:
|
||||
return {"ok": False, "reason": str(exc)}
|
||||
update_brain_model("hermes") # 3) Config + Restart
|
||||
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
|
||||
try:
|
||||
@@ -214,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 = {}
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
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)
|
||||
|
||||
# Restart the user-space service to apply changes
|
||||
try:
|
||||
import services.maintenance as maintenance
|
||||
maintenance.restart_service("hermes-gateway")
|
||||
except Exception:
|
||||
log.warning("update_brain_model: hermes-gateway-Restart fehlgeschlagen", exc_info=True)
|
||||
|
||||
return True
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
@@ -19,15 +19,22 @@ import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from config import MODELS_DIR
|
||||
import httpx
|
||||
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
|
||||
# (mc2-steward), darf er den Store NICHT direkt schreiben — Datei + In-Memory-Cache
|
||||
# gehören exklusiv dem Steuerpult. Gesetzt (Unit-Env, z. B. http://127.0.0.1:9001)
|
||||
# liefert add() die Meldung stattdessen per HTTP am /api/voice/announce-Endpunkt ab.
|
||||
ANNOUNCE_HTTP = os.environ.get("MC_ANNOUNCE_HTTP", "").rstrip("/")
|
||||
|
||||
_lock = threading.Lock()
|
||||
_state: dict | None = None # {"next_id": int, "items": [...]}
|
||||
|
||||
@@ -58,6 +65,22 @@ def add(text: str, subject: str = "", source: str = "", priority: str = "normal"
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
raise ValueError("Leere Meldung.")
|
||||
if ANNOUNCE_HTTP:
|
||||
# Fremdprozess-Modus (mc2-steward): per HTTP beim Steuerpult abliefern.
|
||||
payload = {"text": text[:4000], "subject": (subject or "").strip()[:120],
|
||||
"source": (source or "").strip()[:60], "priority": priority}
|
||||
try:
|
||||
with httpx.Client(timeout=5.0) as c:
|
||||
r = c.post(f"{ANNOUNCE_HTTP}/api/voice/announce", json=payload)
|
||||
if r.status_code == 200:
|
||||
return (r.json() or {}).get("item") or payload
|
||||
except Exception:
|
||||
pass
|
||||
# MC2 down/Endpunkt weg: Alarm ist nicht verloren — der Telegram-Direktweg des
|
||||
# Aufrufers (notify.sh) läuft separat; hier bleibt nur der Log-Nachweis.
|
||||
log.warning("announce: HTTP-Abgabe an %s fehlgeschlagen — nur im Log: [%s] %.120s",
|
||||
ANNOUNCE_HTTP, subject or "-", text)
|
||||
return payload
|
||||
with _lock:
|
||||
state = _load()
|
||||
item = {
|
||||
@@ -89,14 +112,6 @@ def notify_telegram(subject: str, text: str) -> None:
|
||||
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,457 +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
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
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,
|
||||
})
|
||||
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)
|
||||
|
||||
# Alle aktuellen Branches als gemeldet speichern
|
||||
_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:
|
||||
@@ -51,7 +46,7 @@ def backup_now() -> dict:
|
||||
r = subprocess.run(["/bin/bash", str(BACKUP_SH)], capture_output=True, text=True, timeout=180)
|
||||
if r.returncode != 0:
|
||||
return {"ok": False, "snapshot": "", "files": [], "error": (r.stderr or r.stdout).strip()[-300:]}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except Exception as exc:
|
||||
return {"ok": False, "snapshot": "", "files": [], "error": str(exc)}
|
||||
|
||||
latest = _latest()
|
||||
@@ -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,160 +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 LLAMA_SWAP_URL, MEM0_SERVICE_URL, PORT
|
||||
|
||||
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:
|
||||
# Eingebauter Gateway: MC2 serviert /v1 selbst (gleicher Port wie das Cockpit).
|
||||
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→chat."},
|
||||
"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 = Gateway/Engine (llama-swap), Leitung 2 = Gedächtnis-Sidecar (Mem0),
|
||||
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"{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: # noqa: BLE001
|
||||
pass
|
||||
|
||||
memory = {"ok": False, "detail": "nicht erreichbar"}
|
||||
try:
|
||||
with httpx.Client(timeout=3.0) as c:
|
||||
r = c.get(f"{MEM0_SERVICE_URL}/health")
|
||||
memory = ({"ok": True, "detail": "bereit"} if r.status_code == 200
|
||||
else {"ok": False, "detail": f"HTTP {r.status_code}"})
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
desktop_gateway = {"ok": False, "detail": "nicht erreichbar"}
|
||||
try:
|
||||
with httpx.Client(timeout=3.0) as c:
|
||||
r = c.get("http://127.0.0.1:9119/api/status")
|
||||
desktop_gateway = ({"ok": True, "detail": "bereit"} if r.status_code == 200
|
||||
else {"ok": False, "detail": f"HTTP {r.status_code}"})
|
||||
except Exception: # noqa: BLE001
|
||||
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}
|
||||
@@ -9,16 +9,15 @@ spätere Auto-Setups nutzen ihn, damit sie nie auseinanderlaufen.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
|
||||
import logging
|
||||
|
||||
from config import DISCOVER_CACHE_PATH, DISCOVER_TTL
|
||||
|
||||
from services import catalog
|
||||
from services.caps import capabilities
|
||||
from services.fit import evaluate_fit, extract_params_b, max_ctx_for
|
||||
@@ -26,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}"}
|
||||
@@ -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)."}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""
|
||||
Routing-Gateway-Status (eingebauter Modus). MC2 IST der Gateway: serviert
|
||||
`/v1/*` mit `model: auto`-Komplexitäts-Routing vor llama-swap. Kein externer
|
||||
LiteLLM-Dienst nötig (baut auf Python 3.14 nicht); bleibt später austauschbar.
|
||||
Routing-Gateway-Status. Seit UMBAU v3 P1 bedient der EIGENSTÄNDIGE mc2-gateway-
|
||||
Prozess den /v1-Pfad (MC_V1_UPSTREAM gesetzt, MC2 reicht nur roh durch); ohne
|
||||
V1_UPSTREAM gilt der alte eingebaute Modus (MC2 serviert /v1 selbst).
|
||||
"""
|
||||
|
||||
from config import PORT
|
||||
import httpx
|
||||
from config import PORT, V1_UPSTREAM
|
||||
|
||||
from services.llamaswap import engine_reachable
|
||||
from services.routing_policy import load_policy
|
||||
|
||||
@@ -13,7 +15,7 @@ def routing_summary() -> dict:
|
||||
p = load_policy()
|
||||
coding_default = p["coder_lite"] or p["coder"]
|
||||
return {
|
||||
"mode": "builtin",
|
||||
"mode": "gateway (eigener Prozess)" if V1_UPSTREAM else "builtin",
|
||||
"endpoint": f":{PORT}/v1 (OpenAI-kompatibel)",
|
||||
# Virtuelle Lanes, die Clients/IDEs als „Modell" wählen (Router pickt das echte Alias).
|
||||
"lanes": [
|
||||
@@ -42,5 +44,13 @@ def routing_summary() -> dict:
|
||||
|
||||
|
||||
def gateway_reachable() -> bool:
|
||||
# Der eingebaute Gateway lebt in MC und proxyt llama-swap → erreichbar, wenn Engine läuft.
|
||||
"""Erreichbarkeit des ECHTEN Denkpfads. Mit V1_UPSTREAM ist das der eigenständige
|
||||
mc2-gateway-Prozess (:9010) — vorher meldete diese Funktion nur die Engine, d. h.
|
||||
ein toter Gateway blieb in Health/Diensten/Cockpit unsichtbar (Review 15.07.)."""
|
||||
if V1_UPSTREAM:
|
||||
try:
|
||||
return httpx.get(f"{V1_UPSTREAM}/v1/models", timeout=2.0).status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
# Eingebauter Modus: der Gateway lebt in MC selbst und proxyt llama-swap.
|
||||
return engine_reachable()
|
||||
|
||||
@@ -7,11 +7,52 @@ 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
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Kontext-Limit-Warnung (User-Wunsch 15.07.: „es sollte eine Warnung geben — Kontext ist
|
||||
# nicht unendlich"): finish_reason=length heißt, eine Antwort ist am Token-/Kontext-Budget
|
||||
# ABGERISSEN — bei Nacht-Workern stirbt damit still die halbe Arbeit (4 Worker am 15.07.).
|
||||
# Statt still: Eintrag in den Briefkasten (silent → Chronik/Panel, kein Sprach-Spam),
|
||||
# je Modell höchstens alle 10 min. Läuft im mc2-gateway-Prozess → announce liefert per
|
||||
# MC_ANNOUNCE_HTTP beim Steuerpult ab (Unit-Env), nie direkt in die Store-Datei.
|
||||
_trunc_last: dict[str, float] = {}
|
||||
_TRUNC_EVERY = 600.0 # s
|
||||
|
||||
# Warm-Pings (Lucys Augen-Wärmer alle 10 min, warmup.sh, models/load) halten Modelle
|
||||
# ABSICHTLICH mit max_tokens=1 warm — deren finish_reason=length ist konstruktionsbedingt
|
||||
# und kein abgerissener Worker (Fehlalarm-Serie 16.07. abends, ~alle 20 min im Briefkasten).
|
||||
# Wer freiwillig so knapp deckelt, will keine echte Antwort → keine Warnung.
|
||||
_PING_MAXTOK = 16
|
||||
|
||||
|
||||
def warn_truncation(model: str, max_tokens: int | None = None) -> None:
|
||||
if isinstance(max_tokens, int) and max_tokens <= _PING_MAXTOK:
|
||||
return
|
||||
now = time.time()
|
||||
if now - _trunc_last.get(model, 0.0) < _TRUNC_EVERY:
|
||||
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- 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)
|
||||
|
||||
|
||||
def record_usage(usage: dict | None, model: str) -> None:
|
||||
"""Ein usage-Objekt verbuchen (no-op bei None/leer)."""
|
||||
@@ -23,9 +64,11 @@ def record_usage(usage: dict | None, model: str) -> None:
|
||||
increment_tokens(prompt, completion, model=model)
|
||||
|
||||
|
||||
def record_stream_chunk(chunk: bytes, model: str) -> None:
|
||||
def record_stream_chunk(chunk: bytes, model: str, max_tokens: int | None = None) -> None:
|
||||
"""Rohen SSE-Chunk auf `usage` prüfen und Tokens verbuchen. Fehler werden
|
||||
geloggt (debug) statt verschluckt — ein defekter Chunk bricht den Stream nicht."""
|
||||
if b'"finish_reason":"length"' in chunk or b'"finish_reason": "length"' in chunk:
|
||||
warn_truncation(model, max_tokens)
|
||||
if b'"usage"' not in chunk:
|
||||
return
|
||||
text = chunk.decode("utf-8", errors="ignore")
|
||||
|
||||
@@ -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,56 @@
|
||||
"""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, zeigt die Übersicht
|
||||
sie ohne Versionsvergleich und ohne Webprüfung — nachtragen genügt.
|
||||
"""
|
||||
|
||||
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]
|
||||
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,253 @@
|
||||
"""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_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,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,345 @@
|
||||
"""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
|
||||
|
||||
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:
|
||||
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:
|
||||
stand.zustand, stand.kurz = "neu", f"{anzahl} Pakete"
|
||||
stand.aktion = Aktion("POST", f"/api/homelab/ziele/{zid}/os-update",
|
||||
f"{anzahl} Pakete 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"
|
||||
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}
|
||||
|
||||
|
||||
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,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"}
|
||||
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,68 @@
|
||||
"""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
|
||||
|
||||
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] = {}
|
||||
gaeste = [g for g in bericht.get("gaeste") or [] if isinstance(g, dict) and "vmid" in g]
|
||||
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,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,570 @@
|
||||
"""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"),
|
||||
}
|
||||
_HOST_AKTIONEN = {"bericht", "host_update", "host_neustart", "kernel_aufraeumen", "sicherung_probe"}
|
||||
_LAUF_WAS = {"app": "Update", "os": "Paket-Update", "pakete": "Paket-Update", "docker": "Docker-Update",
|
||||
"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":
|
||||
titel = f"Update eingespielt · {name}" + (f" {x['version_neu']}" if x.get("version_neu") else "")
|
||||
elif baustein == "docker":
|
||||
titel = f"Docker-Images aktualisiert · {name}"
|
||||
elif baustein == "pakete":
|
||||
titel = f"{_anzahl_pakete(x.get('pakete'), 'Host-Paket')} · {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")))
|
||||
|
||||
|
||||
def _ziel_aus(hinweis_id: str) -> str | None:
|
||||
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 {}
|
||||
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_aus(hid), None, schw)
|
||||
elif art == "erledigt":
|
||||
e = _eintrag(v.get("ts"), "hinweis", f"Erledigt: {titel}", None, "ok", _ziel_aus(hid), None, schw)
|
||||
elif art == "auto":
|
||||
e = _eintrag(v.get("ts"), "hinweis", titel, "Selbstreparatur des Wächters.", "info", _ziel_aus(hid),
|
||||
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) -> list[dict]:
|
||||
"""Alle Einträge, neueste zuerst, höchstens `grenze` (1 bis GRENZE_MAX)."""
|
||||
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]
|
||||
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,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,631 @@
|
||||
"""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)
|
||||
zeilen.append({
|
||||
"ziel": _zid(g), "name": _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,459 @@
|
||||
"""„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 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
|
||||
|
||||
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 []
|
||||
|
||||
|
||||
def _merken(lauf: dict) -> None:
|
||||
with _lock:
|
||||
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:
|
||||
_auftrag(lauf, "update" if baustein == "app" else "os_update", {"vmid": vmid}, 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
|
||||
_auftrag(lauf, "host_update", {}, 60 * 60)
|
||||
_auftrag(lauf, "bericht", {}, 5 * 60)
|
||||
_ende(lauf, "eingespielt", "Proxmox-Host: 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 = "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"])
|
||||
_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"):
|
||||
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 {})
|
||||
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")})
|
||||
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,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
|
||||
@@ -1,249 +0,0 @@
|
||||
"""
|
||||
Ideen-Queue — DIE eine Sammelstelle für Ideen des Commanders (Entscheid 10.07.2026).
|
||||
|
||||
Die Queue selbst ist das NATIVE Hermes-Kanban (SQLite-Board, Dispatcher im Gateway):
|
||||
Idee rein (triage) → der Specifier arbeitet sie aus → ein Worker-Profil setzt sie um →
|
||||
Code-Ergebnisse kommen als Vorschlags-Branch zurück und erscheinen als Karte im
|
||||
Auftragsbuch. Dieser Service ist nur die MC2-Tür dazu: anlegen + anzeigen + aufräumen,
|
||||
alles über die Hermes-CLI (stabile --json-Schnittstelle, kein Griff in die kanban.db).
|
||||
|
||||
Türen insgesamt: Telegram/Desktop (natives kanban_create-Tool), Lucy-Voice
|
||||
(mcp_voice.idee_notieren → POST /api/ideen) und die Zentrale (Auftragsbuch-Tab → hier).
|
||||
|
||||
Lokal (Windows-Dev) ist alles harmlos: available=False, Aktionen geben Fehler statt zu crashen.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
HERMES = Path(os.environ.get("MC_HERMES_BIN", "~/.local/bin/hermes")).expanduser()
|
||||
|
||||
_TASK_ID_RX = re.compile(r"^t_[0-9a-f]{4,16}$")
|
||||
|
||||
# Die CLI kostet pro Aufruf ein paar Sekunden Python-Start — die UI pollt aber.
|
||||
_list_cache: dict = {"ts": 0.0, "data": None}
|
||||
_LIST_EVERY = 15.0 # s
|
||||
|
||||
# Cache für worker log
|
||||
_log_cache: dict = {"ts": 0.0, "task_id": None, "data": None}
|
||||
_LOG_EVERY = 4.0 # s
|
||||
|
||||
|
||||
def _available() -> bool:
|
||||
return os.name == "posix" and HERMES.is_file()
|
||||
|
||||
|
||||
def _hermes(args: list[str], timeout: int = 60) -> subprocess.CompletedProcess:
|
||||
return subprocess.run([str(HERMES), "kanban", *args],
|
||||
capture_output=True, text=True, timeout=timeout)
|
||||
|
||||
|
||||
def _parse_json(stdout: str):
|
||||
"""CLI-Ausgabe kann Log-Zeilen vor dem JSON enthalten — ab der ersten Klammer parsen."""
|
||||
text = stdout or ""
|
||||
for opener in ("[", "{"):
|
||||
idx = text.find(opener)
|
||||
if idx >= 0:
|
||||
try:
|
||||
return json.loads(text[idx:])
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _blocker_frage(task_id: str) -> dict | None:
|
||||
"""Für eine blockierte Aufgabe: die Frage/den Grund des Workers holen (needs_input etc.).
|
||||
|
||||
Die Wahrheit steckt im jüngsten `blocked`-Event (payload.reason/kind); fällt das leer aus,
|
||||
greifen wir auf den letzten „BLOCKED:"-Kommentar zurück. Ein Extra-`show`-Aufruf pro
|
||||
hängender Karte ist billig — die gibt es fast nie und wenn, dann nur eine Handvoll.
|
||||
"""
|
||||
try:
|
||||
r = _hermes(["show", task_id, "--json"], timeout=30)
|
||||
d = _parse_json(r.stdout)
|
||||
except Exception:
|
||||
log.warning("ideen: kanban show %s fehlgeschlagen", task_id, exc_info=True)
|
||||
return None
|
||||
if not isinstance(d, dict):
|
||||
return None
|
||||
|
||||
frage, kind = "", ""
|
||||
for ev in d.get("events") or []: # chronologisch → letztes blocked = aktive Frage
|
||||
if isinstance(ev, dict) and ev.get("kind") == "blocked":
|
||||
p = ev.get("payload") or {}
|
||||
frage = (p.get("reason") or "").strip()
|
||||
kind = (p.get("kind") or "").strip()
|
||||
if not frage: # Fallback: letzter „BLOCKED:"-Kommentar
|
||||
for c in d.get("comments") or []:
|
||||
body = (c.get("body") or "").strip()
|
||||
if body.startswith("BLOCKED:"):
|
||||
frage = body[len("BLOCKED:"):].strip()
|
||||
return {"frage": frage[:800], "kind": kind}
|
||||
|
||||
|
||||
def list_queue() -> dict:
|
||||
"""Alle nicht archivierten Aufgaben des Boards, jüngste zuerst — die Queue-Sicht der UI."""
|
||||
if not _available():
|
||||
return {"available": False, "items": [], "offen": 0}
|
||||
|
||||
now = time.time()
|
||||
if _list_cache["data"] is not None and now - _list_cache["ts"] < _LIST_EVERY:
|
||||
return _list_cache["data"]
|
||||
|
||||
try:
|
||||
r = _hermes(["list", "--json", "--sort", "created-desc"])
|
||||
raw = _parse_json(r.stdout)
|
||||
except Exception:
|
||||
log.warning("ideen: kanban list fehlgeschlagen", exc_info=True)
|
||||
raw = None
|
||||
if not isinstance(raw, list):
|
||||
# CLI kaputt/Timeout → ehrlich leer melden, aber nicht cachen (nächster Poll versucht's neu)
|
||||
return {"available": True, "items": [], "offen": 0, "fehler": "Queue nicht lesbar"}
|
||||
|
||||
items = []
|
||||
for t in raw[:60]:
|
||||
try:
|
||||
items.append({
|
||||
"id": t.get("id", ""),
|
||||
"titel": (t.get("title") or "(ohne Titel)")[:200],
|
||||
"body": (t.get("body") or "")[:600],
|
||||
"status": t.get("status", "?"),
|
||||
"assignee": t.get("assignee"),
|
||||
"erstellt": t.get("created_at"),
|
||||
"fertig": t.get("completed_at"),
|
||||
"von": t.get("created_by"),
|
||||
"frage": None, # bei blocked die Worker-Frage (unten nachgeladen)
|
||||
"frage_kind": None,
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Hängende Karten anreichern: die Frage des Workers holen, damit die Zentrale sie
|
||||
# beantworten kann (Sackgasse-Fix). Deckel gegen Ausreißer, damit ein Poll nie hängt.
|
||||
for it in [i for i in items if i["status"] == "blocked"][:6]:
|
||||
info = _blocker_frage(it["id"])
|
||||
if info:
|
||||
it["frage"] = info["frage"]
|
||||
it["frage_kind"] = info["kind"]
|
||||
|
||||
offen = sum(1 for i in items if i["status"] not in ("done",))
|
||||
data = {"available": True, "items": items, "offen": offen}
|
||||
_list_cache.update(ts=now, data=data)
|
||||
return data
|
||||
|
||||
|
||||
def add_idea(titel: str, notiz: str = "", created_by: str = "mc2-ui") -> dict:
|
||||
"""Idee in die Queue legen: triage — der Specifier der Box arbeitet sie selbst aus."""
|
||||
if not _available():
|
||||
return {"ok": False, "error": "Die Ideen-Queue lebt auf der Box."}
|
||||
titel = (titel or "").strip()
|
||||
if not (3 <= len(titel) <= 200):
|
||||
return {"ok": False, "error": "Titel bitte zwischen 3 und 200 Zeichen."}
|
||||
args = ["create", titel, "--triage", "--created-by", created_by, "--json"]
|
||||
notiz = (notiz or "").strip()
|
||||
if notiz:
|
||||
args[2:2] = ["--body", notiz[:4000]]
|
||||
try:
|
||||
r = _hermes(args)
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": f"Queue nicht erreichbar: {exc}"}
|
||||
task = _parse_json(r.stdout)
|
||||
if r.returncode != 0 or not isinstance(task, dict) or not task.get("id"):
|
||||
return {"ok": False, "error": (r.stderr or r.stdout or "kanban create fehlgeschlagen").strip()[:300]}
|
||||
_list_cache["ts"] = 0.0 # nächster Poll zeigt die neue Idee sofort
|
||||
try:
|
||||
from services import announce
|
||||
announce.add(f"Neue Idee in der Queue: „{titel}“ ({task['id']}) — die Box arbeitet sie aus.",
|
||||
"[Ideen-Queue]", "ideen-queue", "silent")
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "id": task["id"], "status": task.get("status")}
|
||||
|
||||
|
||||
def answer_task(task_id: str, antwort: str) -> dict:
|
||||
"""Eine hängende Aufgabe beantworten: Antwort als Kommentar protokollieren + entsperren.
|
||||
|
||||
`unblock --reason` schreibt die Antwort als „UNBLOCK:"-Kommentar und stellt die Karte auf
|
||||
ready — der Dispatcher spawnt den Worker neu, der die Antwort im Task-Kontext vorfindet und
|
||||
weitermacht. Genau der Ausweg aus der Sackgasse „Aufgabe fragt, aber niemand kann antworten".
|
||||
"""
|
||||
if not _available():
|
||||
return {"ok": False, "error": "Die Ideen-Queue lebt auf der Box."}
|
||||
if not _TASK_ID_RX.match(task_id or ""):
|
||||
return {"ok": False, "error": f"Keine gültige Aufgaben-Nummer: {task_id!r}"}
|
||||
antwort = (antwort or "").strip()
|
||||
if not (1 <= len(antwort) <= 2000):
|
||||
return {"ok": False, "error": "Bitte eine Antwort zwischen 1 und 2000 Zeichen."}
|
||||
try:
|
||||
r = _hermes(["unblock", task_id, "--reason", antwort])
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": f"Queue nicht erreichbar: {exc}"}
|
||||
if r.returncode != 0:
|
||||
return {"ok": False, "error": (r.stderr or r.stdout or "Entsperren fehlgeschlagen").strip()[:300]}
|
||||
_list_cache["ts"] = 0.0 # nächster Poll zeigt den neuen Status (ready/running) sofort
|
||||
try:
|
||||
from services import announce
|
||||
announce.add(f"Deine Antwort ging an die hängende Aufgabe {task_id} — die Box macht weiter.",
|
||||
"[Ideen-Queue]", "ideen-queue", "silent")
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def archive_task(task_id: str) -> dict:
|
||||
"""Erledigtes/Verworfenes aus der Sicht räumen (Kanban-Archiv, nichts wird gelöscht)."""
|
||||
if not _available():
|
||||
return {"ok": False, "error": "Die Ideen-Queue lebt auf der Box."}
|
||||
if not _TASK_ID_RX.match(task_id or ""):
|
||||
return {"ok": False, "error": f"Keine gültige Aufgaben-Nummer: {task_id!r}"}
|
||||
try:
|
||||
r = _hermes(["archive", task_id])
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": f"Queue nicht erreichbar: {exc}"}
|
||||
if r.returncode != 0:
|
||||
return {"ok": False, "error": (r.stderr or r.stdout or "Archivieren fehlgeschlagen").strip()[:300]}
|
||||
_list_cache["ts"] = 0.0
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def log_of(task_id: str) -> dict:
|
||||
"""Worker-Log einer Idee (hermes kanban log <id>), ANSI entfernt, letzte ~120 Zeilen."""
|
||||
if not _available():
|
||||
return {"available": False, "lines": []}
|
||||
if not _TASK_ID_RX.match(task_id or ""):
|
||||
return {"available": True, "lines": []}
|
||||
|
||||
now = time.time()
|
||||
if (
|
||||
_log_cache["data"] is not None
|
||||
and _log_cache["task_id"] == task_id
|
||||
and now - _log_cache["ts"] < _LOG_EVERY
|
||||
):
|
||||
return _log_cache["data"]
|
||||
|
||||
try:
|
||||
r = _hermes(["log", task_id])
|
||||
except Exception as exc:
|
||||
log.warning("ideen: kanban log fehlgeschlagen", exc_info=True)
|
||||
return {"available": True, "lines": []}
|
||||
if r.returncode != 0:
|
||||
log.warning("ideen: kanban log exit=%s stderr=%s", r.returncode, r.stderr)
|
||||
return {"available": True, "lines": []}
|
||||
|
||||
raw = r.stdout or ""
|
||||
# ANSI-Steuerzeichen entfernen
|
||||
ansi = re.compile(r"\x1b\[[0-9;]*m")
|
||||
cleaned = ansi.sub("", raw)
|
||||
|
||||
# Letzte ~120 Zeilen
|
||||
lines = cleaned.strip().splitlines()[-120:]
|
||||
data = {"available": True, "lines": lines}
|
||||
_log_cache.update(ts=now, task_id=task_id, data=data)
|
||||
return data
|
||||
+513
-117
@@ -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 {})},
|
||||
)
|
||||
_PROCS[job_id] = proc
|
||||
|
||||
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 _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
|
||||
|
||||
_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")
|
||||
|
||||
# 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: # noqa: BLE001
|
||||
_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":
|
||||
try:
|
||||
cb()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_append_log(job, f"[mc] Nachbearbeitung-Fehler: {exc}")
|
||||
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"])
|
||||
|
||||
def zu_lang() -> None:
|
||||
job["zeitlimit"] = True
|
||||
_protokoll(job_id, f"[mc] Zeitlimit ({grenze // 60} min) überschritten, Auftrag wird beendet.")
|
||||
_beende_gruppe(proc)
|
||||
|
||||
wecker = threading.Timer(grenze, zu_lang)
|
||||
wecker.daemon = True
|
||||
wecker.start()
|
||||
|
||||
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:
|
||||
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
|
||||
job["total_bytes"] = total_bytes
|
||||
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)
|
||||
@@ -136,7 +406,7 @@ def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> N
|
||||
j["rate_bps"] = rate
|
||||
j["eta_s"] = int((total_bytes - cur) / rate)
|
||||
prev_t, prev_b = now, cur
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1.0)
|
||||
j = JOBS.get(job_id)
|
||||
@@ -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:
|
||||
job = JOBS.get(job_id)
|
||||
if not job or job["state"] in ("done", "failed", "canceled"):
|
||||
return False
|
||||
job["canceled"] = True
|
||||
_append_log(job, "[mc] Abbruch angefordert…")
|
||||
proc = _PROCS.get(job_id)
|
||||
if proc is not None:
|
||||
with _LOCK:
|
||||
_laden()
|
||||
job = JOBS.get(job_id)
|
||||
if not job or job["state"] in _ENDE:
|
||||
return False
|
||||
job["canceled"] = True
|
||||
_speichern(job)
|
||||
_protokoll(job_id, "[mc] Abbruch angefordert…")
|
||||
if job["state"] == "queued":
|
||||
_abschliessen(job, None)
|
||||
elif job.get("art") == "systemd":
|
||||
try:
|
||||
proc.terminate()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
subprocess.run(_systemd("systemctl", "stop", "--no-block", _einheit(job_id)),
|
||||
capture_output=True, timeout=15)
|
||||
except Exception:
|
||||
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)
|
||||
|
||||
+282
-96
@@ -6,17 +6,32 @@ 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 (
|
||||
CMD_TEMPLATE,
|
||||
CONFIG_PATH,
|
||||
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
|
||||
|
||||
from config import (
|
||||
CMD_TEMPLATE, CONFIG_PATH, DEFAULT_TTL, DRAFTS_DIR, LLAMA_SWAP_URL,
|
||||
SPEC_DRAFT_MODEL_PATH, SPEC_DRAFT_N_MAX, SPEC_TYPE,
|
||||
)
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError: # Windows (Entwicklung): nur die Prozess-Sperre
|
||||
fcntl = None
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -48,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
|
||||
@@ -143,19 +227,36 @@ def model_id_from_path(model_path: str) -> str:
|
||||
Split-GGUFs liegen oft in einem Quant-Unterordner (…/Q4_K_M/file-00001-of-…) →
|
||||
dann eine Ebene höher (Repo-Ordner) nehmen, sonst hieße das Modell 'Q4_K_M'."""
|
||||
d = os.path.basename(os.path.dirname(model_path))
|
||||
if re.fullmatch(r"(I?Q\d[\w]*|UD-Q\d[\w]*|F16|BF16|FP16|F32)", d, flags=re.I):
|
||||
if re.fullmatch(r"(I?Q\d[\w]*|UD-Q\d[\w]*|F16|BF16|FP16|F32)", d, flags=re.IGNORECASE):
|
||||
d = os.path.basename(os.path.dirname(os.path.dirname(model_path)))
|
||||
name = re.sub(r"[-_]?GGUF$", "", d, flags=re.I).strip("-_")
|
||||
name = re.sub(r"[-_]?GGUF$", "", d, flags=re.IGNORECASE).strip("-_")
|
||||
if not name:
|
||||
fn = re.sub(r"\.gguf$", "", os.path.basename(model_path), flags=re.I)
|
||||
fn = re.sub(r"\.gguf$", "", os.path.basename(model_path), flags=re.IGNORECASE)
|
||||
fn = re.sub(r"-\d+-of-\d+$", "", fn)
|
||||
name = re.sub(r"[-_](Q\d[\w]*|IQ\d[\w]*|F16|BF16|FP16|F32)$", "", fn, flags=re.I)
|
||||
name = re.sub(r"[-_](Q\d[\w]*|IQ\d[\w]*|F16|BF16|FP16|F32)$", "", fn, flags=re.IGNORECASE)
|
||||
return name or "modell"
|
||||
|
||||
|
||||
# Lebenswichtige Aliase: hängen direkt an Lucys Denk- und Gedächtnis-Pfad. Sie dürfen
|
||||
# beim Rollen-Umhängen NIE stillschweigend verloren gehen (Review 16.07.: Qwen3.6 hält
|
||||
# live `hermes` UND `fast` — ein Rollen-Klick hätte beide gelöscht → Lucy tot).
|
||||
PROTECTED_ALIASES = {"hermes", "embed"}
|
||||
|
||||
|
||||
def protected_alias_of(model_id: str) -> str | None:
|
||||
"""Hält dieses Modell gerade einen lebenswichtigen Alias? (für Guards in der API)"""
|
||||
spec = (read_config().get("models") or {}).get(model_id)
|
||||
if isinstance(spec, dict):
|
||||
for a in spec.get("aliases") or []:
|
||||
if str(a).lower() in PROTECTED_ALIASES:
|
||||
return str(a).lower()
|
||||
return None
|
||||
|
||||
|
||||
def set_role_alias(cfg: dict, model_id: str, role: str | None) -> None:
|
||||
"""Rolle als eindeutigen llama-swap-`aliases`-Eintrag setzen (vorher bei allen
|
||||
anderen Modellen entfernen). role=None/leer entfernt den Alias."""
|
||||
"""Rolle als llama-swap-`aliases`-Eintrag setzen (vorher bei allen anderen Modellen
|
||||
entfernen — deren übrige Aliase bleiben). role=None/leer entfernt die Rolle.
|
||||
Lebenswichtige Aliase (PROTECTED_ALIASES) des Ziel-Modells bleiben IMMER erhalten."""
|
||||
models = cfg.get("models") or {}
|
||||
role = (role or "").strip().lower()
|
||||
if role:
|
||||
@@ -169,8 +270,11 @@ def set_role_alias(cfg: dict, model_id: str, role: str | None) -> None:
|
||||
spec.pop("aliases", None)
|
||||
spec = models.get(model_id)
|
||||
if isinstance(spec, dict):
|
||||
if role and role != model_id.lower():
|
||||
spec["aliases"] = [role]
|
||||
keep = [a for a in (spec.get("aliases") or [])
|
||||
if str(a).lower() in PROTECTED_ALIASES and str(a).lower() != role]
|
||||
new = keep + ([role] if role and role != model_id.lower() else [])
|
||||
if new:
|
||||
spec["aliases"] = new
|
||||
else:
|
||||
spec.pop("aliases", None)
|
||||
|
||||
@@ -187,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")
|
||||
@@ -202,7 +314,7 @@ def write_config(cfg: dict) -> None:
|
||||
try:
|
||||
from services import warmer
|
||||
warmer.nudge()
|
||||
except Exception: # noqa: BLE001 — Vorwärmen ist Komfort, nie ein Schreib-Blocker
|
||||
except Exception:
|
||||
pass
|
||||
except PermissionError as exc:
|
||||
raise PermissionError(
|
||||
@@ -211,12 +323,15 @@ 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) -> str:
|
||||
jinja: bool = False, set_alias: bool = True) -> str:
|
||||
"""Ein GGUF als llama-swap-Modell eintragen (cmd + Rolle-Alias). Gibt die
|
||||
Modell-ID zurück. jinja=True erzwingt --jinja (Tool-Calling, z.B. fürs Agent-Hirn)."""
|
||||
Modell-ID zurück. jinja=True erzwingt --jinja (Tool-Calling, z.B. fürs Agent-Hirn).
|
||||
set_alias=False: Rolle nur für die cmd-Flags nutzen, den Alias aber NICHT umhängen —
|
||||
der Install-Flow setzt ihn erst NACH fertigem Download (sonst zeigt die Rolle
|
||||
minutenlang auf eine Datei, die noch gar nicht existiert)."""
|
||||
cfg = read_config()
|
||||
model_id = model_id_from_path(model_path)
|
||||
cmd = CMD_TEMPLATE.replace("{model}", model_path).replace("{ctx}", str(ctx))
|
||||
@@ -233,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
|
||||
@@ -242,11 +357,19 @@ def register_model(model_path: str, role: str | None = None, ctx: int = 8192,
|
||||
if "--spec-draft-model" not in cmd and "--model-draft" not in cmd:
|
||||
cmd += spec_draft_flags(model_path)
|
||||
|
||||
cfg.setdefault("models", {})[model_id] = {
|
||||
# Bestehende Aliase des Eintrags erhalten (Re-Install/Upgrade desselben Repos):
|
||||
# die Rolle soll während des Downloads beim ALTEN Stand bleiben.
|
||||
old_aliases = (cfg.get("models") or {}).get(model_id, {})
|
||||
old_aliases = old_aliases.get("aliases") if isinstance(old_aliases, dict) else None
|
||||
entry: dict = {
|
||||
"cmd": LiteralScalarString(cmd + "\n"),
|
||||
"ttl": ttl if ttl is not None else DEFAULT_TTL,
|
||||
}
|
||||
set_role_alias(cfg, model_id, role)
|
||||
if old_aliases:
|
||||
entry["aliases"] = old_aliases
|
||||
cfg.setdefault("models", {})[model_id] = entry
|
||||
if set_alias:
|
||||
set_role_alias(cfg, model_id, role)
|
||||
write_config(cfg)
|
||||
return model_id
|
||||
|
||||
@@ -332,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 →
|
||||
@@ -408,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
|
||||
@@ -427,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)."""
|
||||
@@ -438,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)."""
|
||||
@@ -466,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.
|
||||
@@ -477,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):
|
||||
@@ -506,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:
|
||||
@@ -514,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
|
||||
@@ -574,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)}"}
|
||||
|
||||
+264
-264
@@ -11,15 +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"}
|
||||
USER_SERVICES = {"mission-control-2", "hermes-gateway", "hermes-builtin-ui", "mem0-service", "voice-service"}
|
||||
# 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", "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__), "..", ".."))
|
||||
@@ -34,13 +50,16 @@ _engine_cache = {"ts": 0.0, "avail": False}
|
||||
# manchmal noch keine CI-Assets (0 Assets) → ihr Download-Link 404t. Sowohl der Update-Check
|
||||
# als auch der Download (update-engine.sh) müssen daher die neueste ASSET-tragende Release
|
||||
# nehmen, sonst zeigt das UI „Update verfügbar", das dann beim Einspielen scheitert.
|
||||
_ENGINE_ASSET_RX = re.compile(r"ubuntu-vulkan-x64\.tar\.gz$", re.I)
|
||||
_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
|
||||
@@ -72,8 +91,9 @@ def _installed_engine_build() -> int | None:
|
||||
out = subprocess.run([bin_path, "--version"], capture_output=True, text=True,
|
||||
timeout=20, env=env)
|
||||
txt = (out.stderr or "") + (out.stdout or "")
|
||||
# Formate je nach Build: "version: 9821 (hash)" (aktuell), "build: <hash> (9821)", "b9821".
|
||||
if (m := re.search(r"version:\s*(\d{3,})", txt)) \
|
||||
# Formate je nach Build: "version: 0.1.0-dev (build 10426, ...)", "version: 9821 (hash)", "build: <hash> (9821)", "b9821".
|
||||
if (m := re.search(r"\bbuild\s+(\d{3,})\b", txt)) \
|
||||
or (m := re.search(r"version:\s*(\d{3,})", txt)) \
|
||||
or (m := re.search(r"build:\s*\S+\s*\((\d+)\)", txt)) \
|
||||
or (m := re.search(r"\bb(\d{3,})\b", txt)):
|
||||
return int(m.group(1))
|
||||
@@ -82,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
|
||||
|
||||
|
||||
@@ -103,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()
|
||||
@@ -113,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
|
||||
|
||||
@@ -138,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
|
||||
|
||||
@@ -170,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}"],
|
||||
@@ -192,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[:-5] if base.endswith("-gguf") else base
|
||||
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):
|
||||
@@ -306,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,
|
||||
"swap": 1 if _swap_update_available() else 0,
|
||||
"models": len(ups), "model_list": ups, "last_check": _last_apt_update(),
|
||||
"components": _components_cached()}
|
||||
"""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,
|
||||
"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 ──
|
||||
@@ -330,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())
|
||||
@@ -342,7 +328,7 @@ def _os_held_back() -> list[dict]:
|
||||
held.append({"name": name, "reason": reason})
|
||||
elif reason: # nicht eingerückt → Abschnitt zu Ende
|
||||
reason = None
|
||||
except Exception: # noqa: BLE001 — nur Zusatzinfo, nie ein Blocker
|
||||
except Exception:
|
||||
pass
|
||||
held.sort(key=lambda p: p["name"])
|
||||
return held
|
||||
@@ -365,9 +351,12 @@ 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()}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# 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)}
|
||||
|
||||
|
||||
@@ -392,7 +381,7 @@ def engine_update_details() -> dict:
|
||||
ctx = ("Es geht um ein Update der Inferenz-Engine llama.cpp (Vulkan-Build, treibt alle "
|
||||
"Sprachmodelle der Box auf der AMD-Strix-Halo-GPU).")
|
||||
info.update(_summarize_release("engine", tag, ctx, body[:6000]))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except Exception as exc:
|
||||
info["error"] = str(exc)
|
||||
return info
|
||||
|
||||
@@ -403,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
|
||||
@@ -417,7 +405,7 @@ def swap_update_details() -> dict:
|
||||
ctx = ("Es geht um ein Update von llama-swap (der Router, der Anfragen an die Box "
|
||||
"verteilt und Sprachmodelle heiß nachlädt).")
|
||||
info.update(_summarize_release("swap", tag, ctx, body[:6000]))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except Exception as exc:
|
||||
info["error"] = str(exc)
|
||||
return info
|
||||
|
||||
@@ -495,7 +483,7 @@ def _summarize_release(kind: str, key: str, context: str, changes: str) -> dict:
|
||||
if text:
|
||||
cache.update(key=key, data=data)
|
||||
return data
|
||||
except Exception as exc: # noqa: BLE001 — Zusammenfassung ist Komfort, nie Blocker
|
||||
except Exception as exc:
|
||||
return {"summary": f"(Zusammenfassung nicht verfügbar: {exc})",
|
||||
"action_needed": None, "action_text": ""}
|
||||
|
||||
@@ -503,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": []}
|
||||
@@ -516,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 = []
|
||||
@@ -533,7 +544,7 @@ def hermes_update_details() -> dict:
|
||||
info["behind"] = len(commits)
|
||||
if commits:
|
||||
info.update(_summarize_hermes_commits(commits))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
except Exception as exc:
|
||||
info["error"] = str(exc)
|
||||
return info
|
||||
|
||||
@@ -544,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")
|
||||
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: # noqa: BLE001
|
||||
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"])
|
||||
@@ -607,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`) —
|
||||
@@ -646,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:
|
||||
@@ -716,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
|
||||
@@ -773,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."}
|
||||
|
||||
@@ -782,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
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
"""
|
||||
Geteiltes Gedächtnis (die „Verfassung") — jetzt auto-lernend & semantisch über Mem0.
|
||||
|
||||
Dieser Service ist nur noch ein dünner HTTP-Client auf den Mem0-Sidecar (mem0_service/app.py,
|
||||
läuft im ~/.mem0/venv unter Python 3.12). Die `/api/memory`-API-Form bleibt unverändert, damit
|
||||
UI und MCP-Server kompatibel bleiben. Neu gegenüber der alten flachen SQLite:
|
||||
|
||||
- search (q gesetzt) ist SEMANTISCH (Vektor/Embeddings) statt LIKE-Textsuche, mit Relevanz-Score.
|
||||
- learn() reicht Gesprächs-Turns durch → Mem0 EXTRAHIERT Fakten selbst (Auto-Lernen).
|
||||
- Dedup macht Mem0 beim Auto-Lernen selbst; der manuelle Kurator unten bleibt als Komfort.
|
||||
|
||||
5 Kategorien (user · instruction · stable · versioned · ephemeral) bleiben als Metadaten erhalten.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
import httpx
|
||||
|
||||
from config import MEM0_SERVICE_URL
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
CATEGORIES = ("identity", "knowledge", "rules", "events")
|
||||
|
||||
_TIMEOUT = httpx.Timeout(60.0, connect=5.0) # LLM-Extraktion kann ein paar Sekunden dauern
|
||||
|
||||
|
||||
def _get(path: str, **params) -> list | dict:
|
||||
r = httpx.get(f"{MEM0_SERVICE_URL}{path}", params=params, timeout=_TIMEOUT)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def _post(path: str, data: dict) -> dict:
|
||||
r = httpx.post(f"{MEM0_SERVICE_URL}{path}", json=data, timeout=_TIMEOUT)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def _put(path: str, data: dict) -> dict:
|
||||
r = httpx.put(f"{MEM0_SERVICE_URL}{path}", json=data, timeout=_TIMEOUT)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def _delete(path: str) -> dict:
|
||||
r = httpx.delete(f"{MEM0_SERVICE_URL}{path}", timeout=_TIMEOUT)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def list_memories(q: str = "", category: str = "") -> list[dict]:
|
||||
"""Alle Fakten oder — wenn q gesetzt — die semantisch ähnlichsten (mit `score`)."""
|
||||
return _get("/memory", **{k: v for k, v in (("q", q), ("category", category)) if v})
|
||||
|
||||
|
||||
def add_memory(content: str, category: str = "stable", source: str = "manual") -> dict:
|
||||
"""Einen Fakt VERBATIM speichern (keine LLM-Umformung). Auto-Lernen → learn()."""
|
||||
return _post("/memory", {"content": content.strip(), "category": category, "source": source})
|
||||
|
||||
|
||||
def update_memory(mid: str, content: str | None = None, category: str | None = None) -> dict | None:
|
||||
try:
|
||||
return _put(f"/memory/{mid}", {"content": content, "category": category})
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response.status_code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
|
||||
def delete_memory(mid: str) -> bool:
|
||||
try:
|
||||
_delete(f"/memory/{mid}")
|
||||
return True
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response.status_code == 404:
|
||||
return False
|
||||
raise
|
||||
|
||||
|
||||
def learn(text: str | None = None, messages: list[dict] | None = None,
|
||||
source: str = "auto", category: str = "stable") -> dict:
|
||||
"""Auto-Lernen: Text/Gesprächs-Turns durchreichen → Mem0 extrahiert die Fakten selbst."""
|
||||
return _post("/learn", {"text": text, "messages": messages,
|
||||
"source": source, "category": category})
|
||||
|
||||
|
||||
def graph(min_score: float = 0.45, top_k: int = 3) -> dict:
|
||||
"""Fakten als Ähnlichkeits-Graph (Knoten + semantische Kanten) für die UI-Visualisierung."""
|
||||
return _get("/graph", min_score=min_score, top_k=top_k)
|
||||
|
||||
|
||||
def export_text() -> dict:
|
||||
rows = sorted(list_memories(), key=lambda r: (r.get("category", ""), r.get("updated_at", "")))
|
||||
lines = ["# Mission Control — Gedächtnis\n"]
|
||||
current = ""
|
||||
for r in rows:
|
||||
if r.get("category") != current:
|
||||
current = r.get("category", "")
|
||||
lines.append(f"\n## {current}\n")
|
||||
src = r.get("source", "")
|
||||
when = (r.get("updated_at") or "")[:10]
|
||||
lines.append(f"- {r.get('content', '')} _(Quelle: {src}, {when})_")
|
||||
return {"text": "\n".join(lines), "count": len(rows)}
|
||||
|
||||
|
||||
# --- Manueller Kurator (deterministisch, kein LLM) ---------------------------
|
||||
def _norm(s: str) -> str:
|
||||
s = re.sub(r"[^\w\s]", " ", s.lower(), flags=re.UNICODE)
|
||||
return re.sub(r"\s+", " ", s).strip()
|
||||
|
||||
|
||||
def dedupe(apply: bool = False, threshold: float = 0.85) -> dict:
|
||||
"""Findet Dubletten (exakt/enthalten/ähnlich) je Kategorie, behält den längsten
|
||||
Eintrag. Mem0 dedupliziert beim Auto-Lernen schon semantisch — das hier ist der
|
||||
manuelle Komfort-Knopf fürs UI (z.B. nach vielen Verbatim-Importen)."""
|
||||
rows = sorted(list_memories(), key=lambda r: (-len(r.get("content", "")), r.get("created_at", "")))
|
||||
used: set[str] = set()
|
||||
groups: list[dict] = []
|
||||
for i, a in enumerate(rows):
|
||||
if a["id"] in used:
|
||||
continue
|
||||
na = _norm(a.get("content", ""))
|
||||
if not na:
|
||||
continue
|
||||
dups = []
|
||||
for b in rows[i + 1:]:
|
||||
if b["id"] in used or b.get("category") != a.get("category"):
|
||||
continue
|
||||
nb = _norm(b.get("content", ""))
|
||||
if not nb:
|
||||
continue
|
||||
if nb in na or na in nb or SequenceMatcher(None, na, nb).ratio() >= threshold:
|
||||
dups.append(b); used.add(b["id"])
|
||||
if dups:
|
||||
used.add(a["id"])
|
||||
groups.append({
|
||||
"keep": {"id": a["id"], "content": a.get("content"), "category": a.get("category")},
|
||||
"remove": [{"id": d["id"], "content": d.get("content")} for d in dups],
|
||||
})
|
||||
dup_count = sum(len(g["remove"]) for g in groups)
|
||||
removed = 0
|
||||
if apply:
|
||||
for g in groups:
|
||||
for d in g["remove"]:
|
||||
if delete_memory(d["id"]):
|
||||
removed += 1
|
||||
return {"groups": groups, "duplicate_count": dup_count, "removed": removed, "applied": apply}
|
||||
|
||||
|
||||
# ── Auto-Dedupe (D16e): Dubletten von selbst wegräumen, kein Knopf-Zwang ─────────────
|
||||
# Deterministisch in UNSERER Schicht (wie reminders, Verdikt 11c) statt am Hermes-cron —
|
||||
# läuft als Hintergrund-Task. Höhere Schwelle als der manuelle Knopf (0.9 statt 0.85), weil
|
||||
# OHNE Mensch-Review angewandt wird: lieber eine Dublette stehen lassen als etwas Distinktes
|
||||
# löschen. Mem0 dedupliziert beim Auto-Lernen schon semantisch — das hier fängt den Rest
|
||||
# (Verbatim-Importe, wiederholte Fakten) ab, damit das Gedächtnis nicht ohne Zutun aufbläht.
|
||||
AUTO_DEDUPE_ENABLED = os.environ.get("MC_MEM_DEDUPE_ENABLED", "1") != "0"
|
||||
AUTO_DEDUPE_INTERVAL = int(os.environ.get("MC_MEM_DEDUPE_INTERVAL", str(24 * 3600))) # täglich
|
||||
AUTO_DEDUPE_START_DELAY = int(os.environ.get("MC_MEM_DEDUPE_START_DELAY", "300")) # 5 min nach Start
|
||||
AUTO_DEDUPE_THRESHOLD = float(os.environ.get("MC_MEM_DEDUPE_THRESHOLD", "0.75"))
|
||||
|
||||
|
||||
async def auto_dedupe_loop() -> None:
|
||||
"""Hintergrund-Task: räumt periodisch Gedächtnis-Dubletten weg (apply=True)."""
|
||||
await asyncio.sleep(AUTO_DEDUPE_START_DELAY) # Mem0-Sidecar nach Start hochkommen lassen
|
||||
while True:
|
||||
try:
|
||||
res = await asyncio.to_thread(dedupe, True, AUTO_DEDUPE_THRESHOLD) # sync HTTP → Thread
|
||||
if res.get("removed"):
|
||||
log.info("mem-dedupe: %d Dublette(n) automatisch entfernt", res["removed"])
|
||||
except Exception:
|
||||
log.debug("mem-dedupe: Lauf fehlgeschlagen", exc_info=True)
|
||||
await asyncio.sleep(AUTO_DEDUPE_INTERVAL)
|
||||
@@ -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)}}
|
||||
@@ -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
@@ -21,15 +21,15 @@ import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from config import MODELS_DIR
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
from services import announce
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
STORE_PATH = Path(os.environ.get("MC_REMINDERS_STORE", str(MODELS_DIR / "mc2-reminders.json")))
|
||||
LOCAL_TZ = ZoneInfo(os.environ.get("MC_LOCAL_TZ", "Europe/Berlin"))
|
||||
INTERVAL = int(os.environ.get("MC_REMINDERS_INTERVAL", "20")) # Wecker-Tick (Sekunden)
|
||||
MAX_ITEMS = int(os.environ.get("MC_REMINDERS_MAX", "100"))
|
||||
REPEATS = ("", "daily", "weekdays", "weekly")
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
"""
|
||||
Rollen-Empfehlung: welches INSTALLIERTE Modell passt am besten auf eine Serving-Rolle?
|
||||
Capability-getrieben (Vision/Coder/Tools/MoE aus services.caps) + setup-bewusster Fit
|
||||
(services.budget). Speist den 'Empfohlen'-Hinweis + Auto-Pick im Rollen-Zuweisungs-Modal.
|
||||
|
||||
EINE Quelle der Wahrheit mit der ctx-/Fit-Logik: nutzt budget.setup_aware_ctx_for_model
|
||||
und fit.evaluate_fit — dieselbe Mathematik wie Install-Automatik und Auto-ctx-Button.
|
||||
"""
|
||||
|
||||
import psutil
|
||||
|
||||
from services import budget, catalog, llamaswap
|
||||
from services.fit import evaluate_fit
|
||||
|
||||
|
||||
def _ram_gb() -> float:
|
||||
return psutil.virtual_memory().total / (1024 ** 3)
|
||||
|
||||
|
||||
def _capability_suit(role: str, caps: dict, name: str) -> float:
|
||||
"""0..1 — Capability-Eignung (HARTE Gates). 0 = grundsätzlich falsch für die Rolle.
|
||||
Größe/Tempo bewertet getrennt _pref(), damit z.B. 'fast' nicht das größte Modell zieht."""
|
||||
role = (role or "").lower()
|
||||
low = (name or "").lower()
|
||||
vision = bool(caps.get("vision"))
|
||||
coder = bool(caps.get("coder"))
|
||||
tools = caps.get("tools") != "no"
|
||||
|
||||
if role == "vision":
|
||||
if not vision:
|
||||
return 0.0 # harte Anforderung
|
||||
return 1.0 if ("vl" in low or "llava" in low or "pixtral" in low) else 0.7 # dediziert > omni
|
||||
if role == "coder":
|
||||
return 1.0 if coder else 0.4 # Coder-Modell Pflicht für Empfehlung
|
||||
if role == "hermes":
|
||||
# Agent-Hirn: Hermes-Familie am robustesten; sonst natives Tool-Calling Pflicht.
|
||||
if "hermes" in low:
|
||||
return 1.0
|
||||
return 0.6 if tools else 0.1
|
||||
if role == "fast":
|
||||
# Alltags-Hirn braucht zuverlässige Tools; Größe/Tempo macht _pref.
|
||||
return 1.0 if tools else 0.6
|
||||
if role == "heavy":
|
||||
return 1.0
|
||||
if role == "scout":
|
||||
return 0.9 if vision else 0.7
|
||||
return 0.5
|
||||
|
||||
|
||||
def _pref(role: str, params: float, tps: float) -> float:
|
||||
"""0..1 — rollengerechte GRÖSSEN-/TEMPO-Präferenz. 'fast' belohnt Tempo & Kleinheit,
|
||||
'heavy' Größe (Wissen), 'hermes' moderate Größe (muss warm + ko-resident bleiben)."""
|
||||
role = (role or "").lower()
|
||||
if role == "fast":
|
||||
speed = min(tps / 25.0, 1.0)
|
||||
size_ok = 1.0 if params <= 50 else 50.0 / params
|
||||
return speed * size_ok
|
||||
if role == "heavy":
|
||||
return min(params / 120.0, 1.0)
|
||||
if role == "hermes":
|
||||
return 1.0 if params <= 24 else max(0.15, 24.0 / params) # 7–24B ideal als Hirn
|
||||
if role == "vision":
|
||||
return 1.0 if params <= 12 else 0.7 # klein/günstig bevorzugt
|
||||
if role == "coder":
|
||||
return 0.5 + 0.5 * min(params / 80.0, 1.0)
|
||||
if role == "scout":
|
||||
return 1.0 if params <= 40 else 0.5
|
||||
return 0.5
|
||||
|
||||
|
||||
def _catalog_role_match(role: str, name: str) -> bool:
|
||||
"""Ist dieses Modell im kuratierten Katalog (Cookbook) genau für DIESE Rolle gelistet?
|
||||
Dann ist es der prinzipien-konforme Pick → starker Bonus."""
|
||||
meta = catalog.meta_for_name(name)
|
||||
return bool(meta and (meta.get("role") or "").lower() == (role or "").lower())
|
||||
|
||||
|
||||
def _reason(role: str, caps: dict, name: str, fit: dict, fits: bool,
|
||||
incomplete: bool, cat_match: bool) -> str:
|
||||
if incomplete:
|
||||
return "Download unvollständig"
|
||||
if role == "vision" and not caps.get("vision"):
|
||||
return "keine Vision-Fähigkeit"
|
||||
if role == "coder" and not caps.get("coder"):
|
||||
return "kein Coder-Modell"
|
||||
if role == "hermes" and "hermes" not in (name or "").lower() and caps.get("tools") == "no":
|
||||
return "kein natives Tool-Calling"
|
||||
if not fits:
|
||||
return "passt nicht ins Budget (OOM)"
|
||||
bits = []
|
||||
if cat_match:
|
||||
bits.append("Katalog-Pick ✓")
|
||||
if role == "vision":
|
||||
bits.append("Vision ✓")
|
||||
if role == "coder" and caps.get("coder"):
|
||||
bits.append("Coder ✓")
|
||||
if role == "hermes":
|
||||
bits.append("Hermes" if "hermes" in (name or "").lower()
|
||||
else ("Tools ✓" if caps.get("tools") != "no" else "ohne Tools"))
|
||||
if caps.get("moe"):
|
||||
bits.append("MoE")
|
||||
bits.append(f"{fit['text']}, ~{fit['tps']:.0f} t/s")
|
||||
return " · ".join(bits)
|
||||
|
||||
|
||||
def recommend_for_role(role: str) -> dict:
|
||||
"""Rankt alle installierten Modelle für eine Rolle. Empfohlen = bester geeigneter,
|
||||
passender Eintrag. Liefert pro Modell Fit/Eignung/Begründung fürs UI."""
|
||||
role = (role or "").strip().lower()
|
||||
ram = _ram_gb()
|
||||
out = []
|
||||
for m in llamaswap.list_models():
|
||||
caps = m.get("capabilities") or {}
|
||||
params = budget.params_of_model(m)
|
||||
quant = m.get("quant") or "Q4_K_M"
|
||||
ctx = budget.setup_aware_ctx_for_model(m)["ctx"]
|
||||
fit = evaluate_fit(params, quant, ctx, ram, name=m["name"])
|
||||
incomplete = bool(m.get("incomplete"))
|
||||
fits = (fit["level"] != "too_tight") and not incomplete
|
||||
tps = fit["tps"] or 0
|
||||
suit = _capability_suit(role, caps, m["name"])
|
||||
cat_match = _catalog_role_match(role, m["name"])
|
||||
suitable = suit >= 0.5 and fits
|
||||
|
||||
fit_term = {"perfect": 1.0, "marginal": 0.3}.get(fit["level"], -2.0)
|
||||
# Eignung dominiert (×2), rollengerechte Größe/Tempo (_pref), Katalog-Anker, dann Fit.
|
||||
score = (2.0 * suit) + _pref(role, params, tps) + (0.6 if cat_match else 0.0) + fit_term
|
||||
if not fits:
|
||||
score -= 5.0
|
||||
|
||||
out.append({
|
||||
"name": m["name"], "current_role": m.get("role"),
|
||||
"params_b": round(params, 1), "quant": quant,
|
||||
"fit": fit, "suitable": suitable, "incomplete": incomplete,
|
||||
"score": round(score, 3),
|
||||
"reason": _reason(role, caps, m["name"], fit, fits, incomplete, cat_match),
|
||||
})
|
||||
|
||||
out.sort(key=lambda x: -x["score"])
|
||||
rec = next((o["name"] for o in out if o["suitable"]), None)
|
||||
for o in out:
|
||||
o["recommended"] = (o["name"] == rec)
|
||||
return {"role": role, "recommended": rec, "models": out}
|
||||
@@ -3,7 +3,7 @@ Lane-Routing für den eingebauten MC2-Gateway (:9001/v1).
|
||||
|
||||
Zwei virtuelle Lanes, die Clients/IDEs auswählen — der Router pickt das echte Modell:
|
||||
- **chat** (= altes `auto`): Alltag → `fast`, schwer/lang → `heavy`.
|
||||
- **coding**: Code-Arbeit → `coder` (Qwen3-Coder-Next); riesiger/architektonischer Kontext → `heavy`;
|
||||
- **coding**: Code-Arbeit → `coder`; riesiger/architektonischer Kontext → `heavy`;
|
||||
triviale Kurzfrage ohne Code → `fast` (Tempo).
|
||||
|
||||
Regelbasiert, sub-ms, ohne Cloud. Schwellen/Aliases liegen in einer UI-editierbaren Policy
|
||||
@@ -19,8 +19,6 @@ from services.routing_policy import load_policy
|
||||
# (routing_policy.py) und kommen pro Request via load_policy() (hot-reload). Die Env-Vars
|
||||
# sind dort die Defaults. Die Regex-Keyword-Listen unten bleiben bewusst im Code.
|
||||
|
||||
# Virtuelle Lanes, die im Gateway als „Modelle" sichtbar sind.
|
||||
LANES = ["coding", "chat"]
|
||||
LANE_ALIASES = {"auto": "chat"} # Rückwärtskompatibel: model:auto == chat
|
||||
|
||||
_HEAVY_KW = re.compile(
|
||||
@@ -36,23 +34,44 @@ _CODING_HEAVY_KW = re.compile(
|
||||
r"entwirf\s+(eine\s+)?architektur|plane?\s+(die\s+)?architektur)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Code-Indikatoren — verhindert, dass echte Code-Anfragen als „trivial" auf fast abrutschen.
|
||||
# Bewusst breit (inkl. natürlichsprachiger Coding-Begriffe DE+EN): in der coding-Lane soll im Zweifel
|
||||
# `coder` gewinnen; nur echte Nicht-Code-Kürze ("hallo", "wie spät") rutscht auf fast.
|
||||
_CODE_HINT = re.compile(
|
||||
r"```|\bdef \b|\bclass \b|\bimport \b|\bfunction\b|=>|;\s*$|"
|
||||
r"\.(py|ts|tsx|js|jsx|go|rs|java|cpp|c|rb|php|sql)\b|/src/|traceback|stack\s*trace|"
|
||||
r"\b(funktion|function|bug|fix|fehler|error|exception|implementier\w*|schreib\w*|"
|
||||
r"code\w*|coden|test\w*|klasse|method\w*|methode|refactor\w*|kompil\w*|compile|"
|
||||
r"build|deploy|debug|script|skript|api|endpoint|query|regex|json|yaml|"
|
||||
r"npm|pip|git|docker|terminal|shell|command)\b",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
# Bild-Weiche (Faden 11): Aliase, die selbst Bilder koennen — die werden NIE umgeroutet.
|
||||
VISION_CAPABLE = {"vision", "scout"}
|
||||
_IMAGE_PART_TYPES = {"image_url", "input_image", "image"}
|
||||
# Seit 24.09.2026 sind vision und coder-bild die Bild-Zwillinge von Hirn und Coder (gleiche Gewichte
|
||||
# plus Bild-Projektor, ohne Draft — llama.cpp kann Draft und Bild nicht zusammen, HTTP 500).
|
||||
VISION_CAPABLE = {"vision", "coder-bild", "scout"}
|
||||
IMAGE_PART_TYPES = {"image_url", "input_image", "image"}
|
||||
|
||||
|
||||
def ist_coder_alias(alias: str) -> bool:
|
||||
"""Coder-Ziele: coder, heavy (derselbe Coder) und rohe Coder-IDs."""
|
||||
a = (alias or "").lower()
|
||||
return "coder" in a or a == "heavy"
|
||||
|
||||
|
||||
def bilder_aufteilen(body: dict) -> tuple[list[tuple[dict, int]], list[tuple[dict, int]]]:
|
||||
"""Bild-Parts in (frisch, alt) teilen. Frisch = nach der letzten Antwort des Modells (der Schritt,
|
||||
um den es gerade geht: neue Nutzer-Nachricht oder Werkzeug-Ergebnis). Alt = davor — das Modell hat
|
||||
sie schon gesehen und seine Schlüsse im Verlauf. Jeder Eintrag ist (Nachricht, Index im content)."""
|
||||
msgs = [m for m in body.get("messages") or [] if isinstance(m, dict)]
|
||||
letzte_antwort = max((i for i, m in enumerate(msgs) if m.get("role") == "assistant"), default=-1)
|
||||
frisch: list[tuple[dict, int]] = []
|
||||
alt: list[tuple[dict, int]] = []
|
||||
for i, m in enumerate(msgs):
|
||||
if not isinstance(m.get("content"), list):
|
||||
continue
|
||||
for j, part in enumerate(m["content"]):
|
||||
if isinstance(part, dict) and part.get("type") in IMAGE_PART_TYPES:
|
||||
(frisch if i > letzte_antwort else alt).append((m, j))
|
||||
return frisch, alt
|
||||
|
||||
|
||||
def bild_ziel(alias: str, vision_alias: str, coder_vision_alias: str | None) -> str:
|
||||
"""Bild-Zwilling für eine Anfrage mit frischem Bild: Coder-Ziele an den Coder-Zwilling (falls es ihn
|
||||
gibt), alles andere an den Hirn-Zwilling (vision)."""
|
||||
if coder_vision_alias and ist_coder_alias(alias):
|
||||
return coder_vision_alias
|
||||
return vision_alias
|
||||
|
||||
|
||||
def has_image(body: dict) -> bool:
|
||||
@@ -64,7 +83,7 @@ def has_image(body: dict) -> bool:
|
||||
content = m.get("content")
|
||||
if isinstance(content, list):
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get("type") in _IMAGE_PART_TYPES:
|
||||
if isinstance(part, dict) and part.get("type") in IMAGE_PART_TYPES:
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -117,6 +136,3 @@ def choose_for_lane(lane: str, body: dict) -> tuple[str, str]:
|
||||
return _route_chat(text, n) # chat + alles Unbekannte
|
||||
|
||||
|
||||
def choose_model(body: dict) -> tuple[str, str]:
|
||||
"""Rückwärtskompatibel: altes `model:auto` == chat-Lane."""
|
||||
return choose_for_lane("chat", body)
|
||||
|
||||
@@ -33,6 +33,9 @@ DEFAULTS: dict = {
|
||||
# Bild-Weiche (Faden 11): Requests mit Bild-Anhang werden automatisch hierhin geroutet
|
||||
# (die MTP-Hirn-Config kann keine Bilder). "" schaltet die Weiche ab (Passthrough).
|
||||
"vision": os.environ.get("MC_ROUTE_VISION", "vision"),
|
||||
# Seit 24.09.2026: Bild-Zwilling des Coders — Coder-Anfragen mit neuem Bild gehen hierhin statt an vision.
|
||||
# "" = auch Coder-Bilder gehen an vision.
|
||||
"coder_vision": os.environ.get("MC_ROUTE_CODER_VISION", "coder-bild"),
|
||||
"heavy_chars": int(os.environ.get("MC_GATEWAY_HEAVY_CHARS", "8000")),
|
||||
"coding_escalate_chars": int(os.environ.get("MC_CODING_ESCALATE_CHARS", "120000")),
|
||||
"fast_no_think": _env_bool("MC_FAST_NO_THINK", "1"),
|
||||
@@ -45,6 +48,7 @@ FIELDS: list[dict] = [
|
||||
{"key": "coder", "label": "coder-Alias (coding: stark / Eskalation)", "type": "str"},
|
||||
{"key": "coder_lite", "label": "coder-lite-Alias (coding: schneller Default; leer = aus)", "type": "str"},
|
||||
{"key": "vision", "label": "vision-Alias (Bild-Weiche: Requests mit Bild; leer = aus)", "type": "str"},
|
||||
{"key": "coder_vision", "label": "Bild-Zwilling des Coders (leer = Coder-Bilder an vision)", "type": "str"},
|
||||
{"key": "heavy_chars", "label": "chat → heavy ab N Zeichen", "type": "int", "min": 500, "max": 1_000_000},
|
||||
{"key": "coding_escalate_chars", "label": "coding → starker Coder ab N Zeichen", "type": "int", "min": 1000, "max": 4_000_000},
|
||||
{"key": "fast_no_think", "label": "fast-Spur: Thinking aus (flotte Antworten)", "type": "bool"},
|
||||
@@ -81,7 +85,7 @@ def _coerce(patch: dict) -> dict:
|
||||
out[k] = bool(v)
|
||||
else: # str
|
||||
sv = str(v).strip()
|
||||
if k not in ("coder_lite", "vision") and not sv:
|
||||
if k not in ("coder_lite", "vision", "coder_vision") and not sv:
|
||||
raise ValueError(f"{k} darf nicht leer sein")
|
||||
out[k] = sv
|
||||
return out
|
||||
@@ -111,21 +115,3 @@ def load_policy() -> dict:
|
||||
return dict(_CACHE["policy"])
|
||||
|
||||
|
||||
def save_policy(patch: dict) -> dict:
|
||||
"""Validiert + persistiert atomar. Gibt die neue, vollständige Policy zurück."""
|
||||
clean = _coerce(patch) # wirft bei ungültigem Input
|
||||
with _LOCK:
|
||||
current = {**DEFAULTS, **_coerce_safe(_read_file()), **clean}
|
||||
POLICY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = POLICY_PATH.with_suffix(".json.tmp")
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(current, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, POLICY_PATH)
|
||||
_CACHE["mtime"] = None # nächster load_policy() lädt frisch
|
||||
_CACHE["policy"] = None
|
||||
return current
|
||||
|
||||
|
||||
def policy_meta() -> dict:
|
||||
"""Für den UI-Editor: aktuelle Werte + Defaults (für „Zurücksetzen“) + Feld-Spezifikation."""
|
||||
return {"policy": load_policy(), "defaults": dict(DEFAULTS), "fields": FIELDS}
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
"""
|
||||
Health-Wächter der Box (Lucy-Proaktivität, Faden A3).
|
||||
|
||||
Prüft periodisch die Kern-Dienste (Engine, Agent-Hirn, Hermes-Gateway, Mem0,
|
||||
Voice-Sidecar, Platte) und meldet ZUSTANDSWECHSEL in den Melde-Briefkasten
|
||||
(services/announce.py → Lucy spricht es) und via notify.sh (Telegram).
|
||||
|
||||
Flankenerkennung statt Dauerfeuer: Alarm erst nach FAIL_AFTER Fehl-Ticks in
|
||||
Folge (überlebt Neustarts/Update-Fenster), Entwarnung beim ersten grünen Tick
|
||||
nach einem Alarm. Hält ein Problem an, wird frühestens nach REMIND_S erinnert.
|
||||
|
||||
Braucht KEIN sudo, keine neuen Dienste — läuft als asyncio-Task im MC2-Backend
|
||||
(wie der Re-Warm-Wächter). Abschaltbar via MC_SENTRY_ENABLED=0.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import psutil
|
||||
|
||||
from config import HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, MODELS_DIR, VOICE_SERVICE_URL
|
||||
from services import announce, llamaswap
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
ENABLED = os.environ.get("MC_SENTRY_ENABLED", "1") != "0"
|
||||
INTERVAL = int(os.environ.get("MC_SENTRY_INTERVAL", "120")) # Sekunden zwischen Ticks
|
||||
START_DELAY = int(os.environ.get("MC_SENTRY_START_DELAY", "90")) # Dienste nach Boot setzen lassen
|
||||
FAIL_AFTER = int(os.environ.get("MC_SENTRY_FAIL_AFTER", "3")) # Fehl-Ticks bis Alarm (3×120s = 6 min)
|
||||
REMIND_S = int(os.environ.get("MC_SENTRY_REMIND_S", "21600")) # Erinnerung bei Dauerproblem: 6 h
|
||||
DISK_ALARM_PCT = float(os.environ.get("MC_SENTRY_DISK_PCT", "90"))
|
||||
|
||||
|
||||
def _reach(url: str, path: str = "/health") -> bool:
|
||||
try:
|
||||
with httpx.Client(timeout=5.0) as c:
|
||||
return c.get(f"{url}{path}").status_code < 500
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _check_engine() -> bool:
|
||||
return llamaswap.engine_reachable()
|
||||
|
||||
|
||||
def _check_brain() -> bool:
|
||||
"""Hirn tot? Verdrängung durch ein ANDERES laufendes Modell (IDE-Last) ist NORMAL —
|
||||
Alarm nur, wenn gar nichts läuft und das Hirn trotz Re-Warm-Wächter kalt bleibt."""
|
||||
st = llamaswap.brain_status()
|
||||
if st.get("ready"):
|
||||
return True
|
||||
return bool(llamaswap.get_running_models()) # anderes Modell aktiv → Verdrängung, kein Defekt
|
||||
|
||||
|
||||
def _check_disk() -> bool:
|
||||
try:
|
||||
return psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else os.getcwd()).percent < DISK_ALARM_PCT
|
||||
except Exception:
|
||||
return True # kein Messwert ≠ Alarm
|
||||
|
||||
|
||||
# name → (Checker, Alarm-Text, Entwarnungs-Text) — Texte sind Lucy-sprechbar (kurz, Alltagssprache).
|
||||
CHECKS: dict[str, tuple] = {
|
||||
"engine": (_check_engine,
|
||||
"Die Modell-Engine antwortet nicht mehr. Ohne sie laufen keine KI-Modelle.",
|
||||
"Die Modell-Engine ist wieder da."),
|
||||
"brain": (_check_brain,
|
||||
"Mein Gehirn lädt nicht — ich kann gerade nicht richtig denken. Ein Neustart der Engine könnte helfen.",
|
||||
"Mein Gehirn ist wieder geladen. Alles klar bei mir."),
|
||||
"hermes": (lambda: _reach(HERMES_API_URL),
|
||||
"Der Agent-Dienst ist ausgefallen — Telegram und meine Tools gehen gerade nicht.",
|
||||
"Der Agent-Dienst läuft wieder."),
|
||||
"mem0": (lambda: _reach(MEM0_SERVICE_URL),
|
||||
"Mein Gedächtnis-Dienst ist ausgefallen — ich merke mir vorübergehend nichts Neues.",
|
||||
"Mein Gedächtnis ist wieder online."),
|
||||
"voice": (lambda: _reach(VOICE_SERVICE_URL),
|
||||
"Der Hör-Dienst auf der Box ist ausgefallen — Spracheingabe könnte haken.",
|
||||
"Der Hör-Dienst läuft wieder."),
|
||||
"disk": (_check_disk,
|
||||
f"Die Platte der Box ist zu über {DISK_ALARM_PCT:.0f} Prozent voll. Es wird eng für Modelle und Backups.",
|
||||
"Die Platte hat wieder genug Luft."),
|
||||
}
|
||||
|
||||
|
||||
class _Watch:
|
||||
__slots__ = ("fails", "alerted", "alert_ts")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.fails = 0 # Fehl-Ticks in Folge
|
||||
self.alerted = False # Alarm ist raus, Entwarnung steht aus
|
||||
self.alert_ts = 0.0 # Zeitpunkt des letzten Alarms (für REMIND_S)
|
||||
|
||||
|
||||
_watches: dict[str, _Watch] = {name: _Watch() for name in CHECKS}
|
||||
|
||||
|
||||
def _notify_telegram(subject: str, text: str) -> None:
|
||||
"""Telegram-Direktweg (gemeinsamer Helper in announce.py); Briefkasten-Eintrag
|
||||
legt der Wächter selbst ab."""
|
||||
announce.notify_telegram(subject, text + " (Diese Meldung kam auch an Lucy.)")
|
||||
|
||||
|
||||
def _tick() -> None:
|
||||
now = time.time()
|
||||
for name, (check, fail_msg, ok_msg) in CHECKS.items():
|
||||
w = _watches[name]
|
||||
try:
|
||||
ok = bool(check())
|
||||
except Exception:
|
||||
ok = False
|
||||
if ok:
|
||||
w.fails = 0
|
||||
if w.alerted:
|
||||
w.alerted = False
|
||||
announce.add(ok_msg, subject="[Box wieder ok]", source="sentry")
|
||||
_notify_telegram("[Box wieder ok]", ok_msg)
|
||||
continue
|
||||
w.fails += 1
|
||||
due = (not w.alerted and w.fails >= FAIL_AFTER) or (w.alerted and now - w.alert_ts >= REMIND_S)
|
||||
if due:
|
||||
prefix = "" if not w.alerted else "Immer noch: "
|
||||
w.alerted = True
|
||||
w.alert_ts = now
|
||||
announce.add(prefix + fail_msg, subject="[Box-Problem]", source="sentry")
|
||||
_notify_telegram("[Box-Problem]", prefix + fail_msg)
|
||||
log.warning("sentry: %s ALARM (%s Fehl-Ticks)", name, w.fails)
|
||||
|
||||
|
||||
async def sentry_loop() -> None:
|
||||
"""Endlos-Schleife (Hintergrund-Task im MC2-Lifespan)."""
|
||||
await asyncio.sleep(START_DELAY)
|
||||
log.info("sentry: Health-Wächter aktiv (Intervall %ss, Alarm nach %s Fehl-Ticks)", INTERVAL, FAIL_AFTER)
|
||||
while True:
|
||||
try:
|
||||
await asyncio.to_thread(_tick)
|
||||
except Exception:
|
||||
log.debug("sentry: Tick fehlgeschlagen", exc_info=True)
|
||||
await asyncio.sleep(INTERVAL)
|
||||
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Software-Stand einer Instanz (seit 24.09.2026): welcher Commit seit wann live ist.
|
||||
|
||||
Nach einem erfolgreichen Umschalten schreibt deploy/deploy.sh (KI-Box) bzw. deploy/homelab/ausrollen.sh
|
||||
(Container des Homelab-Teils) die Stand-Datei mc2-stand.json in den Datenordner (deploy/stand-schreiben.py):
|
||||
Commit kurz, Betreff, Commit-Datum und den Zeitpunkt des Deploys. Die Oberfläche zeigt sie unter
|
||||
Einstellungen → Software-Stand, für diese Instanz über /api/instanz und für die andere über /api/partner/instanz.
|
||||
|
||||
Ohne Stand-Datei (Entwicklung am PC, Box vor dem ersten Deploy mit dieser Datei) kommt der Stand aus git, sonst
|
||||
heißt er „unbekannt“. Nur lesend.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from kern.einstellungen import einstellungen
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
GIT_CACHE_S = 60.0
|
||||
|
||||
_lock = threading.Lock()
|
||||
_git_cache: dict = {"ts": 0.0, "stand": None}
|
||||
|
||||
|
||||
def stand_datei() -> Path:
|
||||
return Path(os.environ.get("MC_STAND_DATEI", str(einstellungen().daten_dir / "mc2-stand.json")))
|
||||
|
||||
|
||||
def _text(wert: object) -> str | None:
|
||||
return wert.strip() if isinstance(wert, str) and wert.strip() else None
|
||||
|
||||
|
||||
def _aus_datei() -> dict | None:
|
||||
try:
|
||||
daten = json.loads(stand_datei().read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if not isinstance(daten, dict) or not _text(daten.get("commit")):
|
||||
return None
|
||||
return {"quelle": "deploy", **{k: _text(daten.get(k)) for k in ("commit", "betreff", "commit_datum",
|
||||
"deploy_zeit")}}
|
||||
|
||||
|
||||
def _git_log() -> str | None:
|
||||
"""Kopf des Checkouts, in dem dieser Code liegt. Die Schicht, die Tests ersetzen."""
|
||||
try:
|
||||
r = subprocess.run(["git", "-C", str(REPO), "log", "-1", "--format=%h%n%cI%n%s"],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
return r.stdout if r.returncode == 0 else None
|
||||
|
||||
|
||||
def _aus_git() -> dict | None:
|
||||
with _lock:
|
||||
if time.time() - _git_cache["ts"] < GIT_CACHE_S:
|
||||
return _git_cache["stand"]
|
||||
ausgabe = _git_log()
|
||||
stand = None
|
||||
if ausgabe:
|
||||
commit, datum, betreff = ([z.strip() for z in ausgabe.splitlines()] + ["", "", ""])[:3]
|
||||
if commit:
|
||||
stand = {"quelle": "git", "commit": commit, "betreff": betreff or None,
|
||||
"commit_datum": datum or None, "deploy_zeit": None}
|
||||
with _lock:
|
||||
_git_cache.update(ts=time.time(), stand=stand)
|
||||
return stand
|
||||
|
||||
|
||||
def stand() -> dict:
|
||||
"""{"quelle": "deploy"|"git"|"unbekannt", "commit", "betreff", "commit_datum", "deploy_zeit"}."""
|
||||
return _aus_datei() or _aus_git() or {"quelle": "unbekannt", "commit": None, "betreff": None,
|
||||
"commit_datum": None, "deploy_zeit": None}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user