11 Commits
Author SHA1 Message Date
HitonabiandClaude Opus 5.5 363bc211e1 Welle 2 · dist gebaut
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 18:09:15 +02:00
HitonabiandClaude Opus 5.5 dc4c014b46 Welle 2 · Befehlssuche mit Strg+K: Seiten, Geräte und Aktionen, Filter typ: und update:
Strg+K (oder die Lupe oben) öffnet eine Suche über alle Seiten und je Gerät Cockpit, Zeitleiste, Monitoring,
Snapshots und — wo eins ansteht — Update, etwa „AdGuard Snapshot“ oder „typ:lxc update:ja“. Pfeiltasten, Enter, Esc.
Befehle führen nur hin; ausgelöst wird dort mit der dortigen Rückfrage. Das Fenster lädt erst beim ersten Öffnen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 18:09:15 +02:00
HitonabiandClaude Opus 5.5 83bb227969 Welle 2 · Zeitleiste je Gerät: Protokoll nach Gerät, Updates von → nach, Hinweise mit ihrem Gerät
/homelab/protokoll#ct-104 zeigt die Zeitleiste eines Geräts (Auswahl oben, Link „Zeitleiste“ je Gerät in der
Geräte-Tabelle); die Punkte eines Tages hängen an einer Linie. Das Protokoll filtert dafür im Backend (?ziel=…),
Update-Läufe heißen „Update eingespielt · Gitea 1.27.2 → 1.27.3“, und Hinweise finden ihr Gerät über ihre Quelle
(der Wächter schreibt sie seit heute in seinen Verlauf) — auch Zertifikate, Docker und Sicherungen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 18:06:41 +02:00
HitonabiandClaude Opus 5.5 1c66daad0b Welle 2 · Sicherheits-Markierung: Debian-Sicherheitsupdates, Alpine über secdb, Lücken-Scans aus Arcane
Der Ausführer zählt je Gast, wie viele offene Updates aus einer Sicherheits-Suite kommen („-security“ in apt list),
beim Proxmox-Host ebenso; für Alpine (NPMplus) schickt er die Paketliste, und der Homelab-Teil gleicht sie mit Alpines
Sicherheitsdatenbank ab (im Hintergrund geladen, zwölf Stunden gemerkt, Alpine-Versionsvergleich). Die Update-Tabelle,
die Kacheln und die Übersicht zeigen „· 3 Sicherheit“, die Rückfrage nennt es. Je Docker-Container steht, was Arcanes
Lücken-Scan (Trivy, sonntags 02:00) im Image fand, kritische und hohe. Der Ausführer braucht ausfuehrer-einrichten.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 18:02:33 +02:00
HitonabiandClaude Opus 5.5 995a0f34ee Welle 2 · Befundliste mit Ablauf: neu, gesehen, 24 h stumm; Puffer gegen Flackern
Jeder Hinweis hat jetzt einen Zustand (neu, gesehen, stumm bis …) mit Knöpfen in beiden Cockpits; das Homelab-Cockpit
nutzt dieselbe Liste wie die KI-Box (Knöpfe gehen an den Homelab-Teil, neue Route). Stumm heißt: keine
Telegram-Erinnerungen, unten in der Liste, keine Warnleuchte — verschärft er sich von gelb auf rot, gilt stumm nicht
mehr. Ein Hinweis ist erst erledigt, wenn sein Befund drei Takte am Stück fehlt: Kurze Wackler bringen kein „Erledigt“
und gleich danach keinen neuen Alarm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 17:54:56 +02:00
HitonabiandClaude Opus 5.5 45f11bc3dc Welle 2 · „Was ist neu?“ vor dem Knopf: Release Notes, Debian-Änderungslisten und Lucys Kurzfassung
Vor einem App-Update sammelt der Orchestrator die Release Notes aller Versionen zwischen installiert und neu (GitHub),
beim Proxmox-Host die Debian-Änderungslisten der wichtigsten Pakete (neue Ausführer-Aktion changelog, nur lesend, nur
für anstehende Pakete). Lucy fasst sie über das Modell fast der KI-Box auf Deutsch zusammen: Handarbeit ganz oben,
höchstens fünf Stichpunkte, Brüche zuerst. Die Update-Tabelle hat „Was ist neu?“ je Gerät, und die Rückfrage vor dem
Update zeigt die Kurzfassung gleich mit. Gemerkt je Version; der Ausführer braucht ausfuehrer-einrichten.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 17:47:04 +02:00
HitonabiandClaude Opus 5.5 0190c25d5a Welle 2 · Datenbanken in Docker-Stacks mit Logo (postgres → postgresql, redis, mariadb …)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 17:42:03 +02:00
HitonabiandClaude Opus 5.5 2f608736af Welle 2 (7, 8) · dist gebaut
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 17:32:36 +02:00
HitonabiandClaude Opus 5.5 6af42bd42c Welle 2 · Docker-Dienste unter Arcane einzeln: Stacks, Zustand, Gesundheit, Update-Stand; neue Stacks unter „Neu entdeckt“
Statt „Arcane (Docker) · 11 Images“ zeigt das Homelab-Cockpit je Stack jeden Container mit Zustand, Healthcheck und
Update-Stand („Update verfügbar“, „lokal gebaut“ für NerdQuiz und Rippy, „aktuell“). Der Wächter meldet gelb, wenn
ein Container ungesund ist oder ständig neu startet; beendete Container sind kein Befund. Neue Stacks erscheinen unter
„Neu entdeckt“ (eigenes Register, nur „Übernehmen“), Lucy meldet sie einmal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 17:31:05 +02:00
HitonabiandClaude Opus 5.5 c7c396f56d Welle 2 · Neu entdeckt: neue Geräte mit Vorschlägen, Übernehmen per Klick (Etikett), App-Katalog pflegt sich selbst
Jeder Bericht wird mit einem Register bekannter Geräte verglichen; ein neuer Container oder eine neue VM erscheint im
Homelab-Cockpit unter „Neu entdeckt“, und Lucy meldet ihn einmal über Telegram. Übernehmen: „Für Updates freigeben“
(der Ausführer setzt das Etikett watcher — neue Aktion etikett_setzen, sie darf nur watcher und watcher-aus setzen oder
entfernen), „Nur beobachten“ oder „Nicht anfassen“ (watcher-aus). Unbekannte Community-Scripts-Kennungen trägt
katalog.py aus ihrem Update-Skript nach (Name, GitHub-Projekt, Port, Weg); ein Port, der nicht antwortet, schaltet die
Webprüfung nicht ein. Der Ausführer braucht ausfuehrer-einrichten.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 17:25:38 +02:00
HitonabiandClaude Opus 5.5 f2bffc7203 Welle 1 · Nachschliff: Proxmox-Kachel zählt die Gäste wie die Homelab-Karte, der eigene Container heißt lesbar
„8 von 8 Gästen an“ neben „7 Gäste“: Die Kachel zählte den eigenen Container (Etikett mc2) mit, die Karte nicht.
In „Sicherungen je Gerät“ heißt er „Homelab Orchestrator (dieser Container)“ statt homelab-orchestrator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 17:16:22 +02:00
89 changed files with 2913 additions and 149 deletions
+1
View File
@@ -36,6 +36,7 @@ class BausteinStand:
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
+4 -2
View File
@@ -132,8 +132,10 @@ def _box() -> dict:
def start() -> dict:
stand = waechter.lese_stand()
u = box_updates.gecacht()
rot = sum(1 for h in stand["hinweise"] if h.get("stufe") == "rot")
anzahl = len(stand["hinweise"])
# 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": {
+58 -3
View File
@@ -6,12 +6,16 @@
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 ┐
@@ -25,6 +29,8 @@ 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
@@ -33,6 +39,8 @@ 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"])
@@ -102,6 +110,13 @@ 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:
"""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()}
@@ -147,9 +162,10 @@ def sammellauf_quittieren(sl_id: str) -> dict:
@router.get("/protokoll")
def protokoll_lesen(grenze: int = 200, berichte: bool = False) -> dict:
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)}
return {"eintraege": protokoll.eintraege(grenze, berichte, ziel or None)}
@router.get("/speicher")
@@ -159,6 +175,39 @@ def speicher_lage() -> dict:
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)."""
@@ -279,9 +328,15 @@ def auftrag_abholen() -> dict:
@router.post("/ausfuehrer/bericht", dependencies=[Depends(_nur_ausfuehrer)])
def bericht(daten: dict) -> dict:
from services.homelab import speicher
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}
+7 -2
View File
@@ -8,8 +8,9 @@ Wie eine App aktualisiert wird, ist je Community-Script verschieden (nachgelesen
eigene-oberflaeche das Skript verweist auf den eingebauten Updater der App (AdGuard, PVE Scripts Local)
pakete die App kommt als Paket; das Update der Pakete im Gast ist ihr Update (PBS)
Nur beim Weg „skript“ bietet die Übersicht „Jetzt updaten“ für die App an.
Neue Container mit dem Etikett erscheinen von selbst; fehlt ihre Kennung hier, zeigt die Übersicht
sie ohne Versionsvergleich und ohne Webprüfung — nachtragen genügt.
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
@@ -46,6 +47,10 @@ NACH_NAME: dict[str, App] = {
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())
+45
View File
@@ -200,6 +200,51 @@ def _images_mit_update(url: str) -> list[dict]:
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."""
+96
View File
@@ -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
+218
View File
@@ -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 "")}
+8 -2
View File
@@ -23,7 +23,7 @@ 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
from services.homelab import apps, arcane, kanal, karenz, sicherheit
BERICHT_ALT_S = 30 * 60
LISTEN_ALT_S = 14 * 24 * 3600
@@ -168,9 +168,13 @@ def _os_baustein(gast: dict, zid: str, jetzt: float) -> BausteinStand:
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 im Gast jetzt einspielen? {_rueckweg_frage(gast)}")
f"{anzahl} Pakete{davon} im Gast jetzt einspielen? {_rueckweg_frage(gast)}")
return stand
@@ -269,6 +273,8 @@ def _host_ziel(host: dict) -> ZielStand:
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.")
+1 -1
View File
@@ -34,7 +34,7 @@ except ImportError: # Windows (Entwicklung): nur die Thread-Sperre
AKTIONEN = {"bericht", "snapshot", "update", "os_update", "suchen", "zurueck", "snapshot_loeschen",
"host_update", "host_neustart", "sichern", "sicherung_zurueck", "sicherung_loeschen",
"kernel_aufraeumen", "sicherung_probe"}
"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 …
+160
View File
@@ -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()
+4 -1
View File
@@ -13,6 +13,7 @@ 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"}
@@ -36,7 +37,9 @@ def _npm(vmid: int) -> dict | None:
def fuer_bericht(bericht: dict) -> dict[str, dict]:
"""Ziel-ID → Kennzahl. Ein Fehler bei einem Gerät kostet nur dessen Zahl."""
zahlen: dict[str, dict] = {}
gaeste = [g for g in bericht.get("gaeste") or [] if isinstance(g, dict) and "vmid" in g]
# 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"}
+212
View File
@@ -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)}
+23 -10
View File
@@ -69,8 +69,11 @@ _AKTION = {
"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"}
_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",
@@ -344,7 +347,10 @@ def _lauf(x: dict, schw: Callable) -> dict | None:
elif ergebnis == "eingespielt":
stufe = "ok"
if baustein == "app":
titel = f"Update eingespielt · {name}" + (f" {x['version_neu']}" if x.get("version_neu") else "")
# 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":
@@ -404,7 +410,13 @@ def _waechter_pfad() -> Path:
return Path(os.environ.get("MC_WAECHTER_STORE", str(einstellungen().daten_dir / "mc2-waechter.json")))
def _ziel_aus(hinweis_id: str) -> str | None:
_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):
@@ -425,15 +437,15 @@ def _hinweise(schw: Callable) -> list[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_aus(hid), None, schw)
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_aus(hid), None, schw)
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_aus(hid),
None, schw)
e = _eintrag(v.get("ts"), "hinweis", titel, "Selbstreparatur des Wächters.", "info", ziel, None, schw)
else:
continue
eintraege.append(e)
@@ -549,8 +561,9 @@ def _pflege(geraete: dict, schw: Callable) -> list[dict]:
# --- Zusammen -------------------------------------------------------------------------------------------
def eintraege(grenze: int = 200, berichte: bool = False) -> list[dict]:
"""Alle Einträge, neueste zuerst, höchstens `grenze` (1 bis GRENZE_MAX)."""
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()
@@ -565,6 +578,6 @@ def eintraege(grenze: int = 200, berichte: bool = False) -> list[dict]:
alle += _hinweise(schw)
alle += _meldungen(_betreffe(laeufe, sammellaeufe), schw)
alle += _pflege(geraete, schw)
liste = [e for e in alle if e is not None]
liste = [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]
+120
View File
@@ -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
+3 -1
View File
@@ -375,8 +375,10 @@ def _je_geraet(bericht: dict, ziele: dict, fehlend: set[str], ohne: set[int], pr
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": _name(g), "vmid": g["vmid"], "auftrag": g["vmid"] not in ohne,
"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 []}),
+85 -8
View File
@@ -14,7 +14,10 @@ gesehen. Dieser Wächter schaut deshalb breiter hin:
• Probe monatliche Probe-Wiederherstellung der Sicherung (rot oder zu alt = gelb)
Jeder Befund wird zum Hinweis mit Stufe (rot = jetzt, gelb = bei Gelegenheit), Beginn und
Knöpfen. Einfaches behebt er selbst (User-Entscheid 23.09.): Ein ABGESTÜRZTER Dienst
Knöpfen. Seit 25.09.2026 (Ausbauplan Welle 2, Punkt 10) hat jeder Hinweis einen Zustand: neu, gesehen oder stumm bis …
(24 Stunden, je Hinweis per Knopf; verschärft er sich von gelb auf rot, gilt stumm nicht mehr). Stumm heißt: keine
Telegram-Erinnerungen, in der Liste unten. Und ein Hinweis ist erst erledigt, wenn sein Befund ERLEDIGT_NACH Takte am
Stück fehlt — ein kurzer Wackler bringt kein „Erledigt“ und gleich danach keinen neuen Alarm. Einfaches behebt er selbst (User-Entscheid 23.09.): Ein ABGESTÜRZTER Dienst
(ActiveState=failed) wird neu gestartet, höchstens AUTO_MAX_PRO_STUNDE-mal pro Stunde. Ein
bewusst gestoppter Dienst (inactive) bleibt aus und wird nur gemeldet. Rote Hinweise gehen
zusätzlich an Telegram und in Lucys Briefkasten.
@@ -67,6 +70,12 @@ STORE_PATH = Path(os.environ.get("MC_WAECHTER_STORE", str(DATEN_DIR / "mc2-waech
# „Ausblenden bis zum nächsten Lauf“ (24.09.2026): Hinweis-ID → Start des Laufs, der ausgeblendet ist.
# Schreibt nur MC2 (Knopf), der Steward liest. Ein neuer Lauf macht den Eintrag wirkungslos.
QUITTIERT_PATH = Path(os.environ.get("MC_WAECHTER_QUITTIERT", str(DATEN_DIR / "mc2-quittiert.json")))
# Zustand je Hinweis (seit 25.09.2026): gesehen / stumm bis … Schreibt nur MC2 (Knöpfe), der Steward liest.
ZUSTAND_PATH = Path(os.environ.get("MC_WAECHTER_ZUSTAND", str(DATEN_DIR / "mc2-hinweis-zustand.json")))
STUMM_S = int(os.environ.get("MC_WAECHTER_STUMM_S", str(24 * 3600)))
# So viele Takte am Stück muss ein Befund fehlen, bis sein Hinweis erledigt ist (Puffer gegen Flackern).
ERLEDIGT_NACH = int(os.environ.get("MC_WAECHTER_ERLEDIGT_NACH", "3"))
STUFE_RANG = {"gelb": 1, "rot": 2}
# Die andere Instanz darf einen Neustart lang schweigen (Sonntags-Update der Box, Container-Update
# auf dem Proxmox-PC), bevor daraus ein roter Hinweis wird.
PARTNER_TAKTE = int(os.environ.get("MC_WAECHTER_PARTNER_TAKTE", "5"))
@@ -624,6 +633,8 @@ PRUEFUNGEN += (__import__("services.homelab.messwerte").homelab.messwerte.pruefe
PRUEFUNGEN += (__import__("services.homelab.speicher").homelab.speicher.pruefe_speicher,) if ROLLE == "homelab" else ()
# Zertifikats-Wache (seit 25.09.2026): gelb, wenn die Erneuerung hängt; rot kurz vor Ablauf.
PRUEFUNGEN += (__import__("services.homelab.zertifikate").homelab.zertifikate.pruefe_zertifikate,) if ROLLE == "homelab" else ()
# Docker-Dienste unter Arcane (seit 25.09.2026): gelb, wenn einer ungesund ist oder ständig neu startet.
PRUEFUNGEN += (__import__("services.homelab.docker").homelab.docker.pruefe_docker,) if ROLLE == "homelab" else ()
BETREFF_PROBLEM, BETREFF_OK = {"box": ("[Box-Problem]", "[Box wieder ok]"),
"homelab": ("[Homelab-Problem]", "[Homelab wieder ok]")}[ROLLE]
@@ -653,10 +664,12 @@ def _speichere() -> None:
log.warning("waechter: Stand %s nicht schreibbar", STORE_PATH, exc_info=True)
def _verlauf(art: str, hinweis_id: str, text: str, stufe: str | None = None) -> None:
def _verlauf(art: str, hinweis_id: str, text: str, stufe: str | None = None, quelle: str | None = None) -> None:
eintrag = {"ts": time.time(), "art": art, "id": hinweis_id, "text": text}
if stufe: # seit 24.09.2026: das Protokoll des Homelab-Teils zeigt, wie ernst ein Hinweis war
eintrag["stufe"] = stufe
if quelle: # seit 25.09.2026: welches Gerät — für die Zeitleiste je Gerät
eintrag["quelle"] = quelle
_stand["verlauf"].append(eintrag)
del _stand["verlauf"][:-VERLAUF_MAX]
@@ -715,23 +728,27 @@ def takt() -> None:
hinweise: dict = _stand["hinweise"]
kandidaten: dict = _stand["kandidaten"]
aktuell = {b.id for b in befunde}
zustaende = hinweis_zustaende()
for b in befunde:
k = kandidaten.setdefault(b.id, {"takte": 0, "erstmals": jetzt})
k["takte"] += 1
if not update and b.auto:
_selbst_beheben(b, jetzt)
if not (b.sofort or k["takte"] >= (b.nach_takten or FAIL_AFTER)):
# Ein Hinweis, der noch im Puffer steht (Befund kurz weg und wieder da), läuft einfach weiter.
if not (b.sofort or k["takte"] >= (b.nach_takten or FAIL_AFTER) or b.id in hinweise):
continue
h = hinweise.get(b.id)
neu = h is None
h = h or {"id": b.id, "seit": k["erstmals"], "gemeldet": 0.0}
h.update(stufe=b.stufe, titel=b.titel, text=b.text, quelle=b.quelle,
aktionen=b.aktionen, zuletzt=jetzt, takte=k["takte"])
h.pop("fehlt", None)
hinweise[b.id] = h
if neu:
_verlauf("neu", b.id, b.titel, b.stufe)
if b.stufe == "rot" and (neu or jetzt - h.get("gemeldet", 0.0) >= REMIND_S):
_verlauf("neu", b.id, b.titel, b.stufe, b.quelle)
if b.stufe == "rot" and (neu or jetzt - h.get("gemeldet", 0.0) >= REMIND_S) \
and not ist_stumm(zustaende.get(b.id), b.stufe, jetzt):
vorsatz = "" if neu else "Immer noch: "
_telegram(BETREFF_PROBLEM, f"{vorsatz}{b.titel}. {b.text}")
h["gemeldet"] = jetzt
@@ -743,8 +760,12 @@ def takt() -> None:
continue
if update and hid.startswith(("dienst:", "kern:")):
continue
hinweise[hid]["fehlt"] = hinweise[hid].get("fehlt", 0) + 1
if hinweise[hid]["fehlt"] < ERLEDIGT_NACH:
continue
h = hinweise.pop(hid)
_verlauf("erledigt", hid, h.get("titel", hid), h.get("stufe"))
h.pop("fehlt", None)
_verlauf("erledigt", hid, h.get("titel", hid), h.get("stufe"), h.get("quelle"))
if h.get("stufe") == "rot" and h.get("gemeldet"):
_telegram(BETREFF_OK, f"Erledigt: {h.get('titel', hid)}.")
for kid in list(kandidaten):
@@ -770,6 +791,51 @@ async def waechter_loop() -> None:
await asyncio.sleep(INTERVAL)
# --- Zustand je Hinweis: gesehen, stumm (seit 25.09.2026) --------------------------------
def hinweis_zustaende() -> dict[str, dict]:
try:
daten = json.loads(ZUSTAND_PATH.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return daten if isinstance(daten, dict) else {}
def ist_stumm(eintrag: dict | None, stufe: str | None, jetzt: float) -> bool:
"""Stumm, solange die Frist läuft und der Hinweis sich nicht verschärft hat (gelb stumm geschaltet, jetzt rot:
nicht mehr stumm — Kritisches meldet sich trotzdem)."""
if not eintrag or float(eintrag.get("stumm_bis") or 0) <= jetzt:
return False
return STUFE_RANG.get(stufe or "", 0) <= STUFE_RANG.get(str(eintrag.get("stumm_stufe")), 0)
def hinweis_zustand_setzen(hinweis: dict, was: str, jetzt: float | None = None) -> dict:
"""Die Knöpfe „Gesehen“, „24 h stumm“, „Wieder melden“ (MC2-Prozess)."""
jetzt = time.time() if jetzt is None else jetzt
daten = hinweis_zustaende()
eintrag = dict(daten.get(hinweis["id"]) or {})
if was == "gesehen":
eintrag["gesehen"] = hinweis.get("seit")
text = "Als gesehen markiert."
elif was == "stumm":
eintrag.update(stumm_bis=jetzt + STUMM_S, stumm_stufe=hinweis.get("stufe"), gesehen=hinweis.get("seit"))
text = (f"Stumm für {STUMM_S // 3600} Stunden: keine Erinnerungen. Wird er schlimmer (rot), "
"meldet er sich trotzdem.")
else:
eintrag.pop("stumm_bis", None)
eintrag.pop("stumm_stufe", None)
text = "Meldet sich wieder."
eintrag["geaendert"] = jetzt
daten[hinweis["id"]] = eintrag
# Einträge ohne laufende Frist, die eine Woche nicht angefasst wurden, fallen weg.
daten = {k: v for k, v in daten.items()
if float(v.get("stumm_bis") or 0) > jetzt or jetzt - float(v.get("geaendert") or 0) < 7 * 86400}
tmp = ZUSTAND_PATH.with_suffix(".json.tmp")
tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8")
os.replace(tmp, ZUSTAND_PATH)
return {"ok": True, "text": text}
# --- Lesen und Knöpfe (MC2-Prozess) ------------------------------------------------
def lese_stand() -> dict:
@@ -778,10 +844,19 @@ def lese_stand() -> dict:
daten = json.loads(STORE_PATH.read_text(encoding="utf-8"))
except (OSError, ValueError):
daten = {}
hinweise = sorted((daten.get("hinweise") or {}).values(),
key=lambda h: (h.get("stufe") != "rot", h.get("seit", 0)))
jetzt = time.time()
zustaende = hinweis_zustaende()
hinweise = list((daten.get("hinweise") or {}).values())
for h in hinweise:
h.pop("gemeldet", None)
h.pop("fehlt", None)
z = zustaende.get(h.get("id")) or {}
if ist_stumm(z, h.get("stufe"), jetzt):
h["zustand"], h["stumm_bis"] = "stumm", z["stumm_bis"]
else:
h["zustand"] = "gesehen" if z.get("gesehen") == h.get("seit") else "neu"
# Rot vor gelb, stumm ganz unten, sonst nach Beginn.
hinweise.sort(key=lambda h: (h["zustand"] == "stumm", h.get("stufe") != "rot", h.get("seit", 0)))
return {
"hinweise": hinweise,
"verlauf": list(reversed((daten.get("verlauf") or [])[-50:])),
@@ -803,6 +878,8 @@ def fuehre_aktion_aus(hinweis_id: str, aktion_id: str) -> dict:
hinweis = next((h for h in stand["hinweise"] if h.get("id") == hinweis_id), None)
if hinweis is None:
return {"ok": False, "detail": "Diesen Hinweis gibt es nicht mehr."}
if aktion_id in ("gesehen", "stumm", "laut"): # gibt es bei jedem Hinweis (seit 25.09.2026)
return hinweis_zustand_setzen(hinweis, aktion_id)
aktion = next((a for a in hinweis.get("aktionen", []) if a.get("id") == aktion_id), None)
if aktion is None:
return {"ok": False, "detail": "Diese Aktion gehört nicht zu dem Hinweis."}
+95
View File
@@ -0,0 +1,95 @@
"""Befundliste mit Ablauf (Ausbauplan Welle 2, Punkt 10, 25.09.2026): Puffer gegen Flackern, Zustand je Hinweis
(neu, gesehen, stumm bis …) und dass Kritisches trotz „stumm“ durchkommt."""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from services import waechter
@pytest.fixture
def wache(tmp_path, monkeypatch):
monkeypatch.setattr(waechter, "STORE_PATH", tmp_path / "waechter.json")
monkeypatch.setattr(waechter, "ZUSTAND_PATH", tmp_path / "zustand.json")
monkeypatch.setattr(waechter, "_stand", {"hinweise": {}, "kandidaten": {}, "verlauf": [], "auto": {}, "stand": 0.0})
monkeypatch.setattr(waechter, "_update_laeuft", lambda: False)
monkeypatch.setattr(waechter, "FAIL_AFTER", 2)
meldungen: list[str] = []
monkeypatch.setattr(waechter, "_telegram", lambda betreff, text: meldungen.append(f"{betreff} {text}"))
befund = {"stufe": "rot", "da": True}
def pruefung():
if not befund["da"]:
return []
return [waechter.Befund(id="kern:engine", stufe=befund["stufe"], titel="Der Motor antwortet nicht", text="…",
quelle="engine")]
monkeypatch.setattr(waechter, "PRUEFUNGEN", (pruefung,))
return befund, meldungen
def test_kurzer_wackler_bringt_kein_erledigt_und_keinen_neuen_alarm(wache):
befund, meldungen = wache
waechter.takt()
waechter.takt()
seit = waechter._stand["hinweise"]["kern:engine"]["seit"]
assert len(meldungen) == 1
befund["da"] = False
waechter.takt() # einen Takt weg …
befund["da"] = True
waechter.takt() # … und wieder da: derselbe Hinweis, keine Meldung
assert waechter._stand["hinweise"]["kern:engine"]["seit"] == seit and len(meldungen) == 1
assert [v["art"] for v in waechter._stand["verlauf"]] == ["neu"]
def test_zustand_neu_gesehen_stumm(wache):
befund, meldungen = wache
befund["stufe"] = "gelb"
waechter.takt()
waechter.takt()
(h,) = waechter.lese_stand()["hinweise"]
assert h["zustand"] == "neu" and "fehlt" not in h
assert waechter.fuehre_aktion_aus("kern:engine", "gesehen") == {"ok": True, "text": "Als gesehen markiert."}
assert waechter.lese_stand()["hinweise"][0]["zustand"] == "gesehen"
antwort = waechter.fuehre_aktion_aus("kern:engine", "stumm")
assert antwort["ok"] and antwort["text"].startswith("Stumm für 24 Stunden")
h = waechter.lese_stand()["hinweise"][0]
assert h["zustand"] == "stumm" and h["stumm_bis"] > h["zuletzt"]
# Wird er rot, gilt stumm nicht mehr: Kritisches meldet sich trotzdem.
befund["stufe"] = "rot"
waechter.takt()
assert waechter.lese_stand()["hinweise"][0]["zustand"] == "gesehen"
assert len(meldungen) == 1 and "Der Motor antwortet nicht" in meldungen[0]
assert waechter.fuehre_aktion_aus("kern:engine", "laut")["text"] == "Meldet sich wieder."
def test_stumm_haelt_die_erinnerung_zurueck(wache, monkeypatch):
_, meldungen = wache
waechter.takt()
waechter.takt()
assert len(meldungen) == 1
waechter.fuehre_aktion_aus("kern:engine", "stumm")
monkeypatch.setattr(waechter, "REMIND_S", 0) # jede Erinnerung wäre fällig …
waechter.takt()
assert len(meldungen) == 1 # … aber stumm
waechter.fuehre_aktion_aus("kern:engine", "laut")
waechter.takt()
assert len(meldungen) == 2 and "Immer noch" in meldungen[1]
def test_stumme_stehen_unten_und_der_knopf_geht_auch_im_homelab(wache, monkeypatch):
from routers import homelab
befund, _ = wache
befund["stufe"] = "gelb"
waechter.takt()
waechter.takt()
waechter._stand["hinweise"]["zweiter"] = {"id": "zweiter", "stufe": "gelb", "titel": "B", "text": "", "quelle": "x",
"seit": 1.0, "zuletzt": 2.0, "aktionen": []}
waechter._speichere()
waechter.fuehre_aktion_aus("zweiter", "stumm")
assert [h["id"] for h in waechter.lese_stand()["hinweise"]] == ["kern:engine", "zweiter"]
app = FastAPI()
app.include_router(homelab.router)
client = TestClient(app)
assert client.post("/api/homelab/hinweise/kern:engine/aktion/stumm").json()["ok"] is True
assert client.post("/api/homelab/hinweise/weg/aktion/stumm").status_code == 409
+92
View File
@@ -0,0 +1,92 @@
"""Docker-Dienste unter Arcane einzeln (Ausbauplan Welle 2, Punkt 8, 25.09.2026). Die Container stammen aus Arcanes
Antwort vom 25.09. (sieben Container in drei Stacks; NerdQuiz und Rippy baut der User selbst)."""
import json
import pytest
from kern import einstellungen as einstellungen_mod
from services.homelab import arcane, docker, einstellungen, entdecken
JETZT = 1_790_340_000.0
def _c(name: str, stack: str, image: str, status: str = "Up 3 hours", state: str = "running", **info) -> dict:
return {"id": f"{name}0123456789ab", "names": [f"/{name}"], "image": image, "state": state, "status": status,
"labels": {"com.docker.compose.project": stack, "com.docker.compose.service": name.split("-")[1]
if "-" in name else name}, "updateInfo": info}
ARCANE = [
_c("arcane", "arcane", "ghcr.io/getarcaneapp/manager:latest", updateType="digest", currentVersion="latest"),
_c("nerdquiz", "deploy", "nerdquiz:latest", "Up 3 hours (healthy)", currentVersion="1.18.2",
error="distribution inspect of docker.io/library/nerdquiz:1.18.2 failed: Error response from daemon"),
_c("rippy-api-1", "rippy", "rippy-api", "Up 3 hours (healthy)", updateType="local"),
_c("rippy-postgres-1", "rippy", "postgres:16-alpine", "Up 3 hours (healthy)", updateType="digest", hasUpdate=True,
currentVersion="16-alpine"),
]
def test_container_zeile():
zeilen = {z["name"]: z for z in map(docker.container_zeile, ARCANE)}
assert zeilen["arcane"]["update"] == "aktuell" and zeilen["arcane"]["gesund"] is None
assert zeilen["nerdquiz"]["update"] == "lokal" and zeilen["nerdquiz"]["gesund"] is True # kein Fehler, lokal gebaut
assert zeilen["rippy-api-1"]["update"] == "lokal" and zeilen["rippy-api-1"]["stack"] == "rippy"
assert zeilen["rippy-postgres-1"]["update"] == "neu" and zeilen["rippy-postgres-1"]["dienst"] == "postgres"
krank = docker.container_zeile(_c("x", "s", "i", "Up 2 minutes (unhealthy)", error="timeout"))
assert (krank["gesund"], krank["update"]) == (False, "unbekannt")
def test_lage_je_stack(monkeypatch):
monkeypatch.setattr(einstellungen, "arcane_url", lambda: None)
assert docker.lage() == {"verfuegbar": False, "grund": "Arcane steht nicht im Bericht des Ausführers.", "stacks": []}
monkeypatch.setattr(einstellungen, "arcane_url", lambda: "http://192.168.178.28:3552")
monkeypatch.setattr(arcane, "schluessel", lambda: "")
assert docker.lage()["grund"].startswith("Es fehlt der Arcane-API-Schlüssel")
monkeypatch.setattr(arcane, "schluessel", lambda: "schluessel")
monkeypatch.setattr(arcane, "container_liste", lambda url: None)
assert docker.lage()["grund"] == "Arcane antwortet gerade nicht."
monkeypatch.setattr(arcane, "container_liste", lambda url: ARCANE)
lage = docker.lage()
assert [(s["name"], [c["name"] for c in s["container"]]) for s in lage["stacks"]] == [
("arcane", ["arcane"]), ("deploy", ["nerdquiz"]), ("rippy", ["rippy-api-1", "rippy-postgres-1"])]
def test_waechter_meldet_ungesunde_und_neustartende(monkeypatch):
monkeypatch.setattr(einstellungen, "arcane_url", lambda: "http://arcane")
monkeypatch.setattr(arcane, "schluessel", lambda: "schluessel")
monkeypatch.setattr(docker, "arcane_ziel", lambda: "vm-106")
krank = [*ARCANE, _c("rippy-worker-1", "rippy", "rippy-worker", "Up 1 minute (unhealthy)"),
_c("einmal", "jobs", "busybox", "Exited (0) 2 hours ago", state="exited"),
_c("hakt", "jobs", "busybox", "Restarting (1) 5 seconds ago", state="restarting")]
monkeypatch.setattr(arcane, "container_liste", lambda url: krank)
befunde = {b.id: b for b in docker.pruefe_docker()}
assert set(befunde) == {"docker:rippy-worker-1", "docker:hakt"} # beendet ist kein Befund
assert befunde["docker:rippy-worker-1"].titel == "Docker-Dienst rippy-worker-1 ist ungesund"
assert befunde["docker:hakt"].quelle == "vm-106" and befunde["docker:hakt"].stufe == "gelb"
@pytest.fixture
def daten(tmp_path, monkeypatch):
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
einstellungen_mod.einstellungen.cache_clear()
yield tmp_path
einstellungen_mod.einstellungen.cache_clear()
def test_neue_stacks(daten, monkeypatch):
meldungen: list[str] = []
stacks = [{"name": "arcane", "container": [{"name": "arcane"}]}, {"name": "rippy", "container": [{"name": "rippy-api-1"}]}]
assert entdecken.docker_gesehen(stacks, JETZT, meldungen.append) == [] # erster Abgleich: alles bekannt
neu = [*stacks, {"name": "immich", "container": [{"name": "immich-server"}, {"name": "immich-ml"}]}]
assert entdecken.docker_gesehen(neu, JETZT + 600, meldungen.append) == ["docker:immich"]
assert meldungen == [("Neu unter Arcane: der Docker-Stack „immich“ (immich-server, immich-ml). Im Homelab-Cockpit "
"unter „Neu entdeckt“ übernehmen.")]
monkeypatch.setattr(docker, "lage", lambda: {"verfuegbar": True, "grund": None, "stacks": [
{"name": s["name"], "container": [{"name": c["name"]} for c in s["container"]]} for s in neu]})
(stack,) = entdecken.neue_docker()
assert (stack["id"], stack["container"]) == ("docker:immich", ["immich-server", "immich-ml"])
assert entdecken.uebernehmen("docker:immich", "freigeben")["ok"] is False # Etiketten gibt es dort nicht
assert entdecken.uebernehmen("docker:immich", "beobachten") == {"ok": True, "text": "Der Stack immich ist übernommen."}
assert entdecken.neue_docker() == []
register = json.loads((daten / "docker-bekannt.json").read_text(encoding="utf-8"))
assert register["docker:immich"]["zustand"] == "bekannt"
+207
View File
@@ -0,0 +1,207 @@
"""„Neu entdeckt“, der selbst gepflegte App-Katalog und das Etikett per Klick (Ausbauplan Welle 2, Punkt 7, 25.09.2026).
Die Skript-Auszüge stammen aus community-scripts/ProxmoxVE (ct/adguard.sh, gelesen am 25.09.)."""
import importlib.util
import json
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from kern import einstellungen as einstellungen_mod
from services.homelab import apps, entdecken, kanal, katalog
AUSFUEHRER = Path(__file__).resolve().parents[2] / "deploy" / "homelab" / "ausfuehrer.py"
JETZT = 1_790_340_000.0
ADGUARD_SH = """#!/usr/bin/env bash
# Copyright (c) 2021-2026 tteck
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# Source: https://adguard.com/ | Github: https://github.com/AdguardTeam/AdGuardHome
APP="Adguard"
var_tags="${var_tags:-adblock}"
function update_script() {
header_info
if [[ ! -d /opt/AdGuardHome ]]; then
msg_error "No ${APP} Installation Found!"
exit
fi
msg_error "Adguard Home can only be updated via the user interface."
exit
}
start
build_container
description
msg_ok "Completed Successfully!\\n"
echo -e "${INFO}${YW} Access it using the following URL:${CL}"
echo -e "${GATEWAY}${BGN}http://${IP}:3000${CL}"
"""
JELLYFIN_SH = """# Source: https://jellyfin.org/ | Github: https://github.com/jellyfin/jellyfin
APP="Jellyfin"
function update_script() {
msg_info "Updating ${APP} LXC"
$STD apt-get update
$STD apt-get -y upgrade
}
echo -e "${GATEWAY}${BGN}http://${IP}:8096${CL}"
"""
@pytest.fixture
def daten(tmp_path, monkeypatch):
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
einstellungen_mod.einstellungen.cache_clear()
katalog._stand.update(mtime=None, daten={})
yield tmp_path
einstellungen_mod.einstellungen.cache_clear()
katalog._stand.update(mtime=None, daten={})
class _Antwort:
def __init__(self, text: str, status: int = 200):
self.text, self.status_code = text, status
def raise_for_status(self):
if self.status_code >= 400:
raise RuntimeError(f"HTTP {self.status_code}")
# --- Katalog -------------------------------------------------------------------------------------------------------
def test_katalog_liest_das_update_skript():
assert katalog.aus_skript("adguard", ADGUARD_SH) == {
"name": "Adguard", "quelle": "AdguardTeam/AdGuardHome", "port": 3000, "https": False, "pfad": "/",
"weg": "eigene-oberflaeche"}
jf = katalog.aus_skript("jellyfin", JELLYFIN_SH)
assert (jf["quelle"], jf["port"], jf["weg"]) == ("jellyfin/jellyfin", 8096, "skript")
leer = katalog.aus_skript("etwas", "#!/bin/bash\n")
assert leer == {"name": "etwas", "quelle": None, "port": None, "https": False, "pfad": "/", "weg": "skript"}
def test_katalog_prueft_den_port_und_merkt_sich_alles(daten, monkeypatch):
geholt: list[str] = []
monkeypatch.setattr(katalog, "_antwortet", lambda ip, port: False) # der User hat den Port geändert
e = katalog.eintragen("jellyfin", "192.168.178.170", holen=lambda k: geholt.append(k) or _Antwort(JELLYFIN_SH),
jetzt=JETZT)
assert geholt == ["jellyfin"] and (e["port"], e["port_skript"]) == (None, 8096)
app = apps.app_fuer("jellyfin", "jellyfin")
assert (app.name, app.quelle, app.port, app.weg) == ("Jellyfin", "jellyfin/jellyfin", None, "skript")
assert apps.adresse(app, "192.168.178.170") is None # keine Webprüfung auf Verdacht
# Was von Hand im Katalog steht, geht vor (AdGuard: Port 8080 statt der 3000 aus dem Skript).
assert apps.app_fuer("adguard", "adguard").port == 8080
katalog.eintragen("kaputt", None, holen=lambda k: _Antwort("", 404), jetzt=JETZT)
assert katalog.app("kaputt") is None
bericht = {"gaeste": [{"vmid": 1, "app": {"kennung": "jellyfin"}}, {"vmid": 2, "app": {"kennung": "kaputt"}},
{"vmid": 3, "app": {"kennung": "gitea"}}, {"vmid": 4, "app": {"kennung": "neu-da"}, "ip": "x"}]}
assert katalog.fehlende(bericht, JETZT + 3600) == [("neu-da", "x")]
assert katalog.fehlende(bericht, JETZT + 2 * 86400) == [("kaputt", None), ("neu-da", "x")] # Fehler nach 1 Tag
assert katalog.fehlende(bericht, JETZT + 8 * 86400)[0] == ("jellyfin", None)
# --- Register „Neu entdeckt“ -----------------------------------------------------------------------------------------
def _gast(vmid: int, name: str, **mehr) -> dict:
return {"vmid": vmid, "art": "lxc", "name": name, "status": "running", "etiketten": [], "erlaubt": False, **mehr}
def test_neue_geraete_werden_einmal_gemeldet(daten):
meldungen: list[str] = []
basis = [_gast(100, "adguard"), _gast(107, "homelab-orchestrator", etiketten=["mc2"])]
assert entdecken.bericht_gesehen({"gaeste": basis}, JETZT, meldungen.append) == [] # erster Bericht: alles bekannt
neu = [*basis, _gast(108, "jellyfin", app={"kennung": "jellyfin"}, etiketten=["community-script"], erlaubt=True)]
assert entdecken.bericht_gesehen({"gaeste": neu}, JETZT + 600, meldungen.append) == ["ct-108"]
assert entdecken.bericht_gesehen({"gaeste": neu}, JETZT + 1200, meldungen.append) == []
assert len(meldungen) == 1 and meldungen[0].startswith("Neu im Homelab: Container 108 „jellyfin“")
# Dieselbe Nummer mit anderem Namen ist ein neues Gerät; ein verschwundenes fällt aus dem Register.
anders = [_gast(100, "adguard"), _gast(108, "immich")]
assert entdecken.bericht_gesehen({"gaeste": anders}, JETZT + 1800, meldungen.append) == ["ct-108"]
register = json.loads((daten / "geraete-bekannt.json").read_text(encoding="utf-8"))
assert set(register) == {"ct-100", "ct-108"} and register["ct-108"]["name"] == "immich"
assert entdecken.bericht_gesehen({"gaeste": []}, JETZT + 2400, meldungen.append) == [] # nie leer räumen
assert set(json.loads((daten / "geraete-bekannt.json").read_text(encoding="utf-8"))) == {"ct-100", "ct-108"}
def test_uebernehmen(daten, monkeypatch):
from routers import homelab
entdecken.bericht_gesehen({"gaeste": [_gast(100, "adguard")]}, JETZT, lambda t: None)
bericht = {"host": {"nicht_gesichert": [108]}, "gaeste": [
_gast(100, "adguard"), _gast(108, "jellyfin", ip="192.168.178.170", app={"kennung": "jellyfin"}),
{"vmid": 109, "art": "qemu", "name": "win11", "status": "stopped", "etiketten": [], "erlaubt": False}]}
entdecken.bericht_gesehen(bericht, JETZT + 60, lambda t: None)
kanal.bericht_speichern(bericht)
app = FastAPI()
app.include_router(homelab.router)
client = TestClient(app)
geraete = {g["id"]: g for g in client.get("/api/homelab/neu").json()["geraete"]}
assert set(geraete) == {"ct-108", "vm-109"}
assert geraete["ct-108"]["ohne_sicherung"] is True and geraete["vm-109"]["art"] == "vm"
assert client.post("/api/homelab/neu/vm-109", json={"wahl": "beobachten"}).json()["ok"] is True
assert kanal.liste() == [] # kein Etikett, kein Auftrag
antworten = {"status": "fertig", "code": 0, "text": "etiketten=watcher"}
monkeypatch.setattr(kanal, "warten", lambda auftrag_id, zeitlimit_s, takt_s=2.0: antworten)
gut = client.post("/api/homelab/neu/ct-108", json={"wahl": "freigeben"}).json()
assert gut["ok"] is True and "für Updates freigegeben" in gut["text"]
(auftrag,) = kanal.liste()
assert (auftrag["aktion"], auftrag["parameter"]) == ("etikett_setzen", {"vmid": 108, "etikett": "watcher", "an": True})
assert client.get("/api/homelab/neu").json()["geraete"] == []
weg = client.post("/api/homelab/neu/ct-108", json={"wahl": "freigeben"})
assert weg.status_code == 409 and "nicht (mehr)" in weg.json()["detail"]
assert client.post("/api/homelab/neu/ct-100", json={"wahl": "irgendwas"}).status_code == 409
def test_etikett_scheitert_und_das_geraet_bleibt_neu(daten, monkeypatch):
entdecken.bericht_gesehen({"gaeste": [_gast(100, "adguard")]}, JETZT, lambda t: None)
bericht = {"gaeste": [_gast(100, "adguard"), _gast(108, "jellyfin")]}
entdecken.bericht_gesehen(bericht, JETZT + 60, lambda t: None)
kanal.bericht_speichern(bericht)
monkeypatch.setattr(kanal, "warten", lambda auftrag_id, zeitlimit_s, takt_s=2.0: None)
antwort = entdecken.uebernehmen("ct-108", "ausschliessen")
assert antwort == {"ok": False, "detail": "Das Etikett ließ sich nicht setzen: Der Ausführer hat nicht rechtzeitig "
"geantwortet."}
assert [g["id"] for g in entdecken.neue()] == ["ct-108"]
# --- Ausführer: etikett_setzen ---------------------------------------------------------------------------------------
@pytest.fixture(scope="module")
def a():
spec = importlib.util.spec_from_file_location("ausfuehrer_etikett", AUSFUEHRER)
modul = importlib.util.module_from_spec(spec)
spec.loader.exec_module(modul)
return modul
def test_etiketten_regeln(a):
assert a.etiketten_neu(["community-script", "adblock"], "watcher", True) == ["community-script", "adblock", "watcher"]
assert a.etiketten_neu(["watcher", "adblock"], "watcher-aus", True) == ["adblock", "watcher-aus"]
assert a.etiketten_neu(["watcher-aus", "adblock"], "watcher-aus", False) == ["adblock"]
with pytest.raises(ValueError):
a.etiketten_neu([], "community-script", True) # nur watcher und watcher-aus
def test_etikett_setzen_auch_ohne_freigabe(a, monkeypatch):
befehle: list[list[str]] = []
konfig = {"tags": "adblock;community-script"}
def pvesh(pfad, *parameter):
if pfad == "/cluster/resources":
return [{"vmid": 108, "type": "lxc", "node": "pve", "tags": konfig["tags"]}]
return dict(konfig)
monkeypatch.setattr(a, "NODE", "pve")
monkeypatch.setattr(a, "_pvesh", pvesh)
monkeypatch.setattr(a, "_laufen", lambda befehl, zeitlimit=60, sanft=False: befehle.append(befehl) or (0, ""))
assert a.ausfuehren({"aktion": "etikett_setzen", "parameter": {"vmid": 108, "etikett": "watcher", "an": True}})["code"] == 0
assert befehle == [["pvesh", "set", "/nodes/pve/lxc/108/config", "--tags", "adblock;community-script;watcher"]]
konfig["tags"] = "watcher"
a.ausfuehren({"aktion": "etikett_setzen", "parameter": {"vmid": 108, "etikett": "watcher", "an": False}})
assert befehle[-1] == ["pvesh", "set", "/nodes/pve/lxc/108/config", "--delete", "tags"]
schon = a.ausfuehren({"aktion": "etikett_setzen", "parameter": {"vmid": 108, "etikett": "watcher", "an": True}})
assert schon["code"] == 0 and "Schon so." in schon["text"] and len(befehle) == 2
fremd = a.ausfuehren({"aktion": "etikett_setzen", "parameter": {"vmid": 108, "etikett": "prod", "an": True}})
assert fremd["code"] == 2 and "darf der Orchestrator nicht setzen" in fremd["text"]
assert a.ausfuehren({"aktion": "etikett_setzen", "parameter": {"vmid": 999, "etikett": "watcher"}})["code"] == 2
monkeypatch.setattr(a, "NUR_LESEN", True)
assert a.ausfuehren({"aktion": "etikett_setzen", "parameter": {"vmid": 108, "etikett": "watcher"}})["code"] == 2
+18 -2
View File
@@ -168,8 +168,8 @@ def test_alle_quellen_mit_sprechenden_titeln(geschichte):
assert lang.startswith("…\nZeile ") and len(lang) <= protokoll.DETAILS_MAX and lang.endswith("Zeile 199: " + "x" * 90)
# Update-Läufe mit ihren Schritten
assert e["Update eingespielt · Gitea 1.27.3"]["stufe"] == "ok"
assert e["Update eingespielt · Gitea 1.27.3"]["details"] == "snapshot: ok\nupdate: ok"
assert e["Update eingespielt · Gitea 1.27.2 → 1.27.3"]["stufe"] == "ok"
assert e["Update eingespielt · Gitea 1.27.2 → 1.27.3"]["details"] == "snapshot: ok\nupdate: ok"
assert e["Paket-Update zurückgerollt · Proxmox Backup Server"]["stufe"] == "warn"
assert e["Update nicht begonnen · NetBird"]["stufe"] == "warn"
host_lauf = e["5 Host-Pakete eingespielt · Proxmox-Host"]
@@ -333,3 +333,19 @@ def test_pflege_merkt_sich_anstoss_fehlschlag_und_erholung(daten, monkeypatch):
assert titel == Counter({"Wöchentliche Suche angestoßen · AdGuard Home": 2,
"Wöchentliche Suche gescheitert · AdGuard Home": 1,
"Wöchentliche Suche klappt wieder · AdGuard Home": 1})
def test_zeitleiste_je_geraet(geschichte):
"""Seit 25.09.2026: nur die Einträge eines Geräts — Updates von → nach, Aufträge, Hinweise."""
gitea = protokoll.eintraege(ziel="ct-104")
assert gitea and all(e["ziel"] == "ct-104" for e in gitea)
assert "Update eingespielt · Gitea 1.27.2 → 1.27.3" in {e["titel"] for e in gitea}
assert protokoll.eintraege(ziel="vm-999") == []
def test_hinweise_finden_ihr_geraet_ueber_die_quelle():
assert protokoll._ziel_aus("zertifikat:npm", "ct-101") == "ct-101"
assert protokoll._ziel_aus("docker:rippy-api-1", "vm-106") == "vm-106"
assert protokoll._ziel_aus("gast-platte:106", None) == "ct-106" # alte Einträge ohne Quelle
assert protokoll._ziel_aus("sicherung:probe", "pve") == "pve"
assert protokoll._ziel_aus("platte", "platte") is None
+3 -1
View File
@@ -38,7 +38,9 @@ def test_zahlen_je_geraet(monkeypatch):
monkeypatch.setattr(zertifikate, "lage", lambda jetzt=None: {"zertifikate": [npm] * 10})
monkeypatch.setattr(arcane, "container_zahlen", lambda url: {"laufend": 11, "gesamt": 12})
monkeypatch.setattr(adguard, "geblockt", lambda: {"anfragen": 84_211, "geblockt": 10_523, "anteil": 12.5})
zahlen = kennzahlen.fuer_bericht(_bericht())
b = _bericht()
b["gaeste"].append({"vmid": 107, "art": "lxc", "status": "running", "etiketten": ["mc2"]}) # der eigene zählt nicht
zahlen = kennzahlen.fuer_bericht(b)
laufend = sum(1 for g in BERICHT["gaeste"] if g["status"] == "running")
assert zahlen["pve"] == {"text": f"{laufend} von {len(BERICHT['gaeste'])} Gästen an"}
assert zahlen["ct-105"] == {"text": "letzte Sicherung", "zeit": 1790296281}
+157
View File
@@ -0,0 +1,157 @@
"""„Was ist neu?“ vor dem Knopf (Ausbauplan Welle 2, Punkt 9, 25.09.2026): Release Notes zwischen installiert und neu,
Debian-Änderungslisten des Proxmox-Hosts über den Ausführer und Lucys Kurzfassung. Die Änderungsliste ist ein Auszug
von pvesh get /nodes/pve/apt/changelog --name pve-manager vom 25.09."""
import importlib.util
import json
from pathlib import Path
import pytest
from kern import einstellungen as einstellungen_mod
from services.homelab import inventar, kanal, neuerungen
AUSFUEHRER = Path(__file__).resolve().parents[2] / "deploy" / "homelab" / "ausfuehrer.py"
RELEASES = [
{"tag_name": "v1.27.0-rc1", "prerelease": True, "body": "Vorab"},
{"tag_name": "v1.26.1", "name": "Gitea 1.26.1", "published_at": "2026-09-20T10:00:00Z", "html_url": "u1",
"body": "* Security: fix XSS in markdown renderer"},
{"tag_name": "v1.26.0", "name": "Gitea 1.26.0", "published_at": "2026-09-01T10:00:00Z", "html_url": "u0",
"body": "BREAKING: `[server] LFS_JWT_SECRET` moved to `[lfs] JWT_SECRET`"},
{"tag_name": "v1.25.4", "body": "alt"},
{"tag_name": "v1.26.2", "draft": True, "body": "Entwurf"},
]
CHANGELOG = """pve-manager (9.2.20) trixie; urgency=medium
* Revert "partially fix #1989: ui: qemu: disk: add qcow2 cache size config"
-- Proxmox Support Team <support@proxmox.com> Fri, 11 Sep 2026 14:10:20 +0200
pve-manager (9.2.19) trixie; urgency=medium
* pvesh: fix startup failure caused by API endpoints with empty parameter schemas
-- Proxmox Support Team <support@proxmox.com> Tue, 01 Sep 2026 10:00:00 +0200
pve-manager (9.2.18) trixie; urgency=medium
* älter
"""
def test_releases_zwischen_installiert_und_neu():
versionen = neuerungen.zwischen(RELEASES, "1.25.4", "1.26.1")
assert [v["version"] for v in versionen] == ["v1.26.1", "v1.26.0"] # ohne Vorab und Entwurf
assert versionen[1]["text"].startswith("BREAKING") and versionen[0]["url"] == "u1"
assert neuerungen.zwischen(RELEASES, "Image vom 2026-08-27", "") == [] # Datum statt Version
assert neuerungen.zwischen("kaputt", "1.0", "2.0") == []
def test_wichtige_pakete_zuerst():
updates = [{"paket": n} for n in ("libc6", "pve-manager", "zlib1g", "proxmox-kernel-7.0", "qemu-server", "curl")]
assert neuerungen.wichtige_pakete(updates)[:3] == ["proxmox-kernel-7.0", "pve-manager", "qemu-server"]
def test_lucys_antwort_auswerten():
a = neuerungen.auswerten("AKTION: JA — In app.ini [lfs] JWT_SECRET setzen\n- ⚠️ LFS-Schlüssel umgezogen\n"
"- Sicherheitskorrektur im Markdown\n\n* noch was")
assert a == {"aktion": True, "aktion_text": "In app.ini [lfs] JWT_SECRET setzen",
"punkte": ["⚠️ LFS-Schlüssel umgezogen", "Sicherheitskorrektur im Markdown", "noch was"]}
assert neuerungen.auswerten("\nAKTION: NEIN\n- nur Fehlerkorrekturen")["aktion"] is False
@pytest.fixture
def daten(tmp_path, monkeypatch):
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
monkeypatch.setenv("MC_PARTNER_URL", "http://box:9001")
einstellungen_mod.einstellungen.cache_clear()
yield tmp_path
einstellungen_mod.einstellungen.cache_clear()
class _Antwort:
def __init__(self, daten: dict):
self._daten = daten
def raise_for_status(self):
pass
def json(self):
return self._daten
def test_kurzfassung_von_der_box(daten, monkeypatch):
gesendet: list[dict] = []
monkeypatch.setattr(neuerungen.httpx, "post", lambda url, timeout, json: gesendet.append({"url": url, **json}) or _Antwort(
{"choices": [{"message": {"content": "AKTION: NEIN\n- Fehler behoben"}, "finish_reason": "stop"}]}))
z = neuerungen.zusammenfassen("Gitea", "1.25.4", "1.26.1", "## v1.26.1\n* fix")
assert z == {"aktion": False, "aktion_text": "", "punkte": ["Fehler behoben"]}
assert gesendet[0]["url"] == "http://box:9001/v1/chat/completions" and gesendet[0]["model"] == "fast"
assert "Gitea (1.25.4 → 1.26.1)" in gesendet[0]["messages"][0]["content"]
monkeypatch.delenv("MC_PARTNER_URL")
einstellungen_mod.einstellungen.cache_clear()
assert neuerungen.zusammenfassen("Gitea", "1", "2", "x") is None # ohne Box keine Kurzfassung
def test_fuer_eine_app_und_gemerkt(daten, monkeypatch):
ziel = {"id": "ct-104", "bausteine": [{"id": "app", "zustand": "neu", "installiert": "1.25.4", "verfuegbar": "1.26.1"}]}
monkeypatch.setattr(inventar, "ziele", lambda: {"ziele": [ziel]})
monkeypatch.setattr(inventar, "gast", lambda zid: {"vmid": 104, "name": "gitea", "app": {"kennung": "gitea"}})
abrufe: list[str] = []
monkeypatch.setattr(neuerungen, "github_json", lambda pfad: abrufe.append(pfad) or RELEASES)
monkeypatch.setattr(neuerungen, "zusammenfassen", lambda was, von, nach, quellen: {
"aktion": True, "aktion_text": "JWT_SECRET umziehen", "punkte": [quellen.count("## ")]})
e = neuerungen.fuer("ct-104", "app")
assert e["verfuegbar"] and [v["version"] for v in e["versionen"]] == ["v1.26.1", "v1.26.0"]
assert e["zusammenfassung"]["punkte"] == [2] and e["quelle_url"] == "https://github.com/go-gitea/gitea/releases"
assert abrufe == ["repos/go-gitea/gitea/releases?per_page=40"]
assert neuerungen.fuer("ct-104", "app") == e and len(abrufe) == 1 # gemerkt
assert neuerungen.fuer("ct-104", "os")["verfuegbar"] is False
def test_fuer_den_proxmox_host(daten, monkeypatch):
ziel = {"id": "pve", "bausteine": [{"id": "pakete", "zustand": "neu", "installiert": "9.2.19", "kurz": "3 Pakete"}]}
monkeypatch.setattr(inventar, "ziele", lambda: {"ziele": [ziel]})
monkeypatch.setattr(kanal, "bericht", lambda: {"bericht": {"host": {"updates": [
{"paket": "pve-manager"}, {"paket": "curl"}, {"paket": "libc6"}]}}})
angefragt: list[list[str]] = []
monkeypatch.setattr(neuerungen, "_changelogs", lambda pakete: angefragt.append(pakete) or {"pve-manager": "* Revert"})
monkeypatch.setattr(neuerungen, "zusammenfassen", lambda was, von, nach, quellen: {"aktion": False, "aktion_text": "",
"punkte": [was]})
e = neuerungen.fuer("pve", "pakete")
assert angefragt == [["pve-manager", "curl", "libc6"]]
assert e["pakete"] == [{"name": "pve-manager", "text": "* Revert"}] and e["weitere"] == 2
assert e["zusammenfassung"]["punkte"] == ["Proxmox VE (3 Pakete)"]
# --- Ausführer: changelog -------------------------------------------------------------------------------------------
@pytest.fixture(scope="module")
def a():
spec = importlib.util.spec_from_file_location("ausfuehrer_changelog", AUSFUEHRER)
modul = importlib.util.module_from_spec(spec)
spec.loader.exec_module(modul)
return modul
def test_changelog_bis_zur_installierten_version(a):
neu = a.changelog_neu(CHANGELOG, "9.2.19")
assert neu.startswith("pve-manager (9.2.20)") and "9.2.19" not in neu
assert a.changelog_neu(CHANGELOG, None) == CHANGELOG.strip()
assert a.changelog_neu(CHANGELOG, "1.0") == CHANGELOG.strip() # unbekannt: alles
def test_changelog_nur_fuer_anstehende_pakete_und_nur_lesend(a, monkeypatch):
def pvesh(pfad, *parameter):
if pfad.endswith("/apt/update"):
return [{"Package": "pve-manager", "OldVersion": "9.2.19"}]
return CHANGELOG
monkeypatch.setattr(a, "NODE", "pve")
monkeypatch.setattr(a, "_pvesh", pvesh)
monkeypatch.setattr(a, "NUR_LESEN", True)
antwort = a.ausfuehren({"aktion": "changelog", "parameter": {"pakete": ["pve-manager", "curl"]}})
assert antwort["code"] == 0
assert list(json.loads(antwort["text"])) == ["pve-manager"] # curl steht nicht an
kaputt = a.ausfuehren({"aktion": "changelog", "parameter": {"pakete": ["pve; rm -rf /"]}})
assert kaputt["code"] == 2 and "ungültige Paketliste" in kaputt["text"]
+2 -1
View File
@@ -156,6 +156,7 @@ def test_waechter_meldet_den_partner_erst_nach_geduld(monkeypatch, tmp_path):
assert len(meldungen) == 1 and "Proxmox-PC (http://pve:9001) ist nicht erreichbar" in meldungen[0]
erreichbar[0] = True
for _ in range(waechter.ERLEDIGT_NACH): # Puffer gegen Flackern (seit 25.09.2026)
waechter.takt()
assert waechter._stand["hinweise"] == {}
assert meldungen[-1] == "Erledigt: Homelab antwortet nicht."
@@ -221,7 +222,7 @@ print(json.dumps({"pruefungen": [p.__name__ for p in waechter.PRUEFUNGEN], "date
""")
assert ergebnis["pruefungen"] == ["pruefe_platte", "pruefe_partner", "pruefe_ausfuehrer", "pruefe_gaeste",
"pruefe_gast_platten", "pruefe_paketlisten", "pruefe_host", "pruefe_speicher",
"pruefe_zertifikate"]
"pruefe_zertifikate", "pruefe_docker"]
def test_homelab_rolle_hat_die_homelab_schnittstellen(tmp_path):
+127
View File
@@ -0,0 +1,127 @@
"""Sicherheits-Markierung (Ausbauplan Welle 2, Punkt 11, 25.09.2026): Debian-Suiten „-security“ (Ausführer), Alpine
über die Sicherheitsdatenbank (NPMplus, Alpine 3.24), Lücken-Scans aus Arcane je Container."""
import importlib.util
import json
import time
from pathlib import Path
import pytest
from kern import einstellungen as einstellungen_mod
from services.homelab import arcane, docker, einstellungen, sicherheit
AUSFUEHRER = Path(__file__).resolve().parents[2] / "deploy" / "homelab" / "ausfuehrer.py"
@pytest.fixture(scope="module")
def a():
spec = importlib.util.spec_from_file_location("ausfuehrer_sicherheit", AUSFUEHRER)
modul = importlib.util.module_from_spec(spec)
spec.loader.exec_module(modul)
return modul
def test_alpine_versionen_vergleichen():
t = sicherheit.apk_teile
assert t("1.37.0-r18") < t("1.37.0-r19") < t("1.37.1-r0")
assert t("3.3.2_rc1-r0") < t("3.3.2-r0") < t("3.3.2_p1-r0")
assert t("1.2-r0") < t("1.2a-r0") and t("2.042-r0") > t("2.41-r9")
def test_alpine_sicherheitsupdates_zaehlen():
fixes = {"busybox": ["1.37.0-r19", "1.36.1-r2"], "py3-requests": ["2.32.0-r0"]}
stand = {"alpine": "3.24.2", "pakete": [
{"name": "busybox", "alt": "1.37.0-r18", "neu": "1.37.0-r19"}, # schließt eine Lücke
{"name": "py3-requests", "alt": "2.32.3-r0", "neu": "2.32.4-r0"}, # Fix war schon älter
{"name": "curl", "alt": "8.14.1-r0", "neu": "8.14.1-r1"}]} # ohne Eintrag
assert sicherheit.alpine_sicherheit(stand, fixes) == 1
assert sicherheit.alpine_sicherheit({"pakete": []}) == 0
assert sicherheit.alpine_sicherheit({"anzahl": 3}) is None # Debian-Stand: nicht hier
@pytest.fixture
def daten(tmp_path, monkeypatch):
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
einstellungen_mod.einstellungen.cache_clear()
sicherheit._gemerkt.clear()
yield tmp_path
einstellungen_mod.einstellungen.cache_clear()
sicherheit._gemerkt.clear()
def test_datenbank_laedt_im_hintergrund_und_blockiert_nie(daten, monkeypatch):
geladen: list[str] = []
monkeypatch.setattr(sicherheit, "_laden", lambda zweig: geladen.append(zweig) or sicherheit._laedt.discard(zweig))
monkeypatch.setattr(sicherheit.threading, "Thread", lambda target, args, name, daemon: type(
"T", (), {"start": lambda self: target(*args)})())
assert sicherheit.secdb("3.24") is None and geladen == ["3.24"] # fehlt: nachladen, bis dahin None
(daten / "alpine-secdb-v3.24.json").write_text(json.dumps({"geholt": time.time(), "fixes": {"busybox": ["1-r1"]}}),
encoding="utf-8")
assert sicherheit.secdb("3.24") == {"busybox": ["1-r1"]} and geladen == ["3.24"]
alt = time.time() - sicherheit.NEU_LADEN_S - 60
(daten / "alpine-secdb-v3.24.json").write_text(json.dumps({"geholt": alt, "fixes": {"x": []}}), encoding="utf-8")
assert sicherheit.secdb("3.24") == {"x": []} and geladen == ["3.24", "3.24"] # alt: liefern und nachladen
# --- Ausführer ---------------------------------------------------------------------------------------------------
def test_ausfuehrer_zaehlt_sicherheitsupdates(a, monkeypatch):
monkeypatch.setattr(a, "_im_gast", lambda vmid, befehl, zeitlimit=30: (0, "12\n3\n1790329178\n"))
assert a._os_updates(104, "debian") == {"anzahl": 12, "sicherheit": 3, "listen_stand": 1790329178}
monkeypatch.setattr(a, "_im_gast", lambda vmid, befehl, zeitlimit=30: (
0, "3.24.2\n1790328932\nbusybox-1.37.0-r18 < 1.37.0-r19\n"))
alpine = a._os_updates(101, "alpine")
assert (alpine["anzahl"], alpine["alpine"], alpine["pakete"]) == (
1, "3.24.2", [{"name": "busybox", "alt": "1.37.0-r18", "neu": "1.37.0-r19"}])
liste = ("Listing...\ncurl/stable-security,stable 8.14.1-2+deb13u1 amd64 [upgradable from: 8.14.1-2]\n"
"pve-manager/stable 9.2.20 all [upgradable from: 9.2.19]\n")
assert a.sicherheit_auf_dem_host(liste) == ["curl"]
# --- Homelab-Teil ------------------------------------------------------------------------------------------------
def test_bausteine_tragen_die_markierung(monkeypatch):
from services.homelab import inventar
gast = {"vmid": 104, "art": "lxc", "os_updates": {"anzahl": 12, "sicherheit": 3, "listen_stand": time.time()},
"snapshot_moeglich": True}
stand = inventar._os_baustein(gast, "ct-104", time.time())
assert (stand.sicherheit, stand.kurz) == (3, "12 Pakete") and "(davon 3 Sicherheitsupdates)" in stand.aktion.frage
host = inventar._host_ziel({"version": "9.2.19", "updates": [{"paket": "curl"}, {"paket": "vim"}],
"updates_sicherheit": ["curl"]})
assert host.bausteine[0].sicherheit == 1
ohne = inventar._host_ziel({"version": "9.2.19", "updates": [{"paket": "vim"}]})
assert ohne.bausteine[0].sicherheit is None # älterer Ausführer: unbekannt
def test_docker_mit_luecken_aus_arcane(monkeypatch):
monkeypatch.setattr(einstellungen, "arcane_url", lambda: "http://arcane")
monkeypatch.setattr(arcane, "schluessel", lambda: "schluessel")
monkeypatch.setattr(arcane, "container_liste", lambda url: [
{"id": "a1", "names": ["/nerdquiz"], "imageId": "sha256:q", "state": "running", "labels": {}},
{"id": "a2", "names": ["/arcane"], "imageId": "sha256:a", "state": "running", "labels": {}}])
gefragt: list[tuple] = []
monkeypatch.setattr(arcane, "luecken", lambda url, ids: gefragt.append(ids) or {
"sha256:q": {"kritisch": 2, "hoch": 5, "mittel": 9, "gesamt": 30, "zeit": "2026-09-27T02:04:00Z"}})
zeilen = {c["name"]: c for s in docker.lage()["stacks"] for c in s["container"]}
assert gefragt == [("sha256:a", "sha256:q")]
assert zeilen["nerdquiz"]["luecken"]["kritisch"] == 2 and zeilen["arcane"]["luecken"] is None
def test_arcane_luecken_auslesen(monkeypatch):
monkeypatch.setattr(arcane, "schluessel", lambda: "schluessel")
monkeypatch.setattr(arcane, "umgebungen", lambda url, s=None: [{"id": 0}])
arcane.cache_leeren()
class Antwort:
def raise_for_status(self):
pass
def json(self):
return {"data": {"summaries": {
"sha256:q": {"scanTime": "2026-09-27T02:04:00Z", "summary": {"critical": 2, "high": 5, "total": 30}},
"sha256:n": {"status": "not_scanned", "summary": {}}}}}
monkeypatch.setattr(arcane.httpx, "post", lambda url, headers, json, timeout: Antwort())
assert arcane.luecken("http://arcane", ("sha256:n", "sha256:q")) == {
"sha256:q": {"kritisch": 2, "hoch": 5, "mittel": 0, "gesamt": 30, "zeit": "2026-09-27T02:04:00Z"}}
assert arcane.luecken("http://arcane", ()) == {}
+5 -3
View File
@@ -121,7 +121,7 @@ def _bericht(**host) -> dict:
"neueste": {k: v["zeit"] for k, v in details.items()},
"schaetzung": {"voll_am": 1887231717, "belegt": 1_517_685_571_584,
"gesamt": 3_847_503_609_856}}},
"nicht_gesichert": [104],
"nicht_gesichert": [104], # 107 (der eigene) ist gesichert
"sicherung_probe": {"zeit": 1790330000, "gaeste": {
"102": {"speicher": "pbs-qnap", "sicherung": 1790296227, "ok": True, "fehler": None},
"106": {"speicher": "pbs-qnap", "sicherung": 1790296281, "ok": False,
@@ -130,7 +130,8 @@ def _bericht(**host) -> dict:
return {"host": {**basis, **host}, "gaeste": [
{"vmid": 102, "art": "lxc", "name": "netbird", "app": {"kennung": "netbird"}},
{"vmid": 104, "art": "lxc", "name": "gitea", "app": {"kennung": "gitea"}},
{"vmid": 106, "art": "qemu", "name": "arcane"}]}
{"vmid": 106, "art": "qemu", "name": "arcane"},
{"vmid": 107, "art": "lxc", "name": "homelab-orchestrator", "etiketten": ["mc2"]}]}
def test_abdeckung_je_geraet():
@@ -144,6 +145,7 @@ def test_abdeckung_je_geraet():
"probe": {"speicher": "pbs-qnap", "sicherung": 1790296227, "ok": True, "fehler": None,
"zeit": 1790330000}}
assert zeilen["vm-106"]["defekt"] == [1789864278] and zeilen["vm-106"]["probe"]["ok"] is False
assert zeilen["ct-107"]["name"] == "Homelab Orchestrator (dieser Container)"
# Die letzte Prüfung von Arcane fand Beschädigtes: rot, auch wenn alles frisch gesichert ist.
assert (s["stufe_frische"], s["stufe"]) == ("ok", "rot")
assert s["pruefung"] == {"zuletzt": GEPRUEFT, "noetig": False} # älteste Sicherung erst 5,5 Tage alt
@@ -194,7 +196,7 @@ def test_aelterer_ausfuehrer_ohne_neue_angaben():
b["host"].pop(schluessel)
b["host"]["sicherungsziele"]["pbs-qnap"].pop("details")
s = speicher.sicherungen(b, JETZT)
assert [g["ziel"] for g in s["ohne_auftrag"]] == ["ct-104"] # selbst ausgerechnet aus den Aufträgen
assert [g["ziel"] for g in s["ohne_auftrag"]] == ["ct-104", "ct-107"] # selbst ausgerechnet aus den Aufträgen
assert s["stufe"] == "gelb" and s["probe"] is None
assert all(g["geprueft"] is None and g["probe"] is None for g in s["geraete"])
+4
View File
@@ -95,7 +95,11 @@ def test_befunde_werden_erst_nach_fail_after_takten_zum_hinweis(monkeypatch, tmp
assert "kern:engine" in waechter._stand["hinweise"]
assert meldungen == ["[Box-Problem]"]
# Seit 25.09.2026 mit Puffer: erst nach ERLEDIGT_NACH Takten ohne Befund ist der Hinweis erledigt.
defekt[0] = False
for _ in range(waechter.ERLEDIGT_NACH - 1):
waechter.takt()
assert "kern:engine" in waechter._stand["hinweise"] and meldungen == ["[Box-Problem]"]
waechter.takt()
assert waechter._stand["hinweise"] == {}
assert meldungen == ["[Box-Problem]", "[Box wieder ok]"]
+115 -9
View File
@@ -34,6 +34,11 @@ Container des Homelab-Teils braucht keinen Proxmox-Schlüssel.
• Zertifikate im Bericht (seit 25.09.2026, höchstens alle 30 Minuten neu gelesen): die https-Zertifikate von
NPMplus samt dem, was es für den Namen gerade ausliefert, das des Proxmox-Hosts und das des PBS —
Laufzeit, Aussteller, Namen. Nur lesend.
• Changelog (seit 25.09.2026, „Was ist neu?“ vor einem Host-Update): die Debian-Änderungslisten einzelner Pakete
aus der Update-Liste, jeweils bis zur installierten Version. Nur lesend.
• Etikett (seit 25.09.2026, Knopf „Übernehmen“ bei neu entdeckten Geräten): setzt oder entfernt genau eines der
beiden Etiketten watcher und watcher-aus — die beiden schließen sich aus, alle anderen Etiketten
bleiben, wie sie sind. Das geht auch bei Gästen ohne Freigabe: Es IST die Freigabe.
Nur die Standardbibliothek (Debian-Python des Hosts). Konfiguration: /etc/mc2-ausfuehrer.json
{"server": "http://192.168.178.x:9001", "token": "<gemeinsames Geheimnis>", "node": "pve", "nur_lesen": false}
@@ -315,21 +320,54 @@ def _app_version(vmid: int, kennung: str) -> str | None:
return m.group(1) if m else zeile[:40]
# Sicherheitsupdates (seit 25.09.2026, Ausbauplan Welle 2, Punkt 11): Debian und Ubuntu liefern sie aus eigenen Suiten
# („trixie-security“, „jammy-security“) — das steht in `apt list --upgradable` hinter dem Paketnamen. Alpine hat das
# nicht; dort geht die Liste mit den Versionen mit, und der Homelab-Teil gleicht sie mit Alpines Sicherheitsdatenbank ab.
APT_ZAEHLEN = ("LC_ALL=C apt list --upgradable 2>/dev/null"
" | awk '/upgradable/{n++; if ($1 ~ /-security/) s++} END{print n+0; print s+0}'; "
"stat -c %Y /var/lib/apt/lists 2>/dev/null || echo 0")
APK_LISTE = ("cat /etc/alpine-release 2>/dev/null || echo ?; stat -c %Y /var/cache/apk 2>/dev/null || echo 0; "
"apk version -l '<' 2>/dev/null | tail -n +2")
_APK_ZEILE = re.compile(r"^(\S+?)-(\d\S*)\s+<\s+(\S+)$")
def apk_auswerten(text: str) -> dict:
"""Ausgabe von APK_LISTE → {"anzahl", "listen_stand", "alpine", "pakete": [{"name", "alt", "neu"}]}."""
zeilen = text.splitlines()
pakete = [{"name": m.group(1), "alt": m.group(2), "neu": m.group(3)}
for z in zeilen[2:] if (m := _APK_ZEILE.match(z.strip()))]
try:
stand = int(zeilen[1].strip()) or None
except (IndexError, ValueError):
stand = None
return {"anzahl": len(pakete), "listen_stand": stand, "alpine": zeilen[0].strip() if zeilen else None,
"pakete": pakete[:300]}
def _os_updates(vmid: int, ostype: str) -> dict:
"""Aktualisierbare Pakete nach den vorhandenen Paketlisten (liest nur) und wie alt die Listen sind."""
"""Aktualisierbare Pakete nach den vorhandenen Paketlisten (liest nur), wie alt die Listen sind und — seit
25.09.2026 — wie viele davon Sicherheitsupdates sind (Alpine: die Liste für den Abgleich im Homelab-Teil)."""
if ostype == "alpine":
code, text = _im_gast(vmid, "apk version -l '<' 2>/dev/null | tail -n +2 | wc -l; "
"stat -c %Y /var/cache/apk 2>/dev/null || echo 0", 30)
else:
code, text = _im_gast(vmid, "LC_ALL=C apt list --upgradable 2>/dev/null | grep -c upgradable; "
"stat -c %Y /var/lib/apt/lists 2>/dev/null || echo 0", 30)
code, text = _im_gast(vmid, APK_LISTE, 30)
return apk_auswerten(text) if code in (0, 1) else {"anzahl": None, "listen_stand": None, "fehler": text[:160]}
code, text = _im_gast(vmid, APT_ZAEHLEN, 30)
zeilen = text.split() if code in (0, 1) else []
try:
return {"anzahl": int(zeilen[0]), "listen_stand": int(zeilen[1]) or None}
return {"anzahl": int(zeilen[0]), "sicherheit": int(zeilen[1]), "listen_stand": int(zeilen[2]) or None}
except (IndexError, ValueError):
return {"anzahl": None, "listen_stand": None, "fehler": text[:160]}
def sicherheit_auf_dem_host(apt_liste: str) -> list[str]:
"""Die Pakete aus `apt list --upgradable`, die aus einer Sicherheits-Suite kommen."""
namen = []
for zeile in apt_liste.splitlines():
kopf = zeile.split(" ", 1)[0]
if "upgradable" in zeile and "/" in kopf and "-security" in kopf.split("/", 1)[1]:
namen.append(kopf.split("/", 1)[0])
return namen
def _ip_lxc(vmid: int) -> str | None:
try:
for schnitt in _pvesh(f"/nodes/{NODE}/lxc/{vmid}/interfaces"):
@@ -829,6 +867,9 @@ def _host() -> dict:
host["version"] = _pvesh("/version").get("version")
host["updates"] = [{"paket": p.get("Package"), "alt": p.get("OldVersion"), "neu": p.get("Version"),
"herkunft": p.get("Origin")} for p in _pvesh(f"/nodes/{NODE}/apt/update")]
code, liste = _laufen(["env", "LC_ALL=C", "apt", "list", "--upgradable"], 60)
if code == 0:
host["updates_sicherheit"] = sicherheit_auf_dem_host(liste)
except Exception as exc:
host["fehler"] = str(exc)[:200]
code, kernel = _laufen(["uname", "-r"], 5)
@@ -1227,6 +1268,70 @@ def a_host_neustart(p: dict) -> tuple[int, str]:
return _laufen(["systemctl", "reboot"], 30)
_PAKETNAME = re.compile(r"[a-z0-9][a-z0-9+.-]{1,80}")
_KOPF = re.compile(r"^\S+ \(([^)]+)\) ", re.MULTILINE)
def changelog_neu(text: str, installiert: str | None) -> str:
"""Die Einträge einer Debian-Änderungsliste, die neuer sind als die installierte Version (sie steht oben)."""
if installiert:
for m in _KOPF.finditer(text):
if m.group(1) == installiert:
return text[:m.start()].strip()
return text.strip()
def a_changelog(p: dict) -> tuple[int, str]:
"""Änderungslisten für „Was ist neu?“ (seit 25.09.2026), nur lesend: nur Pakete, die gerade zum Update anstehen."""
pakete = p.get("pakete")
if not isinstance(pakete, list) or not 1 <= len(pakete) <= 12 \
or not all(isinstance(x, str) and _PAKETNAME.fullmatch(x) for x in pakete):
raise ValueError("ungültige Paketliste")
anstehend = {u.get("Package"): u.get("OldVersion") for u in _pvesh(f"/nodes/{NODE}/apt/update") or []}
ergebnis = {}
for name in pakete:
if name not in anstehend:
continue
try:
text = _pvesh(f"/nodes/{NODE}/apt/changelog", "--name", name)
except Exception as exc:
ergebnis[name] = f"(Änderungsliste nicht lesbar: {str(exc)[:120]})"
continue
ergebnis[name] = changelog_neu(str(text or ""), anstehend[name])[:6000]
return 0, json.dumps(ergebnis, ensure_ascii=False)
# Die einzigen Etiketten, die der Server setzen oder entfernen lassen kann (seit 25.09.2026).
SETZBARE_ETIKETTEN = ("watcher", AUSGENOMMEN)
def etiketten_neu(vorher: list[str], etikett: str, an: bool) -> list[str]:
"""Die Etiketten nach dem Setzen oder Entfernen: watcher und watcher-aus schließen sich aus, alles andere bleibt."""
if etikett not in SETZBARE_ETIKETTEN:
raise ValueError(f"Etikett {etikett!r} darf der Orchestrator nicht setzen")
ohne = [t for t in vorher if t not in SETZBARE_ETIKETTEN] if an else [t for t in vorher if t != etikett]
return ohne + [etikett] if an else ohne
def a_etikett_setzen(p: dict) -> tuple[int, str]:
"""Freigabe per Etikett (Knopf „Übernehmen“): kein _gast_pruefen — der Gast hat die Freigabe ja noch nicht."""
vmid, etikett, an = p.get("vmid"), p.get("etikett"), p.get("an", True)
if not isinstance(vmid, int) or not 100 <= vmid <= 999_999_999 or not isinstance(an, bool):
raise ValueError("ungültige Gast-Nummer oder Angabe")
eintrag = next((e for e in _pvesh("/cluster/resources", "--type", "vm") or []
if int(e.get("vmid", -1)) == vmid and e.get("node") == NODE), None)
if not eintrag:
raise ValueError(f"Gast {vmid} gibt es auf diesem Host nicht")
art = eintrag["type"]
vorher = _etiketten(_pvesh(f"/nodes/{NODE}/{art}/{vmid}/config").get("tags"))
neu = etiketten_neu(vorher, str(etikett), an)
if neu == vorher:
return 0, f"etiketten={';'.join(neu)}\nSchon so."
zusatz = ["--tags", ";".join(neu)] if neu else ["--delete", "tags"]
code, text = _laufen(["pvesh", "set", f"/nodes/{NODE}/{art}/{vmid}/config", *zusatz], 60)
return code, f"etiketten={';'.join(neu)}\n{text}"
def a_bericht(p: dict) -> tuple[int, str]:
return 0, json.dumps(bericht(), ensure_ascii=False)
@@ -1265,9 +1370,10 @@ AKTIONEN = {"snapshot": a_snapshot, "update": a_update, "os_update": a_os_update
"snapshot_loeschen": a_snapshot_loeschen, "host_update": a_host_update,
"host_neustart": a_host_neustart, "bericht": a_bericht,
"sichern": a_sichern, "sicherung_zurueck": a_sicherung_zurueck, "sicherung_loeschen": a_sicherung_loeschen,
"kernel_aufraeumen": a_kernel_aufraeumen, "sicherung_probe": a_sicherung_probe}
"kernel_aufraeumen": a_kernel_aufraeumen, "sicherung_probe": a_sicherung_probe,
"etikett_setzen": a_etikett_setzen, "changelog": a_changelog}
# Was auch im Nur-Lesen-Modus geht: Es liest nur.
NUR_LESEND = {a_bericht, a_sicherung_probe}
NUR_LESEND = {a_bericht, a_sicherung_probe, a_changelog}
def ausfuehren(auftrag: dict) -> dict:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{i as e,r as t,s as n}from"./dist-aE7UmubC.js";import{t as r}from"./button-DwYvdWVr.js";import{a as i,i as a,n as o,o as s,r as c,t as l}from"./dialog-BEmPjtjX.js";var u=n({Bestaetigen:()=>p}),d=t(),f=e();function p(e){let t=(0,d.c)(32),{offen:n,titel:u,text:p,knopf:m,neinKnopf:h,gefahr:g,gesperrt:_,onJa:v,onNein:y,children:b}=e,x=h===void 0?`Abbrechen`:h,S;t[0]===y?S=t[1]:(S=e=>!e&&y(),t[0]=y,t[1]=S);let C;t[2]===u?C=t[3]:(C=(0,f.jsx)(s,{className:`pr-10 text-[19px] leading-snug font-semibold break-words`,children:u}),t[2]=u,t[3]=C);let w;t[4]===p?w=t[5]:(w=(0,f.jsx)(c,{className:`text-[15px] leading-relaxed text-text-2`,children:p}),t[4]=p,t[5]=w);let T;t[6]!==C||t[7]!==w?(T=(0,f.jsxs)(i,{children:[C,w]}),t[6]=C,t[7]=w,t[8]=T):T=t[8];let E;t[9]!==x||t[10]!==y?(E=(0,f.jsx)(r,{variant:`outline`,onClick:y,children:x}),t[9]=x,t[10]=y,t[11]=E):E=t[11];let D=g?`gefahr`:`default`,O=_||void 0,k;t[12]!==_||t[13]!==v?(k=()=>{_||v()},t[12]=_,t[13]=v,t[14]=k):k=t[14];let A;t[15]!==_||t[16]!==m||t[17]!==D||t[18]!==O||t[19]!==k?(A=(0,f.jsx)(r,{variant:D,disabled:_,"aria-busy":O,onClick:k,children:m}),t[15]=_,t[16]=m,t[17]=D,t[18]=O,t[19]=k,t[20]=A):A=t[20];let j;t[21]!==A||t[22]!==E?(j=(0,f.jsxs)(a,{className:`sticky bottom-0 mb-0 gap-2 bg-[#121722] sm:gap-2`,children:[E,A]}),t[21]=A,t[22]=E,t[23]=j):j=t[23];let M;t[24]!==b||t[25]!==j||t[26]!==T?(M=(0,f.jsxs)(o,{className:`max-h-[calc(100dvh-2rem)] overflow-y-auto border-white/10 bg-[#0e121a] pb-0 sm:max-w-lg`,children:[T,b,j]}),t[24]=b,t[25]=j,t[26]=T,t[27]=M):M=t[27];let N;return t[28]!==n||t[29]!==M||t[30]!==S?(N=(0,f.jsx)(l,{open:n,onOpenChange:S,children:M}),t[28]=n,t[29]=M,t[30]=S,t[31]=N):N=t[31],N}export{u as n,p as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
import{i as e,r as t,t as n}from"./dist-aE7UmubC.js";import{O as r}from"./index-CJ1jXWsM.js";var i=t(),a=e(),o={ok:{block:`bg-gruen/[0.07] ring-gruen/30`,glut:`var(--gruen)`,text:`text-gruen`},info:{block:`bg-cyan/[0.07] ring-cyan/30`,glut:`var(--cyan)`,text:`text-cyan-text`},gelb:{block:`bg-bernstein/[0.08] ring-bernstein/35`,glut:`var(--bernstein)`,text:`text-bernstein`},rot:{block:`bg-rot/10 ring-rot/40`,glut:`var(--rot)`,text:`text-rot-text`},stumm:{block:`bg-white/[0.04] ring-white/15`,glut:`var(--text-3)`,text:`text-text-2`}};function s(e){let t=(0,i.c)(32),{art:r,oben:s,mitte:c,unten:l,aktiv:u,onAnsehen:d,label:f,children:p}=e,m=o[r],h=`radial-gradient(closest-side, color-mix(in srgb, ${m.glut} 20%, transparent), transparent)`,g;t[0]===h?g=t[1]:(g=(0,a.jsx)(`div`,{"aria-hidden":!0,className:`glut-atmet pointer-events-none absolute -top-40 -left-32 size-[460px] rounded-full`,style:{background:h}}),t[0]=h,t[1]=g);let _=!u,v=`${s}: ${c}`,y=u?`cursor-pointer hover:brightness-125`:`cursor-default`,b=r===`gelb`&&`lampe-atmet`,x;t[2]!==m.block||t[3]!==y||t[4]!==b?(x=n(`flex min-h-[132px] flex-col items-start justify-center gap-1.5 rounded-2xl px-5 py-4 text-left ring-1 ring-inset transition-[filter]`,m.block,y,b),t[2]=m.block,t[3]=y,t[4]=b,t[5]=x):x=t[5];let S;t[6]===s?S=t[7]:(S=(0,a.jsx)(`span`,{className:`etikett text-[11px] tracking-[0.18em] text-text-3`,children:s}),t[6]=s,t[7]=S);let C;t[8]===m.text?C=t[9]:(C=n(`text-[30px] leading-tight font-semibold tracking-[-0.01em] sm:text-[34px]`,m.text),t[8]=m.text,t[9]=C);let w;t[10]!==c||t[11]!==C?(w=(0,a.jsx)(`span`,{className:C,children:c}),t[10]=c,t[11]=C,t[12]=w):w=t[12];let T;t[13]===l?T=t[14]:(T=(0,a.jsx)(`span`,{className:`text-[13.5px] text-text-2`,children:l}),t[13]=l,t[14]=T);let E;t[15]!==d||t[16]!==w||t[17]!==T||t[18]!==_||t[19]!==v||t[20]!==x||t[21]!==S?(E=(0,a.jsxs)(`button`,{type:`button`,onClick:d,disabled:_,"aria-label":v,className:x,children:[S,w,T]}),t[15]=d,t[16]=w,t[17]=T,t[18]=_,t[19]=v,t[20]=x,t[21]=S,t[22]=E):E=t[22];let D;t[23]===p?D=t[24]:(D=(0,a.jsx)(`ul`,{className:`m-0 grid list-none grid-cols-1 gap-2 p-0 min-[480px]:grid-cols-2 xl:grid-cols-4`,children:p}),t[23]=p,t[24]=D);let O;t[25]!==E||t[26]!==D?(O=(0,a.jsxs)(`div`,{className:`relative grid gap-4 p-4 sm:p-5 lg:grid-cols-[280px_minmax(0,1fr)] lg:gap-5`,children:[E,D]}),t[25]=E,t[26]=D,t[27]=O):O=t[27];let k;return t[28]!==f||t[29]!==O||t[30]!==g?(k=(0,a.jsxs)(`section`,{"aria-label":f,className:`auftauchen flaeche relative overflow-hidden rounded-3xl`,children:[g,O]}),t[28]=f,t[29]=O,t[30]=g,t[31]=k):k=t[31],k}var c={ok:`ok`,aus:`aus`,warn:`warn`,info:`info`,fehler:`rot`},l={ok:`text-text-3`,aus:`text-text-3`,warn:`text-bernstein`,info:`text-cyan-text`,fehler:`text-rot-text`},u={ok:`in Ordnung`,aus:`aus`,warn:`braucht Aufmerksamkeit`,info:`Info`,fehler:`Störung`};function d(e){let t=(0,i.c)(25),{bild:o,name:s,wert:d,zustand:f,zahl:p,onClick:m}=e,h=c[f],g;t[0]!==o||t[1]!==h?(g=(0,a.jsx)(r,{bild:o,punkt:h}),t[0]=o,t[1]=h,t[2]=g):g=t[2];let _;t[3]===s?_=t[4]:(_=(0,a.jsx)(`span`,{className:`truncate text-[14.5px] leading-snug font-medium`,children:s}),t[3]=s,t[4]=_);let v=l[f],y;t[5]===v?y=t[6]:(y=n(`truncate text-[12.5px] leading-snug`,v),t[5]=v,t[6]=y);let b;t[7]!==y||t[8]!==d?(b=(0,a.jsx)(`span`,{className:y,children:d}),t[7]=y,t[8]=d,t[9]=b):b=t[9];let x;t[10]===p?x=t[11]:(x=p&&(0,a.jsx)(`span`,{className:n(`truncate text-[12px] leading-snug`,p.stufe===`rot`?`text-rot-text`:p.stufe===`gelb`?`text-bernstein`:`text-text-3`),children:p.text}),t[10]=p,t[11]=x);let S=u[f],C;t[12]===S?C=t[13]:(C=(0,a.jsx)(`span`,{className:`sr-only`,children:S}),t[12]=S,t[13]=C);let w;t[14]!==_||t[15]!==b||t[16]!==x||t[17]!==C?(w=(0,a.jsxs)(`span`,{className:`flex min-w-0 flex-col gap-0.5`,children:[_,b,x,C]}),t[14]=_,t[15]=b,t[16]=x,t[17]=C,t[18]=w):w=t[18];let T;t[19]!==w||t[20]!==g?(T=(0,a.jsxs)(a.Fragment,{children:[g,w]}),t[19]=w,t[20]=g,t[21]=T):T=t[21];let E=T,D;return t[22]!==E||t[23]!==m?(D=(0,a.jsx)(`li`,{children:m?(0,a.jsx)(`button`,{type:`button`,onClick:m,className:n(`flaeche-innen flex h-full w-full min-w-0 items-center gap-3 rounded-xl px-3 py-2.5 text-left`,`transition-colors hover:border-white/20 hover:bg-white/[0.05]`),children:E}):(0,a.jsx)(`div`,{className:`flaeche-innen flex h-full w-full min-w-0 items-center gap-3 rounded-xl px-3 py-2.5 text-left`,children:E})}),t[22]=E,t[23]=m,t[24]=D):D=t[24],D}export{s as n,d as t};
@@ -1 +1 @@
import{i as e,r as t}from"./dist-aE7UmubC.js";import{F as n,I as r}from"./index-CJ1jXWsM.js";import{a as i,i as a,n as o,r as s,t as c}from"./sheet-DE9QulgP.js";var l=t(),u=e();function d(e){let t=(0,l.c)(8),{onSchliessen:d}=e,f;t[0]===d?f=t[1]:(f=e=>!e&&d(),t[0]=d,t[1]=f);let p;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(p=(0,u.jsxs)(a,{className:`p-0`,children:[(0,u.jsx)(i,{asChild:!0,children:(0,u.jsx)(`div`,{children:(0,u.jsx)(n,{})})}),(0,u.jsx)(s,{className:`sr-only`,children:`Bereiche und Seiten`})]}),t[2]=p):p=t[2];let m;t[3]===d?m=t[4]:(m=(0,u.jsxs)(o,{side:`left`,className:`flex flex-col gap-7 border-white/[0.06] bg-[#0a0d14] px-4 pt-5 pb-[calc(1.25rem+env(safe-area-inset-bottom))] data-[side=left]:w-[300px]`,children:[p,(0,u.jsx)(r,{onGewaehlt:d})]}),t[3]=d,t[4]=m);let h;return t[5]!==f||t[6]!==m?(h=(0,u.jsx)(c,{open:!0,onOpenChange:f,children:m}),t[5]=f,t[6]=m,t[7]=h):h=t[7],h}export{d as MenueSchublade};
import{i as e,r as t}from"./dist-aE7UmubC.js";import{F as n,I as r}from"./index-BUHDF9RN.js";import{a as i,i as a,n as o,r as s,t as c}from"./sheet-Dx0TG2uq.js";var l=t(),u=e();function d(e){let t=(0,l.c)(8),{onSchliessen:d}=e,f;t[0]===d?f=t[1]:(f=e=>!e&&d(),t[0]=d,t[1]=f);let p;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(p=(0,u.jsxs)(a,{className:`p-0`,children:[(0,u.jsx)(i,{asChild:!0,children:(0,u.jsx)(`div`,{children:(0,u.jsx)(n,{})})}),(0,u.jsx)(s,{className:`sr-only`,children:`Bereiche und Seiten`})]}),t[2]=p):p=t[2];let m;t[3]===d?m=t[4]:(m=(0,u.jsxs)(o,{side:`left`,className:`flex flex-col gap-7 border-white/[0.06] bg-[#0a0d14] px-4 pt-5 pb-[calc(1.25rem+env(safe-area-inset-bottom))] data-[side=left]:w-[300px]`,children:[p,(0,u.jsx)(r,{onGewaehlt:d})]}),t[3]=d,t[4]=m);let h;return t[5]!==f||t[6]!==m?(h=(0,u.jsx)(c,{open:!0,onOpenChange:f,children:m}),t[5]=f,t[6]=m,t[7]=h):h=t[7],h}export{d as MenueSchublade};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{i as e,r as t}from"./dist-aE7UmubC.js";import{a as n,i as r,n as i,r as a,t as o}from"./sheet-DE9QulgP.js";var s=t(),c=e();function l(e){let t=(0,s.c)(14),{offen:l,titel:u,text:d,onSchliessen:f}=e,p;t[0]===f?p=t[1]:(p=e=>!e&&f(),t[0]=f,t[1]=p);let m;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(m=(0,c.jsx)(n,{className:`text-[19px] font-semibold`,children:`Protokoll`}),t[2]=m):m=t[2];let h;t[3]===u?h=t[4]:(h=(0,c.jsxs)(r,{children:[m,(0,c.jsx)(a,{className:`text-text-2`,children:u})]}),t[3]=u,t[4]=h);let g;t[5]===d?g=t[6]:(g=(0,c.jsx)(`pre`,{className:`ziffern mx-4 mb-4 flex-1 overflow-auto rounded-lg bg-black/30 ring-1 ring-white/[0.07] ring-inset p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2`,children:d}),t[5]=d,t[6]=g);let _;t[7]!==h||t[8]!==g?(_=(0,c.jsxs)(i,{side:`right`,className:`border-white/10 bg-[#0e121a] data-[side=right]:w-full data-[side=right]:sm:max-w-2xl`,children:[h,g]}),t[7]=h,t[8]=g,t[9]=_):_=t[9];let v;return t[10]!==l||t[11]!==p||t[12]!==_?(v=(0,c.jsx)(o,{open:l,onOpenChange:p,children:_}),t[10]=l,t[11]=p,t[12]=_,t[13]=v):v=t[13],v}export{l as Protokollfenster};
import{i as e,r as t}from"./dist-aE7UmubC.js";import{a as n,i as r,n as i,r as a,t as o}from"./sheet-Dx0TG2uq.js";var s=t(),c=e();function l(e){let t=(0,s.c)(14),{offen:l,titel:u,text:d,onSchliessen:f}=e,p;t[0]===f?p=t[1]:(p=e=>!e&&f(),t[0]=f,t[1]=p);let m;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(m=(0,c.jsx)(n,{className:`text-[19px] font-semibold`,children:`Protokoll`}),t[2]=m):m=t[2];let h;t[3]===u?h=t[4]:(h=(0,c.jsxs)(r,{children:[m,(0,c.jsx)(a,{className:`text-text-2`,children:u})]}),t[3]=u,t[4]=h);let g;t[5]===d?g=t[6]:(g=(0,c.jsx)(`pre`,{className:`ziffern mx-4 mb-4 flex-1 overflow-auto rounded-lg bg-black/30 ring-1 ring-white/[0.07] ring-inset p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2`,children:d}),t[5]=d,t[6]=g);let _;t[7]!==h||t[8]!==g?(_=(0,c.jsxs)(i,{side:`right`,className:`border-white/10 bg-[#0e121a] data-[side=right]:w-full data-[side=right]:sm:max-w-2xl`,children:[h,g]}),t[7]=h,t[8]=g,t[9]=_):_=t[9];let v;return t[10]!==l||t[11]!==p||t[12]!==_?(v=(0,c.jsx)(o,{open:l,onOpenChange:p,children:_}),t[10]=l,t[11]=p,t[12]=_,t[13]=v):v=t[13],v}export{l as Protokollfenster};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{a as e,i as t,r as n,t as r}from"./dist-aE7UmubC.js";import{Q as i}from"./index-BUHDF9RN.js";import{t as a}from"./button-DwYvdWVr.js";import{a as o,i as s,n as c,o as l,r as u,s as d,t as f}from"./dist-oje4j9hw.js";var p=n();e();var m=t();function h(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(f,{"data-slot":`dialog`,...n}),t[2]=n,t[3]=r),r}function g(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(l,{"data-slot":`dialog-portal`,...n}),t[2]=n,t[3]=r),r}function _(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0`,n),t[3]=n,t[4]=a);let s;return t[5]!==i||t[6]!==a?(s=(0,m.jsx)(o,{"data-slot":`dialog-overlay`,className:a,...i}),t[5]=i,t[6]=a,t[7]=s):s=t[7],s}function v(e){let t=(0,p.c)(15),n,o,s,l;t[0]===e?(n=t[1],o=t[2],s=t[3],l=t[4]):({className:o,children:n,showCloseButton:l,...s}=e,t[0]=e,t[1]=n,t[2]=o,t[3]=s,t[4]=l);let d=l===void 0||l,f;t[5]===Symbol.for(`react.memo_cache_sentinel`)?(f=(0,m.jsx)(_,{}),t[5]=f):f=t[5];let h;t[6]===o?h=t[7]:(h=r(`fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95`,o),t[6]=o,t[7]=h);let v;t[8]===d?v=t[9]:(v=d&&(0,m.jsx)(c,{"data-slot":`dialog-close`,asChild:!0,children:(0,m.jsxs)(a,{variant:`ghost`,className:`absolute top-2 right-2`,size:`icon-sm`,children:[(0,m.jsx)(i,{}),(0,m.jsx)(`span`,{className:`sr-only`,children:`Schließen`})]})}),t[8]=d,t[9]=v);let y;return t[10]!==n||t[11]!==s||t[12]!==h||t[13]!==v?(y=(0,m.jsxs)(g,{children:[f,(0,m.jsxs)(u,{"data-slot":`dialog-content`,className:h,...s,children:[n,v]})]}),t[10]=n,t[11]=s,t[12]=h,t[13]=v,t[14]=y):y=t[14],y}function y(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`flex flex-col gap-2`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(`div`,{"data-slot":`dialog-header`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function b(e){let t=(0,p.c)(14),n,i,o,s;t[0]===e?(n=t[1],i=t[2],o=t[3],s=t[4]):({className:i,showCloseButton:s,children:n,...o}=e,t[0]=e,t[1]=n,t[2]=i,t[3]=o,t[4]=s);let l=s!==void 0&&s,u;t[5]===i?u=t[6]:(u=r(`-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end`,i),t[5]=i,t[6]=u);let d;t[7]===l?d=t[8]:(d=l&&(0,m.jsx)(c,{asChild:!0,children:(0,m.jsx)(a,{variant:`outline`,children:`Schließen`})}),t[7]=l,t[8]=d);let f;return t[9]!==n||t[10]!==o||t[11]!==u||t[12]!==d?(f=(0,m.jsxs)(`div`,{"data-slot":`dialog-footer`,className:u,...o,children:[n,d]}),t[9]=n,t[10]=o,t[11]=u,t[12]=d,t[13]=f):f=t[13],f}function x(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`font-heading text-base leading-none font-medium`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(d,{"data-slot":`dialog-title`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function S(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(s,{"data-slot":`dialog-description`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}export{y as a,b as i,v as n,x as o,S as r,h as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
import{n as e}from"./dist-aE7UmubC.js";var t={name:`search`,size:24,node:[[`path`,{d:`m21 21-4.34-4.34`,key:`14j7rj`}],[`circle`,{cx:`11`,cy:`11`,r:`8`,key:`4ej97u`}]]};t.node;var n=e(t);export{n as t};
-1
View File
@@ -1 +0,0 @@
import{a as e,i as t,r as n,t as r}from"./dist-aE7UmubC.js";import{_n as i}from"./index-CJ1jXWsM.js";import{t as a}from"./button-DwYvdWVr.js";import{a as o,i as s,n as c,o as l,r as u,s as d,t as f}from"./dist-D-yM169e.js";var p=n();e();var m=t();function h(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(f,{"data-slot":`sheet`,...n}),t[2]=n,t[3]=r),r}function g(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(l,{"data-slot":`sheet-portal`,...n}),t[2]=n,t[3]=r),r}function _(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0`,n),t[3]=n,t[4]=a);let s;return t[5]!==i||t[6]!==a?(s=(0,m.jsx)(o,{"data-slot":`sheet-overlay`,className:a,...i}),t[5]=i,t[6]=a,t[7]=s):s=t[7],s}function v(e){let t=(0,p.c)(17),n,o,s,l,d;t[0]===e?(n=t[1],o=t[2],s=t[3],l=t[4],d=t[5]):({className:o,children:n,side:l,showCloseButton:d,...s}=e,t[0]=e,t[1]=n,t[2]=o,t[3]=s,t[4]=l,t[5]=d);let f=l===void 0?`right`:l,h=d===void 0||d,v;t[6]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,m.jsx)(_,{}),t[6]=v):v=t[6];let y;t[7]===o?y=t[8]:(y=r(`fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10`,o),t[7]=o,t[8]=y);let b;t[9]===h?b=t[10]:(b=h&&(0,m.jsx)(c,{"data-slot":`sheet-close`,asChild:!0,children:(0,m.jsxs)(a,{variant:`ghost`,className:`absolute top-3 right-3`,size:`icon-sm`,children:[(0,m.jsx)(i,{}),(0,m.jsx)(`span`,{className:`sr-only`,children:`Schließen`})]})}),t[9]=h,t[10]=b);let x;return t[11]!==n||t[12]!==s||t[13]!==f||t[14]!==y||t[15]!==b?(x=(0,m.jsxs)(g,{children:[v,(0,m.jsxs)(u,{"data-slot":`sheet-content`,"data-side":f,className:y,...s,children:[n,b]})]}),t[11]=n,t[12]=s,t[13]=f,t[14]=y,t[15]=b,t[16]=x):x=t[16],x}function y(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`flex flex-col gap-0.5 p-4`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(`div`,{"data-slot":`sheet-header`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function b(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`font-heading text-base font-medium text-foreground`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(d,{"data-slot":`sheet-title`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function x(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`text-sm text-muted-foreground`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(s,{"data-slot":`sheet-description`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}export{b as a,y as i,v as n,x as r,h as t};
+1
View File
@@ -0,0 +1 @@
import{a as e,i as t,r as n,t as r}from"./dist-aE7UmubC.js";import{Q as i}from"./index-BUHDF9RN.js";import{t as a}from"./button-DwYvdWVr.js";import{a as o,i as s,n as c,o as l,r as u,s as d,t as f}from"./dist-oje4j9hw.js";var p=n();e();var m=t();function h(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(f,{"data-slot":`sheet`,...n}),t[2]=n,t[3]=r),r}function g(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(l,{"data-slot":`sheet-portal`,...n}),t[2]=n,t[3]=r),r}function _(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0`,n),t[3]=n,t[4]=a);let s;return t[5]!==i||t[6]!==a?(s=(0,m.jsx)(o,{"data-slot":`sheet-overlay`,className:a,...i}),t[5]=i,t[6]=a,t[7]=s):s=t[7],s}function v(e){let t=(0,p.c)(17),n,o,s,l,d;t[0]===e?(n=t[1],o=t[2],s=t[3],l=t[4],d=t[5]):({className:o,children:n,side:l,showCloseButton:d,...s}=e,t[0]=e,t[1]=n,t[2]=o,t[3]=s,t[4]=l,t[5]=d);let f=l===void 0?`right`:l,h=d===void 0||d,v;t[6]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,m.jsx)(_,{}),t[6]=v):v=t[6];let y;t[7]===o?y=t[8]:(y=r(`fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10`,o),t[7]=o,t[8]=y);let b;t[9]===h?b=t[10]:(b=h&&(0,m.jsx)(c,{"data-slot":`sheet-close`,asChild:!0,children:(0,m.jsxs)(a,{variant:`ghost`,className:`absolute top-3 right-3`,size:`icon-sm`,children:[(0,m.jsx)(i,{}),(0,m.jsx)(`span`,{className:`sr-only`,children:`Schließen`})]})}),t[9]=h,t[10]=b);let x;return t[11]!==n||t[12]!==s||t[13]!==f||t[14]!==y||t[15]!==b?(x=(0,m.jsxs)(g,{children:[v,(0,m.jsxs)(u,{"data-slot":`sheet-content`,"data-side":f,className:y,...s,children:[n,b]})]}),t[11]=n,t[12]=s,t[13]=f,t[14]=y,t[15]=b,t[16]=x):x=t[16],x}function y(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`flex flex-col gap-0.5 p-4`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(`div`,{"data-slot":`sheet-header`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function b(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`font-heading text-base font-medium text-foreground`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(d,{"data-slot":`sheet-title`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function x(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`text-sm text-muted-foreground`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(s,{"data-slot":`sheet-description`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}export{b as a,y as i,v as n,x as r,h as t};
+1
View File
@@ -0,0 +1 @@
import{n as e,t}from"./useSelector-DBNxxw17.js";import{n}from"./useMatch-DDVpPKJI.js";function r(r){let i=e({warn:r?.router===void 0}),a=r?.router||i;return t(a.stores.__store,n(r,a))}export{r as t};
+4 -2
View File
@@ -9,11 +9,13 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>Homelab Orchestrator</title>
<script type="module" crossorigin src="/assets/index-CJ1jXWsM.js"></script>
<script type="module" crossorigin src="/assets/index-BUHDF9RN.js"></script>
<link rel="modulepreload" crossorigin href="/assets/dist-aE7UmubC.js">
<link rel="modulepreload" crossorigin href="/assets/abfragen-DKcWnZ8a.js">
<link rel="modulepreload" crossorigin href="/assets/useSelector-DBNxxw17.js">
<link rel="modulepreload" crossorigin href="/assets/useMatch-DDVpPKJI.js">
<link rel="stylesheet" crossorigin href="/assets/index-CeLk3zt0.css">
<link rel="modulepreload" crossorigin href="/assets/useRouterState-DJr4hrdj.js">
<link rel="stylesheet" crossorigin href="/assets/index-DcqanB_e.css">
</head>
<body>
<div id="root"></div>
+89
View File
@@ -0,0 +1,89 @@
import { useMemo, useState } from "react"
import { useNavigate } from "@tanstack/react-router"
import { Search } from "lucide-react"
import { cn } from "cn"
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
import { useHomelab, useInstanz, usePartner } from "@/lib/abfragen"
import { befehle, suchen } from "@/lib/befehle"
// Befehlssuche mit Strg+K (Ausbauplan Welle 2, Punkt 13, seit 25.09.2026). Wird erst beim ersten Öffnen geladen —
// die Radix-Dialog-Kette gehört nicht ins Start-Bündel. Befehle führen nur hin; ausgelöst wird dort.
export function Befehlssuche({ onSchliessen }: { onSchliessen: () => void }) {
const navigate = useNavigate()
const instanz = useInstanz()
const partner = usePartner()
const aktiv = instanz.data?.rolle === "homelab" || !!(partner.data?.eingerichtet && partner.data.erreichbar)
const ziele = useHomelab(aktiv).data?.ziele
const alle = useMemo(() => befehle(ziele ?? []), [ziele])
const [eingabe, setEingabe] = useState("")
const [auswahl, setAuswahl] = useState(0)
const treffer = suchen(alle, eingabe)
const aktuell = Math.min(auswahl, Math.max(0, treffer.length - 1))
function oeffnen(i: number) {
const b = treffer[i]
if (!b) return
onSchliessen()
void navigate({ to: b.to, hash: b.hash })
}
return (
<Dialog open onOpenChange={(o) => !o && onSchliessen()}>
<DialogContent className="top-[12vh] translate-y-0 gap-0 border-white/10 bg-[#0e121a] p-0 sm:max-w-xl" showCloseButton={false}>
<DialogTitle className="sr-only">Suchen</DialogTitle>
<DialogDescription className="sr-only">
Geräte, Seiten und Aktionen finden. Filter: typ:lxc, typ:vm, typ:host, update:ja.
</DialogDescription>
<div className="flex items-center gap-3 border-b border-white/[0.08] px-4">
<Search className="size-4 shrink-0 text-text-3" aria-hidden />
<input
autoFocus
role="combobox"
aria-expanded
aria-controls="befehle"
aria-activedescendant={treffer[aktuell] ? `befehl-${aktuell}` : undefined}
aria-label="Suchen"
value={eingabe}
placeholder="Gerät, Seite oder Aktion — etwa „AdGuard Snapshot“ oder „typ:lxc update:ja“"
onChange={(e) => {
setEingabe(e.target.value)
setAuswahl(0)
}}
onKeyDown={(e) => {
if (e.key === "ArrowDown") {
e.preventDefault()
setAuswahl(Math.min(aktuell + 1, treffer.length - 1))
} else if (e.key === "ArrowUp") {
e.preventDefault()
setAuswahl(Math.max(aktuell - 1, 0))
} else if (e.key === "Enter") {
e.preventDefault()
oeffnen(aktuell)
}
}}
className="h-14 min-w-0 flex-1 bg-transparent text-[16px] outline-none placeholder:text-text-3"
/>
<kbd className="hidden rounded-md border border-white/10 px-1.5 py-0.5 text-[11px] text-text-3 sm:block">Esc</kbd>
</div>
<ul id="befehle" role="listbox" aria-label="Treffer" className="m-0 max-h-[60vh] list-none overflow-y-auto p-2">
{treffer.length === 0 && <li className="px-3 py-6 text-center text-sm text-text-3">Nichts gefunden.</li>}
{treffer.map((b, i) => (
<li
key={b.id}
id={`befehl-${i}`}
role="option"
aria-selected={i === aktuell}
onMouseEnter={() => setAuswahl(i)}
onClick={() => oeffnen(i)}
className={cn("flex cursor-pointer flex-col rounded-xl px-3 py-2.5", i === aktuell && "bg-white/[0.07]")}
>
<span className="text-[15px] font-medium">{b.text}</span>
<span className="text-[13px] text-text-3">{b.unter}</span>
</li>
))}
</ul>
</DialogContent>
</Dialog>
)
}
+31 -2
View File
@@ -1,6 +1,6 @@
import { lazy, Suspense, useEffect, useState } from "react"
import { lazy, Suspense, useCallback, useEffect, useState } from "react"
import { Outlet } from "@tanstack/react-router"
import { Menu } from "lucide-react"
import { Menu, Search } from "lucide-react"
import { cn } from "cn"
import { ORT_NAME, type Ort, useAktuelleSeite } from "@/lib/navigation"
import { useStrom, useStromLage } from "@/lib/strom"
@@ -11,6 +11,8 @@ import { Marke, Seitenleiste } from "./Seitenleiste"
// Radix-Dialog-Kette (Fokus-Falle, Scroll-Sperre) sprengte sonst das 140-KB-Budget.
// („Dienste und Protokolle“ war bis 25.09.2026 die zweite Schublade; jetzt ist es eine Seite.)
const MenueSchublade = lazy(() => import("./MenueSchublade").then((m) => ({ default: m.MenueSchublade })))
// Die Befehlssuche (Strg+K, seit 25.09.2026) ebenso: erst beim ersten Öffnen.
const Befehlssuche = lazy(() => import("./Befehlssuche").then((m) => ({ default: m.Befehlssuche })))
const uhrzeit = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit", timeZone: "Europe/Berlin" })
const wochentag = new Intl.DateTimeFormat("de-DE", { weekday: "short", timeZone: "Europe/Berlin" })
@@ -60,10 +62,26 @@ const ORT_FARBE: Record<Ort, string> = {
system: "text-text-3",
}
/** Strg+K (am Mac ⌘K) öffnet die Befehlssuche — überall, auch aus einem Eingabefeld heraus. */
function useStrgK(oeffnen: () => void) {
useEffect(() => {
const taste = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && !e.altKey && e.key.toLowerCase() === "k") {
e.preventDefault()
oeffnen()
}
}
window.addEventListener("keydown", taste)
return () => window.removeEventListener("keydown", taste)
}, [oeffnen])
}
export function Shell() {
useStrom()
const [menue, setMenue] = useState(false)
const [suche, setSuche] = useState(false)
const seite = useAktuelleSeite()
useStrgK(useCallback(() => setSuche(true), []))
return (
<div className="min-h-dvh lg:grid lg:grid-cols-[272px_minmax(0,1fr)]">
@@ -93,6 +111,16 @@ export function Shell() {
<span className="lg:hidden">
<Verbindung />
</span>
<button
type="button"
onClick={() => setSuche(true)}
className="flex size-11 shrink-0 items-center justify-center gap-2 rounded-xl text-text-2 hover:bg-white/[0.06] hover:text-foreground md:w-auto md:px-3"
aria-label="Suchen (Strg+K)"
title="Suchen (Strg+K)"
>
<Search className="size-5" aria-hidden />
<kbd className="hidden rounded-md border border-white/10 px-1.5 py-0.5 font-sans text-[11px] text-text-3 md:block">Strg K</kbd>
</button>
<Uhr />
</div>
</header>
@@ -104,6 +132,7 @@ export function Shell() {
<Suspense fallback={null}>
{menue && <MenueSchublade onSchliessen={() => setMenue(false)} />}
{suche && <Befehlssuche onSchliessen={() => setSuche(false)} />}
</Suspense>
<Meldungen />
</div>
@@ -0,0 +1,41 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
import { post } from "@/lib/api"
import type { Hinweis } from "@/lib/typen"
import { Checkliste } from "./Checkliste"
vi.mock("@/lib/api", async (original) => ({ ...(await original<typeof import("@/lib/api")>()), post: vi.fn() }))
// Befundliste mit Ablauf (Ausbauplan Welle 2, Punkt 10, 25.09.2026): Zustand je Hinweis und die Knöpfe dafür.
const jetzt = Math.floor(Date.now() / 1000)
const hinweis: Hinweis = {
id: "ablauf:timer:mc2-radar", stufe: "gelb", titel: "Modell-Radar ist überfällig", text: "Zuletzt vor 30 Stunden.",
quelle: "mc2-radar", seit: jetzt - 3600, zuletzt: jetzt - 30, aktionen: [], zustand: "neu",
}
function zeigen(h: Hinweis, basis?: string) {
vi.mocked(post).mockResolvedValue({ ok: true, text: "Als gesehen markiert." })
render(
<QueryClientProvider client={new QueryClient()}>
<Checkliste hinweise={[h]} verlauf={[]} basis={basis} />
</QueryClientProvider>,
)
}
describe("Befundliste", () => {
it("neue Hinweise lassen sich als gesehen markieren oder 24 h stumm schalten — im Homelab beim Homelab-Teil", async () => {
zeigen(hinweis, "/api/homelab/hinweise")
expect(screen.getByText("· neu")).toBeInTheDocument()
fireEvent.click(screen.getByRole("button", { name: "24 h stumm" }))
await waitFor(() => expect(post).toHaveBeenCalledWith("/api/homelab/hinweise/ablauf%3Atimer%3Amc2-radar/aktion/stumm"))
expect(screen.getByRole("button", { name: "Gesehen" })).toBeInTheDocument()
})
it("stumme stehen gedämpft da und lassen sich wieder laut schalten", async () => {
zeigen({ ...hinweis, zustand: "stumm", stumm_bis: jetzt + 20 * 3600 })
expect(screen.getByText(/stumm bis/)).toBeInTheDocument()
expect(screen.queryByRole("button", { name: "24 h stumm" })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole("button", { name: "Wieder melden" }))
await waitFor(() => expect(post).toHaveBeenCalledWith("/api/hinweise/ablauf%3Atimer%3Amc2-radar/aktion/laut"))
})
})
+34 -10
View File
@@ -6,16 +6,17 @@ import { hinweisRueckfrage } from "@/lib/anzeige"
import { post } from "@/lib/api"
import { melden } from "@/lib/meldungen"
import type { AktionsErgebnis, Hinweis, HinweisAktion, VerlaufEintrag } from "@/lib/typen"
import { seit, vor } from "@/lib/zeit"
import { flugplanZeit, seit, vor } from "@/lib/zeit"
// Erst laden, wenn jemand ein Protokoll öffnet oder eine Rückfrage kommt — die Radix-Dialog-Kette
// gehört nicht ins Start-Bündel.
const Protokollfenster = lazy(() => import("./Protokollfenster").then((m) => ({ default: m.Protokollfenster })))
const Bestaetigen = lazy(() => import("./Bestaetigen").then((m) => ({ default: m.Bestaetigen })))
/** Ein offener Punkt: Titel · Leitpunkte · PRÜFEN/JETZT, darunter Text und Knöpfe. */
function Punkt({ h }: { h: Hinweis }) {
const aktion = useHinweisAktion()
/** Ein offener Punkt: Titel · Leitpunkte · PRÜFEN/JETZT, darunter Text und Knöpfe. Seit 25.09.2026 mit Zustand
* (neu, gesehen, stumm bis …) und den Knöpfen dafür; stumme stehen gedämpft unten. */
function Punkt({ h, basis }: { h: Hinweis; basis: string }) {
const aktion = useHinweisAktion(basis)
const [protokoll, setProtokoll] = useState<{ titel: string; text: string } | null>(null)
const [frage, setFrage] = useState<HinweisAktion | null>(null)
const rot = h.stufe === "rot"
@@ -23,7 +24,7 @@ function Punkt({ h }: { h: Hinweis }) {
async function protokollZeigen() {
try {
const e = await post<AktionsErgebnis>(`/api/hinweise/${encodeURIComponent(h.id)}/aktion/protokoll`)
const e = await post<AktionsErgebnis>(`${basis}/${encodeURIComponent(h.id)}/aktion/protokoll`)
setProtokoll({ titel: h.titel, text: e.text || e.out || e.err || "Kein Protokoll vorhanden." })
} catch (f) {
melden("fehler", (f as Error).message)
@@ -41,9 +42,10 @@ function Punkt({ h }: { h: Hinweis }) {
else ausfuehren(a)
}
const stumm = h.zustand === "stumm"
return (
<article className={cn("flex flex-col gap-2.5 rounded-2xl px-4 py-3.5 ring-1 ring-inset",
rot ? "bg-rot/[0.07] ring-rot/35" : "bg-bernstein/[0.06] ring-bernstein/30")}>
stumm ? "bg-white/[0.03] opacity-70 ring-white/10" : rot ? "bg-rot/[0.07] ring-rot/35" : "bg-bernstein/[0.06] ring-bernstein/30")}>
<div className="flex items-center gap-3">
<h3 className="min-w-0 flex-1 font-sans text-[16.5px] leading-snug font-semibold">{h.titel}</h3>
<span className={cn("etikett shrink-0 rounded-full px-2.5 py-0.5 text-[10.5px] tracking-[0.12em] ring-1 ring-inset",
@@ -53,9 +55,12 @@ function Punkt({ h }: { h: Hinweis }) {
</div>
<p className="ziffern text-[13px] text-text-3">
{seit(h.seit)} · zuletzt {vor(h.zuletzt)}
{h.zustand === "neu" && <span className="font-sans text-cyan-text"> · neu</span>}
{stumm && h.stumm_bis && (
<span className="font-sans"> · stumm bis {flugplanZeit(new Date(h.stumm_bis * 1000).toISOString()).toLowerCase()}</span>
)}
</p>
{h.text && <p className="text-[15px] leading-relaxed break-words text-text-2">{h.text}</p>}
{h.aktionen.length > 0 && (
<div className="flex flex-wrap gap-2.5 pt-1">
{h.aktionen.map((a, i) => (
<Button
@@ -68,8 +73,22 @@ function Punkt({ h }: { h: Hinweis }) {
{a.label}
</Button>
))}
</div>
{h.zustand === "neu" && (
<Button variant="ghost" size="sm" disabled={aktion.isPending} onClick={() => ausfuehren({ id: "gesehen", label: "Gesehen" })}>
Gesehen
</Button>
)}
{h.zustand && !stumm && (
<Button variant="ghost" size="sm" disabled={aktion.isPending} onClick={() => ausfuehren({ id: "stumm", label: "24 h stumm" })}>
24 h stumm
</Button>
)}
{stumm && (
<Button variant="ghost" size="sm" disabled={aktion.isPending} onClick={() => ausfuehren({ id: "laut", label: "Wieder melden" })}>
Wieder melden
</Button>
)}
</div>
{protokoll && (
<Suspense fallback={null}>
<Protokollfenster offen titel={protokoll.titel} text={protokoll.text} onSchliessen={() => setProtokoll(null)} />
@@ -95,7 +114,12 @@ function Punkt({ h }: { h: Hinweis }) {
)
}
export function Checkliste({ hinweise, verlauf }: { hinweise: Hinweis[]; verlauf: VerlaufEintrag[] }) {
export function Checkliste({ hinweise, verlauf, basis = "/api/hinweise" }: {
hinweise: Hinweis[]
verlauf: VerlaufEintrag[]
/** Wohin die Knöpfe gehen: KI-Box oder Homelab-Teil (seit 25.09.2026). */
basis?: string
}) {
const heute = new Date().toDateString()
const erledigtHeute = verlauf.filter(
(v) => (v.art === "erledigt" || v.art === "auto") && new Date(v.ts * 1000).toDateString() === heute,
@@ -119,7 +143,7 @@ export function Checkliste({ hinweise, verlauf }: { hinweise: Hinweis[]; verlauf
return (
<div className="flex flex-col gap-3">
{hinweise.map((h) => (
<Punkt key={h.id} h={h} />
<Punkt key={h.id} h={h} basis={basis} />
))}
</div>
)
@@ -0,0 +1,37 @@
import { render, screen, within } from "@testing-library/react"
import type { DockerContainer, DockerLage } from "@/lib/typen"
import { DockerDienste } from "./DockerDienste"
const c = (name: string, mehr: Partial<DockerContainer> = {}): DockerContainer => ({
id: name, name, stack: "rippy", dienst: name.split("-")[1] ?? name, image: `${name}:latest`, zustand: "running",
status: "Up 3 hours", gesund: null, update: "aktuell", aktuell: null, neu: null, ...mehr,
})
const lage: DockerLage = {
verfuegbar: true, grund: null,
stacks: [
{ name: "deploy", container: [c("nerdquiz", { stack: "deploy", gesund: true, update: "lokal" })] },
{ name: "rippy", container: [
c("rippy-postgres-1", { image: "postgres:16-alpine", update: "neu" }),
c("rippy-worker-1", { gesund: false }),
] },
],
}
describe("DockerDienste", () => {
it("zeigt je Stack Zustand, Gesundheit und Update-Stand", () => {
render(<DockerDienste lage={lage} />)
const deploy = screen.getByRole("region", { name: "Stack deploy" })
expect(within(deploy).getByText("läuft, gesund")).toBeInTheDocument()
expect(within(deploy).getByText("lokal gebaut")).toBeInTheDocument()
const rippy = screen.getByRole("region", { name: "Stack rippy" })
expect(within(rippy).getByText("2 von 2 laufen · 1 mit Update")).toBeInTheDocument()
expect(within(rippy).getByText("Update verfügbar")).toBeInTheDocument()
expect(within(rippy).getByText("ungesund")).toHaveClass("text-rot-text")
})
it("zeigt nichts ohne Arcane", () => {
const { container } = render(<DockerDienste lage={{ verfuegbar: false, grund: "Arcane antwortet gerade nicht.", stacks: [] }} />)
expect(container).toBeEmptyDOMElement()
})
})
@@ -0,0 +1,86 @@
import { cn } from "cn"
import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
import { geraetebild, type Punktzustand } from "@/lib/geraetebild"
import type { DockerContainer, DockerLage } from "@/lib/typen"
// Docker-Dienste unter Arcane einzeln (Ausbauplan Welle 2, Punkt 8, seit 25.09.2026): je Stack die Container mit
// Zustand, Gesundheit und Update-Stand — statt nur „Arcane (Docker) · 11 Images“.
const UPDATE: Record<DockerContainer["update"], { text: string; klasse: string }> = {
neu: { text: "Update verfügbar", klasse: "text-cyan-text" },
lokal: { text: "lokal gebaut", klasse: "text-text-3" },
aktuell: { text: "aktuell", klasse: "text-text-3" },
unbekannt: { text: "Update-Stand unklar", klasse: "text-bernstein" },
}
function punkt(c: DockerContainer): Punktzustand {
if (c.zustand === "restarting" || c.gesund === false) return "rot"
if (c.zustand === "running") return "ok"
return "aus"
}
function zustandText(c: DockerContainer): string {
if (c.gesund === false) return "ungesund"
if (c.zustand === "restarting") return "startet immer wieder neu"
if (c.zustand === "running") return c.gesund ? "läuft, gesund" : "läuft"
if (c.zustand === "exited") return "beendet"
if (c.zustand === "paused") return "angehalten"
return c.zustand
}
/** Aus „ghcr.io/getarcaneapp/manager:latest“ wird „manager“ — für das Logo. */
const imageName = (image: string | null) => (image ?? "").split("/").at(-1)?.split(":")[0] ?? ""
/** „2 kritische, 5 hohe Lücken“ aus Arcanes Scan (seit 25.09.2026) — nur, wenn es kritische oder hohe gibt. */
function lueckenText(c: DockerContainer): string | null {
const l = c.luecken
if (!l || (!l.kritisch && !l.hoch)) return null
const teile = [l.kritisch ? `${l.kritisch} kritische` : null, l.hoch ? `${l.hoch} hohe` : null].filter(Boolean)
return `${teile.join(", ")} Lücken`
}
function Zeile({ c }: { c: DockerContainer }) {
const u = UPDATE[c.update] ?? UPDATE.unbekannt
const luecken = lueckenText(c)
return (
<li className="flex min-w-0 items-center gap-3 border-t border-white/[0.06] py-2.5 first:border-t-0">
<Geraetesymbol bild={geraetebild({ art: "container", name: c.dienst ?? imageName(c.image) })} groesse="sm" punkt={punkt(c)} />
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-[14.5px] font-medium">{c.name}</span>
<span className="truncate text-[12.5px] text-text-3">{c.image}</span>
{luecken && (
<span className={cn("truncate text-[12.5px]", c.luecken?.kritisch ? "text-rot-text" : "text-bernstein")}>{luecken}</span>
)}
</span>
<span className="flex shrink-0 flex-col items-end text-right">
<span className={cn("text-[13px]", punkt(c) === "rot" ? "text-rot-text" : "text-text-2")}>{zustandText(c)}</span>
<span className={cn("text-[12px]", u.klasse)}>{u.text}</span>
</span>
</li>
)
}
export function DockerDienste({ lage }: { lage: DockerLage }) {
if (!lage.verfuegbar || lage.stacks.length === 0) return null
return (
<div className="grid gap-3 md:grid-cols-2 2xl:grid-cols-3">
{lage.stacks.map((s) => {
const laufen = s.container.filter((c) => c.zustand === "running").length
const neu = s.container.filter((c) => c.update === "neu").length
return (
<section key={s.name} aria-label={`Stack ${s.name}`} className="flaeche-innen flex min-w-0 flex-col rounded-2xl px-4 py-3">
<div className="flex items-baseline justify-between gap-3 pb-1">
<h3 className="m-0 truncate text-[15px] font-semibold">{s.name}</h3>
<span className="shrink-0 text-[12.5px] text-text-3">
{laufen} von {s.container.length} laufen{neu ? ` · ${neu} mit Update` : ""}
</span>
</div>
<ul className="m-0 list-none p-0">
{s.container.map((c) => <Zeile key={c.id} c={c} />)}
</ul>
</section>
)
})}
</div>
)
}
@@ -77,7 +77,11 @@ export function Gesundheit({ ziele, messwerte }: { ziele: ZielStand[]; messwerte
<Link to="/homelab/monitoring" hash={z.id} className="text-[15px] font-semibold break-words hover:text-lab-text">
{z.name}
</Link>
{artText(z) !== z.name && <span className="text-[13px] text-text-3">{artText(z)}</span>}
<span className="flex flex-wrap gap-x-2 text-[13px] text-text-3">
{artText(z) !== z.name && <span>{artText(z)}</span>}
{/* Zeitleiste je Gerät (seit 25.09.2026). */}
<Link to="/homelab/protokoll" hash={z.id} className="text-lab-text hover:underline">Zeitleiste</Link>
</span>
</span>
</div>
<span role="cell" className={cn("flex items-center gap-1.5 text-[13px]", z.erreichbar === false ? "text-rot-text" : "text-text-3")}>
@@ -0,0 +1,40 @@
import { fireEvent, render, screen, within } from "@testing-library/react"
import type { NeuesGeraet } from "@/lib/typen"
import { NeuEntdeckt } from "./NeuEntdeckt"
const jellyfin: NeuesGeraet = {
id: "ct-108", vmid: 108, name: "Jellyfin", hostname: "jellyfin", art: "container", status: "running",
erstmals: Math.floor(Date.now() / 1000) - 600, etiketten: [], freigegeben: false, ausgeschlossen: false,
community_script: false, ip: "192.168.178.170", ohne_sicherung: true,
app: { kennung: "jellyfin", name: "Jellyfin", quelle: "jellyfin/jellyfin", weg: "skript", adresse: null },
}
describe("NeuEntdeckt", () => {
it("zeigt, was der Orchestrator weiß, und fragt vor dem Freigeben", () => {
const onWahl = vi.fn()
render(<NeuEntdeckt geraete={[jellyfin]} gesperrt={false} onWahl={onWahl} />)
expect(screen.getByText("Jellyfin (GitHub jellyfin/jellyfin)")).toBeInTheDocument()
expect(screen.getByText("Weboberfläche nicht gefunden, keine Webprüfung")).toBeInTheDocument()
expect(screen.getByText(/In keinem Sicherungsauftrag/)).toBeInTheDocument()
fireEvent.click(screen.getByRole("button", { name: "Für Updates freigeben" }))
expect(onWahl).not.toHaveBeenCalled()
const dialog = screen.getByRole("dialog")
expect(within(dialog).getByText(/Etikett „watcher“/)).toBeInTheDocument()
fireEvent.click(within(dialog).getByRole("button", { name: "Freigeben" }))
expect(onWahl).toHaveBeenCalledWith("ct-108", "freigeben")
})
it("übernimmt Beobachten ohne Rückfrage; ein schon freigegebenes Gerät hat nur „Übernehmen“", () => {
const onWahl = vi.fn()
render(<NeuEntdeckt geraete={[{ ...jellyfin, freigegeben: true, community_script: true }]} gesperrt={false} onWahl={onWahl} />)
expect(screen.queryByRole("button", { name: "Für Updates freigeben" })).not.toBeInTheDocument()
expect(screen.getByText("Schon freigegeben (Etikett community-script).")).toBeInTheDocument()
fireEvent.click(screen.getByRole("button", { name: "Übernehmen" }))
expect(onWahl).toHaveBeenCalledWith("ct-108", "beobachten")
})
it("zeigt nichts, wenn nichts neu ist", () => {
const { container } = render(<NeuEntdeckt geraete={[]} gesperrt={false} onWahl={() => {}} />)
expect(container).toBeEmptyDOMElement()
})
})
@@ -0,0 +1,135 @@
import { useState } from "react"
import { cn } from "cn"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
import { Panel } from "@/components/cockpit/Panel"
import { Button } from "@/components/ui/button"
import { geraetebild } from "@/lib/geraetebild"
import type { NeuerStack, NeuesGeraet, NeuWahl } from "@/lib/typen"
import { flugplanZeit } from "@/lib/zeit"
// „Neu entdeckt“ (Ausbauplan Welle 2, Punkt 7, seit 25.09.2026): Geräte, die der Orchestrator noch nicht kennt, mit
// dem, was er über sie weiß, und drei Wegen, sie zu übernehmen. Freigeben setzt das Etikett watcher — erst danach darf
// er dort Snapshots anlegen und Updates einspielen; deshalb fragt die Seite vorher nach.
const WEG: Record<string, string> = {
skript: "Updates über das Community-Script",
"eigene-oberflaeche": "Updates über die eigene Oberfläche der App",
pakete: "Updates über die Pakete im Gast",
}
function Karte({ g, gesperrt, onWahl }: { g: NeuesGeraet; gesperrt: boolean; onWahl: (w: NeuWahl) => void }) {
const bild = geraetebild({ art: g.art, name: g.app?.kennung ?? g.hostname ?? g.name })
const fakten = [
g.app ? `${g.app.name}${g.app.quelle ? ` (GitHub ${g.app.quelle})` : ""}` : "App unbekannt",
g.app ? WEG[g.app.weg] ?? g.app.weg : null,
g.app?.adresse ? `Weboberfläche ${g.app.adresse} wird geprüft` : g.app ? "Weboberfläche nicht gefunden, keine Webprüfung" : null,
].filter(Boolean)
return (
<li className="flaeche-innen flex flex-col gap-3 rounded-2xl p-4">
<div className="flex items-start gap-3">
<Geraetesymbol bild={bild} groesse="md" punkt={g.status === "running" ? "ok" : "aus"} />
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-[15px] font-semibold break-words">{g.name}</span>
<span className="text-[13px] text-text-3">
{g.art === "container" ? "Container" : "VM"} {g.vmid}
{g.hostname && g.hostname !== g.name ? ` · „${g.hostname}“` : ""}
{g.erstmals ? ` · entdeckt ${flugplanZeit(new Date(g.erstmals * 1000).toISOString()).toLowerCase()}` : ""}
</span>
</div>
</div>
<ul className="m-0 flex list-none flex-col gap-1 p-0 text-sm text-text-2">
{fakten.map((f) => <li key={f}>{f}</li>)}
{g.freigegeben && <li>Schon freigegeben (Etikett {g.community_script ? "community-script" : "watcher"}).</li>}
{g.ohne_sicherung && (
<li className="text-bernstein">In keinem Sicherungsauftrag — in Proxmox: Rechenzentrum → Backup → den Auftrag bearbeiten.</li>
)}
</ul>
<div className="flex flex-wrap gap-2">
{!g.freigegeben && !g.ausgeschlossen && (
<Button size="sm" disabled={gesperrt} onClick={() => onWahl("freigeben")}>Für Updates freigeben</Button>
)}
<Button variant={g.freigegeben ? "default" : "outline"} size="sm" disabled={gesperrt} onClick={() => onWahl("beobachten")}>
{g.freigegeben ? "Übernehmen" : "Nur beobachten"}
</Button>
{!g.ausgeschlossen && (
<Button variant="ghost" size="sm" disabled={gesperrt} onClick={() => onWahl("ausschliessen")}>Nicht anfassen</Button>
)}
</div>
</li>
)
}
const FRAGE: Record<NeuWahl, { titel: string; text: string; knopf: string }> = {
freigeben: {
titel: "Für Updates freigeben?",
text: "Der Orchestrator setzt in Proxmox das Etikett „watcher“. Danach darf er dort Snapshots anlegen und Updates " +
"einspielen — immer erst nach deinem Klick, mit Snapshot vorher und Rückweg bei Rot.",
knopf: "Freigeben",
},
beobachten: { titel: "Nur beobachten?", text: "", knopf: "Übernehmen" },
ausschliessen: {
titel: "Nicht anfassen?",
text: "Der Orchestrator setzt in Proxmox das Etikett „watcher-aus“ und lässt das Gerät dann auch bei „Alle " +
"aktualisieren“ aus.",
knopf: "Nicht anfassen",
},
}
/** Ein neuer Docker-Stack: nur „Übernehmen“ — Etiketten gibt es unter Arcane nicht. */
function StackKarte({ s, gesperrt, onWahl }: { s: NeuerStack; gesperrt: boolean; onWahl: (w: NeuWahl) => void }) {
return (
<li className="flaeche-innen flex flex-col gap-3 rounded-2xl p-4">
<div className="flex items-start gap-3">
<Geraetesymbol bild={geraetebild({ art: "container", name: s.name })} groesse="md" punkt="ok" />
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-[15px] font-semibold break-words">Docker-Stack {s.name}</span>
<span className="text-[13px] text-text-3">
unter Arcane{s.erstmals ? ` · entdeckt ${flugplanZeit(new Date(s.erstmals * 1000).toISOString()).toLowerCase()}` : ""}
</span>
</div>
</div>
<p className="m-0 text-sm break-words text-text-2">Container: {s.container.join(", ") || "–"}</p>
<Button size="sm" className="self-start" disabled={gesperrt} onClick={() => onWahl("beobachten")}>Übernehmen</Button>
</li>
)
}
export function NeuEntdeckt({ geraete, stacks = [], gesperrt, onWahl }: {
geraete: NeuesGeraet[]
/** Neue Docker-Stacks unter Arcane (seit 25.09.2026). */
stacks?: NeuerStack[]
gesperrt: boolean
onWahl: (id: string, wahl: NeuWahl) => void
}) {
const [frage, setFrage] = useState<{ g: NeuesGeraet; wahl: NeuWahl } | null>(null)
const anzahl = geraete.length + stacks.length
if (anzahl === 0) return null
return (
<Panel titel="Neu entdeckt" id="neu">
<p className={cn("-mt-2 text-[15px] text-text-2")}>
{anzahl === 1 ? "Eins kennt" : `${anzahl} kennt`} der Orchestrator noch nicht. Entscheide, ob er
{anzahl === 1 ? " es" : " sie"} nur beobachtet oder auch aktualisieren darf.
</p>
<ul className="m-0 grid list-none gap-3 p-0 md:grid-cols-2">
{geraete.map((g) => (
<Karte key={g.id} g={g} gesperrt={gesperrt}
onWahl={(wahl) => (wahl === "beobachten" ? onWahl(g.id, wahl) : setFrage({ g, wahl }))} />
))}
{stacks.map((s) => <StackKarte key={s.id} s={s} gesperrt={gesperrt} onWahl={(wahl) => onWahl(s.id, wahl)} />)}
</ul>
<Bestaetigen
offen={!!frage}
titel={frage ? `${frage.g.name}: ${FRAGE[frage.wahl].titel}` : ""}
text={frage ? FRAGE[frage.wahl].text : ""}
knopf={frage ? FRAGE[frage.wahl].knopf : ""}
gesperrt={gesperrt}
onNein={() => setFrage(null)}
onJa={() => {
if (frage) onWahl(frage.g.id, frage.wahl)
setFrage(null)
}}
/>
</Panel>
)
}
@@ -0,0 +1,46 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { fireEvent, render, screen } from "@testing-library/react"
import { api } from "@/lib/api"
import type { Neuerungen as NeuerungenDaten } from "@/lib/typen"
import { Neuerungen } from "./Neuerungen"
vi.mock("@/lib/api", async (original) => ({
...(await original<typeof import("@/lib/api")>()),
api: vi.fn(),
}))
const GITEA: NeuerungenDaten = {
verfuegbar: true, von: "1.25.4", nach: "1.26.1", quelle_url: "https://github.com/go-gitea/gitea/releases",
versionen: [
{ version: "v1.26.1", titel: "Gitea 1.26.1", datum: null, url: "u1", text: "* Security: fix XSS" },
{ version: "v1.26.0", titel: "Gitea 1.26.0", datum: null, url: "u0", text: "BREAKING: LFS_JWT_SECRET moved" },
],
zusammenfassung: { aktion: true, aktion_text: "In app.ini [lfs] JWT_SECRET setzen", punkte: ["⚠️ LFS-Schlüssel umgezogen"] },
}
function zeigen(daten: NeuerungenDaten, kompakt = false) {
vi.mocked(api).mockResolvedValue(daten)
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(<QueryClientProvider client={qc}><Neuerungen zid="ct-104" baustein="app" kompakt={kompakt} /></QueryClientProvider>)
}
describe("Neuerungen", () => {
it("stellt die Handarbeit an den Anfang und zeigt die Release Notes auf Wunsch", async () => {
zeigen(GITEA)
expect(await screen.findByText("⚠ Handarbeit: In app.ini [lfs] JWT_SECRET setzen")).toBeInTheDocument()
expect(screen.getByText("⚠️ LFS-Schlüssel umgezogen")).toBeInTheDocument()
fireEvent.click(screen.getByText("Release Notes (2 Versionen)"))
expect(screen.getByText("BREAKING: LFS_JWT_SECRET moved")).toBeInTheDocument()
expect(api).toHaveBeenCalledWith("/api/homelab/ziele/ct-104/neuerungen?baustein=app")
})
it("in der Rückfrage nur die Kurzfassung; ohne Notes der Grund", async () => {
const { unmount } = zeigen({ ...GITEA, zusammenfassung: { aktion: false, aktion_text: "", punkte: [] } }, true)
expect(await screen.findByText("Du musst dafür nichts selbst tun.")).toBeInTheDocument()
expect(screen.queryByText(/Release Notes/)).not.toBeInTheDocument()
unmount()
zeigen({ verfuegbar: false, grund: "Zu diesen Versionen stehen auf GitHub keine Release Notes.", quelle_url: "https://x" })
expect(await screen.findByText(/keine Release Notes/)).toBeInTheDocument()
expect(screen.getByRole("link", { name: "Zu den Releases" })).toHaveAttribute("href", "https://x")
})
})
@@ -0,0 +1,88 @@
import { ExternalLink } from "lucide-react"
import { cn } from "cn"
import { useNeuerungen } from "@/lib/abfragen"
import type { Neuerungen as NeuerungenDaten } from "@/lib/typen"
// „Was ist neu?“ vor dem Knopf (Ausbauplan Welle 2, Punkt 9, seit 25.09.2026): Lucys Kurzfassung der Release Notes
// (bzw. der Debian-Änderungslisten beim Proxmox-Host), Handarbeit ganz oben — und darunter die Quellen selbst.
function Kurzfassung({ d }: { d: NeuerungenDaten }) {
const z = d.zusammenfassung
if (!z) return <p className="m-0 text-sm text-text-3">Lucy konnte gerade keine Kurzfassung schreiben; die Quellen stehen unten.</p>
return (
<div className="flex flex-col gap-2">
{z.aktion ? (
<p className="m-0 rounded-xl bg-bernstein/10 px-3 py-2 text-sm text-bernstein ring-1 ring-bernstein/30 ring-inset">
⚠ Handarbeit: {z.aktion_text || "Lies die Hinweise unten, bevor du updatest."}
</p>
) : (
z.aktion === false && <p className="m-0 text-sm text-gruen-text">Du musst dafür nichts selbst tun.</p>
)}
{z.punkte.length > 0 && (
<ul className="m-0 flex list-disc flex-col gap-1 pl-5 text-sm text-text-2">
{z.punkte.map((p) => <li key={p}>{p}</li>)}
</ul>
)}
</div>
)
}
function Quellen({ d }: { d: NeuerungenDaten }) {
const versionen = d.versionen ?? []
const pakete = d.pakete ?? []
if (versionen.length === 0 && pakete.length === 0) return null
return (
<details className="group text-sm">
<summary className="cursor-pointer text-text-3 hover:text-text-2">
{versionen.length ? `Release Notes (${versionen.length} ${versionen.length === 1 ? "Version" : "Versionen"})`
: `Änderungslisten (${pakete.length} Pakete${d.weitere ? `, ${d.weitere} weitere ohne Liste` : ""})`}
</summary>
<div className="mt-2 flex max-h-80 flex-col gap-3 overflow-y-auto pr-1">
{versionen.map((v) => (
<section key={v.version} className="flex flex-col gap-1">
<span className="flex items-baseline justify-between gap-2">
<span className="font-medium">{v.titel}</span>
{v.url && (
<a href={v.url} target="_blank" rel="noopener" className="inline-flex shrink-0 items-center gap-1 text-cyan hover:underline">
GitHub <ExternalLink className="size-3" aria-hidden />
</a>
)}
</span>
<pre className="m-0 font-sans text-[13px] break-words whitespace-pre-wrap text-text-3">{v.text || "–"}</pre>
</section>
))}
{pakete.map((p) => (
<section key={p.name} className="flex flex-col gap-1">
<span className="font-mono text-[13px] font-medium">{p.name}</span>
<pre className="m-0 font-mono text-[12px] break-words whitespace-pre-wrap text-text-3">{p.text}</pre>
</section>
))}
</div>
</details>
)
}
/** Lädt beim ersten Anzeigen; das erste Mal kann es ein paar Sekunden dauern (GitHub, Ausführer, Lucy). */
export function Neuerungen({ zid, baustein, kompakt = false, className }: {
zid: string
baustein: string
/** In der Rückfrage vor dem Update: nur die Kurzfassung, ohne Quellen. */
kompakt?: boolean
className?: string
}) {
const q = useNeuerungen(zid, baustein, true)
let inhalt
if (q.isPending) inhalt = <p className="m-0 text-sm text-text-3" role="status">Lucy liest, was neu ist …</p>
else if (q.error) inhalt = <p className="m-0 text-sm text-text-3">Ließ sich gerade nicht lesen: {q.error.message}</p>
else if (!q.data?.verfuegbar) {
inhalt = (
<p className="m-0 text-sm text-text-3">
{q.data?.grund ?? "Keine Angaben."}
{q.data?.quelle_url && <> <a href={q.data.quelle_url} target="_blank" rel="noopener" className="text-cyan hover:underline">Zu den Releases</a></>}
</p>
)
} else {
inhalt = <><Kurzfassung d={q.data} />{!kompakt && <Quellen d={q.data} />}</>
}
return <div className={cn("flex flex-col gap-2", className)}>{inhalt}</div>
}
@@ -1,9 +1,10 @@
import { useState } from "react"
import { Search } from "lucide-react"
import { ChevronRight, Search } from "lucide-react"
import { cn } from "cn"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Chip } from "@/components/cockpit/Chip"
import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
import { Neuerungen } from "@/components/homelab/Neuerungen"
import { Button } from "@/components/ui/button"
import { geraetebild } from "@/lib/geraetebild"
import {
@@ -41,7 +42,14 @@ function PaketZelle({ b, onSuchen, gesperrt }: { b: BausteinStand | null; onSuch
if (!b) return <span className="text-sm text-text-3">–</span>
const docker = b.id === "docker"
let text
if (b.zustand === "neu") text = <span className="text-cyan-text">{b.kurz || "Update bereit"}</span>
if (b.zustand === "neu") {
text = (
<span className="text-cyan-text">
{b.kurz || "Update bereit"}
{!!b.sicherheit && <span className="text-rot-text"> · {b.sicherheit} Sicherheit</span>}
</span>
)
}
else if (b.zustand === "aktuell") text = <span className="text-gruen-text">{docker ? "Images aktuell" : "aktuell"}</span>
else {
const datum = listenAlter(b.grund)
@@ -68,6 +76,12 @@ function PaketZelle({ b, onSuchen, gesperrt }: { b: BausteinStand | null; onSuch
const KNOPF_TEXT: Record<string, string> = { app: "App updaten", os: "Pakete einspielen", pakete: "Pakete einspielen" }
/** Wofür es „Was ist neu?“ gibt (seit 25.09.2026): eine neue App-Version, die Pakete des Proxmox-Hosts. */
function mitNeuerungen(ziel: ZielStand, b: BausteinStand | null | undefined): b is BausteinStand {
if (!b || b.zustand !== "neu") return false
return (b.id === "app" && !!b.verfuegbar) || (ziel.art === "proxmox-host" && b.id === "pakete")
}
function Zeile({ ziel, laeuft, gesperrt, onFrage }: {
ziel: ZielStand
laeuft: string | null
@@ -78,6 +92,8 @@ function Zeile({ ziel, laeuft, gesperrt, onFrage }: {
const sys = systemBaustein(ziel)
const knoepfe = ziel.bausteine.filter((b) => b.zustand === "neu" && b.aktion)
const ruhig = updateRang(ziel) >= 2
const neues = ziel.bausteine.find((b) => mitNeuerungen(ziel, b))
const [offen, setOffen] = useState(false)
return (
<div
role="row"
@@ -111,6 +127,16 @@ function Zeile({ ziel, laeuft, gesperrt, onFrage }: {
))
)}
</span>
{neues && (
<div className="col-span-full flex flex-col gap-2">
<button type="button" aria-expanded={offen} onClick={() => setOffen(!offen)}
className="inline-flex min-h-9 items-center gap-1.5 self-start text-sm text-cyan-text hover:underline">
<ChevronRight className={cn("size-4 transition-transform", offen && "rotate-90")} aria-hidden />
Was ist neu?
</button>
{offen && <Neuerungen zid={ziel.id} baustein={neues.id} className="flaeche-innen rounded-xl p-3" />}
</div>
)}
</div>
)
}
@@ -126,6 +152,8 @@ export function UpdateTabelle({ ziele, laufend, gesperrt, onAktion }: {
onAktion: (a: ZielAktion) => void
}) {
const [frage, setFrage] = useState<Frage>(null)
// Zeigt die Rückfrage ein Update mit „Was ist neu?“, steht Lucys Kurzfassung gleich darin (seit 25.09.2026).
const frageBaustein = frage ? frage.ziel.bausteine.find((b) => b.aktion?.pfad === frage.aktion.pfad && mitNeuerungen(frage.ziel, b)) : undefined
const sortiert = [...ziele].sort((a, b) => updateRang(a) - updateRang(b))
const aktuell = ziele.filter((z) => updateRang(z) === 3).length
return (
@@ -155,7 +183,9 @@ export function UpdateTabelle({ ziele, laufend, gesperrt, onAktion }: {
setFrage(null)
}}
onNein={() => setFrage(null)}
/>
>
{frageBaustein && frage && <Neuerungen zid={frage.ziel.id} baustein={frageBaustein.id} kompakt />}
</Bestaetigen>
</>
)
}
+54 -8
View File
@@ -10,7 +10,7 @@ import { melden } from "./meldungen"
import { stromVerbunden } from "./strom"
import type {
Ablauf, AktionsErgebnis, Altrest, AufraeumKandidat, BoxMesswerte, EinstellungenStand, Hinweis, HomelabEinstellungen,
HomelabLauf, HomelabMesswerte, HomelabSpeicher, HomelabStand, HomelabZertifikate, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus,
HomelabLauf, HomelabMesswerte, HomelabSpeicher, HomelabStand, HomelabZertifikate, NeuesGeraet, NeuerStack, NeuWahl, DockerLage, Neuerungen, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus,
ProtokollEintrag, Radar, SammelPlanSchritt, Sammellauf, Sicherung, Start, TelegramTest, UpdateDetails, ZielAktion,
ZielStand, Zeitraum,
} from "./typen"
@@ -190,17 +190,23 @@ function useAktion<V, E extends AktionsErgebnis | Record<string, unknown>>(
})
}
export const useHinweisAktion = () =>
/** Knöpfe eines Hinweises. basis: „/api/hinweise“ (KI-Box) oder „/api/homelab/hinweise“ (seit 25.09.2026). Die
* Knöpfe für den Zustand (gesehen, stumm, laut) melden sich mit dem Satz des Wächters. */
export const useHinweisAktion = (basis = "/api/hinweise") =>
useAktion(
({ hinweis, aktion }: { hinweis: string; aktion: string; label: string }) =>
post<AktionsErgebnis>(`/api/hinweise/${encodeURIComponent(hinweis)}/aktion/${aktion}`),
post<AktionsErgebnis>(`${basis}/${encodeURIComponent(hinweis)}/aktion/${aktion}`),
{
erfolg: (v, e) =>
v.aktion === "freigeben" && e.text ? e.text : `${v.label}: erledigt. Der Wächter prüft in einer Minute nach.`,
neuLaden: ["start"],
(v.aktion === "freigeben" || ZUSTAND_KNOEPFE.has(v.aktion)) && e.text
? e.text
: `${v.label}: erledigt. Der Wächter prüft in einer Minute nach.`,
neuLaden: basis === "/api/hinweise" ? ["start"] : ["homelab-hinweise"],
},
)
const ZUSTAND_KNOEPFE = new Set(["gesehen", "stumm", "laut"])
const UPDATE_PFADE: Record<string, string> = {
alle: "/api/maintenance/update-all",
pruefen: "/api/maintenance/check-updates",
@@ -473,10 +479,13 @@ export const useSammellaufQuittieren = () =>
neuLaden: ["homelab-sammellauf"],
})
export const useHomelabProtokoll = (aktiv: boolean, berichte: boolean) =>
/** Das Protokoll des Homelab-Teils; mit ziel nur ein Gerät (Zeitleiste je Gerät, seit 25.09.2026). */
export const useHomelabProtokoll = (aktiv: boolean, berichte: boolean, ziel: string | null = null) =>
useQuery({
queryKey: ["homelab-protokoll", berichte],
queryFn: () => api<{ eintraege: ProtokollEintrag[] }>(`/api/homelab/protokoll?grenze=300&berichte=${berichte ? 1 : 0}`),
queryKey: ["homelab-protokoll", berichte, ziel],
queryFn: () => api<{ eintraege: ProtokollEintrag[] }>(
`/api/homelab/protokoll?grenze=300&berichte=${berichte ? 1 : 0}${ziel ? `&ziel=${encodeURIComponent(ziel)}` : ""}`,
),
enabled: aktiv,
refetchInterval: takt(30_000),
retry: false,
@@ -492,6 +501,43 @@ export const useHomelabSpeicher = (aktiv: boolean) =>
retry: false,
})
/** „Was ist neu?“ (seit 25.09.2026): Release Notes und Lucys Kurzfassung. Ändert sich nur mit der Version. */
export const useNeuerungen = (zid: string, baustein: string, aktiv: boolean) =>
useQuery({
queryKey: ["homelab-neuerungen", zid, baustein],
queryFn: () => api<Neuerungen>(`/api/homelab/ziele/${zid}/neuerungen?baustein=${encodeURIComponent(baustein)}`),
enabled: aktiv,
staleTime: 10 * 60_000,
retry: false,
})
/** „Neu entdeckt“ (seit 25.09.2026): Geräte, die der Orchestrator noch nicht kennt. */
export const useNeuEntdeckt = (aktiv: boolean) =>
useQuery({
queryKey: ["homelab-neu"],
queryFn: () => api<{ geraete: NeuesGeraet[]; docker?: NeuerStack[] }>("/api/homelab/neu"),
enabled: aktiv,
refetchInterval: takt(60_000),
retry: false,
})
/** Die Docker-Dienste unter Arcane je Stack (seit 25.09.2026). Arcane wird höchstens alle zwei Minuten gefragt. */
export const useHomelabDocker = (aktiv: boolean) =>
useQuery({
queryKey: ["homelab-docker"],
queryFn: () => api<DockerLage>("/api/homelab/docker"),
enabled: aktiv,
refetchInterval: takt(60_000),
retry: false,
})
/** Übernehmen: freigeben (Etikett watcher), nur beobachten oder nicht anfassen (watcher-aus). */
export const useNeuUebernehmen = () =>
useAktion(({ id, wahl }: { id: string; wahl: NeuWahl }) => post<AktionsErgebnis>(`/api/homelab/neu/${id}`, { wahl }), {
erfolg: (_, e) => (e as AktionsErgebnis).text ?? "Übernommen.",
neuLaden: ["homelab-neu", "homelab"],
})
/** Die https-Zertifikate (Zertifikats-Wache, seit 25.09.2026) — der Ausführer liest sie alle 30 Minuten. */
export const useHomelabZertifikate = (aktiv: boolean) =>
useQuery({
+37
View File
@@ -0,0 +1,37 @@
import { befehle, suchen, zerlegen } from "@/lib/befehle"
import type { ZielStand } from "@/lib/typen"
const ziel = (id: string, name: string, art: string, offen = 0): ZielStand => ({
id, name, art, instanz: "homelab", erreichbar: true, bausteine: [], geprueft: null, rueckweg: null, zaehlung: {}, offen,
})
const ZIELE = [
ziel("pve", "Proxmox-Host", "proxmox-host", 2),
ziel("ct-100", "AdGuard Home", "container"),
ziel("ct-104", "Gitea", "container", 1),
ziel("vm-106", "Arcane (Docker)", "vm", 1),
]
describe("Befehlssuche", () => {
const alle = befehle(ZIELE)
it("findet Gerät und Aktion zusammen", () => {
const [erster] = suchen(alle, "AdGuard Snapshot")
expect(erster).toMatchObject({ text: "AdGuard Home · Snapshots", to: "/homelab/snapshots" })
expect(suchen(alle, "gitea zeit")[0]).toMatchObject({ to: "/homelab/protokoll", hash: "ct-104" })
})
it("filtert mit typ: und update:", () => {
expect(zerlegen("typ:lxc update:ja gitea")).toEqual({ typ: "container", update: true, woerter: ["gitea"] })
const treffer = suchen(alle, "typ:lxc update:ja", 50)
expect(new Set(treffer.map((b) => b.text.split(" · ")[0]))).toEqual(new Set(["Gitea"]))
expect(suchen(alle, "typ:vm update", 50).every((b) => b.text.startsWith("Arcane"))).toBe(true)
expect(suchen(alle, "typ:host update:ja", 50).map((b) => b.to)).toContain("/homelab/updates")
})
it("findet Seiten, wer mit dem Wort beginnt steht oben", () => {
const treffer = suchen(alle, "updates")
expect(treffer[0]).toMatchObject({ text: "Updates" })
expect(treffer.filter((b) => b.text === "Updates").map((b) => b.unter)).toEqual(["KI-Box", "Homelab"])
expect(suchen(alle, "gibtesnicht")).toEqual([])
})
})
+75
View File
@@ -0,0 +1,75 @@
import { SEITEN, ORT_NAME, type SeitenPfad } from "@/lib/navigation"
import type { ZielStand } from "@/lib/typen"
// Befehlssuche mit Strg+K (Ausbauplan Welle 2, Punkt 13, seit 25.09.2026): was sich finden lässt und wie die
// Eingabe darauf passt. Befehle führen nur hin — ausgelöst wird an Ort und Stelle, mit der dortigen Rückfrage.
export interface Befehl {
id: string
text: string
unter: string
to: SeitenPfad
hash?: string
/** Für die Filter typ: und update:. */
geraet?: { art: string; offen: number }
/** Wörter, die zusätzlich passen (etwa „lxc“ bei Containern). */
stichworte?: string
}
const ART_WORT: Record<string, string> = { "proxmox-host": "host proxmox", container: "lxc container ct", vm: "vm qemu" }
const TYP: Record<string, string> = { lxc: "container", ct: "container", container: "container", vm: "vm", qemu: "vm",
host: "proxmox-host", pve: "proxmox-host" }
/** Alle Befehle: die Seiten, dazu je Gerät des Homelabs Cockpit, Zeitleiste, Monitoring, Snapshots und Updates. */
export function befehle(ziele: ZielStand[]): Befehl[] {
const liste: Befehl[] = SEITEN.map((s) => ({
id: `seite:${s.to}`, text: s.label, unter: ORT_NAME[s.ort], to: s.to,
stichworte: s.to.replace(/\//g, " "),
}))
for (const z of ziele) {
const geraet = { art: z.art, offen: z.offen }
const stichworte = `${ART_WORT[z.art] ?? ""} ${z.id}`
const basis = { geraet, stichworte }
liste.push(
{ ...basis, id: `${z.id}:cockpit`, text: z.name, unter: "Gerät im Homelab-Cockpit", to: "/homelab", hash: `geraet-${z.id}` },
{ ...basis, id: `${z.id}:zeitleiste`, text: `${z.name} · Zeitleiste`, unter: "Was an diesem Gerät geschah", to: "/homelab/protokoll", hash: z.id },
{ ...basis, id: `${z.id}:monitoring`, text: `${z.name} · Monitoring`, unter: "CPU, Speicher, Platte, Netz", to: "/homelab/monitoring", hash: z.id },
)
if (z.art !== "proxmox-host") {
liste.push({ ...basis, id: `${z.id}:snapshot`, text: `${z.name} · Snapshots`, unter: "Snapshot anlegen, Rückweg testen", to: "/homelab/snapshots" })
}
if (z.offen > 0) {
liste.push({ ...basis, id: `${z.id}:update`, text: `${z.name} · Update`, unter: `${z.offen} ${z.offen === 1 ? "Update steht" : "Updates stehen"} an`, to: "/homelab/updates" })
}
}
return liste
}
/** Die Eingabe zerlegen: Filter (typ:lxc, update:ja) und die übrigen Wörter. */
export function zerlegen(eingabe: string): { typ: string | null; update: boolean | null; woerter: string[] } {
let typ: string | null = null
let update: boolean | null = null
const woerter: string[] = []
for (const roh of eingabe.toLowerCase().split(/\s+/).filter(Boolean)) {
const [schluessel, wert] = roh.split(":", 2)
if (wert !== undefined && schluessel === "typ") typ = TYP[wert] ?? wert
else if (wert !== undefined && schluessel === "update") update = ["ja", "j", "1", "true"].includes(wert)
else woerter.push(roh)
}
return { typ, update, woerter }
}
/** Die passenden Befehle, die besten zuerst: Jedes Wort muss vorkommen; wer mit dem ersten Wort beginnt, steht oben. */
export function suchen(alle: Befehl[], eingabe: string, grenze = 12): Befehl[] {
const { typ, update, woerter } = zerlegen(eingabe)
const gefiltert = alle.filter((b) => {
if ((typ || update !== null) && !b.geraet) return false
if (typ && b.geraet?.art !== typ) return false
if (update !== null && (b.geraet!.offen > 0) !== update) return false
const heu = `${b.text} ${b.unter} ${b.stichworte ?? ""}`.toLowerCase()
return woerter.every((w) => heu.includes(w))
})
const erstes = woerter[0]
const rang = (b: Befehl) => (erstes && b.text.toLowerCase().startsWith(erstes) ? 0 : 1) + (b.geraet ? 0.5 : 0)
return [...gefiltert].sort((a, b) => rang(a) - rang(b)).slice(0, grenze)
}
+4 -1
View File
@@ -1,5 +1,5 @@
import {
AudioLines, Bird, Bot, Box, BrainCircuit, Clapperboard, Code, Container, DatabaseBackup, FileCode2, GitBranch, HardDrive,
AudioLines, Bird, Bot, Box, BrainCircuit, Clapperboard, Code, Container, Database, DatabaseBackup, FileCode2, GitBranch, HardDrive,
House, Layers, LayoutDashboard, type LucideIcon, Mic, Monitor, RefreshCw, Route, Server, ShieldCheck, Shuffle, Workflow,
Zap,
} from "lucide-react"
@@ -45,6 +45,9 @@ const NACH_NAME: [RegExp, LucideIcon, string?][] = [
[/home ?assistant/i, House],
[/jellyfin|plex|emby/i, Clapperboard],
[/\bnas\b|qnap|truenas|synology/i, HardDrive],
// Datenbanken in Docker-Stacks (seit 25.09.2026): der Dienst heißt „postgres“, das Logo „postgresql“.
[/postgres/i, Database, "postgresql"],
[/mariadb|mysql|mongo|redis|valkey/i, Database],
]
/** „Arcane (Docker)“ → „arcane“, „AdGuard Home“ → „adguard-home“. */
+10 -1
View File
@@ -1,4 +1,4 @@
import { kennzahlText, lage, lampeFuer, listenAlter, offeneUpdates, rueckwegText } from "@/lib/homelab"
import { kennzahlText, lage, lampeFuer, listenAlter, offeneUpdates, rueckwegText, sicherheitText } from "@/lib/homelab"
import type { BausteinStand, Hinweis, HomelabStand, ZielStand } from "@/lib/typen"
const baustein = (b: Partial<BausteinStand> & Pick<BausteinStand, "id" | "zustand">): BausteinStand => ({
@@ -110,3 +110,12 @@ describe("kennzahlText (seit 25.09.2026)", () => {
expect(kennzahlText(undefined)).toBeNull()
})
})
describe("Sicherheits-Markierung (seit 25.09.2026)", () => {
it("hängt die Zahl der Sicherheitsupdates an", () => {
const pakete = baustein({ id: "os", zustand: "neu", kurz: "12 Pakete", sicherheit: 3 })
expect(sicherheitText(pakete)).toBe(" · 3 Sicherheit")
expect(lampeFuer(ziel({ id: "ct-104", name: "Gitea", bausteine: [pakete] }), false).wert).toBe("12 Pakete · 3 Sicherheit")
expect(sicherheitText(baustein({ id: "os", zustand: "neu", kurz: "2 Pakete", sicherheit: 0 }))).toBe("")
})
})
+9 -2
View File
@@ -95,7 +95,12 @@ export const versionText = (v: string) => v.replace(/^Image vom /, "")
/** Was die Versionsspalte „Neu“ zeigt: die neue Version einer App, sonst „58 Pakete“. */
export function neuText(b: BausteinStand): string {
if (istApp(b) && b.verfuegbar) return b.verfuegbar
return b.kurz || b.verfuegbar || "neu"
return `${b.kurz || b.verfuegbar || "neu"}${sicherheitText(b)}`
}
/** „ · 3 Sicherheit“ — wenn Updates eine Sicherheitslücke schließen (seit 25.09.2026), sonst leer. */
export function sicherheitText(b: BausteinStand): string {
return b.sicherheit ? ` · ${b.sicherheit} Sicherheit` : ""
}
/** Anteil der belegten Platte (0…1) oder null, wo unbekannt. Ab 80 % gelb, ab 90 % rot — wie der Wächter (ext4 hält 5 % zurück). */
@@ -137,7 +142,9 @@ export interface Lage {
const hinweisZahl = (n: number) => `${n} Hinweis${n === 1 ? "" : "e"}`
/** Die große Leuchte oben links — Störung vor Hinweis vor laufendem Update vor offenen Updates. */
export function lage(stand: HomelabStand, hinweise: Hinweis[], laufen: number, berichtZeit: string | null): Lage {
export function lage(stand: HomelabStand, alleHinweise: Hinweis[], laufen: number, berichtZeit: string | null): Lage {
// Stumm geschaltete Hinweise (seit 25.09.2026) stehen in der Liste, lösen die Leuchte aber nicht aus.
const hinweise = alleHinweise.filter((h) => h.zustand !== "stumm")
const stumm = stand.ziele.filter((z) => z.erreichbar === false)
if (stumm.length > 0) {
const mitte = stumm.length === 1 ? "1 Gerät stumm" : `${stumm.length} Geräte stumm`
+76
View File
@@ -22,6 +22,9 @@ export interface Hinweis {
zuletzt: number
takte?: number
aktionen: HinweisAktion[]
/** Seit 25.09.2026: neu, gesehen oder stumm (bis stumm_bis, Unix-Sekunden). */
zustand?: "neu" | "gesehen" | "stumm"
stumm_bis?: number
}
export interface VerlaufEintrag {
@@ -303,6 +306,8 @@ export interface BausteinStand {
kurz: string
grund: string | null
aktion: ZielAktion | null
/** Davon Sicherheitsupdates (seit 25.09.2026; null/fehlt = unbekannt). */
sicherheit?: number | null
}
/** Ein Gerät (KI-Box, Proxmox-Host, Container, VM) mit seinen Bausteinen. */
@@ -633,6 +638,77 @@ export interface HomelabSpeicher {
empfehlungen: SpeicherEmpfehlung[]
}
// --- Was ist neu? (GET /api/homelab/ziele/{id}/neuerungen, seit 25.09.2026) --------------------------------------
export interface Neuerungen {
verfuegbar: boolean
grund?: string | null
von?: string
nach?: string
versionen?: { version: string; titel: string; datum: string | null; url: string | null; text: string }[]
pakete?: { name: string; text: string }[]
weitere?: number
quelle_url?: string
/** Lucys Kurzfassung; aktion = ob der User selbst etwas tun muss. */
zusammenfassung?: { aktion: boolean | null; aktion_text: string; punkte: string[] } | null
}
// --- Neu entdeckt (GET /api/homelab/neu, seit 25.09.2026) ------------------------------------------------------
export type NeuWahl = "freigeben" | "beobachten" | "ausschliessen"
export interface NeuesGeraet {
id: string
vmid: number
/** Name der App, sonst der Name in Proxmox. */
name: string
hostname: string | null
art: "container" | "vm"
status: string | null
erstmals: number | null
etiketten: string[]
/** Darf der Orchestrator schon (Etikett community-script oder watcher)? */
freigegeben: boolean
ausgeschlossen: boolean
community_script: boolean
app: { kennung: string | null; name: string; quelle: string | null; weg: string; adresse: string | null } | null
ip: string | null
ohne_sicherung: boolean
}
/** Ein neuer Docker-Stack unter Arcane (seit 25.09.2026). */
export interface NeuerStack {
id: string
name: string
erstmals: number | null
container: string[]
}
// --- Docker-Dienste unter Arcane (GET /api/homelab/docker, seit 25.09.2026) --------------------------------------
export interface DockerContainer {
id: string
name: string
stack: string | null
dienst: string | null
image: string | null
zustand: string
status: string | null
/** Healthcheck: true gesund, false ungesund, null ohne Healthcheck. */
gesund: boolean | null
update: "neu" | "lokal" | "aktuell" | "unbekannt"
aktuell: string | null
neu: string | null
/** Arcanes Lücken-Scan des Images (seit 25.09.2026); null = noch nicht gescannt. */
luecken?: { kritisch: number; hoch: number; mittel: number; gesamt: number; zeit: string } | null
}
export interface DockerLage {
verfuegbar: boolean
grund: string | null
stacks: { name: string; container: DockerContainer[] }[]
}
// --- Zertifikate des Homelabs (GET /api/homelab/zertifikate, seit 25.09.2026) -----------------------------------
export interface Zertifikat {
+2 -2
View File
@@ -1,4 +1,4 @@
import { systemBaustein, appBaustein } from "@/lib/homelab"
import { appBaustein, sicherheitText, systemBaustein } from "@/lib/homelab"
import type { BausteinStand, ZielStand } from "@/lib/typen"
// Die Kurzform für die Übersicht: wer hat wo welche Updates — in einer Zeile je Gerät.
@@ -32,7 +32,7 @@ export function kurzform(ziel: ZielStand): KurzZeile | null {
teile.push(`App → ${app.verfuegbar ?? "neu"}${w ? ` (${w})` : ""}`)
}
const sys = systemBaustein(ziel)
if (sys?.zustand === "neu") teile.push(sys.kurz || (sys.id === "docker" ? "Images" : "Pakete"))
if (sys?.zustand === "neu") teile.push(`${sys.kurz || (sys.id === "docker" ? "Images" : "Pakete")}${sicherheitText(sys)}`)
// KI-Box: Bausteine heißen selbst (Betriebssystem, Hermes, Motor …).
for (const b of neu) {
if (b === app || b === sys) continue
+25 -14
View File
@@ -1,16 +1,21 @@
import { Link, useNavigate } from "@tanstack/react-router"
import { ArrowRight } from "lucide-react"
import { Checkliste } from "@/components/cockpit/Checkliste"
import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
import { Panel } from "@/components/cockpit/Panel"
import { DockerDienste } from "@/components/homelab/DockerDienste"
import { Gesundheit } from "@/components/homelab/Gesundheit"
import { Lagebild } from "@/components/homelab/Lagebild"
import { NeuEntdeckt } from "@/components/homelab/NeuEntdeckt"
import { Speicher } from "@/components/homelab/Speicher"
import { AlleKnopf, SammellaufFortschritt } from "@/components/homelab/Sammellauf"
import { UpdateTabelle } from "@/components/homelab/UpdateTabelle"
import { Zertifikate } from "@/components/homelab/Zertifikate"
import {
useHomelab, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useHomelabSpeicher, useHomelabZertifikate,
useInstanz, usePartner, useSammellauf, useSammellaufQuittieren, useSicherungsProbe, useZielAktion,
useHomelab, useHomelabDocker, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useHomelabSpeicher,
useHomelabZertifikate,
useInstanz, useNeuEntdeckt, useNeuUebernehmen, usePartner, useSammellauf, useSammellaufQuittieren, useSicherungsProbe,
useZielAktion,
} from "@/lib/abfragen"
import { lage, offeneUpdates } from "@/lib/homelab"
import { istSnapshotLauf } from "@/lib/snapshots"
@@ -81,20 +86,15 @@ function HomelabFehlt({ partner }: { partner: PartnerStatus }) {
}
/** Hinweise des Homelab-Wächters (die der KI-Box stehen in deren Cockpit). */
/** Die Hinweise des Homelab-Wächters — seit 25.09.2026 dieselbe Liste wie bei der KI-Box, mit Knöpfen (auch
* „Gesehen“ und „24 h stumm“), die an den Homelab-Teil gehen. */
function Hinweise({ hinweise }: { hinweise: Hinweis[] }) {
const rot = hinweise.some((h) => h.stufe === "rot")
const laut = hinweise.filter((h) => h.zustand !== "stumm")
const rot = laut.some((h) => h.stufe === "rot")
return (
<Panel titel="Hinweise" id="hinweise" rechts={<Chip art={rot ? "rot" : "bernstein"}>{hinweise.length} offen</Chip>}>
<ul className="m-0 flex list-none flex-col p-0">
{hinweise.map((h) => (
<li key={h.id} className="flex flex-col gap-1 border-t border-white/[0.06] py-3 first:border-t-0 first:pt-0">
<span className={h.stufe === "rot" ? "text-[15px] font-medium text-rot-text" : "text-[15px] font-medium text-bernstein"}>
{h.titel}
</span>
<span className="text-sm text-text-2">{h.text}</span>
</li>
))}
</ul>
<Panel titel="Hinweise" id="hinweise"
rechts={<Chip art={rot ? "rot" : laut.length ? "bernstein" : "grau"}>{laut.length ? `${laut.length} offen` : "alle stumm"}</Chip>}>
<Checkliste hinweise={hinweise} verlauf={[]} basis="/api/homelab/hinweise" />
</Panel>
)
}
@@ -199,6 +199,9 @@ export function HomelabSeite() {
const speicher = useHomelabSpeicher(!!stand)
const zertifikate = useHomelabZertifikate(!!stand)
const probe = useSicherungsProbe()
const neu = useNeuEntdeckt(!!stand)
const docker = useHomelabDocker(!!stand)
const uebernehmen = useNeuUebernehmen()
if (ersatz || !stand) return ersatz
const berichtZeit = stand.bericht ? zeitAus(stand.bericht.empfangen) : null
@@ -222,6 +225,9 @@ export function HomelabSeite() {
/>
<AusfuehrerHinweis stand={stand} />
{hinweise.length > 0 && <Hinweise hinweise={hinweise} />}
<NeuEntdeckt geraete={neu.data?.geraete ?? []} stacks={neu.data?.docker ?? []}
gesperrt={uebernehmen.isPending || !stand.ausfuehrer.verbunden}
onWahl={(id, wahl) => uebernehmen.mutate({ id, wahl })} />
<Panel
titel="Geräte"
id="geraete"
@@ -239,6 +245,11 @@ export function HomelabSeite() {
<p className="text-xs text-text-3">CPU und Speicher erscheinen, sobald der Ausführer Messwerte schickt.</p>
)}
</Panel>
{docker.data?.verfuegbar && docker.data.stacks.length > 0 && (
<Panel titel="Docker-Dienste" id="docker">
<DockerDienste lage={docker.data} />
</Panel>
)}
{speicher.data?.bericht && (
<Panel titel="Speicher und Sicherungen" id="speicher">
<Speicher
+28 -9
View File
@@ -1,4 +1,5 @@
import { useState } from "react"
import { useNavigate, useRouterState } from "@tanstack/react-router"
import { Bell, ChevronRight, Cog, History, RefreshCw, Search, TriangleAlert, type LucideIcon } from "lucide-react"
import { cn } from "cn"
import { Zustandsfeld } from "@/components/cockpit/Chip"
@@ -7,7 +8,9 @@ import { useHomelab, useHomelabProtokoll, useInstanz, usePartner } from "@/lib/a
import type { ProtokollEintrag } from "@/lib/typen"
// Was der Homelab-Teil getan hat: Aufträge an den Ausführer (Snapshot, Update, Suchen …), Update-Läufe,
// Hinweise des Wächters, gesendete Meldungen und die nächtliche Pflege — neueste zuerst, nach Tagen.
// Hinweise des Wächters, gesendete Meldungen und die nächtliche Pflege — neueste zuerst, nach Tagen. Seit 25.09.2026
// (Ausbauplan Welle 2, Punkt 12) auch je Gerät: /homelab/protokoll#ct-104 ist die Zeitleiste von Gitea — Updates
// von → nach, Snapshots, Rückwege, Neustarts und Hinweise auf einer Linie.
type Art = ProtokollEintrag["art"]
@@ -55,8 +58,10 @@ function Eintrag({ e, geraet }: { e: ProtokollEintrag; geraet: string | null })
return (
<li className="grid grid-cols-[64px_18px_minmax(0,1fr)] gap-x-3 border-t border-white/[0.06] py-2.5 first:border-t-0">
<span className="ziffern pt-0.5 text-xs text-text-3">{uhr.format(new Date(e.zeit * 1000))}</span>
<span className="flex flex-col items-center pt-1">
<span aria-hidden className={cn("size-2.5 rounded-full", s.punkt)} />
{/* Die Linie verbindet die Punkte eines Tages zur Zeitleiste. */}
<span className="relative flex flex-col items-center pt-1">
<span aria-hidden className="absolute -top-2.5 -bottom-2.5 w-px bg-white/10" />
<span aria-hidden className={cn("relative size-2.5 rounded-full ring-4 ring-[var(--panel)]", s.punkt)} />
</span>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="flex flex-wrap items-center gap-x-2 text-[15px]">
@@ -91,8 +96,12 @@ export function HomelabProtokollSeite() {
const aktiv = instanz.data?.rolle === "homelab" || !!(partner.data?.eingerichtet && partner.data.erreichbar)
const [art, setArt] = useState<Art | "alle">("alle")
const [berichte, setBerichte] = useState(false)
const protokoll = useHomelabProtokoll(aktiv, berichte)
const namen = new Map((useHomelab(aktiv).data?.ziele ?? []).map((z) => [z.id, z.name]))
const navigate = useNavigate()
const hash = useRouterState({ select: (s) => s.location.hash })
const ziele = useHomelab(aktiv).data?.ziele ?? []
const namen = new Map(ziele.map((z) => [z.id, z.name]))
const geraet = hash && /^(pve|(ct|vm)-\d+)$/.test(hash) ? hash : null
const protokoll = useHomelabProtokoll(aktiv, berichte, geraet)
if (instanz.isPending || partner.isPending) return <Zustandsfeld text="Wird gelesen …" />
if (!aktiv) return <Zustandsfeld fehler text="Der Homelab-Teil antwortet gerade nicht." />
@@ -108,11 +117,21 @@ export function HomelabProtokollSeite() {
}
return (
<Panel titel="Protokoll">
<Panel titel={geraet ? `Zeitleiste · ${namen.get(geraet) ?? geraet}` : "Protokoll"}>
<p className="-mt-2 text-[15px] text-text-2">
Was der Homelab-Teil getan hat: Aufträge an den Ausführer auf dem Proxmox-Host, Update-Läufe, Snapshots,
Hinweise, gesendete Meldungen und die nächtliche Paketsuche.
{geraet
? "Alles, was an diesem Gerät geschah: Updates von → nach, Snapshots, Rückwege, Neustarts und Hinweise."
: "Was der Homelab-Teil getan hat: Aufträge an den Ausführer auf dem Proxmox-Host, Update-Läufe, Snapshots, " +
"Hinweise, gesendete Meldungen und die nächtliche Paketsuche."}
</p>
<label className="flex max-w-sm flex-col gap-1 text-sm text-text-2">
Gerät
<select value={geraet ?? ""} onChange={(ev) => void navigate({ to: "/homelab/protokoll", hash: ev.target.value || undefined })}
className="h-10 rounded-xl border border-white/12 bg-white/[0.04] px-3 text-[15px] text-foreground outline-none focus:border-lab/60">
<option value="">Alle Geräte</option>
{ziele.map((z) => <option key={z.id} value={z.id}>{z.name}</option>)}
</select>
</label>
<div className="flex flex-wrap items-center gap-2" role="group" aria-label="Filter">
{ARTEN.map((a) => (
<button key={a.id} type="button" onClick={() => setArt(a.id)} aria-pressed={art === a.id}
@@ -142,7 +161,7 @@ export function HomelabProtokollSeite() {
<ol className="m-0 flex list-none flex-col p-0">
{t.eintraege.map((e, i) => {
// Das Gerät als Schild — außer, der Titel nennt es schon.
const name = e.ziel ? namen.get(e.ziel) ?? e.ziel : null
const name = e.ziel && !geraet ? namen.get(e.ziel) ?? e.ziel : null
return <Eintrag key={`${e.zeit}-${e.titel}-${i}`} e={e} geraet={name && !e.titel.includes(name) ? name : null} />
})}
</ol>
+4 -1
View File
@@ -230,7 +230,9 @@ function Bereichskarte({ bereich, verbindung, unterzeile, i, children }: {
}
/** Hinweise stehen in der Karte ganz oben — was Aufmerksamkeit braucht, zuerst. */
function Hinweisliste({ hinweise, ziel }: { hinweise: Hinweis[]; ziel: "/box" | "/homelab" }) {
function Hinweisliste({ hinweise: alle, ziel }: { hinweise: Hinweis[]; ziel: "/box" | "/homelab" }) {
// Stumm geschaltete (seit 25.09.2026) stehen im Cockpit, nicht auf der Übersicht.
const hinweise = alle.filter((h) => h.zustand !== "stumm")
if (hinweise.length === 0) return null
const sortiert = [...hinweise].sort((a, b) => Number(b.stufe === "rot") - Number(a.stufe === "rot"))
return (
@@ -589,6 +591,7 @@ export function UebersichtSeite() {
const geraete = homelab.data?.ziele ?? []
const stumm = geraete.filter((z) => z.erreichbar === false).map((z) => z.name)
const alleHinweise = [...(start.data?.hinweise ?? []), ...(labHinweise.data?.hinweise ?? [])]
.filter((h) => h.zustand !== "stumm")
const rot = alleHinweise.some((h) => h.stufe === "rot")
const updates = stand.box.updates + stand.homelab.updates
const lage = gesamtlage({