system_view.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. # -*- coding: utf-8 -*-
  2. """
  3. F1: System-Konfiguration — Hostname, User, Install-Pfad, OS-Info, Modell-Download.
  4. """
  5. from __future__ import annotations
  6. import platform
  7. from textual.widgets import Checkbox, DataTable, Input, Label, RadioButton, RadioSet, Static
  8. from .base import InstallerView
  9. from ..system_info import (
  10. SystemInfo,
  11. detect_system,
  12. format_system_summary,
  13. get_model_recommendations,
  14. )
  15. class SystemView(InstallerView):
  16. """System-Einstellungen (F1)."""
  17. TITLE = "System"
  18. VIEW_ID = "system"
  19. MODES = ["server", "client", "standalone", "config"]
  20. def __init__(self, installer_config, **kwargs) -> None:
  21. super().__init__(installer_config, **kwargs)
  22. self._system_info: SystemInfo | None = None
  23. def compose(self):
  24. yield Static("── System-Konfiguration ──", classes="section-header")
  25. # OS-Info (nur Anzeige)
  26. os_info = f"{platform.system()} {platform.release()} ({platform.machine()})"
  27. py_info = platform.python_version()
  28. # Paketmanager erkennen
  29. import shutil
  30. pm_names = {
  31. "apt-get": "apt (Debian/Ubuntu/Raspbian)",
  32. "dnf": "dnf (Fedora/RHEL)",
  33. "pacman": "pacman (Arch Linux)",
  34. "zypper": "zypper (openSUSE)",
  35. }
  36. pm_info = "Nicht erkannt"
  37. for cmd, label in pm_names.items():
  38. if shutil.which(cmd):
  39. pm_info = label
  40. break
  41. yield Label(f"Betriebssystem: {os_info}")
  42. yield Label(f"Python: {py_info}")
  43. yield Label(f"Paketmanager: {pm_info}")
  44. yield Label(f"Modus: {self._config.mode.title()}")
  45. yield Label("")
  46. # Hardware-Info (wird beim Aktivieren gefuellt)
  47. yield Static("── Hardware ──", classes="section-header")
  48. yield Label("Erkenne Hardware...", id="hw-info")
  49. yield Label("")
  50. # Hostname
  51. yield Static("── Konfiguration ──", classes="section-header")
  52. yield Label("Hostname:")
  53. yield Input(
  54. value=self._config.hostname,
  55. placeholder="z.B. trixyone",
  56. id="input-hostname",
  57. )
  58. # Benutzername
  59. yield Label("Benutzername:")
  60. yield Input(
  61. value=self._config.username,
  62. placeholder="z.B. pi",
  63. id="input-username",
  64. )
  65. # Install-Pfad
  66. yield Label("Installationspfad:")
  67. yield Input(
  68. value=self._config.install_path,
  69. placeholder="z.B. /home/pi/trixy",
  70. id="input-install-path",
  71. )
  72. # Dienst-Optionen (nicht fuer config — config ist rein manuell)
  73. if self._config.mode != "config":
  74. yield Label("")
  75. yield Static("── Dienst ──", classes="section-header")
  76. yield Checkbox(
  77. "Beim Systemstart automatisch starten",
  78. value=self._config.autostart,
  79. id="cb-autostart",
  80. )
  81. yield Checkbox(
  82. "Als systemd-Service einrichten",
  83. value=self._config.install_service,
  84. id="cb-install-service",
  85. disabled=not self._config.autostart,
  86. )
  87. yield Label(
  88. " Service ermoeglicht: systemctl start/stop/status trixy-{mode}",
  89. id="lbl-service-hint",
  90. )
  91. if self._config.mode in ("server", "standalone"):
  92. yield Checkbox(
  93. "Auto-Registrierung fuer Satellites (--auto-regist)",
  94. value=self._config.auto_regist,
  95. id="cb-auto-regist",
  96. )
  97. yield Label(
  98. " Satellites werden automatisch akzeptiert ohne manuelle Bestaetigung.",
  99. )
  100. else:
  101. # Config-Modus: Kein Autostart moeglich
  102. self._config.autostart = False
  103. self._config.install_service = False
  104. # NLP-Backend und Modelle (nur bei Modi mit ML)
  105. if self._config.mode in ("server", "standalone"):
  106. yield Label("")
  107. yield Static("── NLP Backend ──", classes="section-header")
  108. yield Label("Sprachverstaendnis (Intent-Erkennung):")
  109. yield RadioSet(
  110. RadioButton(
  111. "llama.cpp — Lokales LLM, kein Server noetig (empfohlen fuer Pi)",
  112. value=self._config.nlp_backend == "llama_cpp",
  113. id="rb-nlp-llama",
  114. ),
  115. RadioButton(
  116. "Ollama (lokal) — LLM-Server auf diesem Geraet",
  117. value=self._config.nlp_backend == "ollama",
  118. id="rb-nlp-ollama",
  119. ),
  120. RadioButton(
  121. "Ollama (extern) — LLM-Server auf anderem Geraet im Netzwerk",
  122. value=self._config.nlp_backend == "ollama_remote",
  123. id="rb-nlp-ollama-remote",
  124. ),
  125. id="rs-nlp-backend",
  126. )
  127. yield Label("", id="lbl-nlp-hint")
  128. yield Label("Ollama Server-Adresse:")
  129. yield Input(
  130. value=self._config.ollama_host,
  131. placeholder="http://192.168.1.100:11434",
  132. id="input-ollama-host",
  133. disabled=self._config.nlp_backend not in ("ollama", "ollama_remote"),
  134. )
  135. yield Label("")
  136. yield Static("── Modelle ──", classes="section-header")
  137. yield Checkbox(
  138. "ML-Modelle herunterladen (empfohlene Modelle)",
  139. value=self._config.download_models,
  140. id="cb-download-models",
  141. )
  142. yield Label("")
  143. yield Static("── Modell-Empfehlungen ──", classes="section-header")
  144. yield Label("Hardware wird erkannt...", id="model-rec-hint")
  145. yield DataTable(id="model-rec-table")
  146. async def on_activate(self) -> None:
  147. """Erkennt Hardware und zeigt Empfehlungen."""
  148. if self._system_info is not None:
  149. return
  150. self._system_info = detect_system()
  151. # Hardware-Info anzeigen
  152. hw_label = self.query_one("#hw-info", Label)
  153. hw_label.update(format_system_summary(self._system_info))
  154. # Modell-Empfehlungen (nur server/standalone)
  155. if self._config.mode not in ("server", "standalone"):
  156. return
  157. recs = get_model_recommendations(
  158. self._system_info,
  159. self._config.selected_plugins,
  160. )
  161. # Hinweistext
  162. hint = self.query_one("#model-rec-hint", Label)
  163. if not recs:
  164. hint.update("Keine Modelle fuer die ausgewaehlten Plugins verfuegbar.")
  165. return
  166. gpu_text = ""
  167. if self._system_info.gpu.cuda_available:
  168. gpu_text = f" | GPU: {self._system_info.gpu.name}"
  169. hint.update(
  170. f"Basierend auf: {self._system_info.ram_total_mb}MB RAM, "
  171. f"{self._system_info.disk_free_mb}MB Festplatte frei{gpu_text}"
  172. )
  173. # Tabelle fuellen
  174. table = self.query_one("#model-rec-table", DataTable)
  175. table.clear(columns=True)
  176. table.add_columns("", "Modell", "Groesse", "Empfehlung")
  177. for rec in recs:
  178. status = "[+]" if rec.recommended else "[-]"
  179. size = f"{rec.size_mb} MB"
  180. table.add_row(status, rec.name, size, rec.reason)
  181. def on_radio_set_changed(self, event: RadioSet.Changed) -> None:
  182. """Aktualisiert NLP-Backend-Auswahl."""
  183. if event.radio_set.id == "rs-nlp-backend":
  184. pressed_id = event.pressed.id if event.pressed else ""
  185. if pressed_id == "rb-nlp-llama":
  186. self._config.nlp_backend = "llama_cpp"
  187. elif pressed_id == "rb-nlp-ollama":
  188. self._config.nlp_backend = "ollama"
  189. self._config.ollama_host = "http://localhost:11434"
  190. elif pressed_id == "rb-nlp-ollama-remote":
  191. self._config.nlp_backend = "ollama_remote"
  192. # Host-Feld aktivieren/deaktivieren
  193. try:
  194. host_input = self.query_one("#input-ollama-host", Input)
  195. is_ollama = self._config.nlp_backend in ("ollama", "ollama_remote")
  196. host_input.disabled = not is_ollama
  197. if self._config.nlp_backend == "ollama":
  198. host_input.value = "http://localhost:11434"
  199. self._config.ollama_host = "http://localhost:11434"
  200. except Exception:
  201. pass
  202. # Hinweis aktualisieren
  203. try:
  204. hint = self.query_one("#lbl-nlp-hint", Label)
  205. if self._config.nlp_backend == "llama_cpp":
  206. hint.update(
  207. " llama-cpp-python wird installiert. "
  208. "Kompilierung kann auf ARM 10-30 Min dauern."
  209. )
  210. elif self._config.nlp_backend == "ollama":
  211. hint.update(
  212. " Ollama muss auf diesem Geraet installiert sein: "
  213. "curl -fsSL https://ollama.com/install.sh | sh"
  214. )
  215. elif self._config.nlp_backend == "ollama_remote":
  216. hint.update(
  217. " Ollama muss auf dem Zielgeraet laufen und im Netzwerk "
  218. "erreichbar sein (Port 11434)."
  219. )
  220. except Exception:
  221. pass
  222. def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
  223. """Aktualisiert Optionen."""
  224. if event.checkbox.id == "cb-download-models":
  225. self._config.download_models = event.value
  226. elif event.checkbox.id == "cb-autostart":
  227. self._config.autostart = event.value
  228. # "Als Service" nur aktiv wenn Autostart an
  229. try:
  230. service_cb = self.query_one("#cb-install-service", Checkbox)
  231. service_cb.disabled = not event.value
  232. if not event.value:
  233. service_cb.value = False
  234. self._config.install_service = False
  235. except Exception:
  236. pass
  237. elif event.checkbox.id == "cb-install-service":
  238. self._config.install_service = event.value
  239. elif event.checkbox.id == "cb-auto-regist":
  240. self._config.auto_regist = event.value
  241. def on_input_changed(self, event: Input.Changed) -> None:
  242. """Aktualisiert Konfig bei Eingabeaenderungen."""
  243. if event.input.id == "input-hostname":
  244. self._config.hostname = event.value
  245. elif event.input.id == "input-username":
  246. self._config.username = event.value
  247. # Install-Pfad automatisch anpassen
  248. self._config.install_path = f"/home/{event.value}/trixy"
  249. path_input = self.query_one("#input-install-path", Input)
  250. path_input.value = self._config.install_path
  251. elif event.input.id == "input-install-path":
  252. self._config.install_path = event.value
  253. elif event.input.id == "input-ollama-host":
  254. self._config.ollama_host = event.value
  255. @staticmethod
  256. def _check_hostname_conflict(hostname: str) -> str | None:
  257. """Prueft ob der Hostname bereits im Netzwerk existiert."""
  258. import os
  259. import subprocess
  260. hostname = hostname.strip()
  261. # Eigener Hostname — kein Konflikt
  262. try:
  263. current = os.uname().nodename
  264. if current.lower() == hostname.lower():
  265. return None
  266. except Exception:
  267. pass
  268. # Hostname im Netzwerk aufloesbbar?
  269. try:
  270. result = subprocess.run(
  271. ["getent", "hosts", hostname],
  272. capture_output=True, text=True, timeout=3,
  273. )
  274. if result.returncode == 0 and result.stdout.strip():
  275. ip = result.stdout.strip().split()[0]
  276. # Pruefen ob es unsere eigene IP ist
  277. own_ips = subprocess.run(
  278. ["hostname", "-I"],
  279. capture_output=True, text=True, timeout=3,
  280. )
  281. own_ip_list = own_ips.stdout.strip().split() if own_ips.returncode == 0 else []
  282. if ip not in own_ip_list:
  283. return (
  284. f"Hostname '{hostname}' existiert bereits im Netzwerk "
  285. f"(IP: {ip}). Bitte anderen Hostnamen waehlen."
  286. )
  287. except (FileNotFoundError, subprocess.TimeoutExpired):
  288. # getent nicht verfuegbar oder Timeout — ping als Fallback
  289. try:
  290. result = subprocess.run(
  291. ["ping", "-c", "1", "-W", "1", hostname],
  292. capture_output=True, timeout=3,
  293. )
  294. if result.returncode == 0:
  295. return (
  296. f"Hostname '{hostname}' antwortet bereits im Netzwerk. "
  297. f"Bitte anderen Hostnamen waehlen."
  298. )
  299. except (FileNotFoundError, subprocess.TimeoutExpired):
  300. pass
  301. return None
  302. def validate(self) -> str | None:
  303. if not self._config.hostname.strip():
  304. return "Hostname darf nicht leer sein"
  305. if not self._config.username.strip():
  306. return "Benutzername darf nicht leer sein"
  307. if not self._config.install_path.strip():
  308. return "Installationspfad darf nicht leer sein"
  309. # Hostname-Konflikt pruefen
  310. conflict = self._check_hostname_conflict(self._config.hostname)
  311. if conflict:
  312. return conflict
  313. return None