radar: „Jetzt testen“ – Kandidaten auf Knopfdruck prüfen, wenn der Speicher reicht

User 25.09.: „Warum kann ich Modell-Tests nicht selbst anstoßen, sondern muss
immer auf die Box warten?“ Nachts testet das Radar weiter selbst. Tagsüber jetzt
auch per Knopf – aber nur, wenn Kandidat, Warm-Set UND der heutige Coder
zusammen unter die 115-GB-Grenze passen (der Test-Wächter stoppt den Kandidaten
erst bis zu 10 s, nachdem der Coder zu laden beginnt), und nicht 00:00–03:30.
Sonst steht der Grund beim Kandidaten. Test als Auftrag (radar_lauf.py --test),
höchstens 2 h, dieselben Leitplanken wie nachts; jedes Ergebnis kommt als Meldung.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-25 13:16:39 +02:00
co-authored by Claude Opus 5.5
parent c31ef827fc
commit 44f33c31d3
31 changed files with 278 additions and 36 deletions
+43 -1
View File
@@ -135,5 +135,47 @@ def nur_suche() -> int:
return 0 if any(quellen.values()) else 1 return 0 if any(quellen.values()) else 1
def melde_ergebnis(test: dict) -> None:
"""Nach „Jetzt testen“ kommt jedes Ergebnis als Meldung — der Commander hat den Test selbst angestoßen."""
if test.get("ergebnis") == "bestanden":
melde_bestanden(test)
return
k = radar.lade_stand()["kandidaten"].get(test.get("kandidat")) or {}
text = f"Test von {k.get('name') or test.get('kandidat')}: {str(test.get('zusammenfassung') or test.get('ergebnis'))[:400]}"
try:
subprocess.run(["bash", str(NOTIFY), "-s", "[Modell-Radar]", text], timeout=120, check=False,
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except (OSError, subprocess.SubprocessError):
log.warning("Radar: Meldung zum Test ging nicht raus.", exc_info=True)
def test_auf_knopfdruck(kid: str) -> int:
"""„Jetzt testen“ (seit 25.09.2026, services/radar.test_starten): ein Kandidat, jetzt, mit eigener Frist von
TAGTEST_H Stunden. Dieselben Leitplanken wie nachts: nur Warm-Set neben dem Kandidaten, Test-Wächter, Notbremse.
Ob der Kandidat tagsüber überhaupt darf (Speicher mit Coder, Sperrzeit), hat test_starten geprüft; hier noch
einmal, falls zwischen Knopf und Start Zeit verging."""
k = radar.lade_stand()["kandidaten"].get(kid)
testbar, grund = radar.jetzt_testbar(k or {})
if not k or not testbar:
print(f"Kein Test: {grund or 'den Kandidaten gibt es nicht mehr'}.", flush=True)
return 1
frist = time.time() + radar.TAGTEST_H * 3600
signal.signal(signal.SIGTERM, _beim_beenden)
bremse = _notbremse(frist)
try:
test = radar.teste(kid, frist, auf_knopfdruck=True)
if test:
print(f"{k.get('name')}: {test.get('ergebnis')} – {test.get('zusammenfassung')}", flush=True)
melde_ergebnis(test)
return 0
finally:
bremse.cancel()
if __name__ == "__main__": if __name__ == "__main__":
sys.exit(nur_suche() if "--nur-suche" in sys.argv[1:] else main()) argumente = sys.argv[1:]
if "--nur-suche" in argumente:
sys.exit(nur_suche())
if "--test" in argumente and argumente.index("--test") + 1 < len(argumente):
sys.exit(test_auf_knopfdruck(argumente[argumente.index("--test") + 1]))
sys.exit(main())
+9 -1
View File
@@ -5,8 +5,9 @@ Modell-Radar-Schnittstellen (Box-Wart, Umbau 09/2026).
POST /api/radar/suche Jetzt suchen (Merkliste + Hugging-Face-Entdeckung) — als Auftrag, antwortet sofort POST /api/radar/suche Jetzt suchen (Merkliste + Hugging-Face-Entdeckung) — als Auftrag, antwortet sofort
POST /api/radar/{id}/uebernehmen Bestandenen Kandidaten in Betrieb nehmen (Modell-Tausch) POST /api/radar/{id}/uebernehmen Bestandenen Kandidaten in Betrieb nehmen (Modell-Tausch)
POST /api/radar/{id}/verwerfen Kandidaten verwerfen (Dateien weg, nie wieder testen) POST /api/radar/{id}/verwerfen Kandidaten verwerfen (Dateien weg, nie wieder testen)
POST /api/radar/{id}/testen Jetzt testen (seit 25.09.2026; nur wenn er samt Coder in den Speicher passt)
Dünn: die Logik liegt in services/radar.py. Getestet wird nur nachts (backend/radar_lauf.py). Dünn: die Logik liegt in services/radar.py. Getestet wird nachts (backend/radar_lauf.py) oder auf Knopfdruck.
""" """
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
@@ -38,6 +39,13 @@ def radar_uebernehmen(kandidat_id: str) -> dict:
return _antwort(radar.uebernehmen(kandidat_id)) return _antwort(radar.uebernehmen(kandidat_id))
@router.post("/radar/{kandidat_id}/testen")
def radar_testen(kandidat_id: str) -> dict:
"""„Jetzt testen“ (seit 25.09.2026): nur, wenn der Kandidat samt Coder neben das Warm-Set passt und nicht in
der Nacht-Sperre — sonst 409 mit Grund. Antwortet sofort mit der Auftrags-ID."""
return _antwort(radar.test_starten(kandidat_id))
@router.post("/radar/{kandidat_id}/verwerfen") @router.post("/radar/{kandidat_id}/verwerfen")
def radar_verwerfen(kandidat_id: str) -> dict: def radar_verwerfen(kandidat_id: str) -> dict:
return _antwort(radar.verwerfen(kandidat_id)) return _antwort(radar.verwerfen(kandidat_id))
+64 -6
View File
@@ -86,6 +86,10 @@ MIN_PARAMS_B = float(os.environ.get("MC_RADAR_MIN_PARAMS_B", "20"))
HIRN_MAX_AKTIV_B = float(os.environ.get("MC_RADAR_HIRN_MAX_AKTIV_B", "12")) # Verdikt: dichte Modelle nie als Hirn HIRN_MAX_AKTIV_B = float(os.environ.get("MC_RADAR_HIRN_MAX_AKTIV_B", "12")) # Verdikt: dichte Modelle nie als Hirn
ABGELEHNT_TAGE = 30 ABGELEHNT_TAGE = 30
TESTS_MAX = 100 TESTS_MAX = 100
# „Jetzt testen“ (User 25.09.2026: „Warum kann ich Modell-Tests nicht selbst anstoßen?“): so lange darf ein Test auf
# Knopfdruck dauern, und in dieser Zeit ist er gesperrt (Nachtlauf des Radars, um 03:00 NerdQuiz).
TAGTEST_H = float(os.environ.get("MC_RADAR_TAGTEST_H", "2"))
KNOPF_GESPERRT = (os.environ.get("MC_RADAR_KNOPF_SPERRE_VON", "00:00"), os.environ.get("MC_RADAR_KNOPF_SPERRE_BIS", "03:30"))
STORE_PATH = Path(os.environ.get("MC_RADAR_STORE", str(MODELS_DIR / "mc2-radar.json"))) STORE_PATH = Path(os.environ.get("MC_RADAR_STORE", str(MODELS_DIR / "mc2-radar.json")))
BASELINE_PATH = Path(os.environ.get("MC_RADAR_BASELINE", str(MODELS_DIR / "mc2-radar-baseline.json"))) BASELINE_PATH = Path(os.environ.get("MC_RADAR_BASELINE", str(MODELS_DIR / "mc2-radar-baseline.json")))
@@ -1191,9 +1195,11 @@ def _draft_text(wahl: dict) -> str:
f" (13k: {'; '.join(tempi)})." if len(tempi) > 1 else ".") f" (13k: {'; '.join(tempi)})." if len(tempi) > 1 else ".")
def teste(kid: str, frist: float) -> dict | None: def teste(kid: str, frist: float, auf_knopfdruck: bool = False) -> dict | None:
"""Einen Kandidaten prüfen: Download → Baseline → nur Warm-Set in llama-swap → Kandidat auf :5899 → """Einen Kandidaten prüfen: Download → Baseline → nur Warm-Set in llama-swap → Kandidat auf :5899 →
Urteil. Gibt den Test-Eintrag zurück (None, wenn nur gemessen wurde oder es den Kandidaten nicht gibt).""" Urteil. Gibt den Test-Eintrag zurück (None, wenn nur gemessen wurde oder es den Kandidaten nicht gibt).
auf_knopfdruck: gestartet mit „Jetzt testen“ (test_starten) statt im Nachtlauf — nur der Text zum Zeitende
unterscheidet sich; die Leitplanken (Warm-Set, Test-Wächter, Frist) sind dieselben."""
ps = _pruefstand() ps = _pruefstand()
k = _lade()["kandidaten"].get(kid) k = _lade()["kandidaten"].get(kid)
if not k or k.get("status") not in ("neu", "wartet"): # inzwischen verworfen o. Ä. if not k or k.get("status") not in ("neu", "wartet"): # inzwischen verworfen o. Ä.
@@ -1226,7 +1232,8 @@ def teste(kid: str, frist: float) -> dict | None:
werte["baseline"] = {"modell": (basis.get("werte") or {}).get("modell"), "gemessen": _iso(basis.get("zeit"))} werte["baseline"] = {"modell": (basis.get("werte") or {}).get("modell"), "gemessen": _iso(basis.get("zeit"))}
ergebnis, satz = lauf["ergebnis"], lauf["zusammenfassung"] + _draft_text(wahl) ergebnis, satz = lauf["ergebnis"], lauf["zusammenfassung"] + _draft_text(wahl)
except ps.Zeitende: except ps.Zeitende:
satz = f"Das Nachtfenster endete um {FENSTER_ENDE}, bevor der Test fertig war." satz = (f"Die {TAGTEST_H:g} Stunden für den Test auf Knopfdruck waren um, bevor er fertig war."
if auf_knopfdruck else f"Das Nachtfenster endete um {FENSTER_ENDE}, bevor der Test fertig war.")
except ps.ServerFehler as e: except ps.ServerFehler as e:
if "unknown model architecture" in str(e).lower(): if "unknown model architecture" in str(e).lower():
ergebnis, satz = "durchgefallen", f"Durchgefallen: läuft nicht im offiziellen llama.cpp ({str(e)[:160]})." ergebnis, satz = "durchgefallen", f"Durchgefallen: läuft nicht im offiziellen llama.cpp ({str(e)[:160]})."
@@ -1446,8 +1453,59 @@ def _tausche_ein(k: dict) -> dict:
return {"modell_id": modell_id, "pfad": str(ziel), "alt": (alt or {}).get("name"), "hinweis": hinweis} return {"modell_id": modell_id, "pfad": str(ziel), "alt": (alt or {}).get("name"), "hinweis": hinweis}
def _oeffentlich(k: dict) -> dict: def _oeffentlich(k: dict, zeit: datetime | None = None) -> dict:
return {f: k.get(f) for f in KANDIDAT_FELDER} testbar, grund = jetzt_testbar(k, zeit)
return {**{f: k.get(f) for f in KANDIDAT_FELDER}, "testbar_jetzt": testbar, "testbar_grund": grund}
def _in_knopf_sperre(zeit: datetime) -> bool:
von, bis = (uhrzeit(*(int(x) for x in t.split(":"))) for t in KNOPF_GESPERRT)
uhr = zeit.astimezone(LOCAL_TZ).time()
return von <= uhr < bis if von <= bis else (uhr >= von or uhr < bis)
def jetzt_testbar(k: dict, zeit: datetime | None = None) -> tuple[bool, str | None]:
"""Darf dieser Kandidat jetzt mit „Jetzt testen“ geprüft werden — und wenn nicht, warum?
Tagsüber kann jederzeit der Coder geladen werden (OpenChamber). Der Test-Wächter stoppt den Kandidaten dann zwar
(_Nachtwaechter), aber erst nach bis zu zehn Sekunden; so lange stünden Warm-Set, Kandidat UND Coder im Speicher.
Also nur, wenn alle drei zusammen unter die Speichergrenze passen. Größere testet das Radar nachts."""
if k.get("status") not in ("neu", "wartet"):
return False, None
bedarf = float(k.get("bedarf_gb") or 0)
frei = SPEICHER_GRENZE_GB - WARMSET_GB - CODER_HEUTE_GB
if not k.get("passt") or not bedarf:
return False, "Ob er in den Speicher passt, ist nicht bekannt — das Radar testet ihn nachts."
if bedarf > frei:
return False, (f"Zu groß für einen Test tagsüber: braucht ~{_gb(bedarf)} GB, neben Warm-Set und Coder sind "
f"{_gb(frei)} GB frei. Das Radar testet ihn nachts.")
if _in_knopf_sperre(zeit or jetzt()):
return False, (f"Zwischen {KNOPF_GESPERRT[0]} und {KNOPF_GESPERRT[1]} testet das Radar selbst, und um 03:00 "
"braucht NerdQuiz das Hirn — danach geht es auf Knopfdruck.")
return True, None
def test_starten(kid: str, zeit: datetime | None = None) -> dict:
"""„Jetzt testen“: den Test als Auftrag starten (Gruppe „radar“, höchstens einer zugleich; radar_lauf.py --test).
Antwortet sofort; den Stand zeigen Auftragskarte und Kandidat („Läuft gerade seit …“)."""
stand = _lade()
k = stand["kandidaten"].get(kid)
if not k:
return {"ok": False, "status": 404, "detail": "Diesen Kandidaten gibt es nicht."}
testbar, grund = jetzt_testbar(k, zeit)
if not testbar:
return {"ok": False, "status": 409, "detail": grund or f"{k.get('name')} lässt sich nicht testen "
f"(Status: {k.get('status')})."}
if _aktiver_lauf(stand):
return {"ok": False, "status": 409, "detail": "Es läuft schon ein Test des Radars."}
job_id, laeuft = jobengine.start_job_exklusiv(
"radar", [sys.executable, str(_LAUF_SKRIPT), "--test", kid], f"Modell-Radar: Test von {k.get('name')}",
zeitlimit_s=int(TAGTEST_H * 3600) + 900)
if job_id is None:
return {"ok": False, "status": 409,
"detail": f"Das Radar ist gerade beschäftigt: {(laeuft or {}).get('label') or 'ein Auftrag'}."}
return {"ok": True, "job_id": job_id,
"text": f"Der Test von {k.get('name')} läuft. Er dauert je nach Download etwa eine halbe bis zwei Stunden; "
"das Ergebnis kommt als Meldung."}
def uebernehmen(kid: str) -> dict: def uebernehmen(kid: str) -> dict:
@@ -1523,7 +1581,7 @@ def uebersicht(zeit: datetime | None = None) -> dict:
kandidaten = sorted(stand["kandidaten"].values(), kandidaten = sorted(stand["kandidaten"].values(),
key=lambda k: (_REIHENFOLGE.get(k.get("status"), 9), 0 if k.get("quelle") == "watchlist" else 1, key=lambda k: (_REIHENFOLGE.get(k.get("status"), 9), 0 if k.get("quelle") == "watchlist" else 1,
k.get("rang", 999))) k.get("rang", 999)))
oeffentlich = [_oeffentlich(k) for k in kandidaten] oeffentlich = [_oeffentlich(k, zeit) for k in kandidaten]
if (lauf := _aktiver_lauf(stand)): # den laufenden Test in seiner Begründung zeigen (nicht gespeichert) if (lauf := _aktiver_lauf(stand)): # den laufenden Test in seiner Begründung zeigen (nicht gespeichert)
for k in oeffentlich: for k in oeffentlich:
if k["id"] == lauf.get("kandidat"): if k["id"] == lauf.get("kandidat"):
+2 -1
View File
@@ -336,7 +336,8 @@ def test_uebersicht_hat_die_felder_der_oberflaeche(zustand):
radar._speichere(stand) radar._speichere(stand)
u = radar.uebersicht(_zeit("2026-10-01T12:00:00")) u = radar.uebersicht(_zeit("2026-10-01T12:00:00"))
assert set(u) == {"naechster_test", "letzter_test", "kandidaten", "tests"} assert set(u) == {"naechster_test", "letzter_test", "kandidaten", "tests"}
assert set(u["kandidaten"][0]) == set(radar.KANDIDAT_FELDER) # Dazu seit 25.09.2026, ob „Jetzt testen“ geht und warum nicht.
assert set(u["kandidaten"][0]) == set(radar.KANDIDAT_FELDER) | {"testbar_jetzt", "testbar_grund"}
assert [k["status"] for k in u["kandidaten"]] == ["wartet", "bestanden"] assert [k["status"] for k in u["kandidaten"]] == ["wartet", "bestanden"]
assert u["naechster_test"] == "2026-10-06T00:30:00+02:00" # die Woche ab dem 29.09. ist belegt assert u["naechster_test"] == "2026-10-06T00:30:00+02:00" # die Woche ab dem 29.09. ist belegt
assert u["letzter_test"].startswith("2026-09-29T01:40") assert u["letzter_test"].startswith("2026-09-29T01:40")
+52
View File
@@ -2,6 +2,7 @@
in radar_lauf.py --nur-suche. Vorher lief die Suche synchron in der Anfrage und konnte Minuten dauern.""" in radar_lauf.py --nur-suche. Vorher lief die Suche synchron in der Anfrage und konnte Minuten dauern."""
import sys import sys
from datetime import datetime
import pytest import pytest
import radar_lauf import radar_lauf
@@ -75,3 +76,54 @@ def test_nur_suche_bei_absturz(monkeypatch, capsys):
monkeypatch.setattr(radar_lauf.radar, "suche", kaputt) monkeypatch.setattr(radar_lauf.radar, "suche", kaputt)
assert radar_lauf.nur_suche() == 1 assert radar_lauf.nur_suche() == 1
assert capsys.readouterr().out.strip().endswith("Die Suche ist gescheitert: OSError: Platte voll") assert capsys.readouterr().out.strip().endswith("Die Suche ist gescheitert: OSError: Platte voll")
# --- „Jetzt testen“ (seit 25.09.2026) ------------------------------------------------------------------------
@pytest.fixture
def radarstand(monkeypatch, tmp_path):
monkeypatch.setattr(radar, "STORE_PATH", tmp_path / "mc2-radar.json")
monkeypatch.setattr(radar, "RADAR_DIR", tmp_path / "radar")
(tmp_path / "radar").mkdir()
stand = radar._leer()
# Zahlen vom 25.09.: Ornith (~27 GB mit KV-Cache) passt tagsüber, Flash-Next (84,6 GB) nur nachts.
stand["kandidaten"]["ornith"] = {"id": "ornith", "name": "Ornith-1.5-35B-A3B", "rolle": "hirn", "status": "neu",
"passt": True, "eng": False, "bedarf_gb": 27.4, "groesse_gb": 22.6}
stand["kandidaten"]["flash"] = {"id": "flash", "name": "Qwen3.8-Flash-Next", "rolle": "coder", "status": "wartet",
"passt": True, "eng": True, "bedarf_gb": 84.6, "groesse_gb": 82.9}
stand["kandidaten"]["alt"] = {"id": "alt", "name": "Alt", "rolle": "hirn", "status": "durchgefallen",
"passt": True, "bedarf_gb": 20.0}
radar._speichere(stand)
return stand
MITTAG = datetime(2026, 9, 25, 13, 0, tzinfo=radar.LOCAL_TZ)
NACHT = datetime(2026, 9, 26, 1, 15, tzinfo=radar.LOCAL_TZ)
def test_jetzt_testbar_nur_wenn_auch_der_coder_noch_passt(radarstand):
k = radarstand["kandidaten"]
assert radar.jetzt_testbar(k["ornith"], MITTAG) == (True, None)
testbar, grund = radar.jetzt_testbar(k["flash"], MITTAG)
assert not testbar and grund.startswith("Zu groß für einen Test tagsüber: braucht ~84,6 GB")
assert radar.jetzt_testbar(k["alt"], MITTAG) == (False, None) # schon beurteilt
testbar, grund = radar.jetzt_testbar(k["ornith"], NACHT)
assert not testbar and "testet das Radar selbst" in grund
oeffentlich = {x["id"]: x for x in radar.uebersicht(MITTAG)["kandidaten"]}
assert oeffentlich["ornith"]["testbar_jetzt"] is True and oeffentlich["flash"]["testbar_jetzt"] is False
def test_jetzt_testen_startet_einen_auftrag(radarstand, auftraege, monkeypatch):
monkeypatch.setattr(radar, "jetzt", lambda: MITTAG)
ergebnis = radar.test_starten("ornith")
assert ergebnis["ok"] and ergebnis["job_id"] == "job123"
start = auftraege["gestartet"][0]
assert start["gruppe"] == "radar" and start["args"][1:] == [str(radar._LAUF_SKRIPT), "--test", "ornith"]
assert start["label"] == "Modell-Radar: Test von Ornith-1.5-35B-A3B"
zu_gross = radar.test_starten("flash")
assert zu_gross["ok"] is False and zu_gross["status"] == 409 and "Zu groß" in zu_gross["detail"]
assert radar.test_starten("gibt-es-nicht")["status"] == 404
app = FastAPI()
app.include_router(radar_router.router)
antwort = TestClient(app).post("/api/radar/flash/testen")
assert antwort.status_code == 409 and "Zu groß" in antwort.json()["detail"]
+7
View File
@@ -21,6 +21,13 @@ Merkliste `deploy/radar-watchlist.json`. Zustand: `/srv/models/mc2-radar.json`,
- Zwei Rollen: Hirn (Alias `hermes`) und Coder (Alias `coder`), jede mit Bild-Zwilling (`vision` bzw. `coder-bild`). - Zwei Rollen: Hirn (Alias `hermes`) und Coder (Alias `coder`), jede mit Bild-Zwilling (`vision` bzw. `coder-bild`).
- Tests nur nachts 00:30–02:30, höchstens ein neuer Kandidat pro Woche. Um 03:00 braucht der NerdQuiz-Nachtlauf das - Tests nur nachts 00:30–02:30, höchstens ein neuer Kandidat pro Woche. Um 03:00 braucht der NerdQuiz-Nachtlauf das
Hirn. Hirn.
- **Jetzt testen** (seit 25.09.2026, User-Wunsch): Auf der Modelle-Seite hat jeder wartende Kandidat den Knopf, wenn er
samt heutigem Coder neben das Warm-Set passt (Bedarf + 29 GB Coder ≤ 88 GB, also ~59 GB) und es nicht zwischen 00:00
und 03:30 ist. Grund: Tagsüber kann jederzeit der Coder laden; der Test-Wächter stoppt den Kandidaten dann, aber
erst nach bis zu zehn Sekunden — so lange müssen alle drei in den Speicher passen. Sonst steht der Grund beim
Kandidaten, und er wartet auf die Nacht. Der Test läuft als Auftrag (`radar_lauf.py --test <id>`, Gruppe „radar“),
höchstens zwei Stunden, mit denselben Leitplanken wie nachts; jedes Ergebnis kommt als Meldung. Er zählt für die
Wochengrenze mit (`erster_start`). `POST /api/radar/{id}/testen`, 409 mit Grund, wenn es nicht geht.
- Nur was neben das Warm-Set passt: 27 GB Warm-Set plus Kandidat mit KV-Cache ≤ 115 GB, gerechnet mit dem Kontext - Nur was neben das Warm-Set passt: 27 GB Warm-Set plus Kandidat mit KV-Cache ≤ 115 GB, gerechnet mit dem Kontext
aus dem Betrieb (131 072; passt das nicht, stufenweise kleiner, nie unter 32 768). Ab 90 % des Budgets heißt es aus dem Betrieb (131 072; passt das nicht, stufenweise kleiner, nie unter 32 768). Ab 90 % des Budgets heißt es
„passt knapp". „passt knapp".
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,n as t,r as n,s as r,t as i}from"./dist-D4CVSex9.js";import{$t as a,Ht as o,I as s,Nt as c,kt as l,rt as u}from"./index-DzdbFvQV.js";import{t as d}from"./button-D8rvET0Z.js";import{a as f,i as p,n as m,r as h,t as g}from"./sheet-BReEHJc5.js";var _=t(),v=r(e(),1),y=n();function b(e){let t=(0,_.c)(4),{unit:n}=e,r=u(n),i;if(r.isPending)i=`Wird gelesen …`;else if(r.isError)i=`Das Protokoll lässt sich gerade nicht lesen: ${r.error.message}`;else if(r.data.ok===!1)i=r.data.err||r.data.text||`Das Protokoll lässt sich nicht lesen.`;else{let e;t[0]===r.data.text?e=t[1]:(e=r.data.text?.trim()||`Noch keine Einträge.`,t[0]=r.data.text,t[1]=e),i=e}let a;return t[2]===i?a=t[3]:(a=(0,y.jsx)(`pre`,{className:`ziffern m-0 max-h-[50vh] w-full basis-full overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap break-all text-text-2`,children:i}),t[2]=i,t[3]=a),a}function x({offen:e,onSchliessen:t}){let n=a(),r=s(),[u,_]=(0,v.useState)(null),[x,S]=(0,v.useState)(null),[C,w]=(0,v.useState)(null);async function T(e){if(!C){S(null),w(e.unit);try{let t=await o(`/api/maintenance/restart`,{service:e.unit});t.ok?l(`erfolg`,`${e.name} ${e.schlaeft?`wird geweckt`:`startet neu`}.`):l(`fehler`,`${e.name} ließ sich nicht starten: ${c(t)}`),n.invalidateQueries({queryKey:[`dienste`]})}catch(e){l(`fehler`,e.message)}finally{w(null)}}}let E;return E=r.isPending?(0,y.jsx)(`p`,{role:`status`,className:`px-4 text-[15px] text-text-2`,children:`Dienste werden gelesen …`}):r.isError&&!r.data?(0,y.jsxs)(`p`,{role:`alert`,className:`px-4 text-[15px] text-rot-text`,children:[`Die Dienste lassen sich gerade nicht lesen: `,r.error.message]}):r.data?.services.length?(0,y.jsx)(`ul`,{className:`m-0 flex list-none flex-col px-4 pb-4`,children:r.data.services.map(e=>(0,y.jsxs)(`li`,{className:`flex flex-wrap items-center justify-between gap-2 border-t border-linie py-2.5`,children:[(0,y.jsxs)(`span`,{className:`flex min-w-0 items-center gap-2.5`,children:[(0,y.jsx)(`span`,{className:i(`size-2.5 shrink-0 rounded-full`,e.ok?`bg-gruen`:e.schlaeft?`bg-text-3`:`bg-rot`),"aria-hidden":!0}),(0,y.jsxs)(`span`,{className:`min-w-0`,children:[(0,y.jsx)(`span`,{className:`block text-[15px]`,children:e.name}),(0,y.jsxs)(`span`,{className:`ziffern text-xs text-text-3`,children:[e.unit,` · `,e.ok?`läuft`:e.schlaeft?`schläft (abgeschaltet)`:`antwortet nicht`]})]})]}),(0,y.jsxs)(`span`,{className:`flex flex-wrap gap-2`,children:[(0,y.jsx)(d,{variant:u===e.unit?`info`:`ghost`,size:`sm`,"aria-expanded":u===e.unit,onClick:()=>_(u===e.unit?null:e.unit),children:`Protokoll`}),x===e.unit?(0,y.jsxs)(y.Fragment,{children:[(0,y.jsx)(d,{variant:`gefahr`,size:`sm`,onClick:()=>T(e),children:e.schlaeft?`Wirklich wecken?`:`Wirklich neu starten?`}),(0,y.jsx)(d,{variant:`ghost`,size:`sm`,onClick:()=>S(null),children:`Nein`})]}):(0,y.jsx)(d,{variant:`outline`,size:`sm`,disabled:C!==null,onClick:()=>S(e.unit),children:C===e.unit?`Startet …`:e.schlaeft?`Wecken`:`Neu starten`})]}),u===e.unit&&(0,y.jsx)(b,{unit:e.unit})]},e.unit))}):(0,y.jsx)(`p`,{className:`px-4 text-[15px] text-text-2`,children:`Keine Dienste gefunden.`}),(0,y.jsx)(g,{open:e,onOpenChange:e=>!e&&t(),children:(0,y.jsxs)(m,{side:`right`,className:`gap-3 overflow-y-auto border-linie bg-panel data-[side=right]:w-full data-[side=right]:sm:max-w-2xl`,children:[(0,y.jsxs)(p,{children:[(0,y.jsx)(f,{className:`schild text-lg`,children:`Dienste und Protokolle`}),(0,y.jsx)(h,{className:`text-text-2`,children:`Welcher Dienst läuft, was er zuletzt geschrieben hat.`})]}),E]})})}export{x as Dienste}; import{i as e,n as t,r as n,s as r,t as i}from"./dist-D4CVSex9.js";import{At as a,I as o,Pt as s,Ut as c,en as l,rt as u}from"./index-21ZM2s1S.js";import{t as d}from"./button-D8rvET0Z.js";import{a as f,i as p,n as m,r as h,t as g}from"./sheet-CgwkrC7e.js";var _=t(),v=r(e(),1),y=n();function b(e){let t=(0,_.c)(4),{unit:n}=e,r=u(n),i;if(r.isPending)i=`Wird gelesen …`;else if(r.isError)i=`Das Protokoll lässt sich gerade nicht lesen: ${r.error.message}`;else if(r.data.ok===!1)i=r.data.err||r.data.text||`Das Protokoll lässt sich nicht lesen.`;else{let e;t[0]===r.data.text?e=t[1]:(e=r.data.text?.trim()||`Noch keine Einträge.`,t[0]=r.data.text,t[1]=e),i=e}let a;return t[2]===i?a=t[3]:(a=(0,y.jsx)(`pre`,{className:`ziffern m-0 max-h-[50vh] w-full basis-full overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap break-all text-text-2`,children:i}),t[2]=i,t[3]=a),a}function x({offen:e,onSchliessen:t}){let n=l(),r=o(),[u,_]=(0,v.useState)(null),[x,S]=(0,v.useState)(null),[C,w]=(0,v.useState)(null);async function T(e){if(!C){S(null),w(e.unit);try{let t=await c(`/api/maintenance/restart`,{service:e.unit});t.ok?a(`erfolg`,`${e.name} ${e.schlaeft?`wird geweckt`:`startet neu`}.`):a(`fehler`,`${e.name} ließ sich nicht starten: ${s(t)}`),n.invalidateQueries({queryKey:[`dienste`]})}catch(e){a(`fehler`,e.message)}finally{w(null)}}}let E;return E=r.isPending?(0,y.jsx)(`p`,{role:`status`,className:`px-4 text-[15px] text-text-2`,children:`Dienste werden gelesen …`}):r.isError&&!r.data?(0,y.jsxs)(`p`,{role:`alert`,className:`px-4 text-[15px] text-rot-text`,children:[`Die Dienste lassen sich gerade nicht lesen: `,r.error.message]}):r.data?.services.length?(0,y.jsx)(`ul`,{className:`m-0 flex list-none flex-col px-4 pb-4`,children:r.data.services.map(e=>(0,y.jsxs)(`li`,{className:`flex flex-wrap items-center justify-between gap-2 border-t border-linie py-2.5`,children:[(0,y.jsxs)(`span`,{className:`flex min-w-0 items-center gap-2.5`,children:[(0,y.jsx)(`span`,{className:i(`size-2.5 shrink-0 rounded-full`,e.ok?`bg-gruen`:e.schlaeft?`bg-text-3`:`bg-rot`),"aria-hidden":!0}),(0,y.jsxs)(`span`,{className:`min-w-0`,children:[(0,y.jsx)(`span`,{className:`block text-[15px]`,children:e.name}),(0,y.jsxs)(`span`,{className:`ziffern text-xs text-text-3`,children:[e.unit,` · `,e.ok?`läuft`:e.schlaeft?`schläft (abgeschaltet)`:`antwortet nicht`]})]})]}),(0,y.jsxs)(`span`,{className:`flex flex-wrap gap-2`,children:[(0,y.jsx)(d,{variant:u===e.unit?`info`:`ghost`,size:`sm`,"aria-expanded":u===e.unit,onClick:()=>_(u===e.unit?null:e.unit),children:`Protokoll`}),x===e.unit?(0,y.jsxs)(y.Fragment,{children:[(0,y.jsx)(d,{variant:`gefahr`,size:`sm`,onClick:()=>T(e),children:e.schlaeft?`Wirklich wecken?`:`Wirklich neu starten?`}),(0,y.jsx)(d,{variant:`ghost`,size:`sm`,onClick:()=>S(null),children:`Nein`})]}):(0,y.jsx)(d,{variant:`outline`,size:`sm`,disabled:C!==null,onClick:()=>S(e.unit),children:C===e.unit?`Startet …`:e.schlaeft?`Wecken`:`Neu starten`})]}),u===e.unit&&(0,y.jsx)(b,{unit:e.unit})]},e.unit))}):(0,y.jsx)(`p`,{className:`px-4 text-[15px] text-text-2`,children:`Keine Dienste gefunden.`}),(0,y.jsx)(g,{open:e,onOpenChange:e=>!e&&t(),children:(0,y.jsxs)(m,{side:`right`,className:`gap-3 overflow-y-auto border-linie bg-panel data-[side=right]:w-full data-[side=right]:sm:max-w-2xl`,children:[(0,y.jsxs)(p,{children:[(0,y.jsx)(f,{className:`schild text-lg`,children:`Dienste und Protokolle`}),(0,y.jsx)(h,{className:`text-text-2`,children:`Welcher Dienst läuft, was er zuletzt geschrieben hat.`})]}),E]})})}export{x as Dienste};
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{n as e,r as t}from"./dist-D4CVSex9.js";import{g as n,h as r}from"./index-DzdbFvQV.js";import{a as i,i as a,n as o,r as s,t as c}from"./sheet-BReEHJc5.js";var l=e(),u=t();function d(e){let t=(0,l.c)(9),{onDienste: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,u.jsxs)(a,{className:`p-0`,children:[(0,u.jsx)(i,{asChild:!0,children:(0,u.jsx)(`div`,{children:(0,u.jsx)(r,{})})}),(0,u.jsx)(s,{className:`sr-only`,children:`Bereiche und Seiten`})]}),t[2]=m):m=t[2];let h;t[3]!==d||t[4]!==f?(h=(0,u.jsxs)(o,{side:`left`,className:`flex flex-col gap-6 border-linie bg-[#0c0f12] px-4 pt-5 pb-[calc(1.25rem+env(safe-area-inset-bottom))] data-[side=left]:w-[300px]`,children:[m,(0,u.jsx)(n,{onDienste:d,onGewaehlt:f})]}),t[3]=d,t[4]=f,t[5]=h):h=t[5];let g;return t[6]!==p||t[7]!==h?(g=(0,u.jsx)(c,{open:!0,onOpenChange:p,children:h}),t[6]=p,t[7]=h,t[8]=g):g=t[8],g}export{d as MenueSchublade}; import{n as e,r as t}from"./dist-D4CVSex9.js";import{g as n,h as r}from"./index-21ZM2s1S.js";import{a as i,i as a,n as o,r as s,t as c}from"./sheet-CgwkrC7e.js";var l=e(),u=t();function d(e){let t=(0,l.c)(9),{onDienste: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,u.jsxs)(a,{className:`p-0`,children:[(0,u.jsx)(i,{asChild:!0,children:(0,u.jsx)(`div`,{children:(0,u.jsx)(r,{})})}),(0,u.jsx)(s,{className:`sr-only`,children:`Bereiche und Seiten`})]}),t[2]=m):m=t[2];let h;t[3]!==d||t[4]!==f?(h=(0,u.jsxs)(o,{side:`left`,className:`flex flex-col gap-6 border-linie bg-[#0c0f12] px-4 pt-5 pb-[calc(1.25rem+env(safe-area-inset-bottom))] data-[side=left]:w-[300px]`,children:[m,(0,u.jsx)(n,{onDienste:d,onGewaehlt:f})]}),t[3]=d,t[4]=f,t[5]=h):h=t[5];let g;return t[6]!==p||t[7]!==h?(g=(0,u.jsx)(c,{open:!0,onOpenChange:p,children:h}),t[6]=p,t[7]=h,t[8]=g):g=t[8],g}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
@@ -1 +1 @@
import{n as e,r as t}from"./dist-D4CVSex9.js";import{a as n,i as r,n as i,r as a,t as o}from"./sheet-BReEHJc5.js";var s=e(),c=t();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:`schild text-lg`,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 border border-linie bg-[#0b0d0f] 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-linie bg-panel 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{n as e,r as t}from"./dist-D4CVSex9.js";import{a as n,i as r,n as i,r as a,t as o}from"./sheet-CgwkrC7e.js";var s=e(),c=t();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:`schild text-lg`,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 border border-linie bg-[#0b0d0f] 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-linie bg-panel 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
@@ -1 +1 @@
import{Jt as e}from"./index-DzdbFvQV.js";var t={name:`chevron-right`,size:24,node:[[`path`,{d:`m9 18 6-6-6-6`,key:`mthhwq`}]]};t.node;var n=e(t);export{n as t}; import{Yt as e}from"./index-21ZM2s1S.js";var t={name:`chevron-right`,size:24,node:[[`path`,{d:`m9 18 6-6-6-6`,key:`mthhwq`}]]};t.node;var n=e(t);export{n as t};
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{Jt as e}from"./index-DzdbFvQV.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}; import{Yt as e}from"./index-21ZM2s1S.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 +1 @@
import{i as e,n as t,r as n,t as r}from"./dist-D4CVSex9.js";import{Ut as i}from"./index-DzdbFvQV.js";import{t as a}from"./button-D8rvET0Z.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-C31gG1ts.js";var p=t();e();var m=n();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}; import{i as e,n as t,r as n,t as r}from"./dist-D4CVSex9.js";import{Wt as i}from"./index-21ZM2s1S.js";import{t as a}from"./button-D8rvET0Z.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-CPF3acEI.js";var p=t();e();var m=n();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 -1
View File
@@ -9,7 +9,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>Homelab Orchestrator</title> <title>Homelab Orchestrator</title>
<script type="module" crossorigin src="/assets/index-DzdbFvQV.js"></script> <script type="module" crossorigin src="/assets/index-21ZM2s1S.js"></script>
<link rel="modulepreload" crossorigin href="/assets/dist-D4CVSex9.js"> <link rel="modulepreload" crossorigin href="/assets/dist-D4CVSex9.js">
<link rel="modulepreload" crossorigin href="/assets/useSelector-uUwrspuX.js"> <link rel="modulepreload" crossorigin href="/assets/useSelector-uUwrspuX.js">
<link rel="modulepreload" crossorigin href="/assets/useMatch-CiyhmkMV.js"> <link rel="modulepreload" crossorigin href="/assets/useMatch-CiyhmkMV.js">
+7
View File
@@ -259,6 +259,13 @@ export const useRadarAktion = () =>
}, },
) )
/** „Jetzt testen“ (seit 25.09.2026): der Test läuft als Auftrag (Gruppe „radar“), das Ergebnis kommt als Meldung. */
export const useRadarTest = () =>
useAktion(({ id }: { id: string; name: string }) => post<AktionsErgebnis>(`/api/radar/${encodeURIComponent(id)}/testen`), {
erfolg: (v, e) => (e as AktionsErgebnis).text ?? `Der Test von ${v.name} läuft.`,
neuLaden: ["radar", "jobs"],
})
/** „Jetzt suchen“: Die Box startet die Suche als Auftrag (Gruppe „radar“) und antwortet sofort. */ /** „Jetzt suchen“: Die Box startet die Suche als Auftrag (Gruppe „radar“) und antwortet sofort. */
export const useRadarSuche = () => export const useRadarSuche = () =>
useAktion(() => post<AktionsErgebnis>("/api/radar/suche"), { useAktion(() => post<AktionsErgebnis>("/api/radar/suche"), {
+3
View File
@@ -205,6 +205,9 @@ export interface RadarKandidat {
quelle: string quelle: string
status: RadarStatus status: RadarStatus
begruendung: string begruendung: string
/** „Jetzt testen“ geht (seit 25.09.2026) — sonst steht in testbar_grund, warum nicht (null: gar nicht mehr). */
testbar_jetzt?: boolean
testbar_grund?: string | null
} }
export interface RadarTest { export interface RadarTest {
+41
View File
@@ -0,0 +1,41 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"
import { post } from "@/lib/api"
import type { RadarKandidat } from "@/lib/typen"
import { Kandidat } from "./Modelle"
vi.mock("@/lib/api", async (original) => ({ ...(await original<typeof import("@/lib/api")>()), post: vi.fn() }))
// Wie am 25.09.2026: Ornith passt tagsüber samt Coder, Flash-Next (84,6 GB) nur nachts.
const ornith: RadarKandidat = {
id: "ornith", name: "Ornith-1.5-35B-A3B", rolle: "hirn", repo: "org/ornith", datei: "o.gguf", groesse_gb: 22.6,
passt: true, eng: false, quelle: "watchlist", status: "neu", begruendung: "Aus der Merkliste.",
testbar_jetzt: true, testbar_grund: null,
}
function zeigen(k: RadarKandidat) {
return render(
<QueryClientProvider client={new QueryClient()}>
<ul><Kandidat k={k} test={undefined} heute="Qwen3.6-35B-A3B" /></ul>
</QueryClientProvider>,
)
}
describe("Radar: Jetzt testen (seit 25.09.2026)", () => {
it("fragt nach und startet den Test", async () => {
vi.mocked(post).mockResolvedValue({ ok: true, text: "Der Test läuft." })
zeigen(ornith)
fireEvent.click(screen.getByRole("button", { name: "Jetzt testen" }))
const dialog = screen.getByRole("dialog")
expect(within(dialog).getByText(/Lucy und NerdQuiz etwas langsamer/)).toBeInTheDocument()
fireEvent.click(within(dialog).getByRole("button", { name: "Jetzt testen" }))
await waitFor(() => expect(post).toHaveBeenCalledWith("/api/radar/ornith/testen"))
})
it("sagt, warum es gerade nicht geht", () => {
zeigen({ ...ornith, id: "flash", name: "Qwen3.8-Flash-Next", status: "wartet", testbar_jetzt: false,
testbar_grund: "Zu groß für einen Test tagsüber: braucht ~84,6 GB, neben Warm-Set und Coder sind 59 GB frei." })
expect(screen.queryByRole("button", { name: "Jetzt testen" })).not.toBeInTheDocument()
expect(screen.getByText(/Zu groß für einen Test tagsüber/)).toBeInTheDocument()
})
})
+27 -4
View File
@@ -9,7 +9,7 @@ import { Panel } from "@/components/cockpit/Panel"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
useAltreste, useAltrestLoeschen, useAufraeumen, useAufraeumenLoeschen, useJobs, useModellLaden, useModelle, useAltreste, useAltrestLoeschen, useAufraeumen, useAufraeumenLoeschen, useJobs, useModellLaden, useModelle,
useNutzung, useRadar, useRadarAktion, useRadarSuche, useStart, useNutzung, useRadar, useRadarAktion, useRadarSuche, useRadarTest, useStart,
} from "@/lib/abfragen" } from "@/lib/abfragen"
import { laeuftNoch, messwert, RADAR_STATUS_TEXT, rolleVon } from "@/lib/anzeige" import { laeuftNoch, messwert, RADAR_STATUS_TEXT, rolleVon } from "@/lib/anzeige"
import { ApiFehler } from "@/lib/api" import { ApiFehler } from "@/lib/api"
@@ -177,9 +177,10 @@ function Vergleich({ test }: { test: RadarTest }) {
) )
} }
function Kandidat({ k, test, heute }: { k: RadarKandidat; test: RadarTest | undefined; heute: string | undefined }) { export function Kandidat({ k, test, heute }: { k: RadarKandidat; test: RadarTest | undefined; heute: string | undefined }) {
const aktion = useRadarAktion() const aktion = useRadarAktion()
const [frage, setFrage] = useState<null | "uebernehmen" | "verwerfen">(null) const testen = useRadarTest()
const [frage, setFrage] = useState<null | "uebernehmen" | "verwerfen" | "testen">(null)
const offen = ["bestanden", "wartet", "neu", "getestet"].includes(k.status) const offen = ["bestanden", "wartet", "neu", "getestet"].includes(k.status)
const hirn = k.rolle === "hirn" const hirn = k.rolle === "hirn"
return ( return (
@@ -197,8 +198,16 @@ function Kandidat({ k, test, heute }: { k: RadarKandidat; test: RadarTest | unde
{k.begruendung} {k.begruendung}
</p> </p>
{test && ["bestanden", "durchgefallen"].includes(k.status) && <Vergleich test={test} />} {test && ["bestanden", "durchgefallen"].includes(k.status) && <Vergleich test={test} />}
{k.testbar_grund && !k.begruendung.startsWith("Läuft gerade") && (
<p className="text-xs text-text-3">{k.testbar_grund}</p>
)}
{offen && ( {offen && (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{k.testbar_jetzt && (
<Button variant="info" size="sm" disabled={testen.isPending} onClick={() => setFrage("testen")}>
Jetzt testen
</Button>
)}
{k.status === "bestanden" && ( {k.status === "bestanden" && (
<Button size="sm" disabled={aktion.isPending} onClick={() => setFrage("uebernehmen")}> <Button size="sm" disabled={aktion.isPending} onClick={() => setFrage("uebernehmen")}>
Übernehmen Übernehmen
@@ -224,6 +233,19 @@ function Kandidat({ k, test, heute }: { k: RadarKandidat; test: RadarTest | unde
setFrage(null) setFrage(null)
}} }}
/> />
<Bestaetigen
offen={frage === "testen"}
titel={`${k.name} jetzt testen?`}
text={`Das Radar lädt ${k.name}${k.groesse_gb != null ? ` (${k.groesse_gb.toLocaleString("de-DE")} GB)` : ""} herunter, falls nötig, misst das heutige Modell der Rolle und dann den Kandidaten. Das dauert je nach Download eine halbe bis zwei Stunden. Solange antworten Lucy und NerdQuiz etwas langsamer; ein geladener Coder wird dafür entladen, und wird er währenddessen gebraucht, bricht der Test ab. Das Ergebnis kommt als Meldung; fällt der Kandidat durch, werden seine Dateien gelöscht.`}
knopf="Jetzt testen"
gesperrt={testen.isPending}
onNein={() => setFrage(null)}
onJa={() => {
if (frage !== "testen") return
testen.mutate({ id: k.id, name: k.name })
setFrage(null)
}}
/>
<Bestaetigen <Bestaetigen
offen={frage === "verwerfen"} offen={frage === "verwerfen"}
gefahr gefahr
@@ -286,7 +308,8 @@ function RadarPanel({ hirn, coder }: { hirn: string | undefined; coder: string |
} }
> >
<p className="-mt-2 text-sm text-text-2"> <p className="-mt-2 text-sm text-text-2">
Testet nachts zwischen 00:30 und 02:30 selbst, höchstens einen Kandidaten pro Woche. Testet nachts zwischen 00:30 und 02:30 selbst, höchstens einen Kandidaten pro Woche. Tagsüber geht es mit
„Jetzt testen“, wenn der Kandidat samt Coder in den Speicher passt.
{d?.naechster_test ? ` Nächster Test: ${flugplanZeit(d.naechster_test)}.` : ""} {d?.naechster_test ? ` Nächster Test: ${flugplanZeit(d.naechster_test)}.` : ""}
{sucht ? " Sucht gerade bei Hugging Face. Das dauert ein paar Minuten; den Stand zeigt der Auftrag oben unter „Aufträge“." : ""} {sucht ? " Sucht gerade bei Hugging Face. Das dauert ein paar Minuten; den Stand zeigt der Auftrag oben unter „Aufträge“." : ""}
</p> </p>