Einstellungen, die etwas einstellen (services/einstellungen.py, /api/einstellungen): - Update-Zeitfenster: Wochentag und Uhrzeit des Update-Laufs als Drop-in mc2-autoupdate.timer.d/zeitfenster.conf. Gegen die Nachhol-Falle von Persistent=true (24.09.): Timer vor dem daemon-reload stoppen, Stempel auf jetzt, dann starten. Bei Fehler zurück auf das alte Drop-in. Während eines Update-Laufs abgelehnt. - Neustart nur im eingestellten Fenster (deploy/wartungsfenster.sh, drei Stunden ab der vollen Stunde des Beginns; Standard So 04:00-06:59). - Radar an/aus: disable --now bzw. enable + Stempel + start (kein Nachholen). deploy.sh respektiert den Schalter und startet den Radar-Timer nur mit Stempel; das Zeitfenster-Drop-in fasst er nie an. - Wächter: schläft der Timer eines Timer-Dienstes, ist ein alter Fehlschlag kein Befund mehr (der Dienst selbst ist static und schläft nie). - Telegram-Test je Instanz (/api/einstellungen/telegram-test und /api/homelab/einstellungen/telegram-test) über notify.sh -d; Ergebnis und Grund aus dem Melde-Log. notify.sh schreibt jetzt auch den Grund des gescheiterten Zweitwegs in den FALLBACK-Eintrag. - Einstellungsdatei mc2-einstellungen.json im Datenordner. Software-Stand: deploy.sh und homelab/ausrollen.sh schreiben nach dem Umschalten mc2-stand.json (deploy/stand-schreiben.py); /api/instanz liefert den Stand, die Einstellungen zeigen beide Instanzen und warnen bei Abweichung. Ohne Datei aus git, sonst unbekannt. Aufräumen: Altreste neben dem Betrieb aus einer festen, auf der Box geprüften Liste (Größe, Hinweis, nur was da und nicht in Gebrauch ist). Löschen nur per Kennung und Klick mit Rückfrage; Worktrees mit git worktree prune, Alt-Units mit daemon-reload, /opt/llamacpp notfalls mit sudo -n. Tests: pytest mit einem systemd-Abbild samt Nachhol-Regel, echtem notify.sh (Ersatz-Hermes, Tmp-Zugang, lokale Bot-API), Shell-Funktionen des Wartungsfensters und von deploy.sh; vitest für die Rückfragen der Schublade. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
380 lines
19 KiB
Python
380 lines
19 KiB
Python
"""
|
|
Aufräumen der Modell-Platte (Box-Wart, Umbau 09/2026): was belegt Platz, ohne gebraucht zu werden?
|
|
|
|
Zwei Arten von Kandidaten:
|
|
• Einträge ohne Rolle llama-swap-Einträge, die keine der heutigen Rollen tragen (Hirn, Coder, ihre
|
|
Bild-Zwillinge, Einbettung, Reranker) und in keiner immer-warmen Gruppe stehen.
|
|
• Ordner ohne Eintrag Unterordner von MODELS_DIR, auf die kein Eintrag mehr zeigt (alte Originale,
|
|
Reserve-Modelle, Drafts aus früheren Versuchen).
|
|
|
|
Gelöscht wird nur auf Knopfdruck des Nutzers (mit Rückfrage in der Oberfläche), nie automatisch
|
|
(User-Entscheid 23.09.: „nach der Umstellung löschen“ — die Entscheidung je Modell bleibt beim Nutzer).
|
|
Ein Eintrag wird aus der Config genommen, danach verschwinden nur Ordner, auf die NICHTS mehr zeigt:
|
|
Die Bild-Zwillinge teilen sich ihre Gewichte mit Hirn und Coder, geteilte Dateien bleiben immer liegen.
|
|
|
|
Seit 24.09.2026 zusätzlich: Altreste neben dem Betrieb (Test-Ordner, alte Umgebungen, Worktrees, Alt-Units) aus
|
|
einer festen Liste — siehe ALTRESTE unten.
|
|
"""
|
|
|
|
import os
|
|
import shutil
|
|
import stat
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from config import MODELS_DIR
|
|
|
|
from services import llamaswap
|
|
|
|
AKTIVE_ROLLEN = {"hermes", "fast", "coder", "heavy", "vision", "coder-bild", "embed", "reranker"}
|
|
SYSTEM_ORDNER = {"mc2-backups", "radar", "pruefstand-cache", "lost+found"}
|
|
AUF_DER_BOX = os.name == "posix" # auf dem Windows-PC (Entwicklung) wird nie gelöscht
|
|
|
|
# Was der Nutzer über bekannte Ordner wissen sollte, bevor er löscht (Stand 24.09.2026).
|
|
HINWEISE = {
|
|
"Qwen3.6-35B-A3B-MTP-GGUF": "Original des Hirns vor der Uncensored-Variante (17.09.) — der Rückweg, falls das Hirn "
|
|
"einmal Probleme macht.",
|
|
"Qwen3.8-27B-GGUF": "Original des Coders vor der Uncensored-Variante (17.09.) — der Rückweg für den Coder.",
|
|
"Nemotron-3.5-Lightning-30B-A3B-GGUF": "Reserve-Hirn aus dem Prüfstand vom 17.09. (gleich gut bewertet, "
|
|
"nie in Betrieb).",
|
|
"DFlash-drafts": "Draft-Modelle aus früheren Versuchen, heute ungenutzt.",
|
|
"drafts": "Draft-Modelle aus früheren Versuchen, heute ungenutzt.",
|
|
"gpt-oss-120b": "Früheres heavy-Modell, hat seit dem 04.09. keine Rolle mehr.",
|
|
"Qwen3-VL-30B-A3B-Instruct": "Früheres Bild-Modell, seit dem 24.09. durch die Bild-Zwillinge abgelöst.",
|
|
"Muse-Glimmer-30B": "Debugger — die Rolle entfällt (Entscheid 23.09.: höchstens Hirn und Coder).",
|
|
}
|
|
|
|
|
|
def _ordner_von(pfad: str) -> str | None:
|
|
"""Oberster Ordner unter MODELS_DIR, in dem eine Datei liegt (oder None, wenn sie woanders liegt)."""
|
|
if not os.path.isabs(pfad):
|
|
return None
|
|
try:
|
|
rel = Path(pfad).resolve().relative_to(Path(MODELS_DIR).resolve())
|
|
except (ValueError, OSError):
|
|
return None
|
|
return rel.parts[0] if len(rel.parts) > 1 else None
|
|
|
|
|
|
def _genutzte_ordner(models: dict) -> dict[str, set[str]]:
|
|
"""Ordner → Einträge, deren Befehl eine Datei darin nennt (Gewichte, Projektor, Draft)."""
|
|
genutzt: dict[str, set[str]] = {}
|
|
for name, spec in models.items():
|
|
if not isinstance(spec, dict):
|
|
continue
|
|
for wort in str(spec.get("cmd") or "").split(): # jedes Wort, das unter MODELS_DIR liegt
|
|
if (ordner := _ordner_von(wort.strip("'\""))):
|
|
genutzt.setdefault(ordner, set()).add(name)
|
|
return genutzt
|
|
|
|
|
|
def _groesse(pfad: Path) -> int:
|
|
summe = 0
|
|
for wurzel, _, dateien in os.walk(pfad):
|
|
for datei in dateien:
|
|
try:
|
|
summe += os.path.getsize(os.path.join(wurzel, datei))
|
|
except OSError:
|
|
pass
|
|
return summe
|
|
|
|
|
|
def _immer_warm(cfg: dict) -> set[str]:
|
|
return {m for g in (cfg.get("groups") or {}).values()
|
|
if isinstance(g, dict) and (g.get("persistent") or g.get("persist")) for m in g.get("members") or []}
|
|
|
|
|
|
def kandidaten() -> list[dict]:
|
|
"""Alles, was sich löschen ließe — größte zuerst. Löscht nichts."""
|
|
cfg = llamaswap.read_config()
|
|
models = cfg.get("models") or {}
|
|
warm = _immer_warm(cfg)
|
|
genutzt = _genutzte_ordner(models)
|
|
liste: list[dict] = []
|
|
for name, spec in models.items():
|
|
aliase = {str(a).lower() for a in (spec or {}).get("aliases") or []}
|
|
if aliase & AKTIVE_ROLLEN or name in warm:
|
|
continue
|
|
eigene = {o for o, wer in genutzt.items() if wer == {name}} # nur von diesem Eintrag genutzt
|
|
groesse = sum(_groesse(Path(MODELS_DIR) / o) for o in eigene)
|
|
rolle = f"Rolle „{', '.join(sorted(aliase))}“ gibt es nicht mehr." if aliase else "Hat keine Rolle."
|
|
liste.append({"art": "eintrag", "name": name, "groesse_gb": round(groesse / 1e9, 1),
|
|
"hinweis": HINWEISE.get(name, rolle)})
|
|
try:
|
|
ordner = sorted(p for p in Path(MODELS_DIR).iterdir() if p.is_dir())
|
|
except OSError:
|
|
ordner = []
|
|
for pfad in ordner:
|
|
if pfad.name in genutzt or pfad.name in SYSTEM_ORDNER or pfad.name.startswith("."):
|
|
continue
|
|
liste.append({"art": "ordner", "name": pfad.name, "groesse_gb": round(_groesse(pfad) / 1e9, 1),
|
|
"hinweis": HINWEISE.get(pfad.name, "Kein Eintrag nutzt diesen Ordner.")})
|
|
return sorted(liste, key=lambda k: -k["groesse_gb"])
|
|
|
|
|
|
def _ordner_loeschen(name: str) -> int:
|
|
"""Einen Ordner direkt unter MODELS_DIR löschen; gibt die freigewordenen Bytes zurück."""
|
|
wurzel = Path(MODELS_DIR).resolve()
|
|
pfad = (wurzel / name).resolve()
|
|
if pfad.parent != wurzel or not pfad.is_dir() or name in SYSTEM_ORDNER or name.startswith("."):
|
|
raise ValueError(f"{name} ist kein löschbarer Modell-Ordner.")
|
|
groesse = _groesse(pfad)
|
|
shutil.rmtree(pfad)
|
|
return groesse
|
|
|
|
|
|
def loeschen(art: str, name: str) -> dict:
|
|
"""Knopf „Löschen“: nur, was gerade als Kandidat gilt. Gibt {ok, text} oder {ok: False, detail} zurück."""
|
|
if not any(k["art"] == art and k["name"] == name for k in kandidaten()):
|
|
return {"ok": False, "detail": f"{name} ist nicht (mehr) zum Löschen frei."}
|
|
if not AUF_DER_BOX:
|
|
return {"ok": False, "detail": "Löschen geht nur auf der Box."}
|
|
frei = 0
|
|
try:
|
|
if art == "ordner":
|
|
frei = _ordner_loeschen(name)
|
|
else:
|
|
with llamaswap.config_sperre():
|
|
cfg = llamaswap.read_config()
|
|
models = cfg.get("models") or {}
|
|
vorher = _genutzte_ordner(models)
|
|
del models[name]
|
|
for gruppe in (cfg.get("groups") or {}).values():
|
|
if isinstance(gruppe, dict) and name in (gruppe.get("members") or []):
|
|
gruppe["members"] = [m for m in gruppe["members"] if m != name]
|
|
llamaswap.write_config(cfg)
|
|
nachher = _genutzte_ordner(models)
|
|
for ordner in sorted(set(vorher) - set(nachher)): # nur Ordner, auf die jetzt nichts mehr zeigt
|
|
frei += _ordner_loeschen(ordner)
|
|
except (OSError, ValueError) as e:
|
|
return {"ok": False, "detail": f"Löschen ging nicht: {e}"}
|
|
return {"ok": True, "text": f"{name} gelöscht, {frei / 1e9:.1f} GB frei."}
|
|
|
|
|
|
# --- Altreste neben dem Betrieb (seit 24.09.2026) ---------------------------------------------------------------
|
|
# Feste Liste aus dem Prüfbericht vom 24.09.2026, am selben Abend auf der Box nachgemessen (du) und gegengeprüft:
|
|
# Kein laufender Dienst, keine eingetragene Unit, kein Cron- oder Hermes-Job, kein aktiver Skill und keine
|
|
# Hermes-Einstellung nennt einen dieser Pfade. Angezeigt wird nur, was noch da ist; gelöscht wird nur auf Knopfdruck
|
|
# mit Rückfrage und nur ein Eintrag dieser Liste — die Anfrage nennt eine Kennung, nie einen Pfad.
|
|
#
|
|
# Bewusst NICHT in der Liste, weil nicht klar tot: ~/.voice (die Spracherkennung schläft nur), ~/.cache (Modelle
|
|
# von Lucys Stimme), ~/.opencode (steht in ~/.bashrc), ~/.hermes/kanban.db (Hermes öffnet sie noch), Archive
|
|
# (profiles-archive, archiv-aufraeumen, wissens-vault), ~/.hermes/state-snapshots, der PBS-Weg, die Rückweg-Kopien
|
|
# des Updaters (/opt/llamacpp-vulkan.bak, llama-swap.bak) und Skripte, die ein aktiver Skill noch nennt
|
|
# (ampel-waechter.sh, night-cron-wrapper.sh, pc_path_lookup.*).
|
|
|
|
@dataclass(frozen=True)
|
|
class Altrest:
|
|
id: str
|
|
name: str
|
|
pfade: tuple[str, ...] # "~/…" (Home des Dienst-Nutzers) oder absolut
|
|
hinweis: str
|
|
art: str = "ordner" # "ordner" | "worktree" (danach git worktree prune) | "unit" (danach daemon-reload)
|
|
sperre: tuple[str, ...] = () # User-Units, die weder laufen noch für den Autostart eingetragen sein dürfen
|
|
sudo: bool = False # Elternordner gehört root (/opt): den geleerten Ordner mit sudo -n entfernen
|
|
|
|
|
|
_UNITS = "~/.config/systemd/user/"
|
|
_STEMPEL = "~/.local/share/systemd/timers/stamp-"
|
|
_SKRIPTE = "~/.hermes/scripts/"
|
|
|
|
ALTRESTE: tuple[Altrest, ...] = (
|
|
Altrest("zonos2-test", "TTS-Test Zonos2", ("~/zonos2-test",),
|
|
"Test einer anderen Sprachausgabe vom 04.09. Lucys Stimme ist pocket-tts (~/.lucy-stimme); "
|
|
"nichts ruft diesen Ordner auf."),
|
|
Altrest("rocm-build", "Alter ROCm-Build von llama.cpp", ("/opt/llamacpp",),
|
|
"Motor aus der ROCm-Zeit (Juni). Seit dem Vulkan-Verdikt läuft er aus /opt/llamacpp-vulkan, und "
|
|
"llama-swap verweist nicht mehr hierher. Zurück zu ROCm ginge danach nur mit einem neuen Download.",
|
|
sudo=True),
|
|
Altrest("mem0", "mem0-Umgebung", ("~/.mem0",),
|
|
"Gedächtnis-Sidecar, am 27.08. abgelöst: Hermes führt das Gedächtnis selbst. Nur die abgeschaltete "
|
|
"Unit mem0-service verweist noch darauf.", sperre=("mem0-service.service",)),
|
|
Altrest("kanban", "Kanban-Arbeitsordner", ("~/.hermes/kanban",),
|
|
"Arbeitsordner, Anhänge und Protokolle der Kanban-Aufgaben aus Juli und August. Kanban ist in Hermes "
|
|
"abgeschaltet; die kleine Datenbank kanban.db bleibt liegen, weil Hermes sie noch öffnet."),
|
|
Altrest("alte-venvs", "Alte Python-Umgebungen", ("~/.venv-audit", "~/.venv-lint", "~/.litellm-venv"),
|
|
"Umgebungen für den früheren venv-Audit, Lint-Versuche und LiteLLM (der Gateway ist seit Juli "
|
|
"eingebaut). Kein Dienst und kein Job nutzt sie."),
|
|
Altrest("audiocpp-test", "audio.cpp-Test", ("~/audiocpp-test",),
|
|
"Test von audio.cpp mit Vulkan vom 04.09., nie in Betrieb gegangen."),
|
|
Altrest("avatar", "Avatar in der Oberfläche", ("~/mission-control-v2/frontend/dist/avatar.vrm",),
|
|
"VRM-Avatar vom 25.07., nie im Repo. Die Oberfläche lädt ihn nicht (kein Abruf in 30 Tagen), "
|
|
"Lucy-Desktop bringt einen eigenen mit."),
|
|
Altrest("worktrees", "Alte Git-Arbeitskopien",
|
|
("~/.hermes/worktrees/orch-graph-verknuepfer", "~/.hermes/worktrees/orch-graph-verknuepfer-work",
|
|
"~/.hermes/worktrees/wartung-remove-openrouter", "~/projekte/mc2-referenz",
|
|
"~/mission-control-v2/.worktrees/t_b7b426dc"),
|
|
"Arbeitskopien alter Aufgaben aus Juli und August, weit hinter main. Ihre Zweige bleiben im Box-Repo "
|
|
"erhalten; verloren gingen nur Änderungen, die dort nie committet wurden.", art="worktree"),
|
|
Altrest("test-reste", "Test-Reste im Home-Ordner",
|
|
("~/v2test", "~/bench_ab", "~/stimm_varianten", "~/stimm_moods", "~/emotion_test", "~/english_probe",
|
|
"~/zed-web-search-mcp", "~/cron_digest.py", "~/mc2-träum-duplikatscheck.patch"),
|
|
"Hörproben der Stimm-Tests vom 09. bis 12.07., ein MCP-Server der abgelösten Zed-Bahn, ein Skript des "
|
|
"alten Morgen-Digests und ein Patch aus der mem0-Zeit. Lucys Stimme nutzt ihre Referenz in "
|
|
"~/.lucy-stimme."),
|
|
Altrest("hermes-fremd", "Fremde Dateien im Hermes-Quellbaum",
|
|
("~/.hermes/hermes-agent/hermes_cli/mc2_kanban_reaper.py",
|
|
"~/.hermes/hermes-agent/hermes_cli/web_dist.bak-rootbuild"),
|
|
"Ein alter Kanban-Aufräumer und eine Sicherung der Web-Oberfläche, beide nicht von Hermes. Nichts lädt "
|
|
"sie; Hermes selbst bleibt unverändert."),
|
|
Altrest("hermes-skripte", "Alte Skripte in ~/.hermes/scripts",
|
|
tuple(_SKRIPTE + s for s in (
|
|
"blogwatcher-logger.py", "briefing-date.sh", "fremdblick.sh", "gitea-repo-create.sh",
|
|
"hermes-release-radar-feed.sh", "news-melden.sh.bak-20260923", "radar-feed.sh", "restore-probe.sh",
|
|
"selbstkritik-feed.sh", "venv-audit.sh", "worker.sh")),
|
|
"Skripte früherer Cron-Jobs (Werkstatt, Ampel, alte Radare). Kein Job, kein Cron und kein aktiver "
|
|
"Skill ruft sie auf; deploy.sh legt nur die heutigen Skripte dorthin."),
|
|
Altrest("mc2-memory", "Plugin mc2-memory", ("~/.hermes/plugins/mc2-memory",),
|
|
"Gedächtnis-Plugin aus der mem0-Zeit, in Hermes nicht aktiviert und nicht mehr im Repo."),
|
|
Altrest("pinned-version", "Veraltete PINNED_VERSION", ("~/.hermes/PINNED_VERSION",),
|
|
"Sagt noch „v0.17.0“ und führt in die Irre: Festgehaltene Versionen stehen in /srv/models/mc2-pins.json."),
|
|
Altrest("alt-units", "Abgeschaltete Alt-Units",
|
|
tuple(_UNITS + u for u in (
|
|
"mem0-service.service", "mem0-service.service.bak-174750", "mc2-morgen-digest.service",
|
|
"mc2-morgen-digest.timer", "mc2-selfsmoke.service", "mc2-selfsmoke.timer",
|
|
"hermes-dashboard.service", "lucy-stimme.service.bak-20260904"))
|
|
+ (_STEMPEL + "mc2-morgen-digest.timer", _STEMPEL + "mc2-selfsmoke.timer"),
|
|
"Units früherer Dienste (mem0, Morgen-Digest, Selbsttest, altes Hermes-Dashboard) samt zwei Sicherungen "
|
|
"davon, alle abgeschaltet; ihre Skripte gibt es nicht mehr. Danach liest systemd die Units neu ein.",
|
|
art="unit",
|
|
sperre=("mem0-service.service", "mc2-morgen-digest.service", "mc2-morgen-digest.timer",
|
|
"mc2-selfsmoke.service", "mc2-selfsmoke.timer", "hermes-dashboard.service")),
|
|
)
|
|
|
|
REPO = Path(__file__).resolve().parents[2]
|
|
ALTRESTE_CACHE_S = 300 # Größen messen dauert (os.walk über ~20 000 Dateien); sie ändern sich selten
|
|
_altreste_lock = threading.Lock()
|
|
_altreste_cache: dict = {"ts": 0.0, "liste": None}
|
|
|
|
|
|
def _home() -> Path:
|
|
return Path(os.environ.get("MC_ALTRESTE_HOME", str(Path.home())))
|
|
|
|
|
|
def _pfad(roh: str) -> Path:
|
|
return _home() / roh[2:] if roh.startswith("~/") else Path(roh)
|
|
|
|
|
|
def _belegt(pfad: Path) -> int:
|
|
"""Bytes einer Datei oder eines Ordners, ohne Verknüpfungen zu folgen (ein venv zeigt sonst aufs System-Python)."""
|
|
try:
|
|
st = pfad.lstat()
|
|
except OSError:
|
|
return 0
|
|
if not stat.S_ISDIR(st.st_mode):
|
|
return st.st_size
|
|
summe = 0
|
|
for wurzel, _, dateien in os.walk(pfad):
|
|
for datei in dateien:
|
|
try:
|
|
summe += os.lstat(os.path.join(wurzel, datei)).st_size
|
|
except OSError:
|
|
pass
|
|
return summe
|
|
|
|
|
|
def _menge(n: int) -> str:
|
|
"""Größe in Worten, dezimal wie die Modell-Kandidaten: „7,7 GB“, „323 MB“, „44 KB“."""
|
|
if n >= 1e9:
|
|
return f"{n / 1e9:.1f} GB".replace(".", ",")
|
|
if n >= 1e6:
|
|
return f"{n / 1e6:.0f} MB"
|
|
if n >= 1e3:
|
|
return f"{n / 1e3:.0f} KB"
|
|
return "unter 1 KB"
|
|
|
|
|
|
def _befehl(args: list[str]) -> tuple[bool, str]:
|
|
"""Befehle (systemctl, git, sudo) über eine Schicht, die Tests ersetzen. Ohne das Programm: (False, Grund)."""
|
|
try:
|
|
r = subprocess.run(args, capture_output=True, text=True, timeout=60)
|
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
return False, f"{exc.__class__.__name__}: {exc}"
|
|
return r.returncode == 0, ((r.stdout if r.returncode == 0 else (r.stderr or r.stdout)) or "").strip()[:300]
|
|
|
|
|
|
def _vorhanden(a: Altrest) -> list[tuple[str, Path]]:
|
|
return [(roh, p) for roh in a.pfade if (p := _pfad(roh)).exists() or p.is_symlink()]
|
|
|
|
|
|
def _sperrgrund(a: Altrest) -> str | None:
|
|
"""Läuft eine der Units noch oder steht sie im Autostart, wird der Rest gebraucht — dann nicht anbieten."""
|
|
for unit in a.sperre:
|
|
ok, ausgabe = _befehl(["systemctl", "--user", "show", unit, "-p", "ActiveState,UnitFileState"])
|
|
z = dict(zeile.split("=", 1) for zeile in ausgabe.splitlines() if "=" in zeile) if ok else {}
|
|
if z.get("ActiveState") in ("active", "activating", "reloading"):
|
|
return f"{unit} läuft gerade, {a.name} wird also noch gebraucht."
|
|
if z.get("UnitFileState") in ("enabled", "enabled-runtime", "linked", "linked-runtime"):
|
|
return f"{unit} steht im Autostart, {a.name} wird also noch gebraucht."
|
|
return None
|
|
|
|
|
|
def altreste(frisch: bool = False) -> list[dict]:
|
|
"""Die Altreste der festen Liste, die noch da und nicht in Gebrauch sind — größte zuerst. Löscht nichts."""
|
|
with _altreste_lock:
|
|
if not frisch and _altreste_cache["liste"] is not None \
|
|
and time.time() - _altreste_cache["ts"] < ALTRESTE_CACHE_S:
|
|
return [dict(e) for e in _altreste_cache["liste"]]
|
|
liste = []
|
|
for a in ALTRESTE:
|
|
da = _vorhanden(a)
|
|
if not da or _sperrgrund(a):
|
|
continue
|
|
groesse = sum(_belegt(p) for _, p in da)
|
|
liste.append({"id": a.id, "name": a.name, "pfade": [roh for roh, _ in da], "groesse_bytes": groesse,
|
|
"groesse": _menge(groesse), "hinweis": a.hinweis})
|
|
liste.sort(key=lambda e: -e["groesse_bytes"])
|
|
with _altreste_lock:
|
|
_altreste_cache.update(ts=time.time(), liste=liste)
|
|
return [dict(e) for e in liste]
|
|
|
|
|
|
def _entfernen(pfad: Path, a: Altrest) -> None:
|
|
if pfad.is_symlink() or not pfad.is_dir():
|
|
pfad.unlink() # eine Verknüpfung selbst, nie ihr Ziel
|
|
return
|
|
try:
|
|
shutil.rmtree(pfad)
|
|
except PermissionError:
|
|
if not a.sudo:
|
|
raise
|
|
# /opt gehört root: Den Inhalt darf der Dienst-Nutzer löschen, den geleerten Ordner selbst nur mit sudo.
|
|
ok, grund = _befehl(["sudo", "-n", "rm", "-rf", "--", str(pfad)])
|
|
if not ok:
|
|
raise PermissionError(f"sudo -n rm ging nicht ({grund})") from None
|
|
|
|
|
|
def altrest_loeschen(kennung: str) -> dict:
|
|
"""Knopf „Löschen“ eines Altrests: nur Einträge der festen Liste, nur was da ist, nur wenn nichts es braucht."""
|
|
a = next((x for x in ALTRESTE if x.id == kennung), None)
|
|
if a is None:
|
|
return {"ok": False, "detail": "Diesen Altrest kennt die Liste nicht."}
|
|
if not AUF_DER_BOX:
|
|
return {"ok": False, "detail": "Löschen geht nur auf der Box."}
|
|
da = _vorhanden(a)
|
|
if not da:
|
|
return {"ok": False, "detail": f"{a.name}: schon weg."}
|
|
if (grund := _sperrgrund(a)):
|
|
return {"ok": False, "detail": grund}
|
|
frei, fehler = 0, []
|
|
for roh, pfad in da:
|
|
groesse = _belegt(pfad)
|
|
try:
|
|
_entfernen(pfad, a)
|
|
frei += groesse
|
|
except OSError as exc:
|
|
fehler.append(f"{roh} ({exc.strerror or exc})")
|
|
if a.art == "worktree": # Git vergisst die gelöschten Arbeitskopien, die Zweige bleiben im Repo
|
|
_befehl(["git", "-C", str(REPO), "worktree", "prune"])
|
|
elif a.art == "unit":
|
|
_befehl(["systemctl", "--user", "daemon-reload"])
|
|
with _altreste_lock:
|
|
_altreste_cache.update(ts=0.0, liste=None)
|
|
if fehler:
|
|
return {"ok": False, "detail": f"{a.name} nur zum Teil gelöscht ({_menge(frei)} frei). Geblieben: "
|
|
+ "; ".join(fehler)}
|
|
return {"ok": True, "text": f"{a.name} gelöscht, {_menge(frei)} frei."}
|