Files
aza/AzA march 2026/aza_systemstatus.py

555 lines
19 KiB
Python
Raw Normal View History

2026-03-25 22:03:39 +01:00
# -*- coding: utf-8 -*-
"""
AZA Systemstatus / Selbsttest Praxiscomputer-Validierung.
Zeigt den Zustand aller kritischen Komponenten in einem Fenster.
"""
import os
import sys
import time
import json
import uuid
import platform
import urllib.error
2026-05-16 20:33:36 +02:00
import urllib.request
2026-03-25 22:03:39 +01:00
import tkinter as tk
from tkinter import messagebox
from pathlib import Path
from aza_config import get_writable_data_dir
from aza_style import FONT_FAMILY, ACCENT, TEXT, SUBTLE, BORDER
def _get_device_id() -> str:
p = Path(get_writable_data_dir()) / "device_id.txt"
if p.exists():
try:
v = p.read_text(encoding="utf-8").strip()
if v:
return v
except Exception:
pass
v = f"aza-{platform.system()}-{platform.machine()}-{uuid.uuid4()}"
try:
p.write_text(v, encoding="utf-8")
except Exception:
pass
return v
_BG = "#FFFFFF"
_OK = "#22C55E"
_WARN = "#F59E0B"
_ERR = "#EF4444"
def _status_icon(ok, warn=False):
if ok is None:
return "\u2014", SUBTLE
if warn:
return "\u26A0", _WARN
if ok:
return "\u2713", _OK
return "\u2717", _ERR
2026-05-16 20:33:36 +02:00
def collect_status(parent_app=None) -> list[dict]:
2026-03-25 22:03:39 +01:00
"""Sammelt alle Statuspunkte. Gibt Liste von dicts mit name, value, ok, detail."""
checks = []
try:
from aza_version import APP_VERSION
except Exception:
APP_VERSION = "?"
checks.append({"name": "App-Version", "value": APP_VERSION, "ok": True})
frozen = getattr(sys, "frozen", False)
checks.append({
"name": "Installationsmodus",
"value": "Installiert" if frozen else "Entwicklung",
"ok": True,
})
if frozen:
exe_dir = os.path.dirname(sys.executable)
else:
exe_dir = os.path.dirname(os.path.abspath(__file__))
checks.append({"name": "Installationspfad", "value": exe_dir, "ok": True})
data_dir = get_writable_data_dir()
writable = False
try:
test_file = os.path.join(data_dir, ".write_test")
with open(test_file, "w") as f:
f.write("ok")
os.remove(test_file)
writable = True
except Exception:
pass
checks.append({
"name": "APPDATA-Verzeichnis",
"value": data_dir,
"ok": writable,
"detail": "" if writable else "Nicht beschreibbar",
})
key_ok = False
try:
from security_vault import has_vault_key
key_ok = has_vault_key()
except Exception:
pass
if not key_ok:
key_ok = bool(os.getenv("OPENAI_API_KEY", "").strip())
_key_detail = ""
if key_ok:
_key_detail = "(verschlüsselt, benutzerbezogen)"
else:
_key_detail = ("Nicht eingerichtet \u2013 jeder Windows-Benutzer muss "
"seinen eigenen Schlüssel unter Einstellungen hinterlegen")
checks.append({
"name": "OpenAI-Schlüssel",
"value": ("Eingerichtet " + _key_detail) if key_ok else _key_detail,
"ok": key_ok,
})
runtime_cfg = False
cfg_path = os.path.join(data_dir, "config", "aza_runtime.env")
if not os.path.isfile(cfg_path):
alt = os.path.join(exe_dir, "config", "aza_runtime.env") if frozen else ""
if alt and os.path.isfile(alt):
runtime_cfg = True
else:
runtime_cfg = True
checks.append({
"name": "Runtime-Config",
"value": "Vorhanden" if runtime_cfg else "Nicht gefunden",
"ok": runtime_cfg,
"warn": not runtime_cfg,
})
backend_ok = False
backend_url = ""
backend_detail = ""
try:
backend_url = (os.getenv("MEDWORK_BACKEND_URL") or "").strip().rstrip("/")
if not backend_url:
url_file = os.path.join(exe_dir, "backend_url.txt") if frozen else os.path.join(
os.path.dirname(os.path.abspath(__file__)), "backend_url.txt"
)
if os.path.isfile(url_file):
with open(url_file, "r") as f:
backend_url = f.read().strip().rstrip("/")
if not backend_url:
try:
from desktop_backend_autostart import BACKEND_HOST, BACKEND_PORT
backend_url = f"http://{BACKEND_HOST}:{BACKEND_PORT}"
except Exception:
backend_url = "http://127.0.0.1:8000"
if backend_url:
import urllib.request
req = urllib.request.Request(f"{backend_url}/health", method="GET")
resp = urllib.request.urlopen(req, timeout=5)
backend_ok = resp.status == 200
except Exception:
pass
if backend_ok:
backend_detail = backend_url[:60]
elif backend_url:
backend_detail = f"{backend_url[:60]} (nicht erreichbar)"
else:
backend_detail = "URL nicht konfiguriert"
checks.append({
"name": "Backend erreichbar",
"value": "Ja" if backend_ok else "Nein",
"ok": backend_ok,
"detail": backend_detail,
})
license_ok = None
license_detail = ""
try:
if backend_ok and backend_url:
import urllib.request
token = ""
script_dir = os.path.dirname(os.path.abspath(__file__))
search_dirs = [script_dir, exe_dir]
if frozen:
internal_dir = os.path.join(exe_dir, "_internal")
if os.path.isdir(internal_dir):
search_dirs.insert(0, internal_dir)
for d in search_dirs:
tf = os.path.join(d, "backend_token.txt")
if os.path.isfile(tf):
try:
with open(tf, "r", encoding="utf-8-sig") as f:
token = (f.read() or "").replace("\ufeff", "").strip()
except Exception:
pass
if token:
break
if not token:
tokens_env = os.getenv("MEDWORK_API_TOKENS", "").strip()
if tokens_env:
token = tokens_env.split(",")[0].strip()
if not token:
token = os.getenv("MEDWORK_API_TOKEN", "").strip()
if token:
lic_headers = {"X-API-Token": token}
try:
lic_headers["X-Device-Id"] = _get_device_id()
except Exception:
pass
req = urllib.request.Request(
f"{backend_url}/license/status",
headers=lic_headers,
)
resp = urllib.request.urlopen(req, timeout=5)
data = json.loads(resp.read().decode("utf-8"))
license_ok = data.get("valid", False)
if license_ok:
license_detail = "Gueltig"
else:
license_detail = "Ungueltig"
else:
license_detail = "Kein Token"
except urllib.error.HTTPError as e:
license_detail = f"HTTP {e.code}" if hasattr(e, "code") else "HTTP-Fehler"
except urllib.error.URLError:
license_detail = "Backend nicht erreichbar"
except Exception:
license_detail = "Pruefung fehlgeschlagen"
checks.append({
"name": "Lizenz",
"value": license_detail or ("Gueltig" if license_ok else "Unbekannt"),
"ok": license_ok,
})
activation_ok = None
try:
from aza_activation import check_app_access
result = check_app_access()
if isinstance(result, tuple) and len(result) >= 2:
activation_ok = bool(result[0])
act_detail = str(result[1]) if result[1] else ("Aktiv" if activation_ok else "Abgelaufen")
elif isinstance(result, dict):
activation_ok = bool(result.get("allowed", False))
act_detail = result.get("reason", "") or ("Aktiv" if activation_ok else "Abgelaufen")
else:
act_detail = "Unbekanntes Format"
except Exception:
act_detail = "Nicht pruefbar"
checks.append({
"name": "Aktivierung",
"value": act_detail,
"ok": activation_ok,
})
login_ok = None
try:
from aza_persistence import load_user_profile
profile = load_user_profile()
last_ts = profile.get("last_login_ts")
if isinstance(last_ts, (int, float)):
elapsed_days = (time.time() - float(last_ts)) / 86400
login_ok = elapsed_days < 7
if login_ok:
checks.append({
"name": "Wochenlogin",
"value": f"OK (vor {elapsed_days:.0f} Tagen)",
"ok": True,
})
else:
checks.append({
"name": "Wochenlogin",
"value": f"Ausstehend (vor {elapsed_days:.0f} Tagen)",
"ok": False,
})
else:
checks.append({"name": "Wochenlogin", "value": "Noch nie", "ok": False})
except Exception:
checks.append({"name": "Wochenlogin", "value": "Nicht pruefbar", "ok": None})
try:
from aza_persistence import load_launcher_prefs
prefs = load_launcher_prefs()
has_pref = bool(prefs.get("default_module"))
checks.append({
"name": "Launcher-Startpraeferenz",
"value": prefs.get("default_module", "Keine") if has_pref else "Keine",
"ok": True,
"warn": not has_pref,
})
except Exception:
checks.append({"name": "Launcher-Startpraeferenz", "value": "?", "ok": None})
notizen_dir = os.path.join(data_dir, "notizen")
notizen_ok = False
try:
os.makedirs(notizen_dir, exist_ok=True)
test_f = os.path.join(notizen_dir, ".write_test")
with open(test_f, "w") as f:
f.write("ok")
os.remove(test_f)
notizen_ok = True
except Exception:
pass
checks.append({
"name": "Notizen-Verzeichnis",
"value": "Beschreibbar" if notizen_ok else "Nicht beschreibbar",
"ok": notizen_ok,
})
try:
from aza_firewall import check_firewall_rule
fw = check_firewall_rule()
if fw["exists"]:
all_ok = all(
fw.get(k) is True
for k in ("protocol_ok", "port_ok", "remote_ok", "program_ok")
)
if all_ok:
checks.append({
"name": "Firewall-Regel",
"value": "OK",
"ok": True,
"detail": fw["detail"],
})
else:
checks.append({
"name": "Firewall-Regel",
"value": "Teilweise",
"ok": True,
"warn": True,
"detail": fw["detail"],
})
else:
detail = fw.get("detail", "")
if "nicht moeglich" in detail.lower():
checks.append({
"name": "Firewall-Regel",
"value": "Hinweis",
"ok": None,
"detail": detail,
})
else:
checks.append({
"name": "Firewall-Regel",
"value": "Nicht vorhanden",
"ok": False,
"detail": "Regel wird bei Neuinstallation automatisch angelegt",
})
except Exception:
checks.append({
"name": "Firewall-Regel",
"value": "Hinweis",
"ok": None,
"detail": "Firewall-Status konnte nicht vollstaendig geprueft werden",
})
2026-05-16 20:33:36 +02:00
if parent_app is not None and hasattr(parent_app, "get_practice_id"):
try:
pid_snap = (parent_app.get_practice_id() or "").strip()
if not pid_snap:
checks.append({
"name": "Hetzner Profil-Snapshot",
"value": "practice_id fehlt",
"ok": False,
"detail": "Keine fuehrende Praxis-ID im lokalen Profil.",
})
else:
bu = parent_app.get_backend_url().rstrip("/")
tok = parent_app.get_backend_token()
uid_snap = (parent_app._user_profile.get("empfang_user_id") or "").strip()
req = urllib.request.Request(
f"{bu}/empfang/practice/profile",
method="GET",
)
req.add_header("X-API-Token", tok)
req.add_header("X-Practice-Id", pid_snap)
if uid_snap:
req.add_header("X-AzA-Empfang-User-Id", uid_snap)
resp = urllib.request.urlopen(req, timeout=12)
body = json.loads(resp.read().decode("utf-8"))
pr = body.get("practice") or {}
us = body.get("user") if isinstance(body.get("user"), dict) else {}
lcm = body.get("license_customer_email") or ""
warns = body.get("warnings") or []
lines = [
f"Praxisname: {pr.get('name') or ''}",
f"Praxis-Fachrichtung: {pr.get('specialty') or ''}",
f"Lizenz-E-Mail: {lcm or ''}",
f"Admin-E-Mail: {pr.get('admin_email') or ''}",
f"Kontakt-E-Mail (Profil): {pr.get('contact_email') or ''}",
]
if us:
lines.append(
"Benutzer: "
f"{us.get('display_name') or ''} | "
f"Titel: {us.get('title') or ''} | "
f"Fach: {us.get('specialty_user') or ''} | "
f"E-Mail: {us.get('email') or ''}",
)
warn_txt = ""
if isinstance(warns, list) and warns:
warn_txt = "Warnungen: " + "; ".join(str(w) for w in warns[:10])
snap_ok = not bool(warns)
checks.append({
"name": "Hetzner Profil-Snapshot (Lesen)",
"value": "abgerufen — siehe Detail",
"ok": snap_ok,
"warn": not snap_ok,
"detail": "\n".join(lines) + (f"\n{warn_txt}" if warn_txt else ""),
})
except urllib.error.HTTPError as exc_http:
checks.append({
"name": "Hetzner Profil-Snapshot",
"value": f"HTTP {exc_http.code}",
"ok": False,
"detail": (exc_http.read() or b"").decode("utf-8", errors="ignore")[:240],
})
except Exception as exc:
checks.append({
"name": "Hetzner Profil-Snapshot",
"value": "Abruf fehlgeschlagen",
"ok": False,
"detail": str(exc)[:240],
})
2026-03-25 22:03:39 +01:00
return checks
def format_status_text(checks: list[dict]) -> str:
"""Formatiert den Status als kopierbaren Text (ohne Secrets)."""
lines = ["AZA Desktop Systemstatus", "=" * 40]
for c in checks:
icon = "OK" if c.get("ok") else ("?" if c.get("ok") is None else "FEHLER")
if c.get("warn"):
icon = "HINWEIS"
line = f"[{icon:>7}] {c['name']}: {c['value']}"
if c.get("detail"):
line += f" ({c['detail']})"
lines.append(line)
lines.append("=" * 40)
return "\n".join(lines)
def show_systemstatus(parent):
"""Zeigt das Systemstatus-Fenster."""
win = tk.Toplevel(parent)
win.title("AZA \u2013 Systemstatus")
win.configure(bg=_BG)
win.resizable(True, True)
win.minsize(500, 400)
win.attributes("-topmost", True)
outer = tk.Frame(win, bg=_BG)
outer.pack(fill="both", expand=True, padx=28, pady=20)
header = tk.Frame(outer, bg=_BG)
header.pack(fill="x", side="top")
tk.Label(header, text="\U0001F6E0 Systemstatus",
font=(FONT_FAMILY, 16, "bold"), fg=TEXT, bg=_BG
).pack(anchor="w", pady=(0, 4))
tk.Label(header, text="Pruefung der wichtigsten Komponenten",
font=(FONT_FAMILY, 10), fg=SUBTLE, bg=_BG
).pack(anchor="w", pady=(0, 12))
btn_frame = tk.Frame(outer, bg=_BG)
btn_frame.pack(fill="x", side="bottom", pady=(14, 0))
mid = tk.Frame(outer, bg=_BG)
mid.pack(fill="both", expand=True, side="top")
canvas = tk.Canvas(mid, bg=_BG, highlightthickness=0)
scrollbar = tk.Scrollbar(mid, orient="vertical", command=canvas.yview)
scroll_frame = tk.Frame(canvas, bg=_BG)
scroll_frame.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
canvas.create_window((0, 0), window=scroll_frame, anchor="nw")
canvas.configure(yscrollcommand=scrollbar.set)
canvas.pack(side="left", fill="both", expand=True)
scrollbar.pack(side="right", fill="y")
status_data = [None]
def _populate(checks):
for widget in scroll_frame.winfo_children():
widget.destroy()
for c in checks:
row = tk.Frame(scroll_frame, bg=_BG)
row.pack(fill="x", pady=3)
is_warn = c.get("warn", False)
ok = c.get("ok")
icon_text, icon_color = _status_icon(ok, warn=is_warn)
tk.Label(row, text=icon_text, font=(FONT_FAMILY, 12, "bold"),
fg=icon_color, bg=_BG, width=3).pack(side="left")
name_lbl = tk.Label(row, text=c["name"] + ":", font=(FONT_FAMILY, 10),
fg=SUBTLE, bg=_BG, anchor="w", width=22)
name_lbl.pack(side="left")
val_text = c.get("value", "")
detail = c.get("detail", "")
if detail:
val_text += f" ({detail})"
tk.Label(row, text=val_text, font=(FONT_FAMILY, 9),
fg=TEXT, bg=_BG, anchor="w", wraplength=280, justify="left"
).pack(side="left", fill="x", expand=True)
def _run_checks():
2026-05-16 20:33:36 +02:00
checks = collect_status(parent)
2026-03-25 22:03:39 +01:00
status_data[0] = checks
_populate(checks)
def _copy_status():
if status_data[0]:
txt = format_status_text(status_data[0])
try:
win.clipboard_clear()
win.clipboard_append(txt)
messagebox.showinfo("Kopiert",
"Diagnose wurde in die Zwischenablage kopiert.",
parent=win)
except Exception:
pass
btn_refresh = tk.Button(
btn_frame, text="\u21BB Neu pruefen", font=(FONT_FAMILY, 10, "bold"),
bg=ACCENT, fg="white", activebackground="#0067C0", activeforeground="white",
relief="flat", bd=0, padx=16, pady=7, cursor="hand2",
command=_run_checks,
)
btn_refresh.pack(side="left", padx=(0, 8))
btn_copy = tk.Button(
btn_frame, text="Diagnose kopieren", font=(FONT_FAMILY, 10),
bg="#EBEDF0", fg="#374151", activebackground="#D1D5DB",
relief="flat", bd=0, padx=16, pady=7, cursor="hand2",
command=_copy_status,
)
btn_copy.pack(side="left")
btn_close = tk.Button(
btn_frame, text="Schliessen", font=(FONT_FAMILY, 10),
bg=_BG, fg=SUBTLE, activebackground="#F0F0F0",
relief="solid", bd=1, padx=16, pady=6, cursor="hand2",
highlightbackground=BORDER, command=win.destroy,
)
btn_close.pack(side="right")
win.grab_set()
_run_checks()
win.update_idletasks()
req_w = max(win.winfo_reqwidth(), 600)
req_h = win.winfo_reqheight() + 20
sw = win.winfo_screenwidth()
sh = win.winfo_screenheight()
w = min(req_w, sw - 40)
h = min(req_h, sh - 80)
win.geometry(f"{w}x{h}+{(sw - w) // 2}+{(sh - h) // 2}")