| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354 |
- # -*- coding: utf-8 -*-
- """
- F1: System-Konfiguration — Hostname, User, Install-Pfad, OS-Info, Modell-Download.
- """
- from __future__ import annotations
- import platform
- from textual.widgets import Checkbox, DataTable, Input, Label, RadioButton, RadioSet, Static
- from .base import InstallerView
- from ..system_info import (
- SystemInfo,
- detect_system,
- format_system_summary,
- get_model_recommendations,
- )
- class SystemView(InstallerView):
- """System-Einstellungen (F1)."""
- TITLE = "System"
- VIEW_ID = "system"
- MODES = ["server", "client", "standalone", "config"]
- def __init__(self, installer_config, **kwargs) -> None:
- super().__init__(installer_config, **kwargs)
- self._system_info: SystemInfo | None = None
- def compose(self):
- yield Static("── System-Konfiguration ──", classes="section-header")
- # OS-Info (nur Anzeige)
- os_info = f"{platform.system()} {platform.release()} ({platform.machine()})"
- py_info = platform.python_version()
- # Paketmanager erkennen
- import shutil
- pm_names = {
- "apt-get": "apt (Debian/Ubuntu/Raspbian)",
- "dnf": "dnf (Fedora/RHEL)",
- "pacman": "pacman (Arch Linux)",
- "zypper": "zypper (openSUSE)",
- }
- pm_info = "Nicht erkannt"
- for cmd, label in pm_names.items():
- if shutil.which(cmd):
- pm_info = label
- break
- yield Label(f"Betriebssystem: {os_info}")
- yield Label(f"Python: {py_info}")
- yield Label(f"Paketmanager: {pm_info}")
- yield Label(f"Modus: {self._config.mode.title()}")
- yield Label("")
- # Hardware-Info (wird beim Aktivieren gefuellt)
- yield Static("── Hardware ──", classes="section-header")
- yield Label("Erkenne Hardware...", id="hw-info")
- yield Label("")
- # Hostname
- yield Static("── Konfiguration ──", classes="section-header")
- yield Label("Hostname:")
- yield Input(
- value=self._config.hostname,
- placeholder="z.B. trixyone",
- id="input-hostname",
- )
- # Benutzername
- yield Label("Benutzername:")
- yield Input(
- value=self._config.username,
- placeholder="z.B. pi",
- id="input-username",
- )
- # Install-Pfad
- yield Label("Installationspfad:")
- yield Input(
- value=self._config.install_path,
- placeholder="z.B. /home/pi/trixy",
- id="input-install-path",
- )
- # Dienst-Optionen (nicht fuer config — config ist rein manuell)
- if self._config.mode != "config":
- yield Label("")
- yield Static("── Dienst ──", classes="section-header")
- yield Checkbox(
- "Beim Systemstart automatisch starten",
- value=self._config.autostart,
- id="cb-autostart",
- )
- yield Checkbox(
- "Als systemd-Service einrichten",
- value=self._config.install_service,
- id="cb-install-service",
- disabled=not self._config.autostart,
- )
- yield Label(
- " Service ermoeglicht: systemctl start/stop/status trixy-{mode}",
- id="lbl-service-hint",
- )
- if self._config.mode in ("server", "standalone"):
- yield Checkbox(
- "Auto-Registrierung fuer Satellites (--auto-regist)",
- value=self._config.auto_regist,
- id="cb-auto-regist",
- )
- yield Label(
- " Satellites werden automatisch akzeptiert ohne manuelle Bestaetigung.",
- )
- else:
- # Config-Modus: Kein Autostart moeglich
- self._config.autostart = False
- self._config.install_service = False
- # NLP-Backend und Modelle (nur bei Modi mit ML)
- if self._config.mode in ("server", "standalone"):
- yield Label("")
- yield Static("── NLP Backend ──", classes="section-header")
- yield Label("Sprachverstaendnis (Intent-Erkennung):")
- yield RadioSet(
- RadioButton(
- "llama.cpp — Lokales LLM, kein Server noetig (empfohlen fuer Pi)",
- value=self._config.nlp_backend == "llama_cpp",
- id="rb-nlp-llama",
- ),
- RadioButton(
- "Ollama (lokal) — LLM-Server auf diesem Geraet",
- value=self._config.nlp_backend == "ollama",
- id="rb-nlp-ollama",
- ),
- RadioButton(
- "Ollama (extern) — LLM-Server auf anderem Geraet im Netzwerk",
- value=self._config.nlp_backend == "ollama_remote",
- id="rb-nlp-ollama-remote",
- ),
- id="rs-nlp-backend",
- )
- yield Label("", id="lbl-nlp-hint")
- yield Label("Ollama Server-Adresse:")
- yield Input(
- value=self._config.ollama_host,
- placeholder="http://192.168.1.100:11434",
- id="input-ollama-host",
- disabled=self._config.nlp_backend not in ("ollama", "ollama_remote"),
- )
- yield Label("")
- yield Static("── Modelle ──", classes="section-header")
- yield Checkbox(
- "ML-Modelle herunterladen (empfohlene Modelle)",
- value=self._config.download_models,
- id="cb-download-models",
- )
- yield Label("")
- yield Static("── Modell-Empfehlungen ──", classes="section-header")
- yield Label("Hardware wird erkannt...", id="model-rec-hint")
- yield DataTable(id="model-rec-table")
- async def on_activate(self) -> None:
- """Erkennt Hardware und zeigt Empfehlungen."""
- if self._system_info is not None:
- return
- self._system_info = detect_system()
- # Hardware-Info anzeigen
- hw_label = self.query_one("#hw-info", Label)
- hw_label.update(format_system_summary(self._system_info))
- # Modell-Empfehlungen (nur server/standalone)
- if self._config.mode not in ("server", "standalone"):
- return
- recs = get_model_recommendations(
- self._system_info,
- self._config.selected_plugins,
- )
- # Hinweistext
- hint = self.query_one("#model-rec-hint", Label)
- if not recs:
- hint.update("Keine Modelle fuer die ausgewaehlten Plugins verfuegbar.")
- return
- gpu_text = ""
- if self._system_info.gpu.cuda_available:
- gpu_text = f" | GPU: {self._system_info.gpu.name}"
- hint.update(
- f"Basierend auf: {self._system_info.ram_total_mb}MB RAM, "
- f"{self._system_info.disk_free_mb}MB Festplatte frei{gpu_text}"
- )
- # Tabelle fuellen
- table = self.query_one("#model-rec-table", DataTable)
- table.clear(columns=True)
- table.add_columns("", "Modell", "Groesse", "Empfehlung")
- for rec in recs:
- status = "[+]" if rec.recommended else "[-]"
- size = f"{rec.size_mb} MB"
- table.add_row(status, rec.name, size, rec.reason)
- def on_radio_set_changed(self, event: RadioSet.Changed) -> None:
- """Aktualisiert NLP-Backend-Auswahl."""
- if event.radio_set.id == "rs-nlp-backend":
- pressed_id = event.pressed.id if event.pressed else ""
- if pressed_id == "rb-nlp-llama":
- self._config.nlp_backend = "llama_cpp"
- elif pressed_id == "rb-nlp-ollama":
- self._config.nlp_backend = "ollama"
- self._config.ollama_host = "http://localhost:11434"
- elif pressed_id == "rb-nlp-ollama-remote":
- self._config.nlp_backend = "ollama_remote"
- # Host-Feld aktivieren/deaktivieren
- try:
- host_input = self.query_one("#input-ollama-host", Input)
- is_ollama = self._config.nlp_backend in ("ollama", "ollama_remote")
- host_input.disabled = not is_ollama
- if self._config.nlp_backend == "ollama":
- host_input.value = "http://localhost:11434"
- self._config.ollama_host = "http://localhost:11434"
- except Exception:
- pass
- # Hinweis aktualisieren
- try:
- hint = self.query_one("#lbl-nlp-hint", Label)
- if self._config.nlp_backend == "llama_cpp":
- hint.update(
- " llama-cpp-python wird installiert. "
- "Kompilierung kann auf ARM 10-30 Min dauern."
- )
- elif self._config.nlp_backend == "ollama":
- hint.update(
- " Ollama muss auf diesem Geraet installiert sein: "
- "curl -fsSL https://ollama.com/install.sh | sh"
- )
- elif self._config.nlp_backend == "ollama_remote":
- hint.update(
- " Ollama muss auf dem Zielgeraet laufen und im Netzwerk "
- "erreichbar sein (Port 11434)."
- )
- except Exception:
- pass
- def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
- """Aktualisiert Optionen."""
- if event.checkbox.id == "cb-download-models":
- self._config.download_models = event.value
- elif event.checkbox.id == "cb-autostart":
- self._config.autostart = event.value
- # "Als Service" nur aktiv wenn Autostart an
- try:
- service_cb = self.query_one("#cb-install-service", Checkbox)
- service_cb.disabled = not event.value
- if not event.value:
- service_cb.value = False
- self._config.install_service = False
- except Exception:
- pass
- elif event.checkbox.id == "cb-install-service":
- self._config.install_service = event.value
- elif event.checkbox.id == "cb-auto-regist":
- self._config.auto_regist = event.value
- def on_input_changed(self, event: Input.Changed) -> None:
- """Aktualisiert Konfig bei Eingabeaenderungen."""
- if event.input.id == "input-hostname":
- self._config.hostname = event.value
- elif event.input.id == "input-username":
- self._config.username = event.value
- # Install-Pfad automatisch anpassen
- self._config.install_path = f"/home/{event.value}/trixy"
- path_input = self.query_one("#input-install-path", Input)
- path_input.value = self._config.install_path
- elif event.input.id == "input-install-path":
- self._config.install_path = event.value
- elif event.input.id == "input-ollama-host":
- self._config.ollama_host = event.value
- @staticmethod
- def _check_hostname_conflict(hostname: str) -> str | None:
- """Prueft ob der Hostname bereits im Netzwerk existiert."""
- import os
- import subprocess
- hostname = hostname.strip()
- # Eigener Hostname — kein Konflikt
- try:
- current = os.uname().nodename
- if current.lower() == hostname.lower():
- return None
- except Exception:
- pass
- # Hostname im Netzwerk aufloesbbar?
- try:
- result = subprocess.run(
- ["getent", "hosts", hostname],
- capture_output=True, text=True, timeout=3,
- )
- if result.returncode == 0 and result.stdout.strip():
- ip = result.stdout.strip().split()[0]
- # Pruefen ob es unsere eigene IP ist
- own_ips = subprocess.run(
- ["hostname", "-I"],
- capture_output=True, text=True, timeout=3,
- )
- own_ip_list = own_ips.stdout.strip().split() if own_ips.returncode == 0 else []
- if ip not in own_ip_list:
- return (
- f"Hostname '{hostname}' existiert bereits im Netzwerk "
- f"(IP: {ip}). Bitte anderen Hostnamen waehlen."
- )
- except (FileNotFoundError, subprocess.TimeoutExpired):
- # getent nicht verfuegbar oder Timeout — ping als Fallback
- try:
- result = subprocess.run(
- ["ping", "-c", "1", "-W", "1", hostname],
- capture_output=True, timeout=3,
- )
- if result.returncode == 0:
- return (
- f"Hostname '{hostname}' antwortet bereits im Netzwerk. "
- f"Bitte anderen Hostnamen waehlen."
- )
- except (FileNotFoundError, subprocess.TimeoutExpired):
- pass
- return None
- def validate(self) -> str | None:
- if not self._config.hostname.strip():
- return "Hostname darf nicht leer sein"
- if not self._config.username.strip():
- return "Benutzername darf nicht leer sein"
- if not self._config.install_path.strip():
- return "Installationspfad darf nicht leer sein"
- # Hostname-Konflikt pruefen
- conflict = self._check_hostname_conflict(self._config.hostname)
- if conflict:
- return conflict
- return None
|