modelle: Aufraeumen auf Knopfdruck, geteilte Gewichte bleiben beim Loeschen liegen
Ampel / ampel (push) Failing after 21s

Neuer Bereich "Aufraeumen" auf der Modelle-Seite: Eintraege ohne heutige Rolle und Ordner, auf
die kein Eintrag zeigt, mit Groesse und Erklaerung (Rueckweg, Reserve, alte Drafts). Geloescht
wird nur auf Knopfdruck mit Rueckfrage. delete_model loescht keine Dateien mehr, die ein anderer
Eintrag nennt - seit den Bild-Zwillingen haette sonst das Loeschen eines Zwillings den Coder
mitgerissen. Der Projektor des Hirn-Zwillings liegt jetzt neben den Hirn-Gewichten, damit der
Original-Ordner loeschbar bleibt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-24 13:17:11 +02:00
co-authored by Claude Opus 5.5
parent 133a491aca
commit 42363229d4
16 changed files with 340 additions and 17 deletions
+22
View File
@@ -8,6 +8,8 @@ Box-Wart-Schnittstellen (Umbau 09/2026): alles, was der Cockpit-Startbildschirm
GET /api/zeitplan Heute gelaufen / demnächst geplant GET /api/zeitplan Heute gelaufen / demnächst geplant
GET /api/updates/verlauf Was die letzten Update-Läufe wirklich gebracht haben GET /api/updates/verlauf Was die letzten Update-Läufe wirklich gebracht haben
POST /api/updates/festgehalten/{b}/freigeben Festgehaltenen Baustein wieder freigeben POST /api/updates/festgehalten/{b}/freigeben Festgehaltenen Baustein wieder freigeben
GET /api/modelle/aufraeumen Was auf der Modell-Platte ungenutzt Platz belegt
POST /api/modelle/aufraeumen/loeschen Einen Kandidaten löschen (nur auf Knopfdruck)
Dünn: die Logik liegt in services/waechter.py, modell_nutzung.py, zeitplan.py, update_verlauf.py. Dünn: die Logik liegt in services/waechter.py, modell_nutzung.py, zeitplan.py, update_verlauf.py.
""" """
@@ -21,7 +23,9 @@ from zoneinfo import ZoneInfo
import psutil import psutil
from config import MODELS_DIR from config import MODELS_DIR
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import ( from services import (
aufraeumen,
backup, backup,
llamaswap, llamaswap,
maintenance, maintenance,
@@ -220,3 +224,21 @@ def festgehalten_freigeben(baustein: str) -> dict:
if not update_verlauf.freigeben(baustein): if not update_verlauf.freigeben(baustein):
raise HTTPException(status_code=404, detail="Dieser Baustein ist nicht festgehalten.") raise HTTPException(status_code=404, detail="Dieser Baustein ist nicht festgehalten.")
return {"ok": True, "text": update_verlauf.FREIGEGEBEN_TEXT} return {"ok": True, "text": update_verlauf.FREIGEGEBEN_TEXT}
class LoeschAuftrag(BaseModel):
art: str # "eintrag" | "ordner"
name: str
@router.get("/modelle/aufraeumen")
def aufraeumen_liste() -> dict:
return {"kandidaten": aufraeumen.kandidaten()}
@router.post("/modelle/aufraeumen/loeschen")
def aufraeumen_loeschen(auftrag: LoeschAuftrag) -> dict:
ergebnis = aufraeumen.loeschen(auftrag.art, auftrag.name)
if not ergebnis.get("ok"):
raise HTTPException(status_code=409, detail=ergebnis.get("detail") or "Löschen ging nicht.")
return ergebnis
+145
View File
@@ -0,0 +1,145 @@
"""
Aufräumen der Modell-Platte (Box-Wart, Umbau 09/2026): was belegt Platz, ohne gebraucht zu werden?
Zwei Arten von Kandidaten:
• Einträge ohne Rolle llama-swap-Einträge, die keine der heutigen Rollen tragen (Hirn, Coder, ihre
Bild-Zwillinge, Einbettung, Reranker) und in keiner immer-warmen Gruppe stehen.
• Ordner ohne Eintrag Unterordner von MODELS_DIR, auf die kein Eintrag mehr zeigt (alte Originale,
Reserve-Modelle, Drafts aus früheren Versuchen).
Gelöscht wird nur auf Knopfdruck des Nutzers (mit Rückfrage in der Oberfläche), nie automatisch
(User-Entscheid 23.09.: „nach der Umstellung löschen“ — die Entscheidung je Modell bleibt beim Nutzer).
Ein Eintrag wird aus der Config genommen, danach verschwinden nur Ordner, auf die NICHTS mehr zeigt:
Die Bild-Zwillinge teilen sich ihre Gewichte mit Hirn und Coder, geteilte Dateien bleiben immer liegen.
"""
import os
import shutil
from pathlib import Path
from config import MODELS_DIR
from services import llamaswap
AKTIVE_ROLLEN = {"hermes", "fast", "coder", "heavy", "vision", "coder-bild", "embed", "reranker"}
SYSTEM_ORDNER = {"mc2-backups", "radar", "pruefstand-cache", "lost+found"}
AUF_DER_BOX = os.name == "posix" # auf dem Windows-PC (Entwicklung) wird nie gelöscht
# Was der Nutzer über bekannte Ordner wissen sollte, bevor er löscht (Stand 24.09.2026).
HINWEISE = {
"Qwen3.6-35B-A3B-MTP-GGUF": "Original des Hirns vor der Uncensored-Variante (17.09.) — der Rückweg, falls das Hirn "
"einmal Probleme macht.",
"Qwen3.8-27B-GGUF": "Original des Coders vor der Uncensored-Variante (17.09.) — der Rückweg für den Coder.",
"Nemotron-3.5-Lightning-30B-A3B-GGUF": "Reserve-Hirn aus dem Prüfstand vom 17.09. (gleich gut bewertet, "
"nie in Betrieb).",
"DFlash-drafts": "Draft-Modelle aus früheren Versuchen, heute ungenutzt.",
"drafts": "Draft-Modelle aus früheren Versuchen, heute ungenutzt.",
"gpt-oss-120b": "Früheres heavy-Modell, hat seit dem 04.09. keine Rolle mehr.",
"Qwen3-VL-30B-A3B-Instruct": "Früheres Bild-Modell, seit dem 24.09. durch die Bild-Zwillinge abgelöst.",
"Muse-Glimmer-30B": "Debugger — die Rolle entfällt (Entscheid 23.09.: höchstens Hirn und Coder).",
}
def _ordner_von(pfad: str) -> str | None:
"""Oberster Ordner unter MODELS_DIR, in dem eine Datei liegt (oder None, wenn sie woanders liegt)."""
if not os.path.isabs(pfad):
return None
try:
rel = Path(pfad).resolve().relative_to(Path(MODELS_DIR).resolve())
except (ValueError, OSError):
return None
return rel.parts[0] if len(rel.parts) > 1 else None
def _genutzte_ordner(models: dict) -> dict[str, set[str]]:
"""Ordner → Einträge, deren Befehl eine Datei darin nennt (Gewichte, Projektor, Draft)."""
genutzt: dict[str, set[str]] = {}
for name, spec in models.items():
if not isinstance(spec, dict):
continue
for wort in str(spec.get("cmd") or "").split(): # jedes Wort, das unter MODELS_DIR liegt
if (ordner := _ordner_von(wort.strip("'\""))):
genutzt.setdefault(ordner, set()).add(name)
return genutzt
def _groesse(pfad: Path) -> int:
summe = 0
for wurzel, _, dateien in os.walk(pfad):
for datei in dateien:
try:
summe += os.path.getsize(os.path.join(wurzel, datei))
except OSError:
pass
return summe
def _immer_warm(cfg: dict) -> set[str]:
return {m for g in (cfg.get("groups") or {}).values()
if isinstance(g, dict) and (g.get("persistent") or g.get("persist")) for m in g.get("members") or []}
def kandidaten() -> list[dict]:
"""Alles, was sich löschen ließe — größte zuerst. Löscht nichts."""
cfg = llamaswap.read_config()
models = cfg.get("models") or {}
warm = _immer_warm(cfg)
genutzt = _genutzte_ordner(models)
liste: list[dict] = []
for name, spec in models.items():
aliase = {str(a).lower() for a in (spec or {}).get("aliases") or []}
if aliase & AKTIVE_ROLLEN or name in warm:
continue
eigene = {o for o, wer in genutzt.items() if wer == {name}} # nur von diesem Eintrag genutzt
groesse = sum(_groesse(Path(MODELS_DIR) / o) for o in eigene)
rolle = f"Rolle „{', '.join(sorted(aliase))}“ gibt es nicht mehr." if aliase else "Hat keine Rolle."
liste.append({"art": "eintrag", "name": name, "groesse_gb": round(groesse / 1e9, 1),
"hinweis": HINWEISE.get(name, rolle)})
try:
ordner = sorted(p for p in Path(MODELS_DIR).iterdir() if p.is_dir())
except OSError:
ordner = []
for pfad in ordner:
if pfad.name in genutzt or pfad.name in SYSTEM_ORDNER or pfad.name.startswith("."):
continue
liste.append({"art": "ordner", "name": pfad.name, "groesse_gb": round(_groesse(pfad) / 1e9, 1),
"hinweis": HINWEISE.get(pfad.name, "Kein Eintrag nutzt diesen Ordner.")})
return sorted(liste, key=lambda k: -k["groesse_gb"])
def _ordner_loeschen(name: str) -> int:
"""Einen Ordner direkt unter MODELS_DIR löschen; gibt die freigewordenen Bytes zurück."""
wurzel = Path(MODELS_DIR).resolve()
pfad = (wurzel / name).resolve()
if pfad.parent != wurzel or not pfad.is_dir() or name in SYSTEM_ORDNER or name.startswith("."):
raise ValueError(f"{name} ist kein löschbarer Modell-Ordner.")
groesse = _groesse(pfad)
shutil.rmtree(pfad)
return groesse
def loeschen(art: str, name: str) -> dict:
"""Knopf „Löschen“: nur, was gerade als Kandidat gilt. Gibt {ok, text} oder {ok: False, detail} zurück."""
if not any(k["art"] == art and k["name"] == name for k in kandidaten()):
return {"ok": False, "detail": f"{name} ist nicht (mehr) zum Löschen frei."}
if not AUF_DER_BOX:
return {"ok": False, "detail": "Löschen geht nur auf der Box."}
frei = 0
try:
if art == "ordner":
frei = _ordner_loeschen(name)
else:
cfg = llamaswap.read_config()
models = cfg.get("models") or {}
vorher = _genutzte_ordner(models)
del models[name]
for gruppe in (cfg.get("groups") or {}).values():
if isinstance(gruppe, dict) and name in (gruppe.get("members") or []):
gruppe["members"] = [m for m in gruppe["members"] if m != name]
llamaswap.write_config(cfg)
nachher = _genutzte_ordner(models)
for ordner in sorted(set(vorher) - set(nachher)): # nur Ordner, auf die jetzt nichts mehr zeigt
frei += _ordner_loeschen(ordner)
except (OSError, ValueError) as e:
return {"ok": False, "detail": f"Löschen ging nicht: {e}"}
return {"ok": True, "text": f"{name} gelöscht, {frei / 1e9:.1f} GB frei."}
+8 -2
View File
@@ -532,7 +532,13 @@ def delete_model(model_id: str) -> bool:
model_spec = models[model_id] or {} model_spec = models[model_id] or {}
cmd = str(model_spec.get("cmd", "")).strip() cmd = str(model_spec.get("cmd", "")).strip()
if (m := _PATH_RE.search(cmd)): # Seit 24.09.2026 teilen sich die Bild-Zwillinge ihre Gewichte (und ggf. Projektoren) mit Hirn und Coder.
# Dateien, die ein anderer Eintrag noch nennt, bleiben liegen — sonst risse das Löschen eines Zwillings
# den Coder mit. Dann wird nur der Eintrag entfernt.
andere = " ".join(str((s or {}).get("cmd", "")) for mid, s in models.items() if mid != model_id)
if (m := _PATH_RE.search(cmd)) and m.group(1).replace("'", "").replace('"', "") in andere:
m = None
if m:
path = m.group(1).replace("'", "").replace('"', "") path = m.group(1).replace("'", "").replace('"', "")
if path: if path:
# 1. Haupt-GGUF-Datei löschen # 1. Haupt-GGUF-Datei löschen
@@ -561,7 +567,7 @@ def delete_model(model_id: str) -> bool:
mmproj_match = re.search(r'--mmproj\s+[\'"]?([^\s\'"]+)[\'"]?', cmd) mmproj_match = re.search(r'--mmproj\s+[\'"]?([^\s\'"]+)[\'"]?', cmd)
if mmproj_match: if mmproj_match:
m_path = mmproj_match.group(1) m_path = mmproj_match.group(1)
if os.path.exists(m_path): if os.path.exists(m_path) and m_path not in andere:
try: try:
os.remove(m_path) os.remove(m_path)
except Exception: except Exception:
+67
View File
@@ -0,0 +1,67 @@
"""Aufräumen der Modell-Platte: nur Ungenutztes, nur auf Knopfdruck, geteilte Dateien bleiben."""
from pathlib import Path
import pytest
from services import aufraeumen, llamaswap
@pytest.fixture
def platte(tmp_path, monkeypatch):
"""Nachbau der Box: Hirn + Bild-Zwilling teilen Gewichte, der Projektor liegt im Original-Ordner."""
for ordner, datei, groesse in (("Hirn-GGUF", "hirn.gguf", 3000), ("Original-GGUF", "mmproj.gguf", 500),
("Coder-GGUF", "coder.gguf", 2000), ("Alt-GGUF", "alt.gguf", 4000),
("Muse-GGUF", "muse.gguf", 1500), ("Reserve-GGUF", "reserve.gguf", 2500),
("radar", "log.txt", 10), (".cache", "x", 10)):
(tmp_path / ordner).mkdir()
(tmp_path / ordner / datei).write_bytes(b"x" * groesse)
m = str(tmp_path)
cfg = {"models": {
"hirn": {"cmd": f"llama-server -m {m}/Hirn-GGUF/hirn.gguf", "aliases": ["hermes", "fast"]},
"hirn-bild": {"cmd": f"llama-server -m {m}/Hirn-GGUF/hirn.gguf --mmproj {m}/Original-GGUF/mmproj.gguf",
"aliases": ["vision"]},
"coder": {"cmd": f"llama-server -m {m}/Coder-GGUF/coder.gguf", "aliases": ["coder"]},
"alt": {"cmd": f"llama-server -m {m}/Alt-GGUF/alt.gguf"},
"muse": {"cmd": f"llama-server -m {m}/Muse-GGUF/muse.gguf", "aliases": ["debugger"]},
}, "groups": {"brains": {"persistent": True, "members": ["hirn"]}}}
monkeypatch.setattr(aufraeumen, "MODELS_DIR", tmp_path)
monkeypatch.setattr(aufraeumen, "AUF_DER_BOX", True)
monkeypatch.setattr(llamaswap, "read_config", lambda: cfg)
monkeypatch.setattr(llamaswap, "write_config", lambda c: None)
return tmp_path, cfg
def test_kandidaten_sind_rollenlose_eintraege_und_verwaiste_ordner(platte):
namen = {(k["art"], k["name"]) for k in aufraeumen.kandidaten()}
assert namen == {("eintrag", "alt"), ("eintrag", "muse"), ("ordner", "Reserve-GGUF")}
# Der Original-Ordner ist KEIN Kandidat: der Bild-Zwilling nutzt seinen Projektor.
muse = next(k for k in aufraeumen.kandidaten() if k["name"] == "muse")
assert "debugger" in muse["hinweis"]
def test_eintrag_loeschen_nimmt_ihn_aus_der_config_und_seinen_ordner_mit(platte):
wurzel, cfg = platte
ergebnis = aufraeumen.loeschen("eintrag", "alt")
assert ergebnis["ok"] and "alt gelöscht" in ergebnis["text"]
assert "alt" not in cfg["models"] and not (wurzel / "Alt-GGUF").exists()
assert (wurzel / "Hirn-GGUF" / "hirn.gguf").exists() and (wurzel / "Coder-GGUF").exists()
def test_nur_aktuelle_kandidaten_und_nie_systemordner(platte):
wurzel, _ = platte
assert not aufraeumen.loeschen("ordner", "Hirn-GGUF")["ok"] # wird genutzt
assert not aufraeumen.loeschen("ordner", "radar")["ok"] # Systemordner
assert not aufraeumen.loeschen("ordner", "../etwas")["ok"]
assert (wurzel / "Hirn-GGUF").exists() and (wurzel / "radar").exists()
assert aufraeumen.loeschen("ordner", "Reserve-GGUF")["ok"] and not (wurzel / "Reserve-GGUF").exists()
def test_delete_model_laesst_geteilte_gewichte_liegen(platte):
"""Das Löschen des Bild-Zwillings darf die Gewichte des Hirns nicht mitnehmen (24.09.)."""
wurzel, cfg = platte
assert llamaswap.delete_model("hirn-bild") is True
assert "hirn-bild" not in cfg["models"]
assert (wurzel / "Hirn-GGUF" / "hirn.gguf").exists()
# Im Zweifel liegen lassen: der Ordner taucht danach als verwaist im Aufräumen auf.
assert Path(wurzel / "Original-GGUF" / "mmproj.gguf").exists()
assert ("ordner", "Original-GGUF") in {(k["art"], k["name"]) for k in aufraeumen.kandidaten()}
+4 -3
View File
@@ -33,10 +33,11 @@ models:
# auch speculative.n_max=0 je Anfrage hilft nicht). Das MC2-Gateway schickt nur Anfragen mit einem Bild im # auch speculative.n_max=0 je Anfrage hilft nicht). Das MC2-Gateway schickt nur Anfragen mit einem Bild im
# aktuellen Schritt hierher; ältere Bilder ersetzt es durch eine Beschreibung von hier, damit der Rest der # aktuellen Schritt hierher; ältere Bilder ersetzt es durch eine Beschreibung von hier, damit der Rest der
# Aufgabe beim schnellen Hirn mit Draft bleibt. Probe 24.09.: 8/8 Bildmerkmale, Bildschirm-Aufgabe im # Aufgabe beim schnellen Hirn mit Draft bleibt. Probe 24.09.: 8/8 Bildmerkmale, Bildschirm-Aufgabe im
# Agenten-Ablauf richtig, 67,9 t/s @13k (Hirn mit Draft 105,8). Projektor aus dem Original-Repo # Agenten-Ablauf richtig, 67,9 t/s @13k (Hirn mit Draft 105,8). Projektor stammt aus dem Original-Repo
# (Qwen3.6-35B-A3B-MTP-GGUF) — er passt zur abliterierten Variante. Löst Qwen3-VL als `vision` ab. # (Qwen3.6-35B-A3B-MTP-GGUF), passt zur abliterierten Variante und liegt als Kopie neben deren Gewichten —
# so bleibt der Original-Ordner im Aufräumen löschbar. Löst Qwen3-VL als `vision` ab.
cmd: | cmd: |
llama-server -m /srv/models/Qwen3.6-35B-A3B-Uncensored-GGUF/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 65536 -ngl 999 -fa on --load-mode none --mmproj /srv/models/Qwen3.6-35B-A3B-MTP-GGUF/mmproj-BF16.gguf --jinja --parallel 1 -cram 8192 -ctk q8_0 -ctv q8_0 --reasoning-budget 2048 llama-server -m /srv/models/Qwen3.6-35B-A3B-Uncensored-GGUF/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 65536 -ngl 999 -fa on --load-mode none --mmproj /srv/models/Qwen3.6-35B-A3B-Uncensored-GGUF/mmproj-BF16.gguf --jinja --parallel 1 -cram 8192 -ctk q8_0 -ctv q8_0 --reasoning-budget 2048
ttl: 900 ttl: 900
aliases: aliases:
- vision - vision
@@ -1 +1 @@
import{c as e,f as t,i as n,t as r,u as i}from"./button-Dd8hfusv.js";import{A as a,L as o,f as s,j as c,y as l}from"./index-BRMEvo2A.js";import{a as u,i as d,n as f,r as p,t as m}from"./sheet-DWG2Q04V.js";var h=t(i(),1),g=e();function _({offen:e,onSchliessen:t}){let i=o(),_=s(),[v,y]=(0,h.useState)(null),[b,x]=(0,h.useState)(null),S=l(v);async function C(e){x(null);try{let t=await a(`/api/maintenance/restart`,{service:e});t.ok?c(`erfolg`,`${e} startet neu.`):c(`fehler`,t.err||`${e} ließ sich nicht neu starten.`),i.invalidateQueries({queryKey:[`dienste`]})}catch(e){c(`fehler`,e.message)}}return(0,g.jsx)(m,{open:e,onOpenChange:e=>!e&&t(),children:(0,g.jsxs)(f,{side:`right`,className:`w-full gap-3 overflow-y-auto border-linie bg-panel sm:max-w-2xl`,children:[(0,g.jsxs)(d,{children:[(0,g.jsx)(u,{className:`schild text-lg`,children:`Dienste und Protokolle`}),(0,g.jsx)(p,{className:`text-text-2`,children:`Welcher Dienst läuft, was er zuletzt geschrieben hat.`})]}),(0,g.jsx)(`ul`,{className:`m-0 flex list-none flex-col px-4 pb-2`,children:(_.data?.services??[]).map(e=>(0,g.jsxs)(`li`,{className:`flex flex-wrap items-center justify-between gap-2 border-t border-linie py-2.5`,children:[(0,g.jsxs)(`span`,{className:`flex min-w-0 items-center gap-2.5`,children:[(0,g.jsx)(`span`,{className:n(`size-2.5 shrink-0 rounded-full`,e.ok?`bg-gruen`:`bg-rot`),"aria-hidden":!0}),(0,g.jsxs)(`span`,{className:`min-w-0`,children:[(0,g.jsx)(`span`,{className:`block text-[15px]`,children:e.name}),(0,g.jsxs)(`span`,{className:`ziffern text-xs text-text-3`,children:[e.unit,` · `,e.ok?`läuft`:`antwortet nicht`]})]})]}),(0,g.jsxs)(`span`,{className:`flex gap-2`,children:[(0,g.jsx)(r,{variant:v===e.unit?`info`:`ghost`,size:`sm`,onClick:()=>y(e.unit),children:`Protokoll`}),b===e.unit?(0,g.jsx)(r,{variant:`gefahr`,size:`sm`,onClick:()=>C(e.unit),children:`Wirklich neu starten?`}):(0,g.jsx)(r,{variant:`outline`,size:`sm`,onClick:()=>x(e.unit),children:`Neu starten`})]})]},e.unit))}),v&&(0,g.jsx)(`pre`,{className:`ziffern mx-4 mb-4 max-h-[50vh] overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2`,children:S.isFetching?`Wird gelesen …`:S.data?.text||S.data?.err||`Kein Protokoll.`})]})})}export{_ as Dienste}; import{c as e,f as t,i as n,t as r,u as i}from"./button-Dd8hfusv.js";import{M as a,N as o,m as s,x as c,z as l}from"./index-D17zMI0S.js";import{a as u,i as d,n as f,r as p,t as m}from"./sheet-DWG2Q04V.js";var h=t(i(),1),g=e();function _({offen:e,onSchliessen:t}){let i=l(),_=s(),[v,y]=(0,h.useState)(null),[b,x]=(0,h.useState)(null),S=c(v);async function C(e){x(null);try{let t=await a(`/api/maintenance/restart`,{service:e});t.ok?o(`erfolg`,`${e} startet neu.`):o(`fehler`,t.err||`${e} ließ sich nicht neu starten.`),i.invalidateQueries({queryKey:[`dienste`]})}catch(e){o(`fehler`,e.message)}}return(0,g.jsx)(m,{open:e,onOpenChange:e=>!e&&t(),children:(0,g.jsxs)(f,{side:`right`,className:`w-full gap-3 overflow-y-auto border-linie bg-panel sm:max-w-2xl`,children:[(0,g.jsxs)(d,{children:[(0,g.jsx)(u,{className:`schild text-lg`,children:`Dienste und Protokolle`}),(0,g.jsx)(p,{className:`text-text-2`,children:`Welcher Dienst läuft, was er zuletzt geschrieben hat.`})]}),(0,g.jsx)(`ul`,{className:`m-0 flex list-none flex-col px-4 pb-2`,children:(_.data?.services??[]).map(e=>(0,g.jsxs)(`li`,{className:`flex flex-wrap items-center justify-between gap-2 border-t border-linie py-2.5`,children:[(0,g.jsxs)(`span`,{className:`flex min-w-0 items-center gap-2.5`,children:[(0,g.jsx)(`span`,{className:n(`size-2.5 shrink-0 rounded-full`,e.ok?`bg-gruen`:`bg-rot`),"aria-hidden":!0}),(0,g.jsxs)(`span`,{className:`min-w-0`,children:[(0,g.jsx)(`span`,{className:`block text-[15px]`,children:e.name}),(0,g.jsxs)(`span`,{className:`ziffern text-xs text-text-3`,children:[e.unit,` · `,e.ok?`läuft`:`antwortet nicht`]})]})]}),(0,g.jsxs)(`span`,{className:`flex gap-2`,children:[(0,g.jsx)(r,{variant:v===e.unit?`info`:`ghost`,size:`sm`,onClick:()=>y(e.unit),children:`Protokoll`}),b===e.unit?(0,g.jsx)(r,{variant:`gefahr`,size:`sm`,onClick:()=>C(e.unit),children:`Wirklich neu starten?`}):(0,g.jsx)(r,{variant:`outline`,size:`sm`,onClick:()=>x(e.unit),children:`Neu starten`})]})]},e.unit))}),v&&(0,g.jsx)(`pre`,{className:`ziffern mx-4 mb-4 max-h-[50vh] overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2`,children:S.isFetching?`Wird gelesen …`:S.data?.text||S.data?.err||`Kein Protokoll.`})]})})}export{_ as Dienste};
@@ -1 +1 @@
import{c as e,f as t,t as n,u as r}from"./button-Dd8hfusv.js";import{A as i,F as a,I as o,L as s,j as c,k as l}from"./index-BRMEvo2A.js";import{a as u,i as d,n as f,r as p,t as m}from"./sheet-DWG2Q04V.js";var h=t(r(),1),g=e();function _({offen:e,onSchliessen:t}){let r=s(),_=o({queryKey:[`geheimnisse`],queryFn:()=>l(`/api/maintenance/geheimnisse`),enabled:e}),[v,y]=(0,h.useState)(``);async function b(e){try{await i(`/api/maintenance/geheimnisse`,{schluessel:`hf_token`,wert:e}),c(`erfolg`,e?`Hugging-Face-Zugang gespeichert.`:`Hugging-Face-Zugang gelöscht.`),y(``),r.invalidateQueries({queryKey:[`geheimnisse`]})}catch(e){c(`fehler`,e.message)}}let x=_.data;return(0,g.jsx)(m,{open:e,onOpenChange:e=>!e&&t(),children:(0,g.jsxs)(f,{side:`right`,className:`w-full gap-3 overflow-y-auto border-linie bg-panel sm:max-w-lg`,children:[(0,g.jsxs)(d,{children:[(0,g.jsx)(u,{className:`schild text-lg`,children:`Einstellungen`}),(0,g.jsx)(p,{className:`text-text-2`,children:`Was die Box sich merkt.`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-3 border-t border-linie px-4 pt-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hugging-Face-Zugang`}),(0,g.jsxs)(`p`,{className:`text-sm text-text-2`,children:[x?.hf_token_gesetzt?`Ist hinterlegt.`:`Fehlt.`,` Nötig für gesperrte Modelle und schnellere Downloads.`,x?.hf_token_aus_env?` Er kommt aus der Dienst-Einstellung und lässt sich hier nicht löschen.`:``]}),(0,g.jsxs)(`form`,{className:`flex gap-2`,onSubmit:e=>{e.preventDefault(),v.trim()&&b(v.trim())},children:[(0,g.jsx)(`label`,{htmlFor:`hf-token`,className:`sr-only`,children:`Hugging-Face-Zugang`}),(0,g.jsx)(`input`,{id:`hf-token`,type:`password`,autoComplete:`off`,value:v,onChange:e=>y(e.target.value),placeholder:`hf_…`,className:`h-11 min-w-0 flex-1 rounded-lg border border-linie-stark bg-erhaben px-3 text-[15px] outline-none focus:border-cyan`}),(0,g.jsx)(n,{type:`submit`,disabled:!v.trim()||x?.schreibbar===!1,children:`Speichern`})]}),x?.hf_token_gesetzt&&!x.hf_token_aus_env&&(0,g.jsx)(n,{variant:`ghost`,size:`sm`,className:`self-start`,onClick:()=>b(null),children:`Zugang löschen`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-2 border-t border-linie px-4 pt-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hinweise`}),(0,g.jsx)(`p`,{className:`text-sm text-text-2`,children:`Dringende Hinweise gehen zusätzlich per Telegram raus, alles andere bleibt hier. Einfache Probleme wie einen abgestürzten Dienst behebt der Wächter selbst und schreibt es in den Verlauf.`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-2 border-t border-linie px-4 pt-4 pb-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hermes`}),(0,g.jsxs)(`a`,{href:`/hermes-ui/`,target:`_blank`,rel:`noopener`,className:`flex h-11 items-center gap-2 self-start rounded-lg border border-linie-stark bg-erhaben px-4 text-[15px] hover:border-text-3`,children:[(0,g.jsx)(a,{className:`size-4`,"aria-hidden":!0}),` Hermes-Dashboard öffnen`]}),(0,g.jsx)(`p`,{className:`text-xs text-text-3`,children:`Chat, Sitzungen und Cron-Jobs verwaltet Hermes selbst. Es fragt nach seiner eigenen Anmeldung.`})]})]})})}export{_ as Einstellungen}; import{c as e,f as t,t as n,u as r}from"./button-Dd8hfusv.js";import{L as i,M as a,N as o,R as s,j as c,z as l}from"./index-D17zMI0S.js";import{a as u,i as d,n as f,r as p,t as m}from"./sheet-DWG2Q04V.js";var h=t(r(),1),g=e();function _({offen:e,onSchliessen:t}){let r=l(),_=s({queryKey:[`geheimnisse`],queryFn:()=>c(`/api/maintenance/geheimnisse`),enabled:e}),[v,y]=(0,h.useState)(``);async function b(e){try{await a(`/api/maintenance/geheimnisse`,{schluessel:`hf_token`,wert:e}),o(`erfolg`,e?`Hugging-Face-Zugang gespeichert.`:`Hugging-Face-Zugang gelöscht.`),y(``),r.invalidateQueries({queryKey:[`geheimnisse`]})}catch(e){o(`fehler`,e.message)}}let x=_.data;return(0,g.jsx)(m,{open:e,onOpenChange:e=>!e&&t(),children:(0,g.jsxs)(f,{side:`right`,className:`w-full gap-3 overflow-y-auto border-linie bg-panel sm:max-w-lg`,children:[(0,g.jsxs)(d,{children:[(0,g.jsx)(u,{className:`schild text-lg`,children:`Einstellungen`}),(0,g.jsx)(p,{className:`text-text-2`,children:`Was die Box sich merkt.`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-3 border-t border-linie px-4 pt-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hugging-Face-Zugang`}),(0,g.jsxs)(`p`,{className:`text-sm text-text-2`,children:[x?.hf_token_gesetzt?`Ist hinterlegt.`:`Fehlt.`,` Nötig für gesperrte Modelle und schnellere Downloads.`,x?.hf_token_aus_env?` Er kommt aus der Dienst-Einstellung und lässt sich hier nicht löschen.`:``]}),(0,g.jsxs)(`form`,{className:`flex gap-2`,onSubmit:e=>{e.preventDefault(),v.trim()&&b(v.trim())},children:[(0,g.jsx)(`label`,{htmlFor:`hf-token`,className:`sr-only`,children:`Hugging-Face-Zugang`}),(0,g.jsx)(`input`,{id:`hf-token`,type:`password`,autoComplete:`off`,value:v,onChange:e=>y(e.target.value),placeholder:`hf_…`,className:`h-11 min-w-0 flex-1 rounded-lg border border-linie-stark bg-erhaben px-3 text-[15px] outline-none focus:border-cyan`}),(0,g.jsx)(n,{type:`submit`,disabled:!v.trim()||x?.schreibbar===!1,children:`Speichern`})]}),x?.hf_token_gesetzt&&!x.hf_token_aus_env&&(0,g.jsx)(n,{variant:`ghost`,size:`sm`,className:`self-start`,onClick:()=>b(null),children:`Zugang löschen`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-2 border-t border-linie px-4 pt-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hinweise`}),(0,g.jsx)(`p`,{className:`text-sm text-text-2`,children:`Dringende Hinweise gehen zusätzlich per Telegram raus, alles andere bleibt hier. Einfache Probleme wie einen abgestürzten Dienst behebt der Wächter selbst und schreibt es in den Verlauf.`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-2 border-t border-linie px-4 pt-4 pb-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hermes`}),(0,g.jsxs)(`a`,{href:`/hermes-ui/`,target:`_blank`,rel:`noopener`,className:`flex h-11 items-center gap-2 self-start rounded-lg border border-linie-stark bg-erhaben px-4 text-[15px] hover:border-text-3`,children:[(0,g.jsx)(i,{className:`size-4`,"aria-hidden":!0}),` Hermes-Dashboard öffnen`]}),(0,g.jsx)(`p`,{className:`text-xs text-text-3`,children:`Chat, Sitzungen und Cron-Jobs verwaltet Hermes selbst. Es fragt nach seiner eigenen Anmeldung.`})]})]})})}export{_ as Einstellungen};
@@ -1 +1 @@
import{c as e,s as t}from"./button-Dd8hfusv.js";import{F as n,N as r,P as i}from"./index-BRMEvo2A.js";import{a,i as o,n as s,r as c,t as l}from"./sheet-DWG2Q04V.js";var u=t(),d=e();function f(e){let t=(0,u.c)(17),{verbindung:f,onDienste:p,onEinstellungen:m,onSchliessen:h}=e,g;t[0]===h?g=t[1]:(g=e=>!e&&h(),t[0]=h,t[1]=g);let _;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(_=(0,d.jsxs)(o,{children:[(0,d.jsx)(a,{className:`schild text-lg`,children:`Mehr`}),(0,d.jsx)(c,{className:`sr-only`,children:`Weitere Bereiche`})]}),t[2]=_):_=t[2];let v;t[3]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,d.jsxs)(`a`,{href:`/hermes-ui/`,target:`_blank`,rel:`noopener`,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-base`,children:[(0,d.jsx)(n,{className:`size-5`,"aria-hidden":!0}),` Hermes-Dashboard öffnen`]}),t[3]=v):v=t[3];let y;t[4]===Symbol.for(`react.memo_cache_sentinel`)?(y=(0,d.jsx)(i,{className:`size-5`,"aria-hidden":!0}),t[4]=y):y=t[4];let b;t[5]===p?b=t[6]:(b=(0,d.jsxs)(`button`,{type:`button`,onClick:p,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base`,children:[y,` Dienste und Protokolle`]}),t[5]=p,t[6]=b);let x;t[7]===Symbol.for(`react.memo_cache_sentinel`)?(x=(0,d.jsx)(r,{className:`size-5`,"aria-hidden":!0}),t[7]=x):x=t[7];let S;t[8]===m?S=t[9]:(S=(0,d.jsxs)(`button`,{type:`button`,onClick:m,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base`,children:[x,` Einstellungen`]}),t[8]=m,t[9]=S);let C;t[10]!==b||t[11]!==S||t[12]!==f?(C=(0,d.jsxs)(s,{side:`bottom`,className:`border-linie bg-panel pb-[calc(1rem+env(safe-area-inset-bottom))]`,children:[_,(0,d.jsxs)(`div`,{className:`flex flex-col gap-2 px-4`,children:[f,v,b,S]})]}),t[10]=b,t[11]=S,t[12]=f,t[13]=C):C=t[13];let w;return t[14]!==g||t[15]!==C?(w=(0,d.jsx)(l,{open:!0,onOpenChange:g,children:C}),t[14]=g,t[15]=C,t[16]=w):w=t[16],w}export{f as MehrMenue}; import{c as e,s as t}from"./button-Dd8hfusv.js";import{F as n,I as r,L as i}from"./index-D17zMI0S.js";import{a,i as o,n as s,r as c,t as l}from"./sheet-DWG2Q04V.js";var u=t(),d=e();function f(e){let t=(0,u.c)(17),{verbindung:f,onDienste:p,onEinstellungen:m,onSchliessen:h}=e,g;t[0]===h?g=t[1]:(g=e=>!e&&h(),t[0]=h,t[1]=g);let _;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(_=(0,d.jsxs)(o,{children:[(0,d.jsx)(a,{className:`schild text-lg`,children:`Mehr`}),(0,d.jsx)(c,{className:`sr-only`,children:`Weitere Bereiche`})]}),t[2]=_):_=t[2];let v;t[3]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,d.jsxs)(`a`,{href:`/hermes-ui/`,target:`_blank`,rel:`noopener`,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-base`,children:[(0,d.jsx)(i,{className:`size-5`,"aria-hidden":!0}),` Hermes-Dashboard öffnen`]}),t[3]=v):v=t[3];let y;t[4]===Symbol.for(`react.memo_cache_sentinel`)?(y=(0,d.jsx)(r,{className:`size-5`,"aria-hidden":!0}),t[4]=y):y=t[4];let b;t[5]===p?b=t[6]:(b=(0,d.jsxs)(`button`,{type:`button`,onClick:p,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base`,children:[y,` Dienste und Protokolle`]}),t[5]=p,t[6]=b);let x;t[7]===Symbol.for(`react.memo_cache_sentinel`)?(x=(0,d.jsx)(n,{className:`size-5`,"aria-hidden":!0}),t[7]=x):x=t[7];let S;t[8]===m?S=t[9]:(S=(0,d.jsxs)(`button`,{type:`button`,onClick:m,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base`,children:[x,` Einstellungen`]}),t[8]=m,t[9]=S);let C;t[10]!==b||t[11]!==S||t[12]!==f?(C=(0,d.jsxs)(s,{side:`bottom`,className:`border-linie bg-panel pb-[calc(1rem+env(safe-area-inset-bottom))]`,children:[_,(0,d.jsxs)(`div`,{className:`flex flex-col gap-2 px-4`,children:[f,v,b,S]})]}),t[10]=b,t[11]=S,t[12]=f,t[13]=C):C=t[13];let w;return t[14]!==g||t[15]!==C?(w=(0,d.jsx)(l,{open:!0,onOpenChange:g,children:C}),t[14]=g,t[15]=C,t[16]=w):w=t[16],w}export{f as MehrMenue};
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
View File
@@ -7,7 +7,7 @@
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>MC2 · Box-Wart</title> <title>MC2 · Box-Wart</title>
<script type="module" crossorigin src="/assets/index-BRMEvo2A.js"></script> <script type="module" crossorigin src="/assets/index-D17zMI0S.js"></script>
<link rel="modulepreload" crossorigin href="/assets/button-Dd8hfusv.js"> <link rel="modulepreload" crossorigin href="/assets/button-Dd8hfusv.js">
<link rel="stylesheet" crossorigin href="/assets/index-C3amg33Y.css"> <link rel="stylesheet" crossorigin href="/assets/index-C3amg33Y.css">
</head> </head>
+14 -1
View File
@@ -8,7 +8,7 @@ import { api, post } from "./api"
import { melden } from "./meldungen" import { melden } from "./meldungen"
import { stromVerbunden } from "./strom" import { stromVerbunden } from "./strom"
import type { import type {
AktionsErgebnis, Job, Modell, Nutzung, Radar, Sicherung, Start, AktionsErgebnis, AufraeumKandidat, Job, Modell, Nutzung, Radar, Sicherung, Start,
} from "./typen" } from "./typen"
const takt = (ms: number) => () => (stromVerbunden() ? ms * 5 : ms) const takt = (ms: number) => () => (stromVerbunden() ? ms * 5 : ms)
@@ -146,6 +146,19 @@ export const useUpdateStarten = () =>
neuLaden: ["jobs", "start"], neuLaden: ["jobs", "start"],
}) })
export const useAufraeumen = () =>
useQuery({
queryKey: ["aufraeumen"],
queryFn: () => api<{ kandidaten: AufraeumKandidat[] }>("/api/modelle/aufraeumen"),
staleTime: 60_000,
})
export const useAufraeumenLoeschen = () =>
useAktion(
(k: AufraeumKandidat) => post<AktionsErgebnis>("/api/modelle/aufraeumen/loeschen", { art: k.art, name: k.name }),
{ erfolg: (_, e) => (e as AktionsErgebnis).text ?? "Gelöscht.", neuLaden: ["aufraeumen", "models", "start"] },
)
export const useFreigeben = () => export const useFreigeben = () =>
useAktion( useAktion(
(baustein: string) => post<AktionsErgebnis>(`/api/updates/festgehalten/${encodeURIComponent(baustein)}/freigeben`), (baustein: string) => post<AktionsErgebnis>(`/api/updates/festgehalten/${encodeURIComponent(baustein)}/freigeben`),
+8
View File
@@ -82,6 +82,14 @@ export interface Festgehalten {
grund: string grund: string
} }
/** Etwas auf der Modell-Platte, das ungenutzt Platz belegt (Box-Wart, Aufräumen). */
export interface AufraeumKandidat {
art: "eintrag" | "ordner"
name: string
groesse_gb: number
hinweis: string
}
export interface AktionsErgebnis { export interface AktionsErgebnis {
ok: boolean ok: boolean
text?: string text?: string
+63 -2
View File
@@ -5,11 +5,12 @@ import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
import { Panel } from "@/components/cockpit/Panel" import { Panel } from "@/components/cockpit/Panel"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
useModellLaden, useModelle, useNutzung, useRadar, useRadarAktion, useRadarSuche, useStart, useAufraeumen, useAufraeumenLoeschen, useModellLaden, useModelle, useNutzung, useRadar, useRadarAktion,
useRadarSuche, useStart,
} from "@/lib/abfragen" } from "@/lib/abfragen"
import { messwert, RADAR_STATUS_TEXT, rolleVon } from "@/lib/anzeige" import { messwert, RADAR_STATUS_TEXT, rolleVon } from "@/lib/anzeige"
import { useLetzteMetrik } from "@/lib/strom" import { useLetzteMetrik } from "@/lib/strom"
import type { Modell, Nutzung, RadarKandidat, RadarTest } from "@/lib/typen" import type { AufraeumKandidat, Modell, Nutzung, RadarKandidat, RadarTest } from "@/lib/typen"
import { flugplanZeit, gb, gbText, zahl } from "@/lib/zeit" import { flugplanZeit, gb, gbText, zahl } from "@/lib/zeit"
import { ModellSuche } from "./ModellSuche" import { ModellSuche } from "./ModellSuche"
@@ -273,6 +274,65 @@ function RadarPanel({ hirn, coder }: { hirn: string | undefined; coder: string |
) )
} }
/** Was ungenutzt Platz belegt. Gelöscht wird nur auf Knopfdruck, mit Rückfrage — Löschen ist endgültig. */
function AufraeumenPanel() {
const liste = useAufraeumen()
const loeschen = useAufraeumenLoeschen()
const [frage, setFrage] = useState<AufraeumKandidat | null>(null)
const kandidaten = liste.data?.kandidaten ?? []
const summe = kandidaten.reduce((s, k) => s + k.groesse_gb, 0)
if (liste.isError) return null
return (
<Panel
titel="Aufräumen"
rechts={kandidaten.length > 0
? <span className="ziffern text-sm text-text-2">bis zu {summe.toLocaleString("de-DE", { maximumFractionDigits: 0 })} GB frei</span>
: undefined}
>
<p className="-mt-2 text-sm text-text-2">
Modelle und Ordner, die gerade niemand nutzt. Nichts davon wird von selbst gelöscht, das entscheidest du je Eintrag.
</p>
{liste.isPending ? (
<p className="text-[15px] text-text-2">Wird gelesen …</p>
) : kandidaten.length === 0 ? (
<p className="text-[15px] text-text-2">Nichts aufzuräumen.</p>
) : (
<ul className="m-0 flex list-none flex-col p-0">
{kandidaten.map((k) => (
<li key={`${k.art}-${k.name}`} className="flex flex-wrap items-center justify-between gap-3 border-t border-linie py-3">
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex flex-wrap items-center gap-2">
<span className="ziffern text-sm break-all">{k.name}</span>
<Chip>{k.art === "eintrag" ? "Eintrag" : "Ordner"}</Chip>
</span>
<span className="text-sm text-text-2">{k.hinweis}</span>
</span>
<span className="flex items-center gap-3">
<span className="ziffern text-sm text-text-2">{k.groesse_gb.toLocaleString("de-DE")} GB</span>
<Button variant="gefahr" size="sm" disabled={loeschen.isPending} onClick={() => setFrage(k)}>
Löschen
</Button>
</span>
</li>
))}
</ul>
)}
<Bestaetigen
offen={frage !== null}
gefahr
titel={`${frage?.name ?? ""} löschen?`}
text={`${frage?.hinweis ?? ""} Es werden ${frage?.groesse_gb.toLocaleString("de-DE") ?? ""} GB frei. Das lässt sich nicht rückgängig machen — zurück ginge es nur mit einem neuen Download.`}
knopf="Endgültig löschen"
onNein={() => setFrage(null)}
onJa={() => {
if (frage) loeschen.mutate(frage)
setFrage(null)
}}
/>
</Panel>
)
}
export function ModelleSeite() { export function ModelleSeite() {
const modelle = useModelle() const modelle = useModelle()
const nutzung = useNutzung() const nutzung = useNutzung()
@@ -324,6 +384,7 @@ export function ModelleSeite() {
))} ))}
</ul> </ul>
</Panel> </Panel>
<AufraeumenPanel />
<ModellSuche offen={suche} onSchliessen={() => setSuche(false)} /> <ModellSuche offen={suche} onSchliessen={() => setSuche(false)} />
</> </>
) )