Compare commits
496
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
363bc211e1 | ||
|
|
dc4c014b46 | ||
|
|
83bb227969 | ||
|
|
1c66daad0b | ||
|
|
995a0f34ee | ||
|
|
45f11bc3dc | ||
|
|
0190c25d5a | ||
|
|
2f608736af | ||
|
|
6af42bd42c | ||
|
|
c7c396f56d | ||
|
|
f2bffc7203 | ||
|
|
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 | ||
|
|
960bda6021 | ||
|
|
5f6e3c77d4 | ||
|
|
85a744ac7b | ||
|
|
faf25bf76c | ||
|
|
5d02e7af91 | ||
|
|
913256e241 | ||
|
|
5a236b4b28 | ||
|
|
967225b1d7 | ||
|
|
e7f143823e | ||
|
|
4e8f0b91dd | ||
|
|
69537de304 | ||
|
|
c81523c0f4 | ||
|
|
e5f546a72f | ||
|
|
5436e4df16 | ||
|
|
a1957d7094 | ||
|
|
5690217d84 | ||
|
|
0a9293f669 | ||
|
|
5496159b8b | ||
|
|
9a41cce99b | ||
|
|
1427048f38 | ||
|
|
82453e9f12 | ||
|
|
a2bfe0e480 | ||
|
|
c71a0a1abd | ||
|
|
94444f4dcb | ||
|
|
f88f8e8bf9 | ||
|
|
52f25432b8 | ||
|
|
2caecb0a79 | ||
|
|
552299a761 | ||
|
|
15c137e849 | ||
|
|
fc591da024 | ||
|
|
9872c1b3f6 | ||
|
|
1570b2fc71 | ||
|
|
961b003df4 | ||
|
|
90d9b68d3d | ||
|
|
b4c405a127 | ||
|
|
3fa2fbb9d8 | ||
|
|
6d5b09a592 | ||
|
|
e951db36c5 | ||
|
|
73a20daa52 | ||
|
|
e1ca7f6a5e | ||
|
|
ae4b0f229e | ||
|
|
7f224ec98e | ||
|
|
2d32d21742 | ||
|
|
ae523a171b | ||
|
|
3f32a9a746 | ||
|
|
6e093ed163 | ||
|
|
73f593b1a1 | ||
|
|
83cca21113 | ||
|
|
07be502e4e | ||
|
|
042c3f16d9 | ||
|
|
35be5e13f6 | ||
|
|
dfcf86902a | ||
|
|
6ad6c09cc0 | ||
|
|
c452b873b7 | ||
|
|
c0586c3fad | ||
|
|
1983d70805 | ||
|
|
65d5a49411 | ||
|
|
83e5a04735 | ||
|
|
082f4a659d | ||
|
|
103b277643 | ||
|
|
64f45ed0ff | ||
|
|
90d8a7d56d | ||
|
|
dec4922171 | ||
|
|
2ed7100f90 | ||
|
|
895eb9cac7 | ||
|
|
a261462857 | ||
|
|
35e8951a0d | ||
|
|
520cfd9fe4 | ||
|
|
9375b8eea8 | ||
|
|
3ea0e2d540 | ||
|
|
c20c070a6b | ||
|
|
5c279ccc38 | ||
|
|
7fab2cbcaa | ||
|
|
c2fe13857c | ||
|
|
5016ceef06 | ||
|
|
bbed8211f8 | ||
|
|
51c0c868b2 |
-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*
|
|
||||||
+55
-28
@@ -1,28 +1,55 @@
|
|||||||
{
|
{
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"name": "mc2",
|
"name": "mc2",
|
||||||
"runtimeExecutable": "F:\\Coding Stuff\\mission-control-2\\backend\\.venv\\Scripts\\python.exe",
|
"runtimeExecutable": "F:\\Coding Stuff\\mission-control-2\\backend\\.venv\\Scripts\\python.exe",
|
||||||
"runtimeArgs": [
|
"runtimeArgs": [
|
||||||
"-m",
|
"-m",
|
||||||
"uvicorn",
|
"uvicorn",
|
||||||
"app:app",
|
"app:app",
|
||||||
"--app-dir",
|
"--app-dir",
|
||||||
"F:\\Coding Stuff\\mission-control-2\\backend",
|
"F:\\Coding Stuff\\mission-control-2\\backend",
|
||||||
"--port",
|
"--port",
|
||||||
"9000"
|
"9000"
|
||||||
],
|
],
|
||||||
"port": 9000
|
"port": 9000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"runtimeExecutable": "npm",
|
"runtimeExecutable": "npm",
|
||||||
"runtimeArgs": ["run", "dev", "--", "--port", "5180", "--strictPort"],
|
"runtimeArgs": [
|
||||||
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
|
"run",
|
||||||
"env": { "MC_API_TARGET": "http://192.168.178.151:9001" },
|
"dev",
|
||||||
"autoPort": false,
|
"--",
|
||||||
"port": 5180
|
"--port",
|
||||||
}
|
"5180",
|
||||||
]
|
"--strictPort"
|
||||||
}
|
],
|
||||||
|
"cwd": "F:\\Coding Stuff\\mission-control-2\\frontend",
|
||||||
|
"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)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,15 @@
|
|||||||
*.service text eol=lf
|
*.service text eol=lf
|
||||||
*.timer text eol=lf
|
*.timer text eol=lf
|
||||||
|
|
||||||
|
# Agent-Hooks laufen auf der Box (Shebang!) — .py-Hooks MÜSSEN LF behalten,
|
||||||
|
# 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.
|
# Windows-Batch-Wrapper bleiben CRLF.
|
||||||
*.cmd text eol=crlf
|
*.cmd text eol=crlf
|
||||||
*.bat text eol=crlf
|
*.bat text eol=crlf
|
||||||
|
|||||||
+34
-22
@@ -1,22 +1,34 @@
|
|||||||
# Python
|
# Python
|
||||||
backend/.venv/
|
backend/.venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|
||||||
# Node / Vite
|
# Node / Vite
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
# frontend/dist wird committet (kein Node-Build auf der Box) — siehe deploy/
|
# 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.
|
# Env / local
|
||||||
# Wird per Direkt-Deploy auf die Box gespielt (dist/avatar.vrm), nicht über git.
|
*.env
|
||||||
frontend/public/avatar.vrm
|
.DS_Store
|
||||||
frontend/dist/avatar.vrm
|
|
||||||
|
# Box-Recon-/Scratch-Skripte (lokale Diagnose, nicht fürs Repo)
|
||||||
# Env / local
|
box_recon*
|
||||||
*.env
|
gemma_swap*
|
||||||
.DS_Store
|
|
||||||
|
# TypeScript-Inkrementalcache (reines Build-Artefakt, maschinenabhängig)
|
||||||
# Box-Recon-/Scratch-Skripte (lokale Diagnose, nicht fürs Repo)
|
frontend/tsconfig.tsbuildinfo
|
||||||
box_recon*
|
|
||||||
gemma_swap*
|
# 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,48 +1,74 @@
|
|||||||
# 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).
|
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/`.
|
Kurz gehalten, nur die Wahrheiten, die man sonst schmerzhaft lernt. Details: `README.md`, `docs/`.
|
||||||
|
|
||||||
## Was das ist
|
## Was das ist
|
||||||
Lokaler Local-AI-Stack für die Box (Bosgame M5, Strix Halo, Vulkan/RADV). Schichten:
|
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) ·
|
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.
|
||||||
|
|
||||||
## Sprache (nicht verhandelbar)
|
**Hardware-Spezifikationen der Box (WICHTIG für alle Agenten):**
|
||||||
- **Alles User-Sichtbare ist Deutsch:** UI-Texte, Fehlermeldungen, Update-/Radar-Meldungen,
|
- **System:** Bosgame M5 (AMD Strix Halo APU)
|
||||||
Skills, LLM-Summaries, Commit-Messages, Code-Kommentare.
|
- **Arbeitsspeicher (RAM/VRAM):** 128 GB Shared Memory (ca. 122.7 GB nutzbar).
|
||||||
- **Hermes' INTERNE System-Prompts bleiben Englisch** (fremde Software, wir forken sie nicht).
|
- **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.
|
||||||
Antwort-Sprache ≠ Prompt-Sprache — Deutsch kommt aus SOUL.md, nicht aus den Tool-Prompts.
|
**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.
|
||||||
|
|
||||||
## Build & Deploy (die häufigste Falle)
|
## Sprache (nicht verhandelbar)
|
||||||
- **`frontend/dist` WIRD committet.** Auf der Box läuft KEIN Node-Build; das Backend liefert die
|
- **Alles User-Sichtbare ist Deutsch:** UI-Texte, Fehlermeldungen, Update-/Radar-Meldungen,
|
||||||
gebauten Assets direkt aus. Nach jeder Frontend-Änderung: `cd frontend && npm run build`, dann
|
Skills, LLM-Summaries, Commit-Messages, Code-Kommentare.
|
||||||
**das neue `frontend/dist` mit-committen**. Vergessen = Box zeigt alten Stand.
|
- **Hermes' INTERNE System-Prompts bleiben Englisch** (fremde Software, wir forken sie nicht).
|
||||||
(Ausnahme: `frontend/dist/avatar.vrm` ist bewusst nicht in git — siehe `.gitignore`.)
|
Antwort-Sprache ≠ Prompt-Sprache — Deutsch kommt aus SOUL.md, nicht aus den Tool-Prompts.
|
||||||
- **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).
|
## Build & Deploy (die häufigste Falle)
|
||||||
- **Nie direkt auf `main` arbeiten.** Immer Branch (`wartung/...`), Gate grün, dann Merge/Deploy.
|
- **`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
|
||||||
## Zeit & Umgebung
|
**das neue `frontend/dist` mit-committen**. Vergessen = Box zeigt alten Stand.
|
||||||
- **Box = Ubuntu, läuft in `Europe/Berlin`** (seit 03.07.2026; vorher UTC). Dev-PC = Windows.
|
- **Deploy** (`bash ~/mission-control-v2/deploy/deploy.sh` auf der Box) holt `origin/main` per
|
||||||
Naive/lokale Zeiten immer über `MC_LOCAL_TZ` (= `Europe/Berlin`) auflösen, nie `datetime.now()` ohne TZ annehmen
|
fast-forward — **`main` muss vorher auf Gitea liegen**, im Box-Checkout nie von Hand ändern.
|
||||||
(siehe `backend/services/reminders.py`).
|
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.
|
||||||
## Backend-Konventionen
|
Scheitert etwas: automatisch zurück auf den alten Stand + dringende Meldung. Laufende Update-Jobs
|
||||||
- Router unter `backend/routers/` (`APIRouter(prefix="/api")`), Logik in `backend/services/`
|
verschieben den Deploy. Die llama-swap-Config wird nur überschrieben, wenn sich der Repo-Abzug im
|
||||||
(Single Source of Truth — Router bleiben dünn). Beispiel-Lehre: Restart-Allowlist lebt NUR in
|
selben Deploy geändert hat (sonst gewinnt die lebende Datei, die MC2 selbst schreibt).
|
||||||
`services.maintenance` (System-Dienste via `sudo -n`, User-Dienste via `systemctl --user`);
|
- **Nie direkt auf `main` arbeiten.** Immer Branch (`wartung/...`), Gate grün, dann Merge/Deploy.
|
||||||
keine zweite Allowlist in einem Router duplizieren.
|
|
||||||
- **Gate vor Commit:** `python -m py_compile <geänderte .py>` muss durchlaufen.
|
## Agentic IDE & Vibe Coding
|
||||||
- Wartung ist **sudo-frei** gedacht (systemctl --user). Wo doch sudo nötig ist (llama-swap =
|
- **Zero Middle-Layers:** Coding passiert zu 100% lokal auf dem Dev-PC in **OpenChamber** (mit eigener OpenCode-CLI).
|
||||||
System-Dienst), sauber über die NOPASSWD-Whitelist / `password_required`-Rückgabe, nie hart failen.
|
- Es gibt keinen Zed-Workflow, keine OpenCode-CLI-Mittelschicht und keinen Governor mehr.
|
||||||
- Lokal (Windows) müssen Box-Shell-Befehle **harmlos fehlschlagen** statt zu crashen.
|
- 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.
|
||||||
## Grenzen (Verdikt, eingehalten)
|
Der Deploy auf der Box führt es vor dem Umschalten noch einmal aus; rot = automatisch zurück auf den alten Stand.
|
||||||
- **Hermes-Quellcode nie selbst patchen** (Fork verboten). Config/Deps ja, Code-Umbau nein.
|
|
||||||
- **Security-Config** (approvals, Tokens, ufw, command_allowlist) **nie ohne explizites User-Ja.**
|
## Zeit & Umgebung
|
||||||
- Alles reversibel halten: Branch + Backup + Pin.
|
- **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
|
||||||
## Gitea
|
(siehe `backend/services/reminders.py`).
|
||||||
Remote = `Hitonabi/mission-control-v2`. Auth ist flatterhaft → **Push mit Retry**, nur über
|
|
||||||
PowerShell/GCM. Neue Repos per API anlegen. Kein GitHub.
|
## Backend-Konventionen
|
||||||
|
- Router unter `backend/routers/` (`APIRouter(prefix="/api")`), Logik in `backend/services/`
|
||||||
|
(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:** `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)
|
||||||
|
- **Hermes-Quellcode nie selbst patchen** (Fork verboten). Config/Deps ja, Code-Umbau nein.
|
||||||
|
- **Security-Config** (approvals, Tokens, ufw, command_allowlist) **nie ohne explizites User-Ja.**
|
||||||
|
- Alles reversibel halten: Branch + Backup + Pin.
|
||||||
|
|
||||||
|
## Gitea
|
||||||
|
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**
|
Ein Steuerpult fürs Heimnetz, das sich selbst wartet. Zwei Bereiche, eine Oberfläche:
|
||||||
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.
|
|
||||||
|
|
||||||
**Schichten:** Engine (llama.cpp **Vulkan/RADV** auf Strix Halo) · Router (**llama-swap**,
|
- **Box-Wart** betreut die KI-Box (Bosgame M5, AMD Ryzen AI MAX+ 395, 128 GB gemeinsamer Speicher, llama.cpp über
|
||||||
Ko-Residenz-Warm-Set) · **Builtin-Routing-Gateway** in MC2 (`model: auto`, kein externer
|
Vulkan): hält sie aktuell, passt auf sie auf und sucht bessere Modelle. Live seit 23.09.2026.
|
||||||
LiteLLM — scheitert auf Python 3.14) · Mission Control 2.0 (FastAPI + React/shadcn) ·
|
- **Homelab** soll den Proxmox-PC mit seinen Containern und Arcane (Docker) betreuen: offene Updates zeigen, per
|
||||||
**Hermes Agent** (api_server-Gateway :8642, Tools/MCP/Telegram/cron) · auto-lernendes
|
Knopf einspielen, danach die Erreichbarkeit prüfen. In Arbeit ([Fahrplan](docs/wissen/OFFENE-FAEDEN.md)).
|
||||||
**Gedächtnis** (Mem0-Sidecar, semantisch). Jede Schicht hinter stabilem Vertrag austauschbar.
|
|
||||||
|
|
||||||
> **Sprachassistentin Lucy** (Voice + 3D-Avatar) ist in ein **eigenes Repo** ausgelagert
|
Dieselbe Codebasis läuft als zwei Instanzen: auf der KI-Box (Rolle `box`) und später als Container auf dem
|
||||||
> (`Hitonabi/lucy`) und spricht über den Hermes-Gateway. Aus MC2 selbst sind die Voice-/
|
Proxmox-PC (Rolle `homelab`); beide prüfen sich gegenseitig. Repo, Dienste und Dateien tragen noch den alten Namen
|
||||||
> Web-Reste entfernt — MC2 ist die Steuerzentrale, kein Sprach-Frontend.
|
„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
|
Im Heimnetz `http://192.168.178.151:9001`, am PC oder am Handy.
|
||||||
ohne Betreuung monatelang weiter):
|
|
||||||
|
|
||||||
- **Modelle & Routing** — Discover (live HF + Fit/Caps), Engine-Write (register, `groups`/
|
| Seite | Inhalt |
|
||||||
Ko-Residenz, vocab-geprüfte Spec-Drafts), Gateway `model: auto` + Fallbacks.
|
|---|---|
|
||||||
- **System & Wartung** — Status (CPU/RAM/GPU/Disk), **ehrliche Speicher-Zahlen** (echte KV-Bytes
|
| **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 |
|
||||||
aus GGUF-Architektur), Logs mit Fehler-Filter, Dienst-Neustart (sudo-frei).
|
| **Updates** | Bausteine Betriebssystem, Motor (llama.cpp), llama-swap und Hermes; Verlauf der Update-Läufe; Sicherungen |
|
||||||
- **Updates mit Fangnetz** — OS/Engine/Router/Hermes per Timer: Backup → Update → Postcheck →
|
| **Modelle** | Speicher, Rollen Hirn und Coder, wer die Modelle nutzt, Modell-Radar, Aufräumen, Modelle selbst suchen |
|
||||||
bei Fehler **Auto-Rollback + Pin**; verständliche Zusammenfassung („Musst du etwas tun?") in
|
| oben rechts bzw. „Mehr" | Hermes-Dashboard, Dienste und Protokolle, Einstellungen |
|
||||||
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).
|
|
||||||
|
|
||||||
API: `health · models · discover · fit · models/register · groups · routing · system/* ·
|
Anleitung für den Alltag: [docs/BEDIENUNG.md](docs/BEDIENUNG.md).
|
||||||
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),
|
## Was von allein läuft
|
||||||
[`docs/BEDIENUNG.md`](docs/BEDIENUNG.md) + `deploy/` (Units, `deploy.sh`, `backup.sh`, `warmup.sh`).
|
|
||||||
|
- **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
|
## Entwickeln
|
||||||
|
|
||||||
**Backend:**
|
|
||||||
```bash
|
```bash
|
||||||
|
# Backend lokal auf :9000 (Windows)
|
||||||
cd backend
|
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
|
.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):**
|
Vor jedem Push: `bash deploy/pruefen.sh` (Shell- und Python-Syntax, `ruff check .`, Importe, `pytest backend/tests`);
|
||||||
```bash
|
bei Frontend-Änderungen zusätzlich `npm run lint`, `npm test` und `npm run build` in `frontend/`, und das neue
|
||||||
cd frontend
|
`frontend/dist` mitcommitten, denn die Box baut kein Frontend. Gearbeitet wird auf einem Branch; `main` liegt auf
|
||||||
npm install
|
Gitea (`ssh://gitea@192.168.178.153:2222/Hitonabi/mission-control-v2.git`), danach auf der Box
|
||||||
npm run dev # http://localhost:5173
|
`bash ~/mission-control-v2/deploy/deploy.sh`. Regeln für Agenten: [AGENTS.md](AGENTS.md).
|
||||||
```
|
|
||||||
|
|
||||||
**Frontend (Build → wird vom Backend ausgeliefert):**
|
## Doku
|
||||||
```bash
|
|
||||||
cd frontend && npm run build # → frontend/dist
|
|
||||||
```
|
|
||||||
|
|
||||||
## Env-Vars (Auswahl)
|
| Dokument | Für | Inhalt |
|
||||||
|
|
||||||
| Variable | Default | Zweck |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `MC_LLAMA_SWAP_URL` | `http://127.0.0.1:8080` | Engine |
|
| [docs/README.md](docs/README.md) | alle | Index, Lesereihenfolge, Pflegeregeln |
|
||||||
| `MC_CONFIG_PATH` | `/etc/llama-swap/config.yaml` | llama-swap Config |
|
| [docs/BEDIENUNG.md](docs/BEDIENUNG.md) | User | Oberfläche und Telegram-Nachrichten |
|
||||||
| `MC_GATEWAY_URL` | `http://127.0.0.1:$MC_PORT` | Builtin-Gateway (Teil von MC2, kein externer Dienst) |
|
| [docs/ARCHITEKTUR.md](docs/ARCHITEKTUR.md) | Agenten, Technik | Prozesse, Rollen, Datenwege, wer was schreibt |
|
||||||
| `MC_PORT` | `9000` | MC-Backend-Port |
|
| [docs/BETRIEB.md](docs/BETRIEB.md) | Agenten, Technik | Handgriffe, Meldungen, Wächter, Deploy, Sicherung, Notfall |
|
||||||
| `MC_ENGINE_PATH` | `/opt/llamacpp-vulkan` | Aktive Engine-Binary (Vulkan-Build) |
|
| [docs/UPDATES.md](docs/UPDATES.md) | Agenten, Technik | Sonntags-Update, Festhalten, Freigeben |
|
||||||
| `MC_ENGINE_REPO` | `ggml-org/llama.cpp` | Quelle für Engine-Update-Check |
|
| [docs/RADAR.md](docs/RADAR.md) | Agenten, Technik | Modell-Radar, Stack-Radar, Prüfstand |
|
||||||
| `MC_DRAFTS_DIR` | `$MODELS/drafts` | Spec-Draft-Modelle (vocab-geprüft) |
|
| [docs/WIEDERAUFBAU.md](docs/WIEDERAUFBAU.md) | Technik | die KI-Box von null aufbauen |
|
||||||
| `MC_SPEC_TYPE` | `draft-simple` | Speculative-Decoding-Typ (llama.cpp) |
|
| [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).
|
|
||||||
+204
-160
@@ -1,160 +1,204 @@
|
|||||||
"""
|
"""
|
||||||
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
|
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-
|
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.
|
Server (proxyt /api hierher), daher CORS für localhost offen.
|
||||||
"""
|
|
||||||
|
Zwei Rollen, derselbe Code (MC_ROLLE, kern/einstellungen.py, seit 24.09.2026):
|
||||||
import asyncio
|
box — die KI-Box: Box-Wart (Updater, Wächter, Modell-Radar) plus die Schnittstellen,
|
||||||
import logging
|
die Lucy-Desktop, OpenChamber und Hermes brauchen (Sprache, /v1, MCP-Werkzeuge)
|
||||||
import os
|
homelab — der Proxmox-PC: der Homelab-Teil
|
||||||
from contextlib import asynccontextmanager
|
Beide liefern dieselbe Oberfläche aus und reichen den Bereich der anderen Instanz unter
|
||||||
from typing import Any, AsyncGenerator, Awaitable, Callable
|
/api/partner/… durch.
|
||||||
|
"""
|
||||||
import httpx
|
|
||||||
from fastapi import FastAPI
|
import asyncio
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
import logging
|
||||||
from fastapi.responses import FileResponse
|
import os
|
||||||
from fastapi.staticfiles import StaticFiles
|
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||||
from starlette.requests import Request
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Any
|
||||||
from config import FRONTEND_DIST, VERSION
|
|
||||||
from routers import (
|
import httpx
|
||||||
agent,
|
from config import FRONTEND_DIST, V1_UPSTREAM, VERSION
|
||||||
auftragsbuch,
|
from fastapi import FastAPI, HTTPException
|
||||||
chronik,
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
connect,
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
console,
|
from fastapi.staticfiles import StaticFiles
|
||||||
gateway_proxy,
|
from kern.einstellungen import einstellungen
|
||||||
health,
|
from routers import health, partner
|
||||||
hermes_ui,
|
from services.herkunft import erlaubt
|
||||||
maintenance,
|
from starlette.requests import Request
|
||||||
memory,
|
|
||||||
models,
|
ROLLE = einstellungen().rolle
|
||||||
routing,
|
|
||||||
system,
|
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
|
||||||
voice,
|
# für alle Module (logging.getLogger(__name__)).
|
||||||
wissen,
|
logging.basicConfig(
|
||||||
zeitmaschine,
|
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
|
||||||
)
|
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
||||||
from routers import reminders as reminders_router
|
)
|
||||||
from services import memory as memory_svc
|
log = logging.getLogger(__name__)
|
||||||
from services import reminders, sentry, warmer
|
|
||||||
|
|
||||||
# Zentrales Logging — Level via MC_LOG_LEVEL (INFO default). Eine Konfiguration
|
def _box_hintergrund() -> list[asyncio.Task[Any]]:
|
||||||
# für alle Module (logging.getLogger(__name__)).
|
"""Hintergrund-Tasks der KI-Box. Der Wächter läuft NICHT hier, sondern im mc2-steward
|
||||||
logging.basicConfig(
|
(eigener Prozess, steward.py) — ein totes MC2 könnte sich sonst nicht selbst melden."""
|
||||||
level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(),
|
from services import reminders, warmer
|
||||||
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
|
||||||
)
|
tasks: list[asyncio.Task[Any]] = []
|
||||||
log = logging.getLogger(__name__)
|
if warmer.ENABLED:
|
||||||
|
tasks.append(asyncio.create_task(warmer.rewarm_loop()))
|
||||||
|
log.info(
|
||||||
@asynccontextmanager
|
"Hirn-Re-Warm-Wächter aktiv (Intervall %ss, Hirn dynamisch aus Hermes-Config)",
|
||||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
warmer.INTERVAL,
|
||||||
"""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)."""
|
# Probelauf (neue Fassung neben der laufenden, Box-Wart-Umbau 09/2026): Erinnerungen
|
||||||
tasks: list[asyncio.Task[Any]] = []
|
# gehören dem echten MC2 — zwei Schreiber würden Erinnerungen doppelt feuern.
|
||||||
if warmer.ENABLED:
|
if os.environ.get("MC_PROBELAUF", "") != "1":
|
||||||
tasks.append(asyncio.create_task(warmer.rewarm_loop()))
|
tasks.append(asyncio.create_task(reminders.reminders_loop()))
|
||||||
log.info(
|
else:
|
||||||
"Hirn-Re-Warm-Wächter aktiv (Intervall %ss, Hirn dynamisch aus Hermes-Config)",
|
log.info("Probelauf: Erinnerungen bleiben beim echten MC2.")
|
||||||
warmer.INTERVAL,
|
return tasks
|
||||||
)
|
|
||||||
if sentry.ENABLED:
|
|
||||||
tasks.append(asyncio.create_task(sentry.sentry_loop()))
|
@asynccontextmanager
|
||||||
tasks.append(asyncio.create_task(reminders.reminders_loop()))
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
if memory_svc.AUTO_DEDUPE_ENABLED:
|
"""Hintergrund-Tasks an den App-Lebenszyklus binden."""
|
||||||
tasks.append(asyncio.create_task(memory_svc.auto_dedupe_loop()))
|
# Aufträge (Updates, Downloads) laufen als eigene Einheiten weiter, während MC2 neu startet —
|
||||||
log.info(
|
# hier werden sie wieder beobachtet. Ein Probelauf daneben fasst sie nicht an.
|
||||||
"Mem0-Auto-Dedupe aktiv (alle %ss, Schwelle %s)",
|
if os.environ.get("MC_PROBELAUF", "") != "1":
|
||||||
memory_svc.AUTO_DEDUPE_INTERVAL,
|
from services import jobengine
|
||||||
memory_svc.AUTO_DEDUPE_THRESHOLD,
|
await asyncio.to_thread(jobengine.wiederaufnehmen)
|
||||||
)
|
tasks = _box_hintergrund() if ROLLE == "box" else []
|
||||||
# Geteilter HTTP-Client zur lokalen Engine: Keep-Alive/Connection-Pooling statt neuer Client
|
if ROLLE == "homelab":
|
||||||
# pro /v1-Anfrage (spart Sockets/TIME_WAIT unter parallelen Agent-Strömen von Zed/Kilo).
|
from services.homelab import updates
|
||||||
app.state.gw_client = httpx.AsyncClient(
|
updates.unterbrochene_abschliessen() # Läufe des vorigen Prozesses gelten als unterbrochen
|
||||||
timeout=httpx.Timeout(connect=10.0, read=None, write=None, pool=10.0),
|
# Geteilter HTTP-Client zur lokalen Engine: Keep-Alive/Connection-Pooling statt neuer Client
|
||||||
limits=httpx.Limits(max_keepalive_connections=100, max_connections=200),
|
# pro /v1-Anfrage (spart Sockets/TIME_WAIT unter parallelen Agent-Strömen von Zed/Kilo).
|
||||||
)
|
app.state.gw_client = httpx.AsyncClient(
|
||||||
try:
|
timeout=httpx.Timeout(connect=10.0, read=None, write=None, pool=10.0),
|
||||||
yield
|
limits=httpx.Limits(max_keepalive_connections=100, max_connections=200),
|
||||||
finally:
|
)
|
||||||
for task in tasks:
|
try:
|
||||||
_ = task.cancel()
|
yield
|
||||||
await app.state.gw_client.aclose()
|
finally:
|
||||||
|
for task in tasks:
|
||||||
|
_ = task.cancel()
|
||||||
app = FastAPI(title="Mission Control 2.0", version=VERSION, lifespan=lifespan)
|
await app.state.gw_client.aclose()
|
||||||
|
|
||||||
# Dev: Vite-Dev-Server (5173) ruft das Backend per /api auf.
|
|
||||||
app.add_middleware(
|
app = FastAPI(title="Homelab Orchestrator", version=VERSION, lifespan=lifespan)
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
|
# Dev: Vite-Dev-Server (5173) ruft das Backend per /api auf.
|
||||||
allow_methods=["*"],
|
app.add_middleware(
|
||||||
allow_headers=["*"],
|
CORSMiddleware,
|
||||||
)
|
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
@app.middleware("http")
|
)
|
||||||
async def no_cache(
|
|
||||||
request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]]
|
|
||||||
) -> httpx.Response:
|
# Herkunftsprüfung (24.09.2026, User-Entscheid: keine Anmeldung): Knöpfe fremder Webseiten werden
|
||||||
resp = await call_next(request)
|
# abgelehnt, Skripte, Desktop-Lucy und /v1 bleiben unberührt. Regeln in services/herkunft.py.
|
||||||
if request.url.path.startswith("/api"):
|
@app.middleware("http")
|
||||||
resp.headers["Cache-Control"] = "no-cache"
|
async def herkunft(request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]]):
|
||||||
return resp
|
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.include_router(health.router)
|
|
||||||
app.include_router(models.router)
|
|
||||||
app.include_router(routing.router)
|
@app.middleware("http")
|
||||||
app.include_router(system.router)
|
async def no_cache(
|
||||||
app.include_router(connect.router)
|
request: Request, call_next: Callable[[Request], Awaitable[httpx.Response]]
|
||||||
app.include_router(memory.router)
|
) -> httpx.Response:
|
||||||
app.include_router(agent.router)
|
resp = await call_next(request)
|
||||||
app.include_router(
|
if request.url.path.startswith("/api"):
|
||||||
voice.router
|
resp.headers["Cache-Control"] = "no-cache"
|
||||||
) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat (Voice-Tab)
|
return resp
|
||||||
app.include_router(
|
|
||||||
reminders_router.router
|
|
||||||
) # Erinnerungen/Routinen (A3) — feuern in den Briefkasten
|
def _box_router(app: FastAPI) -> None:
|
||||||
app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
|
"""Die Schnittstellen der KI-Box (Box-Wart und alles, was Lucy, OpenChamber und Hermes brauchen)."""
|
||||||
app.include_router(maintenance.router)
|
from routers import (
|
||||||
app.include_router(auftragsbuch.router) # Vorschlags-Inbox (Mensch-Gate als Klick)
|
boxwart,
|
||||||
app.include_router(chronik.router) # Timeline der autonomen Taten (Announce-Store)
|
einstellungen,
|
||||||
app.include_router(wissen.router) # Wissens-Vault (Traum-Notizen) read-only
|
events,
|
||||||
app.include_router(zeitmaschine.router) # Snapshots ansehen + Ein-Klick-Restore (detached)
|
gateway_proxy,
|
||||||
app.include_router(
|
hermes_ui,
|
||||||
console.router
|
maintenance,
|
||||||
) # Box-Konsole (ttyd) same-origin durchreichen — VOR dem SPA-Catch-all
|
messwerte,
|
||||||
app.include_router(
|
models,
|
||||||
hermes_ui.router
|
radar,
|
||||||
) # Eingebaute Hermes-Web-GUI (hermes serve) same-origin unter /hermes-ui/ — VOR dem SPA-Catch-all
|
routing,
|
||||||
|
system,
|
||||||
|
voice,
|
||||||
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
|
zeitmaschine,
|
||||||
if FRONTEND_DIST.exists():
|
)
|
||||||
app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")
|
from routers import reminders as reminders_router
|
||||||
|
|
||||||
_DIST_ROOT = FRONTEND_DIST.resolve()
|
app.include_router(boxwart.router) # Cockpit: Start, Hinweise, Modell-Nutzung, Zeitplan
|
||||||
|
app.include_router(einstellungen.router) # Update-Zeitfenster, Radar-Schalter, Telegram-Test
|
||||||
@app.get("/{full_path:path}")
|
app.include_router(radar.router) # Modell-Radar: Kandidaten, Nachttests, Übernehmen/Verwerfen
|
||||||
def spa(full_path: str):
|
app.include_router(messwerte.router) # Messwerte mit Verlauf (1h, 24h, 7d; schreibt der Steward)
|
||||||
# Datei direkt aus FRONTEND_DIST ausliefern (manifest.webmanifest, favicon.ico, …) — aber
|
app.include_router(models.router)
|
||||||
# NUR innerhalb des dist-Ordners: Pfad auflösen + Traversal (../, absolute Pfade) hart raus.
|
app.include_router(routing.router)
|
||||||
try:
|
app.include_router(system.router)
|
||||||
target = (FRONTEND_DIST / full_path).resolve()
|
app.include_router(voice.router) # Sprache: STT/TTS-Proxy + Hermes-Agent-Chat
|
||||||
if target.is_relative_to(_DIST_ROOT) and target.is_file():
|
app.include_router(reminders_router.router) # Erinnerungen/Routinen — feuern in den Briefkasten
|
||||||
return FileResponse(target)
|
# /v1-Datenpfad: Nach dem Gateway-Auszug (UMBAU v3 P1) läuft der eigentliche Gateway als
|
||||||
except (ValueError, OSError):
|
# eigener Prozess (mc2-gateway, Loopback :9010) — MC2 reicht /v1 dann nur roh durch, damit
|
||||||
pass
|
# 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.
|
||||||
index = FRONTEND_DIST / "index.html"
|
if V1_UPSTREAM:
|
||||||
if index.exists():
|
from routers import gateway_forward
|
||||||
# index.html nie cachen → Browser zieht nach jedem Deploy das aktuelle (gehashte) Bundle.
|
app.include_router(gateway_forward.router) # dünner Roh-Weiterleiter → mc2-gateway
|
||||||
return FileResponse(
|
else:
|
||||||
index, headers={"Cache-Control": "no-cache, must-revalidate"}
|
app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
|
||||||
)
|
app.include_router(maintenance.router)
|
||||||
return {"detail": "frontend not built"}
|
app.include_router(events.router) # SSE-Strom /api/stream — Messwerte + Invalidation
|
||||||
|
app.include_router(zeitmaschine.router) # Sicherungen ansehen + zurückspielen (detached)
|
||||||
|
# Eingebaute Hermes-Web-GUI (hermes dashboard) same-origin unter /hermes-ui/ — VOR dem SPA-Catch-all
|
||||||
|
app.include_router(hermes_ui.router)
|
||||||
|
|
||||||
|
|
||||||
|
app.include_router(health.router)
|
||||||
|
app.include_router(partner.router) # zweite Instanz: Lebenszeichen + Durchreiche
|
||||||
|
if ROLLE == "box":
|
||||||
|
_box_router(app)
|
||||||
|
else:
|
||||||
|
from routers import einstellungen, homelab
|
||||||
|
app.include_router(homelab.router) # Proxmox-Host, Container, Arcane (Phase 3/4)
|
||||||
|
app.include_router(homelab.strom_router) # Live-Strom der Homelab-Instanz
|
||||||
|
app.include_router(einstellungen.homelab_router) # Telegram-Test des Homelab-Teils
|
||||||
|
|
||||||
|
|
||||||
|
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
|
||||||
|
if FRONTEND_DIST.exists():
|
||||||
|
app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")
|
||||||
|
|
||||||
|
_DIST_ROOT = FRONTEND_DIST.resolve()
|
||||||
|
|
||||||
|
@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:
|
||||||
|
target = (FRONTEND_DIST / full_path).resolve()
|
||||||
|
if target.is_relative_to(_DIST_ROOT) and target.is_file():
|
||||||
|
return FileResponse(target)
|
||||||
|
except (ValueError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
index = FRONTEND_DIST / "index.html"
|
||||||
|
if index.exists():
|
||||||
|
# index.html nie cachen → Browser zieht nach jedem Deploy das aktuelle (gehashte) Bundle.
|
||||||
|
return FileResponse(
|
||||||
|
index, headers={"Cache-Control": "no-cache, must-revalidate"}
|
||||||
|
)
|
||||||
|
return {"detail": "frontend not built"}
|
||||||
|
|||||||
+126
-131
@@ -1,131 +1,126 @@
|
|||||||
"""
|
"""
|
||||||
Zentrale Konfiguration für Mission Control 2.0.
|
Zentrale Konfiguration für Mission Control 2.0.
|
||||||
|
|
||||||
Eine Quelle der Wahrheit für Pfade, URLs und Defaults — alles über Env-Vars
|
Eine Quelle der Wahrheit für Pfade, URLs und Defaults — alles über Env-Vars
|
||||||
überschreibbar. Bewusst schlank: MC 2.0 ist ein Glue-Cockpit, das vorhandene
|
überschreibbar. Bewusst schlank: MC 2.0 ist ein Glue-Cockpit, das vorhandene
|
||||||
Dienste (llama-swap, LiteLLM-Gateway, Hermes) steuert, statt sie nachzubauen.
|
Dienste (llama-swap, LiteLLM-Gateway, Hermes) steuert, statt sie nachzubauen.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
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("/")
|
||||||
# --- Engine (llama-swap) -----------------------------------------------------
|
CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/etc/llama-swap/config.yaml"))
|
||||||
LLAMA_SWAP_URL = os.environ.get("MC_LLAMA_SWAP_URL", "http://127.0.0.1:8080").rstrip("/")
|
MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models"))
|
||||||
CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/etc/llama-swap/config.yaml"))
|
# Cache der Modell-Entdeckung ("aktuell beste Modelle", live von HuggingFace).
|
||||||
MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models"))
|
# Persistent neben den Modellen (übersteht Deploys). TTL = Frische-Fenster.
|
||||||
# Cache der Modell-Entdeckung ("aktuell beste Modelle", live von HuggingFace).
|
DISCOVER_CACHE_PATH = Path(os.environ.get("MC_DISCOVER_CACHE", str(MODELS_DIR / "mc2-discover.json")))
|
||||||
# Persistent neben den Modellen (übersteht Deploys). TTL = Frische-Fenster.
|
DISCOVER_TTL = int(os.environ.get("MC_DISCOVER_TTL", "43200")) # 12 h
|
||||||
DISCOVER_CACHE_PATH = Path(os.environ.get("MC_DISCOVER_CACHE", str(MODELS_DIR / "mc2-discover.json")))
|
# Gedächtnis: MC2 hält KEINS mehr. Bis August 2026 lief hier erst eine eigene SQLite-DB,
|
||||||
DISCOVER_TTL = int(os.environ.get("MC_DISCOVER_TTL", "43200")) # 12 h
|
# dann ein semantischer Sidecar auf :8765 — beides ist abgelöst, der Port ist tot.
|
||||||
# Geteiltes Gedächtnis (SQLite, WAL). Persistent neben den Modellen.
|
# Einzige Gedächtnis-Wahrheit ist jetzt Hermes selbst (HERMES_API_URL, siehe unten).
|
||||||
# Hinweis: nur noch für die einmalige Mem0-Migration relevant — das aktive Gedächtnis
|
# Befehl-Vorlage für llama-swap: {model}=GGUF-Pfad, {ctx}=Kontext, ${PORT} bleibt stehen.
|
||||||
# liegt jetzt in Mem0/Chroma hinter dem Sidecar (siehe MEM0_SERVICE_URL).
|
# Hinweis: --prompt-cache/--prompt-cache-all sind llama-CLI-Flags, NICHT llama-server —
|
||||||
MEMORY_DB = Path(os.environ.get("MC_MEMORY_DB", str(MODELS_DIR / "mc2-memory.db")))
|
# llama-server lehnt sie ab ("invalid argument") und startet dann nicht. Prompt-Caching
|
||||||
# Mem0-Sidecar (auto-lernendes, semantisches Gedächtnis). Läuft im ~/.mem0/venv (Python 3.12),
|
# macht llama-server ohnehin automatisch pro Slot (KV-Reuse).
|
||||||
# weil mem0+chromadb unter dem 3.14-Backend nicht laufen. MC2 spricht ihn lokal per HTTP an.
|
# --load-mode none = das frühere --no-mmap; das alte Flag ist seit llama.cpp b10936 weg
|
||||||
MEM0_SERVICE_URL = os.environ.get("MC_MEM0_SERVICE_URL", "http://127.0.0.1:8765").rstrip("/")
|
# ("invalid argument", jedes Modell stirbt beim Start — gelernt 13./17.09.2026).
|
||||||
# Befehl-Vorlage für llama-swap: {model}=GGUF-Pfad, {ctx}=Kontext, ${PORT} bleibt stehen.
|
_DEFAULT_CMD_TEMPLATE = (
|
||||||
# Hinweis: --prompt-cache/--prompt-cache-all sind llama-CLI-Flags, NICHT llama-server —
|
"llama-server -m {model} --host 127.0.0.1 --port ${PORT} "
|
||||||
# llama-server lehnt sie ab ("invalid argument") und startet dann nicht. Prompt-Caching
|
"-c {ctx} -ngl 999 -fa on --load-mode none"
|
||||||
# macht llama-server ohnehin automatisch pro Slot (KV-Reuse).
|
)
|
||||||
_DEFAULT_CMD_TEMPLATE = (
|
CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE)
|
||||||
"llama-server -m {model} --host 127.0.0.1 --port ${PORT} "
|
if "{model}" not in CMD_TEMPLATE:
|
||||||
"-c {ctx} -ngl 999 -fa on --no-mmap"
|
CMD_TEMPLATE = _DEFAULT_CMD_TEMPLATE
|
||||||
)
|
DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300"))
|
||||||
CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE)
|
# Verzeichnis mit Draft-Modellen für Speculative Decoding. Beim Hinzufügen eines
|
||||||
if "{model}" not in CMD_TEMPLATE:
|
# fast/coder-Modells wird hieraus automatisch ein **vocab-kompatibler** Draft gewählt
|
||||||
CMD_TEMPLATE = _DEFAULT_CMD_TEMPLATE
|
# (Vocab-Check via services.gguf_meta; ein inkompatibler Draft lässt llama.cpp scheitern).
|
||||||
DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300"))
|
DRAFTS_DIR = Path(os.environ.get("MC_DRAFTS_DIR", str(MODELS_DIR / "drafts")))
|
||||||
# Verzeichnis mit Draft-Modellen für Speculative Decoding. Beim Hinzufügen eines
|
# Optionaler expliziter Default-Draft (leer = Auto-Erkennung aus DRAFTS_DIR). Wird nur
|
||||||
# fast/coder-Modells wird hieraus automatisch ein **vocab-kompatibler** Draft gewählt
|
# verwendet, wenn er zum Ziel-Modell vocab-kompatibel ist. (Früher fix qwen2.5 → entfernt,
|
||||||
# (Vocab-Check via services.gguf_meta; ein inkompatibler Draft lässt llama.cpp scheitern).
|
# weil das mit neueren Vocabs wie Qwen3.6 inkompatibel ist und Spec stillschweigend brach.)
|
||||||
DRAFTS_DIR = Path(os.environ.get("MC_DRAFTS_DIR", str(MODELS_DIR / "drafts")))
|
SPEC_DRAFT_MODEL_PATH = os.environ.get("MC_SPEC_DRAFT_MODEL", "")
|
||||||
# Optionaler expliziter Default-Draft (leer = Auto-Erkennung aus DRAFTS_DIR). Wird nur
|
# Speculative-Decoding-Typ (llama.cpp dieser Generation braucht --spec-type zusätzlich
|
||||||
# verwendet, wenn er zum Ziel-Modell vocab-kompatibel ist. (Früher fix qwen2.5 → entfernt,
|
# zu --spec-draft-model, sonst ist Spec inaktiv).
|
||||||
# weil das mit neueren Vocabs wie Qwen3.6 inkompatibel ist und Spec stillschweigend brach.)
|
SPEC_TYPE = os.environ.get("MC_SPEC_TYPE", "draft-simple")
|
||||||
SPEC_DRAFT_MODEL_PATH = os.environ.get("MC_SPEC_DRAFT_MODEL", "")
|
# MTP-Speculative-Decoding (Multi-Token-Prediction): manche Modelle bringen einen eigenen
|
||||||
# Speculative-Decoding-Typ (llama.cpp dieser Generation braucht --spec-type zusätzlich
|
# MTP-Kopf mit (z.B. gemma-4 → arch 'gemma4-assistant', Datei 'mtp-*.gguf'). Der wird mit
|
||||||
# zu --spec-draft-model, sonst ist Spec inaktiv).
|
# `--model-draft <mtp.gguf> --spec-type draft-mtp --spec-draft-n-max N` geladen (NICHT
|
||||||
SPEC_TYPE = os.environ.get("MC_SPEC_TYPE", "draft-simple")
|
# --spec-draft-model/draft-simple). 1,5–2× Durchsatz bei null Qualitätsverlust.
|
||||||
# MTP-Speculative-Decoding (Multi-Token-Prediction): manche Modelle bringen einen eigenen
|
SPEC_DRAFT_N_MAX = int(os.environ.get("MC_SPEC_DRAFT_N_MAX", "4"))
|
||||||
# MTP-Kopf mit (z.B. gemma-4 → arch 'gemma4-assistant', Datei 'mtp-*.gguf'). Der wird mit
|
# Env für HuggingFace-Downloads: XET deaktivieren (Hänger bei ~6 MB, siehe v1-Gotcha).
|
||||||
# `--model-draft <mtp.gguf> --spec-type draft-mtp --spec-draft-n-max N` geladen (NICHT
|
HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"}
|
||||||
# --spec-draft-model/draft-simple). 1,5–2× Durchsatz bei null Qualitätsverlust.
|
|
||||||
SPEC_DRAFT_N_MAX = int(os.environ.get("MC_SPEC_DRAFT_N_MAX", "4"))
|
# --- Routing-Gateway (builtin in MC2, model: auto) ---------------------------
|
||||||
# Env für HuggingFace-Downloads: XET deaktivieren (Hänger bei ~6 MB, siehe v1-Gotcha).
|
# MC2 IST der Gateway (services/gateway.py + routers/gateway_proxy.py). KEIN externer
|
||||||
HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"}
|
# 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("/")
|
||||||
# --- Routing-Gateway (builtin in MC2, model: auto) ---------------------------
|
# Gateway-Auszug (UMBAU v3 P1): Ist MC_V1_UPSTREAM gesetzt (Unit-Env, z. B.
|
||||||
# MC2 IST der Gateway (services/gateway.py + routers/gateway_proxy.py). KEIN externer
|
# http://127.0.0.1:9010), bedient der eigenständige mc2-gateway-Prozess den /v1-Pfad
|
||||||
# LiteLLM-Dienst (scheitert auf Python 3.14). Daher keine Gateway-Config-Datei mehr.
|
# und MC2 reicht /v1 nur noch roh durch (routers/gateway_forward.py). Leer = altes
|
||||||
GATEWAY_URL = os.environ.get("MC_GATEWAY_URL", f"http://127.0.0.1:{os.environ.get('MC_PORT', '9000')}").rstrip("/")
|
# 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 Agent (eigener Dienst auf der Box) -------------------------------
|
||||||
HERMES_API_URL = os.environ.get("HERMES_API_URL", "http://127.0.0.1:8642").rstrip("/")
|
# Gateway (OpenAI-API des Agenten) + interaktives Web-Terminal (ttyd → `hermes chat`).
|
||||||
# API-Key der Hermes-`api_server`-Plattform (~/.hermes/.env: API_SERVER_KEY). Nötig für
|
HERMES_API_URL = os.environ.get("HERMES_API_URL", "http://127.0.0.1:8642").rstrip("/")
|
||||||
# /v1/chat/completions (Voice-Pipeline) — Bearer-Auth, sonst 401. Derselbe volle Agent
|
# API-Key der Hermes-`api_server`-Plattform (~/.hermes/.env: API_SERVER_KEY). Nötig für
|
||||||
# (Tools + geteiltes Mem0) wie CLI/Telegram, nur über HTTP.
|
# /v1/chat/completions (Voice-Pipeline) — Bearer-Auth, sonst 401. Derselbe volle Agent
|
||||||
def _read_hermes_env(key: str) -> str:
|
# (Tools + eigenes Gedächtnis) wie CLI/Telegram, nur über HTTP.
|
||||||
"""Liest einen Schlüssel aus ~/.hermes/.env (Fallback, falls nicht in der Prozess-Env).
|
def _read_hermes_env(key: str) -> str:
|
||||||
Der MC2-Dienst erbt die Hermes-Secrets sonst nicht."""
|
"""Liest einen Schlüssel aus ~/.hermes/.env (Fallback, falls nicht in der Prozess-Env).
|
||||||
try:
|
Der MC2-Dienst erbt die Hermes-Secrets sonst nicht."""
|
||||||
env_path = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes"))) / ".env"
|
try:
|
||||||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
env_path = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes"))) / ".env"
|
||||||
line = line.strip()
|
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||||||
if line.startswith(f"{key}="):
|
line = line.strip()
|
||||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
if line.startswith(f"{key}="):
|
||||||
except OSError:
|
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||||
pass
|
except OSError:
|
||||||
return ""
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
HERMES_API_KEY = (
|
|
||||||
os.environ.get("HERMES_API_KEY")
|
HERMES_API_KEY = (
|
||||||
or os.environ.get("API_SERVER_KEY")
|
os.environ.get("HERMES_API_KEY")
|
||||||
or _read_hermes_env("API_SERVER_KEY")
|
or os.environ.get("API_SERVER_KEY")
|
||||||
)
|
or _read_hermes_env("API_SERVER_KEY")
|
||||||
# Modellfeld im OpenAI-Request; die api_server-Plattform nutzt ihr konfiguriertes Hirn,
|
)
|
||||||
# das Feld ist i.d.R. kosmetisch. Override via Env, falls die Plattform strikt prüft.
|
# Modellfeld im OpenAI-Request; die api_server-Plattform nutzt ihr konfiguriertes Hirn,
|
||||||
HERMES_API_MODEL = os.environ.get("HERMES_API_MODEL", "hermes")
|
# das Feld ist i.d.R. kosmetisch. Override via Env, falls die Plattform strikt prüft.
|
||||||
|
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.
|
# --- Voice-Sidecar (STT faster-whisper + TTS Piper/Chatterbox) ---------------
|
||||||
VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:8650").rstrip("/")
|
# Eigenes Python-3.12-venv (~/.voice/venv), weil Parakeet/Piper nicht ins 3.14-Backend-venv
|
||||||
# Box-Konsole: ttyd-Web-Terminal (echte Login-Shell). Bindet NUR an Loopback
|
# passen. MC2 proxyt nach außen.
|
||||||
# (127.0.0.1:7682, base-path /console) und wird von MC2 über den ohnehin offenen Port 9001
|
VOICE_SERVICE_URL = os.environ.get("MC_VOICE_SERVICE_URL", "http://127.0.0.1:8650").rstrip("/")
|
||||||
# rückwärts geproxyt (routers/console.py → same-origin /console/). So braucht die Konsole
|
# --- Lucys Stimme (lucy-stimme.service, pocket-tts german_24l, Klon aus ref.mp3) -----------
|
||||||
# KEINE eigene Firewall-Freigabe (Port 7682 ist von außen dicht) und keinen sudo-Eingriff.
|
# Eigener User-Dienst in ~/.lucy-stimme (nur Loopback :8021). Spricht die Telegram-Sprachnachrichten
|
||||||
# Direkter, SSH-artiger Zugriff, kein Passwort — gleiches LAN-Trust-Modell wie das Dashboard.
|
# UND — seit dem Raphael-Umbau der Desktop-Lucy (04.09.2026) — auch den PC: MC2 reicht ihn unter
|
||||||
BOX_CONSOLE_UPSTREAM = os.environ.get("MC_BOX_CONSOLE_UPSTREAM", "http://127.0.0.1:7682").rstrip("/")
|
# /api/lucy/stimme/* ins LAN (routers/voice.py). EINE Stimme für alle Türen. :8650 ist NICHT Lucy.
|
||||||
# Öffentlicher, gleicher-Ursprung-Pfad, unter dem MC2 die Konsole ausliefert (iframe-Ziel).
|
LUCY_STIMME_URL = os.environ.get("MC_LUCY_STIMME_URL", "http://127.0.0.1:8021").rstrip("/")
|
||||||
BOX_CONSOLE_PATH = "/console/"
|
# Eingebaute Hermes-Web-GUI (`hermes serve`/`dashboard` — die richtige Agent-Oberfläche mit
|
||||||
# Eingebaute Hermes-Web-GUI (`hermes serve`/`dashboard` — die richtige Agent-Oberfläche mit
|
# Threads/Tool-Calls, die auch die Electron-Desktop-App umhüllt). ‼️ Sie bindet NICHT auf Loopback:
|
||||||
# Threads/Tool-Calls, die auch die Electron-Desktop-App umhüllt). Bindet NUR an Loopback
|
# ein systemd-Drop-in überschreibt `--host 127.0.0.1` mit `0.0.0.0`, weil Hermes seine Anmeldung auf
|
||||||
# (127.0.0.1:9119, kein Login — LAN-Trust wie Terminal/Konsole) und wird von MC2 same-origin unter
|
# Loopback abschaltet (dann wäre /hermes-ui/ ohne Login offen). Dass sie trotzdem nicht im LAN hängt,
|
||||||
# /hermes-ui/ durchgereicht (routers/hermes_ui.py). Anders als das ttyd-Terminal ist es eine volle
|
# liegt allein an ufw (9119 ist nicht freigegeben — 27.08. und 25.09.2026 vom PC aus gegengeprüft).
|
||||||
# SPA → das Bundle wird mit Vite-base=/hermes-ui/ gebaut, damit Assets/API/WS unter dem Präfix
|
# Wer die Firewall anfasst, öffnet damit auch diese Oberfläche. Das feste Session-Token der früheren
|
||||||
# liegen; der Proxy streift /hermes-ui ab. Kein eigener Firewall-Port nötig.
|
# Desktop-Anbindung ist seit 25.09.2026 weg. MC2 erreicht sie über Loopback und reicht sie
|
||||||
HERMES_BUILTIN_UI_UPSTREAM = os.environ.get("MC_HERMES_BUILTIN_UI_UPSTREAM", "http://127.0.0.1:9119").rstrip("/")
|
# same-origin unter
|
||||||
HERMES_BUILTIN_UI_PATH = "/hermes-ui/"
|
# /hermes-ui/ durchgereicht (routers/hermes_ui.py). Anders als das ttyd-Terminal ist es eine volle
|
||||||
# GitHub-Repo für Update-Checks.
|
# SPA → das Bundle wird mit Vite-base=/hermes-ui/ gebaut, damit Assets/API/WS unter dem Präfix
|
||||||
HERMES_AGENT_REPO = os.environ.get("MC_HERMES_AGENT_REPO", "NousResearch/hermes-agent")
|
# liegen; der Proxy streift /hermes-ui ab. Kein eigener Firewall-Port nötig.
|
||||||
HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes")))
|
HERMES_BUILTIN_UI_UPSTREAM = os.environ.get("MC_HERMES_BUILTIN_UI_UPSTREAM", "http://127.0.0.1:9119").rstrip("/")
|
||||||
# PC Executor — läuft auf dem Windows-PC, erreichbar über LAN.
|
HERMES_BUILTIN_UI_PATH = "/hermes-ui/"
|
||||||
PC_EXECUTOR_URL = os.environ.get("MC_PC_EXECUTOR_URL", "http://192.168.178.98:7777").rstrip("/")
|
HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes")))
|
||||||
|
|
||||||
# --- Server ------------------------------------------------------------------
|
# --- Server ------------------------------------------------------------------
|
||||||
HOST = os.environ.get("MC_HOST", "0.0.0.0")
|
PORT = int(os.environ.get("MC_PORT", "9000"))
|
||||||
PORT = int(os.environ.get("MC_PORT", "9000"))
|
# Gebautes React-Frontend (frontend/dist). In Prod liefert FastAPI es statisch aus;
|
||||||
# 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.
|
||||||
# im Dev läuft der Vite-Dev-Server separat und proxyt /api hierher.
|
FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent / "frontend" / "dist")))
|
||||||
FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent / "frontend" / "dist")))
|
|
||||||
|
# Version (Phase 0 — Greenfield-Skeleton).
|
||||||
# Version (Phase 0 — Greenfield-Skeleton).
|
VERSION = "2.0.0-w8"
|
||||||
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,64 @@
|
|||||||
|
"""Ziele und Bausteine (Phase 2, 24.09.2026): ein Modell für alles, was Updates bekommt.
|
||||||
|
|
||||||
|
Ein Ziel ist ein Gerät: die KI-Box, der Proxmox-Host, ein Container, die Arcane-VM. Es besteht aus
|
||||||
|
Bausteinen (Betriebssystem, Motor, die App im Container, Docker-Images …). Jeder Baustein sagt, wie
|
||||||
|
sein Stand ist — neu, aktuell, unbekannt (mit Grund) oder festgehalten — und wie man ihn einspielt.
|
||||||
|
|
||||||
|
Die KI-Box meldet ihre Bausteine über den Box-Adapter (services/box_updates.py), der Homelab-Teil
|
||||||
|
ab Phase 3 über Proxmox- und Arcane-Adapter. So wird aus beiden Instanzen EINE Liste offener Updates
|
||||||
|
(User-Entscheid 24.09.: eine Anlaufstelle für Updates im ganzen Homelab).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
Zustand = Literal["neu", "aktuell", "unbekannt", "festgehalten", "wird-geprueft"]
|
||||||
|
# Reihenfolge für die Anzeige: was Aufmerksamkeit braucht, zuerst.
|
||||||
|
_RANG = {"unbekannt": 0, "festgehalten": 1, "neu": 2, "wird-geprueft": 3, "aktuell": 4}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Aktion:
|
||||||
|
"""Wie ein Update angestoßen wird: eine Schnittstelle der Instanz, die das Ziel pflegt."""
|
||||||
|
methode: str # "POST"
|
||||||
|
pfad: str # "/api/maintenance/engine-update"
|
||||||
|
frage: str # Rückfrage vor dem Klick
|
||||||
|
label: str = "Jetzt updaten"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BausteinStand:
|
||||||
|
id: str # "engine"
|
||||||
|
name: str # "Motor (llama.cpp)"
|
||||||
|
zustand: Zustand
|
||||||
|
installiert: str | None = None
|
||||||
|
verfuegbar: str | None = None
|
||||||
|
kurz: str = "" # „10 Pakete", „b11160 → b11172", „743 Änderungen"
|
||||||
|
grund: str | None = None # bei „unbekannt" und „festgehalten": warum
|
||||||
|
aktion: Aktion | None = None # None = kein Knopf (aktuell, festgehalten, unbekannt)
|
||||||
|
sicherheit: int | None = None # davon Sicherheitsupdates (seit 25.09.2026; None = unbekannt)
|
||||||
|
|
||||||
|
|
||||||
|
@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.",
|
"_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",
|
"version": "2026-07-03",
|
||||||
"models": [
|
"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",
|
"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,
|
"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",
|
"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,
|
"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
|
"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
|
# Gepinnt am 24.09.2026 auf das, was nachweislich läuft: `pip freeze` im venv der KI-Box (Python 3.14) und im
|
||||||
python-multipart>=0.0.9
|
# Homelab-Container 107 (/opt/mc2/backend/.venv, Python 3.13). Gleiche Version = „==“, sonst ein Bereich, der
|
||||||
uvicorn[standard]>=0.30
|
# beide Stände abdeckt — so installiert weder deploy.sh (Box) noch einrichten.sh (Container) etwas Neues.
|
||||||
httpx>=0.27
|
# Wer eine Grenze anhebt: vorher deploy/probelauf-box.sh, und die neue Version in beiden Umgebungen prüfen.
|
||||||
ruamel.yaml>=0.18
|
fastapi>=0.139.0,<=0.141.1
|
||||||
psutil>=5.9
|
python-multipart==0.0.32
|
||||||
huggingface_hub>=0.27
|
uvicorn[standard]>=0.51.0,<=0.53.0
|
||||||
mcp>=1.2.0
|
httpx==0.28.1
|
||||||
tzdata>=2024.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,62 +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
|
|
||||||
|
|
||||||
|
|
||||||
class KandidatIn(BaseModel):
|
|
||||||
file: str
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/auftragsbuch")
|
|
||||||
def list_proposals() -> dict:
|
|
||||||
return auftragsbuch.list_proposals()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/auftragsbuch/diff")
|
|
||||||
def diff(branch: str) -> dict:
|
|
||||||
res = auftragsbuch.diff_of(branch)
|
|
||||||
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)
|
|
||||||
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)
|
|
||||||
if not res.get("ok"):
|
|
||||||
raise HTTPException(400, res.get("error", "Ablehnen fehlgeschlagen."))
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/auftragsbuch/skill/annehmen")
|
|
||||||
def skill_accept(body: KandidatIn) -> dict:
|
|
||||||
res = auftragsbuch.skill_accept(body.file)
|
|
||||||
if not res.get("ok"):
|
|
||||||
raise HTTPException(400, res.get("error", "Beauftragen fehlgeschlagen."))
|
|
||||||
return res
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/auftragsbuch/skill/ablehnen")
|
|
||||||
def skill_reject(body: KandidatIn) -> dict:
|
|
||||||
res = auftragsbuch.skill_reject(body.file)
|
|
||||||
if not res.get("ok"):
|
|
||||||
raise HTTPException(400, res.get("error", "Verwerfen fehlgeschlagen."))
|
|
||||||
return res
|
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
"""
|
||||||
|
Box-Wart-Schnittstellen (Umbau 09/2026): alles, was der Cockpit-Startbildschirm braucht.
|
||||||
|
|
||||||
|
GET /api/start Zustand, Hinweise, Warnlampen, Box-Werte, Updates, Flugplan
|
||||||
|
GET /api/hinweise Hinweise + Verlauf des Wächters
|
||||||
|
POST /api/hinweise/{id}/aktion/{aktion} Knopf eines Hinweises ausführen
|
||||||
|
GET /api/modelle/nutzung Wer nutzt die Modelle (7 Tage, 24 h je Stunde)
|
||||||
|
GET /api/updates/verlauf Was die letzten Update-Läufe wirklich gebracht haben
|
||||||
|
POST /api/updates/festgehalten/{b}/freigeben Festgehaltenen Baustein wieder freigeben
|
||||||
|
GET /api/modelle/aufraeumen Was auf der Modell-Platte ungenutzt Platz belegt
|
||||||
|
GET /api/ziele Die KI-Box als Ziel mit Bausteinen (gemeinsames Modell, Phase 2)
|
||||||
|
POST /api/modelle/aufraeumen/loeschen Einen Kandidaten löschen (nur auf Knopfdruck)
|
||||||
|
GET /api/aufraeumen/altreste Altreste neben dem Betrieb (feste Liste, nur was da ist)
|
||||||
|
POST /api/aufraeumen/altreste/loeschen Einen Altrest löschen — per Kennung, nie per Pfad
|
||||||
|
|
||||||
|
Dünn: die Logik liegt in services/waechter.py, modell_nutzung.py, zeitplan.py, update_verlauf.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import psutil
|
||||||
|
from config import MODELS_DIR
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from kern import prognose
|
||||||
|
from kern.zeit import LOCAL_TZ
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from services import (
|
||||||
|
aufraeumen,
|
||||||
|
backup,
|
||||||
|
box_updates,
|
||||||
|
llamaswap,
|
||||||
|
messwerte,
|
||||||
|
modell_nutzung,
|
||||||
|
system,
|
||||||
|
update_verlauf,
|
||||||
|
waechter,
|
||||||
|
zeitplan,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["boxwart"])
|
||||||
|
|
||||||
|
def _zeit_kurz(ts: float | None) -> str:
|
||||||
|
if not ts:
|
||||||
|
return "–"
|
||||||
|
dt = datetime.fromtimestamp(ts, LOCAL_TZ)
|
||||||
|
heute = datetime.now(LOCAL_TZ).date()
|
||||||
|
if dt.date() == heute:
|
||||||
|
return f"heute {dt:%H:%M}"
|
||||||
|
if (heute - dt.date()).days == 1:
|
||||||
|
return f"gestern {dt:%H:%M}"
|
||||||
|
return f"{dt:%d.%m. %H:%M}"
|
||||||
|
|
||||||
|
|
||||||
|
def _lampen(stand: dict, u: dict) -> list[dict]:
|
||||||
|
ids = {h.get("id", "") for h in stand["hinweise"]}
|
||||||
|
versionen = system.check_versions_cached()
|
||||||
|
|
||||||
|
def lampe(lid: str, label: str, zustand: str, wert: str) -> dict:
|
||||||
|
return {"id": lid, "label": label, "zustand": zustand, "wert": wert}
|
||||||
|
|
||||||
|
engine_ok = llamaswap.engine_reachable()
|
||||||
|
build = (versionen.get("engine") or {}).get("version_text", "")
|
||||||
|
lampen = [lampe("motor", "Motor", "ok" if engine_ok else "fehler", build if engine_ok else "antwortet nicht")]
|
||||||
|
|
||||||
|
hirn = llamaswap.brain_status() if engine_ok else {}
|
||||||
|
lampen.append(lampe("hirn", "Hirn", "ok" if hirn.get("ready") else "fehler",
|
||||||
|
"geladen" if hirn.get("ready") else "lädt nicht"))
|
||||||
|
|
||||||
|
laufend = set(llamaswap.get_running_models()) if engine_ok else set()
|
||||||
|
coder = next((m for m in llamaswap.list_models() if "coder" in (m.get("aliases") or [])), None)
|
||||||
|
coder_an = bool(coder and coder.get("name") in laufend)
|
||||||
|
lampen.append(lampe("coder", "Coder", "ok" if coder_an else "aus", "geladen" if coder_an else "auf Abruf"))
|
||||||
|
|
||||||
|
hermes_weg = any(i in ids for i in ("kern:hermes", "dienst:hermes-gateway"))
|
||||||
|
lampen.append(lampe("hermes", "Hermes", "fehler" if hermes_weg else "ok",
|
||||||
|
"antwortet nicht" if hermes_weg else "läuft"))
|
||||||
|
|
||||||
|
job_hinweise = [h for h in stand["hinweise"] if h.get("id", "").startswith(("job:", "timer:"))]
|
||||||
|
rot = any(h.get("stufe") == "rot" for h in job_hinweise)
|
||||||
|
lampen.append(lampe("jobs", "Jobs", "fehler" if rot else ("warn" if job_hinweise else "ok"),
|
||||||
|
f"{len(job_hinweise)} Hinweis{'e' if len(job_hinweise) != 1 else ''}"
|
||||||
|
if job_hinweise else "alles gelaufen"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
sicherungen = backup.list_backups()
|
||||||
|
except Exception:
|
||||||
|
sicherungen = []
|
||||||
|
letzte = max((s.get("mtime") or s.get("ts") or 0 for s in sicherungen), default=0)
|
||||||
|
zu_alt = not letzte or time.time() - letzte > 36 * 3600
|
||||||
|
lampen.append(lampe("sicherung", "Sicherung", "warn" if zu_alt else "ok", _zeit_kurz(letzte)))
|
||||||
|
|
||||||
|
try:
|
||||||
|
platte = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else ".").percent
|
||||||
|
except Exception:
|
||||||
|
platte = None
|
||||||
|
zustand = "ok" if platte is None or platte < waechter.DISK_GELB_PCT else (
|
||||||
|
"warn" if platte < waechter.DISK_ROT_PCT else "fehler")
|
||||||
|
lampen.append(lampe("platte", "Platte", zustand, f"{platte:.0f} %" if platte is not None else "–"))
|
||||||
|
|
||||||
|
n = len(box_updates.bausteine(u))
|
||||||
|
fest = len(update_verlauf.festgehalten())
|
||||||
|
unklar = len(box_updates.unklar(u))
|
||||||
|
if fest:
|
||||||
|
lampen.append(lampe("updates", "Updates", "warn", f"{fest} festgehalten"))
|
||||||
|
elif not box_updates.gelesen():
|
||||||
|
lampen.append(lampe("updates", "Updates", "aus", "wird geprüft"))
|
||||||
|
elif n:
|
||||||
|
lampen.append(lampe("updates", "Updates", "info", f"{n} bereit"))
|
||||||
|
elif unklar:
|
||||||
|
lampen.append(lampe("updates", "Updates", "warn", "Prüfung unklar"))
|
||||||
|
else:
|
||||||
|
lampen.append(lampe("updates", "Updates", "ok", "aktuell"))
|
||||||
|
return lampen
|
||||||
|
|
||||||
|
|
||||||
|
def _box() -> dict:
|
||||||
|
p = system.metrik_punkt()
|
||||||
|
try:
|
||||||
|
du = psutil.disk_usage(str(MODELS_DIR) if MODELS_DIR.exists() else ".")
|
||||||
|
# Seit 25.09.2026 mit „voll in etwa N Tagen“ aus dem Verlauf der Woche (kern/prognose.py).
|
||||||
|
platte = {"used": du.used, "total": du.total, "percent": du.percent,
|
||||||
|
"vorhersage": prognose.aus_messreihe(messwerte.QUELLE, "platte")}
|
||||||
|
except Exception:
|
||||||
|
platte = None
|
||||||
|
return {"ram_used": p.get("ram_used"), "ram_total": p.get("ram_total"),
|
||||||
|
"temp_cpu": p.get("temp_cpu"), "temp_gpu": p.get("temp_gpu"),
|
||||||
|
"platte": platte, "uptime_s": p.get("uptime_s")}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/start")
|
||||||
|
def start() -> dict:
|
||||||
|
stand = waechter.lese_stand()
|
||||||
|
u = box_updates.gecacht()
|
||||||
|
# Stumm geschaltete Hinweise (seit 25.09.2026) stehen in der Liste, lösen aber die Leuchte nicht aus.
|
||||||
|
laut = [h for h in stand["hinweise"] if h.get("zustand") != "stumm"]
|
||||||
|
rot = sum(1 for h in laut if h.get("stufe") == "rot")
|
||||||
|
anzahl = len(laut)
|
||||||
|
wach = bool(stand["stand"]) and time.time() - float(stand["stand"] or 0) < 5 * 60
|
||||||
|
return {
|
||||||
|
"zustand": {
|
||||||
|
"stufe": "rot" if rot else ("gelb" if anzahl else "ok"),
|
||||||
|
"anzahl": anzahl,
|
||||||
|
"waechter_wach": wach,
|
||||||
|
"stand": stand["stand"],
|
||||||
|
"update_laeuft": stand["update_laeuft"],
|
||||||
|
},
|
||||||
|
"hinweise": stand["hinweise"],
|
||||||
|
"verlauf": stand["verlauf"][:20],
|
||||||
|
"lampen": _lampen(stand, u),
|
||||||
|
"box": _box(),
|
||||||
|
"updates": {"bausteine": box_updates.bausteine(u), "unklar": box_updates.unklar(u),
|
||||||
|
"festgehalten": update_verlauf.festgehalten(),
|
||||||
|
"gelesen": box_updates.gelesen()},
|
||||||
|
"flugplan": zeitplan.flugplan(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/hinweise")
|
||||||
|
def hinweise() -> dict:
|
||||||
|
return waechter.lese_stand()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/hinweise/{hinweis_id}/aktion/{aktion_id}")
|
||||||
|
def hinweis_aktion(hinweis_id: str, aktion_id: str) -> dict:
|
||||||
|
ergebnis = waechter.fuehre_aktion_aus(hinweis_id, aktion_id)
|
||||||
|
if ergebnis.get("detail") and not ergebnis.get("ok"):
|
||||||
|
raise HTTPException(status_code=409, detail=ergebnis["detail"])
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/modelle/nutzung")
|
||||||
|
def nutzung() -> dict:
|
||||||
|
return modell_nutzung.nutzung()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/updates/verlauf")
|
||||||
|
def updates_verlauf(anzahl: int = 8) -> dict:
|
||||||
|
"""Die letzten Update-Läufe mit dem Ergebnis je Baustein (aus dem Meldeprotokoll der Box)."""
|
||||||
|
return {"laeufe": update_verlauf.laeufe(max(1, min(anzahl, 30)))}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/updates/festgehalten/{baustein}/freigeben")
|
||||||
|
def festgehalten_freigeben(baustein: str) -> dict:
|
||||||
|
if not update_verlauf.freigeben(baustein):
|
||||||
|
raise HTTPException(status_code=404, detail="Dieser Baustein ist nicht festgehalten.")
|
||||||
|
return {"ok": True, "text": update_verlauf.FREIGEGEBEN_TEXT}
|
||||||
|
|
||||||
|
|
||||||
|
class LoeschAuftrag(BaseModel):
|
||||||
|
art: str # "eintrag" | "ordner"
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/modelle/aufraeumen")
|
||||||
|
def aufraeumen_liste() -> dict:
|
||||||
|
return {"kandidaten": aufraeumen.kandidaten()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/modelle/aufraeumen/loeschen")
|
||||||
|
def aufraeumen_loeschen(auftrag: LoeschAuftrag) -> dict:
|
||||||
|
ergebnis = aufraeumen.loeschen(auftrag.art, auftrag.name)
|
||||||
|
if not ergebnis.get("ok"):
|
||||||
|
raise HTTPException(status_code=409, detail=ergebnis.get("detail") or "Löschen ging nicht.")
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
class AltrestAuftrag(BaseModel):
|
||||||
|
id: str # Kennung aus der festen Liste (services/aufraeumen.ALTRESTE), nie ein Pfad
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/aufraeumen/altreste")
|
||||||
|
def altreste_liste() -> dict:
|
||||||
|
"""Altreste neben dem Betrieb (Test-Ordner, alte Umgebungen, Worktrees …), nur was da und nicht in Gebrauch ist."""
|
||||||
|
return {"altreste": aufraeumen.altreste()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/aufraeumen/altreste/loeschen")
|
||||||
|
def altrest_loeschen(auftrag: AltrestAuftrag) -> dict:
|
||||||
|
ergebnis = aufraeumen.altrest_loeschen(auftrag.id)
|
||||||
|
if not ergebnis.get("ok"):
|
||||||
|
raise HTTPException(status_code=409, detail=ergebnis.get("detail") or "Löschen ging nicht.")
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ziele")
|
||||||
|
def ziele() -> dict:
|
||||||
|
"""Die KI-Box im gemeinsamen Ziel-Modell (kern/ziele.py). Der Homelab-Teil liefert seine Ziele ab
|
||||||
|
Phase 3 unter /api/homelab/ziele; die Oberfläche legt beide zu einer Update-Liste zusammen."""
|
||||||
|
return {"ziele": [box_updates.ki_box_ziel().als_dict()]}
|
||||||
@@ -1,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)
|
||||||
|
)
|
||||||
+333
-140
@@ -1,140 +1,333 @@
|
|||||||
import os
|
import hashlib
|
||||||
|
import json
|
||||||
from fastapi import APIRouter, Request
|
import logging
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse
|
import os
|
||||||
|
from collections import OrderedDict
|
||||||
from config import LLAMA_SWAP_URL
|
|
||||||
from services.gateway_stream import record_stream_chunk, record_usage
|
import httpx
|
||||||
from services.router_logic import LANES, choose_for_lane
|
from config import LLAMA_SWAP_URL
|
||||||
from services.routing_policy import load_policy
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
router = APIRouter(prefix="/v1")
|
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
|
||||||
# Antwortsprache für IDE-/Lane-Traffic: die Coding-Modelle antworten sonst englisch
|
from services.routing_policy import load_policy
|
||||||
# (User-Anforderung 03.07.2026). Leerer String (MC_GATEWAY_LANG_DIRECTIVE="") schaltet ab.
|
|
||||||
_LANG_DIRECTIVE = os.environ.get(
|
log = logging.getLogger(__name__)
|
||||||
"MC_GATEWAY_LANG_DIRECTIVE",
|
|
||||||
"Antworte dem Nutzer grundsätzlich auf Deutsch (Erklärungen, Pläne, Rückfragen, "
|
router = APIRouter(prefix="/v1")
|
||||||
"Zusammenfassungen) — auch wenn die Frage oder Tool-Anweisungen englisch sind. "
|
|
||||||
"Quellcode, Bezeichner und Shell-Befehle bleiben unverändert.")
|
# Antwortsprache für IDE-/Lane-Traffic: die Coding-Modelle antworten sonst englisch
|
||||||
|
# (User-Anforderung 03.07.2026). Leerer String (MC_GATEWAY_LANG_DIRECTIVE="") schaltet ab.
|
||||||
|
_LANG_DIRECTIVE = os.environ.get(
|
||||||
def _inject_language(body: dict, alias: str) -> None:
|
"MC_GATEWAY_LANG_DIRECTIVE",
|
||||||
"""Deutsch-Direktive anhängen. An die ERSTE System-Message (viele Chat-Templates
|
"Antworte dem Nutzer grundsätzlich auf Deutsch (Erklärungen, Pläne, Rückfragen, "
|
||||||
erwarten nur eine), sonst als neue System-Message. `hermes` ausgenommen — Lucys
|
"Zusammenfassungen) — auch wenn die Frage oder Tool-Anweisungen englisch sind. "
|
||||||
Persona (SOUL.md) regelt die Sprache selbst."""
|
"Quellcode, Bezeichner und Shell-Befehle bleiben unverändert.")
|
||||||
if not _LANG_DIRECTIVE or alias == "hermes":
|
|
||||||
return
|
|
||||||
msgs = body.get("messages")
|
# Deckel für die Bild-Beschreibungen. Ohne ihn wächst die Wartezeit linear mit der Bildzahl:
|
||||||
if not isinstance(msgs, list):
|
# je Bild bis zu 2 Versuche à _BILD_TIMEOUT_S, und der Client hängt so lange am offenen
|
||||||
return
|
# Request. Sind mehr als _BILD_MAX Bilder NEU zu beschreiben, bleiben alle Bilder drin und die
|
||||||
first_sys = next((m for m in msgs if isinstance(m, dict) and m.get("role") == "system"), None)
|
# Anfrage geht an den Bild-Zwilling (ehrlich langsam statt scheinbar hängend).
|
||||||
if first_sys is None:
|
_BILD_TIMEOUT_S = float(os.environ.get("MC_CODER_IMAGE_TIMEOUT_S", "240"))
|
||||||
msgs.insert(0, {"role": "system", "content": _LANG_DIRECTIVE})
|
_BILD_MAX = int(os.environ.get("MC_CODER_IMAGE_MAX", "4"))
|
||||||
elif isinstance(first_sys.get("content"), str):
|
|
||||||
first_sys["content"] = first_sys["content"].rstrip() + "\n\n" + _LANG_DIRECTIVE
|
# Beschreibung älterer Bilder: Prompt bewusst auf wörtliche Wiedergabe von Code/Fehlermeldungen
|
||||||
elif isinstance(first_sys.get("content"), list):
|
# getrimmt — das schnelle Modell arbeitet in den Folgeschritten nur noch mit diesem Text.
|
||||||
first_sys["content"].append({"type": "text", "text": _LANG_DIRECTIVE})
|
_BILD_BESCHREIB_PROMPT = os.environ.get(
|
||||||
|
"MC_CODER_IMAGE_PROMPT",
|
||||||
# Virtuelle Lanes, die der Gateway zusätzlich zu den echten Modellen als „Modell" anbietet.
|
"Beschreibe dieses Bild vollstaendig und praezise auf Deutsch: sichtbarer Text, "
|
||||||
_LANE_LABELS = {"coding": "Coding (Router → coder/heavy/fast)", "chat": "Chat (Router → fast/heavy)"}
|
"Code, Zahlen/Daten, UI-Elemente, Layout, Farben und alles Auffaellige. "
|
||||||
|
"Sichtbaren Code und Fehlermeldungen gib WOERTLICH wieder.")
|
||||||
|
|
||||||
@router.get("/models")
|
|
||||||
async def models(request: Request):
|
# Lucy-Voice-Schnellspur (16.07.): Lucys Sprach-Turns tragen diesen Marker im System-Prompt.
|
||||||
client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan)
|
# Er schaltet das Qwen-Denken für GENAU diese Requests ab (gemessen direkt am Hirn: 9,9 s -> 0,8 s
|
||||||
r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0)
|
# bis zur Antwort; reasoning_content 2500 Zeichen -> 0) — die „Hirn"-Latenz der Voice-Turns war
|
||||||
data = r.json()
|
# fast vollständig Reasoning-Generierung VOR dem ersten sprechbaren Wort. Der Marker wird VOR dem
|
||||||
# Lanes ganz oben einblenden, damit IDEs einfach „coding"/„chat" wählen können.
|
# Modell aus allen Messages entfernt (konstanter Text -> Prefix-Cache bleibt stabil). Requests ohne
|
||||||
lanes = [{"id": lane, "object": "model", "owned_by": "mc2-router",
|
# Marker (Crons, Telegram, Werkstatt) bleiben unangetastet. Leerer Env-Wert schaltet die Spur ab.
|
||||||
"description": _LANE_LABELS.get(lane, lane)} for lane in LANES]
|
_NO_THINK_MARKER = os.environ.get("MC_NO_THINK_MARKER", "[[MC:NO_THINK]]")
|
||||||
# Kontextlänge je Modell mitliefern (aus der llama-swap-Config geparst). Ohne sie
|
|
||||||
# budgetieren Clients blind — Hermes-Subagents nahmen 256k an, schickten passende
|
|
||||||
# max_tokens und rissen damit den echten Server-Kontext (Radar-Lauf 02.07.).
|
def _apply_no_think_marker(body: dict) -> None:
|
||||||
# Rollen-Aliase (heavy/coder/hermes …) tauchen bei llama-swap NICHT als Einträge auf,
|
"""Marker aus allen Message-Inhalten strippen; war er da, Thinking abschalten
|
||||||
# Clients fragen aber genau damit an → als eigene Einträge einblenden.
|
(sofern der Client chat_template_kwargs nicht selbst gesetzt hat)."""
|
||||||
ctx_map: dict[str, int] = {}
|
if not _NO_THINK_MARKER:
|
||||||
alias_entries: list[dict] = []
|
return
|
||||||
try:
|
found = False
|
||||||
from services import llamaswap
|
for m in body.get("messages") or []:
|
||||||
for m in llamaswap.list_models():
|
if not isinstance(m, dict):
|
||||||
ctx = m.get("ctx")
|
continue
|
||||||
# --parallel teilt den Server-Kontext HART auf die Slots auf — ohne die
|
c = m.get("content")
|
||||||
# Division budgetieren Clients (v. a. Hermes) gegen 131k, real sind 65k/Slot:
|
if isinstance(c, str) and _NO_THINK_MARKER in c:
|
||||||
# Kompaktierung feuert nie, llama-server kappt Prompt/Antwort → abgerissene
|
m["content"] = c.replace(_NO_THINK_MARKER, "").strip()
|
||||||
# Tool-Calls + Retry-Schleifen (Log 07.07., Session bei ~52k Tokens).
|
found = True
|
||||||
slots = m.get("parallel_slots") or 1
|
elif isinstance(c, list):
|
||||||
if ctx and slots > 1:
|
for part in c:
|
||||||
ctx = ctx // slots
|
if (isinstance(part, dict) and isinstance(part.get("text"), str)
|
||||||
if ctx:
|
and _NO_THINK_MARKER in part["text"]):
|
||||||
for api_id in m.get("api_ids", []):
|
part["text"] = part["text"].replace(_NO_THINK_MARKER, "").strip()
|
||||||
ctx_map[api_id] = ctx
|
found = True
|
||||||
for alias in m.get("aliases", []):
|
if found and "chat_template_kwargs" not in body:
|
||||||
entry = {"id": alias, "object": "model", "owned_by": "mc2-alias",
|
body["chat_template_kwargs"] = {"enable_thinking": False}
|
||||||
"description": f"Alias für {m['name']}"}
|
|
||||||
if ctx:
|
|
||||||
entry["context_length"] = ctx
|
# Bild-Weiche v3 (24.09.2026). llama.cpp kann Draft-Beschleunigung und Bilder nicht zusammen (HTTP 500
|
||||||
alias_entries.append(entry)
|
# „failed to process speculative batch“, b11057 und b11157 geprüft). Darum haben Hirn und Coder je einen
|
||||||
except Exception:
|
# Bild-Zwilling: dieselben Gewichte mit Bild-Projektor, ohne Draft (vision, coder-bild).
|
||||||
pass
|
# • Bild im aktuellen Schritt → der Zwilling der Rolle sieht es selbst, auch mitten in einer Agenten-Aufgabe.
|
||||||
if isinstance(data, dict) and isinstance(data.get("data"), list):
|
# • Bild aus früheren Schritten → einmal von vision beschrieben (je Bild gemerkt) und als Text mitgeschickt,
|
||||||
for entry in data["data"]:
|
# damit der Rest der Aufgabe wieder beim schnellen Modell mit Draft läuft.
|
||||||
if (ctx := ctx_map.get(entry.get("id"))):
|
_BESCHREIBUNGEN: OrderedDict[str, str] = OrderedDict()
|
||||||
entry.setdefault("context_length", ctx)
|
_BESCHREIBUNGEN_MAX = 64
|
||||||
data["data"] = lanes + alias_entries + data["data"]
|
|
||||||
return JSONResponse(data, status_code=r.status_code)
|
|
||||||
|
def _bild_schluessel(part: dict) -> str:
|
||||||
|
return hashlib.sha1(json.dumps(part, sort_keys=True).encode("utf-8")).hexdigest()
|
||||||
async def _proxy(path: str, request: Request):
|
|
||||||
body = await request.json()
|
|
||||||
requested = str(body.get("model") or "auto")
|
async def _beschreibe(part: dict, vision_alias: str) -> str:
|
||||||
if requested.lower() in ("auto", "chat", "coding"):
|
"""Beschreibung eines Bild-Parts; Hermes und OpenCode schicken den ganzen Verlauf mit jedem Schritt
|
||||||
lane = requested.lower()
|
neu, darum wird jedes Bild nur einmal beschrieben."""
|
||||||
alias, reason = choose_for_lane(lane, body)
|
schluessel = _bild_schluessel(part)
|
||||||
body["model"] = alias
|
if schluessel in _BESCHREIBUNGEN:
|
||||||
routed = {"x-mc-routed-to": alias, "x-mc-route-reason": reason, "x-mc-lane": lane}
|
_BESCHREIBUNGEN.move_to_end(schluessel)
|
||||||
else:
|
return _BESCHREIBUNGEN[schluessel]
|
||||||
alias = requested
|
frage = {
|
||||||
routed = {"x-mc-routed-to": requested}
|
"model": vision_alias,
|
||||||
# fast-Spur: Thinking aus für flotte Antworten (sofern Client es nicht selbst setzt).
|
"stream": False,
|
||||||
pol = load_policy()
|
"max_tokens": 700,
|
||||||
if pol["fast_no_think"] and alias == pol["fast"] and "chat_template_kwargs" not in body:
|
# Denken aus: sonst frisst die Denkphase das Token-Budget und die Beschreibung bleibt leer.
|
||||||
body["chat_template_kwargs"] = {"enable_thinking": False}
|
"chat_template_kwargs": {"enable_thinking": False},
|
||||||
_inject_language(body, alias)
|
"messages": [{"role": "user", "content": [part, {"type": "text", "text": _BILD_BESCHREIB_PROMPT}]}],
|
||||||
url = f"{LLAMA_SWAP_URL}{path}"
|
}
|
||||||
|
text = ""
|
||||||
client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan)
|
# Eigener Kurzzeit-Client statt des gepoolten (Befund 15.07.: ReadTimeout nach Sekunden trotz
|
||||||
if body.get("stream"):
|
# timeout=240 — llama-swap kappt beim Modell-Swap gern alte Keep-Alive-Sockets) + 1 Retry.
|
||||||
req = client.build_request("POST", url, json=body, timeout=None)
|
for versuch in (1, 2):
|
||||||
r = await client.send(req, stream=True)
|
try:
|
||||||
if r.status_code != 200:
|
async with httpx.AsyncClient(timeout=_BILD_TIMEOUT_S) as c:
|
||||||
await r.aread()
|
r = await c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=frage)
|
||||||
try:
|
if r.status_code == 200:
|
||||||
resp_json = r.json()
|
text = ((r.json().get("choices") or [{}])[0].get("message") or {}).get("content") or ""
|
||||||
except Exception:
|
if text.strip():
|
||||||
resp_json = {"error": {"message": r.text, "type": "upstream_error"}}
|
break
|
||||||
return JSONResponse(resp_json, status_code=r.status_code, headers=routed)
|
log.warning("Bild-Weiche (Versuch %s): Beschreibung leer/HTTP %s — %.200s",
|
||||||
|
versuch, r.status_code, r.text)
|
||||||
async def gen():
|
except Exception:
|
||||||
try:
|
log.warning("Bild-Weiche (Versuch %s): Beschreibung scheiterte", versuch, exc_info=True)
|
||||||
async for chunk in r.aiter_raw():
|
text = text.strip()
|
||||||
record_stream_chunk(chunk, alias)
|
if text:
|
||||||
yield chunk
|
_BESCHREIBUNGEN[schluessel] = text
|
||||||
finally:
|
while len(_BESCHREIBUNGEN) > _BESCHREIBUNGEN_MAX:
|
||||||
await r.aclose()
|
_BESCHREIBUNGEN.popitem(last=False)
|
||||||
return StreamingResponse(gen(), media_type="text/event-stream", headers=routed)
|
return text
|
||||||
|
|
||||||
r = await client.post(url, json=body, timeout=600.0)
|
|
||||||
resp_json = r.json()
|
async def _alte_bilder_beschreiben(alt: list[tuple[dict, int]], vision_alias: str) -> bool:
|
||||||
record_usage(resp_json.get("usage") if isinstance(resp_json, dict) else None, alias)
|
"""Bilder aus früheren Schritten durch ihre Beschreibung ersetzen. False = ging nicht (zu viele neue
|
||||||
return JSONResponse(resp_json, status_code=r.status_code, headers=routed)
|
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:
|
||||||
@router.post("/chat/completions")
|
log.warning("Bild-Weiche: %s ältere Bilder neu zu beschreiben (Deckel %s) — Anfrage geht mit allen "
|
||||||
async def chat_completions(request: Request):
|
"Bildern an den Bild-Zwilling", len(neu), _BILD_MAX)
|
||||||
return await _proxy("/v1/chat/completions", request)
|
return False
|
||||||
|
texte = []
|
||||||
|
for msg, idx in alt:
|
||||||
@router.post("/completions")
|
text = await _beschreibe(msg["content"][idx], vision_alias)
|
||||||
async def completions(request: Request):
|
if not text:
|
||||||
return await _proxy("/v1/completions", request)
|
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
|
||||||
|
Persona (SOUL.md) regelt die Sprache selbst."""
|
||||||
|
if not _LANG_DIRECTIVE or alias == "hermes":
|
||||||
|
return
|
||||||
|
msgs = body.get("messages")
|
||||||
|
if not isinstance(msgs, list):
|
||||||
|
return
|
||||||
|
first_sys = next((m for m in msgs if isinstance(m, dict) and m.get("role") == "system"), None)
|
||||||
|
if first_sys is None:
|
||||||
|
msgs.insert(0, {"role": "system", "content": _LANG_DIRECTIVE})
|
||||||
|
elif isinstance(first_sys.get("content"), str):
|
||||||
|
first_sys["content"] = first_sys["content"].rstrip() + "\n\n" + _LANG_DIRECTIVE
|
||||||
|
elif isinstance(first_sys.get("content"), list):
|
||||||
|
first_sys["content"].append({"type": "text", "text": _LANG_DIRECTIVE})
|
||||||
|
|
||||||
|
def _upstream_fehler(text: str, status: int = 502, headers: dict | None = None) -> JSONResponse:
|
||||||
|
"""Fehler im OpenAI-Format statt eines nackten 500 (24.09.2026): Hermes, OpenChamber und Lucy
|
||||||
|
können damit umgehen und zeigen den Grund an."""
|
||||||
|
return JSONResponse({"error": {"message": text, "type": "upstream_error"}},
|
||||||
|
status_code=status, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/models")
|
||||||
|
async def models(request: Request):
|
||||||
|
client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan)
|
||||||
|
try:
|
||||||
|
r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
return _upstream_fehler(f"Engine nicht erreichbar: {exc.__class__.__name__}")
|
||||||
|
try:
|
||||||
|
data = r.json()
|
||||||
|
except ValueError:
|
||||||
|
# Engine antwortet, aber nicht mit JSON (Startphase/Fehlerseite) — ehrlicher 502
|
||||||
|
# statt eines 500ers aus dem Parser.
|
||||||
|
log.warning("/v1/models: Engine-Antwort ist kein JSON (HTTP %s): %.200s", r.status_code, r.text)
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": {"message": "Engine lieferte keine gültige Modell-Liste.",
|
||||||
|
"type": "upstream_error"}}, status_code=502)
|
||||||
|
# Aufgeräumt (07/2026): Die Router-Lanes (coding/chat) werden NICHT mehr als „Modell"
|
||||||
|
# angeboten — sie verwirrten (coding vs. coder) und seit dem Zed-Aus wählt sie niemand
|
||||||
|
# mehr. Das Routing bleibt erhalten (siehe _proxy), sie stehen nur nicht mehr in der Liste.
|
||||||
|
# Kontextlänge je Modell mitliefern (aus der llama-swap-Config geparst). Ohne sie
|
||||||
|
# budgetieren Clients blind — Hermes-Subagents nahmen 256k an, schickten passende
|
||||||
|
# max_tokens und rissen damit den echten Server-Kontext (Radar-Lauf 02.07.).
|
||||||
|
# Rollen-Aliase (heavy/coder/hermes …) tauchen bei llama-swap NICHT als Einträge auf,
|
||||||
|
# Clients fragen aber genau damit an → als eigene Einträge einblenden.
|
||||||
|
ctx_map: dict[str, int] = {}
|
||||||
|
alias_entries: list[dict] = []
|
||||||
|
aliased_raw: set[str] = set() # rohe Modellnamen, die ein Klarnamen-Alias schon zeigt
|
||||||
|
try:
|
||||||
|
from services import llamaswap
|
||||||
|
for m in llamaswap.list_models():
|
||||||
|
ctx = m.get("ctx")
|
||||||
|
# --parallel teilt den Server-Kontext HART auf die Slots auf — ohne die
|
||||||
|
# Division budgetieren Clients (v. a. Hermes) gegen 131k, real sind 65k/Slot:
|
||||||
|
# Kompaktierung feuert nie, llama-server kappt Prompt/Antwort → abgerissene
|
||||||
|
# Tool-Calls + Retry-Schleifen (Log 07.07., Session bei ~52k Tokens).
|
||||||
|
slots = m.get("parallel_slots") or 1
|
||||||
|
if ctx and slots > 1:
|
||||||
|
ctx = ctx // slots
|
||||||
|
if ctx:
|
||||||
|
for api_id in m.get("api_ids", []):
|
||||||
|
ctx_map[api_id] = ctx
|
||||||
|
aliases = m.get("aliases", [])
|
||||||
|
if aliases:
|
||||||
|
aliased_raw.add(m["name"])
|
||||||
|
for alias in aliases:
|
||||||
|
entry = {"id": alias, "object": "model", "owned_by": "mc2-alias",
|
||||||
|
"description": f"Alias für {m['name']}"}
|
||||||
|
if ctx:
|
||||||
|
entry["context_length"] = ctx
|
||||||
|
alias_entries.append(entry)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if isinstance(data, dict) and isinstance(data.get("data"), list):
|
||||||
|
for entry in data["data"]:
|
||||||
|
if (ctx := ctx_map.get(entry.get("id"))):
|
||||||
|
entry.setdefault("context_length", ctx)
|
||||||
|
# Rohe Doppel-IDs ausblenden, wenn ein Klarnamen-Alias sie schon zeigt → in der
|
||||||
|
# Liste erscheinen nur die freundlichen Rollen-Namen. Routing per roher ID bleibt.
|
||||||
|
raw = [e for e in data["data"] if e.get("id") not in aliased_raw]
|
||||||
|
data["data"] = alias_entries + raw
|
||||||
|
return JSONResponse(data, status_code=r.status_code)
|
||||||
|
|
||||||
|
|
||||||
|
async def _proxy(path: str, request: Request):
|
||||||
|
# Ungültige Payloads gehören dem Client, nicht dem Server: ohne diese Prüfung wirft
|
||||||
|
# request.json() bzw. body.get(...) durch und der Aufrufer bekommt einen 500er
|
||||||
|
# (gemessen 27.08.2026: Rohtext, leerer Body, JSON-Liste, JSON-String und null — 5/5 mal 500).
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": {"message": "Ungültiger Request-Body: JSON erwartet.",
|
||||||
|
"type": "invalid_request_error"}}, status_code=400)
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": {"message": "Ungültiger Request-Body: JSON-Objekt erwartet, "
|
||||||
|
f"'{type(body).__name__}' bekommen.",
|
||||||
|
"type": "invalid_request_error"}}, status_code=400)
|
||||||
|
_apply_no_think_marker(body) # Lucy-Voice-Schnellspur: Marker strippen + Thinking aus
|
||||||
|
requested = str(body.get("model") or "auto")
|
||||||
|
if requested.lower() in ("auto", "chat", "coding"):
|
||||||
|
lane = requested.lower()
|
||||||
|
alias, reason = choose_for_lane(lane, body)
|
||||||
|
body["model"] = alias
|
||||||
|
routed = {"x-mc-routed-to": alias, "x-mc-route-reason": reason, "x-mc-lane": lane}
|
||||||
|
else:
|
||||||
|
alias = requested
|
||||||
|
routed = {"x-mc-routed-to": requested}
|
||||||
|
|
||||||
|
pol = load_policy()
|
||||||
|
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):
|
||||||
|
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}"
|
||||||
|
if body.get("stream"):
|
||||||
|
req = client.build_request("POST", url, json=body, timeout=None)
|
||||||
|
try:
|
||||||
|
r = await client.send(req, stream=True)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
return _upstream_fehler(f"Engine nicht erreichbar: {exc.__class__.__name__}", headers=routed)
|
||||||
|
if r.status_code != 200:
|
||||||
|
await r.aread()
|
||||||
|
try:
|
||||||
|
resp_json = r.json()
|
||||||
|
except Exception:
|
||||||
|
resp_json = {"error": {"message": r.text, "type": "upstream_error"}}
|
||||||
|
return JSONResponse(resp_json, status_code=r.status_code, headers=routed)
|
||||||
|
|
||||||
|
async def gen():
|
||||||
|
try:
|
||||||
|
async for chunk in r.aiter_raw():
|
||||||
|
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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/chat/completions")
|
||||||
|
async def chat_completions(request: Request):
|
||||||
|
return await _proxy("/v1/chat/completions", request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/completions")
|
||||||
|
async def completions(request: Request):
|
||||||
|
return await _proxy("/v1/completions", request)
|
||||||
|
|||||||
+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 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 = 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:
|
def health() -> dict:
|
||||||
return {
|
e = einstellungen()
|
||||||
"status": "ok",
|
antwort: dict = {"status": "ok", "version": VERSION, "rolle": e.rolle, "instanz": e.instanz}
|
||||||
"version": VERSION,
|
if e.rolle == "box":
|
||||||
"engine_reachable": llamaswap.engine_reachable(),
|
from services import gateway, llamaswap
|
||||||
"gateway_reachable": gateway.gateway_reachable(),
|
|
||||||
# Echte Hirn-Bereitschaft: Engine kann erreichbar sein, das Agent-Hirn ('fast') aber tot
|
antwort.update(
|
||||||
# (Crash/OOM nach Engine-Update). Das wäre sonst ein silent fail (App-Fehler statt Status).
|
engine_reachable=llamaswap.engine_reachable(),
|
||||||
"brain": llamaswap.brain_status(),
|
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
|
(/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).
|
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
|
Der Backend-Server (`hermes dashboard`, :9119) wird über den MC2-Port (9001) same-origin
|
||||||
Login — gleiches LAN-Trust-Modell wie Konsole/Terminal) und wird über den ohnehin offenen
|
durchgereicht → keine eigene Firewall-Freigabe, kein CORS.
|
||||||
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`,
|
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
|
`/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
|
liegen ALLE seine Pfade unter `/hermes-ui/`. Dieser Proxy streift das Präfix ab und leitet an
|
||||||
`:9119/...` weiter.
|
`: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
|
import asyncio
|
||||||
@@ -22,11 +27,10 @@ import re
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import websockets
|
import websockets
|
||||||
|
from config import HERMES_BUILTIN_UI_UPSTREAM
|
||||||
from fastapi import APIRouter, Request, WebSocket
|
from fastapi import APIRouter, Request, WebSocket
|
||||||
from starlette.responses import RedirectResponse, Response
|
from starlette.responses import RedirectResponse, Response
|
||||||
|
|
||||||
from config import HERMES_BUILTIN_UI_UPSTREAM
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
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_RE = re.compile(rb'window\.__HERMES_BASE_PATH__\s*=\s*"[^"]*"')
|
||||||
_BASE_PATH_SET = b'window.__HERMES_BASE_PATH__="/' + _BASE.encode() + b'"'
|
_BASE_PATH_SET = b'window.__HERMES_BASE_PATH__="/' + _BASE.encode() + b'"'
|
||||||
_HEAD_INJECT = b"<head><script>" + _BASE_PATH_SET + b";</script>"
|
_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:
|
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
|
body = r.content
|
||||||
if "text/html" in r.headers.get("content-type", "").lower():
|
if "text/html" in r.headers.get("content-type", "").lower():
|
||||||
if _BASE_PATH_RE.search(body):
|
body = praefixiere_html(body)
|
||||||
body = _BASE_PATH_RE.sub(_BASE_PATH_SET, body)
|
resp = Response(content=body, status_code=r.status_code)
|
||||||
elif b"<head>" in body:
|
# multi_items statt dict: Die Anmeldung setzt Cookies, und mehrere Set-Cookie-Zeilen
|
||||||
body = body.replace(b"<head>", _HEAD_INJECT, 1)
|
# dürfen nicht zu einer zusammenfallen.
|
||||||
resp_headers = {k: v for k, v in r.headers.items() if k.lower() not in _DROP}
|
for k, v in r.headers.multi_items():
|
||||||
return Response(content=body, status_code=r.status_code, headers=resp_headers)
|
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}")
|
@router.get(f"/{_BASE}")
|
||||||
|
|||||||
@@ -0,0 +1,408 @@
|
|||||||
|
"""Schnittstellen des Homelab-Teils (Rolle homelab, Phase 3/4, 24.09.2026).
|
||||||
|
|
||||||
|
GET /api/homelab/ziele Proxmox-Host und Gäste im gemeinsamen Ziel-Modell
|
||||||
|
POST /api/homelab/ziele/{id}/update „Jetzt updaten“ (App bzw. Host-Pakete)
|
||||||
|
POST /api/homelab/ziele/{id}/os-update Pakete im Gast einspielen
|
||||||
|
POST /api/homelab/ziele/{id}/suchen Paketlisten im Gast erneuern
|
||||||
|
POST /api/homelab/ziele/{id}/docker Docker über Arcane (zuerst Probelauf)
|
||||||
|
POST /api/homelab/ziele/{id}/rueckweg-probe Rückweg testen: Snapshot, absichtlich rot, zurück, nachprüfen
|
||||||
|
GET /api/homelab/ziele/{id}/neuerungen?baustein=app|pakete Was ist neu? Release Notes + Lucys Kurzfassung
|
||||||
|
POST /api/homelab/ziele/pve/neustart Proxmox-Host neu starten (eigener Knopf)
|
||||||
|
GET /api/homelab/laeufe Die letzten Läufe (Updates, Snapshot-Aktionen) mit ihren Schritten
|
||||||
|
GET /api/homelab/snapshots Snapshots und Sicherungen je Gerät (Aktionen: siehe unten)
|
||||||
|
POST /api/homelab/snapshots/rueckwege-loeschen Alle Rückwege von Updates löschen (nur per Klick)
|
||||||
|
POST /api/homelab/sicherungen/probe Probe-Wiederherstellung jetzt (nur lesend, sonst einmal die Woche)
|
||||||
|
GET /api/homelab/zertifikate https-Zertifikate (NPMplus, Proxmox-Host, PBS) mit Stufe
|
||||||
|
GET /api/homelab/neu „Neu entdeckt“: Geräte und Docker-Stacks, die er noch nicht kennt
|
||||||
|
GET /api/homelab/docker Docker-Dienste unter Arcane je Stack: Zustand, Gesundheit, Update
|
||||||
|
POST /api/homelab/neu/{id} {"wahl": freigeben | beobachten | ausschliessen} — übernehmen
|
||||||
|
GET /api/homelab/hinweise Hinweise des Wächters dieser Instanz
|
||||||
|
GET /api/homelab/ausfuehrer Lebenszeichen des Ausführers
|
||||||
|
GET /api/homelab/ausfuehrer/auftrag ┐
|
||||||
|
POST /api/homelab/ausfuehrer/bericht ├ nur für den Ausführer (Kopfzeile X-MC2-Ausfuehrer)
|
||||||
|
POST /api/homelab/ausfuehrer/ergebnis/{id} ┘
|
||||||
|
|
||||||
|
GET /api/stream Live-Strom dieser Instanz (Anstöße, kein Messpunkt)
|
||||||
|
|
||||||
|
Dünn: die Logik liegt in services/homelab/.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from kern.einstellungen import einstellungen
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from services.homelab import inventar, kanal, updates
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/homelab", tags=["homelab"])
|
||||||
|
strom_router = APIRouter(prefix="/api", tags=["homelab"])
|
||||||
|
|
||||||
|
|
||||||
|
def _nur_ausfuehrer(x_mc2_ausfuehrer: str | None = Header(default=None)) -> None:
|
||||||
|
if not kanal.token_gueltig(x_mc2_ausfuehrer):
|
||||||
|
raise HTTPException(status_code=403, detail="Nur für den Ausführer auf dem Proxmox-Host.")
|
||||||
|
kanal.kontakt()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ziele")
|
||||||
|
def ziele() -> dict:
|
||||||
|
return inventar.ziele()
|
||||||
|
|
||||||
|
|
||||||
|
def _antwort(ergebnis: dict) -> dict:
|
||||||
|
if not ergebnis.get("ok"):
|
||||||
|
raise HTTPException(status_code=409, detail=ergebnis.get("detail") or "Das geht gerade nicht.")
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ziele/{ziel_id}/update")
|
||||||
|
def update(ziel_id: str) -> dict:
|
||||||
|
return _antwort(updates.starten(ziel_id, "pakete" if ziel_id == "pve" else "app"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ziele/{ziel_id}/os-update")
|
||||||
|
def os_update(ziel_id: str) -> dict:
|
||||||
|
return _antwort(updates.starten(ziel_id, "os"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ziele/{ziel_id}/docker")
|
||||||
|
def docker(ziel_id: str) -> dict:
|
||||||
|
"""Docker über Arcanes Updater — zuerst nur als Probelauf (MC_ARCANE_ECHT=1 macht es echt)."""
|
||||||
|
return _antwort(updates.starten(ziel_id, "docker"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ziele/{ziel_id}/rueckweg-probe")
|
||||||
|
def rueckweg_probe(ziel_id: str) -> dict:
|
||||||
|
"""Rückweg testen (seit 25.09.2026): Snapshot anlegen, absichtlich rot werten, zurückgehen, nachprüfen."""
|
||||||
|
return _antwort(updates.starten(ziel_id, "probe"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ziele/pve/neustart")
|
||||||
|
def host_neustart() -> dict:
|
||||||
|
return _antwort(updates.starten("pve", "neustart"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ziele/pve/kernel-aufraeumen")
|
||||||
|
def host_kernel_aufraeumen() -> dict:
|
||||||
|
"""Alte Kernel vom Proxmox-Host entfernen (seit 25.09.2026, nur per Knopf). Welche, entscheidet der Ausführer."""
|
||||||
|
return _antwort(updates.starten("pve", "kernel"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ziele/{ziel_id}/suchen")
|
||||||
|
def suchen(ziel_id: str) -> dict:
|
||||||
|
gast = inventar.gast(ziel_id)
|
||||||
|
if not gast or not gast.get("erlaubt"):
|
||||||
|
raise HTTPException(status_code=404, detail="Dieses Gerät steht nicht (freigegeben) im Bericht.")
|
||||||
|
return {"ok": True, "auftrag": kanal.anlegen("suchen", {"vmid": gast["vmid"]})}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/hinweise")
|
||||||
|
def hinweise() -> dict:
|
||||||
|
"""Was der Wächter dieser Instanz gerade sieht (Ausführer schweigt, Gast antwortet nicht …)."""
|
||||||
|
from services import waechter
|
||||||
|
return waechter.lese_stand()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/hinweise/{hinweis_id}/aktion/{aktion_id}")
|
||||||
|
def hinweis_aktion(hinweis_id: str, aktion_id: str) -> dict:
|
||||||
|
"""Knöpfe eines Hinweises dieser Instanz — auch „gesehen“, „stumm“ (24 h) und „laut“ (seit 25.09.2026)."""
|
||||||
|
from services import waechter
|
||||||
|
return _antwort(waechter.fuehre_aktion_aus(hinweis_id, aktion_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/laeufe")
|
||||||
|
def laeufe() -> 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, ziel: str | None = None) -> dict:
|
||||||
|
"""Was geschah, neueste zuerst; mit ziel nur ein Gerät (Zeitleiste je Gerät, seit 25.09.2026)."""
|
||||||
|
from services.homelab import protokoll
|
||||||
|
return {"eintraege": protokoll.eintraege(grenze, berichte, ziel or None)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/speicher")
|
||||||
|
def speicher_lage() -> dict:
|
||||||
|
"""Speicherpool, NAS und Sicherungen samt Empfehlungen, was Platz bringt (Karte im Homelab-Cockpit)."""
|
||||||
|
from services.homelab import speicher
|
||||||
|
return speicher.lage()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ziele/{ziel_id}/neuerungen")
|
||||||
|
def neuerungen(ziel_id: str, baustein: str = "app") -> dict:
|
||||||
|
"""Release Notes bzw. Änderungslisten vor dem Update, mit Lucys Kurzfassung (seit 25.09.2026). Kann beim ersten
|
||||||
|
Mal einige Sekunden dauern (GitHub, Ausführer, Modell); danach ist es gemerkt."""
|
||||||
|
from services.homelab import neuerungen as neu
|
||||||
|
return neu.fuer(ziel_id, baustein)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/neu")
|
||||||
|
def neu_entdeckt() -> dict:
|
||||||
|
"""Geräte, die der Orchestrator noch nicht kennt, mit Vorschlägen (seit 25.09.2026)."""
|
||||||
|
from services.homelab import entdecken
|
||||||
|
return {"geraete": entdecken.neue(), "docker": entdecken.neue_docker()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/docker")
|
||||||
|
def docker_dienste() -> dict:
|
||||||
|
"""Die Docker-Container unter Arcane, je Stack (seit 25.09.2026)."""
|
||||||
|
from services.homelab import docker
|
||||||
|
return docker.lage()
|
||||||
|
|
||||||
|
|
||||||
|
class Wahl(BaseModel):
|
||||||
|
wahl: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/neu/{ziel_id}")
|
||||||
|
def neu_uebernehmen(ziel_id: str, w: Wahl) -> dict:
|
||||||
|
"""Übernehmen: freigeben (Etikett watcher), beobachten (kein Etikett) oder ausschliessen (watcher-aus)."""
|
||||||
|
from services.homelab import entdecken
|
||||||
|
return _antwort(entdecken.uebernehmen(ziel_id, w.wahl))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/zertifikate")
|
||||||
|
def zertifikate_lage() -> dict:
|
||||||
|
"""Die https-Zertifikate mit Ablauf und Stufe (Zertifikats-Wache, seit 25.09.2026)."""
|
||||||
|
from services.homelab import zertifikate
|
||||||
|
return zertifikate.lage()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sicherungen/probe")
|
||||||
|
def sicherungen_probe() -> dict:
|
||||||
|
"""Knopf „Jetzt prüfen“: die jüngste Sicherung jedes Geräts probeweise öffnen (seit 25.09.2026)."""
|
||||||
|
from services.homelab import speicher
|
||||||
|
return _antwort(speicher.probe_starten())
|
||||||
|
|
||||||
|
|
||||||
|
# --- Snapshots und Sicherungen verwalten (Seite Homelab → Snapshots, seit 25.09.2026) ------------------------------
|
||||||
|
# GET /api/homelab/snapshots je Gerät Snapshots, Sicherungen, ob gerade etwas läuft
|
||||||
|
# POST /api/homelab/snapshots/{id}/anlegen Snapshot jetzt anlegen (freigegeben, Snapshot möglich)
|
||||||
|
# POST /api/homelab/snapshots/{id}/{name}/loeschen nur Snapshots des Orchestrators (mc2-…)
|
||||||
|
# POST /api/homelab/snapshots/{id}/{name}/zuruecksetzen ebenso; danach Prüfung wie nach einem Update
|
||||||
|
# POST /api/homelab/sicherungen/{id}/{datei}/loeschen nur Sicherungen des Orchestrators (Bericht)
|
||||||
|
# POST /api/homelab/snapshots/rueckwege-loeschen alle Rückwege von Updates, nacheinander (nur per Klick)
|
||||||
|
# Jede Aktion ist ein Lauf wie „Jetzt updaten“; was nicht geht, beantworten sie mit 409 wie die Update-Knöpfe.
|
||||||
|
|
||||||
|
@router.get("/snapshots")
|
||||||
|
def snapshots_liste() -> dict:
|
||||||
|
from services.homelab import snapshots
|
||||||
|
return snapshots.liste()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/snapshots/rueckwege-loeschen")
|
||||||
|
def rueckwege_loeschen() -> dict:
|
||||||
|
"""Alle Rückwege von Updates löschen (Snapshots mit Herkunft „update“, Sicherungen des Orchestrators) — nur per
|
||||||
|
Klick des Users, nacheinander, mit Meldung am Ende."""
|
||||||
|
from services.homelab import snapshots
|
||||||
|
return _antwort(snapshots.rueckwege_loeschen())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/snapshots/{ziel_id}/anlegen")
|
||||||
|
def snapshot_anlegen(ziel_id: str) -> dict:
|
||||||
|
from services.homelab import snapshots
|
||||||
|
return _antwort(snapshots.anlegen(ziel_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/snapshots/{ziel_id}/{name}/loeschen")
|
||||||
|
def snapshot_loeschen(ziel_id: str, name: str) -> dict:
|
||||||
|
from services.homelab import snapshots
|
||||||
|
return _antwort(snapshots.loeschen(ziel_id, name))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/snapshots/{ziel_id}/{name}/zuruecksetzen")
|
||||||
|
def snapshot_zuruecksetzen(ziel_id: str, name: str) -> dict:
|
||||||
|
from services.homelab import snapshots
|
||||||
|
return _antwort(snapshots.zuruecksetzen(ziel_id, name))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sicherungen/{ziel_id}/{datei}/loeschen")
|
||||||
|
def sicherung_loeschen(ziel_id: str, datei: str) -> dict:
|
||||||
|
from services.homelab import snapshots
|
||||||
|
return _antwort(snapshots.sicherung_loeschen(ziel_id, datei))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/einstellungen")
|
||||||
|
def einstellungen_stand() -> dict:
|
||||||
|
from services.homelab import einstellungen as homelab_einstellungen
|
||||||
|
return homelab_einstellungen.stand()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/einstellungen/arcane-schluessel")
|
||||||
|
async def arcane_schluessel_setzen(request: Request) -> dict:
|
||||||
|
"""Den Body liest der Dienst selbst: Ein Pydantic-Modell würde bei falscher Form mit 422 antworten und die
|
||||||
|
Eingabe, also den Schlüssel, dabei wiederholen."""
|
||||||
|
from services.homelab import einstellungen as homelab_einstellungen
|
||||||
|
wert = homelab_einstellungen.schluessel_aus_body(await request.body())
|
||||||
|
return await asyncio.to_thread(homelab_einstellungen.arcane_schluessel_setzen, wert)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/einstellungen/arcane-schluessel")
|
||||||
|
def arcane_schluessel_loeschen() -> dict:
|
||||||
|
from services.homelab import einstellungen as homelab_einstellungen
|
||||||
|
return homelab_einstellungen.arcane_schluessel_loeschen()
|
||||||
|
|
||||||
|
|
||||||
|
class ArcaneEcht(BaseModel):
|
||||||
|
an: bool
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/einstellungen/arcane-echt")
|
||||||
|
def arcane_echt(schalter: ArcaneEcht) -> dict:
|
||||||
|
from services.homelab import einstellungen as homelab_einstellungen
|
||||||
|
return homelab_einstellungen.arcane_echt_setzen(schalter.an)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/einstellungen/adguard-zugang")
|
||||||
|
async def adguard_zugang_setzen(request: Request) -> dict:
|
||||||
|
"""Wie beim Arcane-Schlüssel liest der Dienst den Body selbst: Eine 422 würde das Passwort wiederholen."""
|
||||||
|
from services.homelab import einstellungen as homelab_einstellungen
|
||||||
|
werte = homelab_einstellungen.zugang_aus_body(await request.body())
|
||||||
|
return await asyncio.to_thread(homelab_einstellungen.adguard_zugang_setzen, werte)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/einstellungen/adguard-zugang")
|
||||||
|
def adguard_zugang_loeschen() -> dict:
|
||||||
|
from services.homelab import einstellungen as homelab_einstellungen
|
||||||
|
return homelab_einstellungen.adguard_zugang_loeschen()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ausfuehrer")
|
||||||
|
def ausfuehrer() -> dict:
|
||||||
|
zuletzt = kanal.zuletzt()
|
||||||
|
return {"zuletzt": zuletzt, "verbunden": bool(zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S),
|
||||||
|
"auftraege": kanal.liste()[:20]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ausfuehrer/auftrag", dependencies=[Depends(_nur_ausfuehrer)])
|
||||||
|
def auftrag_abholen() -> dict:
|
||||||
|
return kanal.naechster() or {}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ausfuehrer/bericht", dependencies=[Depends(_nur_ausfuehrer)])
|
||||||
|
def bericht(daten: dict) -> dict:
|
||||||
|
from services.homelab import entdecken, katalog, speicher
|
||||||
|
kanal.bericht_speichern(daten)
|
||||||
|
speicher.verlauf_merken(daten) # Verlauf für „voll in etwa N Tagen“ (seit 25.09.2026)
|
||||||
|
try: # „Neu entdeckt“ und der Katalog (seit 25.09.2026) halten den Bericht nie auf
|
||||||
|
entdecken.bericht_gesehen(daten)
|
||||||
|
katalog.ergaenzen(daten)
|
||||||
|
threading.Thread(target=entdecken.docker_abgleichen, name="docker-abgleich", daemon=True).start()
|
||||||
|
except Exception:
|
||||||
|
log.warning("homelab: Abgleich mit dem Register gescheitert", exc_info=True)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
class Ergebnis(BaseModel):
|
||||||
|
code: int
|
||||||
|
text: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ausfuehrer/ergebnis/{auftrag_id}", dependencies=[Depends(_nur_ausfuehrer)])
|
||||||
|
def ergebnis(auftrag_id: str, e: Ergebnis) -> dict:
|
||||||
|
return {"ok": kanal.ergebnis(auftrag_id, e.code, e.text)}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Live-Strom der Homelab-Instanz ---------------------------------------------------------------
|
||||||
|
# Die Oberfläche hört auf /api/stream der Instanz, die sie ausliefert. Im Homelab gibt es keine
|
||||||
|
# Messpunkte der KI-Box; der Strom stößt nur neu laden an, wenn sich Bericht, Läufe oder Hinweise ändern,
|
||||||
|
# und meldet sich alle 10 s mit einem leeren Anstoß (sonst hielte die Oberfläche die Leitung für tot).
|
||||||
|
_DATEIEN = (("homelab", "pve-bericht.json"), ("homelab-laeufe", "homelab-laeufe.json"),
|
||||||
|
("homelab-hinweise", "mc2-waechter.json"))
|
||||||
|
|
||||||
|
|
||||||
|
def _abdruecke() -> dict[str, float]:
|
||||||
|
ordner = einstellungen().daten_dir
|
||||||
|
abdruck = {}
|
||||||
|
for schluessel, name in _DATEIEN:
|
||||||
|
try:
|
||||||
|
abdruck[schluessel] = (ordner / name).stat().st_mtime
|
||||||
|
except OSError:
|
||||||
|
abdruck[schluessel] = 0.0
|
||||||
|
return abdruck
|
||||||
|
|
||||||
|
|
||||||
|
async def _strom(request: Request, takt_s: float = 2.0, lebenszeichen_takte: int = 5):
|
||||||
|
alt = _abdruecke()
|
||||||
|
yield ": verbunden\n\n"
|
||||||
|
takte = 0
|
||||||
|
while not await request.is_disconnected():
|
||||||
|
await asyncio.sleep(takt_s)
|
||||||
|
takte += 1
|
||||||
|
neu = _abdruecke()
|
||||||
|
schluessel = [k for k, v in neu.items() if v != alt.get(k)]
|
||||||
|
alt = neu
|
||||||
|
if schluessel or takte % lebenszeichen_takte == 0:
|
||||||
|
yield f"event: invalidate\ndata: {json.dumps({'keys': schluessel})}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
@strom_router.get("/stream")
|
||||||
|
async def stream(request: Request) -> StreamingResponse:
|
||||||
|
return StreamingResponse(_strom(request), media_type="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
||||||
|
|
||||||
|
|
||||||
|
# --- Messwerte mit Verlauf (Monitoring, seit 24.09.2026) ------------------------------------------------------
|
||||||
|
# POST /api/homelab/ausfuehrer/messwerte jede Minute vom Ausführer (Kopfzeile X-MC2-Ausfuehrer)
|
||||||
|
# GET /api/homelab/messwerte?zeitraum=1h|24h|7d Proxmox-Host und Gäste: Reihen und letzter Punkt
|
||||||
|
# Die Logik liegt in services/homelab/messwerte.py, die Ablage in kern/messreihen.py.
|
||||||
|
|
||||||
|
@router.post("/ausfuehrer/messwerte", dependencies=[Depends(_nur_ausfuehrer)])
|
||||||
|
def messwerte_annehmen(daten: dict) -> dict:
|
||||||
|
from services.homelab import messwerte as homelab_messwerte
|
||||||
|
return {"ok": True, "geraete": homelab_messwerte.annehmen(daten)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/messwerte")
|
||||||
|
def messwerte_lesen(zeitraum: str = "1h") -> dict:
|
||||||
|
from services.homelab import messwerte as homelab_messwerte
|
||||||
|
if zeitraum not in homelab_messwerte.ZEITRAEUME:
|
||||||
|
raise HTTPException(status_code=400, detail="Den Zeitraum gibt es nicht; möglich sind 1h, 24h und 7d.")
|
||||||
|
return homelab_messwerte.abfrage(zeitraum)
|
||||||
@@ -1,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 pydantic import BaseModel
|
||||||
|
from services import geheimnisse, maintenance
|
||||||
from services import maintenance
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
class SudoReq(BaseModel):
|
|
||||||
sudo_password: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class RestartReq(BaseModel):
|
class RestartReq(BaseModel):
|
||||||
service: str
|
service: str
|
||||||
sudo_password: str | None = None
|
|
||||||
|
|
||||||
|
class GeheimnisReq(BaseModel):
|
||||||
|
schluessel: str
|
||||||
|
wert: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/maintenance/updates")
|
@router.get("/maintenance/updates")
|
||||||
@@ -29,57 +37,67 @@ def update_details(kind: str) -> dict:
|
|||||||
return maintenance.update_details(kind)
|
return maintenance.update_details(kind)
|
||||||
|
|
||||||
@router.post("/maintenance/check-updates")
|
@router.post("/maintenance/check-updates")
|
||||||
def check_updates(body: SudoReq) -> dict:
|
def check_updates() -> dict:
|
||||||
res = maintenance.check_updates_job(body.sudo_password)
|
res = maintenance.check_updates_job()
|
||||||
if isinstance(res, dict) and not res.get("ok", True):
|
if isinstance(res, dict) and not res.get("ok", True):
|
||||||
return res
|
return res
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
@router.post("/maintenance/os-update")
|
@router.post("/maintenance/os-update")
|
||||||
def os_update(body: SudoReq) -> dict:
|
def os_update() -> dict:
|
||||||
res = maintenance.os_update_job(body.sudo_password)
|
res = maintenance.os_update_job()
|
||||||
if isinstance(res, dict) and not res.get("ok", True):
|
if isinstance(res, dict) and not res.get("ok", True):
|
||||||
return res
|
return res
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
@router.post("/maintenance/engine-update")
|
@router.post("/maintenance/engine-update")
|
||||||
def engine_update(body: SudoReq) -> dict:
|
def engine_update() -> dict:
|
||||||
res = maintenance.engine_update_job(body.sudo_password)
|
res = maintenance.engine_update_job()
|
||||||
if not res:
|
if not res:
|
||||||
raise HTTPException(400, "Kein Engine-Update-Befehl gesetzt (MC_ENGINE_UPDATE_CMD).")
|
raise HTTPException(400, "Kein Engine-Update-Befehl gesetzt (MC_ENGINE_UPDATE_CMD).")
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
@router.post("/maintenance/swap-update")
|
@router.post("/maintenance/swap-update")
|
||||||
def swap_update(body: SudoReq) -> dict:
|
def swap_update() -> dict:
|
||||||
res = maintenance.swap_update_job(body.sudo_password)
|
res = maintenance.swap_update_job()
|
||||||
if not res:
|
if not res:
|
||||||
raise HTTPException(400, "Kein Router-Update-Befehl gesetzt (MC_SWAP_UPDATE_CMD).")
|
raise HTTPException(400, "Kein Router-Update-Befehl gesetzt (MC_SWAP_UPDATE_CMD).")
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
@router.post("/maintenance/hermes-update")
|
@router.post("/maintenance/hermes-update")
|
||||||
def hermes_update() -> dict:
|
def hermes_update(verlauf: bool = True) -> dict:
|
||||||
return maintenance.hermes_update_job()
|
"""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")
|
@router.post("/maintenance/update-all")
|
||||||
def update_all(body: SudoReq) -> dict:
|
def update_all() -> dict:
|
||||||
return maintenance.update_all_job(body.sudo_password)
|
return maintenance.update_all_job()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/maintenance/reboot")
|
|
||||||
def reboot(body: SudoReq) -> dict:
|
|
||||||
return maintenance.reboot(body.sudo_password)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/maintenance/restart")
|
@router.post("/maintenance/restart")
|
||||||
def restart(body: RestartReq) -> dict:
|
def restart(body: RestartReq) -> dict:
|
||||||
return maintenance.restart_service(body.service, body.sudo_password)
|
return maintenance.restart_service(body.service)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/maintenance/logs")
|
@router.get("/maintenance/logs")
|
||||||
def logs(service: str, lines: int = 200, x_sudo_password: str | None = Header(None)) -> dict:
|
def logs(service: str, lines: int = 200) -> dict:
|
||||||
return maintenance.logs(service, lines, x_sudo_password)
|
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
|
import psutil
|
||||||
|
from config import HF_DOWNLOAD_ENV, MODELS_DIR
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from services import budget, discover, geheimnisse, hf, jobengine, llamaswap
|
||||||
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
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
@@ -30,27 +30,6 @@ def discover_models(force: bool = False) -> dict:
|
|||||||
return {**data, "sys_ram_gb": round(ram, 1)}
|
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):
|
class RegisterReq(BaseModel):
|
||||||
model_path: str
|
model_path: str
|
||||||
role: str | None = None
|
role: str | None = None
|
||||||
@@ -62,6 +41,10 @@ class RegisterReq(BaseModel):
|
|||||||
|
|
||||||
@router.post("/models/register")
|
@router.post("/models/register")
|
||||||
def register(req: RegisterReq) -> dict:
|
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:
|
try:
|
||||||
model_id = llamaswap.register_model(
|
model_id = llamaswap.register_model(
|
||||||
req.model_path, role=req.role, ctx=req.ctx, ttl=req.ttl,
|
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}
|
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):
|
class InstallReq(BaseModel):
|
||||||
repo: str
|
repo: str
|
||||||
role: str | None = None
|
role: str | None = None
|
||||||
quant: str = "Q4_K_M"
|
quant: str = "Q4_K_M"
|
||||||
ctx: int | None = None
|
ctx: int | None = None
|
||||||
jinja: bool = False
|
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")
|
@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).
|
# 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"]
|
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:
|
try:
|
||||||
model_id = llamaswap.register_model(
|
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:
|
except PermissionError as exc:
|
||||||
raise HTTPException(500, str(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.
|
# Download-Job: alle GGUF-Teile (+ mmproj) per --include holen.
|
||||||
args = [hf.hf_bin(), "download", repo]
|
args = [hf.hf_bin(), "download", repo]
|
||||||
for f in info["files"]:
|
args.extend(info["files"])
|
||||||
args.append(f)
|
|
||||||
if info["mmproj"]:
|
if info["mmproj"]:
|
||||||
args.append(info["mmproj"])
|
args.append(info["mmproj"])
|
||||||
args += ["--local-dir", str(target)]
|
args += ["--local-dir", str(target)]
|
||||||
env = dict(HF_DOWNLOAD_ENV)
|
env = dict(HF_DOWNLOAD_ENV)
|
||||||
if req.hf_token:
|
if token := geheimnisse.hf_token():
|
||||||
env["HF_TOKEN"] = req.hf_token
|
env["HF_TOKEN"] = token
|
||||||
job_id = jobengine.start_job(args, f"download {req.repo}", env=env)
|
# 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"])
|
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,
|
return {"ok": True, "job_id": job_id, "model_id": model_id, "model_path": model_path,
|
||||||
"total_bytes": info["total_bytes"], "files": len(info["files"])}
|
"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)}
|
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):
|
class RoleReq(BaseModel):
|
||||||
role: str | None = None
|
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")
|
@router.post("/models/{model_id}/role")
|
||||||
def set_model_role(model_id: str, body: RoleReq) -> dict:
|
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 +
|
# 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.
|
# 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
|
from services.agent import set_agent_brain
|
||||||
res = set_agent_brain(model_id)
|
res = set_agent_brain(model_id)
|
||||||
if not res.get("ok"):
|
if not res.get("ok"):
|
||||||
@@ -174,54 +189,6 @@ def set_model_role(model_id: str, body: RoleReq) -> dict:
|
|||||||
return {"ok": True}
|
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")
|
@router.post("/models/unload")
|
||||||
def unload_all_models() -> dict:
|
def unload_all_models() -> dict:
|
||||||
import httpx
|
import httpx
|
||||||
@@ -248,21 +215,8 @@ def unload_model(model_id: str) -> dict:
|
|||||||
|
|
||||||
@router.post("/models/{model_id}/load")
|
@router.post("/models/{model_id}/load")
|
||||||
def load_model(model_id: str) -> dict:
|
def load_model(model_id: str) -> dict:
|
||||||
import httpx
|
"""Lädt ein Modell. Kommt es nicht zustande, steht ok: false mit dem Grund in `detail` (services/llamaswap)."""
|
||||||
from config import LLAMA_SWAP_URL
|
return llamaswap.lade_modell(model_id)
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/models/{model_id}")
|
@router.delete("/models/{model_id}")
|
||||||
@@ -270,24 +224,3 @@ def delete(model_id: str) -> dict:
|
|||||||
if not llamaswap.delete_model(model_id):
|
if not llamaswap.delete_model(model_id):
|
||||||
raise HTTPException(404, "Modell nicht gefunden")
|
raise HTTPException(404, "Modell nicht gefunden")
|
||||||
return {"ok": True}
|
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 fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from services import reminders
|
from services import reminders
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
@@ -16,7 +15,20 @@ class ReminderIn(BaseModel):
|
|||||||
repeat: str = "" # '' einmalig | daily | weekdays | weekly
|
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:
|
def list_reminders() -> dict:
|
||||||
return {"items": reminders.list_all()}
|
return {"items": reminders.list_all()}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
"""Routing-Endpoints: Lane-Summary (chat/coding) + UI-editierbare Policy (hot-reload)."""
|
"""Routing-Übersicht (Lanes, Gateway erreichbar). Der Policy-Editor ist seit dem Box-Wart-Umbau
|
||||||
|
aus der Oberfläche raus; die Policy selbst liest der Gateway weiter aus mc2-routing.json."""
|
||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
from services import gateway
|
from services import gateway
|
||||||
from services.routing_policy import policy_meta, save_policy
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
@@ -12,32 +10,3 @@ router = APIRouter(prefix="/api")
|
|||||||
@router.get("/routing")
|
@router.get("/routing")
|
||||||
def routing() -> dict:
|
def routing() -> dict:
|
||||||
return {**gateway.routing_summary(), "gateway_reachable": gateway.gateway_reachable()}
|
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).
|
Wartung läuft als systemd-USER-Dienst → KEIN sudo/Passwort (Nordstern).
|
||||||
Lokal (Windows) schlagen die Shell-Befehle harmlos fehl und werden als Fehler
|
Lokal (Windows) schlagen die Shell-Befehle harmlos fehl und werden als Fehler
|
||||||
@@ -6,20 +6,12 @@ zurückgegeben statt zu crashen.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from services import ablaeufe, dienste, maintenance
|
||||||
import httpx
|
|
||||||
|
|
||||||
from config import GATEWAY_URL, HERMES_API_URL, LLAMA_SWAP_URL, MEM0_SERVICE_URL, VOICE_SERVICE_URL
|
|
||||||
from services import backup as backup_svc
|
from services import backup as backup_svc
|
||||||
from services import maintenance
|
from services.llamaswap import list_models
|
||||||
from services.agent import agent_status
|
|
||||||
from services.gateway import gateway_reachable
|
|
||||||
from services.llamaswap import engine_reachable, list_models
|
|
||||||
from services.pricing import compute_savings
|
from services.pricing import compute_savings
|
||||||
from services.system import system_status
|
from services.system import system_status
|
||||||
from services.token_stats import get_stats
|
from services.token_stats import get_stats
|
||||||
@@ -28,49 +20,23 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
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")
|
@router.get("/system/status")
|
||||||
def status() -> dict:
|
def status() -> dict:
|
||||||
return system_status()
|
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")
|
@router.get("/system/services")
|
||||||
def services() -> dict:
|
def services() -> dict:
|
||||||
"""Aggregierte Erreichbarkeit aller Stack-Dienste (für die Health-Anzeige)."""
|
"""Alle Dienste der Box mit Erreichbarkeit, Zustand und Kennzahlen (Speicher, CPU, Laufzeit,
|
||||||
a = agent_status()
|
Neustarts) — die EINE Quelle für die Seite „Dienste und Protokolle“ und die MCP-Werkzeuge.
|
||||||
gw_url = f"{GATEWAY_URL}/v1"
|
Logik in services/dienste.py; `scope`: user|system (Restart-Weg), `unit`: echter systemd-Name."""
|
||||||
return {
|
return dienste.liste()
|
||||||
"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()},
|
@router.get("/system/ablaeufe")
|
||||||
{"name": "Hermes-Gateway", "unit": "hermes-gateway", "url": HERMES_API_URL, "ok": a["gateway_reachable"]},
|
def ablaeufe_liste() -> dict:
|
||||||
{"name": "Hermes-GUI (Desktop-Gateway)", "unit": "hermes-builtin-ui", "url": a["hermes_ui_url"], "ok": a["hermes_ui_reachable"]},
|
"""Die geplanten Abläufe der Box mit letztem und nächstem Lauf — der Totmannschalter (services/ablaeufe.py)."""
|
||||||
{"name": "Mem0 (Gedächtnis)", "unit": "mem0-service", "url": MEM0_SERVICE_URL, "ok": _mem0_reachable()},
|
return {"ablaeufe": ablaeufe.ablaeufe()}
|
||||||
{"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"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/system/backup")
|
@router.post("/system/backup")
|
||||||
@@ -83,15 +49,6 @@ def backups() -> dict:
|
|||||||
return {"backups": backup_svc.list_backups()}
|
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):
|
class RestartReq(BaseModel):
|
||||||
service: str
|
service: str
|
||||||
|
|
||||||
@@ -106,15 +63,6 @@ def restart(req: RestartReq) -> dict:
|
|||||||
return maintenance.restart_service(req.service)
|
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")
|
@router.get("/system/token-stats")
|
||||||
def token_stats() -> dict:
|
def token_stats() -> dict:
|
||||||
"""Token-Verbrauch + Cloud-Ersparnis. Logik im pricing-Service (SSoT)."""
|
"""Token-Verbrauch + Cloud-Ersparnis. Logik im pricing-Service (SSoT)."""
|
||||||
|
|||||||
+289
-357
@@ -1,357 +1,289 @@
|
|||||||
"""
|
"""
|
||||||
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
|
Dünner Layer: STT und die Turn-Erkennung gehen an den Voice-Sidecar (:8650, schläft seit
|
||||||
Hermes-`api_server` (:8642, OpenAI-kompatibel) — denselben vollen Agenten mit Tools +
|
24.09.2026 bis zur Android-App), der Chat an den Hermes-`api_server` (:8642, OpenAI-kompatibel) —
|
||||||
geteiltem Mem0 wie CLI/Telegram. Mit stabilem `X-Hermes-Session-Id` hält die Plattform den
|
derselbe volle Agent mit Werkzeugen und Gedächtnis wie Telegram. Mit stabilem
|
||||||
Transcript server-seitig, daher schickt der Client je Turn nur die neue User-Nachricht.
|
`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 logging
|
"""
|
||||||
import os
|
|
||||||
import time
|
import json
|
||||||
|
import logging
|
||||||
import httpx
|
import os
|
||||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
|
||||||
from fastapi.responses import Response, StreamingResponse
|
# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv).
|
||||||
from pydantic import BaseModel
|
import sys as _sys
|
||||||
|
|
||||||
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL
|
import httpx
|
||||||
from services import announce
|
from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, LUCY_STIMME_URL, VOICE_SERVICE_URL
|
||||||
from services.voice_metrics import ( # Per-Stage-Latenz + Per-Turn-Trace (intern)
|
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
||||||
Timer,
|
from fastapi.responses import Response, StreamingResponse
|
||||||
TurnTrace,
|
from pydantic import BaseModel
|
||||||
get_metrics,
|
from services import announce
|
||||||
get_trace,
|
|
||||||
park,
|
_GUARD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "mcp")
|
||||||
record_stage,
|
if _GUARD_DIR not in _sys.path:
|
||||||
)
|
_sys.path.insert(0, _GUARD_DIR)
|
||||||
|
try:
|
||||||
# Injection-Schutz (Stufe 0): guard.py liegt im mcp/-Verzeichnis. Per Pfad laden (eigene MC2-Venv).
|
from guard import wrap_untrusted
|
||||||
import sys as _sys
|
except Exception: # den Voice-Pfad nie wegen des Filters lahmlegen
|
||||||
_GUARD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "mcp")
|
def wrap_untrusted(text: str, label: str = "") -> str:
|
||||||
if _GUARD_DIR not in _sys.path:
|
return text
|
||||||
_sys.path.insert(0, _GUARD_DIR)
|
|
||||||
try:
|
log = logging.getLogger(__name__)
|
||||||
from guard import wrap_untrusted
|
router = APIRouter(prefix="/api")
|
||||||
except Exception: # den Voice-Pfad nie wegen des Filters lahmlegen
|
|
||||||
def wrap_untrusted(text: str, label: str = "") -> str:
|
# Bildschirm-Sicht: das Bild-Modell beschreibt die Screenshots; die Beschreibung geht als TEXT an
|
||||||
return text
|
# Hermes -> Lucy behält ihr volles Hirn und Gedächtnis. Per Env umstellbar.
|
||||||
|
VISION_MODEL = os.environ.get("MC_VISION_MODEL", "vision")
|
||||||
log = logging.getLogger(__name__)
|
# Knappe Beschreibung = schnellere Generierung UND weniger Kontext für Hermes.
|
||||||
router = APIRouter(prefix="/api")
|
VISION_MAX_TOKENS = int(os.environ.get("MC_VISION_MAX_TOKENS", "280"))
|
||||||
|
_STT_TIMEOUT = httpx.Timeout(120.0, connect=5.0)
|
||||||
# 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.
|
def _sse_fehler(text: str) -> bytes:
|
||||||
VISION_MODEL = os.environ.get("MC_VISION_MODEL", "vision")
|
"""SSE-Fehlerzeile als gültiges JSON (Anführungszeichen im Text brachen früher den Lucy-Client)."""
|
||||||
# Knappe Beschreibung = schnellere VL-Generierung UND weniger Hermes-Kontext-Bloat (B2).
|
return f"data: {json.dumps({'error': text}, ensure_ascii=False)}\n\n".encode()
|
||||||
VISION_MAX_TOKENS = int(os.environ.get("MC_VISION_MAX_TOKENS", "280"))
|
|
||||||
|
|
||||||
|
async def _describe_images(image_urls: list[str], hint: str) -> str:
|
||||||
async def _describe_images(image_urls: list[str], hint: str) -> str:
|
"""Lässt das Bild-Modell die Screenshots (1 je Monitor) knapp beschreiben (Deutsch).
|
||||||
"""Lässt das Vision-Modell die Screenshots (1 je Monitor) knapp beschreiben (Deutsch).
|
Mehrere Bilder gehen in EINER Nachricht ans Modell. Leerer String bei Fehler."""
|
||||||
Mehrere Bilder gehen in EINER Nachricht ans VL-Modell. Leerer String bei Fehler."""
|
multi = len(image_urls) > 1
|
||||||
multi = len(image_urls) > 1
|
intro = (f"Hier sind {len(image_urls)} Screenshots (je ein Monitor). Beschreibe auf Deutsch in höchstens "
|
||||||
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). "
|
||||||
"5 kurzen Sätzen das Wesentliche (pro Monitor: App/Fenster, wichtige Inhalte, sichtbarer Text/Code). "
|
"Keine Einleitung, keine Wiederholung der Frage. "
|
||||||
"Keine Einleitung, keine Wiederholung der Frage. "
|
if multi else
|
||||||
if multi else
|
"Beschreibe auf Deutsch in höchstens 5 kurzen Sätzen das Wesentliche auf diesem Screenshot "
|
||||||
"Beschreibe auf Deutsch in höchstens 5 kurzen Sätzen das Wesentliche auf diesem Screenshot "
|
"(App/Fenster, wichtige Inhalte, sichtbarer Text/Code). Keine Einleitung. ")
|
||||||
"(App/Fenster, wichtige Inhalte, sichtbarer Text/Code). Keine Einleitung. ")
|
content: list = [{"type": "text", "text": intro + "Frage des Nutzers dazu: " + hint}]
|
||||||
content: list = [{"type": "text", "text": intro + "Frage des Nutzers dazu: " + hint}]
|
for u in image_urls:
|
||||||
for u in image_urls:
|
content.append({"type": "image_url", "image_url": {"url": u}})
|
||||||
content.append({"type": "image_url", "image_url": {"url": u}})
|
try:
|
||||||
try:
|
# 45 s: Wenn das Bild-Modell so lange braucht, ist etwas kaputt, und Lucy soll lieber ohne
|
||||||
# 45 s statt 120 s: Qwen3-VL braucht warm ~5 s; wenn es 45 s nicht schafft, ist etwas
|
# Bildschirm-Kontext antworten als ewig hängen.
|
||||||
# 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:
|
||||||
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={
|
||||||
r = await client.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json={
|
"model": VISION_MODEL, "max_tokens": VISION_MAX_TOKENS, "stream": False,
|
||||||
"model": VISION_MODEL, "max_tokens": VISION_MAX_TOKENS, "stream": False,
|
"messages": [{"role": "user", "content": content}],
|
||||||
"messages": [{"role": "user", "content": content}],
|
})
|
||||||
})
|
r.raise_for_status()
|
||||||
r.raise_for_status()
|
return (r.json().get("choices") or [{}])[0].get("message", {}).get("content", "").strip()
|
||||||
return (r.json().get("choices") or [{}])[0].get("message", {}).get("content", "").strip()
|
except Exception as exc:
|
||||||
except Exception as exc:
|
log.warning("Vision-Beschreibung fehlgeschlagen: %s", exc)
|
||||||
log.warning("Vision-Beschreibung fehlgeschlagen: %s", exc)
|
return ""
|
||||||
return ""
|
|
||||||
|
|
||||||
_TIMEOUT = httpx.Timeout(120.0, connect=5.0) # Chatterbox-TTS auf CPU darf dauern
|
class ChatIn(BaseModel):
|
||||||
|
text: str # die neue User-Äußerung (STT-Ergebnis)
|
||||||
|
session_id: str # stabiler Gesprächsfaden → server-seitiger Verlauf
|
||||||
class TTSIn(BaseModel):
|
session_key: str = "" # optional an Hermes durchgereicht (X-Hermes-Session-Key)
|
||||||
text: str
|
system: str = "" # optionaler ephemerer System-Prompt (z.B. „antworte knapp/gesprochen")
|
||||||
engine: str = "piper"
|
model: str = ""
|
||||||
voice: str = ""
|
images: list[str] = [] # optionale Bildschirm-Sicht: ein data:-URL je Monitor (Lucys „Augen")
|
||||||
language: str = ""
|
|
||||||
ref_path: str = ""
|
|
||||||
|
class AnnounceIn(BaseModel):
|
||||||
|
text: str # die Meldung (wird von Lucy gesprochen)
|
||||||
class ChatIn(BaseModel):
|
subject: str = "" # kurze Betreffzeile (z.B. "[Update]")
|
||||||
text: str # die neue User-Äußerung (STT-Ergebnis)
|
source: str = "" # Absender (sentry/notify/cron …) — nur fürs Log/Panel
|
||||||
session_id: str # stabiler Voice-Faden → server-seitiger Transcript
|
priority: str = "normal" # 'silent' = nur im Verlauf zeigen, nicht sprechen
|
||||||
session_key: str = "" # optional: Langzeit-Memory-Scope
|
|
||||||
system: str = "" # optionaler ephemerer System-Prompt (z.B. „antworte knapp/gesprochen")
|
|
||||||
model: str = ""
|
class AlarmIn(BaseModel):
|
||||||
images: list[str] = [] # optionale Bildschirm-Sicht: ein data:-URL je Monitor (Lucys „Augen")
|
text: str # die Alarm-Meldung
|
||||||
|
subject: str = "[Alarm]" # Betreff (Telegram-Präfix)
|
||||||
|
source: str = "alarm" # Absender fürs Log/Panel (z.B. "lucy-watchdog")
|
||||||
class AnnounceIn(BaseModel):
|
|
||||||
text: str # die Meldung (wird von Lucy gesprochen)
|
|
||||||
subject: str = "" # kurze Betreffzeile (z.B. "[Update]")
|
@router.post("/alarm")
|
||||||
source: str = "" # Absender (sentry/notify/cron …) — nur fürs Log/Panel
|
def alarm(body: AlarmIn) -> dict:
|
||||||
priority: str = "normal" # 'silent' = nur im Verlauf zeigen, nicht sprechen
|
"""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. Der Betreff „[Alarm]“ geht
|
||||||
class AlarmIn(BaseModel):
|
auch nachts sofort raus (notify.sh)."""
|
||||||
text: str # die Alarm-Meldung
|
text = (body.text or "").strip()
|
||||||
subject: str = "[Alarm]" # Betreff (Telegram-Präfix)
|
if not text:
|
||||||
source: str = "alarm" # Absender fürs Log/Panel (z.B. "lucy-watchdog")
|
raise HTTPException(400, "Leere Meldung.")
|
||||||
|
subject = (body.subject or "[Alarm]").strip()
|
||||||
|
try:
|
||||||
@router.post("/alarm")
|
item = announce.add(text, subject, body.source or "alarm", "normal")
|
||||||
def alarm(body: AlarmIn) -> dict:
|
except ValueError as exc:
|
||||||
"""Lucy-UNABHÄNGIGER Alarm-Weg: schickt direkt auf Telegram (und legt die Meldung in den
|
raise HTTPException(400, str(exc))
|
||||||
Briefkasten). Für Absender, die NICHT auf die sprechende Lucy zählen können — allen voran
|
announce.notify_telegram(subject, text) # best-effort Telegram (posix/bash; Windows = No-op)
|
||||||
der PC-seitige Lucy-Watchdog, wenn die Desktop-App selbst hängt (dann nützt der Briefkasten
|
return {"ok": True, "item": item}
|
||||||
nichts, weil niemand ihn vorliest → Telegram ist der einzige verlässliche Kanal). LAN-only
|
|
||||||
wie alle MC2-Endpoints."""
|
|
||||||
text = (body.text or "").strip()
|
@router.post("/voice/announce")
|
||||||
if not text:
|
def voice_announce(body: AnnounceIn) -> dict:
|
||||||
raise HTTPException(400, "Leere Meldung.")
|
"""Meldung in den Briefkasten legen (Lucy-Proaktivität). Absender: Wächter, notify.sh
|
||||||
subject = (body.subject or "[Alarm]").strip()
|
(Updates/Radar/Telegram-Spiegel), Hermes-Cron."""
|
||||||
try:
|
try:
|
||||||
item = announce.add(text, subject, body.source or "alarm", "normal")
|
return {"ok": True, "item": announce.add(body.text, body.subject, body.source, body.priority)}
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(400, str(exc))
|
raise HTTPException(400, str(exc))
|
||||||
announce.notify_telegram(subject, text) # best-effort Telegram (posix/bash; Windows = No-op)
|
|
||||||
return {"ok": True, "item": item}
|
|
||||||
|
@router.get("/voice/announcements")
|
||||||
|
def voice_announcements(after: int | None = None, limit: int = 20) -> dict:
|
||||||
@router.post("/voice/announce")
|
"""Neue Meldungen nach Cursor `after` abholen (Lucy pollt). Ohne `after` nur den
|
||||||
def voice_announce(body: AnnounceIn) -> dict:
|
aktuellen Cursor-Stand (latest) — Erststart plappert so keine alten Meldungen nach."""
|
||||||
"""Meldung in den Briefkasten legen (Lucy-Proaktivität). Absender: Health-Wächter,
|
return announce.list_after(after, limit)
|
||||||
notify.sh (Updates/Radar/Telegram-Spiegel), Hermes-cron. LAN-only wie alle MC2-Endpoints."""
|
|
||||||
try:
|
|
||||||
return {"ok": True, "item": announce.add(body.text, body.subject, body.source, body.priority)}
|
@router.post("/voice/stt")
|
||||||
except ValueError as exc:
|
async def voice_stt(audio: UploadFile = File(...), language: str = Form(default="")) -> dict:
|
||||||
raise HTTPException(400, str(exc))
|
"""Mikro-Audio → Text (Proxy auf Sidecar /stt)."""
|
||||||
|
data = await audio.read()
|
||||||
|
if not data:
|
||||||
@router.get("/voice/announcements")
|
raise HTTPException(400, "Leeres Audio.")
|
||||||
def voice_announcements(after: int | None = None, limit: int = 20) -> dict:
|
files = {"audio": (audio.filename or "rec.webm", data, audio.content_type or "audio/webm")}
|
||||||
"""Neue Meldungen nach Cursor `after` abholen (Lucy pollt). Ohne `after` nur den
|
try:
|
||||||
aktuellen Cursor-Stand (latest) — Erststart plappert so keine alten Meldungen nach."""
|
async with httpx.AsyncClient(timeout=_STT_TIMEOUT) as client:
|
||||||
return announce.list_after(after, limit)
|
r = await client.post(f"{VOICE_SERVICE_URL}/stt", files=files, data={"language": language})
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
@router.get("/voice/health")
|
except httpx.HTTPError as exc:
|
||||||
def voice_health() -> dict:
|
raise HTTPException(502, f"STT fehlgeschlagen: {exc}")
|
||||||
"""Erreichbarkeit des Voice-Sidecars + ob der Hermes-API-Key gesetzt ist."""
|
|
||||||
out: dict = {"sidecar": False, "hermes_key": bool(HERMES_API_KEY)}
|
|
||||||
try:
|
@router.post("/voice/turn")
|
||||||
r = httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=httpx.Timeout(5.0))
|
async def voice_turn(audio: UploadFile = File(...)) -> dict:
|
||||||
out["sidecar"] = r.status_code == 200
|
"""Semantische Turn-Detection (Smart Turn v3): war die Äußerung fertig? Proxy → Sidecar."""
|
||||||
out["detail"] = r.json() if r.status_code == 200 else None
|
data = await audio.read()
|
||||||
except Exception as exc: # noqa: BLE001
|
if not data:
|
||||||
out["error"] = str(exc)
|
raise HTTPException(400, "Leeres Audio.")
|
||||||
return out
|
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:
|
||||||
@router.get("/voice/metrics")
|
r = await client.post(f"{VOICE_SERVICE_URL}/turn", files=files)
|
||||||
def voice_metrics() -> dict:
|
r.raise_for_status()
|
||||||
"""Rollende Latenz-Stats je Stufe (avg/p50/p95/last, ms). Quelle u.a. für selbstkritik-feed.sh."""
|
return r.json()
|
||||||
return get_metrics()
|
except httpx.HTTPError as exc:
|
||||||
|
# Turn-Check ist eine Optimierung — bei Ausfall lieber sofort antworten als hängen.
|
||||||
|
log.warning("Turn-Check fehlgeschlagen: %s", exc)
|
||||||
@router.get("/voice/trace")
|
return {"complete": True, "probability": 1.0, "engine": "fallback"}
|
||||||
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 —
|
@router.post("/voice/chat")
|
||||||
damit man den EINEN langsamen Turn sieht, den ein Durchschnitt verschluckt."""
|
async def voice_chat(body: ChatIn) -> StreamingResponse:
|
||||||
return {"turns": get_trace(limit)}
|
"""Neue User-Äußerung → Hermes-Agent (api_server, streamend). SSE wird 1:1 durchgereicht.
|
||||||
|
|
||||||
|
Mit `X-Hermes-Session-Id` hält die Plattform den Verlauf — wir senden nur die neue Nachricht.
|
||||||
@router.get("/voice/voices")
|
Auth per Bearer (API_SERVER_KEY); ohne Key liefert :8642 ein 401."""
|
||||||
def voice_voices() -> dict:
|
if not HERMES_API_KEY:
|
||||||
try:
|
raise HTTPException(503, "HERMES_API_KEY/API_SERVER_KEY nicht gesetzt — Agent-Auth fehlt.")
|
||||||
r = httpx.get(f"{VOICE_SERVICE_URL}/voices", timeout=httpx.Timeout(10.0))
|
|
||||||
r.raise_for_status()
|
headers = {
|
||||||
return r.json()
|
"Authorization": f"Bearer {HERMES_API_KEY}",
|
||||||
except Exception as exc: # noqa: BLE001
|
"X-Hermes-Session-Id": body.session_id,
|
||||||
raise HTTPException(502, f"Voice-Sidecar nicht erreichbar: {exc}")
|
}
|
||||||
|
if body.session_key:
|
||||||
|
headers["X-Hermes-Session-Key"] = body.session_key
|
||||||
@router.post("/voice/stt")
|
|
||||||
async def voice_stt(audio: UploadFile = File(...), language: str = Form(default="")) -> dict:
|
async def gen():
|
||||||
"""Mikro-Audio → Text (Proxy auf Sidecar /stt)."""
|
# Bildschirm-Sicht INNERHALB des Streams: so startet die SSE-Antwort sofort und der Client
|
||||||
data = await audio.read()
|
# bekommt ein Progress-Event (-> Lucy kann eine Warte-Ansage sprechen), statt dass der
|
||||||
if not data:
|
# Request hängt, während das Bild-Modell beschreibt.
|
||||||
raise HTTPException(400, "Leeres Audio.")
|
user_text = body.text
|
||||||
files = {"audio": (audio.filename or "rec.webm", data, audio.content_type or "audio/webm")}
|
imgs = [u for u in (body.images or []) if u]
|
||||||
try:
|
if imgs:
|
||||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
yield b'event: hermes.vision.progress\ndata: {"note": "Bildschirm wird angeschaut"}\n\n'
|
||||||
_t0 = time.perf_counter()
|
desc = await _describe_images(imgs, body.text)
|
||||||
r = await client.post(f"{VOICE_SERVICE_URL}/stt", files=files, data={"language": language})
|
if desc:
|
||||||
_ms = (time.perf_counter() - _t0) * 1000.0
|
safe_desc = wrap_untrusted(desc, "BILDSCHIRM")
|
||||||
record_stage("stt", _ms)
|
user_text = f"[Bildschirm-Sicht — das ist gerade auf dem/den Schirm(en) zu sehen:\n{safe_desc}\n]\n\n{body.text}"
|
||||||
park("stt", _ms) # der folgende /voice/chat-Turn sammelt die STT-Dauer für seinen Trace ein
|
messages = []
|
||||||
r.raise_for_status()
|
if body.system:
|
||||||
return r.json()
|
messages.append({"role": "system", "content": body.system})
|
||||||
except httpx.HTTPError as exc:
|
messages.append({"role": "user", "content": user_text})
|
||||||
raise HTTPException(502, f"STT fehlgeschlagen: {exc}")
|
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).
|
||||||
@router.post("/voice/turn")
|
if os.environ.get("MC_VOICE_NO_THINK", "1") not in ("0", "false", "False"):
|
||||||
async def voice_turn(audio: UploadFile = File(...)) -> dict:
|
payload["chat_template_kwargs"] = {"enable_thinking": False}
|
||||||
"""Semantische Turn-Detection (Smart Turn v3): war die Äußerung fertig? Proxy → Sidecar."""
|
try:
|
||||||
data = await audio.read()
|
async with httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0)) as client:
|
||||||
if not data:
|
async with client.stream(
|
||||||
raise HTTPException(400, "Leeres Audio.")
|
"POST", f"{HERMES_API_URL}/v1/chat/completions", json=payload, headers=headers,
|
||||||
files = {"audio": (audio.filename or "rec.wav", data, audio.content_type or "audio/wav")}
|
) as r:
|
||||||
try:
|
if r.status_code != 200:
|
||||||
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client:
|
detail = (await r.aread()).decode("utf-8", "replace")[:500]
|
||||||
with Timer("turn"):
|
yield _sse_fehler(f"Hermes {r.status_code}: {detail}")
|
||||||
r = await client.post(f"{VOICE_SERVICE_URL}/turn", files=files)
|
return
|
||||||
r.raise_for_status()
|
async for chunk in r.aiter_raw():
|
||||||
return r.json()
|
yield chunk
|
||||||
except httpx.HTTPError as exc:
|
except httpx.HTTPError as exc:
|
||||||
# Turn-Check ist eine Optimierung — bei Ausfall lieber sofort antworten als hängen.
|
yield _sse_fehler(f"Verbindung zu Hermes fehlgeschlagen: {exc}")
|
||||||
log.warning("Turn-Check fehlgeschlagen: %s", exc)
|
|
||||||
return {"complete": True, "probability": 1.0, "engine": "fallback"}
|
return StreamingResponse(gen(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/voice/reference")
|
# ---------------------------------------------------------------------------------------------
|
||||||
async def voice_set_reference(audio: UploadFile = File(...)) -> dict:
|
# Lucys Stimme ins LAN reichen (Raphael-Umbau 04.09.2026). lucy-stimme.service (:8021, pocket-tts
|
||||||
"""Klon-Referenz (z.B. ElevenLabs-Erzeugnis) hochladen → Chatterbox nutzt sie. Proxy → Sidecar."""
|
# german_24l) bindet nur Loopback; die Desktop-Lucy am PC spricht mit DIESEM — dieselbe Stimme wie
|
||||||
data = await audio.read()
|
# die Telegram-Sprachnachrichten. Dünner Proxy, API 1:1 (pocket_server: /health, /tts -> WAV,
|
||||||
if not data:
|
# /tts/stream -> PCM16 + X-Sample-Rate).
|
||||||
raise HTTPException(400, "Leeres Audio.")
|
|
||||||
files = {"audio": (audio.filename or "ref.wav", data, audio.content_type or "audio/mpeg")}
|
class LucyTtsIn(BaseModel):
|
||||||
try:
|
text: str
|
||||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
emo: str | None = None # Stimmungs-Profil (pocket_server EMO_PROFILES); Raphael-Lucy setzt keins
|
||||||
r = await client.post(f"{VOICE_SERVICE_URL}/reference", files=files)
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
@router.get("/lucy/stimme/health")
|
||||||
except httpx.HTTPError as exc:
|
def lucy_stimme_health() -> dict:
|
||||||
raise HTTPException(502, f"Referenz-Upload fehlgeschlagen: {exc}")
|
"""Bereitschaft von Lucys Stimme (pocket_server /health: status ok|loading)."""
|
||||||
|
try:
|
||||||
|
r = httpx.get(f"{LUCY_STIMME_URL}/health", timeout=httpx.Timeout(5.0))
|
||||||
@router.get("/voice/reference")
|
r.raise_for_status()
|
||||||
def voice_get_reference() -> dict:
|
return r.json()
|
||||||
try:
|
except Exception as exc:
|
||||||
r = httpx.get(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
|
raise HTTPException(502, f"Lucys Stimme (:8021) nicht erreichbar: {exc}")
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
|
||||||
except Exception as exc: # noqa: BLE001
|
@router.post("/lucy/stimme/tts")
|
||||||
return {"active": False, "error": str(exc)}
|
async def lucy_stimme_tts(body: LucyTtsIn) -> Response:
|
||||||
|
"""Text -> WAV (ganzer Text). Warm-up der Desktop-Lucy + Jobs, die eine Datei brauchen."""
|
||||||
|
try:
|
||||||
@router.delete("/voice/reference")
|
async with httpx.AsyncClient(timeout=httpx.Timeout(600.0, connect=5.0)) as client:
|
||||||
def voice_clear_reference() -> dict:
|
r = await client.post(f"{LUCY_STIMME_URL}/tts", json=body.model_dump(exclude_none=True))
|
||||||
try:
|
r.raise_for_status()
|
||||||
r = httpx.delete(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0))
|
return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav"),
|
||||||
r.raise_for_status()
|
headers={k: v for k, v in r.headers.items() if k.lower().startswith("x-")})
|
||||||
return r.json()
|
except httpx.HTTPStatusError as exc:
|
||||||
except httpx.HTTPError as exc:
|
raise HTTPException(exc.response.status_code, f"Lucys Stimme: {exc.response.text[:200]}")
|
||||||
raise HTTPException(502, f"Löschen fehlgeschlagen: {exc}")
|
except httpx.HTTPError as exc:
|
||||||
|
raise HTTPException(502, f"Lucys Stimme nicht erreichbar: {exc}")
|
||||||
|
|
||||||
@router.post("/voice/tts")
|
|
||||||
async def voice_tts(body: TTSIn) -> Response:
|
@router.post("/lucy/stimme/tts/stream")
|
||||||
"""Text → Sprache (Proxy auf Sidecar /tts), liefert WAV-Bytes."""
|
async def lucy_stimme_tts_stream(body: LucyTtsIn) -> StreamingResponse:
|
||||||
try:
|
"""Text -> rohes PCM16-mono, satzweise gestreamt (Samplerate im Header X-Sample-Rate).
|
||||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
Der Live-Pfad der Desktop-Lucy: erstes Audio nach dem ersten Satz. Der Upstream-Stream bleibt
|
||||||
with Timer("tts"):
|
offen, solange der Client liest — bricht der Client ab (Barge-in), schließt httpx den Upstream."""
|
||||||
r = await client.post(f"{VOICE_SERVICE_URL}/tts", json=body.model_dump())
|
client = httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0))
|
||||||
r.raise_for_status()
|
try:
|
||||||
return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav"))
|
req = client.build_request("POST", f"{LUCY_STIMME_URL}/tts/stream", json=body.model_dump(exclude_none=True))
|
||||||
except httpx.HTTPError as exc:
|
upstream = await client.send(req, stream=True)
|
||||||
raise HTTPException(502, f"TTS fehlgeschlagen: {exc}")
|
except httpx.HTTPError as exc:
|
||||||
|
await client.aclose()
|
||||||
|
raise HTTPException(502, f"Lucys Stimme nicht erreichbar: {exc}")
|
||||||
@router.post("/voice/chat")
|
if upstream.status_code != 200:
|
||||||
async def voice_chat(body: ChatIn) -> StreamingResponse:
|
detail = (await upstream.aread()).decode("utf-8", "replace")[:200]
|
||||||
"""Neue User-Äußerung → Hermes-Agent (api_server, streamend). SSE wird 1:1 durchgereicht.
|
await upstream.aclose(); await client.aclose()
|
||||||
|
raise HTTPException(upstream.status_code, f"Lucys Stimme: {detail}")
|
||||||
Mit `X-Hermes-Session-Id` hält die Plattform den Verlauf — wir senden nur die neue Nachricht.
|
|
||||||
Auth per Bearer (API_SERVER_KEY); ohne Key liefert :8642 ein 401."""
|
async def gen():
|
||||||
if not HERMES_API_KEY:
|
try:
|
||||||
raise HTTPException(503, "HERMES_API_KEY/API_SERVER_KEY nicht gesetzt — Agent-Auth fehlt.")
|
async for chunk in upstream.aiter_raw():
|
||||||
|
yield chunk
|
||||||
headers = {
|
finally:
|
||||||
"Authorization": f"Bearer {HERMES_API_KEY}",
|
await upstream.aclose()
|
||||||
"X-Hermes-Session-Id": body.session_id,
|
await client.aclose()
|
||||||
}
|
|
||||||
if body.session_key:
|
return StreamingResponse(gen(), media_type="application/octet-stream",
|
||||||
headers["X-Hermes-Session-Key"] = body.session_key
|
headers={"X-Sample-Rate": upstream.headers.get("x-sample-rate", "24000")})
|
||||||
|
|
||||||
async def gen():
|
|
||||||
# Per-Turn-Trace: sammelt STT (davor, geparkt) + Vision + Hirn-TTFT + Generierung + Mem0
|
|
||||||
# (Rückruf während) zu EINEM Datensatz -> die Latenz-Ansicht zeigt den einzelnen Hänger.
|
|
||||||
trace = TurnTrace(session_id=body.session_id, kind="voice")
|
|
||||||
first = True
|
|
||||||
first_content = True
|
|
||||||
committed = False
|
|
||||||
|
|
||||||
def _commit() -> None:
|
|
||||||
nonlocal committed
|
|
||||||
if not committed:
|
|
||||||
committed = True
|
|
||||||
trace.commit()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Bildschirm-Sicht INNERHALB des Streams (C2-Fix): so startet die SSE-Antwort sofort und
|
|
||||||
# der Client bekommt ein Progress-Event (-> Lucy kann eine Warte-Ansage sprechen), statt
|
|
||||||
# dass der Request bis zu 120 s "tot" hängt, während das Vision-Modell beschreibt.
|
|
||||||
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)
|
|
||||||
|
|
||||||
return StreamingResponse(gen(), media_type="text/event-stream")
|
|
||||||
|
|||||||
@@ -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 fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from services import backup as backup_svc
|
from services import backup as backup_svc
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
@@ -32,17 +31,6 @@ def list_snapshots() -> dict:
|
|||||||
return {"available": os.name == "posix", "backups": backup_svc.list_backups()}
|
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")
|
@router.post("/zeitmaschine/restore")
|
||||||
def restore(body: RestoreIn) -> dict:
|
def restore(body: RestoreIn) -> dict:
|
||||||
"""Wiederherstellung starten (detached). restore.sh macht VORHER selbst ein
|
"""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
|
||||||
+77
-118
@@ -1,32 +1,22 @@
|
|||||||
"""
|
"""
|
||||||
Hermes-Agent-Status (Control-Plane-Read). MC betreibt Hermes NICHT — es zeigt nur
|
Hermes-Agent: Status und Hirn-Umstellung. MC betreibt Hermes NICHT — Werkzeuge und MCP werden in
|
||||||
Status + verlinkt das standalone hermes-webui. Voller Zugriff + Tools/MCP werden in
|
Hermes' eigener Config verdrahtet (siehe docs/ARCHITEKTUR.md, „Wer schreibt was").
|
||||||
Hermes' eigener Config verdrahtet (siehe docs/HERMES_SETUP.md).
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import psutil
|
from config import (
|
||||||
|
HERMES_API_URL,
|
||||||
from config import (BOX_CONSOLE_UPSTREAM,
|
HERMES_BUILTIN_UI_PATH,
|
||||||
BOX_CONSOLE_PATH, HERMES_BUILTIN_UI_UPSTREAM, HERMES_BUILTIN_UI_PATH,
|
HERMES_BUILTIN_UI_UPSTREAM,
|
||||||
HERMES_API_URL, HERMES_HOME, PC_EXECUTOR_URL)
|
HERMES_HOME,
|
||||||
|
)
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
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:
|
def _active_brain_name() -> str:
|
||||||
"""Aktives Agent-Hirn aus Hermes' Config: model.default (sonst model.model)."""
|
"""Aktives Agent-Hirn aus Hermes' Config: model.default (sonst model.model)."""
|
||||||
try:
|
try:
|
||||||
@@ -102,74 +92,29 @@ def _reach(url: str, path: str = "") -> bool:
|
|||||||
return False
|
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:
|
def agent_status() -> dict:
|
||||||
"""Erreichbarkeit von Gateway (:8642) + WebUI (:8787) + lokale Hinweise."""
|
"""Erreichbarkeit des Hermes-Gateways (:8642) und des Hermes-Dashboards — für die Dienste-Liste.
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
"gateway_url": HERMES_API_URL,
|
"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/).
|
# 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,
|
"hermes_ui_url": HERMES_BUILTIN_UI_PATH,
|
||||||
"gateway_reachable": _reach(HERMES_API_URL, "/health"),
|
"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, "/"),
|
"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:
|
"""Setzt ein (bereits installiertes) Modell als Agent-Hirn — WARM-bewusst:
|
||||||
1) vergibt den 'hermes'-Alias (das Agent-Hirn-Slot),
|
1) vergibt den 'hermes'-Alias (das Agent-Hirn-Slot),
|
||||||
2) tauscht es in die residente brains-Gruppe (altes Hirn raus, fast/vision bleiben),
|
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.
|
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
|
from services import llamaswap
|
||||||
models = {m["name"]: m for m in llamaswap.list_models()}
|
models = {m["name"]: m for m in llamaswap.list_models()}
|
||||||
if model_id not in 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.
|
# Idempotent härten: auch wenn schon Hirn, warm (brains) + ttl 0 sicherstellen.
|
||||||
try:
|
try:
|
||||||
from services.llamaswap import set_ttl
|
from services.llamaswap import set_ttl
|
||||||
brains = (llamaswap.list_groups().get("brains") or {}).get("members") or []
|
with llamaswap.sammeln():
|
||||||
if model_id not in brains:
|
brains = (llamaswap.list_groups().get("brains") or {}).get("members") or []
|
||||||
llamaswap.set_group("brains", brains + [model_id], swap=False, persist=True)
|
if model_id not in brains:
|
||||||
set_ttl(model_id, 0)
|
llamaswap.set_group("brains", brains + [model_id], swap=False, persist=True)
|
||||||
|
set_ttl(model_id, 0)
|
||||||
except PermissionError as exc:
|
except PermissionError as exc:
|
||||||
return {"ok": False, "reason": str(exc)}
|
return {"ok": False, "reason": str(exc)}
|
||||||
return {"ok": True, "old": old, "new": model_id, "note": "ist bereits das Agent-Hirn"}
|
return {"ok": True, "old": old, "new": model_id, "note": "ist bereits das Agent-Hirn"}
|
||||||
try:
|
try:
|
||||||
llamaswap.set_role(model_id, "hermes") # 1) Alias
|
from services.llamaswap import DEFAULT_TTL, set_ttl
|
||||||
brains = (llamaswap.list_groups().get("brains") or {}).get("members") or []
|
# Alias, Gruppe und ttl als EIN Schreibvorgang (24.09.2026) — jeder einzelne entlud alle Modelle.
|
||||||
new_members = [x for x in brains if x not in (old, model_id)] + [model_id]
|
with llamaswap.sammeln():
|
||||||
llamaswap.set_group("brains", new_members, swap=False, persist=True) # 2) warm
|
llamaswap.set_role(model_id, "hermes") # 1) Alias
|
||||||
# 2b) TTL härten: neues Hirn nie auto-entladen; altes Hirn auf Default entspannen.
|
brains = (llamaswap.list_groups().get("brains") or {}).get("members") or []
|
||||||
from services.llamaswap import set_ttl, DEFAULT_TTL
|
new_members = [x for x in brains if x not in (old, model_id)] + [model_id]
|
||||||
set_ttl(model_id, 0)
|
llamaswap.set_group("brains", new_members, swap=False, persist=True) # 2) warm
|
||||||
if old:
|
# 2b) TTL härten: neues Hirn nie auto-entladen; altes Hirn auf Default entspannen.
|
||||||
set_ttl(old, DEFAULT_TTL)
|
set_ttl(model_id, 0)
|
||||||
|
if old:
|
||||||
|
set_ttl(old, DEFAULT_TTL)
|
||||||
except PermissionError as exc:
|
except PermissionError as exc:
|
||||||
return {"ok": False, "reason": str(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?
|
# Weiche Budget-Warnung (kein Hard-Block): passt Hirn + größtes on-demand zusammen ins GTT?
|
||||||
warning = None
|
warning = None
|
||||||
try:
|
try:
|
||||||
@@ -214,49 +163,59 @@ def set_agent_brain(model_id: str) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def update_brain_model(new_model: str) -> bool:
|
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
|
from config import HERMES_HOME
|
||||||
home = HERMES_HOME
|
from ruamel.yaml import YAML
|
||||||
config_path = home / "config.yaml"
|
|
||||||
|
config_path = HERMES_HOME / "config.yaml"
|
||||||
# Ensure home directory exists
|
if not config_path.exists():
|
||||||
home.mkdir(parents=True, exist_ok=True)
|
log.warning("update_brain_model: %s fehlt — Hirn wird nicht umgestellt", config_path)
|
||||||
|
return False
|
||||||
cfg = {}
|
r_yaml = YAML()
|
||||||
if config_path.exists():
|
r_yaml.preserve_quotes = True
|
||||||
try:
|
r_yaml.width = 100
|
||||||
from ruamel.yaml import YAML
|
r_yaml.indent(mapping=2, sequence=4, offset=2)
|
||||||
r_yaml = YAML()
|
try:
|
||||||
with config_path.open("r", encoding="utf-8") as f:
|
with config_path.open("r", encoding="utf-8") as f:
|
||||||
cfg = r_yaml.load(f) or {}
|
cfg = r_yaml.load(f)
|
||||||
except Exception:
|
except Exception:
|
||||||
log.debug("update_brain_model: bestehende config.yaml nicht lesbar", exc_info=True)
|
log.warning("update_brain_model: config.yaml nicht lesbar — nichts geschrieben", exc_info=True)
|
||||||
cfg = {}
|
return False
|
||||||
|
|
||||||
if not isinstance(cfg, dict):
|
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):
|
if "model" not in cfg or not isinstance(cfg["model"], dict):
|
||||||
cfg["model"] = {}
|
cfg["model"] = {}
|
||||||
|
|
||||||
# Hermes liest model.default als aktives Modell; model.model ist der Provider-Param.
|
# 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"]["default"] = new_model
|
||||||
cfg["model"]["model"] = new_model
|
cfg["model"]["model"] = new_model
|
||||||
|
|
||||||
|
tmp = config_path.with_suffix(".yaml.neu")
|
||||||
try:
|
try:
|
||||||
from ruamel.yaml import YAML
|
shutil.copy2(config_path, config_path.with_name(f"config.yaml.bak-hirn-{_time.strftime('%Y%m%d-%H%M%S')}"))
|
||||||
r_yaml = YAML()
|
with tmp.open("w", encoding="utf-8") as f:
|
||||||
with config_path.open("w", encoding="utf-8") as f:
|
|
||||||
r_yaml.dump(cfg, f)
|
r_yaml.dump(cfg, f)
|
||||||
|
YAML(typ="safe").load(tmp.read_text(encoding="utf-8")) # muss wieder lesbar sein
|
||||||
# Restart the user-space service to apply changes
|
os.replace(tmp, config_path)
|
||||||
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
|
|
||||||
except Exception:
|
except Exception:
|
||||||
log.warning("update_brain_model: Schreiben der config.yaml fehlgeschlagen", exc_info=True)
|
log.warning("update_brain_model: Schreiben der config.yaml fehlgeschlagen", exc_info=True)
|
||||||
|
tmp.unlink(missing_ok=True)
|
||||||
return False
|
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
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from config import MODELS_DIR
|
import httpx
|
||||||
|
from kern.einstellungen import einstellungen
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
NOTIFY_SH = str(Path(__file__).resolve().parent.parent.parent / "deploy" / "notify.sh")
|
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"))
|
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()
|
_lock = threading.Lock()
|
||||||
_state: dict | None = None # {"next_id": int, "items": [...]}
|
_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()
|
text = (text or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
raise ValueError("Leere Meldung.")
|
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:
|
with _lock:
|
||||||
state = _load()
|
state = _load()
|
||||||
item = {
|
item = {
|
||||||
@@ -89,14 +112,6 @@ def notify_telegram(subject: str, text: str) -> None:
|
|||||||
log.warning("notify_telegram: notify.sh fehlgeschlagen", exc_info=True)
|
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:
|
def list_after(after: int | None, limit: int = 20) -> dict:
|
||||||
"""Einträge NACH Cursor `after` (aufsteigend). Ohne Cursor nur den aktuellen
|
"""Einträge NACH Cursor `after` (aufsteigend). Ohne Cursor nur den aktuellen
|
||||||
Stand liefern (latest) — so initialisiert Lucy ihren Cursor, ohne Altes nachzuplappern."""
|
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,316 +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.
|
|
||||||
• 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 deploy/auftrag-annehmen.sh als EIGENE systemd-Unit (detached):
|
|
||||||
Merge im isolierten Worktree → Push origin/main → Deploy → Health → bei Rot Revert+Alarm.
|
|
||||||
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.
|
|
||||||
|
|
||||||
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()
|
|
||||||
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")))
|
|
||||||
|
|
||||||
# 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 = {"ts": 0.0}
|
|
||||||
_FETCH_EVERY = 30.0 # s — Gitea nicht bei jedem UI-Poll anfragen
|
|
||||||
|
|
||||||
|
|
||||||
def _available() -> bool:
|
|
||||||
return os.name == "posix" and (REPO / ".git").exists()
|
|
||||||
|
|
||||||
|
|
||||||
def _git(args: list[str], timeout: int = 20) -> subprocess.CompletedProcess:
|
|
||||||
return subprocess.run(["git", "-C", str(REPO), *args],
|
|
||||||
capture_output=True, text=True, timeout=timeout)
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_throttled() -> None:
|
|
||||||
now = time.time()
|
|
||||||
if now - _fetch_cache["ts"] < _FETCH_EVERY:
|
|
||||||
return
|
|
||||||
_fetch_cache["ts"] = now
|
|
||||||
try:
|
|
||||||
_git(["fetch", "-q", "--prune", "origin"], timeout=30)
|
|
||||||
except Exception:
|
|
||||||
log.warning("auftragsbuch: git fetch fehlgeschlagen", 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(branch: str, state: str, detail: str) -> None:
|
|
||||||
try:
|
|
||||||
try:
|
|
||||||
data = json.loads(STATUS_PATH.read_text(encoding="utf-8"))
|
|
||||||
except Exception:
|
|
||||||
data = {}
|
|
||||||
data.setdefault("branches", {})[branch] = {"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 _remote_branches() -> list[str]:
|
|
||||||
r = _git(["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 list_proposals() -> dict:
|
|
||||||
"""Alle offenen Vorschläge: Branches (mit Commit-/Diff-Zusammenfassung + Status) und
|
|
||||||
Skill-Kandidaten aus dem Vault. Eine Antwort für die ganze Auftragsbuch-Seite."""
|
|
||||||
if not _available():
|
|
||||||
return {"available": False, "items": [], "skill_kandidaten": [], "open_count": 0}
|
|
||||||
|
|
||||||
_fetch_throttled()
|
|
||||||
statuses = _statuses()
|
|
||||||
items = []
|
|
||||||
for branch in _remote_branches():
|
|
||||||
ref = f"origin/{branch}"
|
|
||||||
try:
|
|
||||||
ahead = int((_git(["rev-list", "--count", f"origin/main..{ref}"]).stdout or "0").strip() or 0)
|
|
||||||
if ahead == 0 and (statuses.get(branch, {}).get("state") not in ("laeuft", "rollback")):
|
|
||||||
continue # bereits in main enthalten → keine offene Entscheidung mehr
|
|
||||||
behind = int((_git(["rev-list", "--count", f"{ref}..origin/main"]).stdout or "0").strip() or 0)
|
|
||||||
show = _git(["show", "-s", "--format=%s%x1f%b%x1f%ct%x1f%an", ref])
|
|
||||||
subject, body, cts, author = ((show.stdout or "").split("\x1f") + ["", "", "", ""])[:4]
|
|
||||||
stat = (_git(["diff", "--shortstat", f"origin/main...{ref}"]).stdout or "").strip()
|
|
||||||
files_raw = (_git(["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({
|
|
||||||
"branch": branch,
|
|
||||||
"kind": branch.split("/", 1)[0],
|
|
||||||
"subject": subject.strip(),
|
|
||||||
"body": body.strip()[:2000],
|
|
||||||
"author": author.strip(),
|
|
||||||
"ts": int(cts) if cts.strip().isdigit() else None,
|
|
||||||
"ahead": ahead,
|
|
||||||
"behind": behind,
|
|
||||||
"shortstat": stat,
|
|
||||||
"files": files,
|
|
||||||
"files_truncated": len(files_raw) > 60,
|
|
||||||
# Frontend-Quelltext ohne gebautes Bundle: Box kann nicht bauen (kein Node) —
|
|
||||||
# nach dem Annehmen bliebe die Oberfläche alt, bis am PC gebaut wird.
|
|
||||||
"frontend_ohne_build": frontend_src and not frontend_dist,
|
|
||||||
"status": statuses.get(branch),
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
log.warning("auftragsbuch: Branch %s nicht lesbar", branch, exc_info=True)
|
|
||||||
items.sort(key=lambda i: i.get("ts") or 0, reverse=True)
|
|
||||||
|
|
||||||
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) -> dict:
|
|
||||||
if not _available():
|
|
||||||
return {"ok": False, "error": "Nur auf der Box verfügbar."}
|
|
||||||
if not _valid_branch(branch) or branch not in _remote_branches():
|
|
||||||
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
|
|
||||||
r = _git(["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) -> dict:
|
|
||||||
"""Annehmen: detached Runner starten (Merge→Push→Deploy→Health→ggf. Rollback)."""
|
|
||||||
if not _available():
|
|
||||||
return {"ok": False, "error": "Annehmen geht nur auf der Box."}
|
|
||||||
if not _valid_branch(branch) or branch not in _remote_branches():
|
|
||||||
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
|
|
||||||
state = (_statuses().get(branch) or {}).get("state")
|
|
||||||
if state in ("laeuft", "rollback"):
|
|
||||||
return {"ok": False, "error": "Für diesen Vorschlag läuft bereits ein Annahme-Lauf."}
|
|
||||||
|
|
||||||
# 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" / "auftrag-annehmen.sh"
|
|
||||||
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-]+", "-", 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(branch, "laeuft", "Annahme-Lauf gestartet")
|
|
||||||
return {"ok": True, "unit": unit}
|
|
||||||
|
|
||||||
|
|
||||||
def reject(branch: str) -> dict:
|
|
||||||
"""Ablehnen: Remote-Branch löschen (die Arbeit bleibt in der Gitea-Historie referenzierbar,
|
|
||||||
aber die Entscheidung ist gefallen). Meldung in den Briefkasten — ehrlich und sichtbar."""
|
|
||||||
if not _available():
|
|
||||||
return {"ok": False, "error": "Ablehnen geht nur auf der Box."}
|
|
||||||
if not _valid_branch(branch) or branch not in _remote_branches():
|
|
||||||
return {"ok": False, "error": f"Unbekannter Vorschlags-Branch: {branch}"}
|
|
||||||
r = _git(["push", "origin", "--delete", branch], timeout=30)
|
|
||||||
if r.returncode != 0:
|
|
||||||
return {"ok": False, "error": f"Löschen fehlgeschlagen: {(r.stderr or '').strip()[:300]}"}
|
|
||||||
_git(["fetch", "-q", "--prune", "origin"], timeout=30)
|
|
||||||
_set_status(branch, "abgelehnt", "Vom Commander abgelehnt — Branch gelöscht")
|
|
||||||
try:
|
|
||||||
from services import announce
|
|
||||||
announce.add(f"Vorschlag '{branch}' wurde abgelehnt und der Branch gelöscht.",
|
|
||||||
"[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);
|
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
|
import subprocess
|
||||||
@@ -27,11 +27,6 @@ def _latest() -> Path | None:
|
|||||||
return snaps[0] if snaps else 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]:
|
def _components(tarball: Path) -> list[str]:
|
||||||
"""Top-Level-Einträge im Tarball (zur Anzeige im UI)."""
|
"""Top-Level-Einträge im Tarball (zur Anzeige im UI)."""
|
||||||
try:
|
try:
|
||||||
@@ -51,7 +46,7 @@ def backup_now() -> dict:
|
|||||||
r = subprocess.run(["/bin/bash", str(BACKUP_SH)], capture_output=True, text=True, timeout=180)
|
r = subprocess.run(["/bin/bash", str(BACKUP_SH)], capture_output=True, text=True, timeout=180)
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
return {"ok": False, "snapshot": "", "files": [], "error": (r.stderr or r.stdout).strip()[-300:]}
|
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)}
|
return {"ok": False, "snapshot": "", "files": [], "error": str(exc)}
|
||||||
|
|
||||||
latest = _latest()
|
latest = _latest()
|
||||||
@@ -70,9 +65,11 @@ def list_backups() -> list[dict]:
|
|||||||
return []
|
return []
|
||||||
out = []
|
out = []
|
||||||
for p in sorted(BACKUP_DIR.glob("mc2-state-*.tar.gz"), reverse=True):
|
for p in sorted(BACKUP_DIR.glob("mc2-state-*.tar.gz"), reverse=True):
|
||||||
|
st = p.stat()
|
||||||
out.append({
|
out.append({
|
||||||
"snapshot": _ts(p),
|
"snapshot": _ts(p),
|
||||||
"file": p.name,
|
"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
|
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 json
|
||||||
|
import logging
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from config import DISCOVER_CACHE_PATH, DISCOVER_TTL
|
from config import DISCOVER_CACHE_PATH, DISCOVER_TTL
|
||||||
|
|
||||||
from services import catalog
|
from services import catalog
|
||||||
from services.caps import capabilities
|
from services.caps import capabilities
|
||||||
from services.fit import evaluate_fit, extract_params_b, max_ctx_for
|
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__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
_FIT_ORDER = {"perfect": 0, "marginal": 1, "too_tight": 2}
|
|
||||||
|
|
||||||
|
|
||||||
def _categorize(repo_id: str) -> str:
|
def _categorize(repo_id: str) -> str:
|
||||||
low = repo_id.lower()
|
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)
|
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
|
Routing-Gateway-Status. Seit UMBAU v3 P1 bedient der EIGENSTÄNDIGE mc2-gateway-
|
||||||
`/v1/*` mit `model: auto`-Komplexitäts-Routing vor llama-swap. Kein externer
|
Prozess den /v1-Pfad (MC_V1_UPSTREAM gesetzt, MC2 reicht nur roh durch); ohne
|
||||||
LiteLLM-Dienst nötig (baut auf Python 3.14 nicht); bleibt später austauschbar.
|
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.llamaswap import engine_reachable
|
||||||
from services.routing_policy import load_policy
|
from services.routing_policy import load_policy
|
||||||
|
|
||||||
@@ -13,7 +15,7 @@ def routing_summary() -> dict:
|
|||||||
p = load_policy()
|
p = load_policy()
|
||||||
coding_default = p["coder_lite"] or p["coder"]
|
coding_default = p["coder_lite"] or p["coder"]
|
||||||
return {
|
return {
|
||||||
"mode": "builtin",
|
"mode": "gateway (eigener Prozess)" if V1_UPSTREAM else "builtin",
|
||||||
"endpoint": f":{PORT}/v1 (OpenAI-kompatibel)",
|
"endpoint": f":{PORT}/v1 (OpenAI-kompatibel)",
|
||||||
# Virtuelle Lanes, die Clients/IDEs als „Modell" wählen (Router pickt das echte Alias).
|
# Virtuelle Lanes, die Clients/IDEs als „Modell" wählen (Router pickt das echte Alias).
|
||||||
"lanes": [
|
"lanes": [
|
||||||
@@ -42,5 +44,13 @@ def routing_summary() -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def gateway_reachable() -> bool:
|
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()
|
return engine_reachable()
|
||||||
|
|||||||
@@ -7,11 +7,52 @@ verstreute, still scheiternde String-Suche durch einen testbaren SSE-Zeilenparse
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
from services.token_stats import increment_tokens
|
from services.token_stats import increment_tokens
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
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:
|
def record_usage(usage: dict | None, model: str) -> None:
|
||||||
"""Ein usage-Objekt verbuchen (no-op bei None/leer)."""
|
"""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)
|
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
|
"""Rohen SSE-Chunk auf `usage` prüfen und Tokens verbuchen. Fehler werden
|
||||||
geloggt (debug) statt verschluckt — ein defekter Chunk bricht den Stream nicht."""
|
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:
|
if b'"usage"' not in chunk:
|
||||||
return
|
return
|
||||||
text = chunk.decode("utf-8", errors="ignore")
|
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("/")
|
return s.strip("/")
|
||||||
|
|
||||||
|
|
||||||
def list_quants(repo: str) -> list[str]:
|
def list_quants(repo: str) -> list[dict]:
|
||||||
"""Verfügbare Quant-Stufen eines Repos (aus den GGUF-Dateinamen, ohne mmproj)."""
|
"""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()
|
quants: set[str] = set()
|
||||||
for e in _tree(repo):
|
for e in baum:
|
||||||
p = str(e.get("path", ""))
|
p = str(e.get("path", ""))
|
||||||
if p.lower().endswith(".gguf") and "mmproj" not in p.lower():
|
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)
|
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())
|
quants.add(m.group(1).upper())
|
||||||
# gängige Reihenfolge zuerst
|
# 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}
|
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]:
|
def search(q: str = "", limit: int = 24) -> list[dict]:
|
||||||
"""Freie HF-Suche nach GGUF-Repos. Ohne q → Top-GGUF nach Downloads (Stöbern)."""
|
"""Freie HF-Suche nach GGUF-Repos. Ohne q → Top-GGUF nach Downloads (Stöbern)."""
|
||||||
url = (f"https://huggingface.co/api/models?filter=gguf"
|
params: dict[str, str | int] = {"filter": "gguf", "sort": "downloads", "direction": -1, "limit": limit}
|
||||||
f"&sort=downloads&direction=-1&limit={limit}")
|
|
||||||
if q and q.strip():
|
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:
|
try:
|
||||||
with httpx.Client(timeout=12.0) as c:
|
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:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
out = []
|
out = []
|
||||||
@@ -69,26 +71,34 @@ def _size(entry: dict) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def resolve_gguf(repo: str, quant: str = "Q4_K_M") -> dict:
|
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.
|
(-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}
|
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")]
|
ggufs = [e for e in tree if str(e.get("path", "")).lower().endswith(".gguf")]
|
||||||
q = quant.lower()
|
q = quant.lower()
|
||||||
# mmproj separat (Vision-Projektor)
|
# mmproj separat (Vision-Projektor)
|
||||||
mmproj = next((e["path"] for e in ggufs if "mmproj" in e["path"].lower()), None)
|
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()]
|
model = [e for e in ggufs if "mmproj" not in e["path"].lower()]
|
||||||
# bevorzugt den gewünschten Quant
|
# Nur der gewünschte Quant. Bis 24.09.2026 fiel die Auswahl sonst auf ALLE Modelldateien zurück —
|
||||||
pref = [e for e in model if q in e["path"].lower()]
|
# bei aufgeteilten Repos lud der Download dann alle Teile aller Varianten (hunderte GB).
|
||||||
chosen = pref or model
|
chosen = [e for e in model if q in e["path"].lower()]
|
||||||
if not chosen:
|
if not chosen:
|
||||||
return {"files": [], "first": None, "total_bytes": 0, "mmproj": mmproj, "split": False}
|
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)
|
split = any("-of-" in e["path"].lower() for e in chosen)
|
||||||
if split:
|
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]
|
files = [e["path"] for e in parts]
|
||||||
first = files[0]
|
first = files[0]
|
||||||
total = sum(_size(e) for e in parts)
|
total = sum(_size(e) for e in parts)
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"""Der Homelab-Teil (Rolle homelab, Phase 3/4): Proxmox-Host, Container und Arcane.
|
||||||
|
|
||||||
|
apps.py was in welchem Container steckt, wo seine Oberfläche liegt, woher die neueste Version kommt
|
||||||
|
kanal.py Aufträge und Berichte des Ausführers auf dem Proxmox-Host (gemeinsames Geheimnis)
|
||||||
|
inventar.py Bericht + neueste Versionen + Erreichbarkeit → Ziele im gemeinsamen Modell (kern/ziele.py)
|
||||||
|
karenz.py Wartezeit nach einer Änderung am Update-Skript (community-scripts, ungepinnt von GitHub)
|
||||||
|
updates.py „Jetzt updaten“: Snapshot bzw. Sicherung → Update → Prüfung → bei Rot zurück, mit Meldung
|
||||||
|
sammellauf.py „Alle aktualisieren“: alle Updates mit Knopf nacheinander, bei Rot Schluss
|
||||||
|
protokoll.py was wann geschah (Aufträge, Läufe, Hinweise, Meldungen, Pflege), ohne Geheimnisse
|
||||||
|
einstellungen.py Arcane-Schlüssel und Docker-echt-Schalter aus der Oberfläche, Stand für „Einstellungen“
|
||||||
|
pflege.py wöchentliches Suchen (Paketlisten der Gäste), im Wächter-Takt des Stewards
|
||||||
|
"""
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""AdGuard Home: der Anteil geblockter Anfragen für die Kachel (Ausbauplan Welle 1, seit 25.09.2026).
|
||||||
|
|
||||||
|
Die Statistik (GET /control/stats) gibt AdGuard nur mit Anmeldung heraus. Den Zugang trägt der User selbst unter
|
||||||
|
Einstellungen ein; vor dem Speichern fragt dieser Teil AdGuard damit einmal — lehnt es ab oder antwortet nicht, wird
|
||||||
|
nichts gespeichert. Er liegt in <Datenordner>/adguard.json (0600) und wird bei jedem Zugriff gelesen: ein neuer gilt
|
||||||
|
ohne Neustart. Das Passwort steht nie in einer Antwort, einem Protokoll oder einer Fehlermeldung; den Benutzernamen
|
||||||
|
zeigt die Oberfläche, damit man weiß, welcher hinterlegt ist. Am besten ein eigener Benutzer nur dafür.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from kern.einstellungen import einstellungen
|
||||||
|
|
||||||
|
from services.homelab import apps, kanal
|
||||||
|
|
||||||
|
ZEITLIMIT = httpx.Timeout(10.0, connect=5.0)
|
||||||
|
CACHE_S = 120
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_cache: dict[str, tuple[float, dict | None]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _pfad() -> Path:
|
||||||
|
return einstellungen().daten_dir / "adguard.json"
|
||||||
|
|
||||||
|
|
||||||
|
def zugang() -> tuple[str, str] | None:
|
||||||
|
"""(Benutzer, Passwort) oder None."""
|
||||||
|
try:
|
||||||
|
daten = json.loads(_pfad().read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError, UnicodeDecodeError):
|
||||||
|
return None
|
||||||
|
if not isinstance(daten, dict):
|
||||||
|
return None
|
||||||
|
benutzer, passwort = daten.get("benutzer"), daten.get("passwort")
|
||||||
|
return (benutzer, passwort) if isinstance(benutzer, str) and isinstance(passwort, str) and benutzer else None
|
||||||
|
|
||||||
|
|
||||||
|
def zugang_speichern(benutzer: str, passwort: str) -> None:
|
||||||
|
"""Atomar und von Anfang an nur für den Dienst lesbar (0600)."""
|
||||||
|
pfad = _pfad()
|
||||||
|
tmp = pfad.with_name(pfad.name + ".tmp")
|
||||||
|
with _lock:
|
||||||
|
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp.unlink(missing_ok=True)
|
||||||
|
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"benutzer": benutzer, "passwort": passwort}, f)
|
||||||
|
os.replace(tmp, pfad)
|
||||||
|
_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def zugang_loeschen() -> bool:
|
||||||
|
with _lock:
|
||||||
|
pfad = _pfad()
|
||||||
|
da = pfad.exists()
|
||||||
|
pfad.unlink(missing_ok=True)
|
||||||
|
_cache.clear()
|
||||||
|
return da
|
||||||
|
|
||||||
|
|
||||||
|
def url() -> str | None:
|
||||||
|
"""Die Oberfläche von AdGuard aus dem Bericht des Ausführers."""
|
||||||
|
for gast in ((kanal.bericht() or {}).get("bericht") or {}).get("gaeste") or []:
|
||||||
|
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
|
||||||
|
if app and app.kennung == "adguard" and (adresse := apps.adresse(app, gast.get("ip"))):
|
||||||
|
return adresse.rstrip("/")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def statistik(adresse: str, benutzer: str, passwort: str) -> dict:
|
||||||
|
"""{"ok": True, "anfragen", "geblockt", "anteil"} oder {"ok": False, "status", "art": abgelehnt|http|netz|antwort}.
|
||||||
|
Nichts davon enthält das Passwort."""
|
||||||
|
try:
|
||||||
|
r = httpx.get(f"{adresse}/control/stats", auth=(benutzer, passwort), timeout=ZEITLIMIT)
|
||||||
|
r.raise_for_status()
|
||||||
|
daten = r.json()
|
||||||
|
anfragen, geblockt = int(daten["num_dns_queries"]), int(daten["num_blocked_filtering"])
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
status = exc.response.status_code
|
||||||
|
return {"ok": False, "status": status, "art": "abgelehnt" if status in (401, 403) else "http"}
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return {"ok": False, "status": None, "art": "netz"}
|
||||||
|
except (ValueError, KeyError, TypeError):
|
||||||
|
return {"ok": False, "status": None, "art": "antwort"}
|
||||||
|
return {"ok": True, "anfragen": anfragen, "geblockt": geblockt,
|
||||||
|
"anteil": round(geblockt / anfragen * 100, 1) if anfragen else None}
|
||||||
|
|
||||||
|
|
||||||
|
def geblockt() -> dict | None:
|
||||||
|
"""Für die Kachel: die Statistik mit dem hinterlegten Zugang, höchstens alle CACHE_S neu gefragt. None ohne Zugang,
|
||||||
|
ohne AdGuard im Bericht oder wenn AdGuard nicht antwortet."""
|
||||||
|
z, adresse = zugang(), url()
|
||||||
|
if not z or not adresse:
|
||||||
|
return None
|
||||||
|
jetzt = time.time()
|
||||||
|
with _lock:
|
||||||
|
if (eintrag := _cache.get(adresse)) and jetzt - eintrag[0] < CACHE_S:
|
||||||
|
return eintrag[1]
|
||||||
|
ergebnis = statistik(adresse, *z)
|
||||||
|
wert = ergebnis if ergebnis["ok"] else None
|
||||||
|
with _lock:
|
||||||
|
_cache[adresse] = (jetzt, wert)
|
||||||
|
return wert
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Was in welchem Container steckt (Community-Scripts-Kennung aus /usr/bin/update im Gast).
|
||||||
|
|
||||||
|
Stand der Bestandsaufnahme vom 24.09.2026: sechs Container, alle mit dem Etikett community-script.
|
||||||
|
|
||||||
|
Wie eine App aktualisiert wird, ist je Community-Script verschieden (nachgelesen am 24.09. in ct/<app>.sh):
|
||||||
|
skript `update` im Gast spielt die App wirklich neu ein (Gitea: neues Programm von GitHub,
|
||||||
|
NetBird: apt aus dem NetBird-Repository, NPMplus: neues Docker-Image)
|
||||||
|
eigene-oberflaeche das Skript verweist auf den eingebauten Updater der App (AdGuard, PVE Scripts Local)
|
||||||
|
pakete die App kommt als Paket; das Update der Pakete im Gast ist ihr Update (PBS)
|
||||||
|
Nur beim Weg „skript“ bietet die Übersicht „Jetzt updaten“ für die App an.
|
||||||
|
Neue Container mit dem Etikett erscheinen von selbst. Fehlt ihre Kennung hier, liest katalog.py (seit 25.09.2026)
|
||||||
|
Name, GitHub-Projekt, Port und Weg aus ihrem Update-Skript; was hier von Hand steht, geht vor (etwa ein geänderter
|
||||||
|
Port wie bei AdGuard).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class App:
|
||||||
|
kennung: str
|
||||||
|
name: str
|
||||||
|
quelle: str | None = None # GitHub owner/repo für die neueste Version; None = über die Pakete des Gasts
|
||||||
|
vergleich: str = "version" # „version“ oder „datum“ (Docker-Images ohne Versionsnummer)
|
||||||
|
port: int | None = None # Weboberfläche; None = keine (z. B. NetBird-Client)
|
||||||
|
https: bool = False
|
||||||
|
pfad: str = "/"
|
||||||
|
weg: str = "skript" # „skript“, „eigene-oberflaeche“ oder „pakete“ (siehe oben)
|
||||||
|
|
||||||
|
|
||||||
|
KATALOG: dict[str, App] = {a.kennung: a for a in (
|
||||||
|
App("adguard", "AdGuard Home", "AdguardTeam/AdGuardHome", port=8080, weg="eigene-oberflaeche"),
|
||||||
|
App("npmplus", "NPMplus", "ZoeyVid/NPMplus", vergleich="datum", port=81, https=True),
|
||||||
|
App("netbird", "NetBird", "netbirdio/netbird"),
|
||||||
|
App("pve-scripts-local", "PVE Scripts Local", "community-scripts/ProxmoxVE-Local", port=3000,
|
||||||
|
weg="eigene-oberflaeche"),
|
||||||
|
App("gitea", "Gitea", "go-gitea/gitea", port=3000),
|
||||||
|
App("proxmox-backup-server", "Proxmox Backup Server", port=8007, https=True, weg="pakete"),
|
||||||
|
)}
|
||||||
|
|
||||||
|
# Gäste, die keine Community-Scripts sind, erkennt man am Namen.
|
||||||
|
NACH_NAME: dict[str, App] = {
|
||||||
|
"arcane": App("arcane", "Arcane (Docker)", port=3552, weg="eigene-oberflaeche"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def app_fuer(kennung: str | None, name: str | None) -> App | None:
|
||||||
|
if kennung and kennung in KATALOG:
|
||||||
|
return KATALOG[kennung]
|
||||||
|
if kennung:
|
||||||
|
from services.homelab import katalog # der Katalog baut Apps aus diesem Modul — erst hier
|
||||||
|
if gefunden := katalog.app(kennung):
|
||||||
|
return gefunden
|
||||||
|
return NACH_NAME.get(str(name or "").lower())
|
||||||
|
|
||||||
|
|
||||||
|
def adresse(app: App | None, ip: str | None) -> str | None:
|
||||||
|
"""Adresse der Weboberfläche, falls es eine gibt und die IP bekannt ist."""
|
||||||
|
if not app or not app.port or not ip:
|
||||||
|
return None
|
||||||
|
return f"{'https' if app.https else 'http'}://{ip}:{app.port}{app.pfad}"
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
"""Arcane und Docker auf der Arcane-VM (Phase 3/4, 24.09.2026).
|
||||||
|
|
||||||
|
Arcane (getarcaneapp/arcane) verwaltet die Docker-Container der VM (NerdQuiz, Rippy …) und hat eine
|
||||||
|
eigene Schnittstelle (OpenAPI unter /api/openapi.json). Öffentlich ist nur die eigene Version
|
||||||
|
(/api/app-version mit currentVersion, newestVersion, updateAvailable). Alles über Images braucht einen
|
||||||
|
API-Schlüssel (Kopfzeile X-API-Key), den man in Arcane anlegt.
|
||||||
|
|
||||||
|
Der Schlüssel (seit 24.09.2026 auch über die Oberfläche): MC_ARCANE_KEY in /etc/mc2/homelab.env geht vor, sonst
|
||||||
|
<Datenordner>/arcane.key (0600, schreibt nur die Oberfläche nach einer Prüfung gegen Arcane, einstellungen.py).
|
||||||
|
Beides wird bei jedem Zugriff gelesen: Ein neuer Schlüssel gilt ohne Neustart. Er steht nie in einer Antwort,
|
||||||
|
einem Protokoll oder einer Fehlermeldung.
|
||||||
|
|
||||||
|
Docker-Updates laufen über Arcanes eigenen Updater (POST /environments/{id}/updater/run). User-Entscheid
|
||||||
|
24.09.: zuerst nur als Probelauf (dryRun); echt erst, wenn der Schalter in den Einstellungen an ist
|
||||||
|
(<Datenordner>/homelab-einstellungen.json). Setzt die Umgebung MC_ARCANE_ECHT, gewinnt sie („1“ = echt).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from kern.einstellungen import einstellungen
|
||||||
|
from kern.zeit import LOCAL_TZ
|
||||||
|
|
||||||
|
ZEITLIMIT = httpx.Timeout(20.0, connect=5.0)
|
||||||
|
CACHE_S = 120
|
||||||
|
_cache: dict[str, tuple[float, object]] = {}
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_datei_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _gemerkt(schluessel: str, holen):
|
||||||
|
"""Arcane merkt sich den Update-Stand selbst; die Übersicht fragt ihn höchstens alle zwei Minuten ab."""
|
||||||
|
jetzt = time.time()
|
||||||
|
with _lock:
|
||||||
|
if (eintrag := _cache.get(schluessel)) and jetzt - eintrag[0] < CACHE_S:
|
||||||
|
return eintrag[1]
|
||||||
|
wert = holen()
|
||||||
|
with _lock:
|
||||||
|
_cache[schluessel] = (jetzt, wert)
|
||||||
|
return wert
|
||||||
|
|
||||||
|
|
||||||
|
def cache_leeren() -> None:
|
||||||
|
"""Nach einem neuen oder gelöschten Schlüssel: Die Übersicht fragt Arcane gleich neu."""
|
||||||
|
with _lock:
|
||||||
|
_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Schlüssel ----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _schluessel_pfad() -> Path:
|
||||||
|
return einstellungen().daten_dir / "arcane.key"
|
||||||
|
|
||||||
|
|
||||||
|
def _schluessel_datei() -> str:
|
||||||
|
try:
|
||||||
|
return _schluessel_pfad().read_text(encoding="utf-8").strip()
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def schluessel() -> str:
|
||||||
|
"""Der geltende Schlüssel: MC_ARCANE_KEY, sonst die Datei, sonst leer."""
|
||||||
|
return os.environ.get("MC_ARCANE_KEY", "").strip() or _schluessel_datei()
|
||||||
|
|
||||||
|
|
||||||
|
def schluessel_quelle() -> str:
|
||||||
|
"""„umgebung“ (MC_ARCANE_KEY), „hinterlegt“ (Datei im Datenordner) oder „fehlt“."""
|
||||||
|
if os.environ.get("MC_ARCANE_KEY", "").strip():
|
||||||
|
return "umgebung"
|
||||||
|
return "hinterlegt" if _schluessel_datei() else "fehlt"
|
||||||
|
|
||||||
|
|
||||||
|
def schluessel_speichern(wert: str) -> None:
|
||||||
|
"""Atomar und von Anfang an nur für den Dienst lesbar (0600): erst eine Nachbardatei, dann ersetzen."""
|
||||||
|
pfad = _schluessel_pfad()
|
||||||
|
tmp = pfad.with_name(pfad.name + ".tmp")
|
||||||
|
with _datei_lock:
|
||||||
|
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp.unlink(missing_ok=True)
|
||||||
|
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||||
|
f.write(wert.strip() + "\n")
|
||||||
|
os.replace(tmp, pfad)
|
||||||
|
cache_leeren()
|
||||||
|
|
||||||
|
|
||||||
|
def schluessel_loeschen() -> bool:
|
||||||
|
"""Die Datei weg. True, wenn es eine gab."""
|
||||||
|
with _datei_lock:
|
||||||
|
pfad = _schluessel_pfad()
|
||||||
|
da = pfad.exists()
|
||||||
|
pfad.unlink(missing_ok=True)
|
||||||
|
cache_leeren()
|
||||||
|
return da
|
||||||
|
|
||||||
|
|
||||||
|
# --- Echt statt Probelauf -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _einstellungen_pfad() -> Path:
|
||||||
|
return einstellungen().daten_dir / "homelab-einstellungen.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _einstellungen_lesen() -> dict:
|
||||||
|
try:
|
||||||
|
daten = json.loads(_einstellungen_pfad().read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
return daten if isinstance(daten, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _echt_eingestellt() -> bool | None:
|
||||||
|
arcane = _einstellungen_lesen().get("arcane")
|
||||||
|
wert = arcane.get("echt") if isinstance(arcane, dict) else None
|
||||||
|
return wert if isinstance(wert, bool) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _echt_umgebung() -> str:
|
||||||
|
return os.environ.get("MC_ARCANE_ECHT", "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def echt() -> bool:
|
||||||
|
"""Echte Docker-Updates statt Probelauf? Die Umgebung (MC_ARCANE_ECHT) gewinnt, dann der Schalter, sonst nein."""
|
||||||
|
if umgebung := _echt_umgebung():
|
||||||
|
return umgebung == "1"
|
||||||
|
return bool(_echt_eingestellt())
|
||||||
|
|
||||||
|
|
||||||
|
def echt_quelle() -> str:
|
||||||
|
"""„umgebung“, „einstellung“ oder „standard“ (Probelauf)."""
|
||||||
|
if _echt_umgebung():
|
||||||
|
return "umgebung"
|
||||||
|
return "einstellung" if _echt_eingestellt() is not None else "standard"
|
||||||
|
|
||||||
|
|
||||||
|
def echt_speichern(an: bool) -> None:
|
||||||
|
pfad = _einstellungen_pfad()
|
||||||
|
with _datei_lock:
|
||||||
|
daten = _einstellungen_lesen()
|
||||||
|
arcane = daten.get("arcane") if isinstance(daten.get("arcane"), dict) else {}
|
||||||
|
daten.update(arcane={**arcane, "echt": bool(an)},
|
||||||
|
geaendert=datetime.now(LOCAL_TZ).isoformat(timespec="seconds"))
|
||||||
|
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = pfad.with_name(pfad.name + ".tmp")
|
||||||
|
tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||||
|
os.replace(tmp, pfad)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Schnittstelle ------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def app_version(url: str) -> dict | None:
|
||||||
|
"""Arcanes eigene Version (öffentlich): {"installiert", "neu", "update"} oder None."""
|
||||||
|
return _gemerkt(f"version:{url}", lambda: _app_version(url))
|
||||||
|
|
||||||
|
|
||||||
|
def _app_version(url: str) -> dict | None:
|
||||||
|
try:
|
||||||
|
d = httpx.get(f"{url}/api/app-version", timeout=ZEITLIMIT).json()
|
||||||
|
except (httpx.HTTPError, ValueError):
|
||||||
|
return None
|
||||||
|
if not isinstance(d, dict) or not d.get("currentVersion"):
|
||||||
|
return None
|
||||||
|
return {"installiert": str(d["currentVersion"]).lstrip("v"), "neu": str(d.get("newestVersion") or "").lstrip("v") or None,
|
||||||
|
"update": bool(d.get("updateAvailable"))}
|
||||||
|
|
||||||
|
|
||||||
|
def _get(url: str, pfad: str, mit_schluessel: str | None = None) -> object:
|
||||||
|
r = httpx.get(f"{url}/api{pfad}", headers={"X-API-Key": mit_schluessel or schluessel()}, timeout=ZEITLIMIT)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def umgebungen(url: str, mit_schluessel: str | None = None) -> list[dict]:
|
||||||
|
daten = _get(url, "/environments?limit=50", mit_schluessel)
|
||||||
|
return [e for e in (daten or {}).get("data") or [] if isinstance(e, dict) and e.get("id") is not None]
|
||||||
|
|
||||||
|
|
||||||
|
def images_mit_update(url: str) -> list[dict]:
|
||||||
|
"""Images mit einem neueren Stand in der Registry, über alle Umgebungen: {"name", "aktuell", "neu", "benutzt"}."""
|
||||||
|
return _gemerkt(f"images:{url}", lambda: _images_mit_update(url))
|
||||||
|
|
||||||
|
|
||||||
|
def _images_mit_update(url: str) -> list[dict]:
|
||||||
|
ergebnis = []
|
||||||
|
for umgebung in umgebungen(url):
|
||||||
|
daten = _get(url, f"/environments/{umgebung['id']}/images?limit=500")
|
||||||
|
for image in (daten or {}).get("data") or []:
|
||||||
|
info = image.get("updateInfo") or {}
|
||||||
|
if not info.get("hasUpdate"):
|
||||||
|
continue
|
||||||
|
name = f"{image.get('repo') or '?'}:{image.get('tag') or 'latest'}"
|
||||||
|
ergebnis.append({"name": name, "aktuell": info.get("currentVersion") or None,
|
||||||
|
"neu": info.get("latestVersion") or None, "umgebung": umgebung["id"],
|
||||||
|
"benutzt": [str(u.get("name") if isinstance(u, dict) else u) for u in image.get("usedBy") or []]})
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def container_liste(url: str) -> list[dict] | None:
|
||||||
|
"""Alle Container über alle Umgebungen, wie Arcane sie liefert (Docker-Dienste einzeln, seit 25.09.2026). None
|
||||||
|
ohne Schlüssel oder wenn Arcane nicht antwortet."""
|
||||||
|
if not schluessel():
|
||||||
|
return None
|
||||||
|
return _gemerkt(f"container-liste:{url}", lambda: _container_liste(url))
|
||||||
|
|
||||||
|
|
||||||
|
def _container_liste(url: str) -> list[dict] | None:
|
||||||
|
ergebnis: list[dict] = []
|
||||||
|
try:
|
||||||
|
for umgebung in umgebungen(url):
|
||||||
|
daten = _get(url, f"/environments/{umgebung['id']}/containers?limit=500")
|
||||||
|
ergebnis += [c for c in (daten or {}).get("data") or [] if isinstance(c, dict)]
|
||||||
|
except (httpx.HTTPError, ValueError, TypeError, AttributeError):
|
||||||
|
return None
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def luecken(url: str, image_ids: tuple[str, ...]) -> dict[str, dict]:
|
||||||
|
"""Arcanes Lücken-Scan (Trivy) je Image: {imageId: {"kritisch", "hoch", "mittel", "gesamt", "zeit"}} — nur für
|
||||||
|
gescannte Images. Leer ohne Schlüssel, ohne Scan oder wenn Arcane nicht antwortet (seit 25.09.2026)."""
|
||||||
|
if not schluessel() or not image_ids:
|
||||||
|
return {}
|
||||||
|
return _gemerkt(f"luecken:{url}:{','.join(image_ids)}", lambda: _luecken(url, list(image_ids))) or {}
|
||||||
|
|
||||||
|
|
||||||
|
def _luecken(url: str, image_ids: list[str]) -> dict[str, dict]:
|
||||||
|
ergebnis: dict[str, dict] = {}
|
||||||
|
try:
|
||||||
|
for umgebung in umgebungen(url):
|
||||||
|
r = httpx.post(f"{url}/api/environments/{umgebung['id']}/images/vulnerabilities/summaries",
|
||||||
|
headers={"X-API-Key": schluessel()}, json={"imageIds": image_ids}, timeout=ZEITLIMIT)
|
||||||
|
r.raise_for_status()
|
||||||
|
for image_id, s in (((r.json() or {}).get("data") or {}).get("summaries") or {}).items():
|
||||||
|
zahlen = (s or {}).get("summary") or {}
|
||||||
|
if s and s.get("scanTime") and zahlen:
|
||||||
|
ergebnis[image_id] = {"kritisch": int(zahlen.get("critical") or 0), "hoch": int(zahlen.get("high") or 0),
|
||||||
|
"mittel": int(zahlen.get("medium") or 0), "gesamt": int(zahlen.get("total") or 0),
|
||||||
|
"zeit": s.get("scanTime")}
|
||||||
|
except (httpx.HTTPError, ValueError, TypeError, AttributeError):
|
||||||
|
return {}
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def container_zahlen(url: str) -> dict | None:
|
||||||
|
"""Laufende und alle Container über alle Umgebungen, für die Kachel (seit 25.09.2026): {"laufend", "gesamt"}.
|
||||||
|
None ohne Schlüssel oder wenn Arcane nicht antwortet."""
|
||||||
|
if not schluessel():
|
||||||
|
return None
|
||||||
|
return _gemerkt(f"container:{url}", lambda: _container_zahlen(url))
|
||||||
|
|
||||||
|
|
||||||
|
def _container_zahlen(url: str) -> dict | None:
|
||||||
|
laufend = gesamt = 0
|
||||||
|
try:
|
||||||
|
for umgebung in umgebungen(url):
|
||||||
|
daten = (_get(url, f"/environments/{umgebung['id']}/containers/counts") or {}).get("data") or {}
|
||||||
|
laufend += int(daten.get("runningContainers") or 0)
|
||||||
|
gesamt += int(daten.get("totalContainers") or 0)
|
||||||
|
except (httpx.HTTPError, ValueError, TypeError, AttributeError):
|
||||||
|
return None
|
||||||
|
return {"laufend": laufend, "gesamt": gesamt}
|
||||||
|
|
||||||
|
|
||||||
|
def schluessel_pruefen(url: str, wert: str) -> dict:
|
||||||
|
"""Nimmt Arcane diesen Schlüssel an? Geprüft wird mit denselben Aufrufen, die die Übersicht braucht: die
|
||||||
|
Umgebungen, dann ihre Images. {"ok": True, "umgebungen", "images", "mit_update"} oder {"ok": False, "status":
|
||||||
|
HTTP-Code bzw. None, "art": "abgelehnt" | "http" | "netz" | "antwort"}. Nichts davon enthält den Schlüssel."""
|
||||||
|
try:
|
||||||
|
liste = umgebungen(url, wert)
|
||||||
|
images = mit_update = 0
|
||||||
|
for umgebung in liste:
|
||||||
|
daten = _get(url, f"/environments/{umgebung['id']}/images?limit=500", wert)
|
||||||
|
eintraege = [i for i in (daten or {}).get("data") or [] if isinstance(i, dict)]
|
||||||
|
images += len(eintraege)
|
||||||
|
mit_update += sum(1 for i in eintraege if (i.get("updateInfo") or {}).get("hasUpdate"))
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
status = exc.response.status_code
|
||||||
|
return {"ok": False, "status": status, "art": "abgelehnt" if status in (401, 403) else "http"}
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return {"ok": False, "status": None, "art": "netz"}
|
||||||
|
except (ValueError, AttributeError, TypeError):
|
||||||
|
return {"ok": False, "status": None, "art": "antwort"}
|
||||||
|
return {"ok": True, "umgebungen": len(liste), "images": images, "mit_update": mit_update}
|
||||||
|
|
||||||
|
|
||||||
|
def updater(url: str, probelauf: bool) -> dict:
|
||||||
|
"""Arcanes Updater für alle Umgebungen. Rückgabe: je Umgebung das Ergebnis (geprüft, aktualisiert, Fehler)."""
|
||||||
|
ergebnisse = {}
|
||||||
|
for umgebung in umgebungen(url):
|
||||||
|
r = httpx.post(f"{url}/api/environments/{umgebung['id']}/updater/run", headers={"X-API-Key": schluessel()},
|
||||||
|
json={"dryRun": probelauf}, timeout=httpx.Timeout(600.0, connect=5.0))
|
||||||
|
r.raise_for_status()
|
||||||
|
ergebnisse[str(umgebung.get("name") or umgebung["id"])] = (r.json() or {}).get("data") or {}
|
||||||
|
return ergebnisse
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Docker-Dienste unter Arcane einzeln (Ausbauplan Welle 2, Punkt 8, seit 25.09.2026).
|
||||||
|
|
||||||
|
Bis zum 25.09. stand im Orchestrator nur „Arcane (Docker) · 11 Images“. Jetzt fragt er Arcane nach jedem Container
|
||||||
|
(GET /environments/{id}/containers, mit dem Schlüssel aus den Einstellungen, höchstens alle zwei Minuten) und zeigt je
|
||||||
|
Stack (Compose-Projekt) Zustand, Gesundheit, Image und Update-Stand:
|
||||||
|
• „Update verfügbar“, wenn Arcane für das Image einen neueren Stand kennt,
|
||||||
|
• „lokal gebaut“, wenn das Image aus keiner Registry kommt (Arcane: updateType „local“, oder die Registry kennt es
|
||||||
|
nicht — NerdQuiz und Rippy baut der User selbst) — dort gibt es keinen Abgleich,
|
||||||
|
• sonst „aktuell“.
|
||||||
|
Seit 25.09.2026 (Punkt 11) steht je Container auch, was Arcanes Lücken-Scan (Trivy, sonntags 02:00) im Image gefunden
|
||||||
|
hat — kritische und hohe zuerst; ungescannte Images haben keine Angabe.
|
||||||
|
Der Wächter meldet gelb, wenn ein Container ungesund ist (Healthcheck) oder immer wieder neu startet; beendete
|
||||||
|
Container (etwa einmalige Aufgaben) sind kein Befund. Neue Stacks erscheinen unter „Neu entdeckt“ (entdecken.py).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
from services.homelab import apps, arcane, einstellungen, kanal
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_GESUND = re.compile(r"\((healthy|unhealthy|health: starting)\)")
|
||||||
|
_LOKAL = re.compile(r"distribution inspect|manifest unknown|pull access denied|repository does not exist", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def container_zeile(c: dict) -> dict:
|
||||||
|
"""Ein Container aus Arcanes Liste in der Form der Oberfläche."""
|
||||||
|
labels = c.get("labels") if isinstance(c.get("labels"), dict) else {}
|
||||||
|
info = c.get("updateInfo") if isinstance(c.get("updateInfo"), dict) else {}
|
||||||
|
gesund = _GESUND.search(str(c.get("status") or ""))
|
||||||
|
fehler = str(info.get("error") or "")
|
||||||
|
if info.get("hasUpdate"):
|
||||||
|
update = "neu"
|
||||||
|
elif info.get("updateType") == "local" or _LOKAL.search(fehler):
|
||||||
|
update = "lokal"
|
||||||
|
elif fehler:
|
||||||
|
update = "unbekannt"
|
||||||
|
else:
|
||||||
|
update = "aktuell"
|
||||||
|
name = str((c.get("names") or ["?"])[0]).lstrip("/")
|
||||||
|
return {"id": str(c.get("id") or name)[:12], "image_id": c.get("imageId"), "luecken": None,
|
||||||
|
"name": name, "stack": labels.get("com.docker.compose.project"),
|
||||||
|
"dienst": labels.get("com.docker.compose.service"), "image": c.get("image"),
|
||||||
|
"zustand": str(c.get("state") or "unbekannt"), "status": c.get("status"),
|
||||||
|
"gesund": {"healthy": True, "unhealthy": False}.get(gesund.group(1)) if gesund else None,
|
||||||
|
"update": update, "aktuell": info.get("currentVersion") or None, "neu": info.get("latestVersion") or None}
|
||||||
|
|
||||||
|
|
||||||
|
def lage() -> dict:
|
||||||
|
"""Für GET /api/homelab/docker: {"verfuegbar", "grund", "stacks": [{"name", "container": […]}]}."""
|
||||||
|
url = einstellungen.arcane_url()
|
||||||
|
if not url:
|
||||||
|
return {"verfuegbar": False, "grund": "Arcane steht nicht im Bericht des Ausführers.", "stacks": []}
|
||||||
|
if not arcane.schluessel():
|
||||||
|
return {"verfuegbar": False, "grund": "Es fehlt der Arcane-API-Schlüssel (Einstellungen → Homelab).", "stacks": []}
|
||||||
|
roh = arcane.container_liste(url)
|
||||||
|
if roh is None:
|
||||||
|
return {"verfuegbar": False, "grund": "Arcane antwortet gerade nicht.", "stacks": []}
|
||||||
|
stacks: dict[str, list[dict]] = {}
|
||||||
|
zeilen = [container_zeile(c) for c in roh]
|
||||||
|
befunde = arcane.luecken(url, tuple(sorted({z["image_id"] for z in zeilen if z["image_id"]})))
|
||||||
|
for zeile in zeilen:
|
||||||
|
zeile["luecken"] = befunde.get(zeile["image_id"] or "")
|
||||||
|
stacks.setdefault(zeile["stack"] or zeile["name"], []).append(zeile)
|
||||||
|
return {"verfuegbar": True, "grund": None,
|
||||||
|
"stacks": [{"name": n, "container": sorted(cs, key=lambda z: z["name"])} for n, cs in sorted(stacks.items())]}
|
||||||
|
|
||||||
|
|
||||||
|
def arcane_ziel() -> str:
|
||||||
|
"""Die Ziel-ID der Arcane-VM (für die Hinweise), aus dem Bericht; sonst „arcane“."""
|
||||||
|
for g in ((kanal.bericht() or {}).get("bericht") or {}).get("gaeste") or []:
|
||||||
|
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
|
||||||
|
if app and app.kennung == "arcane":
|
||||||
|
return f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g['vmid']}"
|
||||||
|
return "arcane"
|
||||||
|
|
||||||
|
|
||||||
|
def pruefe_docker() -> list:
|
||||||
|
"""Wächter: gelb, wenn ein Container ungesund ist oder ständig neu startet. Ohne Arcane oder Schlüssel: nichts."""
|
||||||
|
from services.waechter import Befund # der Wächter registriert diese Prüfung, also erst hier
|
||||||
|
befunde = []
|
||||||
|
quelle = None
|
||||||
|
for s in lage()["stacks"]:
|
||||||
|
for c in s["container"]:
|
||||||
|
if c["gesund"] is False:
|
||||||
|
titel, text = (f"Docker-Dienst {c['name']} ist ungesund",
|
||||||
|
f"Sein Healthcheck schlägt fehl ({c['status']}). In Arcane: Container → {c['name']} → Logs.")
|
||||||
|
elif c["zustand"] == "restarting":
|
||||||
|
titel, text = (f"Docker-Dienst {c['name']} startet immer wieder neu",
|
||||||
|
f"Er kommt nicht hoch ({c['status']}). In Arcane: Container → {c['name']} → Logs.")
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
quelle = quelle or arcane_ziel()
|
||||||
|
befunde.append(Befund(id=f"docker:{c['name']}", stufe="gelb", titel=titel, text=text, quelle=quelle))
|
||||||
|
return befunde
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"""Einstellungen des Homelab-Teils (24.09.2026): was sich unter „Einstellungen“ für das Homelab einstellen lässt.
|
||||||
|
|
||||||
|
• Arcane-API-Schlüssel eintragen oder löschen. Vor dem Speichern prüft Arcane ihn mit denselben Aufrufen, die die
|
||||||
|
Übersicht braucht; lehnt es ab oder antwortet nicht, wird nichts gespeichert. Er liegt in
|
||||||
|
<Datenordner>/arcane.key (0600); MC_ARCANE_KEY in /etc/mc2/homelab.env geht vor (arcane.py).
|
||||||
|
• Docker echt echte Docker-Updates über Arcane statt Probelauf (<Datenordner>/homelab-einstellungen.json);
|
||||||
|
MC_ARCANE_ECHT in der Umgebung gewinnt.
|
||||||
|
• AdGuard-Zugang (seit 25.09.2026) Benutzer und Passwort für die Statistik („% geblockt“ auf der Kachel). Vor
|
||||||
|
dem Speichern fragt AdGuard einmal damit; lehnt es ab, wird nichts gespeichert (adguard.py).
|
||||||
|
stand() zeigt dazu die Wartezeit nach Skriptänderungen (karenz.py) und den Ausführer (verbunden, Nur-Lesen,
|
||||||
|
Sicherungsspeicher). Der Schlüssel verlässt diesen Teil nie: Keine Antwort, kein Protokolleintrag und keine
|
||||||
|
Fehlermeldung enthält ihn, auch kein Stück davon.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
|
||||||
|
from services.homelab import adguard, apps, arcane, inventar, kanal, karenz
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
UMGEBUNG_SCHLUESSEL = ("Der Arcane-Schlüssel kommt aus der Umgebung (MC_ARCANE_KEY in /etc/mc2/homelab.env) und "
|
||||||
|
"gilt vor einem hier eingetragenen. Erst dort entfernen, dann hier eintragen.")
|
||||||
|
_FORM = re.compile(r"[\x21-\x7e]{8,512}") # druckbares ASCII ohne Leerzeichen, wie ihn Arcane ausgibt
|
||||||
|
|
||||||
|
|
||||||
|
def arcane_url() -> str | None:
|
||||||
|
"""Adresse der Arcane-Oberfläche aus dem Bericht des Ausführers (die VM, deren Name „arcane“ ist)."""
|
||||||
|
eingang = kanal.bericht()
|
||||||
|
for gast in ((eingang or {}).get("bericht") or {}).get("gaeste") or []:
|
||||||
|
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
|
||||||
|
if app and app.kennung == "arcane" and (url := apps.adresse(app, gast.get("ip"))):
|
||||||
|
return url.rstrip("/")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def stand() -> dict:
|
||||||
|
"""GET /api/homelab/einstellungen."""
|
||||||
|
zuletzt = kanal.zuletzt()
|
||||||
|
host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}
|
||||||
|
sicherung = host.get("sicherung") if isinstance(host.get("sicherung"), dict) else {}
|
||||||
|
return {
|
||||||
|
"arcane": {"url": arcane_url() or "", "schluessel": arcane.schluessel_quelle(), "echt": arcane.echt(),
|
||||||
|
"echt_quelle": arcane.echt_quelle()},
|
||||||
|
"adguard": {"url": adguard.url() or "", "zugang": "hinterlegt" if (z := adguard.zugang()) else "fehlt",
|
||||||
|
"benutzer": z[0] if z else None},
|
||||||
|
"karenz_h": round(karenz.karenz_s() / 3600),
|
||||||
|
"ausfuehrer": {"verbunden": bool(zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S),
|
||||||
|
"zuletzt": zuletzt, "nur_lesen": bool(host.get("nur_lesen")),
|
||||||
|
"sicherung_speicher": sicherung.get("speicher") or None},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def schluessel_aus_body(roh: bytes) -> str | None:
|
||||||
|
"""Der Schlüssel aus dem Body {"schluessel": "…"} — ohne Pydantic, dessen Fehlermeldung (422) die Eingabe
|
||||||
|
wiederholen würde. None, wenn der Body nicht passt."""
|
||||||
|
try:
|
||||||
|
daten = json.loads(roh.decode("utf-8")) if roh else None
|
||||||
|
except (UnicodeDecodeError, ValueError):
|
||||||
|
return None
|
||||||
|
wert = daten.get("schluessel") if isinstance(daten, dict) else None
|
||||||
|
return wert if isinstance(wert, str) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _abgelehnt(ergebnis: dict, url: str) -> str:
|
||||||
|
status, art = ergebnis.get("status"), ergebnis.get("art")
|
||||||
|
if art == "abgelehnt":
|
||||||
|
text = f"Arcane lehnt den Schlüssel ab (HTTP {status})."
|
||||||
|
if status == 403:
|
||||||
|
text += " Ihm fehlt das Recht, Umgebungen und Images zu lesen."
|
||||||
|
return text
|
||||||
|
if art == "http":
|
||||||
|
return f"Arcane antwortet mit HTTP {status}. Der Schlüssel ist nicht gespeichert."
|
||||||
|
if art == "netz":
|
||||||
|
return f"Arcane ist nicht erreichbar ({url}). Der Schlüssel ist nicht gespeichert."
|
||||||
|
return "Arcane antwortet nicht wie erwartet. Der Schlüssel ist nicht gespeichert."
|
||||||
|
|
||||||
|
|
||||||
|
def arcane_schluessel_setzen(wert: str | None) -> dict:
|
||||||
|
"""POST /api/homelab/einstellungen/arcane-schluessel: erst gegen Arcane prüfen, dann speichern."""
|
||||||
|
if arcane.schluessel_quelle() == "umgebung":
|
||||||
|
return {"ok": False, "detail": UMGEBUNG_SCHLUESSEL}
|
||||||
|
wert = (wert or "").strip()
|
||||||
|
if not _FORM.fullmatch(wert):
|
||||||
|
return {"ok": False, "detail": "Das sieht nicht wie ein Arcane-API-Schlüssel aus (leer, zu kurz oder mit "
|
||||||
|
"Leerzeichen). Nichts gespeichert."}
|
||||||
|
url = arcane_url()
|
||||||
|
if not url:
|
||||||
|
return {"ok": False, "detail": "Arcane steht nicht im Bericht des Ausführers, die Adresse ist unbekannt. "
|
||||||
|
"Nichts gespeichert."}
|
||||||
|
ergebnis = arcane.schluessel_pruefen(url, wert)
|
||||||
|
if not ergebnis["ok"]:
|
||||||
|
log.info("homelab: Arcane-Schlüssel nicht übernommen (%s, HTTP %s)", ergebnis.get("art"), ergebnis.get("status"))
|
||||||
|
return {"ok": False, "detail": _abgelehnt(ergebnis, url)}
|
||||||
|
try:
|
||||||
|
arcane.schluessel_speichern(wert)
|
||||||
|
except OSError as exc:
|
||||||
|
return {"ok": False, "detail": f"Arcane nimmt den Schlüssel an, aber er ließ sich nicht speichern "
|
||||||
|
f"({exc.__class__.__name__})."}
|
||||||
|
images, neu, anzahl = ergebnis["images"], ergebnis["mit_update"], ergebnis["umgebungen"]
|
||||||
|
log.info("homelab: Arcane-Schlüssel gespeichert (%d Images geprüft)", images)
|
||||||
|
return {"ok": True, "text": (f"Arcane nimmt den Schlüssel an: {images} Images in {anzahl} "
|
||||||
|
f"{'Umgebung' if anzahl == 1 else 'Umgebungen'} geprüft, {neu} davon mit neuerem "
|
||||||
|
"Stand. Die Übersicht zeigt die Docker-Images ab sofort.")}
|
||||||
|
|
||||||
|
|
||||||
|
def arcane_schluessel_loeschen() -> dict:
|
||||||
|
"""DELETE /api/homelab/einstellungen/arcane-schluessel: die Datei weg."""
|
||||||
|
try:
|
||||||
|
da = arcane.schluessel_loeschen()
|
||||||
|
except OSError as exc:
|
||||||
|
return {"ok": False, "detail": f"Der Schlüssel ließ sich nicht löschen ({exc.__class__.__name__})."}
|
||||||
|
text = "Der Arcane-Schlüssel ist gelöscht." if da else "Es war kein Arcane-Schlüssel hinterlegt."
|
||||||
|
if arcane.schluessel_quelle() == "umgebung":
|
||||||
|
text += " Die Umgebung setzt weiter einen (MC_ARCANE_KEY); der gilt."
|
||||||
|
return {"ok": True, "text": text}
|
||||||
|
|
||||||
|
|
||||||
|
def arcane_echt_setzen(an: bool) -> dict:
|
||||||
|
"""POST /api/homelab/einstellungen/arcane-echt: echte Docker-Updates statt Probelauf."""
|
||||||
|
if arcane.echt_quelle() == "umgebung":
|
||||||
|
return {"ok": False, "detail": f"Die Umgebung legt das fest (MC_ARCANE_ECHT in /etc/mc2/homelab.env: Docker-"
|
||||||
|
f"Updates laufen {'echt' if arcane.echt() else 'als Probelauf'}). Sie gewinnt "
|
||||||
|
"vor diesem Schalter."}
|
||||||
|
try:
|
||||||
|
arcane.echt_speichern(an)
|
||||||
|
except OSError as exc:
|
||||||
|
return {"ok": False, "detail": f"Der Schalter ließ sich nicht speichern ({exc.__class__.__name__})."}
|
||||||
|
return {"ok": True, "text": ("Docker-Updates über Arcane laufen ab jetzt echt: Arcane holt die neuen Images und "
|
||||||
|
"startet die Container neu." if an else
|
||||||
|
"Docker-Updates über Arcane laufen wieder nur als Probelauf; geändert wird nichts.")}
|
||||||
|
|
||||||
|
|
||||||
|
# --- AdGuard-Zugang (seit 25.09.2026) --------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def zugang_aus_body(roh: bytes) -> tuple[str, str] | None:
|
||||||
|
"""(Benutzer, Passwort) aus dem Body {"benutzer", "passwort"} — ohne Pydantic, dessen 422 die Eingabe wiederholte."""
|
||||||
|
try:
|
||||||
|
daten = json.loads(roh.decode("utf-8")) if roh else None
|
||||||
|
except (UnicodeDecodeError, ValueError):
|
||||||
|
return None
|
||||||
|
if not isinstance(daten, dict):
|
||||||
|
return None
|
||||||
|
benutzer, passwort = daten.get("benutzer"), daten.get("passwort")
|
||||||
|
return (benutzer, passwort) if isinstance(benutzer, str) and isinstance(passwort, str) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _adguard_abgelehnt(ergebnis: dict, adresse: str) -> str:
|
||||||
|
art, status = ergebnis.get("art"), ergebnis.get("status")
|
||||||
|
if art == "abgelehnt":
|
||||||
|
return f"AdGuard lehnt Benutzer oder Passwort ab (HTTP {status}). Nichts gespeichert."
|
||||||
|
if art == "http":
|
||||||
|
return f"AdGuard antwortet mit HTTP {status}. Nichts gespeichert."
|
||||||
|
if art == "netz":
|
||||||
|
return f"AdGuard ist nicht erreichbar ({adresse}). Nichts gespeichert."
|
||||||
|
return "AdGuard antwortet nicht wie erwartet. Nichts gespeichert."
|
||||||
|
|
||||||
|
|
||||||
|
def adguard_zugang_setzen(werte: tuple[str, str] | None) -> dict:
|
||||||
|
"""POST /api/homelab/einstellungen/adguard-zugang: erst bei AdGuard die Statistik holen, dann speichern."""
|
||||||
|
benutzer, passwort = (werte[0].strip(), werte[1]) if werte else ("", "")
|
||||||
|
if not benutzer or not passwort or len(benutzer) > 200 or len(passwort) > 500:
|
||||||
|
return {"ok": False, "detail": "Benutzer und Passwort eintragen. Nichts gespeichert."}
|
||||||
|
adresse = adguard.url()
|
||||||
|
if not adresse:
|
||||||
|
return {"ok": False, "detail": "AdGuard steht nicht im Bericht des Ausführers, die Adresse ist unbekannt. "
|
||||||
|
"Nichts gespeichert."}
|
||||||
|
ergebnis = adguard.statistik(adresse, benutzer, passwort)
|
||||||
|
if not ergebnis["ok"]:
|
||||||
|
log.info("homelab: AdGuard-Zugang nicht übernommen (%s, HTTP %s)", ergebnis.get("art"), ergebnis.get("status"))
|
||||||
|
return {"ok": False, "detail": _adguard_abgelehnt(ergebnis, adresse)}
|
||||||
|
try:
|
||||||
|
adguard.zugang_speichern(benutzer, passwort)
|
||||||
|
except OSError as exc:
|
||||||
|
return {"ok": False, "detail": f"AdGuard nimmt den Zugang an, aber er ließ sich nicht speichern "
|
||||||
|
f"({exc.__class__.__name__})."}
|
||||||
|
anteil = ergebnis["anteil"]
|
||||||
|
return {"ok": True, "text": (f"AdGuard nimmt den Zugang an: {ergebnis['anfragen']} Anfragen"
|
||||||
|
+ (f", davon {str(anteil).replace('.', ',')} % geblockt" if anteil is not None else "")
|
||||||
|
+ ". Die Kachel zeigt den Anteil ab sofort.")}
|
||||||
|
|
||||||
|
|
||||||
|
def adguard_zugang_loeschen() -> dict:
|
||||||
|
"""DELETE /api/homelab/einstellungen/adguard-zugang."""
|
||||||
|
try:
|
||||||
|
da = adguard.zugang_loeschen()
|
||||||
|
except OSError as exc:
|
||||||
|
return {"ok": False, "detail": f"Der Zugang ließ sich nicht löschen ({exc.__class__.__name__})."}
|
||||||
|
return {"ok": True, "text": "Der AdGuard-Zugang ist gelöscht." if da else "Es war kein AdGuard-Zugang hinterlegt."}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
"""„Neu entdeckt“: Geräte, die der Orchestrator noch nicht kennt (Ausbauplan Welle 2, Punkt 7, seit 25.09.2026).
|
||||||
|
|
||||||
|
Jeder Bericht des Ausführers wird mit dem Register <Datenordner>/geraete-bekannt.json verglichen. Taucht ein Gast auf,
|
||||||
|
den es noch nicht gibt (oder dessen Nummer jetzt einen anderen Namen trägt), wird er „neu“: Lucy meldet ihn einmal
|
||||||
|
über Telegram, und das Homelab-Cockpit zeigt ihn als Karte „Neu entdeckt“ mit Vorschlägen — welche App darin steckt
|
||||||
|
(katalog.py), ob ihn ein Sicherungsauftrag erfasst, ob seine Oberfläche geprüft wird. Ein Klick übernimmt ihn:
|
||||||
|
• „Für Updates freigeben“ setzt das Etikett watcher (Ausführer: etikett_setzen) — damit darf der Orchestrator
|
||||||
|
Snapshots anlegen und Updates einspielen. Hat er schon community-script, genügt Übernehmen.
|
||||||
|
• „Nur beobachten“ kein Etikett; er steht in der Liste, Updates gibt es für ihn nicht.
|
||||||
|
• „Nicht anfassen“ setzt watcher-aus: auch ein Community-Script bleibt dann unberührt.
|
||||||
|
Neue Docker-Stacks unter Arcane (Compose-Projekte, docker.py) kommen genauso dazu, mit eigenem Register
|
||||||
|
(docker-bekannt.json) und nur „Übernehmen“ — Etiketten gibt es dort nicht.
|
||||||
|
Beim allerersten Bericht (noch kein Register) gilt alles als bekannt — sonst stünden alle Geräte auf einmal da.
|
||||||
|
Verschwindet ein Gast, fällt er aus dem Register; kommt unter derselben Nummer ein neuer, ist er wieder „neu“.
|
||||||
|
Der eigene Container (Etikett mc2) erscheint nie.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from kern.einstellungen import einstellungen
|
||||||
|
|
||||||
|
from services import announce
|
||||||
|
from services.homelab import apps, kanal
|
||||||
|
from services.homelab.inventar import EIGENES_ETIKETT
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
BETREFF = "[Homelab]"
|
||||||
|
WAHL = {"freigeben": ("watcher", True), "ausschliessen": ("watcher-aus", True), "beobachten": (None, None)}
|
||||||
|
ZEITLIMIT_S = 60
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _pfad(name: str = "geraete-bekannt.json") -> Path:
|
||||||
|
return einstellungen().daten_dir / name
|
||||||
|
|
||||||
|
|
||||||
|
DOCKER = "docker-bekannt.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _lesen(name: str = "geraete-bekannt.json") -> dict | None:
|
||||||
|
try:
|
||||||
|
daten = json.loads(_pfad(name).read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
return daten if isinstance(daten, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _schreiben(daten: dict, name: str = "geraete-bekannt.json") -> None:
|
||||||
|
pfad = _pfad(name)
|
||||||
|
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = pfad.with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||||
|
tmp.replace(pfad)
|
||||||
|
|
||||||
|
|
||||||
|
def _zid(g: dict) -> str:
|
||||||
|
return f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g['vmid']}"
|
||||||
|
|
||||||
|
|
||||||
|
def _anzeigename(g: dict) -> str:
|
||||||
|
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
|
||||||
|
return app.name if app else str(g.get("name") or _zid(g))
|
||||||
|
|
||||||
|
|
||||||
|
def bericht_gesehen(bericht: dict, jetzt: float | None = None, melden=None) -> list[str]:
|
||||||
|
"""Den Bericht mit dem Register abgleichen. Rückgabe: die Ziel-IDs, die gerade neu dazukamen."""
|
||||||
|
jetzt = time.time() if jetzt is None else jetzt
|
||||||
|
melden = melden or _melden
|
||||||
|
gaeste = {_zid(g): g for g in bericht.get("gaeste") or []
|
||||||
|
if isinstance(g, dict) and "vmid" in g and EIGENES_ETIKETT not in (g.get("etiketten") or [])}
|
||||||
|
if not gaeste:
|
||||||
|
return [] # ein leerer oder kaputter Bericht löscht nie das Register
|
||||||
|
with _lock:
|
||||||
|
register = _lesen()
|
||||||
|
if register is None:
|
||||||
|
_schreiben({zid: {"name": g.get("name"), "erstmals": jetzt, "zustand": "bekannt"} for zid, g in gaeste.items()})
|
||||||
|
return []
|
||||||
|
neu = []
|
||||||
|
for zid, g in gaeste.items():
|
||||||
|
alt = register.get(zid)
|
||||||
|
if alt and alt.get("name") == g.get("name"):
|
||||||
|
continue
|
||||||
|
register[zid] = {"name": g.get("name"), "erstmals": jetzt, "zustand": "neu"}
|
||||||
|
neu.append(zid)
|
||||||
|
for zid in [z for z in register if z not in gaeste]:
|
||||||
|
register.pop(zid)
|
||||||
|
_schreiben(register)
|
||||||
|
for zid in neu:
|
||||||
|
g = gaeste[zid]
|
||||||
|
art = "Container" if g.get("art") == "lxc" else "VM"
|
||||||
|
melden(f"Neu im Homelab: {art} {g['vmid']} „{g.get('name') or zid}“ ({_anzeigename(g)}). Ich habe ihn noch "
|
||||||
|
"nicht übernommen — im Homelab-Cockpit unter „Neu entdeckt“ entscheidest du, ob der Orchestrator ihn "
|
||||||
|
"aktualisieren darf.")
|
||||||
|
return neu
|
||||||
|
|
||||||
|
|
||||||
|
def _melden(text: str) -> None:
|
||||||
|
try:
|
||||||
|
announce.notify_telegram(BETREFF, text)
|
||||||
|
except Exception:
|
||||||
|
log.warning("entdecken: Meldung ging nicht raus", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def neue() -> list[dict]:
|
||||||
|
"""Für GET /api/homelab/neu: je neu entdecktem Gerät, was der Orchestrator darüber weiß und vorschlägt."""
|
||||||
|
register = _lesen() or {}
|
||||||
|
eingang = (kanal.bericht() or {}).get("bericht") or {}
|
||||||
|
host = eingang.get("host") or {}
|
||||||
|
ohne_auftrag = set(host.get("nicht_gesichert") or [])
|
||||||
|
liste = []
|
||||||
|
for g in eingang.get("gaeste") or []:
|
||||||
|
zid = _zid(g) if isinstance(g, dict) and "vmid" in g else None
|
||||||
|
if not zid or (register.get(zid) or {}).get("zustand") != "neu":
|
||||||
|
continue
|
||||||
|
kennung = (g.get("app") or {}).get("kennung")
|
||||||
|
app = apps.app_fuer(kennung, g.get("name"))
|
||||||
|
etiketten = list(g.get("etiketten") or [])
|
||||||
|
liste.append({
|
||||||
|
"id": zid, "vmid": g["vmid"], "name": app.name if app else str(g.get("name") or zid),
|
||||||
|
"hostname": g.get("name"), "art": "container" if g.get("art") == "lxc" else "vm",
|
||||||
|
"status": g.get("status"), "erstmals": register[zid].get("erstmals"), "etiketten": etiketten,
|
||||||
|
"freigegeben": bool(g.get("erlaubt")), "ausgeschlossen": "watcher-aus" in etiketten,
|
||||||
|
"app": ({"kennung": kennung, "name": app.name, "quelle": app.quelle, "weg": app.weg,
|
||||||
|
"adresse": apps.adresse(app, g.get("ip"))} if app else None),
|
||||||
|
"community_script": "community-script" in etiketten, "ip": g.get("ip"),
|
||||||
|
"ohne_sicherung": g["vmid"] in ohne_auftrag})
|
||||||
|
return liste
|
||||||
|
|
||||||
|
|
||||||
|
def _markieren(zid: str, name: str = "geraete-bekannt.json") -> None:
|
||||||
|
with _lock:
|
||||||
|
register = _lesen(name) or {}
|
||||||
|
if zid in register:
|
||||||
|
register[zid]["zustand"] = "bekannt"
|
||||||
|
_schreiben(register, name)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Docker-Stacks (seit 25.09.2026) ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def docker_gesehen(stacks: list[dict], jetzt: float | None = None, melden=None) -> list[str]:
|
||||||
|
"""Die Stacks aus docker.lage() mit dem Register abgleichen. Rückgabe: die IDs („docker:<stack>“), die neu sind."""
|
||||||
|
jetzt = time.time() if jetzt is None else jetzt
|
||||||
|
melden = melden or _melden
|
||||||
|
aktuell = {f"docker:{s['name']}": s for s in stacks if s.get("name")}
|
||||||
|
if not aktuell:
|
||||||
|
return []
|
||||||
|
with _lock:
|
||||||
|
register = _lesen(DOCKER)
|
||||||
|
if register is None:
|
||||||
|
_schreiben({sid: {"erstmals": jetzt, "zustand": "bekannt"} for sid in aktuell}, DOCKER)
|
||||||
|
return []
|
||||||
|
neu = [sid for sid in aktuell if sid not in register]
|
||||||
|
for sid in neu:
|
||||||
|
register[sid] = {"erstmals": jetzt, "zustand": "neu"}
|
||||||
|
for sid in [s for s in register if s not in aktuell]:
|
||||||
|
register.pop(sid)
|
||||||
|
_schreiben(register, DOCKER)
|
||||||
|
for sid in neu:
|
||||||
|
namen = ", ".join(c["name"] for c in aktuell[sid].get("container") or [])
|
||||||
|
melden(f"Neu unter Arcane: der Docker-Stack „{aktuell[sid]['name']}“ ({namen}). Im Homelab-Cockpit unter „Neu "
|
||||||
|
"entdeckt“ übernehmen.")
|
||||||
|
return neu
|
||||||
|
|
||||||
|
|
||||||
|
def docker_abgleichen() -> None:
|
||||||
|
"""Nach jedem Bericht im eigenen Faden: Arcane fragen (gemerkt) und neue Stacks eintragen."""
|
||||||
|
from services.homelab import docker
|
||||||
|
try:
|
||||||
|
lage = docker.lage()
|
||||||
|
if lage["verfuegbar"]:
|
||||||
|
docker_gesehen(lage["stacks"])
|
||||||
|
except Exception:
|
||||||
|
log.warning("entdecken: Docker-Abgleich gescheitert", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def neue_docker() -> list[dict]:
|
||||||
|
"""Neue Docker-Stacks für GET /api/homelab/neu."""
|
||||||
|
from services.homelab import docker
|
||||||
|
register = _lesen(DOCKER) or {}
|
||||||
|
offen = {sid: e for sid, e in register.items() if e.get("zustand") == "neu"}
|
||||||
|
if not offen:
|
||||||
|
return []
|
||||||
|
lage = docker.lage()
|
||||||
|
return [{"id": f"docker:{s['name']}", "name": s["name"], "erstmals": offen[f"docker:{s['name']}"].get("erstmals"),
|
||||||
|
"container": [c["name"] for c in s["container"]]}
|
||||||
|
for s in lage["stacks"] if f"docker:{s['name']}" in offen]
|
||||||
|
|
||||||
|
|
||||||
|
def uebernehmen(zid: str, wahl: str) -> dict:
|
||||||
|
"""POST /api/homelab/neu/{zid}: „freigeben“, „beobachten“ oder „ausschliessen“."""
|
||||||
|
if wahl not in WAHL:
|
||||||
|
return {"ok": False, "detail": "Unbekannte Wahl."}
|
||||||
|
if zid.startswith("docker:"):
|
||||||
|
if wahl != "beobachten" or zid not in {s["id"] for s in neue_docker()}:
|
||||||
|
return {"ok": False, "detail": "Dieser Docker-Stack steht nicht (mehr) unter „Neu entdeckt“."}
|
||||||
|
_markieren(zid, DOCKER)
|
||||||
|
return {"ok": True, "text": f"Der Stack {zid.split(':', 1)[1]} ist übernommen."}
|
||||||
|
geraet = next((g for g in neue() if g["id"] == zid), None)
|
||||||
|
if not geraet:
|
||||||
|
return {"ok": False, "detail": "Dieses Gerät steht nicht (mehr) unter „Neu entdeckt“."}
|
||||||
|
etikett, an = WAHL[wahl]
|
||||||
|
schon = (wahl == "freigeben" and geraet["freigegeben"]) or (wahl == "ausschliessen" and geraet["ausgeschlossen"])
|
||||||
|
if etikett and not schon:
|
||||||
|
auftrag = kanal.anlegen("etikett_setzen", {"vmid": geraet["vmid"], "etikett": etikett, "an": an})
|
||||||
|
ergebnis = kanal.warten(auftrag, ZEITLIMIT_S)
|
||||||
|
if not ergebnis or ergebnis.get("status") != "fertig":
|
||||||
|
grund = (ergebnis or {}).get("text") or "Der Ausführer hat nicht rechtzeitig geantwortet."
|
||||||
|
return {"ok": False, "detail": f"Das Etikett ließ sich nicht setzen: {str(grund)[-300:]}"}
|
||||||
|
_markieren(zid)
|
||||||
|
text = {"freigeben": f"{geraet['name']} ist übernommen und für Updates freigegeben.",
|
||||||
|
"beobachten": f"{geraet['name']} ist übernommen; der Orchestrator beobachtet ihn nur.",
|
||||||
|
"ausschliessen": f"{geraet['name']} ist übernommen und bleibt unberührt (Etikett watcher-aus)."}[wahl]
|
||||||
|
return {"ok": True, "text": text + (" Der nächste Bericht zeigt es in der Geräteliste." if etikett else "")}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
"""Vom Bericht des Ausführers zu Zielen im gemeinsamen Modell (kern/ziele.py) — Phase 3, 24.09.2026.
|
||||||
|
|
||||||
|
Je Gerät ein Ziel: der Proxmox-Host (Pakete, Neustart) und jeder freigegebene Gast (Etikett
|
||||||
|
community-script oder watcher, nicht watcher-aus) mit seinen Bausteinen „App“ (Community-Script,
|
||||||
|
Vergleich mit der neuesten Veröffentlichung auf GitHub) und „Pakete“ (Betriebssystem im Gast).
|
||||||
|
Dazu prüft dieser Teil selbst, ob die Weboberfläche jedes Gasts antwortet.
|
||||||
|
|
||||||
|
Wie ehrlich der Stand ist, steht dabei: Sind die Paketlisten im Gast älter als zwei Wochen, heißt es
|
||||||
|
„unbekannt“ (mit Knopf zum Suchen) statt „aktuell“; ist der Bericht älter als eine halbe Stunde, sagt
|
||||||
|
die Übersicht, dass der Ausführer schweigt. Ist das Update-Skript einer App frisch geändert, bleibt sie
|
||||||
|
„neu“, aber ohne Knopf (karenz.py). Rückweg und Rückfrage sagen, was vor dem Update geschieht: Snapshot,
|
||||||
|
wo keiner geht eine Sicherung, sonst keiner. Ohne Freigabe (Etikett) legt der Ausführer weder das eine noch
|
||||||
|
das andere an — das betrifft Arcane, dessen Docker-Updates auch ohne Etikett über Arcane selbst laufen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from kern.github import neueste_version
|
||||||
|
from kern.zeit import LOCAL_TZ
|
||||||
|
from kern.ziele import Aktion, BausteinStand, ZielStand
|
||||||
|
|
||||||
|
from services.homelab import apps, arcane, kanal, karenz, sicherheit
|
||||||
|
|
||||||
|
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:
|
||||||
|
# Seit 25.09.2026: wie viele davon eine Sicherheitslücke schließen (Debian: Suite „-security“, Alpine: secdb).
|
||||||
|
stand.sicherheit = os_stand.get("sicherheit") if "sicherheit" in os_stand else sicherheit.alpine_sicherheit(os_stand)
|
||||||
|
davon = f" (davon {stand.sicherheit} Sicherheitsupdate{'s' if stand.sicherheit != 1 else ''})" \
|
||||||
|
if stand.sicherheit else ""
|
||||||
|
stand.zustand, stand.kurz = "neu", f"{anzahl} Pakete"
|
||||||
|
stand.aktion = Aktion("POST", f"/api/homelab/ziele/{zid}/os-update",
|
||||||
|
f"{anzahl} Pakete{davon} im Gast jetzt einspielen? {_rueckweg_frage(gast)}")
|
||||||
|
return stand
|
||||||
|
|
||||||
|
|
||||||
|
def _arcane_bausteine(basis: str | None, zid: str, gast: dict) -> list[BausteinStand]:
|
||||||
|
"""Arcane selbst (öffentliche Version) und die Docker-Images (mit API-Schlüssel)."""
|
||||||
|
app = arcane.app_version(basis) if basis else None
|
||||||
|
selbst = BausteinStand(id="arcane", name="Arcane", zustand="unbekannt",
|
||||||
|
installiert=app["installiert"] if app else None, verfuegbar=app["neu"] if app else None)
|
||||||
|
if not app:
|
||||||
|
selbst.grund = "Arcane antwortet nicht."
|
||||||
|
elif app["update"]:
|
||||||
|
selbst.zustand, selbst.kurz = "neu", f"{app['installiert']} → {app['neu']}"
|
||||||
|
selbst.grund = "Arcane spielt sein eigenes Update über die eigene Oberfläche ein (Einstellungen)."
|
||||||
|
else:
|
||||||
|
selbst.zustand = "aktuell"
|
||||||
|
docker = BausteinStand(id="docker", name="Docker-Images", zustand="unbekannt")
|
||||||
|
if not arcane.schluessel():
|
||||||
|
docker.grund = ("Es fehlt ein Arcane-API-Schlüssel: Ohne ihn sieht der Orchestrator die Images nicht. "
|
||||||
|
"Eintragen unter Einstellungen (Arcane-API-Schlüssel).")
|
||||||
|
elif basis:
|
||||||
|
try:
|
||||||
|
images = arcane.images_mit_update(basis)
|
||||||
|
except Exception:
|
||||||
|
docker.grund = "Arcane verweigert die Auskunft oder antwortet nicht (Schlüssel prüfen)."
|
||||||
|
else:
|
||||||
|
# Nur Images, die ein Container benutzt: Arcanes Updater tauscht Container aus. Ein veraltetes Image ohne
|
||||||
|
# Container (Basis-Images für Builds, CI) bliebe sonst für immer „neu“ (25.09.2026: 9 von 11 nach dem Lauf).
|
||||||
|
ohne = [i for i in images if not i.get("benutzt")]
|
||||||
|
images = [i for i in images if i.get("benutzt")]
|
||||||
|
if ohne:
|
||||||
|
docker.grund = (f"{len(ohne)} ältere Images ohne Container ({', '.join(i['name'] for i in ohne[:4])}"
|
||||||
|
+ (" …" if len(ohne) > 4 else "") + ") betreffen nichts; aufräumen in Arcane unter Images.")
|
||||||
|
if images:
|
||||||
|
docker.zustand, docker.kurz = "neu", f"{len(images)} Images"
|
||||||
|
docker.grund = ", ".join(i["name"] for i in images[:6]) + (" …" if len(images) > 6 else "")
|
||||||
|
probe = not arcane.echt()
|
||||||
|
docker.aktion = Aktion(
|
||||||
|
"POST", f"/api/homelab/ziele/{zid}/docker",
|
||||||
|
("Arcane prüft im Probelauf, welche Container es aktualisieren würde — geändert wird noch nichts."
|
||||||
|
if probe else "Arcane aktualisiert jetzt die Container mit neuen Images; Daten-Volumes bleiben. "
|
||||||
|
+ _rueckweg_frage(gast)),
|
||||||
|
label="Probelauf" if probe else "Jetzt updaten")
|
||||||
|
else:
|
||||||
|
docker.zustand = "aktuell"
|
||||||
|
return [selbst, docker]
|
||||||
|
|
||||||
|
|
||||||
|
def _status_text(status: object) -> str:
|
||||||
|
"""Warum ein Gast keine Bausteine hat — auf Deutsch. „unknown“ kommt kurz nach dem Hochfahren des Hosts, bevor
|
||||||
|
Proxmox den Zustand kennt (der Ausführer berichtet dann nach einer Minute erneut)."""
|
||||||
|
if status == "stopped":
|
||||||
|
return "Der Gast ist gestoppt."
|
||||||
|
if status == "paused":
|
||||||
|
return "Der Gast ist angehalten."
|
||||||
|
return "Proxmox kennt seinen Zustand gerade nicht (etwa kurz nach dem Hochfahren des Hosts)."
|
||||||
|
|
||||||
|
|
||||||
|
def _gast_ziel(gast: dict, jetzt: float) -> ZielStand:
|
||||||
|
art = "container" if gast["art"] == "lxc" else "vm"
|
||||||
|
zid = f"{'ct' if art == 'container' else 'vm'}-{gast['vmid']}"
|
||||||
|
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
|
||||||
|
url = apps.adresse(app, gast.get("ip"))
|
||||||
|
laeuft = gast.get("status") == "running"
|
||||||
|
ziel = ZielStand(id=zid, name=app.name if app else str(gast.get("name") or zid), art=art, instanz="homelab",
|
||||||
|
erreichbar=(erreichbar(url) if laeuft else False) if url else (laeuft or None),
|
||||||
|
rueckweg=_rueckweg(gast), rueckweg_art=_rueckweg_art(gast), platte=gast.get("platte"))
|
||||||
|
if not laeuft:
|
||||||
|
ziel.bausteine.append(BausteinStand(id="status", name="Status", zustand="unbekannt",
|
||||||
|
grund=_status_text(gast.get("status"))))
|
||||||
|
return ziel
|
||||||
|
if app and app.kennung == "arcane":
|
||||||
|
# Arcane läuft über seine eigene Schnittstelle, nicht über den Ausführer: kein Etikett nötig — nur für den
|
||||||
|
# Snapshot vorher (Rückweg), den legt der Ausführer an.
|
||||||
|
ziel.bausteine += _arcane_bausteine(url.rstrip("/") if url else None, zid, gast)
|
||||||
|
return ziel
|
||||||
|
if not gast.get("erlaubt"):
|
||||||
|
ziel.bausteine.append(BausteinStand(
|
||||||
|
id="freigabe", name="Freigabe", zustand="unbekannt",
|
||||||
|
grund="Nicht freigegeben: Das Etikett „watcher“ fehlt (oder „watcher-aus“ ist gesetzt)."))
|
||||||
|
return ziel
|
||||||
|
if app and app.kennung in apps.KATALOG:
|
||||||
|
ziel.bausteine.append(_app_baustein(gast, app, zid))
|
||||||
|
if gast["art"] == "lxc":
|
||||||
|
ziel.bausteine.append(_os_baustein(gast, zid, jetzt))
|
||||||
|
return ziel
|
||||||
|
|
||||||
|
|
||||||
|
def _host_ziel(host: dict) -> ZielStand:
|
||||||
|
ziel = ZielStand(id="pve", name="Proxmox-Host", art="proxmox-host", instanz="homelab", erreichbar=True,
|
||||||
|
rueckweg="Kein Snapshot möglich; der Host bleibt beim Update in Betrieb, neu gestartet wird getrennt.",
|
||||||
|
rueckweg_art="keiner")
|
||||||
|
updates = host.get("updates")
|
||||||
|
pakete = BausteinStand(id="pakete", name="Proxmox VE und Debian", zustand="aktuell",
|
||||||
|
installiert=host.get("version"))
|
||||||
|
if updates is None:
|
||||||
|
pakete.zustand, pakete.grund = "unbekannt", host.get("fehler") or "Die Update-Liste fehlt im Bericht."
|
||||||
|
elif updates:
|
||||||
|
pakete.zustand, pakete.kurz = "neu", f"{len(updates)} Pakete"
|
||||||
|
if isinstance(host.get("updates_sicherheit"), list):
|
||||||
|
pakete.sicherheit = len(host["updates_sicherheit"])
|
||||||
|
pakete.aktion = Aktion("POST", "/api/homelab/ziele/pve/update",
|
||||||
|
f"{len(updates)} Pakete auf dem Proxmox-Host einspielen? Alle Gäste laufen weiter. "
|
||||||
|
"Einen Neustart löst das nicht aus — der ist ein eigener Knopf.")
|
||||||
|
ziel.bausteine.append(pakete)
|
||||||
|
if host.get("neustart_noetig"):
|
||||||
|
ziel.bausteine.append(BausteinStand(
|
||||||
|
id="neustart", name="Neustart", zustand="neu", kurz="steht an",
|
||||||
|
installiert=host.get("kernel"), verfuegbar=host.get("kernel_neu"),
|
||||||
|
grund=(f"Neuer Kernel {host['kernel_neu']} ist installiert, es läuft noch {host.get('kernel')}."
|
||||||
|
if host.get("kernel_neu") else "Ein Update verlangt einen Neustart."),
|
||||||
|
aktion=Aktion("POST", "/api/homelab/ziele/pve/neustart",
|
||||||
|
"Den Proxmox-Host jetzt neu starten? ALLE Container und die Arcane-VM sind dann ein paar "
|
||||||
|
"Minuten weg. Die KI-Box prüft danach von außen, ob alles wiederkommt.",
|
||||||
|
label="Jetzt neu starten")))
|
||||||
|
return ziel
|
||||||
|
|
||||||
|
|
||||||
|
def ziele() -> dict:
|
||||||
|
"""Alle Ziele des Homelabs aus dem letzten Bericht, dazu wie frisch er ist."""
|
||||||
|
eingang = kanal.bericht()
|
||||||
|
jetzt = time.time()
|
||||||
|
zuletzt = kanal.zuletzt()
|
||||||
|
ausfuehrer = {"verbunden": bool(zuletzt and jetzt - zuletzt < BERICHT_ALT_S), "zuletzt": zuletzt}
|
||||||
|
if not eingang:
|
||||||
|
return {"ziele": [], "bericht": None, "ausfuehrer": ausfuehrer}
|
||||||
|
b = eingang["bericht"]
|
||||||
|
liste = [_host_ziel(b.get("host") or {})]
|
||||||
|
# Der Container des Orchestrators selbst (Etikett „mc2“) ist kein Gerät, das er pflegt.
|
||||||
|
liste += [_gast_ziel(g, jetzt) for g in b.get("gaeste") or [] if EIGENES_ETIKETT not in (g.get("etiketten") or [])]
|
||||||
|
zahlen = _kennzahlen(b)
|
||||||
|
for z in liste:
|
||||||
|
z.geprueft = eingang["empfangen"]
|
||||||
|
z.kennzahl = zahlen.get(z.id)
|
||||||
|
return {"ziele": [z.als_dict() for z in liste],
|
||||||
|
"bericht": {"empfangen": eingang["empfangen"], "veraltet": jetzt - eingang["empfangen"] > BERICHT_ALT_S},
|
||||||
|
"ausfuehrer": ausfuehrer}
|
||||||
|
|
||||||
|
|
||||||
|
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", "etikett_setzen", "changelog"}
|
||||||
|
VERLOREN_S = 3 * 3600 # abgeholt, aber nie beantwortet (Ausführer abgestürzt, Host neu gestartet)
|
||||||
|
BEHALTEN = 500 # so viele erledigte Aufträge bleiben sichtbar (Protokoll; bis 24.09.2026: 60)
|
||||||
|
VOLLTEXT = 60 # die jüngsten erledigten behalten ihre ganze Ausgabe …
|
||||||
|
TEXT_KURZ = 4000 # … ältere nur deren letzte Zeichen
|
||||||
|
BERICHTE_BEHALTEN = 150 # Eingänge der Berichte, die der Ausführer von sich aus schickt (alle 10 min: gut ein Tag)
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_kontakt: dict = {"zuletzt": None}
|
||||||
|
|
||||||
|
|
||||||
|
def _dir() -> Path:
|
||||||
|
return einstellungen().daten_dir
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _sperre() -> Iterator[None]:
|
||||||
|
"""Für Lesen-ändern-schreiben der Auftragsliste: Thread-Sperre, unter Linux zusätzlich flock. Ohne sie
|
||||||
|
überschrieben sich Oberfläche und Steward gegenseitig (ein abgeholter Auftrag stünde wieder auf „wartet“)."""
|
||||||
|
with _lock:
|
||||||
|
if fcntl is None:
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
pfad = _dir() / "ausfuehrer-auftraege.lock"
|
||||||
|
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(pfad, "a", encoding="utf-8") as f:
|
||||||
|
fcntl.flock(f, fcntl.LOCK_EX)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
fcntl.flock(f, fcntl.LOCK_UN)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_schreiben(pfad: Path, daten: object) -> None:
|
||||||
|
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = pfad.with_suffix(pfad.suffix + ".tmp")
|
||||||
|
tmp.write_text(json.dumps(daten, ensure_ascii=False), encoding="utf-8")
|
||||||
|
tmp.replace(pfad)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_lesen(pfad: Path, leer: object) -> object:
|
||||||
|
try:
|
||||||
|
return json.loads(pfad.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return leer
|
||||||
|
|
||||||
|
|
||||||
|
# --- Gemeinsames Geheimnis ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def token() -> str:
|
||||||
|
"""Das Geheimnis des Kanals; beim ersten Aufruf erzeugt (0600). MC_AUSFUEHRER_TOKEN geht vor."""
|
||||||
|
if wert := os.environ.get("MC_AUSFUEHRER_TOKEN", "").strip():
|
||||||
|
return wert
|
||||||
|
pfad = _dir() / "ausfuehrer.token"
|
||||||
|
try:
|
||||||
|
return pfad.read_text(encoding="utf-8").strip()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
wert = secrets.token_urlsafe(32)
|
||||||
|
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
fd = os.open(pfad, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||||
|
except FileExistsError: # gleichzeitig erzeugt: den anderen nehmen
|
||||||
|
return pfad.read_text(encoding="utf-8").strip()
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||||
|
f.write(wert + "\n")
|
||||||
|
return wert
|
||||||
|
|
||||||
|
|
||||||
|
def token_gueltig(wert: str | None) -> bool:
|
||||||
|
return bool(wert) and hmac.compare_digest(str(wert), token())
|
||||||
|
|
||||||
|
|
||||||
|
def kontakt() -> None:
|
||||||
|
_kontakt["zuletzt"] = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
def zuletzt() -> float | None:
|
||||||
|
"""Letztes Lebenszeichen des Ausführers (nach einem Neustart: Zeit des letzten Berichts)."""
|
||||||
|
return _kontakt["zuletzt"] or (bericht() or {}).get("empfangen")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Bericht ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def bericht_speichern(daten: dict, aus_auftrag: bool = False) -> None:
|
||||||
|
"""Den neuesten Bericht ablegen. Schickt ihn der Ausführer von sich aus (alle 10 min und nach jedem Auftrag),
|
||||||
|
wird sein Eingang kurz vermerkt — das Protokoll zeigt ihn auf Wunsch. aus_auftrag: ein Lauf hat ihn als Auftrag
|
||||||
|
„bericht“ geholt (updates.py); dann steht er dort schon."""
|
||||||
|
jetzt = time.time()
|
||||||
|
with _lock:
|
||||||
|
_json_schreiben(_dir() / "pve-bericht.json", {"empfangen": jetzt, "bericht": daten})
|
||||||
|
if aus_auftrag:
|
||||||
|
return
|
||||||
|
host = daten.get("host") if isinstance(daten.get("host"), dict) else {}
|
||||||
|
gaeste = [g for g in daten.get("gaeste") or [] if isinstance(g, dict)]
|
||||||
|
eingang = {"empfangen": jetzt, "host_version": host.get("version"), "gaeste": len(gaeste),
|
||||||
|
"laufend": sum(1 for g in gaeste if g.get("status") == "running"),
|
||||||
|
"host_updates": len(host["updates"]) if isinstance(host.get("updates"), list) else None}
|
||||||
|
pfad = _dir() / "ausfuehrer-berichte.json"
|
||||||
|
eingaenge = _json_lesen(pfad, [])
|
||||||
|
eingaenge = eingaenge if isinstance(eingaenge, list) else []
|
||||||
|
_json_schreiben(pfad, (eingaenge + [eingang])[-BERICHTE_BEHALTEN:])
|
||||||
|
|
||||||
|
|
||||||
|
def berichte() -> list[dict]:
|
||||||
|
"""Die vermerkten Eingänge von Berichten (älteste zuerst): Zeit, Host-Version, Zahl der Gäste und Host-Updates."""
|
||||||
|
with _lock:
|
||||||
|
daten = _json_lesen(_dir() / "ausfuehrer-berichte.json", [])
|
||||||
|
return [e for e in daten if isinstance(e, dict)] if isinstance(daten, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def bericht() -> dict | None:
|
||||||
|
"""{"empfangen": Unix-Sekunden, "bericht": {...}} oder None, solange nie einer kam."""
|
||||||
|
with _lock:
|
||||||
|
daten = _json_lesen(_dir() / "pve-bericht.json", None)
|
||||||
|
return daten if isinstance(daten, dict) and isinstance(daten.get("bericht"), dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Aufträge -------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _pfad() -> Path:
|
||||||
|
return _dir() / "ausfuehrer-auftraege.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _alle() -> list[dict]:
|
||||||
|
daten = _json_lesen(_pfad(), [])
|
||||||
|
return daten if isinstance(daten, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def _merken(auftraege: list[dict]) -> None:
|
||||||
|
offen = [a for a in auftraege if a["status"] in ("wartet", "laeuft")]
|
||||||
|
fertig = [a for a in auftraege if a["status"] not in ("wartet", "laeuft")][-BEHALTEN:]
|
||||||
|
for a in fertig[:-VOLLTEXT]:
|
||||||
|
text = a.get("text")
|
||||||
|
if isinstance(text, str) and len(text) > TEXT_KURZ:
|
||||||
|
a["text"] = "…" + text[-(TEXT_KURZ - 1):]
|
||||||
|
_json_schreiben(_pfad(), sorted(offen + fertig, key=lambda a: a["erstellt"]))
|
||||||
|
|
||||||
|
|
||||||
|
def anlegen(aktion: str, parameter: dict | None = None) -> str:
|
||||||
|
if aktion not in AKTIONEN:
|
||||||
|
raise ValueError(f"Unbekannte Aktion: {aktion}")
|
||||||
|
auftrag = {"id": uuid.uuid4().hex[:12], "aktion": aktion, "parameter": parameter or {},
|
||||||
|
"status": "wartet", "erstellt": time.time(), "abgeholt": None, "fertig": None,
|
||||||
|
"code": None, "text": None}
|
||||||
|
with _sperre():
|
||||||
|
auftraege = _alle()
|
||||||
|
auftraege.append(auftrag)
|
||||||
|
_merken(auftraege)
|
||||||
|
return auftrag["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def naechster() -> dict | None:
|
||||||
|
"""Für den Ausführer: der älteste wartende Auftrag, ab jetzt „läuft“. Hängengebliebene werden
|
||||||
|
vorher als verloren abgeschlossen. Geschrieben wird nur, wenn sich etwas ändert: Der Ausführer fragt alle
|
||||||
|
fünf Sekunden."""
|
||||||
|
jetzt = time.time()
|
||||||
|
with _sperre():
|
||||||
|
auftraege = _alle()
|
||||||
|
geaendert = False
|
||||||
|
for a in auftraege:
|
||||||
|
if a["status"] == "laeuft" and jetzt - (a["abgeholt"] or jetzt) > VERLOREN_S:
|
||||||
|
a.update(status="verloren", fertig=jetzt, text="Der Ausführer hat nie geantwortet.")
|
||||||
|
geaendert = True
|
||||||
|
auftrag = next((a for a in auftraege if a["status"] == "wartet"), None)
|
||||||
|
if auftrag:
|
||||||
|
auftrag.update(status="laeuft", abgeholt=jetzt)
|
||||||
|
geaendert = True
|
||||||
|
if geaendert:
|
||||||
|
_merken(auftraege)
|
||||||
|
return {k: auftrag[k] for k in ("id", "aktion", "parameter")} if auftrag else None
|
||||||
|
|
||||||
|
|
||||||
|
def ergebnis(auftrag_id: str, code: int, text: str) -> bool:
|
||||||
|
with _sperre():
|
||||||
|
auftraege = _alle()
|
||||||
|
auftrag = next((a for a in auftraege if a["id"] == auftrag_id), None)
|
||||||
|
if auftrag is None or auftrag["status"] != "laeuft":
|
||||||
|
return False
|
||||||
|
auftrag.update(status="fertig" if code == 0 else "fehler", fertig=time.time(), code=code,
|
||||||
|
text=str(text)[-20000:])
|
||||||
|
_merken(auftraege)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def auftrag(auftrag_id: str) -> dict | None:
|
||||||
|
# Lesen unter derselben Sperre wie das Schreiben: Unter Windows scheitert das atomare Ersetzen,
|
||||||
|
# solange ein anderer Thread die Datei offen hat.
|
||||||
|
with _lock:
|
||||||
|
return next((a for a in _alle() if a["id"] == auftrag_id), None)
|
||||||
|
|
||||||
|
|
||||||
|
def warten(auftrag_id: str, zeitlimit_s: float, takt_s: float = 2.0) -> dict | None:
|
||||||
|
"""Bis der Auftrag fertig ist (oder das Zeitlimit abläuft). Rückgabe: der Auftrag, None bei Zeitablauf."""
|
||||||
|
ende = time.time() + zeitlimit_s
|
||||||
|
while time.time() < ende:
|
||||||
|
a = auftrag(auftrag_id)
|
||||||
|
if a and a["status"] not in ("wartet", "laeuft"):
|
||||||
|
return a
|
||||||
|
time.sleep(takt_s)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def liste() -> list[dict]:
|
||||||
|
with _lock:
|
||||||
|
return list(reversed(_alle()))
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Wartezeit nach einer Änderung am Update-Skript (24.09.2026).
|
||||||
|
|
||||||
|
„Jetzt updaten“ lässt im Gast `update` laufen. Das lädt ct/<kennung>.sh ungepinnt von GitHub
|
||||||
|
(community-scripts/ProxmoxVE, Zweig main) und führt es als root aus. Eine Änderung, die erst ein paar Stunden
|
||||||
|
alt ist, hat noch kaum jemand im Betrieb gesehen. Deshalb gilt für Apps mit dem Weg „skript“: Ist die letzte
|
||||||
|
Änderung an ct/<kennung>.sh jünger als MC_HOMELAB_KARENZ_H Stunden (Standard 48), bleibt der Baustein „neu“,
|
||||||
|
aber ohne Knopf, und updates.starten() lehnt ab — beide mit demselben Satz.
|
||||||
|
|
||||||
|
Wann das Skript zuletzt geändert wurde, sagt die GitHub-API (kern/github.py, 15 Minuten gemerkt). Lässt sich das
|
||||||
|
nicht abfragen, wird nicht blockiert; die Rückfrage vor dem Update sagt es dann (NICHT_GEPRUEFT).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from kern.github import github_json
|
||||||
|
from kern.zeit import LOCAL_TZ
|
||||||
|
|
||||||
|
REPO = "community-scripts/ProxmoxVE"
|
||||||
|
STANDARD_H = 48.0
|
||||||
|
NICHT_GEPRUEFT = "Ob das Skript kürzlich geändert wurde, ließ sich nicht prüfen."
|
||||||
|
_KENNUNG = re.compile(r"^[a-z0-9-]+$")
|
||||||
|
|
||||||
|
|
||||||
|
def karenz_s() -> float:
|
||||||
|
"""Die Wartezeit in Sekunden (MC_HOMELAB_KARENZ_H; 0 schaltet sie ab)."""
|
||||||
|
try:
|
||||||
|
stunden = float(os.environ.get("MC_HOMELAB_KARENZ_H", "") or STANDARD_H)
|
||||||
|
except ValueError:
|
||||||
|
stunden = STANDARD_H
|
||||||
|
return max(0.0, stunden) * 3600
|
||||||
|
|
||||||
|
|
||||||
|
def skript_geaendert(kennung: str) -> float:
|
||||||
|
"""Wann ct/<kennung>.sh auf main zuletzt geändert wurde (Unix-Sekunden). Wirft, wenn es sich nicht sagen lässt."""
|
||||||
|
if not _KENNUNG.match(kennung or ""):
|
||||||
|
raise ValueError(f"ungültige Kennung {kennung!r}")
|
||||||
|
daten = github_json(f"repos/{REPO}/commits?path=ct/{kennung}.sh&sha=main&per_page=1")
|
||||||
|
# Das Datum, an dem die Änderung auf main landete (committer, nicht author: ein Pull-Request kann älter sein).
|
||||||
|
datum = str(daten[0]["commit"]["committer"]["date"])
|
||||||
|
return datetime.fromisoformat(datum.replace("Z", "+00:00")).timestamp()
|
||||||
|
|
||||||
|
|
||||||
|
def sperrtext(geaendert: float, frei_ab: float) -> str:
|
||||||
|
a, b = datetime.fromtimestamp(geaendert, LOCAL_TZ), datetime.fromtimestamp(frei_ab, LOCAL_TZ)
|
||||||
|
return f"Das Update-Skript wurde am {a:%d.%m.} geändert; zur Sicherheit erst ab {b:%d.%m. %H:%M}."
|
||||||
|
|
||||||
|
|
||||||
|
def pruefen(kennung: str, jetzt: float | None = None) -> dict:
|
||||||
|
"""{"gesperrt": Satz oder None, "geprueft": bool}. Gesperrt, solange die letzte Änderung am Skript jünger ist
|
||||||
|
als die Wartezeit. Scheitert die Abfrage: nicht gesperrt, aber geprueft=False."""
|
||||||
|
try:
|
||||||
|
geaendert = skript_geaendert(kennung)
|
||||||
|
except Exception:
|
||||||
|
return {"gesperrt": None, "geprueft": False}
|
||||||
|
frei_ab = geaendert + karenz_s()
|
||||||
|
if (time.time() if jetzt is None else jetzt) >= frei_ab:
|
||||||
|
return {"gesperrt": None, "geprueft": True}
|
||||||
|
return {"gesperrt": sperrtext(geaendert, frei_ab), "geprueft": True}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"""Der App-Katalog pflegt sich selbst (Ausbauplan Welle 2, Punkt 7, seit 25.09.2026).
|
||||||
|
|
||||||
|
Bis zum 25.09. kannte der Orchestrator nur die Apps, die in apps.KATALOG von Hand stehen; ein neuer Container zeigte
|
||||||
|
sich ohne Versionsvergleich und ohne Webprüfung. Jetzt liest er für jede unbekannte Community-Scripts-Kennung einmal
|
||||||
|
deren Update-Skript (ct/<kennung>.sh, öffentlich auf GitHub, derselbe Ort, den `update` im Gast lädt) und nimmt daraus:
|
||||||
|
• den Namen (APP="…"),
|
||||||
|
• das GitHub-Projekt für die neueste Version (Kopfzeile „Github: https://github.com/<owner>/<repo>“),
|
||||||
|
• die Adresse der Oberfläche (die letzte Zeile „http(s)://${IP}:<port>“, die das Skript am Ende ausgibt),
|
||||||
|
• den Weg des Updates: sagt das Skript, die App lasse sich nur über ihre eigene Oberfläche aktualisieren, dann
|
||||||
|
„eigene-oberflaeche“ (kein Knopf), sonst „skript“ (`update` im Gast, mit der Wartezeit aus karenz.py).
|
||||||
|
Den Port prüft er gleich mit: Antwortet dort nichts (der User hat ihn geändert), bleibt die Webprüfung aus — ein
|
||||||
|
geratener Port darf keinen roten Hinweis „antwortet nicht“ auslösen. Die Angaben liegen in
|
||||||
|
<Datenordner>/app-katalog.json; gelungene werden nach 7 Tagen neu gelesen, gescheiterte nach einem Tag. Die Angaben
|
||||||
|
der Community-Scripts selbst (JSON) sind im Sommer 2026 aus dem Repository verschwunden, das Skript bleibt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from kern.einstellungen import einstellungen
|
||||||
|
|
||||||
|
from services.homelab import apps
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ROH = "https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/{kennung}.sh"
|
||||||
|
NEU_LESEN_S, FEHLER_NEU_S = 7 * 86400, 86400
|
||||||
|
ZEITLIMIT = httpx.Timeout(15.0, connect=5.0)
|
||||||
|
_KENNUNG = re.compile(r"[a-z0-9][a-z0-9-]{0,62}")
|
||||||
|
_GITHUB = re.compile(r"Github:\s*https?://github\.com/([\w.-]+/[\w.-]+?)(?:\.git)?/?\s*$", re.IGNORECASE | re.MULTILINE)
|
||||||
|
_APP = re.compile(r'^APP="([^"]+)"', re.MULTILINE)
|
||||||
|
_ADRESSE = re.compile(r"(https?)://\$\{IP\}:(\d{2,5})(/[^\s$\"']*)?")
|
||||||
|
_NUR_OBERFLAECHE = re.compile(r"only be updated via|update(?:d)? (?:it )?(?:via|through|from) the (?:web ?)?(?:ui|user interface|"
|
||||||
|
r"web interface|dashboard)", re.IGNORECASE)
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_stand: dict = {"mtime": None, "daten": {}}
|
||||||
|
_laufend: set[str] = set()
|
||||||
|
|
||||||
|
|
||||||
|
def _pfad() -> Path:
|
||||||
|
return einstellungen().daten_dir / "app-katalog.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _lesen() -> dict:
|
||||||
|
"""Der Katalog aus der Datei, gemerkt solange sie sich nicht ändert (app_fuer fragt oft)."""
|
||||||
|
pfad = _pfad()
|
||||||
|
try:
|
||||||
|
mtime = pfad.stat().st_mtime_ns
|
||||||
|
except OSError:
|
||||||
|
return {}
|
||||||
|
with _lock:
|
||||||
|
if _stand["mtime"] == mtime:
|
||||||
|
return _stand["daten"]
|
||||||
|
try:
|
||||||
|
daten = json.loads(pfad.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
daten = {}
|
||||||
|
daten = daten if isinstance(daten, dict) else {}
|
||||||
|
with _lock:
|
||||||
|
_stand.update(mtime=mtime, daten=daten)
|
||||||
|
return daten
|
||||||
|
|
||||||
|
|
||||||
|
def _schreiben(daten: dict) -> None:
|
||||||
|
pfad = _pfad()
|
||||||
|
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = pfad.with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||||
|
tmp.replace(pfad)
|
||||||
|
|
||||||
|
|
||||||
|
def aus_skript(kennung: str, text: str) -> dict:
|
||||||
|
"""Was das Update-Skript über die App verrät: {"name", "quelle", "port", "https", "pfad", "weg"}."""
|
||||||
|
name = _APP.search(text)
|
||||||
|
quelle = _GITHUB.search(text)
|
||||||
|
adressen = _ADRESSE.findall(text)
|
||||||
|
https, port, pfad = (adressen[-1][0] == "https", int(adressen[-1][1]), adressen[-1][2] or "/") if adressen \
|
||||||
|
else (False, None, "/")
|
||||||
|
return {"name": name.group(1).strip() if name else kennung, "quelle": quelle.group(1) if quelle else None,
|
||||||
|
"port": port, "https": https, "pfad": pfad,
|
||||||
|
"weg": "eigene-oberflaeche" if _NUR_OBERFLAECHE.search(text) else "skript"}
|
||||||
|
|
||||||
|
|
||||||
|
def _antwortet(ip: str, port: int) -> bool:
|
||||||
|
try:
|
||||||
|
with socket.create_connection((ip, port), timeout=3):
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def app(kennung: str | None) -> apps.App | None:
|
||||||
|
"""Die App zu einer Kennung aus dem selbst gepflegten Katalog (None, wenn unbekannt oder das Lesen scheiterte)."""
|
||||||
|
e = _lesen().get(kennung or "")
|
||||||
|
if not isinstance(e, dict) or e.get("fehler") or not e.get("name"):
|
||||||
|
return None
|
||||||
|
return apps.App(kennung, str(e["name"]), e.get("quelle"), port=e.get("port"), https=bool(e.get("https")),
|
||||||
|
pfad=str(e.get("pfad") or "/"), weg=str(e.get("weg") or "skript"))
|
||||||
|
|
||||||
|
|
||||||
|
def eintragen(kennung: str, ip: str | None, holen=None, jetzt: float | None = None) -> dict:
|
||||||
|
"""Eine Kennung (neu) eintragen: Skript lesen, auswerten, Port prüfen. Rückgabe: der Eintrag."""
|
||||||
|
jetzt = time.time() if jetzt is None else jetzt
|
||||||
|
holen = holen or (lambda k: httpx.get(ROH.format(kennung=k), timeout=ZEITLIMIT, follow_redirects=True))
|
||||||
|
try:
|
||||||
|
antwort = holen(kennung)
|
||||||
|
antwort.raise_for_status()
|
||||||
|
eintrag = {**aus_skript(kennung, antwort.text), "geholt": jetzt}
|
||||||
|
except Exception as exc:
|
||||||
|
eintrag = {"fehler": f"{exc.__class__.__name__}: {exc}"[:200], "geholt": jetzt}
|
||||||
|
if eintrag.get("port") and ip and not _antwortet(ip, eintrag["port"]):
|
||||||
|
eintrag.update(port_skript=eintrag["port"], port=None)
|
||||||
|
daten = dict(_lesen())
|
||||||
|
daten[kennung] = eintrag
|
||||||
|
_schreiben(daten)
|
||||||
|
log.info("katalog: %s eingetragen (%s)", kennung, eintrag.get("fehler") or eintrag.get("name"))
|
||||||
|
return eintrag
|
||||||
|
|
||||||
|
|
||||||
|
def fehlende(bericht: dict, jetzt: float | None = None) -> list[tuple[str, str | None]]:
|
||||||
|
"""(Kennung, IP) der Apps im Bericht, die weder von Hand im Katalog stehen noch frisch gelesen sind."""
|
||||||
|
jetzt = time.time() if jetzt is None else jetzt
|
||||||
|
daten = _lesen()
|
||||||
|
ergebnis = []
|
||||||
|
for g in bericht.get("gaeste") or []:
|
||||||
|
kennung = ((g.get("app") if isinstance(g.get("app"), dict) else {}) or {}).get("kennung")
|
||||||
|
if not kennung or not _KENNUNG.fullmatch(kennung) or kennung in apps.KATALOG:
|
||||||
|
continue
|
||||||
|
e = daten.get(kennung)
|
||||||
|
alter = jetzt - float((e or {}).get("geholt") or 0)
|
||||||
|
if e is None or alter > (FEHLER_NEU_S if e.get("fehler") else NEU_LESEN_S):
|
||||||
|
ergebnis.append((kennung, g.get("ip")))
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def ergaenzen(bericht: dict) -> None:
|
||||||
|
"""Nach jedem Bericht: fehlende Apps im eigenen Faden nachtragen — der Bericht wartet nie auf GitHub."""
|
||||||
|
for kennung, ip in fehlende(bericht):
|
||||||
|
with _lock:
|
||||||
|
if kennung in _laufend:
|
||||||
|
continue
|
||||||
|
_laufend.add(kennung)
|
||||||
|
|
||||||
|
def los(k: str = kennung, adresse: str | None = ip) -> None:
|
||||||
|
try:
|
||||||
|
eintragen(k, adresse)
|
||||||
|
except Exception:
|
||||||
|
log.warning("katalog: %s ließ sich nicht eintragen", k, exc_info=True)
|
||||||
|
finally:
|
||||||
|
with _lock:
|
||||||
|
_laufend.discard(k)
|
||||||
|
|
||||||
|
threading.Thread(target=los, name=f"katalog-{kennung}", daemon=True).start()
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"""Eine Live-Zahl je Gerät für die Kacheln des Homelab-Cockpits und der Übersicht (Ausbauplan Welle 1, seit 25.09.2026).
|
||||||
|
|
||||||
|
Proxmox-Host wie viele Gäste laufen (aus dem Bericht)
|
||||||
|
PBS wann zuletzt gesichert wurde (jüngste Sicherung auf den Zielen der Aufträge)
|
||||||
|
NPMplus wie es um seine Zertifikate steht (Zertifikats-Wache, zertifikate.py)
|
||||||
|
Arcane laufende von allen Containern (Arcane-API mit dem Schlüssel aus den Einstellungen)
|
||||||
|
AdGuard Home Anteil geblockter Anfragen (nur mit hinterlegtem Zugang, Einstellungen; adguard.py)
|
||||||
|
Je Ziel-ID {"text", "zeit"?, "stufe"?}: „zeit“ setzt die Oberfläche als „vor …“ dahinter, „stufe“ (gelb/rot) färbt die
|
||||||
|
Zeile. Was sich nicht ermitteln lässt, fehlt — die Kachel zeigt dann nur ihren Stand. Arcane und AdGuard werden
|
||||||
|
höchstens alle zwei Minuten gefragt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from services.homelab import adguard, apps, arcane, zertifikate
|
||||||
|
from services.homelab.inventar import EIGENES_ETIKETT
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
NPM = {"npmplus", "nginxproxymanager"}
|
||||||
|
|
||||||
|
|
||||||
|
def _komma(zahl: float) -> str:
|
||||||
|
return f"{zahl:.1f}".replace(".", ",") if zahl < 10 else str(round(zahl))
|
||||||
|
|
||||||
|
|
||||||
|
def _npm(vmid: int) -> dict | None:
|
||||||
|
eigene = [z for z in zertifikate.lage()["zertifikate"] if z["quelle"] == "npm" and z["gast"] == vmid]
|
||||||
|
if not eigene:
|
||||||
|
return None
|
||||||
|
probleme = [z for z in eigene if z["stufe"] != "ok"]
|
||||||
|
if not probleme:
|
||||||
|
return {"text": f"{len(eigene)} Zertifikat{'e' if len(eigene) != 1 else ''} gültig"}
|
||||||
|
stufe = "rot" if any(z["stufe"] == "rot" for z in probleme) else "gelb"
|
||||||
|
return {"text": f"{len(probleme)} von {len(eigene)} Zertifikaten mit Problem", "stufe": stufe}
|
||||||
|
|
||||||
|
|
||||||
|
def fuer_bericht(bericht: dict) -> dict[str, dict]:
|
||||||
|
"""Ziel-ID → Kennzahl. Ein Fehler bei einem Gerät kostet nur dessen Zahl."""
|
||||||
|
zahlen: dict[str, dict] = {}
|
||||||
|
# Der eigene Container (Etikett „mc2“) zählt nicht mit — wie überall, wo die Geräte des Homelabs stehen.
|
||||||
|
gaeste = [g for g in bericht.get("gaeste") or [] if isinstance(g, dict) and "vmid" in g
|
||||||
|
and EIGENES_ETIKETT not in (g.get("etiketten") or [])]
|
||||||
|
if gaeste:
|
||||||
|
laufend = sum(1 for g in gaeste if g.get("status") == "running")
|
||||||
|
zahlen["pve"] = {"text": f"{laufend} von {len(gaeste)} Gästen an"}
|
||||||
|
ziele = (bericht.get("host") or {}).get("sicherungsziele") or {}
|
||||||
|
for g in gaeste:
|
||||||
|
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
|
||||||
|
if not app or g.get("status") != "running":
|
||||||
|
continue
|
||||||
|
zid = f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g['vmid']}"
|
||||||
|
try:
|
||||||
|
if app.kennung == "proxmox-backup-server":
|
||||||
|
zuletzt = max((t for z in ziele.values() if isinstance(z, dict)
|
||||||
|
for t in (z.get("neueste") or {}).values() if isinstance(t, int)), default=None)
|
||||||
|
if zuletzt:
|
||||||
|
zahlen[zid] = {"text": "letzte Sicherung", "zeit": zuletzt}
|
||||||
|
elif app.kennung in NPM:
|
||||||
|
if zahl := _npm(g["vmid"]):
|
||||||
|
zahlen[zid] = zahl
|
||||||
|
elif app.kennung == "arcane" and (url := apps.adresse(app, g.get("ip"))):
|
||||||
|
container = arcane.container_zahlen(url.rstrip("/"))
|
||||||
|
if container and container["gesamt"]:
|
||||||
|
zahlen[zid] = {"text": f"{container['laufend']} von {container['gesamt']} Containern an"}
|
||||||
|
elif app.kennung == "adguard":
|
||||||
|
statistik = adguard.geblockt()
|
||||||
|
if statistik and statistik.get("anteil") is not None:
|
||||||
|
zahlen[zid] = {"text": f"{_komma(statistik['anteil'])} % geblockt"}
|
||||||
|
except Exception:
|
||||||
|
log.warning("kennzahlen: %s ließ sich nicht ermitteln", zid, exc_info=True)
|
||||||
|
return zahlen
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
"""Messwerte des Homelabs mit Verlauf (Monitoring, seit 24.09.2026).
|
||||||
|
|
||||||
|
Der Ausführer auf dem Proxmox-Host schickt jede Minute POST /api/homelab/ausfuehrer/messwerte (eigener Faden, unabhängig
|
||||||
|
vom 10-min-Bericht und von Aufträgen):
|
||||||
|
{"zeit": Unix-Sekunden der Messung,
|
||||||
|
"host": {"cpu": 0–1 (Mittel der Minute), "speicher": {"belegt", "gesamt"}, "rootfs": {"belegt", "gesamt"}, "load1",
|
||||||
|
"uptime", "temp_cpu" (°C oder null), "netz": {"rx", "tx"} (kumulativ, Bytes)},
|
||||||
|
"gaeste": [{"vmid", "art" (lxc|qemu), "name", "etiketten", "status", "cpu" (0–1 der eigenen Kerne), "maxcpu", "mem",
|
||||||
|
"maxmem", "disk", "maxdisk", "netin", "netout" (kumulativ, Bytes), "uptime"}]}
|
||||||
|
Hier wird daraus je Gerät ein Punkt über kern/messreihen.py (Quellen „pve“, „ct-<vmid>“, „vm-<vmid>“ wie im Inventar):
|
||||||
|
CPU, RAM und Platte in %, Netz in Bytes/s aus der Differenz zum vorigen Zählerstand. Ist ein Zähler kleiner geworden
|
||||||
|
oder die Laufzeit (Neustart des Gasts oder Hosts), fehlt der vorige Stand (Neustart dieses Teils) oder liegt er mehr als
|
||||||
|
fünf Minuten zurück, bleibt die Rate dieser Minute null. Gestoppte Gäste haben keine Messwerte (null), keine Nullen.
|
||||||
|
Der eigene Container (Etikett „mc2“) wird wie im Inventar nicht erfasst. Die Platte von VMs sieht Proxmox nicht (null).
|
||||||
|
|
||||||
|
GET /api/homelab/messwerte liefert je Gerät die Reihen (1h, 24h, 7d) und den letzten Punkt; pruefe_host() meldet dem
|
||||||
|
Wächter, wenn der Host zehn Minuten lang über 95 % RAM oder 90 °C liegt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
from kern import messreihen
|
||||||
|
|
||||||
|
from services.homelab import apps, inventar, kanal, speicher
|
||||||
|
|
||||||
|
HOST = "pve"
|
||||||
|
_GAST = re.compile(r"(ct|vm)-\d+")
|
||||||
|
ZEITRAEUME = messreihen.ZEITRAEUME
|
||||||
|
HOST_REIHEN = ("cpu", "ram", "platte", "netz_rx", "netz_tx", "temp_cpu")
|
||||||
|
GAST_REIHEN = ("cpu", "ram", "platte", "netz_rx", "netz_tx")
|
||||||
|
AKTUELL = ("cpu", "ram", "ram_used", "ram_total", "platte", "netz_rx", "netz_tx", "temp_cpu", "load1", "uptime_s")
|
||||||
|
AKTUELL_MAX_S = 180 # älter ist der letzte Punkt nicht „aktuell“ (der Ausführer schickt gerade nichts)
|
||||||
|
ZEIT_TOLERANZ_S = 120 # die Messzeit des Ausführers gilt, wenn sie so nah an der eigenen Uhr liegt
|
||||||
|
|
||||||
|
# Wächter: der Host zehn Minuten lang über diesen Schwellen → gelber Hinweis.
|
||||||
|
WACHE_S = 600
|
||||||
|
WACHE_MIN_PUNKTE = 8 # so viele Minutenpunkte müssen in den zehn Minuten liegen (ein, zwei dürfen fehlen)
|
||||||
|
RAM_GELB = float(os.environ.get("MC_WAECHTER_HOST_RAM", "95"))
|
||||||
|
TEMP_GELB = float(os.environ.get("MC_WAECHTER_HOST_TEMP", "90"))
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
# Vorige Zählerstände je Gerät (für die Raten) und die Geräte der letzten Meldung (für Namen und die Geräteliste).
|
||||||
|
# Nur im Speicher: Nach einem Neustart dieses Teils fehlt für eine Minute die Netz-Rate, sonst nichts.
|
||||||
|
_zustand: dict = {"zaehler": {}, "geraete": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def _zahl(wert: object) -> float | None:
|
||||||
|
return float(wert) if messreihen.ist_zahl(wert) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _ganz(wert: object) -> int | None:
|
||||||
|
return int(wert) if messreihen.ist_zahl(wert) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _prozent(anteil: object) -> float | None:
|
||||||
|
"""0–1 → %, eine Nachkommastelle, auf 0–100 begrenzt."""
|
||||||
|
return round(min(100.0, max(0.0, float(anteil) * 100)), 1) if messreihen.ist_zahl(anteil) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _anteil(teil: object, ganz: object) -> float | None:
|
||||||
|
if not (messreihen.ist_zahl(teil) and messreihen.ist_zahl(ganz)) or float(ganz) <= 0:
|
||||||
|
return None
|
||||||
|
return _prozent(float(teil) / float(ganz))
|
||||||
|
|
||||||
|
|
||||||
|
def _dict(wert: object) -> dict:
|
||||||
|
return wert if isinstance(wert, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def gast_id(gast: dict) -> str | None:
|
||||||
|
"""ct-<vmid> bzw. vm-<vmid> wie im Inventar; None bei allem, was kein Gast ist."""
|
||||||
|
vmid, art = gast.get("vmid"), gast.get("art")
|
||||||
|
if not isinstance(vmid, int) or isinstance(vmid, bool) or not 100 <= vmid <= 999_999_999:
|
||||||
|
return None
|
||||||
|
return {"lxc": f"ct-{vmid}", "qemu": f"vm-{vmid}"}.get(art)
|
||||||
|
|
||||||
|
|
||||||
|
def _art(geraet_id: str) -> str:
|
||||||
|
return "proxmox-host" if geraet_id == HOST else "container" if geraet_id.startswith("ct-") else "vm"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Zähler → Bytes/s ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def raten(vorher: dict | None, jetzt: dict) -> tuple[int | None, int | None]:
|
||||||
|
"""Empfangen und gesendet in Bytes/s zwischen zwei Zählerständen {"t", "rx", "tx", "uptime", "laeuft"}. None, wenn
|
||||||
|
es keinen vorigen Stand gibt, einer der beiden nicht lief, die Laufzeit kleiner wurde (neu gestartet: die Zähler
|
||||||
|
begannen von vorn) oder messreihen.rate() den Zuwachs verwirft (Zähler kleiner, Lücke, Sprung)."""
|
||||||
|
if not vorher or not vorher.get("laeuft") or not jetzt.get("laeuft"):
|
||||||
|
return None, None
|
||||||
|
alt_lauf, neu_lauf = vorher.get("uptime"), jetzt.get("uptime")
|
||||||
|
if messreihen.ist_zahl(alt_lauf) and messreihen.ist_zahl(neu_lauf) and neu_lauf < alt_lauf:
|
||||||
|
return None, None
|
||||||
|
dt = jetzt["t"] - vorher["t"]
|
||||||
|
rx, tx = messreihen.rate(vorher.get("rx"), jetzt.get("rx"), dt), messreihen.rate(vorher.get("tx"), jetzt.get("tx"), dt)
|
||||||
|
return (None if rx is None else round(rx)), (None if tx is None else round(tx))
|
||||||
|
|
||||||
|
|
||||||
|
def _zaehlen(geraet_id: str, t: float, rx: object, tx: object, uptime: object, laeuft: bool) -> tuple:
|
||||||
|
"""Den neuen Zählerstand merken und die Raten gegen den vorigen liefern (unter _lock aufrufen)."""
|
||||||
|
stand = {"t": t, "rx": rx, "tx": tx, "uptime": uptime, "laeuft": laeuft}
|
||||||
|
ergebnis = raten(_zustand["zaehler"].get(geraet_id), stand)
|
||||||
|
_zustand["zaehler"][geraet_id] = stand
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def _host_punkt(host: dict, t: float) -> dict:
|
||||||
|
speicher, rootfs, netz = _dict(host.get("speicher")), _dict(host.get("rootfs")), _dict(host.get("netz"))
|
||||||
|
rx, tx = _zaehlen(HOST, t, netz.get("rx"), netz.get("tx"), host.get("uptime"), True)
|
||||||
|
temp, last = _zahl(host.get("temp_cpu")), _zahl(host.get("load1"))
|
||||||
|
return {"cpu": _prozent(host.get("cpu")),
|
||||||
|
"ram": _anteil(speicher.get("belegt"), speicher.get("gesamt")),
|
||||||
|
"ram_used": _ganz(speicher.get("belegt")), "ram_total": _ganz(speicher.get("gesamt")),
|
||||||
|
"platte": _anteil(rootfs.get("belegt"), rootfs.get("gesamt")),
|
||||||
|
"netz_rx": rx, "netz_tx": tx,
|
||||||
|
"temp_cpu": None if temp is None else round(temp, 1),
|
||||||
|
"load1": None if last is None else round(last, 2),
|
||||||
|
"uptime_s": _ganz(host.get("uptime"))}
|
||||||
|
|
||||||
|
|
||||||
|
def _gast_punkt(geraet_id: str, gast: dict, t: float) -> dict:
|
||||||
|
laeuft = gast.get("status") == "running"
|
||||||
|
rx, tx = _zaehlen(geraet_id, t, gast.get("netin"), gast.get("netout"), gast.get("uptime"), laeuft)
|
||||||
|
if not laeuft:
|
||||||
|
return {"cpu": None, "ram": None, "ram_used": None, "ram_total": _ganz(gast.get("maxmem")), "platte": None,
|
||||||
|
"netz_rx": None, "netz_tx": None, "uptime_s": _ganz(gast.get("uptime"))}
|
||||||
|
# Proxmox rechnet die CPU eines Gasts schon auf seine eigenen Kerne (1,0 = alle voll). Die Belegung der Platte
|
||||||
|
# kennt es nur bei Containern; bei VMs steht dort immer 0.
|
||||||
|
disk = gast.get("disk")
|
||||||
|
platte = _anteil(disk, gast.get("maxdisk")) if gast.get("art") == "lxc" and messreihen.ist_zahl(disk) and disk else None
|
||||||
|
return {"cpu": _prozent(gast.get("cpu")),
|
||||||
|
"ram": _anteil(gast.get("mem"), gast.get("maxmem")),
|
||||||
|
"ram_used": _ganz(gast.get("mem")), "ram_total": _ganz(gast.get("maxmem")),
|
||||||
|
"platte": platte, "netz_rx": rx, "netz_tx": tx, "uptime_s": _ganz(gast.get("uptime"))}
|
||||||
|
|
||||||
|
|
||||||
|
def annehmen(daten: dict, jetzt: float | None = None) -> int:
|
||||||
|
"""Einen Minutenpunkt des Ausführers speichern. Rückgabe: für wie viele Geräte. Kaputte Einträge fallen still weg."""
|
||||||
|
jetzt = time.time() if jetzt is None else jetzt
|
||||||
|
zeit = daten.get("zeit")
|
||||||
|
t = float(zeit) if messreihen.ist_zahl(zeit) and abs(float(zeit) - jetzt) <= ZEIT_TOLERANZ_S else jetzt
|
||||||
|
punkte: list[tuple[str, dict]] = []
|
||||||
|
with _lock:
|
||||||
|
if isinstance(daten.get("host"), dict):
|
||||||
|
punkte.append((HOST, _host_punkt(daten["host"], t)))
|
||||||
|
gaeste = daten.get("gaeste") if isinstance(daten.get("gaeste"), list) else []
|
||||||
|
geraete = {}
|
||||||
|
for gast in gaeste:
|
||||||
|
if not isinstance(gast, dict) or inventar.EIGENES_ETIKETT in (gast.get("etiketten") or []):
|
||||||
|
continue
|
||||||
|
if not (gid := gast_id(gast)):
|
||||||
|
continue
|
||||||
|
punkte.append((gid, _gast_punkt(gid, gast, t)))
|
||||||
|
geraete[gid] = str(gast.get("name") or gid)
|
||||||
|
if gaeste:
|
||||||
|
_zustand["geraete"] = geraete
|
||||||
|
for quelle, werte in punkte:
|
||||||
|
messreihen.schreiben(quelle, werte, t)
|
||||||
|
return len(punkte)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Lesen ------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _geraete(von: float, bis: float) -> list[dict]:
|
||||||
|
"""Die Geräte wie im Inventar: der Host und die Gäste aus dem letzten Bericht (Namen aus apps.py), dazu Gäste, die
|
||||||
|
erst die Messwerte kennen. Ohne beides: alle Quellen mit Dateien im Zeitraum."""
|
||||||
|
eingang = kanal.bericht()
|
||||||
|
geraete: dict[str, dict] = {}
|
||||||
|
eigene: set[str] = set()
|
||||||
|
if eingang:
|
||||||
|
geraete[HOST] = {"id": HOST, "name": "Proxmox-Host", "art": "proxmox-host"}
|
||||||
|
for gast in (eingang or {}).get("bericht", {}).get("gaeste") or []:
|
||||||
|
if not (gid := gast_id(gast)):
|
||||||
|
continue
|
||||||
|
if inventar.EIGENES_ETIKETT in (gast.get("etiketten") or []):
|
||||||
|
eigene.add(gid)
|
||||||
|
continue
|
||||||
|
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
|
||||||
|
geraete[gid] = {"id": gid, "name": app.name if app else str(gast.get("name") or gid), "art": _art(gid)}
|
||||||
|
with _lock:
|
||||||
|
gemeldet = dict(_zustand["geraete"])
|
||||||
|
host_gemeldet = HOST in _zustand["zaehler"]
|
||||||
|
if host_gemeldet:
|
||||||
|
geraete.setdefault(HOST, {"id": HOST, "name": "Proxmox-Host", "art": "proxmox-host"})
|
||||||
|
for gid, name in gemeldet.items():
|
||||||
|
if gid not in geraete and gid not in eigene:
|
||||||
|
app = apps.app_fuer(None, name)
|
||||||
|
geraete[gid] = {"id": gid, "name": app.name if app else name, "art": _art(gid)}
|
||||||
|
if not geraete:
|
||||||
|
for quelle in messreihen.quellen(von, bis):
|
||||||
|
if quelle == HOST or _GAST.fullmatch(quelle):
|
||||||
|
geraete[quelle] = {"id": quelle, "name": "Proxmox-Host" if quelle == HOST else quelle,
|
||||||
|
"art": _art(quelle)}
|
||||||
|
# Reihenfolge wie im Inventar: der Host, dann die Gäste nach Nummer.
|
||||||
|
return sorted(geraete.values(), key=lambda g: (g["id"] != HOST, int(g["id"].split("-")[1]) if g["id"] != HOST else 0))
|
||||||
|
|
||||||
|
|
||||||
|
def abfrage(zeitraum: str, jetzt: float | None = None) -> dict:
|
||||||
|
"""Für GET /api/homelab/messwerte: {"zeitraum", "schritt_s", "geraete": [{"id", "name", "art", "reihen",
|
||||||
|
"aktuell", "prognose"}]}. temp_cpu gibt es als Reihe nur beim Host; in „aktuell“ stehen temp_cpu und load1 bei
|
||||||
|
Gästen auf null. prognose.platte: „voll in etwa N Tagen“ (kern/prognose.py, seit 25.09.2026)."""
|
||||||
|
dauer, schritt = messreihen.zeitraum_pruefen(zeitraum)
|
||||||
|
zeit = time.time() if jetzt is None else jetzt
|
||||||
|
geraete = []
|
||||||
|
for geraet in _geraete(zeit - dauer, zeit):
|
||||||
|
host = geraet["id"] == HOST
|
||||||
|
daten = messreihen.lesen(geraet["id"], zeitraum, HOST_REIHEN if host else GAST_REIHEN, zeit)
|
||||||
|
letzter = messreihen.letzter_punkt(geraet["id"], zeit, AKTUELL_MAX_S) or {}
|
||||||
|
aktuell = {name: letzter.get(name) for name in AKTUELL}
|
||||||
|
if not host:
|
||||||
|
aktuell["temp_cpu"] = aktuell["load1"] = None
|
||||||
|
geraete.append({**geraet, "reihen": daten["reihen"], "aktuell": aktuell,
|
||||||
|
"prognose": {"platte": speicher.platten_vorhersage(geraet["id"], jetzt)}})
|
||||||
|
return {"zeitraum": zeitraum, "schritt_s": schritt, "geraete": geraete}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Wächter (Rolle homelab, registriert in services/waechter.py) -------------------------------------------
|
||||||
|
|
||||||
|
def _gb(anzahl: object) -> str:
|
||||||
|
return f"{float(anzahl) / 1e9:.1f}".replace(".", ",") if messreihen.ist_zahl(anzahl) else "?"
|
||||||
|
|
||||||
|
|
||||||
|
def pruefe_host(jetzt: float | None = None) -> list:
|
||||||
|
"""Gelb, wenn der Proxmox-Host zehn Minuten lang über RAM_GELB % Arbeitsspeicher oder TEMP_GELB °C CPU-Temperatur
|
||||||
|
liegt. Ohne genug Messungen (Ausführer schweigt, gerade erst gestartet) kein Befund — das Schweigen meldet
|
||||||
|
pruefe_ausfuehrer()."""
|
||||||
|
from services.waechter import Befund # der Wächter registriert diese Prüfung, also erst hier
|
||||||
|
jetzt = time.time() if jetzt is None else jetzt
|
||||||
|
punkte = messreihen.punkte(HOST, jetzt - WACHE_S, jetzt + 1)
|
||||||
|
befunde = []
|
||||||
|
ram = [p["ram"] for p in punkte if messreihen.ist_zahl(p.get("ram"))]
|
||||||
|
if len(ram) >= WACHE_MIN_PUNKTE and min(ram) > RAM_GELB:
|
||||||
|
letzter = next(p for p in reversed(punkte) if messreihen.ist_zahl(p.get("ram")))
|
||||||
|
befunde.append(Befund(
|
||||||
|
id="host-ram", stufe="gelb", titel=f"Proxmox-Host: Arbeitsspeicher seit 10 Minuten über {RAM_GELB:.0f} %",
|
||||||
|
text=(f"Zuletzt {ram[-1]:.0f} % belegt ({_gb(letzter.get('ram_used'))} von {_gb(letzter.get('ram_total'))} "
|
||||||
|
"GB). Wird es noch enger, beendet der Kernel Prozesse, auch in den Gästen. Die Messwerte zeigen, "
|
||||||
|
"welcher Gast wie viel braucht; einem davon weniger Speicher geben oder ihn stoppen."),
|
||||||
|
quelle=HOST, sofort=True))
|
||||||
|
temp = [p["temp_cpu"] for p in punkte if messreihen.ist_zahl(p.get("temp_cpu"))]
|
||||||
|
if len(temp) >= WACHE_MIN_PUNKTE and min(temp) > TEMP_GELB:
|
||||||
|
befunde.append(Befund(
|
||||||
|
id="host-temp", stufe="gelb", titel=f"Proxmox-Host: CPU seit 10 Minuten über {TEMP_GELB:.0f} °C",
|
||||||
|
text=(f"Zuletzt {temp[-1]:.0f} °C. So heiß drosselt sich die CPU, und auf Dauer leidet die Hardware. "
|
||||||
|
"Lüfter und Luftwege prüfen (Staub) und in den Messwerten nachsehen, welcher Gast die Last macht."),
|
||||||
|
quelle=HOST, sofort=True))
|
||||||
|
return befunde
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"""Was ein Update bringt, vor dem Knopf (Ausbauplan Welle 2, Punkt 9, seit 25.09.2026).
|
||||||
|
|
||||||
|
App (Community-Script mit GitHub-Projekt) die Release Notes aller Versionen zwischen installiert und neu
|
||||||
|
(GitHub-API, höchstens zehn, je auf 4 000 Zeichen gekürzt)
|
||||||
|
Proxmox-Host (Pakete) die Debian-Änderungslisten der wichtigsten Pakete (Proxmox, Kernel,
|
||||||
|
QEMU, LXC, ZFS …, höchstens acht) — der Ausführer holt sie nur lesend
|
||||||
|
(Aktion changelog), jeweils bis zur installierten Version
|
||||||
|
Lucy fasst das auf Deutsch zusammen (Modell `fast` der KI-Box über deren /v1, Aufbau wie bei den Updates der Box):
|
||||||
|
eine erste Zeile, ob der User selbst etwas tun muss („Handarbeit“), dann höchstens fünf Stichpunkte, Brüche und
|
||||||
|
geänderte Einstellungen zuerst und mit ⚠️. Fakten kommen aus den Notes, die Worte vom Modell. Antwortet die Box
|
||||||
|
nicht, stehen die Notes ohne Kurzfassung da. Ergebnisse werden je (Gerät, Baustein, von, nach) gemerkt
|
||||||
|
(<Datenordner>/neuerungen.json) — dieselbe Version fasst Lucy nur einmal zusammen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from kern.einstellungen import einstellungen
|
||||||
|
from kern.github import github_json
|
||||||
|
|
||||||
|
from services.homelab import apps, inventar, kanal
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MAX_VERSIONEN = 10
|
||||||
|
MAX_ZEICHEN = 4000
|
||||||
|
MAX_PAKETE = 8
|
||||||
|
WICHTIG = re.compile(r"pve|proxmox|qemu|lxc|kernel|zfs|ceph|corosync|spiceterm|novnc|libpve")
|
||||||
|
ZEITLIMIT_LLM = httpx.Timeout(120.0, connect=5.0)
|
||||||
|
FEHLER_MERKEN_S = 600
|
||||||
|
_AKTION = re.compile(r"^\s*AKTION:\s*(JA|NEIN)\b\s*[—–-]?\s*(.*)$", re.IGNORECASE)
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _pfad() -> Path:
|
||||||
|
return einstellungen().daten_dir / "neuerungen.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _gemerkt() -> dict:
|
||||||
|
try:
|
||||||
|
daten = json.loads(_pfad().read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
return daten if isinstance(daten, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _merken(schluessel: str, ergebnis: dict) -> None:
|
||||||
|
with _lock:
|
||||||
|
daten = _gemerkt()
|
||||||
|
daten[schluessel] = {"zeit": time.time(), "ergebnis": ergebnis}
|
||||||
|
# Nur die jüngsten 100 Einträge: alte Versionen braucht niemand mehr.
|
||||||
|
daten = dict(sorted(daten.items(), key=lambda t: t[1].get("zeit", 0))[-100:])
|
||||||
|
pfad = _pfad()
|
||||||
|
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = pfad.with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(daten, ensure_ascii=False), encoding="utf-8")
|
||||||
|
tmp.replace(pfad)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Quellen -----------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def zwischen(releases: object, installiert: str, neu: str) -> list[dict]:
|
||||||
|
"""Die Releases mit installiert < Version <= neu, neueste zuerst (ohne Entwürfe und Vorabversionen)."""
|
||||||
|
von, bis = inventar._teile(installiert), inventar._teile(neu)
|
||||||
|
if not von or not bis:
|
||||||
|
return []
|
||||||
|
auswahl = []
|
||||||
|
for r in releases if isinstance(releases, list) else []:
|
||||||
|
if not isinstance(r, dict) or r.get("draft") or r.get("prerelease"):
|
||||||
|
continue
|
||||||
|
version = inventar._teile(str(r.get("tag_name") or ""))
|
||||||
|
if version and von < version <= bis:
|
||||||
|
auswahl.append((version, {"version": str(r.get("tag_name")), "titel": r.get("name") or r.get("tag_name"),
|
||||||
|
"datum": r.get("published_at"), "url": r.get("html_url"),
|
||||||
|
"text": str(r.get("body") or "").strip()[:MAX_ZEICHEN]}))
|
||||||
|
auswahl.sort(key=lambda t: t[0], reverse=True)
|
||||||
|
return [r for _, r in auswahl[:MAX_VERSIONEN]]
|
||||||
|
|
||||||
|
|
||||||
|
def wichtige_pakete(updates: list[dict]) -> list[str]:
|
||||||
|
"""Die Pakete, deren Änderungsliste zählt: Proxmox, Kernel und Co. zuerst, höchstens MAX_PAKETE."""
|
||||||
|
namen = [str(u.get("paket")) for u in updates if isinstance(u, dict) and u.get("paket")]
|
||||||
|
return sorted(namen, key=lambda n: (not WICHTIG.search(n), n))[:MAX_PAKETE]
|
||||||
|
|
||||||
|
|
||||||
|
def _changelogs(pakete: list[str]) -> dict[str, str]:
|
||||||
|
"""Über den Ausführer (Aktion changelog, nur lesend). Leer, wenn er nicht antwortet."""
|
||||||
|
auftrag = kanal.anlegen("changelog", {"pakete": pakete})
|
||||||
|
ergebnis = kanal.warten(auftrag, 120)
|
||||||
|
if not ergebnis or ergebnis.get("status") != "fertig":
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
daten = json.loads(ergebnis.get("text") or "{}")
|
||||||
|
except ValueError:
|
||||||
|
return {}
|
||||||
|
return {k: str(v) for k, v in daten.items()} if isinstance(daten, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Lucys Kurzfassung ---------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def auswerten(text: str) -> dict:
|
||||||
|
"""Die Antwort des Modells: {"aktion": bool|None, "aktion_text", "punkte": […]}. Tolerant: Die AKTION-Zeile darf
|
||||||
|
irgendwo stehen, Stichpunkte beginnen mit „-“ oder „•“."""
|
||||||
|
aktion, aktion_text, punkte = None, "", []
|
||||||
|
for zeile in text.splitlines():
|
||||||
|
if aktion is None and (m := _AKTION.match(zeile)):
|
||||||
|
aktion, aktion_text = m.group(1).upper() == "JA", m.group(2).strip()
|
||||||
|
elif zeile.strip().startswith(("-", "•", "*")):
|
||||||
|
punkte.append(zeile.strip().lstrip("-•* ").strip())
|
||||||
|
return {"aktion": aktion, "aktion_text": aktion_text, "punkte": [p for p in punkte if p][:5]}
|
||||||
|
|
||||||
|
|
||||||
|
def zusammenfassen(was: str, von: str, nach: str, quellen: str) -> dict | None:
|
||||||
|
"""Lucy fasst zusammen (Modell `fast` der KI-Box). None, wenn es keine Box gibt oder sie nicht antwortet."""
|
||||||
|
box = einstellungen().partner_url
|
||||||
|
if not box or not quellen.strip():
|
||||||
|
return None
|
||||||
|
prompt = (
|
||||||
|
f"Du bist Lucy und betreust ein Homelab. Vor einem Update von {was} ({von} → {nach}) liest du die "
|
||||||
|
"Änderungen und sagst dem Besitzer, was ihn betrifft. Die Änderungen:\n\n" + quellen[:14000] + "\n\n"
|
||||||
|
"Antworte auf DEUTSCH, knapp und klar, GENAU in diesem Format:\n"
|
||||||
|
"Zeile 1 beginnt mit 'AKTION: ':\n"
|
||||||
|
" • 'AKTION: NEIN' — wenn er nichts selbst tun muss (der Orchestrator legt vorher einen Snapshot an und geht "
|
||||||
|
"bei Problemen von allein zurück). Das ist der Normalfall.\n"
|
||||||
|
" • 'AKTION: JA — <was er konkret tun muss>' — NUR bei Handarbeit: Migration, geänderte oder entfernte "
|
||||||
|
"Einstellungen, neue Pflichtangaben, Brüche.\n"
|
||||||
|
"Danach höchstens 5 kurze Stichpunkte (je mit '- '), Wichtigstes zuerst: Brüche und geänderte Einstellungen "
|
||||||
|
"ZUERST und mit '⚠️', dann Sicherheitskorrekturen, dann lohnende Neuerungen. Keine Einleitung, keine "
|
||||||
|
"Überschrift, nichts erfinden, was nicht in den Änderungen steht.")
|
||||||
|
try:
|
||||||
|
r = httpx.post(f"{box}/v1/chat/completions", timeout=ZEITLIMIT_LLM, json={
|
||||||
|
"model": "fast", "max_tokens": 600, "temperature": 0.2,
|
||||||
|
"chat_template_kwargs": {"enable_thinking": False},
|
||||||
|
"messages": [{"role": "user", "content": prompt}]})
|
||||||
|
r.raise_for_status()
|
||||||
|
wahl = (r.json().get("choices") or [{}])[0]
|
||||||
|
text = ((wahl.get("message") or {}).get("content") or "").strip()
|
||||||
|
except (httpx.HTTPError, ValueError, AttributeError):
|
||||||
|
log.warning("neuerungen: Lucy war nicht zu erreichen", exc_info=True)
|
||||||
|
return None
|
||||||
|
if wahl.get("finish_reason") == "length":
|
||||||
|
text = text.rpartition("\n")[0]
|
||||||
|
ergebnis = auswerten(text)
|
||||||
|
return ergebnis if ergebnis["aktion"] is not None or ergebnis["punkte"] else None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Für die Oberfläche --------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _baustein(zid: str, baustein: str) -> tuple[dict | None, dict | None]:
|
||||||
|
ziel = next((z for z in inventar.ziele()["ziele"] if z["id"] == zid), None)
|
||||||
|
b = next((x for x in (ziel or {}).get("bausteine") or [] if x["id"] == baustein), None)
|
||||||
|
return ziel, b
|
||||||
|
|
||||||
|
|
||||||
|
def fuer(zid: str, baustein: str) -> dict:
|
||||||
|
"""GET /api/homelab/ziele/{zid}/neuerungen?baustein=…: {"verfuegbar", "grund", "von", "nach", "versionen" |
|
||||||
|
"pakete", "zusammenfassung", "quelle_url"}."""
|
||||||
|
ziel, b = _baustein(zid, baustein)
|
||||||
|
if not ziel or not b or b.get("zustand") != "neu":
|
||||||
|
return {"verfuegbar": False, "grund": "Für diesen Baustein steht kein Update an."}
|
||||||
|
von, nach = str(b.get("installiert") or ""), str(b.get("verfuegbar") or b.get("kurz") or "")
|
||||||
|
schluessel = f"{zid}:{baustein}:{von}:{nach}"
|
||||||
|
alt = _gemerkt().get(schluessel)
|
||||||
|
if alt and (alt["ergebnis"].get("zusammenfassung") or time.time() - alt.get("zeit", 0) < FEHLER_MERKEN_S):
|
||||||
|
return alt["ergebnis"]
|
||||||
|
if zid == "pve" and baustein == "pakete":
|
||||||
|
ergebnis = _host(von, nach)
|
||||||
|
elif baustein == "app":
|
||||||
|
ergebnis = _app(ziel, von, nach)
|
||||||
|
else:
|
||||||
|
return {"verfuegbar": False, "grund": "Für Paket-Updates im Gast und Docker-Images gibt es keine Release Notes."}
|
||||||
|
if ergebnis.get("verfuegbar"):
|
||||||
|
_merken(schluessel, ergebnis)
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def _app(ziel: dict, von: str, nach: str) -> dict:
|
||||||
|
gast = inventar.gast(ziel["id"]) or {}
|
||||||
|
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
|
||||||
|
if not app or not app.quelle:
|
||||||
|
return {"verfuegbar": False, "grund": "Zu dieser App kennt der Orchestrator kein GitHub-Projekt."}
|
||||||
|
url = f"https://github.com/{app.quelle}/releases"
|
||||||
|
try:
|
||||||
|
versionen = zwischen(github_json(f"repos/{app.quelle}/releases?per_page=40"), von, nach)
|
||||||
|
except Exception:
|
||||||
|
return {"verfuegbar": False, "grund": "GitHub antwortet gerade nicht.", "quelle_url": url}
|
||||||
|
if not versionen:
|
||||||
|
return {"verfuegbar": False, "grund": "Zu diesen Versionen stehen auf GitHub keine Release Notes.", "quelle_url": url}
|
||||||
|
quellen = "\n\n".join(f"## {v['titel']} ({v['version']})\n{v['text']}" for v in versionen)
|
||||||
|
return {"verfuegbar": True, "grund": None, "von": von, "nach": nach, "versionen": versionen, "quelle_url": url,
|
||||||
|
"zusammenfassung": zusammenfassen(app.name, von, nach, quellen)}
|
||||||
|
|
||||||
|
|
||||||
|
def _host(von: str, nach: str) -> dict:
|
||||||
|
host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}
|
||||||
|
pakete = wichtige_pakete(host.get("updates") or [])
|
||||||
|
if not pakete:
|
||||||
|
return {"verfuegbar": False, "grund": "Der Bericht nennt keine Pakete."}
|
||||||
|
logs = _changelogs(pakete)
|
||||||
|
if not logs:
|
||||||
|
return {"verfuegbar": False, "grund": "Der Ausführer hat die Änderungslisten nicht geliefert."}
|
||||||
|
alle = len(host.get("updates") or [])
|
||||||
|
quellen = "\n\n".join(f"## {name}\n{text}" for name, text in logs.items())
|
||||||
|
return {"verfuegbar": True, "grund": None, "von": von, "nach": nach,
|
||||||
|
"pakete": [{"name": n, "text": t} for n, t in logs.items()], "weitere": max(0, alle - len(logs)),
|
||||||
|
"zusammenfassung": zusammenfassen(f"Proxmox VE ({alle} Pakete)", von or "installiert", nach or "neu",
|
||||||
|
quellen)}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"""Pflege im Hintergrund: wöchentlich nach Updates suchen (24.09.2026).
|
||||||
|
|
||||||
|
Die Übersicht ist nur so ehrlich wie die Paketlisten in den Gästen, und die erneuert dort niemand von selbst
|
||||||
|
(AdGuard stand im September 2026 auf Listen vom Oktober 2025). Deshalb stößt der Homelab-Teil den Auftrag
|
||||||
|
„suchen“ (apt-get update bzw. apk update im Gast, ändert keine Pakete) selbst an, sobald die Listen eines Gasts
|
||||||
|
älter als sieben Tage sind:
|
||||||
|
|
||||||
|
• nur freigegebene, laufende Container — der Ausführer prüft das Etikett ohnehin selbst noch einmal
|
||||||
|
• höchstens ein Auftrag je Gast und Tag; nie während eines Update-Laufs für diesen Gast und nie, solange für
|
||||||
|
ihn ein anderer Auftrag offen ist; nur, wenn der Ausführer gerade berichtet
|
||||||
|
• bevorzugt nachts 02:00–05:00 (Berlin); war die Instanz oder der Ausführer in der letzten Nacht nicht da,
|
||||||
|
eben gleich
|
||||||
|
• keine Meldungen. Scheitert die Suche für einen Gast FEHLSCHLAEGE_HINWEIS-mal hintereinander, wird daraus
|
||||||
|
ein gelber Hinweis des Wächters (gelb geht nicht an Telegram).
|
||||||
|
|
||||||
|
Der Steward der Rolle homelab ruft pruefe_paketlisten() in jedem Wächter-Takt auf (services/waechter.py,
|
||||||
|
PRUEFUNGEN). Zustand in <Datenordner>/homelab-pflege.json; den schreibt nur der Steward. Die Aufträge selbst
|
||||||
|
landen im Kanal (kanal.py, unter Dateisperre, weil auch die Oberfläche dort schreibt). Seit 24.09.2026 steht dort
|
||||||
|
auch eine kurze Ereignisliste (angestoßen, gescheitert, wieder ok; die letzten EREIGNISSE_MAX) — das Protokoll der
|
||||||
|
Oberfläche liest sie (protokoll.py).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from kern.einstellungen import einstellungen
|
||||||
|
from kern.zeit import LOCAL_TZ
|
||||||
|
|
||||||
|
from services.homelab import apps, inventar, kanal, updates
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
LISTEN_ALT_S = 7 * 24 * 3600
|
||||||
|
NACHT_VON, NACHT_BIS = 2, 5 # volle Stunden, Europe/Berlin
|
||||||
|
FEHLSCHLAEGE_HINWEIS = 2
|
||||||
|
EREIGNISSE_MAX = 200
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _pfad() -> Path:
|
||||||
|
return einstellungen().daten_dir / "homelab-pflege.json"
|
||||||
|
|
||||||
|
|
||||||
|
def zustand() -> dict:
|
||||||
|
"""{"nacht": Datum der letzten Nacht, in der gesucht werden konnte, "gaeste": {vmid: {…}}}"""
|
||||||
|
try:
|
||||||
|
daten = json.loads(_pfad().read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
daten = {}
|
||||||
|
if not isinstance(daten, dict):
|
||||||
|
daten = {}
|
||||||
|
if not isinstance(daten.get("gaeste"), dict):
|
||||||
|
daten["gaeste"] = {}
|
||||||
|
return daten
|
||||||
|
|
||||||
|
|
||||||
|
def _schreiben(daten: dict) -> None:
|
||||||
|
_pfad().parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = _pfad().with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||||
|
tmp.replace(_pfad())
|
||||||
|
|
||||||
|
|
||||||
|
def faellig(gaeste: list[dict], jetzt: float) -> list[dict]:
|
||||||
|
"""Die Container, deren Paketlisten eine neue Suche brauchen: freigegeben, laufend, Listen älter als 7 Tage."""
|
||||||
|
ergebnis = []
|
||||||
|
for g in gaeste:
|
||||||
|
listen = (g.get("os_updates") or {}).get("listen_stand")
|
||||||
|
if (g.get("art") == "lxc" and g.get("status") == "running" and g.get("erlaubt")
|
||||||
|
and inventar.EIGENES_ETIKETT not in (g.get("etiketten") or [])
|
||||||
|
and listen and jetzt - listen > LISTEN_ALT_S):
|
||||||
|
ergebnis.append(g)
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def _fehlerzeile(text: str | None) -> str:
|
||||||
|
"""Die aussagekräftigste Zeile einer gescheiterten Suche (apt meldet Fehler mit „E:“)."""
|
||||||
|
zeilen = [z.strip() for z in str(text or "").splitlines() if z.strip()]
|
||||||
|
wichtig = [z for z in zeilen if z.startswith(("E:", "ERROR", "Abgelehnt", "Zeitlimit", "Fehler"))]
|
||||||
|
return (wichtig or zeilen or ["ohne Angabe"])[-1][:200]
|
||||||
|
|
||||||
|
|
||||||
|
def _ereignis(daten: dict, ts: float, vmid: object, name: object, art: str, text: str) -> None:
|
||||||
|
"""Für das Protokoll der Oberfläche; die Liste bleibt kurz."""
|
||||||
|
try:
|
||||||
|
vmid = int(vmid)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return
|
||||||
|
liste = daten["ereignisse"] if isinstance(daten.get("ereignisse"), list) else []
|
||||||
|
liste.append({"ts": ts, "vmid": vmid, "name": str(name or vmid), "art": art, "text": text})
|
||||||
|
daten["ereignisse"] = liste[-EREIGNISSE_MAX:]
|
||||||
|
|
||||||
|
|
||||||
|
def _einsammeln(daten: dict) -> bool:
|
||||||
|
"""Ergebnisse der eigenen Aufträge übernehmen: fertig → Zähler auf null, gescheitert → eins mehr."""
|
||||||
|
geaendert = False
|
||||||
|
for vmid, eintrag in daten["gaeste"].items():
|
||||||
|
aid = eintrag.get("auftrag")
|
||||||
|
if not aid:
|
||||||
|
continue
|
||||||
|
a = kanal.auftrag(aid)
|
||||||
|
if a is not None and a.get("status") in ("wartet", "laeuft"):
|
||||||
|
continue
|
||||||
|
if a is not None and a.get("status") == "fertig":
|
||||||
|
if eintrag.get("fehlschlaege"):
|
||||||
|
_ereignis(daten, a.get("fertig") or time.time(), vmid, eintrag.get("name"), "wieder_ok",
|
||||||
|
"Die Paketlisten ließen sich wieder erneuern.")
|
||||||
|
eintrag["fehlschlaege"], eintrag["fehler"] = 0, None
|
||||||
|
elif a is not None: # fehler, verloren
|
||||||
|
eintrag["fehlschlaege"] = int(eintrag.get("fehlschlaege") or 0) + 1
|
||||||
|
eintrag["fehler"] = _fehlerzeile(a.get("text"))
|
||||||
|
_ereignis(daten, a.get("fertig") or time.time(), vmid, eintrag.get("name"), "gescheitert",
|
||||||
|
f"{eintrag['fehlschlaege']}. Fehlschlag in Folge: {eintrag['fehler']}")
|
||||||
|
eintrag["auftrag"] = None # nicht mehr in der Liste: vergessen, nicht zählen
|
||||||
|
geaendert = True
|
||||||
|
return geaendert
|
||||||
|
|
||||||
|
|
||||||
|
def _offene_gaeste() -> set:
|
||||||
|
"""Gäste mit einem offenen Auftrag gleich welcher Art (auch von Hand oder aus einem Update-Lauf)."""
|
||||||
|
return {(a.get("parameter") or {}).get("vmid") for a in kanal.liste() if a.get("status") in ("wartet", "laeuft")}
|
||||||
|
|
||||||
|
|
||||||
|
def _befunde(daten: dict, faellige: list[dict]) -> list:
|
||||||
|
"""Gelbe Hinweise für Gäste, deren Listen weiter alt sind und deren Suche wiederholt scheiterte."""
|
||||||
|
from services.waechter import Befund # der Wächter registriert diese Prüfung, also erst hier
|
||||||
|
befunde = []
|
||||||
|
for g in faellige:
|
||||||
|
eintrag = daten["gaeste"].get(str(g["vmid"])) or {}
|
||||||
|
anzahl = int(eintrag.get("fehlschlaege") or 0)
|
||||||
|
if anzahl < FEHLSCHLAEGE_HINWEIS:
|
||||||
|
continue
|
||||||
|
name = eintrag.get("name") or str(g.get("name") or g["vmid"])
|
||||||
|
befunde.append(Befund(
|
||||||
|
id=f"pflege:ct-{g['vmid']}", stufe="gelb", titel=f"{name}: Die Suche nach Updates scheitert",
|
||||||
|
text=(f"Die Paketlisten ließen sich {anzahl}-mal hintereinander nicht erneuern (wöchentliche Suche). "
|
||||||
|
f"Zuletzt: {eintrag.get('fehler') or 'ohne Angabe'}"),
|
||||||
|
quelle=f"ct-{g['vmid']}", sofort=True))
|
||||||
|
return befunde
|
||||||
|
|
||||||
|
|
||||||
|
def pruefe_paketlisten(jetzt: float | None = None) -> list:
|
||||||
|
"""Im Wächter-Takt: Ergebnisse einsammeln, fällige Suchen anstoßen. Rückgabe: Befunde für den Wächter —
|
||||||
|
nur, wenn die Suche für einen Gast wiederholt scheitert (gelb)."""
|
||||||
|
from services import waechter
|
||||||
|
jetzt = time.time() if jetzt is None else jetzt
|
||||||
|
lokal = datetime.fromtimestamp(jetzt, LOCAL_TZ)
|
||||||
|
heute = lokal.date().isoformat()
|
||||||
|
nachts = NACHT_VON <= lokal.hour < NACHT_BIS
|
||||||
|
# Die zuletzt begonnene Nacht: vor 02:00 die von gestern.
|
||||||
|
letzte_nacht = (lokal.date() - timedelta(days=0 if lokal.hour >= NACHT_VON else 1)).isoformat()
|
||||||
|
|
||||||
|
eingang = kanal.bericht()
|
||||||
|
bericht = (eingang or {}).get("bericht") or {}
|
||||||
|
gaeste = bericht.get("gaeste") or []
|
||||||
|
zuletzt = kanal.zuletzt()
|
||||||
|
# Aufträge nur, wenn der Ausführer gerade berichtet und nicht im Nur-Lesen-Modus läuft (dann lehnte er ab).
|
||||||
|
verbunden = bool(eingang and zuletzt and jetzt - zuletzt < inventar.BERICHT_ALT_S
|
||||||
|
and not (bericht.get("host") or {}).get("nur_lesen"))
|
||||||
|
faellige = faellig(gaeste, jetzt)
|
||||||
|
|
||||||
|
with _lock:
|
||||||
|
daten = zustand()
|
||||||
|
geaendert = _einsammeln(daten)
|
||||||
|
faellig_ids = {str(g["vmid"]) for g in faellige}
|
||||||
|
for vmid, eintrag in daten["gaeste"].items():
|
||||||
|
# Listen wieder frisch (auch von Hand gesucht) oder Gast nicht mehr dabei: Zähler vergessen.
|
||||||
|
if gaeste and eintrag.get("fehlschlaege") and vmid not in faellig_ids:
|
||||||
|
eintrag["fehlschlaege"], eintrag["fehler"] = 0, None
|
||||||
|
geaendert = True
|
||||||
|
if verbunden and nachts and daten.get("nacht") != heute:
|
||||||
|
daten["nacht"] = heute
|
||||||
|
geaendert = True
|
||||||
|
# Nachts sowieso; tagsüber nur, wenn die letzte Nacht verpasst wurde (Instanz oder Ausführer war weg).
|
||||||
|
if verbunden and (nachts or daten.get("nacht") != letzte_nacht):
|
||||||
|
offen = _offene_gaeste()
|
||||||
|
im_update = updates.laufende_ziele()
|
||||||
|
for g in faellige:
|
||||||
|
vmid = g["vmid"]
|
||||||
|
eintrag = daten["gaeste"].setdefault(str(vmid), {})
|
||||||
|
if eintrag.get("tag") == heute or eintrag.get("auftrag") or vmid in offen \
|
||||||
|
or f"ct-{vmid}" in im_update:
|
||||||
|
continue
|
||||||
|
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
|
||||||
|
eintrag["name"] = app.name if app else str(g.get("name") or vmid)
|
||||||
|
if waechter.TROCKEN:
|
||||||
|
log.info("pflege (trocken): würde die Paketlisten von %s erneuern", eintrag["name"])
|
||||||
|
continue
|
||||||
|
eintrag.update(tag=heute, auftrag=kanal.anlegen("suchen", {"vmid": vmid}), angestossen=jetzt)
|
||||||
|
listen = (g.get("os_updates") or {}).get("listen_stand")
|
||||||
|
_ereignis(daten, jetzt, vmid, eintrag["name"], "angestossen",
|
||||||
|
f"Die Paketlisten sind vom {datetime.fromtimestamp(listen, LOCAL_TZ):%d.%m.%Y}.")
|
||||||
|
geaendert = True
|
||||||
|
log.info("pflege: Paketlisten von %s sind alt → suchen", eintrag["name"])
|
||||||
|
if geaendert:
|
||||||
|
_schreiben(daten)
|
||||||
|
return _befunde(daten, faellige)
|
||||||
@@ -0,0 +1,583 @@
|
|||||||
|
"""Protokoll des Homelab-Teils (24.09.2026): was wann geschah, aus allen Quellen, neueste zuerst.
|
||||||
|
|
||||||
|
Quellen, alle nur gelesen:
|
||||||
|
auftrag Aufträge an den Ausführer (kanal.py): Aktion, Gerät, Status, Code; die Ausgabe als Einzelheiten.
|
||||||
|
Berichte nur auf Wunsch (berichte=1): die Aufträge „bericht“ der Update-Läufe und die Berichte, die
|
||||||
|
der Ausführer von sich aus schickt (alle zehn Minuten; kanal.py vermerkt ihren Eingang).
|
||||||
|
lauf „Jetzt updaten“-Läufe mit ihren Schritten (updates.py)
|
||||||
|
snapshot Aktionen der Seite Snapshots (snapshots.py, seit 25.09.2026): anlegen, löschen, zurücksetzen, Sicherung
|
||||||
|
löschen — Läufe wie die Updates, mit eigenen Titeln
|
||||||
|
sammellauf „Alle aktualisieren“ (sammellauf.py)
|
||||||
|
hinweis Hinweise des Wächters: erschienen, erledigt, Selbstreparatur (der Verlauf in mc2-waechter.json;
|
||||||
|
ein Hinweis, der dort schon herausgefallen ist, erscheint mit seinem Beginn)
|
||||||
|
meldung was notify.sh an Telegram gab oder für die Morgenmeldung zurücklegte (Melde-Log des Containers,
|
||||||
|
MC_NOTIFY_LOG). Den Betreff schreibt notify.sh nicht mit; er folgt aus dem Absender: Lauf,
|
||||||
|
Sammellauf, Wächter, Telegram-Test oder Morgenmeldung.
|
||||||
|
pflege das wöchentliche Suchen (pflege.py): angestoßen, gescheitert, wieder ok
|
||||||
|
|
||||||
|
Kein Eintrag enthält Geheimnisse: schwaerzen() nimmt die bekannten Schlüssel dieser Instanz (Arcane, Ausführer) und
|
||||||
|
alles, was wie ein Zugangs-Token aussieht, aus Titeln, Texten und Ausgaben; Steuerzeichen der Konsole fallen weg.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from collections.abc import Callable
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from kern.einstellungen import einstellungen
|
||||||
|
from kern.zeit import LOCAL_TZ
|
||||||
|
|
||||||
|
from services.einstellungen import melde_log
|
||||||
|
from services.homelab import apps, arcane, kanal, pflege, sammellauf, snapshots, updates
|
||||||
|
|
||||||
|
GRENZE_MAX = 1000
|
||||||
|
DETAILS_MAX = 4000
|
||||||
|
TEXT_MAX = 300
|
||||||
|
MELDELOG_MAX = 512 * 1024 # so viel vom Ende des Melde-Logs
|
||||||
|
STUFE_HINWEIS = {"rot": "fehler", "gelb": "warn"}
|
||||||
|
# Betreffzeilen der Absender im Homelab-Teil (notify.sh schreibt sie nicht ins Melde-Log).
|
||||||
|
BETREFF_WAECHTER = ("[Homelab-Problem]", "[Homelab wieder ok]") # waechter.BETREFF_* in der Rolle homelab
|
||||||
|
BETREFF_TEST = "[Test]" # services/einstellungen.telegram_test
|
||||||
|
BETREFF_MORGEN = "[Morgenmeldung Homelab]" # deploy/homelab/mc2-homelab-morgenmeldung.service
|
||||||
|
LUCY_ZUSATZ = " (Diese Meldung kam auch an Lucy.)" # hängt waechter._telegram an
|
||||||
|
|
||||||
|
# Aktion → (erledigt, gescheitert, offen, kurz). Titel sind „Was · Gerät“.
|
||||||
|
_AKTION = {
|
||||||
|
"bericht": ("Bericht geholt", "Bericht gescheitert", "Bericht angefordert", "Bericht"),
|
||||||
|
"snapshot": ("Snapshot angelegt", "Snapshot gescheitert", "Snapshot wird angelegt", "Snapshot"),
|
||||||
|
"update": ("Update-Skript gelaufen", "Update-Skript gescheitert", "Update-Skript läuft", "Update-Skript"),
|
||||||
|
"os_update": ("Pakete eingespielt", "Pakete einspielen gescheitert", "Pakete werden eingespielt",
|
||||||
|
"Pakete einspielen"),
|
||||||
|
"suchen": ("Paketlisten erneuert", "Paketlisten erneuern gescheitert", "Paketlisten werden erneuert",
|
||||||
|
"Paketlisten erneuern"),
|
||||||
|
"zurueck": ("Auf den Snapshot zurückgesetzt", "Zurücksetzen gescheitert", "Wird auf den Snapshot zurückgesetzt",
|
||||||
|
"Zurücksetzen"),
|
||||||
|
# Seit 25.09.2026 nicht mehr „Alter …“: Gelöscht wird auch auf der Seite Snapshots, nicht nur nach einem Update.
|
||||||
|
"snapshot_loeschen": ("Snapshot gelöscht", "Snapshot löschen gescheitert", "Snapshot wird gelöscht",
|
||||||
|
"Snapshot löschen"),
|
||||||
|
"sichern": ("Sicherung angelegt", "Sicherung gescheitert", "Sicherung läuft", "Sicherung"),
|
||||||
|
"sicherung_zurueck": ("Sicherung zurückgespielt", "Zurückspielen gescheitert", "Sicherung wird zurückgespielt",
|
||||||
|
"Zurückspielen"),
|
||||||
|
"sicherung_loeschen": ("Sicherung gelöscht", "Sicherung löschen gescheitert", "Sicherung wird gelöscht",
|
||||||
|
"Sicherung löschen"),
|
||||||
|
"host_update": ("Host-Pakete eingespielt", "Host-Pakete gescheitert", "Host-Pakete werden eingespielt",
|
||||||
|
"Host-Pakete"),
|
||||||
|
"host_neustart": ("Neustart ausgelöst", "Neustart gescheitert", "Neustart angefordert", "Neustart"),
|
||||||
|
"kernel_aufraeumen": ("Alte Kernel entfernt", "Kernel aufräumen gescheitert", "Alte Kernel werden entfernt",
|
||||||
|
"Kernel aufräumen"),
|
||||||
|
"sicherung_probe": ("Probe-Wiederherstellung gelungen", "Probe-Wiederherstellung gescheitert",
|
||||||
|
"Probe-Wiederherstellung läuft", "Probe-Wiederherstellung"),
|
||||||
|
"etikett_setzen": ("Etikett gesetzt", "Etikett setzen gescheitert", "Etikett wird gesetzt", "Etikett setzen"),
|
||||||
|
"changelog": ("Änderungslisten gelesen", "Änderungslisten nicht gelesen", "Änderungslisten werden gelesen",
|
||||||
|
"Änderungslisten"),
|
||||||
|
}
|
||||||
|
_HOST_AKTIONEN = {"bericht", "host_update", "host_neustart", "kernel_aufraeumen", "sicherung_probe", "changelog"}
|
||||||
|
_LAUF_WAS = {"app": "Update", "os": "Paket-Update", "pakete": "Paket-Update", "docker": "Docker-Update",
|
||||||
|
"neustart": "Neustart", "kernel": "Kernel-Aufräumen", "probe": "Rückweg-Probe"}
|
||||||
|
_SCHRITT_WORT = {"wartet": "wartet", "laeuft": "läuft", "eingespielt": "eingespielt",
|
||||||
|
"zurueckgerollt": "zurückgerollt", "fehler": "gescheitert", "uebersprungen": "übersprungen"}
|
||||||
|
_MELDUNG = re.compile(r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) "
|
||||||
|
r"(OK telegram direkt|OK telegram|QUEUED für Morgen-Digest|FALLBACK \((.*?)\)): ?(.*)$")
|
||||||
|
_WEG = {"OK telegram direkt": ("Meldung raus", "info", "Direkt an die Telegram-Bot-API gesendet."),
|
||||||
|
"OK telegram": ("Meldung raus", "info", "Über Hermes an Telegram gesendet."),
|
||||||
|
"QUEUED für Morgen-Digest": ("Meldung zurückgelegt", "info",
|
||||||
|
"Nachtruhe: Sie kommt mit der Morgenmeldung um 07:00.")}
|
||||||
|
_FEHLERZEILE = ("E:", "ERROR", "Error", "error:", "FATAL", "Fehler", "Abgelehnt", "Zeitlimit", "Traceback")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Geheimnisse und Konsolen-Zeichen -------------------------------------------------------------------
|
||||||
|
|
||||||
|
_ANSI = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-Z\\-_])")
|
||||||
|
_STEUER = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
||||||
|
_GEHEIM = (
|
||||||
|
# Telegram-Bot-Token, frei oder in Adressen (…/bot<Token>/sendMessage: dort ohne Wortgrenze davor)
|
||||||
|
(re.compile(r"(?<!\d)\d{6,12}:[A-Za-z0-9_-]{30,}"), "***"),
|
||||||
|
# Zugangsdaten in Adressen: https://nutzer:passwort@host
|
||||||
|
(re.compile(r"(?i)\b([a-z][a-z0-9+.-]*://)[^/\s:@]+:[^/\s@]+@"), r"\1***@"),
|
||||||
|
# Kopfzeilen: Authorization: Bearer …, X-API-Key: …, X-MC2-Ausfuehrer: …
|
||||||
|
(re.compile(r"(?i)\b(authorization|proxy-authorization|x-api-key|x-mc2-ausfuehrer|cookie|set-cookie)"
|
||||||
|
r"([\"']?\s*[:=]\s*[\"']?)(?:(?:bearer|basic|token)\s+)?[^\s\"',;]+"), r"\1\2***"),
|
||||||
|
# Name=Wert bzw. "name": "wert", wenn der Name verrät, dass der Wert geheim ist
|
||||||
|
(re.compile(r"(?i)\b([\w.-]*(?:token|passwor[dt]|secret|api[_-]?key|apikey|kennwort|passphrase|"
|
||||||
|
r"private[_-]?key|access[_-]?key)[\w.-]*)([\"']?\s*[:=]\s*[\"']?)[^\s\"',;&]+"), r"\1\2***"),
|
||||||
|
(re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]{8,}"), "Bearer ***"),
|
||||||
|
# bekannte Token-Formen: GitHub, GitLab, Slack, JWT
|
||||||
|
(re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{20,}|"
|
||||||
|
r"xox[abpr]-[A-Za-z0-9-]{10,})"), "***"),
|
||||||
|
(re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}"), "***"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ausfuehrer_token() -> str:
|
||||||
|
"""Das Geheimnis des Kanals, falls es schon eines gibt — ohne es zu erzeugen (kanal.token() täte das)."""
|
||||||
|
if wert := os.environ.get("MC_AUSFUEHRER_TOKEN", "").strip():
|
||||||
|
return wert
|
||||||
|
try:
|
||||||
|
return (einstellungen().daten_dir / "ausfuehrer.token").read_text(encoding="utf-8").strip()
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _schwaerzer() -> Callable[[str | None], str | None]:
|
||||||
|
"""Eine Schwärz-Funktion mit den bekannten Schlüsseln dieser Instanz (einmal je Protokoll gelesen)."""
|
||||||
|
bekannt = sorted({w for w in (arcane.schluessel(), _ausfuehrer_token()) if len(w) >= 8}, key=len, reverse=True)
|
||||||
|
|
||||||
|
def schwaerzen(text: str | None) -> str | None:
|
||||||
|
if text is None:
|
||||||
|
return None
|
||||||
|
text = _ANSI.sub("", str(text))
|
||||||
|
# Fortschrittsbalken überschreiben ihre Zeile mit \r: Stehen bleibt, was die Konsole zuletzt zeigte.
|
||||||
|
zeilen = []
|
||||||
|
for zeile in text.split("\n"):
|
||||||
|
teile = [t for t in zeile.split("\r") if t.strip()]
|
||||||
|
zeilen.append(teile[-1] if teile else "")
|
||||||
|
text = _STEUER.sub("", "\n".join(zeilen))
|
||||||
|
for wert in bekannt:
|
||||||
|
text = text.replace(wert, "***")
|
||||||
|
for muster, ersatz in _GEHEIM:
|
||||||
|
text = muster.sub(ersatz, text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
return schwaerzen
|
||||||
|
|
||||||
|
|
||||||
|
def schwaerzen(text: str | None) -> str | None:
|
||||||
|
"""Geheimnisse und Steuerzeichen aus einem Text (für Tests und Einzelfälle; eintraege() liest einmal)."""
|
||||||
|
return _schwaerzer()(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _kuerzen(text: str | None, laenge: int = DETAILS_MAX) -> str | None:
|
||||||
|
"""Das Ende langer Ausgaben (dort stehen Ergebnis und Fehler), wenn möglich ab einem Zeilenanfang."""
|
||||||
|
if not text or not text.strip():
|
||||||
|
return None
|
||||||
|
text = text.strip("\n")
|
||||||
|
if len(text) <= laenge:
|
||||||
|
return text
|
||||||
|
rest = text[-(laenge - 2):]
|
||||||
|
if "\n" in rest[:300]:
|
||||||
|
rest = rest.split("\n", 1)[1]
|
||||||
|
return "…\n" + rest
|
||||||
|
|
||||||
|
|
||||||
|
def _zeile(text: str | None) -> str | None:
|
||||||
|
"""Ein Text für das Feld „text“: eine Zeile, höchstens TEXT_MAX Zeichen."""
|
||||||
|
if not text or not str(text).strip():
|
||||||
|
return None
|
||||||
|
ganz = str(text).strip()
|
||||||
|
erste = ganz.splitlines()[0].strip()
|
||||||
|
if len(erste) > TEXT_MAX:
|
||||||
|
return erste[:TEXT_MAX - 1].rstrip() + "…"
|
||||||
|
return erste + ("…" if "\n" in ganz else "")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Geräte ---------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _geraete() -> dict[int, tuple[str, str]]:
|
||||||
|
"""VMID → (Ziel-ID, Name) aus dem letzten Bericht des Ausführers."""
|
||||||
|
geraete: dict[int, tuple[str, str]] = {}
|
||||||
|
for g in ((kanal.bericht() or {}).get("bericht") or {}).get("gaeste") or []:
|
||||||
|
if not isinstance(g, dict) or not isinstance(g.get("vmid"), int):
|
||||||
|
continue
|
||||||
|
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
|
||||||
|
zid = f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g['vmid']}"
|
||||||
|
geraete[g["vmid"]] = (zid, app.name if app else str(g.get("name") or zid))
|
||||||
|
return geraete
|
||||||
|
|
||||||
|
|
||||||
|
def _eintrag(zeit: object, art: str, titel: str, text: str | None, stufe: str, ziel: str | None,
|
||||||
|
details: str | None, schw: Callable) -> dict | None:
|
||||||
|
if not isinstance(zeit, (int, float)):
|
||||||
|
return None
|
||||||
|
return {"zeit": float(zeit), "art": art, "titel": schw(titel), "text": schw(text), "stufe": stufe,
|
||||||
|
"ziel": ziel, "details": schw(details)}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Aufträge an den Ausführer --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _dauer(bis: object, von: object) -> str | None:
|
||||||
|
if not (isinstance(bis, (int, float)) and isinstance(von, (int, float))) or bis < von:
|
||||||
|
return None
|
||||||
|
s = round(bis - von)
|
||||||
|
if s < 60:
|
||||||
|
return f"{s} s"
|
||||||
|
if s < 3600:
|
||||||
|
return f"{s // 60} min"
|
||||||
|
return f"{s // 3600} h {s % 3600 // 60} min"
|
||||||
|
|
||||||
|
|
||||||
|
def _gegenstand(aktion: str, p: dict, ausgabe: str) -> str | None:
|
||||||
|
"""Worum es ging: der Snapshot bzw. die Sicherung."""
|
||||||
|
if aktion in ("zurueck", "snapshot_loeschen"):
|
||||||
|
return str(p.get("snapshot") or "") or None
|
||||||
|
if aktion in ("sicherung_zurueck", "sicherung_loeschen"):
|
||||||
|
return str(p.get("sicherung") or "").rsplit("/", 1)[-1] or None
|
||||||
|
if aktion == "snapshot" and (m := re.search(r"snapshot=(\S+)", ausgabe)):
|
||||||
|
return m.group(1)
|
||||||
|
if aktion == "sichern" and (m := re.search(r"^sicherung=(\S+)$", ausgabe, re.MULTILINE)):
|
||||||
|
return m.group(1).rsplit("/", 1)[-1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fehlerzeile(ausgabe: str) -> str | None:
|
||||||
|
zeilen = [z.strip() for z in ausgabe.splitlines() if z.strip()]
|
||||||
|
wichtig = [z for z in zeilen if z.startswith(_FEHLERZEILE)]
|
||||||
|
zeile = (wichtig or zeilen or [None])[-1]
|
||||||
|
return zeile[:200] if zeile else None
|
||||||
|
|
||||||
|
|
||||||
|
def _bericht_satz(version: object, gaeste: object, laufend: object, host_updates: object) -> str:
|
||||||
|
teile = [f"Proxmox VE {version}" if version else None, f"{gaeste} Gäste, davon {laufend} laufend",
|
||||||
|
f"{host_updates} Paket-Updates auf dem Host" if isinstance(host_updates, int) else None]
|
||||||
|
return ", ".join(t for t in teile if t) + "."
|
||||||
|
|
||||||
|
|
||||||
|
def _bericht_kurz(ausgabe: str) -> str | None:
|
||||||
|
"""Ein Bericht ist JSON; statt seines Endes eine Zusammenfassung."""
|
||||||
|
try:
|
||||||
|
b = json.loads(ausgabe)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if not isinstance(b, dict):
|
||||||
|
return None
|
||||||
|
host = b.get("host") if isinstance(b.get("host"), dict) else {}
|
||||||
|
gaeste = [g for g in b.get("gaeste") or [] if isinstance(g, dict)]
|
||||||
|
return _bericht_satz(host.get("version"), len(gaeste), sum(1 for g in gaeste if g.get("status") == "running"),
|
||||||
|
len(host["updates"]) if isinstance(host.get("updates"), list) else None)
|
||||||
|
|
||||||
|
|
||||||
|
def _bericht_eingang(e: dict, schw: Callable) -> dict | None:
|
||||||
|
"""Ein Bericht, den der Ausführer von sich aus geschickt hat (kein Auftrag)."""
|
||||||
|
return _eintrag(e.get("empfangen"), "auftrag", "Bericht empfangen · Proxmox-Host",
|
||||||
|
_bericht_satz(e.get("host_version"), e.get("gaeste"), e.get("laufend"), e.get("host_updates")),
|
||||||
|
"info", "pve", None, schw)
|
||||||
|
|
||||||
|
|
||||||
|
def _auftrag(a: dict, geraete: dict, schw: Callable) -> dict | None:
|
||||||
|
aktion = str(a.get("aktion") or "?")
|
||||||
|
erledigt, gescheitert, offen, kurz = _AKTION.get(aktion, (aktion, f"{aktion} gescheitert", f"{aktion} läuft",
|
||||||
|
aktion))
|
||||||
|
p = a.get("parameter") if isinstance(a.get("parameter"), dict) else {}
|
||||||
|
ziel, name = None, None
|
||||||
|
if aktion in _HOST_AKTIONEN:
|
||||||
|
ziel, name = "pve", "Proxmox-Host"
|
||||||
|
elif isinstance(p.get("vmid"), int):
|
||||||
|
ziel, name = geraete.get(p["vmid"], (None, f"Gast {p['vmid']}"))
|
||||||
|
status, code = a.get("status"), a.get("code")
|
||||||
|
ausgabe = str(a.get("text") or "")
|
||||||
|
gegenstand = _gegenstand(aktion, p, ausgabe)
|
||||||
|
dauer = _dauer(a.get("fertig"), a.get("abgeholt"))
|
||||||
|
kopf = ", ".join(t for t in (gegenstand, f"nach {dauer}" if dauer else None,
|
||||||
|
f"Code {code}" if code is not None else None) if t)
|
||||||
|
if status == "fertig":
|
||||||
|
titel, stufe, text = erledigt, "ok", kopf or None
|
||||||
|
elif status == "fehler" and code == 2 and ausgabe.startswith("Abgelehnt"):
|
||||||
|
titel, stufe, text = f"{kurz} abgelehnt", "warn", _fehlerzeile(ausgabe)
|
||||||
|
elif status == "fehler":
|
||||||
|
grund = _fehlerzeile(ausgabe)
|
||||||
|
titel, stufe, text = gescheitert, "fehler", f"{kopf}: {grund}" if grund else kopf or None
|
||||||
|
elif status == "verloren":
|
||||||
|
titel, stufe, text = f"{kurz}: keine Antwort", "fehler", "Der Ausführer hat nie geantwortet."
|
||||||
|
elif status == "laeuft":
|
||||||
|
titel, stufe, text = offen, "info", "Der Ausführer ist dran."
|
||||||
|
else:
|
||||||
|
titel, stufe, text = offen, "info", "Wartet auf den Ausführer."
|
||||||
|
details = _bericht_kurz(ausgabe) if aktion == "bericht" else None
|
||||||
|
return _eintrag(a.get("fertig") or a.get("abgeholt") or a.get("erstellt"), "auftrag",
|
||||||
|
f"{titel} · {name}" if name else titel, text, stufe, ziel,
|
||||||
|
details or _kuerzen(schw(ausgabe)), schw)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Läufe und Sammelläufe ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _anzahl_pakete(n: object, wort: str) -> str:
|
||||||
|
return f"{n} {wort}{'' if n == 1 else 'e'} eingespielt" if isinstance(n, int) and n > 0 else f"{wort}e eingespielt"
|
||||||
|
|
||||||
|
|
||||||
|
# Läufe der Seite Snapshots: (Baustein, Ergebnis) → (Titel, Stufe). Gescheitert heißt dort meist: Am Gerät hat sich
|
||||||
|
# nichts geändert (warn) — nur ein gescheitertes Zurücksetzen ist rot.
|
||||||
|
_SNAPSHOT_TITEL = {
|
||||||
|
("snapshot-anlegen", "angelegt"): ("Snapshot angelegt", "ok"),
|
||||||
|
("snapshot-anlegen", "fehler"): ("Snapshot anlegen gescheitert", "warn"),
|
||||||
|
("snapshot-loeschen", "geloescht"): ("Snapshot gelöscht", "ok"),
|
||||||
|
("snapshot-loeschen", "fehler"): ("Snapshot löschen gescheitert", "warn"),
|
||||||
|
("snapshot-zurueck", "zurueckgesetzt"): ("Zurückgesetzt, Prüfung grün", "ok"),
|
||||||
|
("snapshot-zurueck", "fehler"): ("Zurücksetzen gescheitert", "fehler"),
|
||||||
|
("sicherung-loeschen", "geloescht"): ("Sicherung gelöscht", "ok"),
|
||||||
|
("sicherung-loeschen", "fehler"): ("Sicherung löschen gescheitert", "warn"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_lauf(x: dict, schw: Callable) -> dict | None:
|
||||||
|
"""Eine Aktion der Seite Snapshots (snapshots.py): eigene Art „snapshot“, eigene Titel."""
|
||||||
|
name = str(x.get("name") or x.get("ziel") or "?")
|
||||||
|
baustein, status, text = str(x.get("baustein")), x.get("status"), x.get("text")
|
||||||
|
was = snapshots.WAS.get(baustein, "Snapshot-Aktion")
|
||||||
|
if status == "laeuft":
|
||||||
|
titel, stufe = f"{snapshots.LAEUFT.get(baustein, was)} · {name}", "info"
|
||||||
|
elif status == "unterbrochen":
|
||||||
|
titel, stufe = f"{was} unterbrochen · {name}", "fehler"
|
||||||
|
text = text or ("Der Homelab-Teil wurde währenddessen neu gestartet; was am Gerät geschah, zeigt der nächste "
|
||||||
|
"Bericht.")
|
||||||
|
elif baustein == "snapshot-zurueck" and x.get("ergebnis") == "fehler" and x.get("zurueckgesetzt"):
|
||||||
|
titel, stufe = f"Zurückgesetzt, Prüfung rot · {name}", "fehler"
|
||||||
|
else:
|
||||||
|
wort, stufe = _SNAPSHOT_TITEL.get((baustein, str(x.get("ergebnis"))), (f"{was} beendet", "info"))
|
||||||
|
titel = f"{wort} · {name}"
|
||||||
|
zeilen = [str(s) for s in x.get("schritte") or []]
|
||||||
|
if status == "fertig" and x.get("gemeldet") is False:
|
||||||
|
zeilen.append("Ohne Meldung: Das Ergebnis steht auf der Seite Snapshots.")
|
||||||
|
return _eintrag(x.get("ende") or x.get("start"), "snapshot", titel, text, stufe, x.get("ziel"),
|
||||||
|
"\n".join(zeilen) or None, schw)
|
||||||
|
|
||||||
|
|
||||||
|
def _lauf(x: dict, schw: Callable) -> dict | None:
|
||||||
|
if str(x.get("baustein")) in snapshots.LAEUFT:
|
||||||
|
return _snapshot_lauf(x, schw)
|
||||||
|
name = str(x.get("name") or x.get("ziel") or "?")
|
||||||
|
baustein, ergebnis, status, text = x.get("baustein"), x.get("ergebnis"), x.get("status"), x.get("text")
|
||||||
|
was = _LAUF_WAS.get(str(baustein), "Update")
|
||||||
|
if status == "laeuft":
|
||||||
|
titel, stufe = f"{was} läuft · {name}", "info"
|
||||||
|
elif status == "unterbrochen":
|
||||||
|
titel, stufe = f"{was} unterbrochen · {name}", "fehler"
|
||||||
|
text = text or "Der Homelab-Teil wurde während des Laufs neu gestartet; ob das Update durchlief, zeigt der " \
|
||||||
|
"nächste Bericht."
|
||||||
|
elif ergebnis == "eingespielt":
|
||||||
|
stufe = "ok"
|
||||||
|
if baustein == "app":
|
||||||
|
# Seit 25.09.2026 „von → nach“, wo der Lauf beide kennt (Zeitleiste je Gerät).
|
||||||
|
von_nach = (f" {x['version_alt']} → {x['version_neu']}" if x.get("version_alt") and x.get("version_neu")
|
||||||
|
else f" {x['version_neu']}" if x.get("version_neu") else "")
|
||||||
|
titel = f"Update eingespielt · {name}{von_nach}"
|
||||||
|
elif baustein == "docker":
|
||||||
|
titel = f"Docker-Images aktualisiert · {name}"
|
||||||
|
elif baustein == "pakete":
|
||||||
|
titel = f"{_anzahl_pakete(x.get('pakete'), 'Host-Paket')} · {name}"
|
||||||
|
else:
|
||||||
|
titel = f"{_anzahl_pakete(x.get('pakete'), 'Paket')} · {name}"
|
||||||
|
elif ergebnis == "zurueckgerollt":
|
||||||
|
titel, stufe = f"{was} zurückgerollt · {name}", "warn"
|
||||||
|
elif ergebnis == "fehler" and ("Update nicht begonnen" in str(text or "") or "Probe nicht begonnen" in str(text or "")):
|
||||||
|
titel, stufe = f"{was} nicht begonnen · {name}", "warn"
|
||||||
|
elif ergebnis == "fehler":
|
||||||
|
titel, stufe = f"{was} gescheitert · {name}", "fehler"
|
||||||
|
elif ergebnis == "neustart":
|
||||||
|
titel, stufe = f"Neustart ausgelöst · {name}", "info"
|
||||||
|
elif ergebnis == "aufgeraeumt":
|
||||||
|
titel, stufe = f"Alte Kernel entfernt · {name}", "ok"
|
||||||
|
elif ergebnis == "probe-gruen":
|
||||||
|
titel, stufe = f"Rückweg-Probe grün · {name}", "ok"
|
||||||
|
elif ergebnis == "offen":
|
||||||
|
titel, stufe = f"Docker-Probelauf · {name}", "info"
|
||||||
|
else:
|
||||||
|
titel, stufe = f"{was} beendet · {name}", "info"
|
||||||
|
zeilen = [str(s) for s in x.get("schritte") or []]
|
||||||
|
if x.get("sammellauf"):
|
||||||
|
zeilen.append("Teil von „Alle aktualisieren“.")
|
||||||
|
if x.get("gemeldet") is False:
|
||||||
|
zeilen.append("Keine eigene Meldung: Die Sammelmeldung am Ende sagt es.")
|
||||||
|
return _eintrag(x.get("ende") or x.get("start"), "lauf", titel, text, stufe, x.get("ziel"),
|
||||||
|
"\n".join(zeilen) or None, schw)
|
||||||
|
|
||||||
|
|
||||||
|
def _sammellauf(sl: dict, schw: Callable) -> dict | None:
|
||||||
|
schritte = [s for s in sl.get("schritte") or [] if isinstance(s, dict)]
|
||||||
|
anzahl = len(schritte)
|
||||||
|
eingespielt = sum(1 for s in schritte if s.get("status") == "eingespielt")
|
||||||
|
status = sl.get("status")
|
||||||
|
if status == "laeuft":
|
||||||
|
i = sl.get("aktuell")
|
||||||
|
titel = "Alle aktualisieren läuft" + (f" · Schritt {i + 1} von {anzahl}" if isinstance(i, int) else "")
|
||||||
|
stufe = "info"
|
||||||
|
elif status == "abgebrochen":
|
||||||
|
bei = next((s.get("name") for s in schritte if s.get("status") in ("fehler", "zurueckgerollt")), None)
|
||||||
|
titel, stufe = "Alle aktualisieren abgebrochen" + (f" · {bei}" if bei else ""), "fehler"
|
||||||
|
else:
|
||||||
|
titel = f"Alle aktualisieren fertig · {eingespielt} von {anzahl} eingespielt"
|
||||||
|
stufe = "ok" if eingespielt == anzahl else "info"
|
||||||
|
details = "\n".join(f"{s.get('name')} ({s.get('was')}): {_SCHRITT_WORT.get(str(s.get('status')), s.get('status'))}"
|
||||||
|
+ (f" – {s['text']}" if s.get("text") else "") for s in schritte)
|
||||||
|
return _eintrag(sl.get("ende") or sl.get("start"), "sammellauf", titel, sl.get("text"), stufe, None,
|
||||||
|
details or None, schw)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Hinweise des Wächters ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _waechter_pfad() -> Path:
|
||||||
|
"""Wie waechter.STORE_PATH — hier gelesen, ohne den Wächter zu laden (der zieht Module der KI-Box nach)."""
|
||||||
|
return Path(os.environ.get("MC_WAECHTER_STORE", str(einstellungen().daten_dir / "mc2-waechter.json")))
|
||||||
|
|
||||||
|
|
||||||
|
_GERAET = re.compile(r"pve|(?:ct|vm)-\d+")
|
||||||
|
|
||||||
|
|
||||||
|
def _ziel_aus(hinweis_id: str, quelle: object = None) -> str | None:
|
||||||
|
"""Das Gerät eines Hinweises: seit 25.09.2026 aus seiner Quelle, wenn sie ein Gerät ist, sonst aus der ID."""
|
||||||
|
if isinstance(quelle, str) and _GERAET.fullmatch(quelle):
|
||||||
|
return quelle
|
||||||
|
if m := re.search(r"\b(ct|vm)-(\d+)\b", hinweis_id):
|
||||||
|
return f"{m.group(1)}-{m.group(2)}"
|
||||||
|
if m := re.fullmatch(r"gast-platte:(\d+)", hinweis_id):
|
||||||
|
return f"ct-{m.group(1)}"
|
||||||
|
return "pve" if hinweis_id == "ausfuehrer" else None
|
||||||
|
|
||||||
|
|
||||||
|
def _hinweise(schw: Callable) -> list[dict]:
|
||||||
|
try:
|
||||||
|
daten = json.loads(_waechter_pfad().read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return []
|
||||||
|
daten = daten if isinstance(daten, dict) else {}
|
||||||
|
aktuell = daten.get("hinweise") if isinstance(daten.get("hinweise"), dict) else {}
|
||||||
|
eintraege, erschienen = [], set()
|
||||||
|
for v in daten.get("verlauf") or []:
|
||||||
|
if not isinstance(v, dict):
|
||||||
|
continue
|
||||||
|
art, hid = v.get("art"), str(v.get("id") or "")
|
||||||
|
titel, h = str(v.get("text") or hid), aktuell.get(hid) if isinstance(aktuell.get(hid), dict) else {}
|
||||||
|
ziel = _ziel_aus(hid, v.get("quelle") or h.get("quelle"))
|
||||||
|
if art == "neu":
|
||||||
|
erschienen.add(hid)
|
||||||
|
e = _eintrag(v.get("ts"), "hinweis", f"Hinweis: {titel}", h.get("text"),
|
||||||
|
STUFE_HINWEIS.get(str(v.get("stufe") or h.get("stufe")), "warn"), ziel, None, schw)
|
||||||
|
elif art == "erledigt":
|
||||||
|
e = _eintrag(v.get("ts"), "hinweis", f"Erledigt: {titel}", None, "ok", ziel, None, schw)
|
||||||
|
elif art == "auto":
|
||||||
|
e = _eintrag(v.get("ts"), "hinweis", titel, "Selbstreparatur des Wächters.", "info", ziel, None, schw)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
eintraege.append(e)
|
||||||
|
for hid, h in aktuell.items():
|
||||||
|
if hid not in erschienen and isinstance(h, dict):
|
||||||
|
eintraege.append(_eintrag(h.get("seit"), "hinweis", f"Hinweis: {h.get('titel') or hid}", h.get("text"),
|
||||||
|
STUFE_HINWEIS.get(str(h.get("stufe")), "warn"), _ziel_aus(hid), None, schw))
|
||||||
|
return eintraege
|
||||||
|
|
||||||
|
|
||||||
|
# --- Meldungen ------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _meldelog() -> list[list]:
|
||||||
|
"""[Zeit, Weg, Grund, Text] je Eintrag des Melde-Logs; Zeilen ohne Zeitstempel gehören zur Meldung davor."""
|
||||||
|
try:
|
||||||
|
with melde_log().open("rb") as f:
|
||||||
|
f.seek(0, os.SEEK_END)
|
||||||
|
groesse = f.tell()
|
||||||
|
f.seek(max(0, groesse - MELDELOG_MAX))
|
||||||
|
roh = f.read().decode("utf-8", "replace")
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
if groesse > MELDELOG_MAX:
|
||||||
|
roh = roh.split("\n", 1)[-1] # angeschnittene erste Zeile
|
||||||
|
eintraege: list[list] = []
|
||||||
|
for zeile in roh.splitlines():
|
||||||
|
if m := _MELDUNG.match(zeile):
|
||||||
|
try:
|
||||||
|
zeit = datetime.strptime(m.group(1), "%Y-%m-%d %H:%M:%S").replace(tzinfo=LOCAL_TZ).timestamp()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
weg = "FALLBACK" if m.group(2).startswith("FALLBACK") else m.group(2)
|
||||||
|
eintraege.append([zeit, weg, m.group(3), m.group(4)])
|
||||||
|
elif eintraege:
|
||||||
|
eintraege[-1][3] += "\n" + zeile
|
||||||
|
return eintraege
|
||||||
|
|
||||||
|
|
||||||
|
def _betreffe(laeufe: list[dict], sammellaeufe: list[dict]) -> dict[str, str]:
|
||||||
|
"""Meldungstext → Betreff für das, was Läufe und Sammelläufe gemeldet haben."""
|
||||||
|
bekannt = {}
|
||||||
|
for x in laeufe:
|
||||||
|
if x.get("text") and x.get("gemeldet") is not False:
|
||||||
|
if x.get("betreff"): # die Läufe der Seite Snapshots merken sich ihren Betreff selbst
|
||||||
|
bekannt[x["text"]] = x["betreff"]
|
||||||
|
continue
|
||||||
|
dringend = x.get("dringend")
|
||||||
|
if dringend is None: # Läufe von vor dem 24.09.2026 abends: wie updates._ende es entschied
|
||||||
|
dringend = x.get("ergebnis") in ("zurueckgerollt", "fehler") and "nicht begonnen" not in x["text"]
|
||||||
|
bekannt[x["text"]] = updates.BETREFF_ALARM if dringend else updates.BETREFF
|
||||||
|
for sl in sammellaeufe:
|
||||||
|
if sl.get("meldung"):
|
||||||
|
bekannt[sl["meldung"]] = updates.BETREFF_ALARM if sl.get("dringend") else updates.BETREFF
|
||||||
|
return bekannt
|
||||||
|
|
||||||
|
|
||||||
|
def _betreff(text: str, bekannt: dict[str, str]) -> str | None:
|
||||||
|
if text in bekannt:
|
||||||
|
return bekannt[text]
|
||||||
|
if text.endswith(LUCY_ZUSATZ):
|
||||||
|
return BETREFF_WAECHTER[1] if text.startswith("Erledigt:") else BETREFF_WAECHTER[0]
|
||||||
|
if text.startswith("Testmeldung aus den Einstellungen"):
|
||||||
|
return BETREFF_TEST
|
||||||
|
if text.startswith("Guten Morgen, Commander."):
|
||||||
|
return BETREFF_MORGEN
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _meldungen(bekannt: dict[str, str], schw: Callable) -> list[dict]:
|
||||||
|
eintraege = []
|
||||||
|
for zeit, weg, grund, text in _meldelog():
|
||||||
|
betreff = _betreff(text, bekannt)
|
||||||
|
if weg == "FALLBACK":
|
||||||
|
titel, stufe, wie = "Meldung ging nicht raus", "fehler", f"Telegram ging nicht: {grund or 'ohne Angabe'}"
|
||||||
|
else:
|
||||||
|
titel, stufe, wie = _WEG[weg]
|
||||||
|
kurz = _zeile(text)
|
||||||
|
details = wie if kurz == text.strip() else f"{wie}\n\n{text.strip()}"
|
||||||
|
eintraege.append(_eintrag(zeit, "meldung", f"{titel} · {betreff}" if betreff else titel, kurz, stufe, None,
|
||||||
|
details, schw))
|
||||||
|
return eintraege
|
||||||
|
|
||||||
|
|
||||||
|
# --- Pflege ---------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_PFLEGE = {"angestossen": ("Wöchentliche Suche angestoßen", "info"),
|
||||||
|
"gescheitert": ("Wöchentliche Suche gescheitert", "warn"),
|
||||||
|
"wieder_ok": ("Wöchentliche Suche klappt wieder", "ok")}
|
||||||
|
|
||||||
|
|
||||||
|
def _pflege(geraete: dict, schw: Callable) -> list[dict]:
|
||||||
|
daten = pflege.zustand()
|
||||||
|
ereignisse = [e for e in daten.get("ereignisse") or [] if isinstance(e, dict)]
|
||||||
|
eintraege = []
|
||||||
|
for e in ereignisse:
|
||||||
|
if e.get("art") not in _PFLEGE or not isinstance(e.get("vmid"), int):
|
||||||
|
continue
|
||||||
|
titel, stufe = _PFLEGE[e["art"]]
|
||||||
|
name = e.get("name") or geraete.get(e["vmid"], (None, f"Gast {e['vmid']}"))[1]
|
||||||
|
eintraege.append(_eintrag(e.get("ts"), "pflege", f"{titel} · {name}", e.get("text"), stufe,
|
||||||
|
f"ct-{e['vmid']}", None, schw))
|
||||||
|
# Stände von vor dem 24.09.2026 abends haben keine Ereignisliste: dann wenigstens der letzte Anstoß je Gast.
|
||||||
|
schon = {(e.get("vmid"), e.get("ts")) for e in ereignisse if e.get("art") == "angestossen"}
|
||||||
|
for vmid, g in (daten.get("gaeste") or {}).items():
|
||||||
|
if not (isinstance(g, dict) and isinstance(g.get("angestossen"), (int, float)) and str(vmid).isdigit()):
|
||||||
|
continue
|
||||||
|
if (int(vmid), g["angestossen"]) not in schon:
|
||||||
|
name = g.get("name") or geraete.get(int(vmid), (None, f"Gast {vmid}"))[1]
|
||||||
|
eintraege.append(_eintrag(g["angestossen"], "pflege", f"{_PFLEGE['angestossen'][0]} · {name}",
|
||||||
|
"Die Paketlisten waren älter als 7 Tage.", "info", f"ct-{vmid}", None, schw))
|
||||||
|
return eintraege
|
||||||
|
|
||||||
|
|
||||||
|
# --- Zusammen -------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def eintraege(grenze: int = 200, berichte: bool = False, ziel: str | None = None) -> list[dict]:
|
||||||
|
"""Alle Einträge, neueste zuerst, höchstens `grenze` (1 bis GRENZE_MAX). Mit ziel (seit 25.09.2026) nur die eines
|
||||||
|
Geräts — die Zeitleiste je Gerät."""
|
||||||
|
grenze = max(1, min(int(grenze), GRENZE_MAX))
|
||||||
|
schw = _schwaerzer()
|
||||||
|
geraete = _geraete()
|
||||||
|
laeufe = updates.laeufe(100)
|
||||||
|
sammellaeufe = sammellauf.liste()
|
||||||
|
alle: list[dict | None] = []
|
||||||
|
alle += [_auftrag(a, geraete, schw) for a in kanal.liste() if berichte or a.get("aktion") != "bericht"]
|
||||||
|
if berichte:
|
||||||
|
alle += [_bericht_eingang(e, schw) for e in kanal.berichte()]
|
||||||
|
alle += [_lauf(x, schw) for x in laeufe if isinstance(x, dict)]
|
||||||
|
alle += [_sammellauf(sl, schw) for sl in sammellaeufe]
|
||||||
|
alle += _hinweise(schw)
|
||||||
|
alle += _meldungen(_betreffe(laeufe, sammellaeufe), schw)
|
||||||
|
alle += _pflege(geraete, schw)
|
||||||
|
liste = [e for e in alle if e is not None and (ziel is None or e.get("ziel") == ziel)]
|
||||||
|
liste.sort(key=lambda e: e["zeit"], reverse=True)
|
||||||
|
return liste[:grenze]
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
"""„Alle aktualisieren“ im Homelab (24.09.2026): alle Updates mit Knopf nacheinander.
|
||||||
|
|
||||||
|
Jeder Schritt ist ein gewöhnlicher Lauf von „Jetzt updaten“ (updates.starten) und wartet, bis dieser fertig ist —
|
||||||
|
mit Rückweg, Prüfung und Zurückrollen wie beim einzelnen Knopf. Reihenfolge (User-Entscheid 24.09.2026):
|
||||||
|
1. die Gäste nach VMID, außer NPMplus und AdGuard Home
|
||||||
|
2. NPMplus, dann AdGuard Home: Nadelöhre (Proxy, DNS) — fällt einer aus, hängt anderes mit
|
||||||
|
3. ganz zuletzt die Pakete des Proxmox-Hosts; neu gestartet wird der Host dabei nie
|
||||||
|
Dabei ist nur, was „neu“ ist und einen Knopf hat. Was in der Wartezeit nach einer Skriptänderung steht (karenz.py)
|
||||||
|
oder sich über die eigene Oberfläche aktualisiert, hat keinen Knopf und fehlt deshalb. Docker über Arcane ist nur
|
||||||
|
dabei, wenn die Updates echt laufen: Ein Probelauf ändert nichts.
|
||||||
|
|
||||||
|
Endet ein Schritt mit „zurueckgerollt“ oder „fehler“, hört der Sammellauf auf: Die übrigen Schritte sind
|
||||||
|
„uebersprungen“, er selbst ist „abgebrochen“, und es geht eine dringende Meldung raus. Läuft alles durch, kommt eine
|
||||||
|
Sammelmeldung; die Erfolgsmeldungen der einzelnen Schritte hält updates._ende() so lange zurück, Fehlermeldungen
|
||||||
|
nicht. Lehnt updates.starten einen Schritt ab (am Gerät ändert sich dann nichts, etwa weil die Wartezeit inzwischen
|
||||||
|
greift), wird er übersprungen, und es geht weiter.
|
||||||
|
|
||||||
|
Es läuft höchstens ein Sammellauf, und solange er läuft, lehnt der einzelne Knopf ab (updates.SAMMELLAUF_SPERRE).
|
||||||
|
Stand in <Datenordner>/homelab-sammellauf.json (die letzten BEHALTEN). Startet der Homelab-Teil mitten im Lauf neu,
|
||||||
|
gilt er als abgebrochen, „unterbrochen (Neustart)“ (über updates.unterbrochene_abschliessen beim Start).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from kern.einstellungen import einstellungen
|
||||||
|
|
||||||
|
from services import announce
|
||||||
|
from services.homelab import apps, arcane, inventar, kanal, updates
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
NADELOEHRE = ("npmplus", "adguard") # nach allen anderen Gästen, in dieser Reihenfolge
|
||||||
|
GAST_BAUSTEINE = ("app", "os", "docker")
|
||||||
|
TAKT_S = 2.0
|
||||||
|
WARTEN_MAX_S = 4 * 3600 # Sicherheitsnetz; ein Lauf hat je Schritt eigene Zeitlimits (höchstens 45 min)
|
||||||
|
BEHALTEN = 20
|
||||||
|
UNTERBROCHEN = "unterbrochen (Neustart)"
|
||||||
|
HOST_HINWEIS = "Der Proxmox-Host kommt zuletzt und hat keinen Rückweg; neu gestartet wird er nicht."
|
||||||
|
# Was ein Lauf als Ergebnis hat → Stand des Schritts. „offen“ ist ein Arcane-Probelauf: geändert wurde nichts.
|
||||||
|
STATUS = {"eingespielt": "eingespielt", "zurueckgerollt": "zurueckgerollt", "fehler": "fehler",
|
||||||
|
"offen": "uebersprungen"}
|
||||||
|
# Die Felder, die die Oberfläche bekommt (Vertrag GET /api/homelab/sammellauf); der Rest ist für das Protokoll.
|
||||||
|
FELDER = ("id", "status", "start", "ende", "aktuell", "schritte", "text")
|
||||||
|
SCHRITT_FELDER = ("ziel", "name", "baustein", "was", "status", "lauf", "text")
|
||||||
|
|
||||||
|
_lock = threading.RLock() # _merken liest und schreibt unter derselben Sperre
|
||||||
|
|
||||||
|
|
||||||
|
# --- Stand ------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _pfad() -> Path:
|
||||||
|
return einstellungen().daten_dir / "homelab-sammellauf.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _alle() -> list[dict]:
|
||||||
|
with _lock:
|
||||||
|
try:
|
||||||
|
daten = json.loads(_pfad().read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return []
|
||||||
|
return [x for x in daten if isinstance(x, dict) and x.get("id")] if isinstance(daten, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
def _merken(sl: dict) -> None:
|
||||||
|
with _lock:
|
||||||
|
alle = [x for x in _alle() if x["id"] != sl["id"]] + [sl]
|
||||||
|
_pfad().parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = _pfad().with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(alle[-BEHALTEN:], ensure_ascii=False), encoding="utf-8")
|
||||||
|
tmp.replace(_pfad())
|
||||||
|
|
||||||
|
|
||||||
|
def oeffentlich(sl: dict | None) -> dict | None:
|
||||||
|
"""Ein Sammellauf, wie ihn die Oberfläche bekommt (nur die Felder des Vertrags, als Kopie)."""
|
||||||
|
if sl is None:
|
||||||
|
return None
|
||||||
|
return {**{k: sl.get(k) for k in FELDER},
|
||||||
|
"schritte": [{k: s.get(k) for k in SCHRITT_FELDER} for s in sl.get("schritte") or []]}
|
||||||
|
|
||||||
|
|
||||||
|
def liste() -> list[dict]:
|
||||||
|
"""Alle aufbewahrten Sammelläufe, neueste zuerst, mit allen Feldern (für das Protokoll)."""
|
||||||
|
return list(reversed(_alle()))
|
||||||
|
|
||||||
|
|
||||||
|
def aktueller() -> dict | None:
|
||||||
|
"""Der laufende oder, wenn keiner läuft, der letzte Sammellauf — solange ihn niemand quittiert hat (seit
|
||||||
|
25.09.2026: vorher stand der letzte für immer auf der Update-Seite)."""
|
||||||
|
alle = _alle()
|
||||||
|
if laufend := next((x for x in reversed(alle) if x.get("status") == "laeuft"), None):
|
||||||
|
return oeffentlich(laufend)
|
||||||
|
letzter = alle[-1] if alle else None
|
||||||
|
return None if not letzter or letzter.get("quittiert") else oeffentlich(letzter)
|
||||||
|
|
||||||
|
|
||||||
|
def quittieren(sl_id: str) -> bool:
|
||||||
|
"""Einen beendeten Sammellauf als gesehen markieren; das Protokoll behält ihn."""
|
||||||
|
with _lock:
|
||||||
|
sl = next((x for x in _alle() if x["id"] == sl_id), None)
|
||||||
|
if not sl or sl.get("status") == "laeuft":
|
||||||
|
return False
|
||||||
|
sl["quittiert"] = True
|
||||||
|
_merken(sl)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def laeuft() -> bool:
|
||||||
|
return any(x.get("status") == "laeuft" for x in _alle())
|
||||||
|
|
||||||
|
|
||||||
|
def _aufzaehlung(namen: list[str]) -> str:
|
||||||
|
"""„A“, „A und B“, „A, B und C“."""
|
||||||
|
namen = list(dict.fromkeys(namen))
|
||||||
|
return namen[0] if len(namen) == 1 else ", ".join(namen[:-1]) + " und " + namen[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def _melden(text: str, dringend: bool = False) -> None:
|
||||||
|
"""Derselbe Meldeweg und Betreff wie beim einzelnen Knopf (updates.py)."""
|
||||||
|
try:
|
||||||
|
announce.notify_telegram(updates.BETREFF_ALARM if dringend else updates.BETREFF, text)
|
||||||
|
except Exception:
|
||||||
|
log.warning("homelab: Meldung zu „Alle aktualisieren“ ging nicht raus", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Plan -------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _kennung(ziel_id: str) -> str | None:
|
||||||
|
gast = inventar.gast(ziel_id) or {}
|
||||||
|
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
|
||||||
|
return app.kennung if app else None
|
||||||
|
|
||||||
|
|
||||||
|
def _rang(schritt: dict) -> tuple[int, int]:
|
||||||
|
"""Erst alle anderen Gäste nach VMID, dann die Nadelöhre in ihrer Reihenfolge."""
|
||||||
|
kennung = _kennung(schritt["ziel"])
|
||||||
|
nadel = NADELOEHRE.index(kennung) + 1 if kennung in NADELOEHRE else 0
|
||||||
|
return nadel, int(schritt["ziel"].split("-", 1)[1])
|
||||||
|
|
||||||
|
|
||||||
|
def plan() -> dict:
|
||||||
|
"""Was „Alle aktualisieren“ jetzt täte, in dieser Reihenfolge, samt Hinweis auf das, was keinen Rückweg hat.
|
||||||
|
{"schritte": [{"ziel", "name", "baustein", "was", "rueckweg_art"}], "hinweis": str | None}"""
|
||||||
|
gaeste: list[dict] = []
|
||||||
|
host: list[dict] = []
|
||||||
|
docker_probe = False
|
||||||
|
for z in inventar.ziele()["ziele"]:
|
||||||
|
for b in z.get("bausteine") or []:
|
||||||
|
if b.get("zustand") != "neu" or not b.get("aktion"):
|
||||||
|
continue
|
||||||
|
schritt = {"ziel": z["id"], "name": z["name"], "baustein": b["id"], "was": b.get("kurz") or b["name"],
|
||||||
|
"rueckweg_art": z.get("rueckweg_art") or "keiner"}
|
||||||
|
if z["id"] == "pve":
|
||||||
|
if b["id"] == "pakete": # der Neustart ist nie dabei
|
||||||
|
host.append({**schritt, "rueckweg_art": "keiner"})
|
||||||
|
elif b["id"] == "docker":
|
||||||
|
if not arcane.echt():
|
||||||
|
docker_probe = True # ein Probelauf ist kein Update
|
||||||
|
else:
|
||||||
|
gaeste.append(schritt) # Snapshot der VM nur mit Etikett watcher — rueckweg_art sagt es
|
||||||
|
elif b["id"] in GAST_BAUSTEINE:
|
||||||
|
gaeste.append(schritt)
|
||||||
|
gaeste.sort(key=_rang) # sort ist stabil: je Gast bleibt die Reihenfolge seiner Bausteine
|
||||||
|
schritte = gaeste + host
|
||||||
|
teile = []
|
||||||
|
ohne = [s["name"] for s in gaeste if s["rueckweg_art"] == "keiner"]
|
||||||
|
if ohne:
|
||||||
|
teile.append(f"Ohne Rückweg: {_aufzaehlung(ohne)} – scheitert dort das Update, geht es nicht von selbst "
|
||||||
|
"zurück.")
|
||||||
|
if host:
|
||||||
|
teile.append(HOST_HINWEIS)
|
||||||
|
if docker_probe:
|
||||||
|
teile.append("Die Docker-Images (Arcane) sind nicht dabei, solange Docker-Updates nur als Probelauf laufen.")
|
||||||
|
return {"schritte": schritte, "hinweis": " ".join(teile) or None}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Starten ----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _ausfuehrer_grund() -> str | None:
|
||||||
|
"""Warum der Ausführer gerade keine Updates annimmt — oder None."""
|
||||||
|
zuletzt = kanal.zuletzt()
|
||||||
|
if not (zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S):
|
||||||
|
return "Der Ausführer auf dem Proxmox-Host ist nicht verbunden. Ohne ihn geht kein Update."
|
||||||
|
host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}
|
||||||
|
if host.get("nur_lesen"):
|
||||||
|
return "Der Ausführer auf dem Proxmox-Host läuft im Nur-Lesen-Modus und lehnt Updates ab."
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def starten() -> dict:
|
||||||
|
"""Knopf „Alle aktualisieren“. {"ok": True, "sammellauf": {…}} oder {"ok": False, "detail": …}."""
|
||||||
|
if laeuft():
|
||||||
|
return {"ok": False, "detail": updates.SAMMELLAUF_SPERRE}
|
||||||
|
schritte = plan()["schritte"]
|
||||||
|
if not schritte:
|
||||||
|
return {"ok": False, "detail": "Gerade gibt es kein Update mit Knopf, also nichts zu tun."}
|
||||||
|
if any(s["baustein"] != "docker" for s in schritte) and (grund := _ausfuehrer_grund()):
|
||||||
|
return {"ok": False, "detail": grund}
|
||||||
|
with updates.START_SPERRE:
|
||||||
|
if laeuft():
|
||||||
|
return {"ok": False, "detail": updates.SAMMELLAUF_SPERRE}
|
||||||
|
if laufende := [x.get("name") or x.get("ziel") for x in updates.laeufe(100) if x.get("status") == "laeuft"]:
|
||||||
|
return {"ok": False, "detail": f"Gerade läuft ein einzelnes Update ({_aufzaehlung(laufende)}). "
|
||||||
|
"„Alle aktualisieren“ geht, sobald es fertig ist."}
|
||||||
|
sl = {"id": uuid.uuid4().hex[:12], "status": "laeuft", "start": time.time(), "ende": None, "aktuell": None,
|
||||||
|
"schritte": [{"ziel": s["ziel"], "name": s["name"], "baustein": s["baustein"], "was": s["was"],
|
||||||
|
"status": "wartet", "lauf": None, "text": None} for s in schritte],
|
||||||
|
"text": None, "meldung": None, "dringend": False}
|
||||||
|
_merken(sl)
|
||||||
|
antwort = oeffentlich(sl) # eine Kopie: Der Faden ändert sl gleich weiter
|
||||||
|
threading.Thread(target=_ablauf, args=(sl,), name="homelab-alle", daemon=True).start()
|
||||||
|
log.info("homelab: „Alle aktualisieren“ %s mit %d Schritten gestartet", sl["id"], len(schritte))
|
||||||
|
return {"ok": True, "sammellauf": antwort}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Ablauf -----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _warten(lauf_id: str) -> dict | None:
|
||||||
|
"""Bis der Lauf fertig ist; None, wenn er sich in WARTEN_MAX_S nicht zurückmeldet."""
|
||||||
|
ende = time.time() + WARTEN_MAX_S
|
||||||
|
while time.time() < ende:
|
||||||
|
lauf = updates.lauf_lesen(lauf_id)
|
||||||
|
if lauf is not None and lauf.get("status") != "laeuft":
|
||||||
|
return lauf
|
||||||
|
time.sleep(TAKT_S)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _schritt(sl: dict, schritt: dict) -> None:
|
||||||
|
"""Einen Schritt gehen: den Lauf anstoßen, auf ihn warten, sein Ergebnis übernehmen."""
|
||||||
|
if schritt["baustein"] == "docker" and not arcane.echt():
|
||||||
|
schritt.update(status="uebersprungen", text="Docker-Updates über Arcane stehen inzwischen auf Probelauf.")
|
||||||
|
return
|
||||||
|
antwort = updates.starten(schritt["ziel"], schritt["baustein"], sammellauf={"id": sl["id"], "start": sl["start"]})
|
||||||
|
if not antwort.get("ok"):
|
||||||
|
schritt.update(status="uebersprungen", text=str(antwort.get("detail") or "abgelehnt"))
|
||||||
|
return
|
||||||
|
schritt["lauf"] = antwort["lauf"]
|
||||||
|
_merken(sl)
|
||||||
|
lauf = _warten(antwort["lauf"])
|
||||||
|
if lauf is None:
|
||||||
|
schritt.update(status="fehler", text=f"Der Lauf hat sich in {WARTEN_MAX_S // 3600} Stunden nicht "
|
||||||
|
"zurückgemeldet.")
|
||||||
|
return
|
||||||
|
schritt.update(status=STATUS.get(str(lauf.get("ergebnis")), "fehler"), text=lauf.get("text"))
|
||||||
|
|
||||||
|
|
||||||
|
def _ablauf(sl: dict) -> None:
|
||||||
|
try:
|
||||||
|
for i, schritt in enumerate(sl["schritte"]):
|
||||||
|
sl["aktuell"] = i
|
||||||
|
schritt["status"] = "laeuft"
|
||||||
|
_merken(sl)
|
||||||
|
_schritt(sl, schritt)
|
||||||
|
_merken(sl)
|
||||||
|
if schritt["status"] in ("zurueckgerollt", "fehler"):
|
||||||
|
_abbrechen(sl, i)
|
||||||
|
return
|
||||||
|
_abschliessen(sl)
|
||||||
|
except Exception as exc:
|
||||||
|
# Sonst hieße der Sammellauf bis zum nächsten Neustart „läuft“, und jeder einzelne Knopf wäre gesperrt.
|
||||||
|
log.exception("homelab: „Alle aktualisieren“ %s ist abgestürzt", sl.get("id"))
|
||||||
|
_beenden(sl, f"Interner Fehler: {exc.__class__.__name__}.",
|
||||||
|
f"„Alle aktualisieren“ ist mit einem internen Fehler stehen geblieben ({exc.__class__.__name__}).")
|
||||||
|
|
||||||
|
|
||||||
|
def _abschliessen(sl: dict) -> None:
|
||||||
|
eingespielt = [s for s in sl["schritte"] if s["status"] == "eingespielt"]
|
||||||
|
uebersprungen = [s for s in sl["schritte"] if s["status"] == "uebersprungen"]
|
||||||
|
anzahl = len(eingespielt)
|
||||||
|
if anzahl:
|
||||||
|
text = f"{anzahl} {'Update' if anzahl == 1 else 'Updates'} eingespielt, alle Prüfungen grün."
|
||||||
|
else:
|
||||||
|
text = "Kein Update eingespielt."
|
||||||
|
if uebersprungen:
|
||||||
|
text += " Nicht gelaufen: " + "; ".join(f"{s['name']} ({str(s.get('text') or 'abgelehnt').rstrip('.')})"
|
||||||
|
for s in uebersprungen) + "."
|
||||||
|
sl.update(status="fertig", ende=time.time(), aktuell=None, text=text, meldung=f"Homelab: {text}", dringend=False)
|
||||||
|
_merken(sl)
|
||||||
|
log.info("homelab: „Alle aktualisieren“ %s fertig: %s", sl["id"], text)
|
||||||
|
_melden(sl["meldung"])
|
||||||
|
|
||||||
|
|
||||||
|
def _beenden(sl: dict, text: str, anlass: str) -> None:
|
||||||
|
"""Abbrechen: offene Schritte „uebersprungen“, Status „abgebrochen“, dringende Meldung."""
|
||||||
|
rest = [s for s in sl["schritte"] if s["status"] in ("wartet", "laeuft")]
|
||||||
|
for s in rest:
|
||||||
|
s.update(status="uebersprungen", text="Nicht mehr gelaufen: „Alle aktualisieren“ wurde abgebrochen.")
|
||||||
|
eingespielt = [s["name"] for s in sl["schritte"] if s["status"] == "eingespielt"]
|
||||||
|
meldung = f"Homelab: {anlass}"
|
||||||
|
if eingespielt:
|
||||||
|
meldung += f" Vorher eingespielt: {_aufzaehlung(eingespielt)}."
|
||||||
|
if rest:
|
||||||
|
meldung += f" Nicht mehr gelaufen: {_aufzaehlung([s['name'] for s in rest])}."
|
||||||
|
sl.update(status="abgebrochen", ende=time.time(), aktuell=None, text=text, meldung=meldung, dringend=True)
|
||||||
|
_merken(sl)
|
||||||
|
log.warning("homelab: „Alle aktualisieren“ %s abgebrochen: %s", sl["id"], text)
|
||||||
|
_melden(meldung, dringend=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _abbrechen(sl: dict, i: int) -> None:
|
||||||
|
schritt = sl["schritte"][i]
|
||||||
|
wie = "zurückgerollt" if schritt["status"] == "zurueckgerollt" else "gescheitert"
|
||||||
|
_beenden(sl, f"Abgebrochen bei {schritt['name']}: Update {wie}.",
|
||||||
|
f"„Alle aktualisieren“ abgebrochen bei {schritt['name']} (Update {wie}).")
|
||||||
|
|
||||||
|
|
||||||
|
def unterbrochene_abschliessen() -> None:
|
||||||
|
"""Beim Start (über updates.unterbrochene_abschliessen): Ein Sammellauf, der noch „läuft“, gehörte zum vorigen
|
||||||
|
Prozess. Er gilt als abgebrochen; die Meldung geht nebenher raus, damit der Start nicht auf Telegram wartet."""
|
||||||
|
for sl in _alle():
|
||||||
|
if sl.get("status") != "laeuft":
|
||||||
|
continue
|
||||||
|
bei = None
|
||||||
|
for s in sl["schritte"]:
|
||||||
|
if s.get("status") != "laeuft":
|
||||||
|
continue
|
||||||
|
bei = s["name"]
|
||||||
|
# War sein Lauf noch fertig geworden, zählt dessen Ergebnis; sonst ist offen, was am Gerät geschah.
|
||||||
|
lauf = updates.lauf_lesen(s["lauf"]) if s.get("lauf") else None
|
||||||
|
if lauf is not None and lauf.get("status") == "fertig" and lauf.get("ergebnis") in STATUS:
|
||||||
|
s.update(status=STATUS[lauf["ergebnis"]], text=lauf.get("text"))
|
||||||
|
else:
|
||||||
|
s.update(status="fehler", text=UNTERBROCHEN)
|
||||||
|
rest = [s for s in sl["schritte"] if s.get("status") == "wartet"]
|
||||||
|
for s in rest:
|
||||||
|
s.update(status="uebersprungen", text="Nicht mehr gelaufen: „Alle aktualisieren“ wurde abgebrochen.")
|
||||||
|
eingespielt = [s["name"] for s in sl["schritte"] if s.get("status") == "eingespielt"]
|
||||||
|
meldung = ("Homelab: „Alle aktualisieren“ wurde unterbrochen: Der Homelab-Teil ist mitten im Lauf neu "
|
||||||
|
"gestartet" + (f" (bei {bei})" if bei else "") + ".")
|
||||||
|
if eingespielt:
|
||||||
|
meldung += f" Vorher eingespielt: {_aufzaehlung(eingespielt)}."
|
||||||
|
if rest:
|
||||||
|
meldung += f" Nicht mehr gelaufen: {_aufzaehlung([s['name'] for s in rest])}."
|
||||||
|
meldung += " Bitte den Stand in der Übersicht prüfen."
|
||||||
|
sl.update(status="abgebrochen", ende=time.time(), aktuell=None, text=UNTERBROCHEN, meldung=meldung,
|
||||||
|
dringend=True)
|
||||||
|
_merken(sl)
|
||||||
|
log.warning("homelab: „Alle aktualisieren“ %s war beim Start noch offen → abgebrochen", sl["id"])
|
||||||
|
threading.Thread(target=_melden, args=(meldung, True), name="homelab-alle-meldung", daemon=True).start()
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""Sicherheits-Markierung (Ausbauplan Welle 2, Punkt 11, seit 25.09.2026).
|
||||||
|
|
||||||
|
Welche offenen Paket-Updates schließen eine Sicherheitslücke?
|
||||||
|
• Debian und Ubuntu (Gäste und Proxmox-Host) liefern sie aus eigenen Suiten („trixie-security“); das zählt der
|
||||||
|
Ausführer selbst (os_updates.sicherheit, host.updates_sicherheit).
|
||||||
|
• Alpine (NPMplus) kennt keine solche Suite. Dafür führt Alpine eine Sicherheitsdatenbank (secdb.alpinelinux.org,
|
||||||
|
je Zweig main und community): je Paket die Versionen, die Lücken schließen. Ein Update zählt, wenn eine davon
|
||||||
|
nach der installierten und höchstens bei der neuen Version liegt. Die Datenbank lädt dieser Teil im Hintergrund
|
||||||
|
und merkt sie zwölf Stunden (<Datenordner>/alpine-secdb-v<zweig>.json); bis sie da ist, heißt es „unbekannt“.
|
||||||
|
Docker-Images prüft Arcane selbst (Trivy, sonntags 02:00): docker.py holt dessen Befunde je Image.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from kern.einstellungen import einstellungen
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SECDB = "https://secdb.alpinelinux.org/v{zweig}/{repo}.json"
|
||||||
|
NEU_LADEN_S = 12 * 3600
|
||||||
|
_SUFFIX = {"alpha": -4, "beta": -3, "pre": -2, "rc": -1, "cvs": 1, "svn": 1, "git": 1, "hg": 1, "p": 2}
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_laedt: set[str] = set()
|
||||||
|
_gemerkt: dict[str, tuple[float, dict]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def apk_teile(version: str) -> tuple:
|
||||||
|
"""Eine Alpine-Version („1.37.0-r18“, „3.3.2_rc1-r0“, „1.2a-r1“) als vergleichbares Tupel: Zahlen (Buchstaben
|
||||||
|
als kleine Zahlen), Zusätze (_alpha < _beta < _pre < _rc < ohne < _p), zuletzt die Paketrevision -rN."""
|
||||||
|
haupt, _, revision = version.partition("-r")
|
||||||
|
kern, *zusaetze = haupt.split("_")
|
||||||
|
teile = tuple(int(x) if x.isdigit() else ord(x[0]) - 96 for x in re.findall(r"\d+|[a-z]+", kern.lower()))
|
||||||
|
suffixe = []
|
||||||
|
for z in zusaetze:
|
||||||
|
m = re.fullmatch(r"([a-z]+)(\d*)", z)
|
||||||
|
suffixe.append((_SUFFIX.get(m.group(1), 0), int(m.group(2) or 0)) if m else (0, 0))
|
||||||
|
return (teile, tuple(suffixe) or ((0, 0),), int(revision) if revision.isdigit() else 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _pfad(zweig: str) -> Path:
|
||||||
|
return einstellungen().daten_dir / f"alpine-secdb-v{zweig}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _laden(zweig: str) -> None:
|
||||||
|
"""Beide Repos des Zweigs holen und als {paket: [Versionen mit Fixes]} ablegen (eigener Faden)."""
|
||||||
|
try:
|
||||||
|
fixes: dict[str, list[str]] = {}
|
||||||
|
for repo in ("main", "community"):
|
||||||
|
r = httpx.get(SECDB.format(zweig=zweig, repo=repo), timeout=30, follow_redirects=True)
|
||||||
|
r.raise_for_status()
|
||||||
|
for eintrag in r.json().get("packages") or []:
|
||||||
|
pkg = (eintrag or {}).get("pkg") or {}
|
||||||
|
if pkg.get("name") and isinstance(pkg.get("secfixes"), dict):
|
||||||
|
fixes.setdefault(pkg["name"], []).extend(v for v in pkg["secfixes"] if v != "0")
|
||||||
|
pfad = _pfad(zweig)
|
||||||
|
pfad.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = pfad.with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps({"geholt": time.time(), "fixes": fixes}), encoding="utf-8")
|
||||||
|
tmp.replace(pfad)
|
||||||
|
log.info("sicherheit: Alpine-Sicherheitsdatenbank v%s geladen (%d Pakete)", zweig, len(fixes))
|
||||||
|
except Exception:
|
||||||
|
log.warning("sicherheit: Alpine-Sicherheitsdatenbank v%s nicht geladen", zweig, exc_info=True)
|
||||||
|
finally:
|
||||||
|
with _lock:
|
||||||
|
_laedt.discard(zweig)
|
||||||
|
|
||||||
|
|
||||||
|
def secdb(zweig: str, jetzt: float | None = None) -> dict | None:
|
||||||
|
"""{paket: [Versionen mit Fixes]} — aus der Datei, im Speicher gemerkt; ist sie alt oder fehlt, lädt ein Faden
|
||||||
|
sie nach (bis dahin die alte oder None). Blockiert nie."""
|
||||||
|
jetzt = time.time() if jetzt is None else jetzt
|
||||||
|
pfad = _pfad(zweig)
|
||||||
|
daten = None
|
||||||
|
try:
|
||||||
|
mtime = pfad.stat().st_mtime
|
||||||
|
with _lock:
|
||||||
|
alt = _gemerkt.get(zweig)
|
||||||
|
if alt and alt[0] == mtime:
|
||||||
|
daten = alt[1]
|
||||||
|
else:
|
||||||
|
daten = json.loads(pfad.read_text(encoding="utf-8"))
|
||||||
|
with _lock:
|
||||||
|
_gemerkt[zweig] = (mtime, daten)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
daten = None
|
||||||
|
if daten is None or jetzt - float(daten.get("geholt") or 0) > NEU_LADEN_S:
|
||||||
|
with _lock:
|
||||||
|
starten = zweig not in _laedt
|
||||||
|
_laedt.add(zweig)
|
||||||
|
if starten:
|
||||||
|
threading.Thread(target=_laden, args=(zweig,), name=f"secdb-{zweig}", daemon=True).start()
|
||||||
|
return daten.get("fixes") if daten else None
|
||||||
|
|
||||||
|
|
||||||
|
def alpine_sicherheit(os_stand: dict, fixes: dict | None = None) -> int | None:
|
||||||
|
"""Wie viele der offenen Alpine-Updates eine Lücke schließen (None, solange die Datenbank fehlt)."""
|
||||||
|
pakete = os_stand.get("pakete")
|
||||||
|
if not isinstance(pakete, list):
|
||||||
|
return None
|
||||||
|
if not pakete:
|
||||||
|
return 0
|
||||||
|
if fixes is None:
|
||||||
|
zweig = ".".join(str(os_stand.get("alpine") or "").split(".")[:2])
|
||||||
|
if not re.fullmatch(r"\d+\.\d+", zweig):
|
||||||
|
return None
|
||||||
|
fixes = secdb(zweig)
|
||||||
|
if fixes is None:
|
||||||
|
return None
|
||||||
|
anzahl = 0
|
||||||
|
for p in pakete:
|
||||||
|
alt, neu = apk_teile(str(p.get("alt") or "")), apk_teile(str(p.get("neu") or ""))
|
||||||
|
if any(alt < apk_teile(v) <= neu for v in fixes.get(str(p.get("name")), [])):
|
||||||
|
anzahl += 1
|
||||||
|
return anzahl
|
||||||
@@ -0,0 +1,594 @@
|
|||||||
|
"""Snapshots und Sicherungen verwalten — die Seite Homelab → Snapshots (seit 25.09.2026, User-Wunsch).
|
||||||
|
|
||||||
|
Liste (liste): je Gerät aus dem Bericht des Ausführers die Snapshots mit Zeitpunkt, Beschreibung und Herkunft, dazu die
|
||||||
|
Sicherungen des Orchestrators (vzdump-Archive, heute nur beim PBS, der keinen Snapshot kann) und ob für das Gerät
|
||||||
|
gerade ein Lauf läuft. Ein Ausführer vor dem 25.09.2026 liefert nur die Namen (snapshots, sicherungen): Dann kommt der
|
||||||
|
Zeitpunkt aus dem Namen (mc2-JJJJMMTT-HHMMSS bzw. …-JJJJ_MM_TT-HH_MM_SS, Ortszeit des Hosts), von Hand angelegte
|
||||||
|
Snapshots stehen ohne Zeit da. Wie viel ein Snapshot belegt, sagt Proxmox bei LVM-Thin nicht — hier steht deshalb keine
|
||||||
|
Zahl, die Seite sagt es so.
|
||||||
|
|
||||||
|
Herkunft eines Snapshots:
|
||||||
|
update Rückweg vor einem Update (Name mc2-…)
|
||||||
|
knopf „Snapshot anlegen“ auf dieser Seite (Name mc2-…; der Ausführer schreibt „Auf Knopfdruck …“ in die
|
||||||
|
Beschreibung, und der Lauf hier merkt sich den Namen). Solche räumt kein grünes Update weg
|
||||||
|
(updates._aufraeumen fragt knopf_namen) — die löscht nur der User.
|
||||||
|
proxmox von Hand in Proxmox angelegt: nur zur Ansicht, nie gelöscht oder zurückgesetzt (der Ausführer lehnt das
|
||||||
|
ohnehin ab, er fasst nur mc2-… an).
|
||||||
|
|
||||||
|
Die Aktionen laufen als Läufe wie „Jetzt updaten“ (Mechanik aus updates.py, bewusst mitbenutzt): in
|
||||||
|
homelab-laeufe.json, je Gerät höchstens einer, nie während „Alle aktualisieren“ (SAMMELLAUF_SPERRE) oder eines Updates
|
||||||
|
irgendwo (_sperre: der Ausführer arbeitet einen Auftrag nach dem anderen ab) und nur, wenn der Ausführer verbunden ist.
|
||||||
|
So stehen sie im Protokoll, der Wächter lässt ein Gerät mitten im Zurücksetzen in Ruhe, und nichts kommt einem Update
|
||||||
|
in die Quere. Neue Befehle braucht der Ausführer nicht:
|
||||||
|
snapshot-anlegen snapshot (Anlass „knopf“) → frischer Bericht. Eine Meldung nur, wenn es scheitert.
|
||||||
|
snapshot-loeschen snapshot_loeschen (nur mc2-…) → frischer Bericht. Eine Meldung nur, wenn es scheitert.
|
||||||
|
snapshot-zurueck zurueck (nur mc2-…; der Ausführer stoppt, setzt zurück, startet) → 20 s warten → frischer Bericht
|
||||||
|
→ Prüfung wie nach einem Update (läuft, Weboberfläche antwortet). Immer mit Meldung, dringend, wenn
|
||||||
|
es scheitert oder die Prüfung rot ist.
|
||||||
|
sicherung-loeschen sicherung_loeschen (nur Sicherungen des Orchestrators) → frischer Bericht. Meldung nur bei Fehler.
|
||||||
|
Im Update-Verlauf stehen sie nicht: Das sind keine Updates.
|
||||||
|
|
||||||
|
Seit dem 25.09.2026 außerdem (User-Klärung): je Gerät „Rückweg testen“ (updates.starten(…, "probe"): Snapshot bzw.
|
||||||
|
Sicherung, absichtlich rot, zurück, Prüfung) und oben „Alle Update-Rückwege löschen“ (rueckwege_loeschen): löscht die
|
||||||
|
Snapshots mit Herkunft „update“ und die Sicherungen des Orchestrators nacheinander über dieselben Läufe wie die
|
||||||
|
einzelnen Knöpfe. Per Knopf und von Hand angelegte Snapshots bleiben. Endgültiges Löschen geschieht nur auf den Klick
|
||||||
|
des Users hin — nie von selbst.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from kern.zeit import LOCAL_TZ
|
||||||
|
|
||||||
|
from services import announce
|
||||||
|
from services.homelab import apps, inventar, kanal, sammellauf, updates
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Wie SNAPSHOT_NAME im Ausführer: Nur solche Snapshots fasst er an. Der Zeitpunkt im Namen ist die Ortszeit des
|
||||||
|
# Proxmox-Hosts (Europe/Berlin, wie LOCAL_TZ).
|
||||||
|
SNAPSHOT_NAME = re.compile(r"^mc2-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$")
|
||||||
|
ARCHIV_ZEIT = re.compile(r"-(\d{4})_(\d{2})_(\d{2})-(\d{2})_(\d{2})_(\d{2})\.")
|
||||||
|
KNOPF_BESCHREIBUNG = "Auf Knopfdruck" # so beginnt die Beschreibung, die der Ausführer beim Anlass „knopf“ setzt
|
||||||
|
# Die Läufe dieser Seite: was gerade geschieht (für „Läuft: …“) und wie die Aktion heißt.
|
||||||
|
LAEUFT = {"snapshot-anlegen": "Snapshot wird angelegt", "snapshot-loeschen": "Snapshot wird gelöscht",
|
||||||
|
"snapshot-zurueck": "Wird zurückgesetzt", "sicherung-loeschen": "Sicherung wird gelöscht"}
|
||||||
|
WAS = {"snapshot-anlegen": "Snapshot anlegen", "snapshot-loeschen": "Snapshot löschen",
|
||||||
|
"snapshot-zurueck": "Zurücksetzen", "sicherung-loeschen": "Sicherung löschen"}
|
||||||
|
GUT = {"angelegt", "geloescht", "zurueckgesetzt"}
|
||||||
|
ZULETZT_S = 6 * 3600 # so lange steht das Ergebnis der letzten Aktion beim Gerät
|
||||||
|
BETREFF = "[Homelab-Snapshot]"
|
||||||
|
BETREFF_ALARM = "[Alarm] Homelab-Snapshot"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Kleine Helfer -------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _zid(g: dict) -> str:
|
||||||
|
return f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g['vmid']}"
|
||||||
|
|
||||||
|
|
||||||
|
def _wo(g: dict) -> str:
|
||||||
|
return f"{'Container' if g.get('art') == 'lxc' else 'VM'} {g['vmid']}"
|
||||||
|
|
||||||
|
|
||||||
|
def _name(g: dict) -> str:
|
||||||
|
"""Der Name wie in der Update-Liste (Gitea, Arcane (Docker) …)."""
|
||||||
|
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
|
||||||
|
return app.name if app else str(g.get("name") or _zid(g))
|
||||||
|
|
||||||
|
|
||||||
|
def _ortszeit(m: re.Match | None) -> float | None:
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime(*(int(x) for x in m.groups()), tzinfo=LOCAL_TZ).timestamp()
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _wann(ts: float | None) -> str:
|
||||||
|
"""„vom 25.09.2026 11:26“ — leer, wo der Zeitpunkt fehlt."""
|
||||||
|
return f"vom {datetime.fromtimestamp(ts, LOCAL_TZ):%d.%m.%Y %H:%M}" if ts else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _groesse(n: int | None) -> str | None:
|
||||||
|
if not n:
|
||||||
|
return None
|
||||||
|
return f"{n / 1e6:.0f} MB" if n < 1e9 else f"{n / 1e9:.1f} GB".replace(".", ",")
|
||||||
|
|
||||||
|
|
||||||
|
def _aktion(pfad: str, frage: str, label: str) -> dict:
|
||||||
|
"""Ein Knopf wie im Ziel-Modell (kern/ziele.py: Aktion): Methode, Pfad, Rückfrage, Beschriftung."""
|
||||||
|
return {"methode": "POST", "pfad": pfad, "frage": frage, "label": label}
|
||||||
|
|
||||||
|
|
||||||
|
def _grund(exc: Exception) -> str:
|
||||||
|
return str(exc).rstrip(".")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Snapshots und Sicherungen eines Geräts ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _knopf(laeufe: list[dict]) -> set[tuple[str, str]]:
|
||||||
|
"""(Gerät, Snapshot) aus den Läufen „Snapshot anlegen“ — so erkennt die Seite ihre Snapshots auch mit einem
|
||||||
|
Ausführer, der die Beschreibung „Auf Knopfdruck …“ noch nicht schreibt."""
|
||||||
|
return {(str(x.get("ziel")), str(x["snapshot"])) for x in laeufe
|
||||||
|
if x.get("baustein") == "snapshot-anlegen" and x.get("snapshot")}
|
||||||
|
|
||||||
|
|
||||||
|
def knopf_namen(ziel_id: str, gast: dict) -> set[str]:
|
||||||
|
"""Die Snapshots eines Gasts, die jemand auf der Seite Snapshots angelegt hat. Kein grünes Update räumt sie weg
|
||||||
|
(updates._aufraeumen) — die löscht nur der User."""
|
||||||
|
namen = {name for ziel, name in _knopf(updates.laeufe(100)) if ziel == ziel_id}
|
||||||
|
for d in gast.get("snapshot_details") or []:
|
||||||
|
if isinstance(d, dict) and str(d.get("beschreibung") or "").startswith(KNOPF_BESCHREIBUNG):
|
||||||
|
namen.add(str(d.get("name")))
|
||||||
|
return namen
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshots(g: dict, zid: str, knopf: set[tuple[str, str]]) -> list[dict]:
|
||||||
|
"""Die Snapshots eines Gasts, ältester zuerst: Name, Zeitpunkt, Beschreibung, Herkunft, ob vom Orchestrator."""
|
||||||
|
details = g.get("snapshot_details")
|
||||||
|
roh = ([d for d in details if isinstance(d, dict) and d.get("name")] if isinstance(details, list)
|
||||||
|
else [{"name": n} for n in g.get("snapshots") or [] if n])
|
||||||
|
liste = []
|
||||||
|
for d in roh:
|
||||||
|
name = str(d["name"])
|
||||||
|
m = SNAPSHOT_NAME.match(name)
|
||||||
|
zeit = d.get("zeit") if isinstance(d.get("zeit"), (int, float)) else _ortszeit(m)
|
||||||
|
beschreibung = str(d.get("beschreibung") or "").strip() or None
|
||||||
|
if not m:
|
||||||
|
herkunft = "proxmox"
|
||||||
|
elif (zid, name) in knopf or (beschreibung or "").startswith(KNOPF_BESCHREIBUNG):
|
||||||
|
herkunft = "knopf"
|
||||||
|
else:
|
||||||
|
herkunft = "update"
|
||||||
|
liste.append({"name": name, "zeit": zeit, "beschreibung": beschreibung, "herkunft": herkunft,
|
||||||
|
"eigen": bool(m)})
|
||||||
|
return sorted(liste, key=lambda s: (s["zeit"] or 0, s["name"]))
|
||||||
|
|
||||||
|
|
||||||
|
def _sicherungen(g: dict) -> list[dict]:
|
||||||
|
"""Die Sicherungen des Orchestrators für diesen Gast (der Ausführer nennt nur eigene), älteste zuerst."""
|
||||||
|
details = g.get("sicherung_details")
|
||||||
|
roh = ([d for d in details if isinstance(d, dict) and d.get("volid")] if isinstance(details, list)
|
||||||
|
else [{"volid": v} for v in g.get("sicherungen") or [] if v])
|
||||||
|
liste = []
|
||||||
|
for d in roh:
|
||||||
|
volid = str(d["volid"])
|
||||||
|
datei = volid.rsplit("/", 1)[-1]
|
||||||
|
zeit = d.get("zeit") if isinstance(d.get("zeit"), (int, float)) else _ortszeit(ARCHIV_ZEIT.search(datei))
|
||||||
|
groesse = d.get("groesse") if isinstance(d.get("groesse"), int) else None
|
||||||
|
liste.append({"datei": datei, "speicher": volid.split(":", 1)[0], "zeit": zeit, "groesse": groesse})
|
||||||
|
return sorted(liste, key=lambda e: (e["zeit"] or 0, e["datei"]))
|
||||||
|
|
||||||
|
|
||||||
|
def _anlegen_grund(g: dict) -> str | None:
|
||||||
|
"""Warum es für diesen Gast keinen Knopf „Snapshot anlegen“ gibt — oder None."""
|
||||||
|
if not g.get("erlaubt"):
|
||||||
|
return ("Nicht freigegeben: Das Etikett „watcher“ fehlt (oder „watcher-aus“ ist gesetzt). Der Orchestrator fasst "
|
||||||
|
"dieses Gerät nicht an; Snapshots gehen hier nur von Hand in Proxmox.")
|
||||||
|
if "snapshot_moeglich" not in g:
|
||||||
|
return "Ob ein Snapshot geht, ließ sich nicht lesen" + (f": {g['fehler']}" if g.get("fehler") else ".")
|
||||||
|
if not g["snapshot_moeglich"]:
|
||||||
|
satz = f"Kein Snapshot möglich: {str(g.get('snapshot_grund') or 'Der Speicher kann keine').rstrip('.')}."
|
||||||
|
if g.get("sicherung_moeglich"):
|
||||||
|
satz += " Vor einem Update legt der Orchestrator stattdessen eine Sicherung an."
|
||||||
|
return satz
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Rückfragen (die Oberfläche zeigt sie wörtlich) ---------------------------------------------------------------
|
||||||
|
|
||||||
|
def _frage_anlegen(g: dict, name: str) -> str:
|
||||||
|
vm = g.get("art") != "lxc"
|
||||||
|
saetze = [f"Jetzt einen Snapshot von {name} ({_wo(g)}) anlegen?"]
|
||||||
|
if g.get("status") == "running":
|
||||||
|
saetze.append(f"{'Die VM' if vm else 'Der Container'} läuft dabei weiter.")
|
||||||
|
saetze.append("Diesen Snapshot räumt kein Update weg — du löschst ihn hier, wenn du ihn nicht mehr brauchst.")
|
||||||
|
saetze.append("Die VM schreibt laufend; ihr Snapshot wächst im Speicherpool schnell mit, also nicht lange stehen "
|
||||||
|
"lassen." if vm else "Im Speicherpool belegt er so viel, wie sich seitdem im Container ändert.")
|
||||||
|
return " ".join(saetze)
|
||||||
|
|
||||||
|
|
||||||
|
def _frage_loeschen(name: str, s: dict) -> str:
|
||||||
|
wie = "Rückweg eines Updates" if s["herkunft"] == "update" else "auf Knopfdruck angelegt"
|
||||||
|
return (f"Den Snapshot {s['name']} von {name} ({', '.join(t for t in (_wann(s['zeit']), wie) if t)}) endgültig "
|
||||||
|
"löschen? Am Gerät selbst ändert sich nichts; nur der Weg zurück auf diesen Stand ist danach weg.")
|
||||||
|
|
||||||
|
|
||||||
|
def _frage_zurueck(g: dict, name: str, s: dict) -> str:
|
||||||
|
vm = g.get("art") != "lxc"
|
||||||
|
wann = _wann(s["zeit"])
|
||||||
|
stand = f"den Snapshot {wann} ({s['name']})" if wann else f"den Snapshot {s['name']}"
|
||||||
|
saetze = [f"{name} ({_wo(g)}) auf {stand} zurücksetzen?",
|
||||||
|
(f"Alles, was sich seitdem {'in der VM' if vm else 'im Container'} geändert hat — Daten, Einstellungen, "
|
||||||
|
"Updates —, ist danach verloren. Rückgängig machen lässt sich das nicht."),
|
||||||
|
(f"{'Die VM' if vm else 'Der Container'} wird dafür gestoppt und wieder gestartet; "
|
||||||
|
f"{name} ist ein paar Minuten nicht erreichbar.")]
|
||||||
|
if g.get("status") != "running":
|
||||||
|
saetze.append(f"Gerade {'ist die VM' if vm else 'ist der Container'} gestoppt — danach läuft "
|
||||||
|
f"{'sie' if vm else 'er'}.")
|
||||||
|
saetze.append(f"Danach prüft der Orchestrator, ob {name} wieder antwortet, und meldet das Ergebnis. Willst du den "
|
||||||
|
"jetzigen Stand behalten, lege vorher einen Snapshot an.")
|
||||||
|
return " ".join(saetze)
|
||||||
|
|
||||||
|
|
||||||
|
def _frage_probe(g: dict, name: str) -> str:
|
||||||
|
vm = g.get("art") != "lxc"
|
||||||
|
snapshot = bool(g.get("snapshot_moeglich"))
|
||||||
|
return (f"Den Rückweg von {name} ({_wo(g)}) echt testen? Der Orchestrator legt "
|
||||||
|
f"{'einen Snapshot' if snapshot else 'eine Sicherung'} an, wertet die Prüfung absichtlich als rot und setzt "
|
||||||
|
f"{'die VM' if vm else 'den Container'} darauf zurück — ein Update gibt es dabei nicht. {name} ist ein paar "
|
||||||
|
"Minuten nicht erreichbar; danach prüft er, ob es wieder läuft und antwortet, und meldet das Ergebnis. "
|
||||||
|
f"{'Der Snapshot' if snapshot else 'Die Sicherung'} bleibt danach liegen — wegräumen kannst du hier.")
|
||||||
|
|
||||||
|
|
||||||
|
def _frage_sicherung(name: str, e: dict) -> str:
|
||||||
|
teile = [t for t in (_wann(e["zeit"]), _groesse(e["groesse"])) if t] + [f"auf „{e['speicher']}“"]
|
||||||
|
return (f"Die Sicherung {e['datei']} von {name} ({', '.join(teile)}) endgültig löschen? Der Orchestrator hat sie "
|
||||||
|
"vor einem Update als Rückweg angelegt. Am Gerät selbst ändert sich nichts, und die nächtlichen Sicherungen "
|
||||||
|
"(Proxmox → Backup) bleiben, wie sie sind.")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Liste für die Oberfläche ------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _laeuft_text(x: dict) -> str:
|
||||||
|
"""Was für ein Gerät gerade läuft, in Worten — auch ein Update, das die Knöpfe hier sperrt."""
|
||||||
|
baustein = str(x.get("baustein"))
|
||||||
|
if baustein == "snapshot-zurueck" and "zurueck: ok" in (x.get("schritte") or []):
|
||||||
|
return "Zurückgesetzt, wird geprüft"
|
||||||
|
return LAEUFT.get(baustein, "Update läuft")
|
||||||
|
|
||||||
|
|
||||||
|
def _zuletzt(x: dict | None, jetzt: float) -> dict | None:
|
||||||
|
"""Das Ergebnis der letzten Aktion dieser Seite für ein Gerät, solange es frisch ist (ZULETZT_S)."""
|
||||||
|
ende = (x or {}).get("ende") or (x or {}).get("start")
|
||||||
|
if not isinstance(ende, (int, float)) or jetzt - ende > ZULETZT_S:
|
||||||
|
return None
|
||||||
|
if x.get("status") == "unterbrochen":
|
||||||
|
return {"ok": False, "ende": ende,
|
||||||
|
"text": f"{WAS.get(str(x.get('baustein')), 'Die Aktion')} unterbrochen: Der Homelab-Teil wurde "
|
||||||
|
"währenddessen neu gestartet. Was am Gerät geschah, zeigt die Liste."}
|
||||||
|
return {"ok": x.get("ergebnis") in GUT, "ende": ende, "text": str(x.get("text") or "")}
|
||||||
|
|
||||||
|
|
||||||
|
def _geraet(g: dict, laufend: dict[str, dict], fertig: dict[str, dict], knopf: set, jetzt: float) -> dict:
|
||||||
|
zid, name = _zid(g), _name(g)
|
||||||
|
grund = _anlegen_grund(g)
|
||||||
|
snapshots = _snapshots(g, zid, knopf)
|
||||||
|
for s in snapshots:
|
||||||
|
darf = s["eigen"] and bool(g.get("erlaubt"))
|
||||||
|
pfad = f"/api/homelab/snapshots/{zid}/{s['name']}"
|
||||||
|
s["loeschen"] = _aktion(f"{pfad}/loeschen", _frage_loeschen(name, s), "Snapshot löschen") if darf else None
|
||||||
|
s["zuruecksetzen"] = (_aktion(f"{pfad}/zuruecksetzen", _frage_zurueck(g, name, s), "Zurücksetzen")
|
||||||
|
if darf else None)
|
||||||
|
sicherungen = _sicherungen(g)
|
||||||
|
for e in sicherungen:
|
||||||
|
e["loeschen"] = (_aktion(f"/api/homelab/sicherungen/{zid}/{e['datei']}/loeschen", _frage_sicherung(name, e),
|
||||||
|
"Sicherung löschen") if g.get("erlaubt") else None)
|
||||||
|
lauf = laufend.get(zid)
|
||||||
|
probe_geht = bool(g.get("erlaubt")) and bool(g.get("snapshot_moeglich") or g.get("sicherung_moeglich"))
|
||||||
|
return {"id": zid, "vmid": g["vmid"], "art": "container" if g.get("art") == "lxc" else "vm", "name": name,
|
||||||
|
"wo": _wo(g), "status": g.get("status"), "erlaubt": bool(g.get("erlaubt")),
|
||||||
|
"anlegen": None if grund else _aktion(f"/api/homelab/snapshots/{zid}/anlegen", _frage_anlegen(g, name),
|
||||||
|
"Snapshot anlegen"),
|
||||||
|
"probe": (_aktion(f"/api/homelab/ziele/{zid}/rueckweg-probe", _frage_probe(g, name), "Rückweg testen")
|
||||||
|
if probe_geht else None),
|
||||||
|
"grund": grund, "laeuft": _laeuft_text(lauf) if lauf else None, "zuletzt": _zuletzt(fertig.get(zid), jetzt),
|
||||||
|
"snapshots": snapshots, "sicherungen": sicherungen}
|
||||||
|
|
||||||
|
|
||||||
|
def liste(jetzt: float | None = None) -> dict:
|
||||||
|
"""GET /api/homelab/snapshots: je Gerät (ohne den Container des Orchestrators selbst) Snapshots und Sicherungen."""
|
||||||
|
jetzt = time.time() if jetzt is None else jetzt
|
||||||
|
eingang = kanal.bericht()
|
||||||
|
bericht = (eingang or {}).get("bericht") or {}
|
||||||
|
zuletzt = kanal.zuletzt()
|
||||||
|
gaeste = [g for g in bericht.get("gaeste") or [] if isinstance(g, dict) and isinstance(g.get("vmid"), int)
|
||||||
|
and inventar.EIGENES_ETIKETT not in (g.get("etiketten") or [])]
|
||||||
|
laeufe = updates.laeufe(100) # neueste zuerst
|
||||||
|
laufend: dict[str, dict] = {}
|
||||||
|
fertig: dict[str, dict] = {}
|
||||||
|
for x in laeufe:
|
||||||
|
if x.get("status") == "laeuft":
|
||||||
|
laufend.setdefault(str(x.get("ziel")), x)
|
||||||
|
elif x.get("baustein") in LAEUFT:
|
||||||
|
fertig.setdefault(str(x.get("ziel")), x)
|
||||||
|
knopf = _knopf(laeufe)
|
||||||
|
geraete = [_geraet(g, laufend, fertig, knopf, jetzt) for g in sorted(gaeste, key=lambda g: g["vmid"])]
|
||||||
|
alle = [s for g in geraete for s in g["snapshots"]]
|
||||||
|
sicherungen = [e for g in geraete for e in g["sicherungen"]]
|
||||||
|
empfangen = (eingang or {}).get("empfangen")
|
||||||
|
rueckwege = _rueckwege(geraete)
|
||||||
|
return {
|
||||||
|
"jetzt": jetzt,
|
||||||
|
"bericht": {"empfangen": empfangen, "veraltet": jetzt - empfangen > inventar.BERICHT_ALT_S} if eingang else None,
|
||||||
|
"ausfuehrer": {"verbunden": bool(zuletzt and jetzt - zuletzt < inventar.BERICHT_ALT_S), "zuletzt": zuletzt,
|
||||||
|
"nur_lesen": bool((bericht.get("host") or {}).get("nur_lesen"))},
|
||||||
|
# Warum gerade keine Aktion geht („Alle aktualisieren“, ein Update, der Ausführer) — wie die Knöpfe es sagten.
|
||||||
|
"sperre": _sperre(),
|
||||||
|
# Ausführer vor dem 25.09.2026: nur Namen, Zeitpunkte aus dem Namen, keine Beschreibung.
|
||||||
|
"details": any("snapshot_details" in g for g in gaeste),
|
||||||
|
"geraete": geraete,
|
||||||
|
"summe": {"snapshots": len(alle), "eigene": sum(1 for s in alle if s["eigen"]), "sicherungen": len(sicherungen),
|
||||||
|
"sicherungen_groesse": sum(e["groesse"] or 0 for e in sicherungen) or None},
|
||||||
|
# „Alle Update-Rückwege löschen“: nur, wenn es welche gibt und das Aufräumen nicht schon läuft.
|
||||||
|
"aufraeumen": (_aktion("/api/homelab/snapshots/rueckwege-loeschen", _frage_rueckwege(rueckwege),
|
||||||
|
f"Alle {len(rueckwege)} Update-Rückwege löschen")
|
||||||
|
if rueckwege and not _AUFRAEUMEN.is_set() else None),
|
||||||
|
"aufraeumen_laeuft": _AUFRAEUMEN.is_set(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Alle Update-Rückwege auf einmal (User-Klick) ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
_AUFRAEUMEN = threading.Event() # gesetzt, solange rueckwege_loeschen seine Liste abarbeitet
|
||||||
|
AUFRAEUMEN_ZEITLIMIT_S = 10 * 60 # je Löschung; der Ausführer braucht Sekunden
|
||||||
|
|
||||||
|
|
||||||
|
def _rueckwege(geraete: list[dict]) -> list[tuple[str, str, str, str]]:
|
||||||
|
"""(Gerät, Name, Art, Eintrag) aller Rückwege von Updates, die sich löschen lassen: Snapshots mit Herkunft „update“
|
||||||
|
und die Sicherungen des Orchestrators. Per Knopf oder von Hand angelegte gehören nicht dazu."""
|
||||||
|
liste = []
|
||||||
|
for g in geraete:
|
||||||
|
liste += [(g["id"], g["name"], "snapshot", s["name"]) for s in g["snapshots"]
|
||||||
|
if s["herkunft"] == "update" and s.get("loeschen")]
|
||||||
|
liste += [(g["id"], g["name"], "sicherung", e["datei"]) for e in g["sicherungen"] if e.get("loeschen")]
|
||||||
|
return liste
|
||||||
|
|
||||||
|
|
||||||
|
def _frage_rueckwege(rueckwege: list[tuple[str, str, str, str]]) -> str:
|
||||||
|
snaps = sum(1 for r in rueckwege if r[2] == "snapshot")
|
||||||
|
sich = len(rueckwege) - snaps
|
||||||
|
teile = []
|
||||||
|
if snaps:
|
||||||
|
teile.append(f"{snaps} {'Snapshot' if snaps == 1 else 'Snapshots'}")
|
||||||
|
if sich:
|
||||||
|
teile.append(f"{sich} {'Sicherung' if sich == 1 else 'Sicherungen'}")
|
||||||
|
geraete = ", ".join(dict.fromkeys(r[1] for r in rueckwege))
|
||||||
|
return (f"{' und '.join(teile)} endgültig löschen ({geraete})? Das sind die Rückwege, die der Orchestrator vor den "
|
||||||
|
"letzten Updates angelegt hat. An den Geräten ändert sich nichts; nur der Weg zurück auf die Stände vor "
|
||||||
|
"diesen Updates ist danach weg. Snapshots, die du hier per Knopf oder von Hand in Proxmox angelegt hast, "
|
||||||
|
"bleiben. Gelöscht wird nacheinander; am Ende kommt eine Meldung.")
|
||||||
|
|
||||||
|
|
||||||
|
def _warten_auf(lauf_id: str) -> dict | None:
|
||||||
|
ende = time.monotonic() + AUFRAEUMEN_ZEITLIMIT_S
|
||||||
|
while time.monotonic() < ende:
|
||||||
|
lauf = updates.lauf_lesen(lauf_id)
|
||||||
|
if lauf and lauf.get("status") != "laeuft":
|
||||||
|
return lauf
|
||||||
|
time.sleep(ABFRAGE_TAKT_S)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
ABFRAGE_TAKT_S = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def _rueckwege_abarbeiten(rueckwege: list[tuple[str, str, str, str]]) -> None:
|
||||||
|
geloescht, fehler = 0, []
|
||||||
|
try:
|
||||||
|
for zid, name, art, eintrag in rueckwege:
|
||||||
|
antwort = loeschen(zid, eintrag) if art == "snapshot" else sicherung_loeschen(zid, eintrag)
|
||||||
|
if not antwort.get("ok"):
|
||||||
|
fehler.append(f"{name} {eintrag}: {antwort.get('detail')}")
|
||||||
|
continue
|
||||||
|
lauf = _warten_auf(antwort["lauf"])
|
||||||
|
if lauf and lauf.get("ergebnis") == "geloescht":
|
||||||
|
geloescht += 1
|
||||||
|
else:
|
||||||
|
fehler.append(f"{name} {eintrag}: {(lauf or {}).get('text') or 'keine Antwort'}")
|
||||||
|
finally:
|
||||||
|
_AUFRAEUMEN.clear()
|
||||||
|
text = f"Update-Rückwege aufgeräumt: {geloescht} von {len(rueckwege)} gelöscht."
|
||||||
|
if fehler:
|
||||||
|
text += " Nicht gelöscht: " + "; ".join(fehler[:5])
|
||||||
|
try:
|
||||||
|
announce.notify_telegram(BETREFF_ALARM if fehler else BETREFF, text)
|
||||||
|
except Exception:
|
||||||
|
log.warning("homelab: Meldung zum Aufräumen ging nicht raus", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def rueckwege_loeschen() -> dict:
|
||||||
|
"""Knopf „Alle Update-Rückwege löschen“: startet das Abarbeiten im eigenen Faden und kehrt sofort zurück."""
|
||||||
|
if _AUFRAEUMEN.is_set():
|
||||||
|
return {"ok": False, "detail": "Das Aufräumen läuft schon."}
|
||||||
|
if grund := _sperre():
|
||||||
|
return {"ok": False, "detail": grund}
|
||||||
|
rueckwege = _rueckwege(liste()["geraete"])
|
||||||
|
if not rueckwege:
|
||||||
|
return {"ok": False, "detail": "Es gibt keine Rückwege von Updates zu löschen."}
|
||||||
|
_AUFRAEUMEN.set()
|
||||||
|
threading.Thread(target=_rueckwege_abarbeiten, args=(rueckwege,), name="homelab-rueckwege", daemon=True).start()
|
||||||
|
return {"ok": True, "anzahl": len(rueckwege)}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Aktionen ------------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _sperre() -> str | None:
|
||||||
|
"""Warum gerade gar keine Aktion dieser Seite geht — oder None. Derselbe Satz steht auf der Seite über der Liste.
|
||||||
|
|
||||||
|
Läuft irgendwo ein Update, warten alle: Der Ausführer arbeitet einen Auftrag nach dem anderen ab. Hinter einem
|
||||||
|
Update (bis zu einer Stunde) liefe eine Aktion in ihr Zeitlimit — und käme danach doch noch dran, ohne dass ein
|
||||||
|
Lauf hinsieht; beim Zurücksetzen wäre das gefährlich. Vor Aktionen dieser Seite stehen so nur kurze Aufträge.
|
||||||
|
Umgekehrt darf ein Update neben ihnen starten: Es wartet höchstens ein paar Sekunden."""
|
||||||
|
if sammellauf.laeuft():
|
||||||
|
return f"{updates.SAMMELLAUF_SPERRE} Bis es fertig ist, ruhen die Knöpfe hier."
|
||||||
|
im_update = [str(x.get("name") or x.get("ziel")) for x in updates.laeufe(100)
|
||||||
|
if x.get("status") == "laeuft" and x.get("baustein") not in LAEUFT]
|
||||||
|
if im_update:
|
||||||
|
return (f"Gerade läuft ein Update ({', '.join(dict.fromkeys(im_update))}). Anlegen, löschen und zurücksetzen "
|
||||||
|
"geht, sobald es fertig ist — der Ausführer arbeitet einen Auftrag nach dem anderen ab.")
|
||||||
|
zuletzt = kanal.zuletzt()
|
||||||
|
if not (zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S):
|
||||||
|
return ("Der Ausführer auf dem Proxmox-Host meldet sich nicht. Die Liste zeigt den letzten bekannten Stand; "
|
||||||
|
"anlegen, löschen und zurücksetzen geht erst, wenn er wieder da ist.")
|
||||||
|
if (((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}).get("nur_lesen"):
|
||||||
|
return "Der Ausführer läuft im Nur-Lesen-Modus: Er zeigt alles, ändert aber nichts."
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _vorab(ziel_id: str) -> tuple[dict | None, str | None]:
|
||||||
|
"""Was vor jeder Aktion gelten muss (unter updates.START_SPERRE geprüft): (Gast, None) oder (None, warum nicht)."""
|
||||||
|
if grund := _sperre():
|
||||||
|
return None, grund
|
||||||
|
if updates.laeuft(ziel_id):
|
||||||
|
return None, ("Für dieses Gerät läuft gerade schon etwas (ein Update oder eine Snapshot-Aktion). Das geht "
|
||||||
|
"erst, wenn es fertig ist.")
|
||||||
|
gast = inventar.gast(ziel_id)
|
||||||
|
if not gast or inventar.EIGENES_ETIKETT in (gast.get("etiketten") or []):
|
||||||
|
return None, "Dieses Gerät steht nicht im Bericht des Ausführers."
|
||||||
|
if not gast.get("erlaubt"):
|
||||||
|
return None, "Dieses Gerät ist nicht freigegeben (Etikett „watcher“ fehlt); der Ausführer fasst es nicht an."
|
||||||
|
return gast, None
|
||||||
|
|
||||||
|
|
||||||
|
def _starten(ziel_id: str, baustein: str, vorbereiten: Callable[[dict], tuple[str | None, dict]],
|
||||||
|
schritte: Callable[[dict, dict], None]) -> dict:
|
||||||
|
"""Prüfen und den Lauf anlegen am Stück (wie updates.starten), dann im eigenen Faden ablaufen lassen.
|
||||||
|
Rückgabe {"ok": True, "lauf": id} oder {"ok": False, "detail": …}."""
|
||||||
|
with updates.START_SPERRE:
|
||||||
|
gast, grund = _vorab(ziel_id)
|
||||||
|
mehr: dict = {}
|
||||||
|
if gast is not None:
|
||||||
|
grund, mehr = vorbereiten(gast)
|
||||||
|
if grund or gast is None:
|
||||||
|
return {"ok": False, "detail": grund or "Das geht gerade nicht."}
|
||||||
|
lauf = updates._neuer_lauf(ziel_id, baustein, _name(gast), **mehr)
|
||||||
|
threading.Thread(target=_ablauf, args=(schritte, lauf, gast), name=f"homelab-{ziel_id}", daemon=True).start()
|
||||||
|
return {"ok": True, "lauf": lauf["id"]}
|
||||||
|
|
||||||
|
|
||||||
|
def _eigener_snapshot(gast: dict, name: str) -> tuple[str | None, dict]:
|
||||||
|
if not SNAPSHOT_NAME.match(name):
|
||||||
|
return ("Nur Snapshots des Orchestrators (mc2-…) lassen sich hier löschen oder zurücksetzen; von Hand "
|
||||||
|
"angelegte verwaltest du in Proxmox."), {}
|
||||||
|
s = next((s for s in _snapshots(gast, "", set()) if s["name"] == name), None)
|
||||||
|
if s is None:
|
||||||
|
return f"Den Snapshot {name} gibt es laut dem letzten Bericht nicht (mehr).", {}
|
||||||
|
return None, {"snapshot": name, "snapshot_zeit": s["zeit"]}
|
||||||
|
|
||||||
|
|
||||||
|
def anlegen(ziel_id: str) -> dict:
|
||||||
|
"""Knopf „Snapshot anlegen“: nur freigegebene Gäste, auf deren Speicher ein Snapshot geht."""
|
||||||
|
return _starten(ziel_id, "snapshot-anlegen", lambda g: (_anlegen_grund(g), {}), _anlegen)
|
||||||
|
|
||||||
|
|
||||||
|
def loeschen(ziel_id: str, name: str) -> dict:
|
||||||
|
"""Knopf „Löschen“ an einem Snapshot des Orchestrators."""
|
||||||
|
return _starten(ziel_id, "snapshot-loeschen", lambda g: _eigener_snapshot(g, name), _loeschen)
|
||||||
|
|
||||||
|
|
||||||
|
def zuruecksetzen(ziel_id: str, name: str) -> dict:
|
||||||
|
"""Knopf „Zurücksetzen“ an einem Snapshot des Orchestrators."""
|
||||||
|
return _starten(ziel_id, "snapshot-zurueck", lambda g: _eigener_snapshot(g, name), _zurueck)
|
||||||
|
|
||||||
|
|
||||||
|
def sicherung_loeschen(ziel_id: str, datei: str) -> dict:
|
||||||
|
"""Knopf „Löschen“ an einer Sicherung des Orchestrators (nur, was der Bericht als eigene nennt)."""
|
||||||
|
def vorbereiten(gast: dict) -> tuple[str | None, dict]:
|
||||||
|
volid = next((str(v) for v in gast.get("sicherungen") or [] if str(v).rsplit("/", 1)[-1] == datei), None)
|
||||||
|
if volid is None:
|
||||||
|
return f"Die Sicherung {datei} gibt es laut dem letzten Bericht nicht (mehr).", {}
|
||||||
|
return None, {"sicherung": volid}
|
||||||
|
return _starten(ziel_id, "sicherung-loeschen", vorbereiten, _sicherung_loeschen)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Abläufe (im eigenen Faden) --------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _ende(lauf: dict, ergebnis: str, text: str, melden: bool = False, dringend: bool = False) -> None:
|
||||||
|
betreff = (BETREFF_ALARM if dringend else BETREFF) if melden else None
|
||||||
|
lauf.update(status="fertig", ergebnis=ergebnis, text=text, ende=time.time(), dringend=dringend, gemeldet=melden,
|
||||||
|
betreff=betreff)
|
||||||
|
updates._merken(lauf)
|
||||||
|
if betreff:
|
||||||
|
try:
|
||||||
|
announce.notify_telegram(betreff, text)
|
||||||
|
except Exception:
|
||||||
|
log.warning("homelab: Meldung zu %s ging nicht raus", lauf.get("baustein"), exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _ablauf(schritte: Callable[[dict, dict], None], lauf: dict, gast: dict) -> None:
|
||||||
|
try:
|
||||||
|
schritte(lauf, gast)
|
||||||
|
except Exception as exc:
|
||||||
|
# Sonst hieße der Lauf bis zum nächsten Neustart „läuft“, und das Gerät wäre für alles gesperrt.
|
||||||
|
log.exception("homelab: %s für %s ist abgestürzt", lauf.get("baustein"), lauf.get("ziel"))
|
||||||
|
_ende(lauf, "fehler", f"{lauf['name']}: {WAS.get(lauf['baustein'], 'Die Aktion')} ist mit einem internen "
|
||||||
|
f"Fehler stehen geblieben ({exc.__class__.__name__}).", melden=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _anlegen(lauf: dict, gast: dict) -> None:
|
||||||
|
vmid, name = gast["vmid"], lauf["name"]
|
||||||
|
try:
|
||||||
|
a = updates._auftrag(lauf, "snapshot", {"vmid": vmid, "anlass": "knopf"}, updates.ZEITLIMIT_KURZ_S)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
_ende(lauf, "fehler", f"{name}: Snapshot nicht angelegt — {_grund(exc)}.", melden=True)
|
||||||
|
return
|
||||||
|
m = re.search(r"snapshot=(mc2-\d{8}-\d{6})", a.get("text") or "")
|
||||||
|
lauf["snapshot"] = m.group(1) if m else None
|
||||||
|
nachher = updates._frischer_gast(lauf, vmid)
|
||||||
|
if lauf["snapshot"] and nachher is not None and lauf["snapshot"] not in (nachher.get("snapshots") or []):
|
||||||
|
_ende(lauf, "fehler", f"{name}: Der Ausführer meldete den Snapshot {lauf['snapshot']}, im neuen Bericht fehlt "
|
||||||
|
"er aber.", melden=True)
|
||||||
|
return
|
||||||
|
_ende(lauf, "angelegt", f"{name}: Snapshot {lauf['snapshot']} angelegt." if lauf["snapshot"]
|
||||||
|
else f"{name}: Snapshot angelegt.")
|
||||||
|
|
||||||
|
|
||||||
|
def _loeschen(lauf: dict, gast: dict) -> None:
|
||||||
|
vmid, name, snapshot = gast["vmid"], lauf["name"], lauf["snapshot"]
|
||||||
|
try:
|
||||||
|
updates._auftrag(lauf, "snapshot_loeschen", {"vmid": vmid, "snapshot": snapshot}, updates.ZEITLIMIT_KURZ_S)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
_ende(lauf, "fehler", f"{name}: Snapshot {snapshot} ließ sich nicht löschen — {_grund(exc)}.", melden=True)
|
||||||
|
return
|
||||||
|
updates._frischer_gast(lauf, vmid)
|
||||||
|
_ende(lauf, "geloescht", f"{name}: Snapshot {snapshot} gelöscht.")
|
||||||
|
|
||||||
|
|
||||||
|
def _lage(name: str, gast: dict | None) -> str:
|
||||||
|
"""Wie es dem Gerät nach einem gescheiterten Zurücksetzen geht, laut frischem Bericht."""
|
||||||
|
if gast is None:
|
||||||
|
return f"Ob {name} läuft, ließ sich nicht prüfen — bitte in Proxmox nachsehen."
|
||||||
|
if gast.get("status") == "running":
|
||||||
|
return f"{name} läuft."
|
||||||
|
return f"{name} läuft nicht ({gast.get('status')}) — bitte in Proxmox nachsehen."
|
||||||
|
|
||||||
|
|
||||||
|
def _zurueck(lauf: dict, gast: dict) -> None:
|
||||||
|
vmid, name, snapshot = gast["vmid"], lauf["name"], lauf["snapshot"]
|
||||||
|
wann = _wann(lauf.get("snapshot_zeit"))
|
||||||
|
stand = f"den Snapshot {wann} ({snapshot})" if wann else f"den Snapshot {snapshot}"
|
||||||
|
try:
|
||||||
|
updates._auftrag(lauf, "zurueck", {"vmid": vmid, "snapshot": snapshot}, updates.ZEITLIMIT_KURZ_S)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
nachher = updates._frischer_gast(lauf, vmid)
|
||||||
|
_ende(lauf, "fehler", f"{name}: Zurücksetzen auf {stand} gescheitert — {_grund(exc)}. {_lage(name, nachher)}",
|
||||||
|
melden=True, dringend=True)
|
||||||
|
return
|
||||||
|
lauf["zurueckgesetzt"] = True
|
||||||
|
time.sleep(updates.WARTEN_NACH_UPDATE_S)
|
||||||
|
nachher = updates._frischer_gast(lauf, vmid)
|
||||||
|
fehler = updates.pruefen(gast, nachher, "zurueck")
|
||||||
|
if fehler:
|
||||||
|
_ende(lauf, "fehler", f"{name}: auf {stand} zurückgesetzt, aber die Prüfung ist rot — {' '.join(fehler)}",
|
||||||
|
melden=True, dringend=True)
|
||||||
|
return
|
||||||
|
_ende(lauf, "zurueckgesetzt", f"{name}: auf {stand} zurückgesetzt, Prüfung grün.", melden=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _sicherung_loeschen(lauf: dict, gast: dict) -> None:
|
||||||
|
vmid, name, volid = gast["vmid"], lauf["name"], lauf["sicherung"]
|
||||||
|
datei = volid.rsplit("/", 1)[-1]
|
||||||
|
try:
|
||||||
|
updates._auftrag(lauf, "sicherung_loeschen", {"vmid": vmid, "sicherung": volid}, updates.ZEITLIMIT_KURZ_S)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
_ende(lauf, "fehler", f"{name}: Sicherung {datei} ließ sich nicht löschen — {_grund(exc)}.", melden=True)
|
||||||
|
return
|
||||||
|
updates._frischer_gast(lauf, vmid)
|
||||||
|
_ende(lauf, "geloescht", f"{name}: Sicherung {datei} gelöscht.")
|
||||||
@@ -0,0 +1,633 @@
|
|||||||
|
"""Speicher und Sicherungen des Homelabs (seit 25.09.2026, User-Wunsch).
|
||||||
|
|
||||||
|
Alles aus dem Bericht des Ausführers (alle 10 Minuten), dazu eine eigene Prüfung, ob das NAS antwortet:
|
||||||
|
• Speicherpool (Thin-LVM, heute local-lvm): Läuft er voll, stehen alle Gäste darauf still. Gelb ab 80 %, rot ab
|
||||||
|
90 % — also bei 10 % frei (User-Vorgabe) —, für Daten wie Metadaten. Dazu Empfehlungen, was Platz bringt,
|
||||||
|
der größte Gewinn zuerst: Platz, den ein Gast längst freigegeben hat (TRIM), ungenutzte und verwaiste Platten,
|
||||||
|
Snapshots (seit 25.09.2026 in einem Vorschlag, verwaltet auf der Seite Homelab → Snapshots).
|
||||||
|
• NAS: jedes Netzlaufwerk des Proxmox-Hosts (heute das QNAP per NFS, Ziel der nächtlichen Sicherungen): antwortet,
|
||||||
|
ist eingebunden, hängt nicht, hat Platz (80/90 % wie oben).
|
||||||
|
• Sicherungen: Für jeden Gast, den ein Sicherungsauftrag erfasst, liegt auf dessen Ziel eine frische Sicherung
|
||||||
|
(täglicher Auftrag: höchstens 26 Stunden alt, sonst 8 Tage), und das Ziel ist erreichbar.
|
||||||
|
• Abdeckung (seit 25.09.2026): Gäste ohne Sicherungsauftrag (wie Proxmox sie unter Rechenzentrum → Backup meldet)
|
||||||
|
sofort gelb; je Gerät die jüngste Prüfung durch den PBS (beschädigt = rot) und die Probe-Wiederherstellung des
|
||||||
|
Ausführers (jede Woche die jüngste Sicherung probeweise öffnen; gescheitert = gelb). Prüft der PBS seit über
|
||||||
|
15 Tagen nichts mehr, obwohl es ältere Sicherungen gibt: gelb. Beim Sicherungsziel zählt die Schätzung des PBS,
|
||||||
|
wann es voll ist.
|
||||||
|
• Systemplatte des Proxmox-Hosts (Stufen wie oben): Dort liegen die Kernel, die Sicherungen vor Updates (local) und
|
||||||
|
alles vom Host selbst. Alte Kernel lassen sich per Knopf entfernen (Ausführer: kernel_aufraeumen).
|
||||||
|
• Tempo (seit 25.09.2026, kern/prognose.py): Läuft etwas in weniger als 14 Tagen voll, ist es gelb, unter 3 Tagen
|
||||||
|
rot — auch wenn der Füllstand selbst noch unauffällig ist. Den Verlauf dafür schreibt jeder Bericht in die
|
||||||
|
Messreihe VERLAUF (verlauf_merken); Host und Container haben ihn schon in den Minutenwerten.
|
||||||
|
Hinweise gehen über den Wächter (pruefe_speicher), die Oberfläche zeigt alles als Karte „Speicher und Sicherungen“ (lage).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from kern import messreihen, prognose
|
||||||
|
from kern.zeit import LOCAL_TZ
|
||||||
|
|
||||||
|
from services.homelab import apps, kanal
|
||||||
|
from services.homelab.inventar import BERICHT_ALT_S, EIGENES_ETIKETT
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
GELB, ROT = 0.80, 0.90
|
||||||
|
EMPFEHLUNG_AB = 1_000_000_000 # kleinere Gewinne sind keine eigene Empfehlung wert
|
||||||
|
TRIM_AB = 250_000_000 # ein Container zählt beim gemeinsamen TRIM erst ab hier mit
|
||||||
|
TAEGLICH_S, SONST_S = 26 * 3600, 8 * 86400
|
||||||
|
PORTS = {"nfs": 2049, "nfs4": 2049, "cifs": 445, "smb3": 445}
|
||||||
|
ANTWORT_CACHE_S = 30
|
||||||
|
RANG = {"ok": 0, "gelb": 1, "rot": 2}
|
||||||
|
|
||||||
|
_antwort_cache: dict[tuple[str, int], tuple[float, bool]] = {}
|
||||||
|
_antwort_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _stufe(anteil: float | None) -> str:
|
||||||
|
if anteil is None:
|
||||||
|
return "ok"
|
||||||
|
return "rot" if anteil >= ROT else "gelb" if anteil >= GELB else "ok"
|
||||||
|
|
||||||
|
|
||||||
|
def _schlimmer(*stufen: str) -> str:
|
||||||
|
return max(stufen, key=lambda s: RANG.get(s, 0))
|
||||||
|
|
||||||
|
|
||||||
|
def _gb(n: float | None) -> str:
|
||||||
|
return "–" if n is None else f"{n / 1e9:.1f}".replace(".", ",")
|
||||||
|
|
||||||
|
|
||||||
|
def _groesse(n: float | None) -> str:
|
||||||
|
"""„125 GB“, „2,3 TB“ — für Texte."""
|
||||||
|
if n is None:
|
||||||
|
return "–"
|
||||||
|
if n >= 1e12:
|
||||||
|
return f"{n / 1e12:.1f}".replace(".", ",") + " TB"
|
||||||
|
return f"{n / 1e9:.0f} GB" if n >= 10e9 else f"{_gb(n)} GB"
|
||||||
|
|
||||||
|
|
||||||
|
def _name(g: dict) -> str:
|
||||||
|
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
|
||||||
|
return app.name if app else str(g.get("name") or g["vmid"])
|
||||||
|
|
||||||
|
|
||||||
|
def _wo(g: dict) -> str:
|
||||||
|
return f"{'Container' if g.get('art') == 'lxc' else 'VM'} {g['vmid']}"
|
||||||
|
|
||||||
|
|
||||||
|
def _zid(g: dict) -> str:
|
||||||
|
return f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g['vmid']}"
|
||||||
|
|
||||||
|
|
||||||
|
def _datum(ts: float) -> str:
|
||||||
|
return datetime.fromtimestamp(ts, LOCAL_TZ).strftime("%d.%m. %H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
def _bericht() -> dict:
|
||||||
|
return (kanal.bericht() or {}).get("bericht") or {}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Speicherpool ----------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def pools(bericht: dict) -> list[dict]:
|
||||||
|
ergebnis = []
|
||||||
|
for p in (bericht.get("host") or {}).get("pools") or []:
|
||||||
|
gesamt, belegt = int(p.get("gesamt") or 0), int(p.get("belegt") or 0)
|
||||||
|
if not gesamt:
|
||||||
|
continue
|
||||||
|
anteil, meta = belegt / gesamt, p.get("meta_anteil")
|
||||||
|
ergebnis.append({"speicher": p.get("speicher"), "belegt": belegt, "gesamt": gesamt, "frei": gesamt - belegt,
|
||||||
|
"anteil": anteil, "meta_anteil": meta, "aktiv": p.get("aktiv", True),
|
||||||
|
"stufe": _schlimmer(_stufe(anteil), _stufe(meta))})
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def empfehlungen(bericht: dict) -> list[dict]:
|
||||||
|
"""Was im Speicherpool Platz bringt, der größte Gewinn zuerst; Snapshots (Größe unbekannt) danach.
|
||||||
|
Je Eintrag: art, titel, text (was zu tun ist, mit Klickweg), kurz (für Meldungen), platz (Bytes oder None)."""
|
||||||
|
gaeste = bericht.get("gaeste") or []
|
||||||
|
liste: list[dict] = []
|
||||||
|
container: list[tuple[dict, int]] = []
|
||||||
|
for g in gaeste:
|
||||||
|
platte, im_pool = g.get("platte") or {}, g.get("pool_belegt")
|
||||||
|
if im_pool and platte.get("belegt") is not None:
|
||||||
|
gewinn = int(im_pool) - int(platte["belegt"])
|
||||||
|
if g.get("art") == "lxc":
|
||||||
|
if gewinn >= TRIM_AB:
|
||||||
|
container.append((g, gewinn))
|
||||||
|
elif gewinn >= EMPFEHLUNG_AB:
|
||||||
|
liste.append(_trim_vm(g, int(im_pool), int(platte["belegt"]), gewinn))
|
||||||
|
for u in g.get("ungenutzt") or []:
|
||||||
|
liste.append({
|
||||||
|
"art": "ungenutzt", "platz": u.get("belegt"), "titel": f"{_name(g)}: ungenutzte Platte {u['volume']}",
|
||||||
|
"kurz": f"ungenutzte Platte bei {_name(g)}", "ort": "pool",
|
||||||
|
"text": (f"Hängt an keinem Laufwerk mehr. Wird sie nicht mehr gebraucht: in Proxmox {_wo(g)} → Hardware "
|
||||||
|
"→ „Unbenutzte Festplatte“ → Entfernen (löscht sie endgültig).")})
|
||||||
|
if (summe := sum(x for _, x in container)) >= EMPFEHLUNG_AB:
|
||||||
|
container.sort(key=lambda t: -t[1])
|
||||||
|
einzeln = ", ".join(f"{_name(g)} {_gb(x)} GB" for g, x in container)
|
||||||
|
befehle = ", ".join(f"`pct fstrim {g['vmid']} --ignore-mountpoints`" for g, _ in container)
|
||||||
|
liste.append({
|
||||||
|
"art": "trim", "platz": summe, "titel": "Container: freigegebenen Platz an den Pool zurückgeben",
|
||||||
|
"kurz": f"{_groesse(summe)} per TRIM in den Containern", "ort": "pool",
|
||||||
|
"text": (f"Die Container belegen im Pool {_gb(summe)} GB mehr, als sie nutzen ({einzeln}). In Proxmox: pve → "
|
||||||
|
f"Shell → {befehle} — im laufenden Betrieb und ohne Verlust.")})
|
||||||
|
for p in (bericht.get("host") or {}).get("pools") or []:
|
||||||
|
for v in p.get("verwaist") or []:
|
||||||
|
liste.append({
|
||||||
|
"art": "verwaist", "platz": v.get("belegt"), "titel": f"Verwaiste Platte {v['volume']}",
|
||||||
|
"kurz": f"verwaiste Platte {v['volume']}", "ort": "pool",
|
||||||
|
"text": (f"Gehört zu keinem Gast mehr. Wird sie nicht mehr gebraucht: in Proxmox pve → {p.get('speicher')} "
|
||||||
|
"→ Datenträger → Entfernen (löscht sie endgültig).")})
|
||||||
|
if kernel := _kernel_vorschlag(bericht.get("host") or {}):
|
||||||
|
liste.append(kernel)
|
||||||
|
liste.sort(key=lambda e: -(e["platz"] or 0))
|
||||||
|
if snapshots := _snapshot_vorschlag(gaeste):
|
||||||
|
liste.append(snapshots)
|
||||||
|
return liste
|
||||||
|
|
||||||
|
|
||||||
|
def _geraete_text(namen: list[str]) -> str:
|
||||||
|
"""„Gitea (2), NetBird und AdGuard Home“ — je Gerät einmal, mit der Zahl, wo es mehrere sind."""
|
||||||
|
teile = [f"{n} ({namen.count(n)})" if namen.count(n) > 1 else n for n in dict.fromkeys(namen)]
|
||||||
|
return teile[0] if len(teile) == 1 else ", ".join(teile[:-1]) + " und " + teile[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_vorschlag(gaeste: list[dict]) -> dict | None:
|
||||||
|
"""Alle Snapshots in EINEM Vorschlag (seit 25.09.2026; vorher einer je Snapshot): Verwalten lassen sie sich auf der
|
||||||
|
Seite Homelab → Snapshots, mit Zeitpunkt und Knöpfen. Der Container des Orchestrators selbst fehlt dort wie hier.
|
||||||
|
Wie viel sie belegen, sagt Proxmox nicht — deshalb ohne Größe und zuletzt."""
|
||||||
|
eigene: list[str] = []
|
||||||
|
von_hand: list[str] = []
|
||||||
|
for g in gaeste:
|
||||||
|
if EIGENES_ETIKETT in (g.get("etiketten") or []):
|
||||||
|
continue
|
||||||
|
for s in g.get("snapshots") or []:
|
||||||
|
(eigene if re.fullmatch(r"mc2-\d{8}-\d{6}", str(s)) else von_hand).append(_name(g))
|
||||||
|
anzahl = len(eigene) + len(von_hand)
|
||||||
|
if not anzahl:
|
||||||
|
return None
|
||||||
|
if eigene:
|
||||||
|
titel = (f"{len(eigene)} Snapshot{'s' if len(eigene) != 1 else ''} des Orchestrators — verwalten unter "
|
||||||
|
"Homelab → Snapshots")
|
||||||
|
text = (f"Bei {_geraete_text(eigene)}. Läuft ein Gerät seit seinem Snapshot gut, kann der weg — auf der Seite "
|
||||||
|
"Snapshots per Knopf, dort auch mit Zeitpunkt und dem Weg zurück.")
|
||||||
|
else:
|
||||||
|
titel = (f"{len(von_hand)} von Hand angelegte{'r' if len(von_hand) == 1 else ''} "
|
||||||
|
f"Snapshot{'s' if len(von_hand) != 1 else ''} — ansehen unter Homelab → Snapshots")
|
||||||
|
text = f"Bei {_geraete_text(von_hand)}."
|
||||||
|
if eigene and von_hand:
|
||||||
|
text += f" Dazu {len(von_hand)} von Hand in Proxmox angelegt ({_geraete_text(von_hand)})."
|
||||||
|
if von_hand:
|
||||||
|
text += " Von Hand angelegte löscht der Orchestrator nicht; das geht in Proxmox."
|
||||||
|
text += (" Wie viel ein Snapshot belegt, sagt Proxmox nicht — bei Containern meist wenig, er wächst mit jeder "
|
||||||
|
"Änderung seitdem.")
|
||||||
|
return {"art": "snapshot", "platz": None, "ort": "pool", "titel": titel, "text": text,
|
||||||
|
"kurz": f"{anzahl} Snapshot{'s' if anzahl != 1 else ''} (Homelab → Snapshots)"}
|
||||||
|
|
||||||
|
|
||||||
|
def _kernel_vorschlag(host: dict) -> dict | None:
|
||||||
|
"""Alte Kernel auf der Systemplatte des Hosts — mit Knopf. Die Liste stammt vom Ausführer (was apt für entbehrlich
|
||||||
|
hält, nie der laufende oder der nächste); beim Klick bestimmt er sie noch einmal selbst."""
|
||||||
|
alt = host.get("kernel_alt") or []
|
||||||
|
if not alt:
|
||||||
|
return None
|
||||||
|
platz = sum(int(k.get("groesse") or 0) for k in alt)
|
||||||
|
bleiben = ", ".join(str(v).removesuffix("-pve") for v in host.get("kernel_bleiben") or []) or "der laufende"
|
||||||
|
return {
|
||||||
|
"art": "kernel", "ort": "systemplatte", "platz": platz,
|
||||||
|
"titel": f"Proxmox-Host: {len(alt)} alte Kernel entfernen",
|
||||||
|
"kurz": f"{_groesse(platz)} alte Kernel auf dem Proxmox-Host",
|
||||||
|
"text": (f"Liegen auf der Systemplatte des Hosts, nicht im Speicherpool. Entfernt wird nur, was Proxmox selbst für "
|
||||||
|
f"entbehrlich hält; es bleiben {bleiben} — darunter der laufende und der für den nächsten Start. Die "
|
||||||
|
"Gäste laufen weiter, ein Neustart ist dafür nicht nötig."),
|
||||||
|
"aktion": {"methode": "POST", "pfad": "/api/homelab/ziele/pve/kernel-aufraeumen", "label": "Alte Kernel entfernen",
|
||||||
|
"frage": (f"{len(alt)} alte Kernel vom Proxmox-Host entfernen (etwa {_groesse(platz)})? Es bleiben "
|
||||||
|
f"{bleiben}. Die Gäste laufen weiter, ein Neustart ist nicht nötig.")},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def systemplatte(bericht: dict) -> dict | None:
|
||||||
|
p = (bericht.get("host") or {}).get("systemplatte") or {}
|
||||||
|
gesamt, belegt = p.get("gesamt"), p.get("belegt")
|
||||||
|
if not gesamt or belegt is None:
|
||||||
|
return None
|
||||||
|
anteil = belegt / gesamt
|
||||||
|
return {"belegt": belegt, "gesamt": gesamt, "frei": gesamt - belegt, "anteil": anteil, "stufe": _stufe(anteil)}
|
||||||
|
|
||||||
|
|
||||||
|
def _trim_vm(g: dict, im_pool: int, genutzt: int, gewinn: int) -> dict:
|
||||||
|
kopf = f"Im Pool belegt die VM {_gb(im_pool)} GB, genutzt sind darin {_gb(genutzt)} GB."
|
||||||
|
if g.get("discard"):
|
||||||
|
text = (f"{kopf} In der VM `sudo fstrim -av` ausführen; Linux-Gäste tun das meist ohnehin jede Woche "
|
||||||
|
"(fstrim.timer).")
|
||||||
|
else:
|
||||||
|
text = (f"{kopf} Discard ist aus, deshalb kommt Platz, den die VM freigibt, nie im Pool an. In Proxmox: "
|
||||||
|
f"VM {g['vmid']} → Hardware → Festplatte → Bearbeiten → „Discard“ anhaken, dann die VM einmal neu "
|
||||||
|
"starten (die Dienste darin sind kurz weg). Danach gibt sie freien Platz jede Woche von selbst zurück; "
|
||||||
|
"sofort mit `sudo fstrim -av` in der VM.")
|
||||||
|
return {"art": "trim-vm", "platz": gewinn, "titel": f"{_name(g)}: {_groesse(gewinn)} an den Pool zurückgeben",
|
||||||
|
"kurz": f"{_groesse(gewinn)} bei {_name(g)} per TRIM" + ("" if g.get("discard") else " (Discard einschalten)"),
|
||||||
|
"ort": "pool",
|
||||||
|
"text": text}
|
||||||
|
|
||||||
|
|
||||||
|
# --- NAS --------------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def antwortet(server: str, port: int) -> bool:
|
||||||
|
"""Nimmt das NAS auf dem Port seines Dienstes Verbindungen an? (30 s gemerkt: Wächter und Oberfläche fragen oft.)"""
|
||||||
|
jetzt = time.monotonic()
|
||||||
|
with _antwort_lock:
|
||||||
|
if (e := _antwort_cache.get((server, port))) and jetzt - e[0] < ANTWORT_CACHE_S:
|
||||||
|
return e[1]
|
||||||
|
try:
|
||||||
|
with socket.create_connection((server, port), timeout=3):
|
||||||
|
ok = True
|
||||||
|
except OSError:
|
||||||
|
ok = False
|
||||||
|
with _antwort_lock:
|
||||||
|
_antwort_cache[(server, port)] = (jetzt, ok)
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
def nas(bericht: dict, pruefen=None) -> list[dict]:
|
||||||
|
pruefen = pruefen or antwortet
|
||||||
|
ergebnis = []
|
||||||
|
for m in (bericht.get("host") or {}).get("netzlaufwerke") or []:
|
||||||
|
server, port = m.get("server"), PORTS.get(str(m.get("art")))
|
||||||
|
erreichbar = pruefen(server, port) if server and port else None
|
||||||
|
platz = m.get("platz") or {}
|
||||||
|
gesamt, belegt = platz.get("gesamt"), platz.get("belegt")
|
||||||
|
anteil = belegt / gesamt if gesamt and belegt is not None else None
|
||||||
|
eintrag = {"server": server, "ziel": m.get("ziel"), "quelle": m.get("quelle"), "art": m.get("art"),
|
||||||
|
"eingebunden": bool(m.get("eingebunden")), "zustand": m.get("zustand"), "erreichbar": erreichbar,
|
||||||
|
"belegt": belegt, "gesamt": gesamt, "anteil": anteil, "stufe": "ok", "problem": None}
|
||||||
|
if erreichbar is False:
|
||||||
|
eintrag.update(stufe="rot", problem=f"Das NAS ({server}) antwortet nicht.")
|
||||||
|
elif not m.get("eingebunden"):
|
||||||
|
eintrag.update(stufe="rot", problem=f"{m.get('ziel')} ist auf dem Proxmox-Host nicht eingebunden.")
|
||||||
|
elif m.get("zustand") == "haengt":
|
||||||
|
eintrag.update(stufe="rot", problem=f"Die Einbindung {m.get('ziel')} hängt: Der Proxmox-Host bekommt keine "
|
||||||
|
"Antwort vom NAS.")
|
||||||
|
elif m.get("zustand") == "fehler":
|
||||||
|
eintrag.update(stufe="gelb", problem=f"Die Belegung von {m.get('ziel')} ließ sich nicht lesen.")
|
||||||
|
else:
|
||||||
|
eintrag["stufe"] = _stufe(anteil)
|
||||||
|
ergebnis.append(eintrag)
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
# --- Sicherungen ------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def max_alter_s(zeitplan: object) -> int:
|
||||||
|
"""Wie alt die jüngste Sicherung höchstens sein darf: täglich (nur Uhrzeit, etwa „02:30“) 26 Stunden, bei
|
||||||
|
Wochentagen oder allem Ausgefalleneren 8 Tage."""
|
||||||
|
return TAEGLICH_S if re.fullmatch(r"\s*(\d{1,2}|\*)(:\d{2})?\s*", str(zeitplan or "")) else SONST_S
|
||||||
|
|
||||||
|
|
||||||
|
def _erfasst(job: dict, vmid: int) -> bool:
|
||||||
|
"""Sichert dieser Auftrag den Gast? (alle außer den ausgenommenen, oder die aufgezählten)"""
|
||||||
|
if vmid in (job.get("ausgenommen") or []):
|
||||||
|
return False
|
||||||
|
return bool(job.get("alle")) or vmid in (job.get("vmids") or [])
|
||||||
|
|
||||||
|
|
||||||
|
def sicherungen(bericht: dict, jetzt: float) -> dict:
|
||||||
|
host = bericht.get("host") or {}
|
||||||
|
jobs = [j for j in host.get("sicherung_jobs") or [] if j.get("an")]
|
||||||
|
ziele = host.get("sicherungsziele") or {}
|
||||||
|
probleme: list[str] = []
|
||||||
|
for name in sorted({j.get("speicher") for j in jobs if j.get("speicher")}):
|
||||||
|
z = ziele.get(name) or {}
|
||||||
|
if not z.get("aktiv"):
|
||||||
|
probleme.append(f"Das Sicherungsziel {name} ist nicht erreichbar.")
|
||||||
|
elif z.get("fehler"):
|
||||||
|
probleme.append(f"Die Sicherungen auf {name} ließen sich nicht lesen.")
|
||||||
|
erfasst, fehlend, zuletzt = 0, [], None
|
||||||
|
for g in bericht.get("gaeste") or []:
|
||||||
|
eigene = [j for j in jobs if _erfasst(j, g["vmid"])]
|
||||||
|
if not eigene:
|
||||||
|
continue
|
||||||
|
erfasst += 1
|
||||||
|
stand = [((ziele.get(j.get("speicher")) or {}).get("neueste") or {}).get(str(g["vmid"])) for j in eigene]
|
||||||
|
juengste = max((t for t in stand if t), default=None)
|
||||||
|
if juengste:
|
||||||
|
zuletzt = max(zuletzt or 0, juengste)
|
||||||
|
if not any(t and jetzt - t <= max_alter_s(j.get("zeitplan")) for t, j in zip(stand, eigene, strict=True)):
|
||||||
|
fehlend.append({"ziel": _zid(g), "name": _name(g), "zuletzt": juengste})
|
||||||
|
stufe = "keine" if not jobs else "ok"
|
||||||
|
if fehlend:
|
||||||
|
stufe = "rot" if len(fehlend) == erfasst else "gelb"
|
||||||
|
if probleme:
|
||||||
|
stufe = "rot"
|
||||||
|
frische = stufe
|
||||||
|
ohne = _nicht_erfasst(bericht)
|
||||||
|
probe = host.get("sicherung_probe") if isinstance(host.get("sicherung_probe"), dict) else None
|
||||||
|
geraete = _je_geraet(bericht, ziele, {f["ziel"] for f in fehlend}, ohne, probe)
|
||||||
|
pbs = [d for z in ziele.values() if z.get("art") == "pbs" for d in (z.get("details") or {}).values()]
|
||||||
|
aelteste = min((d["aelteste"] for d in pbs if d.get("aelteste")), default=None)
|
||||||
|
pruefung = {"zuletzt": max((g["geprueft"]["zeit"] for g in geraete if g["geprueft"]), default=None),
|
||||||
|
"noetig": bool(aelteste and jetzt - aelteste >= PRUEFUNG_AB_S)}
|
||||||
|
if stufe != "rot" and (any(g["defekt"] and g["geprueft"] and g["geprueft"]["zustand"] == "failed" for g in geraete)):
|
||||||
|
stufe = "rot"
|
||||||
|
elif stufe in ("ok", "keine") and (ohne & {g["vmid"] for g in bericht.get("gaeste") or []}
|
||||||
|
or any(g["probe"] and not g["probe"]["ok"] for g in geraete)):
|
||||||
|
stufe = "gelb"
|
||||||
|
return {"jobs": [{"zeitplan": j.get("zeitplan"), "speicher": j.get("speicher"), "alle": j.get("alle")} for j in jobs],
|
||||||
|
"ziele": [{"speicher": n, "art": z.get("art"), "aktiv": z.get("aktiv"), "belegt": z.get("belegt"),
|
||||||
|
"gesamt": z.get("gesamt"), "schaetzung": z.get("schaetzung")} for n, z in sorted(ziele.items())],
|
||||||
|
"erfasst": erfasst, "fehlend": fehlend, "zuletzt": zuletzt, "probleme": probleme, "stufe": stufe,
|
||||||
|
"stufe_frische": frische, "ohne_auftrag": [{"ziel": g["ziel"], "name": g["name"]} for g in geraete if not g["auftrag"]],
|
||||||
|
"geraete": geraete, "pruefung": pruefung, "probe": {"zeit": probe.get("zeit")} if probe else None}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Abdeckung je Gerät (seit 25.09.2026) --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PRUEFUNG_AB_S = 8 * 86400 # gibt es Sicherungen, die so alt sind, hätte ein wöchentlicher Prüfauftrag sie gesehen …
|
||||||
|
PRUEFUNG_ALT_S = 15 * 86400 # … und so lange darf die jüngste Prüfung dann höchstens zurückliegen
|
||||||
|
|
||||||
|
|
||||||
|
def _nicht_erfasst(bericht: dict) -> set[int]:
|
||||||
|
"""Gäste ohne Sicherungsauftrag, wie Proxmox sie meldet (Rechenzentrum → Backup; im Bericht seit 25.09.2026). Ein
|
||||||
|
älterer Ausführer liefert das nicht: dann selbst ausgerechnet, ausgeschaltete Aufträge zählen mit."""
|
||||||
|
host = bericht.get("host") or {}
|
||||||
|
if isinstance(host.get("nicht_gesichert"), list):
|
||||||
|
return {int(v) for v in host["nicht_gesichert"] if str(v).isdigit()}
|
||||||
|
if not isinstance(host.get("sicherung_jobs"), list):
|
||||||
|
return set()
|
||||||
|
jobs = host["sicherung_jobs"]
|
||||||
|
return {g["vmid"] for g in bericht.get("gaeste") or [] if not any(_erfasst(j, g["vmid"]) for j in jobs)}
|
||||||
|
|
||||||
|
|
||||||
|
def _je_geraet(bericht: dict, ziele: dict, fehlend: set[str], ohne: set[int], probe: dict | None) -> list[dict]:
|
||||||
|
"""Je Gerät: ob ein Auftrag es erfasst, die jüngste Sicherung (über alle Ziele), ob sie frisch ist, die jüngste
|
||||||
|
Prüfung durch den PBS, beschädigte Sicherungen und das Ergebnis der Probe-Wiederherstellung."""
|
||||||
|
zeilen = []
|
||||||
|
for g in sorted(bericht.get("gaeste") or [], key=lambda g: g["vmid"]):
|
||||||
|
vmid = str(g["vmid"])
|
||||||
|
details = [d for z in ziele.values() if (d := (z.get("details") or {}).get(vmid))]
|
||||||
|
zuletzt = max([t for z in ziele.values() if (t := (z.get("neueste") or {}).get(vmid))], default=None)
|
||||||
|
pruefungen = [d["geprueft"] for d in details if d.get("geprueft")]
|
||||||
|
eigene_probe = ((probe or {}).get("gaeste") or {}).get(vmid)
|
||||||
|
eigener = EIGENES_ETIKETT in (g.get("etiketten") or [])
|
||||||
|
zeilen.append({
|
||||||
|
"ziel": _zid(g), "name": "Homelab Orchestrator (dieser Container)" if eigener else _name(g),
|
||||||
|
"vmid": g["vmid"], "auftrag": g["vmid"] not in ohne,
|
||||||
|
"zuletzt": zuletzt, "frisch": bool(zuletzt) and g["vmid"] not in ohne and _zid(g) not in fehlend,
|
||||||
|
"geprueft": max(pruefungen, key=lambda p: (p["zeit"], p["sicherung"]), default=None),
|
||||||
|
"defekt": sorted({t for d in details for t in d.get("defekt") or []}),
|
||||||
|
"probe": {**eigene_probe, "zeit": probe.get("zeit")} if eigene_probe else None})
|
||||||
|
return zeilen
|
||||||
|
|
||||||
|
|
||||||
|
def probe_starten() -> dict:
|
||||||
|
"""Knopf „Jetzt prüfen“: die Probe-Wiederherstellung als Auftrag an den Ausführer (nur lesend). Das Ergebnis
|
||||||
|
bringt der Bericht gleich danach."""
|
||||||
|
zuletzt = kanal.zuletzt()
|
||||||
|
if not zuletzt or time.time() - zuletzt > BERICHT_ALT_S:
|
||||||
|
return {"ok": False, "detail": "Der Ausführer auf dem Proxmox-Host meldet sich gerade nicht."}
|
||||||
|
if any(a.get("aktion") == "sicherung_probe" and a.get("status") in ("wartet", "laeuft") for a in kanal.liste()):
|
||||||
|
return {"ok": False, "detail": "Die Probe läuft schon."}
|
||||||
|
return {"ok": True, "auftrag": kanal.anlegen("sicherung_probe")}
|
||||||
|
|
||||||
|
|
||||||
|
def _aus_schaetzung(z: dict, jetzt: float) -> dict | None:
|
||||||
|
"""Die Schätzung des PBS als Vorhersage (wie kern/prognose.vorhersage, mit „quelle“: „pbs“)."""
|
||||||
|
s = z.get("schaetzung")
|
||||||
|
if not isinstance(s, dict):
|
||||||
|
return None
|
||||||
|
anteil = _prozent(s.get("belegt") or z.get("belegt"), s.get("gesamt") or z.get("gesamt"))
|
||||||
|
voll_am = s.get("voll_am")
|
||||||
|
if not messreihen.ist_zahl(voll_am) or voll_am <= jetzt:
|
||||||
|
return {"tage": None, "rate": 0.0, "anteil": anteil, "grund": "gleich", "quelle": "pbs"}
|
||||||
|
tage = (voll_am - jetzt) / 86400
|
||||||
|
rate = round((100 - anteil) / tage, 2) if anteil is not None and tage > 0 else None
|
||||||
|
return {"tage": round(tage, 1), "rate": rate, "anteil": anteil, "grund": None, "quelle": "pbs"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Vorhersage „voll in etwa N Tagen“ (seit 25.09.2026) --------------------------------------------------------
|
||||||
|
|
||||||
|
VERLAUF = "speicher" # Quelle in kern/messreihen.py: die Belegungen aus jedem Bericht (alle zehn Minuten)
|
||||||
|
|
||||||
|
|
||||||
|
def _prozent(belegt: object, gesamt: object) -> float | None:
|
||||||
|
if not (messreihen.ist_zahl(belegt) and messreihen.ist_zahl(gesamt)) or gesamt <= 0:
|
||||||
|
return None
|
||||||
|
return round(belegt / gesamt * 100, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def verlauf_merken(bericht: dict, jetzt: float | None = None) -> None:
|
||||||
|
"""Die Belegungen eines Berichts als ein Punkt der Messreihe VERLAUF (in %): je Speicherpool Daten
|
||||||
|
(„pool:<name>“) und Metadaten („pool-meta:<name>“), je Netzlaufwerk („nas:<ziel>“), je Sicherungsziel
|
||||||
|
(„ziel:<name>“) und die Platte jeder VM („vm-<vmid>“ — die Minutenwerte kennen sie nicht). Ein kaputter Bericht
|
||||||
|
kostet nur diesen Punkt."""
|
||||||
|
try:
|
||||||
|
host = bericht.get("host") if isinstance(bericht.get("host"), dict) else {}
|
||||||
|
werte: dict[str, float | None] = {}
|
||||||
|
for p in pools(bericht):
|
||||||
|
werte[f"pool:{p['speicher']}"] = round(p["anteil"] * 100, 2)
|
||||||
|
if messreihen.ist_zahl(p["meta_anteil"]):
|
||||||
|
werte[f"pool-meta:{p['speicher']}"] = round(p["meta_anteil"] * 100, 2)
|
||||||
|
for m in host.get("netzlaufwerke") or []:
|
||||||
|
platz = m.get("platz") or {}
|
||||||
|
werte[f"nas:{m.get('ziel')}"] = _prozent(platz.get("belegt"), platz.get("gesamt"))
|
||||||
|
for name, z in (host.get("sicherungsziele") or {}).items():
|
||||||
|
werte[f"ziel:{name}"] = _prozent(z.get("belegt"), z.get("gesamt"))
|
||||||
|
for g in bericht.get("gaeste") or []:
|
||||||
|
if g.get("art") == "qemu":
|
||||||
|
platte = g.get("platte") or {}
|
||||||
|
werte[f"vm-{g['vmid']}"] = _prozent(platte.get("belegt"), platte.get("gesamt"))
|
||||||
|
werte = {name: wert for name, wert in werte.items() if wert is not None}
|
||||||
|
if werte:
|
||||||
|
messreihen.schreiben(VERLAUF, werte, jetzt)
|
||||||
|
except Exception:
|
||||||
|
log.warning("speicher: Verlauf der Belegungen nicht geschrieben", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def platten_reihe(geraet_id: str) -> tuple[str, str, float] | None:
|
||||||
|
"""Wo der Verlauf der Platte eines Geräts liegt: (Quelle, Name, voll bei %). Host und Container in den
|
||||||
|
Minutenwerten; VMs im VERLAUF, denn ihre Platte kennt nur der Bericht (über den Gast-Agenten)."""
|
||||||
|
if geraet_id == "pve":
|
||||||
|
return "pve", "platte", prognose.VOLL_EXT4
|
||||||
|
if re.fullmatch(r"ct-\d+", geraet_id):
|
||||||
|
return geraet_id, "platte", prognose.VOLL_EXT4
|
||||||
|
if re.fullmatch(r"vm-\d+", geraet_id):
|
||||||
|
return VERLAUF, geraet_id, prognose.VOLL_EXT4
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def platten_vorhersage(geraet_id: str, jetzt: float | None = None) -> dict | None:
|
||||||
|
reihe = platten_reihe(geraet_id)
|
||||||
|
return prognose.aus_messreihe(*reihe, jetzt=jetzt) if reihe else None
|
||||||
|
|
||||||
|
|
||||||
|
def _vorhersage(name: str, jetzt: float | None) -> dict:
|
||||||
|
return prognose.aus_messreihe(VERLAUF, name, jetzt=jetzt)
|
||||||
|
|
||||||
|
|
||||||
|
def pool_vorhersage(speicher: str, jetzt: float | None = None) -> tuple[dict, bool]:
|
||||||
|
"""Die Vorhersage eines Pools — Daten oder Metadaten, was früher voll ist — und ob es die Metadaten sind."""
|
||||||
|
daten, meta = _vorhersage(f"pool:{speicher}", jetzt), _vorhersage(f"pool-meta:{speicher}", jetzt)
|
||||||
|
return (meta, True) if prognose.bis_voll(meta) < prognose.bis_voll(daten) else (daten, False)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Für Oberfläche und Wächter ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def lage(jetzt: float | None = None) -> dict:
|
||||||
|
"""Für GET /api/homelab/speicher. Pools, Systemplatte, NAS und Sicherungsziele tragen seit 25.09.2026 je eine
|
||||||
|
„vorhersage“ (kern/prognose.vorhersage). jetzt nur für Tests: Dann wird die Vorhersage nicht gemerkt."""
|
||||||
|
b = _bericht()
|
||||||
|
zeit = time.time() if jetzt is None else jetzt
|
||||||
|
ergebnis = {"bericht": bool(b), "pools": pools(b), "systemplatte": systemplatte(b), "nas": nas(b),
|
||||||
|
"sicherungen": sicherungen(b, zeit), "empfehlungen": empfehlungen(b)}
|
||||||
|
for p in ergebnis["pools"]:
|
||||||
|
p["vorhersage"] = pool_vorhersage(p["speicher"], jetzt)[0]
|
||||||
|
if ergebnis["systemplatte"]:
|
||||||
|
ergebnis["systemplatte"]["vorhersage"] = platten_vorhersage("pve", jetzt)
|
||||||
|
for n in ergebnis["nas"]:
|
||||||
|
n["vorhersage"] = None if n["problem"] else _vorhersage(f"nas:{n['ziel']}", jetzt)
|
||||||
|
for z in ergebnis["sicherungen"]["ziele"]:
|
||||||
|
z["vorhersage"] = _aus_schaetzung(z, zeit) or _vorhersage(f"ziel:{z['speicher']}", jetzt)
|
||||||
|
return ergebnis
|
||||||
|
|
||||||
|
|
||||||
|
def pruefe_speicher(jetzt: float | None = None) -> list:
|
||||||
|
"""Befunde für den Wächter. Ohne Bericht keine — das Schweigen des Ausführers meldet pruefe_ausfuehrer().
|
||||||
|
Pool, Systemplatte und NAS melden sich auch nach dem Tempo (siehe oben); bestimmt die Vorhersage die Stufe, steht
|
||||||
|
„in etwa N Tagen voll“ im Titel, sonst hängt der Text sie an."""
|
||||||
|
from services.waechter import Befund # der Wächter registriert diese Prüfung, also erst hier
|
||||||
|
b = _bericht()
|
||||||
|
if not b:
|
||||||
|
return []
|
||||||
|
zeit = time.time() if jetzt is None else jetzt
|
||||||
|
befunde = []
|
||||||
|
tipps = empfehlungen(b)
|
||||||
|
platz_tipp = "; ".join([e["kurz"] for e in tipps if e.get("ort") == "pool"][:3])
|
||||||
|
for p in pools(b):
|
||||||
|
v, meta = pool_vorhersage(p["speicher"], jetzt)
|
||||||
|
stufe, kommend = prognose.schlimmer(p["stufe"], v)
|
||||||
|
if stufe == "ok":
|
||||||
|
continue
|
||||||
|
rest = ("Läuft er voll, stehen alle Gäste darauf still. " + (f"Platz bringt: {platz_tipp}. " if platz_tipp else "")
|
||||||
|
+ "Alles dazu im Homelab-Cockpit unter „Speicher und Sicherungen“.")
|
||||||
|
if kommend:
|
||||||
|
was = "Metadaten des Speicherpools" if meta else "Speicherpool"
|
||||||
|
titel = f"{was} {p['speicher']} {prognose.in_tagen(v['tage'])} voll"
|
||||||
|
text = f"{prognose.tempo(v)} Noch {_groesse(p['frei'])} frei. {rest}"
|
||||||
|
else:
|
||||||
|
anteil = max(p["anteil"], p["meta_anteil"] or 0)
|
||||||
|
was = "Metadaten des Speicherpools" if (p["meta_anteil"] or 0) > p["anteil"] else "Speicherpool"
|
||||||
|
titel = f"{was} {p['speicher']} zu {min(100, round(anteil * 100))} % voll"
|
||||||
|
text = f"Noch {_groesse(p['frei'])} frei.{prognose.dazu(v)} {rest}"
|
||||||
|
befunde.append(Befund(id=f"pool:{p['speicher']}", stufe=stufe, titel=titel, text=text, quelle="pve"))
|
||||||
|
sp = systemplatte(b)
|
||||||
|
v = platten_vorhersage("pve", jetzt) if sp else None
|
||||||
|
stufe, kommend = prognose.schlimmer(sp["stufe"], v) if sp else ("ok", False)
|
||||||
|
if sp and stufe != "ok":
|
||||||
|
kernel = next((e for e in tipps if e["art"] == "kernel"), None)
|
||||||
|
befunde.append(Befund(
|
||||||
|
id="host-platte", stufe=stufe,
|
||||||
|
titel=(f"Systemplatte des Proxmox-Hosts {prognose.in_tagen(v['tage'])} voll" if kommend
|
||||||
|
else f"Systemplatte des Proxmox-Hosts zu {min(100, round(sp['anteil'] * 100))} % voll"),
|
||||||
|
text=((f"{prognose.tempo(v)} " if kommend else "") + f"Noch {_groesse(sp['frei'])} frei."
|
||||||
|
+ ("" if kommend else prognose.dazu(v))
|
||||||
|
+ " Läuft sie voll, scheitern Updates und die Sicherungen vor Updates. "
|
||||||
|
+ (f"Platz bringt: {kernel['kurz']} (Knopf im Homelab-Cockpit unter „Speicher und Sicherungen“)."
|
||||||
|
if kernel else "Alte Sicherungen unter /var/lib/vz/dump und ISO-Abbilder prüfen.")),
|
||||||
|
quelle="pve"))
|
||||||
|
for n in nas(b):
|
||||||
|
v = None if n["problem"] else _vorhersage(f"nas:{n['ziel']}", jetzt)
|
||||||
|
stufe, kommend = prognose.schlimmer(n["stufe"], v)
|
||||||
|
if stufe == "ok":
|
||||||
|
continue
|
||||||
|
frei = _groesse((n["gesamt"] or 0) - (n["belegt"] or 0))
|
||||||
|
platz = ("Die Sicherungen brauchen jede Nacht Platz: im PBS die Aufbewahrung kürzen (Datastore → Prune) oder "
|
||||||
|
"alte Dateien auf dem NAS aufräumen.")
|
||||||
|
if n["problem"]:
|
||||||
|
titel, text = n["problem"], ("Die nächtlichen Sicherungen landen dort; bis das behoben ist, scheitern sie. "
|
||||||
|
"Strom und Netz des NAS prüfen, dann in dessen Oberfläche nachsehen.")
|
||||||
|
elif kommend:
|
||||||
|
titel = f"NAS ({n['server']}) {prognose.in_tagen(v['tage'])} voll"
|
||||||
|
text = f"{prognose.tempo(v)} Noch {frei} frei. {platz}"
|
||||||
|
else:
|
||||||
|
titel = f"NAS ({n['server']}) zu {min(100, round((n['anteil'] or 0) * 100))} % voll"
|
||||||
|
text = f"Noch {frei} frei.{prognose.dazu(v)} {platz}"
|
||||||
|
befunde.append(Befund(id=f"nas:{n['ziel']}", stufe=stufe, titel=titel, text=text, quelle="nas"))
|
||||||
|
s = sicherungen(b, zeit)
|
||||||
|
if s["stufe_frische"] in ("gelb", "rot"):
|
||||||
|
teile = list(s["probleme"])
|
||||||
|
if s["fehlend"]:
|
||||||
|
namen = ", ".join(f"{f['name']} ({'zuletzt ' + _datum(f['zuletzt']) if f['zuletzt'] else 'noch nie'})"
|
||||||
|
for f in s["fehlend"][:6])
|
||||||
|
teile.append(f"Keine frische Sicherung: {namen}.")
|
||||||
|
befunde.append(Befund(
|
||||||
|
id="sicherungen", stufe=s["stufe_frische"],
|
||||||
|
titel=("Sicherungen scheitern" if s["stufe_frische"] == "rot" else f"Sicherung fehlt bei {len(s['fehlend'])} Gerät"
|
||||||
|
+ ("en" if len(s["fehlend"]) != 1 else "")),
|
||||||
|
text=" ".join(teile) + " In Proxmox: Rechenzentrum → Backup und die Aufgaben-Liste unten zeigen, woran es lag.",
|
||||||
|
quelle="pve"))
|
||||||
|
befunde += _abdeckung_befunde(s, zeit)
|
||||||
|
for z in s["ziele"]:
|
||||||
|
v = _aus_schaetzung(z, zeit)
|
||||||
|
if prognose.stufe(v) != "ok":
|
||||||
|
befunde.append(Befund(
|
||||||
|
id=f"ziel:{z['speicher']}", stufe=prognose.stufe(v),
|
||||||
|
titel=f"Sicherungsziel {z['speicher']} {prognose.in_tagen(v['tage'])} voll",
|
||||||
|
text=(f"So schätzt es der PBS selbst. Noch {_groesse((z['gesamt'] or 0) - (z['belegt'] or 0))} frei. Im PBS "
|
||||||
|
"die Aufbewahrung kürzen (Datastore → Prune) oder Platz auf dem NAS schaffen."),
|
||||||
|
quelle="pve"))
|
||||||
|
return befunde
|
||||||
|
|
||||||
|
|
||||||
|
def _tag(ts: float) -> str:
|
||||||
|
return datetime.fromtimestamp(ts, LOCAL_TZ).strftime("%d.%m.")
|
||||||
|
|
||||||
|
|
||||||
|
def _abdeckung_befunde(s: dict, jetzt: float) -> list:
|
||||||
|
"""Die Hinweise zur Abdeckung: ohne Auftrag, beschädigt, lange nicht geprüft, Probe gescheitert."""
|
||||||
|
from services.waechter import Befund
|
||||||
|
befunde = []
|
||||||
|
if ohne := s["ohne_auftrag"]:
|
||||||
|
namen = ", ".join(g["name"] for g in ohne)
|
||||||
|
befunde.append(Befund(
|
||||||
|
id="sicherung:ohne-auftrag", stufe="gelb", sofort=True,
|
||||||
|
titel=(f"{namen} ist in keinem Sicherungsauftrag" if len(ohne) == 1
|
||||||
|
else f"{len(ohne)} Geräte sind in keinem Sicherungsauftrag"),
|
||||||
|
text=(("Proxmox sichert es nie. " if len(ohne) == 1 else f"Proxmox sichert sie nie: {namen}. ")
|
||||||
|
+ "In Proxmox: Rechenzentrum → Backup → den Auftrag bearbeiten und die Geräte anhaken (oder „Alle“)."),
|
||||||
|
quelle="pve"))
|
||||||
|
if defekt := [g for g in s["geraete"] if g["defekt"]]:
|
||||||
|
akut = any(g["geprueft"] and g["geprueft"]["zustand"] == "failed" for g in defekt)
|
||||||
|
teile = ", ".join(f"{g['name']} (Sicherung vom {_tag(g['defekt'][-1])})" for g in defekt[:6])
|
||||||
|
befunde.append(Befund(
|
||||||
|
id="sicherung:defekt", stufe="rot" if akut else "gelb",
|
||||||
|
titel=("Der PBS fand eine beschädigte Sicherung" if len(defekt) == 1
|
||||||
|
else f"Der PBS fand beschädigte Sicherungen bei {len(defekt)} Geräten"),
|
||||||
|
text=(f"Beim Prüfen stimmten Daten nicht: {teile}. Die nächste nächtliche Sicherung sollte wieder "
|
||||||
|
"vollständig sein — danach die beschädigte im PBS löschen (Datastore → Inhalt) und am NAS nach "
|
||||||
|
"Plattenfehlern sehen."),
|
||||||
|
quelle="pve"))
|
||||||
|
pruefung = s["pruefung"]
|
||||||
|
if pruefung["noetig"] and (not pruefung["zuletzt"] or jetzt - pruefung["zuletzt"] > PRUEFUNG_ALT_S):
|
||||||
|
befunde.append(Befund(
|
||||||
|
id="sicherung:pruefung", stufe="gelb",
|
||||||
|
titel=("Der PBS prüft die Sicherungen nicht" if not pruefung["zuletzt"]
|
||||||
|
else f"Der PBS hat seit {int((jetzt - pruefung['zuletzt']) // 86400)} Tagen keine Sicherung geprüft"),
|
||||||
|
text=("Ob eine Sicherung noch lesbar ist, zeigt erst die Prüfung. Im PBS: Datastore → Verify Jobs — läuft "
|
||||||
|
"dort ein Auftrag? Wenn keiner da ist: „Add“, etwa einmal die Woche."),
|
||||||
|
quelle="pve"))
|
||||||
|
if gescheitert := [g for g in s["geraete"] if g["probe"] and not g["probe"]["ok"]]:
|
||||||
|
befunde.append(Befund(
|
||||||
|
id="sicherung:probe", stufe="gelb",
|
||||||
|
titel=(f"Probe-Wiederherstellung gescheitert: {gescheitert[0]['name']}" if len(gescheitert) == 1
|
||||||
|
else f"Probe-Wiederherstellung bei {len(gescheitert)} Geräten gescheitert"),
|
||||||
|
text=(" ".join(f"{g['name']}: {g['probe']['fehler']}." for g in gescheitert[:4])
|
||||||
|
+ " Die Probe öffnet die jüngste Sicherung nur lesend und läuft in einer Stunde wieder."),
|
||||||
|
quelle="pve"))
|
||||||
|
return befunde
|
||||||
@@ -0,0 +1,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
|
||||||
+514
-118
@@ -1,118 +1,388 @@
|
|||||||
"""
|
"""
|
||||||
Mini-Job-System: Hintergrund-Prozesse mit Live-Log + Download-Fortschritt.
|
Auftrags-System: Hintergrund-Aufträge (Updates, Modell-Downloads) mit Protokoll und Fortschritt.
|
||||||
Portiert aus Mission Control v1 (jobengine.py). In-Memory, ein Daemon-Thread je Job.
|
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 glob
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shlex
|
import shlex
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from collections.abc import Callable
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from kern.einstellungen import einstellungen
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
JOBS: dict[str, dict] = {}
|
JOBS: dict[str, dict] = {}
|
||||||
_PROCS: dict[str, subprocess.Popen] = {}
|
_PROCS: dict[str, subprocess.Popen] = {}
|
||||||
|
_LOCK = threading.Lock()
|
||||||
_LOG_CAP = 400
|
_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:
|
def nacharbeit(name: str) -> Callable[[Callable[..., None]], Callable[..., None]]:
|
||||||
job["log"].append(line)
|
"""Eine Nacharbeit unter festem Namen anmelden. Sie bekommt die nacharbeit_daten des Auftrags
|
||||||
if len(job["log"]) > _LOG_CAP:
|
als Schlüsselwort-Argumente und läuft nur, wenn der Auftrag erfolgreich war."""
|
||||||
del job["log"][0]
|
def anmelden(fn: Callable[..., None]) -> Callable[..., None]:
|
||||||
|
_NACHARBEITEN[name] = fn
|
||||||
|
return fn
|
||||||
|
return anmelden
|
||||||
|
|
||||||
|
|
||||||
def _pump_output(job: dict, stream) -> None:
|
def abschluss(name: str) -> Callable[[Callable[[dict], None]], Callable[[dict], None]]:
|
||||||
"""Liest byteweise; `\\r` (tqdm/hf-Fortschritt) überschreibt die letzte Zeile."""
|
"""Einen Abschluss unter festem Namen anmelden. Anders als die Nacharbeit läuft er bei JEDEM Ende
|
||||||
buf = b""
|
(fertig, gescheitert, abgebrochen) und bekommt eine Kopie der Akte — etwa um das Ergebnis in den
|
||||||
overwrite = False
|
Update-Verlauf zu schreiben."""
|
||||||
pending_cr = False
|
def anmelden(fn: Callable[[dict], None]) -> Callable[[dict], None]:
|
||||||
|
_ABSCHLUESSE[name] = fn
|
||||||
def commit():
|
return fn
|
||||||
line = buf.decode("utf-8", "replace")
|
return anmelden
|
||||||
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 _run_job(job_id: str, args: list[str], env: dict | None = None, sudo_password: str | None = None):
|
# --- Ablage ------------------------------------------------------------------------
|
||||||
job = JOBS[job_id]
|
|
||||||
job["state"] = "running"
|
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:
|
try:
|
||||||
actual_args = list(args)
|
_jobs_dir().mkdir(parents=True, exist_ok=True)
|
||||||
if sudo_password is not None:
|
tmp = _pfad(job["id"], ".json.tmp")
|
||||||
for i, arg in enumerate(actual_args):
|
tmp.write_text(json.dumps(daten, ensure_ascii=False), encoding="utf-8")
|
||||||
if isinstance(arg, str):
|
tmp.replace(_pfad(job["id"], ".json"))
|
||||||
actual_args[i] = arg.replace("sudo -n", "sudo -S").replace("sudo ", "sudo -S ")
|
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:
|
def _protokoll(job_id: str, zeile: str) -> None:
|
||||||
proc.stdin.write((sudo_password + "\n").encode("utf-8"))
|
"""Eine Zeile von MC2 ins Protokoll des Auftrags schreiben."""
|
||||||
proc.stdin.flush()
|
try:
|
||||||
proc.stdin.close()
|
_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:
|
def attach_download_progress(job_id: str, local_dir: str, total_bytes: int) -> None:
|
||||||
"""Fortschritt in % aus wachsenden *.incomplete-Dateien (hf schreibt sie)."""
|
"""Fortschritt in % aus wachsenden *.incomplete-Dateien (hf schreibt sie)."""
|
||||||
if not total_bytes or total_bytes <= 0:
|
|
||||||
return
|
|
||||||
job = JOBS.get(job_id)
|
job = JOBS.get(job_id)
|
||||||
if job is not None:
|
if job is None or not total_bytes or total_bytes <= 0:
|
||||||
job["progress"] = 0
|
return
|
||||||
job["total_bytes"] = total_bytes
|
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():
|
def _watch():
|
||||||
pat = os.path.join(local_dir, ".cache", "huggingface", "download", "**", "*.incomplete")
|
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
|
rate = 0.0
|
||||||
while True:
|
while True:
|
||||||
j = JOBS.get(job_id)
|
j = JOBS.get(job_id)
|
||||||
if not j or j["state"] in ("done", "failed", "canceled"):
|
if not j or j["state"] in _ENDE:
|
||||||
break
|
break
|
||||||
try:
|
try:
|
||||||
inc = glob.glob(pat, recursive=True)
|
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["rate_bps"] = rate
|
||||||
j["eta_s"] = int((total_bytes - cur) / rate)
|
j["eta_s"] = int((total_bytes - cur) / rate)
|
||||||
prev_t, prev_b = now, cur
|
prev_t, prev_b = now, cur
|
||||||
except Exception: # noqa: BLE001
|
except Exception:
|
||||||
pass
|
pass
|
||||||
time.sleep(1.0)
|
time.sleep(1.0)
|
||||||
j = JOBS.get(job_id)
|
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()
|
threading.Thread(target=_watch, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
def start_job(args: list[str], label: str, env: dict | None = None, on_done=None,
|
# --- Verwaltung -------------------------------------------------------------------
|
||||||
sudo_password: str | None = None, group: str | None = None) -> str:
|
|
||||||
|
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]
|
job_id = uuid.uuid4().hex[:12]
|
||||||
# Mask password in log if present in args
|
job = {
|
||||||
log_args = list(args)
|
"id": job_id, "label": label, "state": "queued", "group": group, "art": _art(),
|
||||||
JOBS[job_id] = {
|
|
||||||
"id": job_id, "label": label, "state": "queued", "group": group,
|
|
||||||
"log": ["$ " + " ".join(shlex.quote(a) for a in log_args)],
|
|
||||||
"returncode": None, "started_at": time.time(), "finished_at": None,
|
"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] = job
|
||||||
JOBS[job_id]["_on_done"] = on_done
|
_protokoll(job_id, "$ " + " ".join(shlex.quote(a) for a in args))
|
||||||
threading.Thread(target=_run_job, args=(job_id, args, env, sudo_password), daemon=True).start()
|
_speichern(job)
|
||||||
return job_id
|
return job
|
||||||
|
|
||||||
|
|
||||||
def active_in_group(group: str) -> dict | None:
|
def _aktiv_in(group: str) -> dict | None:
|
||||||
"""Erster laufender/wartender Job einer Gruppe (z.B. 'maintenance'), sonst None.
|
for j in list(JOBS.values()):
|
||||||
Basis für den Wartungs-Riegel: nur EIN System-Update gleichzeitig."""
|
|
||||||
for j in JOBS.values():
|
|
||||||
if j.get("group") == group and j.get("state") in ("running", "queued"):
|
if j.get("group") == group and j.get("state") in ("running", "queued"):
|
||||||
return j
|
return j
|
||||||
return None
|
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:
|
def cancel_job(job_id: str) -> bool:
|
||||||
job = JOBS.get(job_id)
|
with _LOCK:
|
||||||
if not job or job["state"] in ("done", "failed", "canceled"):
|
_laden()
|
||||||
return False
|
job = JOBS.get(job_id)
|
||||||
job["canceled"] = True
|
if not job or job["state"] in _ENDE:
|
||||||
_append_log(job, "[mc] Abbruch angefordert…")
|
return False
|
||||||
proc = _PROCS.get(job_id)
|
job["canceled"] = True
|
||||||
if proc is not None:
|
_speichern(job)
|
||||||
|
_protokoll(job_id, "[mc] Abbruch angefordert…")
|
||||||
|
if job["state"] == "queued":
|
||||||
|
_abschliessen(job, None)
|
||||||
|
elif job.get("art") == "systemd":
|
||||||
try:
|
try:
|
||||||
proc.terminate()
|
subprocess.run(_systemd("systemctl", "stop", "--no-block", _einheit(job_id)),
|
||||||
except Exception: # noqa: BLE001
|
capture_output=True, timeout=15)
|
||||||
pass
|
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:
|
else:
|
||||||
job["state"] = "canceled"
|
_abschliessen(job, None)
|
||||||
job["finished_at"] = time.time()
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def public_jobs() -> list[dict]:
|
def quittieren(job_id: str) -> bool:
|
||||||
"""Jobs ohne interne Felder (_on_done) für die API."""
|
"""Einen beendeten Auftrag als gesehen markieren — die Oberfläche blendet ihn dann aus (seit 25.09.2026;
|
||||||
return [{k: v for k, v in j.items() if not k.startswith("_")} for j in JOBS.values()]
|
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.
|
`swap:false`) → Multi-Model-Delegation ohne Nachlade-Latenz.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import functools
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
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 ruamel.yaml.scalarstring import LiteralScalarString
|
||||||
|
|
||||||
from config import (
|
try:
|
||||||
CMD_TEMPLATE, CONFIG_PATH, DEFAULT_TTL, DRAFTS_DIR, LLAMA_SWAP_URL,
|
import fcntl
|
||||||
SPEC_DRAFT_MODEL_PATH, SPEC_DRAFT_N_MAX, SPEC_TYPE,
|
except ImportError: # Windows (Entwicklung): nur die Prozess-Sperre
|
||||||
)
|
fcntl = None
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -48,8 +63,77 @@ def _gguf_total_size(path: str) -> int | None:
|
|||||||
return 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 -------------------------------------------------------------------
|
# --- Lesen -------------------------------------------------------------------
|
||||||
def read_config() -> dict:
|
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():
|
if not CONFIG_PATH.exists():
|
||||||
return {"models": {}}
|
return {"models": {}}
|
||||||
from ruamel.yaml import YAML
|
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-…) →
|
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'."""
|
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))
|
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)))
|
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:
|
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)
|
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"
|
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:
|
def set_role_alias(cfg: dict, model_id: str, role: str | None) -> None:
|
||||||
"""Rolle als eindeutigen llama-swap-`aliases`-Eintrag setzen (vorher bei allen
|
"""Rolle als llama-swap-`aliases`-Eintrag setzen (vorher bei allen anderen Modellen
|
||||||
anderen Modellen entfernen). role=None/leer entfernt den Alias."""
|
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 {}
|
models = cfg.get("models") or {}
|
||||||
role = (role or "").strip().lower()
|
role = (role or "").strip().lower()
|
||||||
if role:
|
if role:
|
||||||
@@ -169,8 +270,11 @@ def set_role_alias(cfg: dict, model_id: str, role: str | None) -> None:
|
|||||||
spec.pop("aliases", None)
|
spec.pop("aliases", None)
|
||||||
spec = models.get(model_id)
|
spec = models.get(model_id)
|
||||||
if isinstance(spec, dict):
|
if isinstance(spec, dict):
|
||||||
if role and role != model_id.lower():
|
keep = [a for a in (spec.get("aliases") or [])
|
||||||
spec["aliases"] = [role]
|
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:
|
else:
|
||||||
spec.pop("aliases", None)
|
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:
|
def write_config(cfg: dict) -> None:
|
||||||
"""Atomar schreiben (tmp + os.replace), damit llama-swap mit -watch-config nie
|
"""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:
|
try:
|
||||||
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
tmp = CONFIG_PATH.with_name(CONFIG_PATH.name + ".tmp")
|
tmp = CONFIG_PATH.with_name(CONFIG_PATH.name + ".tmp")
|
||||||
@@ -202,7 +314,7 @@ def write_config(cfg: dict) -> None:
|
|||||||
try:
|
try:
|
||||||
from services import warmer
|
from services import warmer
|
||||||
warmer.nudge()
|
warmer.nudge()
|
||||||
except Exception: # noqa: BLE001 — Vorwärmen ist Komfort, nie ein Schreib-Blocker
|
except Exception:
|
||||||
pass
|
pass
|
||||||
except PermissionError as exc:
|
except PermissionError as exc:
|
||||||
raise PermissionError(
|
raise PermissionError(
|
||||||
@@ -211,12 +323,15 @@ def write_config(cfg: dict) -> None:
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@_mit_sperre
|
||||||
def register_model(model_path: str, role: str | None = None, ctx: int = 8192,
|
def register_model(model_path: str, role: str | None = None, ctx: int = 8192,
|
||||||
ttl: int | None = None, mmproj_path: str | None = None,
|
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
|
"""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()
|
cfg = read_config()
|
||||||
model_id = model_id_from_path(model_path)
|
model_id = model_id_from_path(model_path)
|
||||||
cmd = CMD_TEMPLATE.replace("{model}", model_path).replace("{ctx}", str(ctx))
|
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:
|
if "--cache-reuse" not in cmd and "--mmproj" not in cmd:
|
||||||
cmd += " --cache-reuse 256 -cram 16384"
|
cmd += " --cache-reuse 256 -cram 16384"
|
||||||
# IDE-Coding profitiert von Nebenläufigkeit; sonst Default 1 Slot = voller Kontext/Anfrage
|
# 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:
|
if role_lower == "coder" and "--parallel" not in cmd:
|
||||||
cmd += " --parallel 2"
|
cmd += " --parallel 2"
|
||||||
# Vocab-kompatiblen Draft automatisch anhängen — klassisch (DRAFTS_DIR) ODER MTP-Kopf neben
|
# 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:
|
if "--spec-draft-model" not in cmd and "--model-draft" not in cmd:
|
||||||
cmd += spec_draft_flags(model_path)
|
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"),
|
"cmd": LiteralScalarString(cmd + "\n"),
|
||||||
"ttl": ttl if ttl is not None else DEFAULT_TTL,
|
"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)
|
write_config(cfg)
|
||||||
return model_id
|
return model_id
|
||||||
|
|
||||||
@@ -332,70 +455,8 @@ def spec_draft_flags(target_path: str) -> str:
|
|||||||
return _spec_flags_for_draft(d) if d else ""
|
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) ----------------------------------------------------
|
# --- Groups (Ko-Residenz) ----------------------------------------------------
|
||||||
|
@_mit_sperre
|
||||||
def set_group(group: str, members: list[str], swap: bool = False, persist: bool = False) -> None:
|
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
|
"""llama-swap-`groups`-Eintrag setzen. swap=False → alle Mitglieder dürfen
|
||||||
GLEICHZEITIG laufen (Ko-Residenz, keine Nachlade-Latenz). persist=True →
|
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()
|
cfg = read_config()
|
||||||
groups = cfg.setdefault("groups", {})
|
groups = cfg.setdefault("groups", {})
|
||||||
groups[group] = {"swap": swap, "persist": persist, "persistent": persist,
|
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)}
|
"members": list(members)}
|
||||||
# Härtung: eine persistente (immer-warme) Gruppe verlangt ttl:0 je Mitglied.
|
# Härtung: eine persistente (immer-warme) Gruppe verlangt ttl:0 je Mitglied.
|
||||||
# `persistent` schützt NUR gegen Verdrängung durch andere Modelle, NICHT gegen
|
# `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 {}
|
return read_config().get("groups") or {}
|
||||||
|
|
||||||
|
|
||||||
|
@_mit_sperre
|
||||||
def set_role(model_id: str, role: str | None) -> bool:
|
def set_role(model_id: str, role: str | None) -> bool:
|
||||||
"""Rolle (llama-swap-Alias) eines bestehenden Modells setzen/ändern. So tauscht man
|
"""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)."""
|
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
|
return True
|
||||||
|
|
||||||
|
|
||||||
def set_ctx(model_id: str, ctx: int) -> bool:
|
@_mit_sperre
|
||||||
"""Kontextlänge (-c) eines bestehenden Modells ändern."""
|
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()
|
cfg = read_config()
|
||||||
spec = (cfg.get("models") or {}).get(model_id)
|
models = cfg.get("models") or {}
|
||||||
if not spec:
|
rolle = (role or "").strip().lower()
|
||||||
|
if model_id not in models or not rolle or not isinstance(models[model_id], dict):
|
||||||
return False
|
return False
|
||||||
cmd = str(spec.get("cmd", ""))
|
bisher = [a for a in (models[model_id].get("aliases") or []) if str(a).lower() != rolle]
|
||||||
if _CTX_RE.search(cmd):
|
set_role_alias(cfg, model_id, rolle) # nimmt die Rolle allen anderen Modellen weg
|
||||||
cmd = re.sub(r"-(?:c|-ctx-size)\s+\d+", f"-c {ctx}", cmd)
|
models[model_id]["aliases"] = bisher + [rolle]
|
||||||
else:
|
|
||||||
cmd = cmd.rstrip() + f" -c {ctx}"
|
|
||||||
spec["cmd"] = LiteralScalarString(cmd if cmd.endswith("\n") else cmd + "\n")
|
|
||||||
write_config(cfg)
|
write_config(cfg)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@_mit_sperre
|
||||||
def set_ttl(model_id: str, ttl: int) -> bool:
|
def set_ttl(model_id: str, ttl: int) -> bool:
|
||||||
"""Idle-TTL (Sekunden) eines bestehenden Modells setzen. ttl=0 → nie automatisch
|
"""Idle-TTL (Sekunden) eines bestehenden Modells setzen. ttl=0 → nie automatisch
|
||||||
entladen (für das Agent-Hirn, das dauerhaft warm bleiben muss)."""
|
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
|
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:
|
def delete_model(model_id: str) -> bool:
|
||||||
"""Entfernt einen Modell-Eintrag aus der config.yaml, löscht die zugehörigen
|
"""Entfernt einen Modell-Eintrag aus der config.yaml, löscht die zugehörigen
|
||||||
GGUF-Dateien (auch Splits) vom Datenträger und bereinigt leere Ordner.
|
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 {}
|
model_spec = models[model_id] or {}
|
||||||
cmd = str(model_spec.get("cmd", "")).strip()
|
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('"', "")
|
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:
|
if path:
|
||||||
# 1. Haupt-GGUF-Datei löschen
|
# 1. Haupt-GGUF-Datei löschen
|
||||||
if os.path.exists(path):
|
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)
|
mmproj_match = re.search(r'--mmproj\s+[\'"]?([^\s\'"]+)[\'"]?', cmd)
|
||||||
if mmproj_match:
|
if mmproj_match:
|
||||||
m_path = mmproj_match.group(1)
|
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:
|
try:
|
||||||
os.remove(m_path)
|
os.remove(m_path)
|
||||||
except Exception:
|
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
|
# 3. Eltern-Ordner löschen, falls er leer ist und nicht der Modelle-Wurzelordner selbst ist
|
||||||
try:
|
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)
|
os.rmdir(dirname)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -574,3 +659,104 @@ def get_running_models() -> list[str]:
|
|||||||
except Exception:
|
except Exception:
|
||||||
log.warning("get_running_models fehlgeschlagen", exc_info=True)
|
log.warning("get_running_models fehlgeschlagen", exc_info=True)
|
||||||
return []
|
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)}"}
|
||||||
|
|||||||
+800
-800
File diff suppressed because it is too large
Load Diff
@@ -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
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
from config import MODELS_DIR
|
from config import MODELS_DIR
|
||||||
|
from kern.zeit import LOCAL_TZ
|
||||||
|
|
||||||
from services import announce
|
from services import announce
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
STORE_PATH = Path(os.environ.get("MC_REMINDERS_STORE", str(MODELS_DIR / "mc2-reminders.json")))
|
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)
|
INTERVAL = int(os.environ.get("MC_REMINDERS_INTERVAL", "20")) # Wecker-Tick (Sekunden)
|
||||||
MAX_ITEMS = int(os.environ.get("MC_REMINDERS_MAX", "100"))
|
MAX_ITEMS = int(os.environ.get("MC_REMINDERS_MAX", "100"))
|
||||||
REPEATS = ("", "daily", "weekdays", "weekly")
|
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:
|
Zwei virtuelle Lanes, die Clients/IDEs auswählen — der Router pickt das echte Modell:
|
||||||
- **chat** (= altes `auto`): Alltag → `fast`, schwer/lang → `heavy`.
|
- **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).
|
triviale Kurzfrage ohne Code → `fast` (Tempo).
|
||||||
|
|
||||||
Regelbasiert, sub-ms, ohne Cloud. Schwellen/Aliases liegen in einer UI-editierbaren Policy
|
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
|
# (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.
|
# 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
|
LANE_ALIASES = {"auto": "chat"} # Rückwärtskompatibel: model:auto == chat
|
||||||
|
|
||||||
_HEAVY_KW = re.compile(
|
_HEAVY_KW = re.compile(
|
||||||
@@ -36,23 +34,76 @@ _CODING_HEAVY_KW = re.compile(
|
|||||||
r"entwirf\s+(eine\s+)?architektur|plane?\s+(die\s+)?architektur)\b",
|
r"entwirf\s+(eine\s+)?architektur|plane?\s+(die\s+)?architektur)\b",
|
||||||
re.IGNORECASE,
|
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.
|
# Bild-Weiche (Faden 11): Aliase, die selbst Bilder koennen — die werden NIE umgeroutet.
|
||||||
_CODE_HINT = re.compile(
|
# Seit 24.09.2026 sind vision und coder-bild die Bild-Zwillinge von Hirn und Coder (gleiche Gewichte
|
||||||
r"```|\bdef \b|\bclass \b|\bimport \b|\bfunction\b|=>|;\s*$|"
|
# plus Bild-Projektor, ohne Draft — llama.cpp kann Draft und Bild nicht zusammen, HTTP 500).
|
||||||
r"\.(py|ts|tsx|js|jsx|go|rs|java|cpp|c|rb|php|sql)\b|/src/|traceback|stack\s*trace|"
|
VISION_CAPABLE = {"vision", "coder-bild", "scout"}
|
||||||
r"\b(funktion|function|bug|fix|fehler|error|exception|implementier\w*|schreib\w*|"
|
IMAGE_PART_TYPES = {"image_url", "input_image", "image"}
|
||||||
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",
|
def ist_coder_alias(alias: str) -> bool:
|
||||||
re.IGNORECASE | re.MULTILINE,
|
"""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:
|
||||||
|
"""True, wenn irgendeine Nachricht einen Bild-Part enthaelt (OpenAI-multimodaler
|
||||||
|
content: eine Liste mit einem {"type": "image_url"|"input_image"|"image", ...}-Teil)."""
|
||||||
|
for m in body.get("messages") or []:
|
||||||
|
if not isinstance(m, dict):
|
||||||
|
continue
|
||||||
|
content = m.get("content")
|
||||||
|
if isinstance(content, list):
|
||||||
|
for part in content:
|
||||||
|
if isinstance(part, dict) and part.get("type") in IMAGE_PART_TYPES:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _text_of(body: dict) -> str:
|
def _text_of(body: dict) -> str:
|
||||||
msgs = body.get("messages") or []
|
msgs = body.get("messages") or []
|
||||||
return "\n".join(str(m.get("content") or "") for m in msgs)
|
# Multimodaler content ist eine Liste — nur die Text-Parts fuers Komplexitaets-Routing
|
||||||
|
# zusammenziehen (ein dict/Liste als str() wuerde die Zeichen-Schwelle verfaelschen).
|
||||||
|
out = []
|
||||||
|
for m in msgs:
|
||||||
|
if not isinstance(m, dict):
|
||||||
|
continue
|
||||||
|
c = m.get("content")
|
||||||
|
if isinstance(c, str):
|
||||||
|
out.append(c)
|
||||||
|
elif isinstance(c, list):
|
||||||
|
for part in c:
|
||||||
|
if isinstance(part, dict) and part.get("type") == "text":
|
||||||
|
out.append(str(part.get("text") or ""))
|
||||||
|
return "\n".join(out)
|
||||||
|
|
||||||
|
|
||||||
def _route_chat(text: str, n: int) -> tuple[str, str]:
|
def _route_chat(text: str, n: int) -> tuple[str, str]:
|
||||||
@@ -85,6 +136,3 @@ def choose_for_lane(lane: str, body: dict) -> tuple[str, str]:
|
|||||||
return _route_chat(text, n) # chat + alles Unbekannte
|
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)
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user