From 708453677d4d97cd334ba484573739e8e76445e0 Mon Sep 17 00:00:00 2001 From: emirhanmamak-blueforce Date: Thu, 17 Sep 2026 09:50:40 +0300 Subject: [PATCH] feat(gui): implement installer and remote portal surfaces with user preferences --- bfos/preferences.py | 53 ++++ bfos/ui_gui.py | 25 +- bfos/ui_surfaces.py | 442 ++++++++++++++++++++++++++++++++++ docs/35-GUI-SURFACES.md | 36 +++ tests/test_bfos_gui_static.py | 35 +++ 5 files changed, 590 insertions(+), 1 deletion(-) create mode 100644 bfos/preferences.py create mode 100644 bfos/ui_surfaces.py create mode 100644 docs/35-GUI-SURFACES.md diff --git a/bfos/preferences.py b/bfos/preferences.py new file mode 100644 index 0000000..fb9d212 --- /dev/null +++ b/bfos/preferences.py @@ -0,0 +1,53 @@ +"""Small, local GUI preferences for the active Blueforce identity.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +from bfos.operations import dealer_is_valid + +PREFERENCES_FILENAME = "blueforce-gui.json" + + +def preferences_path() -> Path: + base = os.environ.get("XDG_CONFIG_HOME") + if base: + return Path(base) / "blueforce" / PREFERENCES_FILENAME + return Path.home() / ".config" / "blueforce" / PREFERENCES_FILENAME + + +def load_preferences(path: Path | None = None) -> dict[str, str]: + target = path or preferences_path() + try: + data: Any = json.loads(target.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + if not isinstance(data, dict): + return {} + dealer = str(data.get("dealer_id", "")).strip() + if not dealer_is_valid(dealer): + return {} + return {"dealer_id": dealer, "active_user": f"bf-{dealer}"} + + +def save_dealer(dealer_id: str, path: Path | None = None) -> dict[str, str]: + if not dealer_is_valid(dealer_id): + raise ValueError("Bayi numarası tam 8 rakam olmalıdır.") + target = path or preferences_path() + target.parent.mkdir(parents=True, exist_ok=True) + data = {"dealer_id": dealer_id, "active_user": f"bf-{dealer_id}"} + temporary = target.with_suffix(target.suffix + ".tmp") + temporary.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + os.replace(temporary, target) + return data + + +def clear_dealer(path: Path | None = None) -> None: + target = path or preferences_path() + try: + target.unlink() + except FileNotFoundError: + pass diff --git a/bfos/ui_gui.py b/bfos/ui_gui.py index 1cd6af0..d9ab75b 100644 --- a/bfos/ui_gui.py +++ b/bfos/ui_gui.py @@ -1295,16 +1295,27 @@ class GuiContext: inventory: Path | None = None console_log: str = DEFAULT_CONSOLE_LOG dry_run: bool = False + active_dealer: str | None = None + active_user: str | None = None @classmethod def detect(cls, repo: str | None = None, inventory: str | None = None, dry_run: bool = False) -> "GuiContext": root = Path(repo).expanduser().resolve() if repo else repo_root_from_file() + # GUI identity is loaded once into the session context. Individual + # surfaces may display it, but they do not invent a second identity. + try: + from bfos.preferences import load_preferences + preferences = load_preferences() + except (ImportError, OSError): + preferences = {} return cls( repo_root=root, inventory=resolve_inventory(root, inventory), console_log=os.environ.get("BF_CONSOLE_LOG", DEFAULT_CONSOLE_LOG), dry_run=dry_run, + active_dealer=preferences.get("dealer_id"), + active_user=preferences.get("active_user"), ) @@ -4331,6 +4342,10 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: ) parser.add_argument("--full", action="store_true", help="Doğrudan detaylı modda başlat (kategori sekmeleri).") + parser.add_argument("--install", action="store_true", + help="Yalnızca bağımsız kurulum konsolunu aç.") + parser.add_argument("--portal", action="store_true", + help="Yalnızca bağımsız uzaktan yönetim portalını aç.") parser.add_argument("--repo", default=None, help="Depo kökü (varsayılan: bfos'un üst dizini).") parser.add_argument("--inventory", default=None, help="Ansible envanteri (varsayılan: otomatik).") parser.add_argument("--dry-run", action="store_true", @@ -4360,7 +4375,15 @@ def main(argv: Sequence[str] | None = None) -> int: root = tk.Tk() try: - BfGui(root, backend, ctx, full_mode=args.full) + from bfos.ui_surfaces import launch_surface + + if args.install and args.portal: + print("--install ve --portal aynı anda kullanılamaz.", file=sys.stderr) + return 2 + launch_surface( + root, backend, ctx, + "install" if args.install else "portal" if args.portal or args.full else None, + ) except tk.TclError as exc: # DISPLAY var ama erisilemiyor print(window_start_error(exc), file=sys.stderr) return 3 diff --git a/bfos/ui_surfaces.py b/bfos/ui_surfaces.py new file mode 100644 index 0000000..d7ca088 --- /dev/null +++ b/bfos/ui_surfaces.py @@ -0,0 +1,442 @@ +"""Blueforce GUI surfaces. + +The installer and the remote portal deliberately have separate view models. +They share only the backend contract; no selection, navigation, or execution +state crosses the boundary. +""" + +from __future__ import annotations + +import queue +import threading +import tkinter as tk +from tkinter import messagebox, ttk +from typing import Any + +from bfos import ui_theme as theme +from bfos.ui_gui import ( + CommandError, + GuiContext, + Operation, + Target, + dealer_error, + operation_by_id, + parse_inventory, +) +from bfos.preferences import load_preferences, save_dealer + + +class SurfaceFrame(ttk.Frame): + """Common chrome only; each product surface owns its own state below.""" + + def __init__(self, root: tk.Tk, backend: Any, ctx: GuiContext) -> None: + self.root = root + self.backend = backend + self.ctx = ctx + self.styles = theme.apply_theme(root) + super().__init__(root, style=self.styles["frame"]) + self.pack(fill="both", expand=True) + + def clear(self) -> None: + for child in self.winfo_children(): + child.destroy() + + def title_row(self, title: str, subtitle: str) -> ttk.Frame: + row = ttk.Frame(self, style=self.styles["frame"]) + row.pack(fill="x", padx=theme.SP_6, pady=(theme.SP_5, theme.SP_3)) + ttk.Label(row, text=title, style=self.styles["section"]).pack(anchor="w") + ttk.Label(row, text=subtitle, style=self.styles["panel_muted"]).pack(anchor="w") + return row + + +class ModePicker(SurfaceFrame): + """Neutral launcher: choosing a mode is the only shared interaction.""" + + def __init__(self, root: tk.Tk, backend: Any, ctx: GuiContext) -> None: + super().__init__(root, backend, ctx) + root.title("Blueforce Field OS — çalışma alanı") + root.geometry("980x620") + root.minsize(760, 520) + self._render() + + def _active_identity(self) -> str: + return f"Aktif kullanıcı: {self.ctx.active_user}" if self.ctx.active_user else "Aktif kullanıcı ayarlanmadı" + + def _render(self) -> None: + self.title_row( + "Blueforce Field OS", + self._active_identity() + " · Çalışacağınız yüzeyi seçin.", + ) + body = ttk.Frame(self, style=self.styles["frame"]) + body.pack(fill="both", expand=True, padx=theme.SP_6, pady=theme.SP_3) + body.columnconfigure(0, weight=1) + body.columnconfigure(1, weight=1) + + self._mode_card( + body, 0, "Kurulum konsolu", + "Yeni veya yeniden kurulacak tek cihaz için kontrollü, adım adım akış.", + ("Bayi numarası", "Ön kontrol", "Kurulum ve kayıt", "Canlı çıktı"), + "Kurulum alanına gir", + lambda: self._open(InstallerSurface), + ) + self._mode_card( + body, 1, "Uzaktan yönetim portalı", + "Envanterdeki bilgisayarları izleyin, seçin ve yetkili operasyonları çalıştırın.", + ("Filo görünümü", "Cihaz durumu", "Operasyon", "Denetim izi"), + "Portala gir", + lambda: self._open(RemotePortalSurface), + ) + ttk.Label( + self, text="Modu sonradan üst çubuktan değiştirerek yeni bir çalışma alanı açabilirsiniz.", + style=self.styles["faint"], + ).pack(anchor="w", padx=theme.SP_6, pady=(0, theme.SP_5)) + + def _mode_card(self, parent: ttk.Frame, column: int, title: str, + description: str, steps: tuple[str, ...], action: str, + command) -> None: + card = ttk.Frame(parent, style=self.styles["panel"]) + card.grid(row=0, column=column, sticky="nsew", + padx=(0, theme.SP_3) if column == 0 else (theme.SP_3, 0)) + inner = ttk.Frame(card, style=self.styles["panel"]) + inner.pack(fill="both", expand=True, padx=theme.SP_5, pady=theme.SP_5) + ttk.Label(inner, text=title, style=self.styles["panel_title"]).pack(anchor="w") + ttk.Label(inner, text=description, style=self.styles["panel_label"], + wraplength=330, justify="left").pack(anchor="w", pady=(theme.SP_2, theme.SP_5)) + for step in steps: + ttk.Label(inner, text="— " + step, style=self.styles["panel_muted"]).pack(anchor="w", pady=1) + ttk.Button(inner, text=action, style=self.styles["accent_button"], + command=command).pack(anchor="w", pady=(theme.SP_6, 0)) + + def _open(self, surface_cls) -> None: + self.destroy() + surface_cls(self.root, self.backend, self.ctx) + + +class InstallerSurface(SurfaceFrame): + """Single-device installation surface; no fleet navigation exists here.""" + + def __init__(self, root: tk.Tk, backend: Any, ctx: GuiContext) -> None: + super().__init__(root, backend, ctx) + root.title("Blueforce Field OS — Kurulum") + root.geometry("980x680") + root.minsize(760, 560) + self.dealer_var = tk.StringVar() + self.preferences = load_preferences() + self.dealer_var.set(self.preferences.get("dealer_id", "")) + self.dealer_locked = bool(self.preferences.get("dealer_id")) + self.identity_var = tk.StringVar(value=self._identity_text()) + self.dealer_var.trace_add("write", lambda *_: self._on_dealer_changed()) + self.status_var = tk.StringVar(value="Hazır — bayi numarası bekleniyor") + self.output_queue: queue.Queue[str] = queue.Queue() + self._render() + self._drain_output() + + def _render(self) -> None: + self.title_row("Kurulum konsolu", "Tek cihazı kurun, kaydedin ve doğrulayın.") + content = ttk.Frame(self, style=self.styles["frame"]) + content.pack(fill="both", expand=True, padx=theme.SP_6) + content.columnconfigure(1, weight=1) + steps = ttk.Frame(content, style=self.styles["panel"]) + steps.grid(row=0, column=0, sticky="ns", padx=(0, theme.SP_4)) + ttk.Label(steps, text="KURULUM AKIŞI", style=self.styles["panel_title"]).pack( + anchor="w", padx=theme.SP_4, pady=theme.SP_4) + for i, label in enumerate(("Cihazı tanımla", "Ön kontrol", "Kur ve kaydet", "Doğrula"), 1): + ttk.Label(steps, text=f"{i:02d} {label}", style=self.styles["panel_label"] if i == 1 else self.styles["panel_muted"]).pack( + anchor="w", padx=theme.SP_4, pady=(0, theme.SP_3)) + + main = ttk.Frame(content, style=self.styles["panel"]) + main.grid(row=0, column=1, sticky="nsew") + main.columnconfigure(0, weight=1) + ttk.Label(main, text="Cihaz kimliği", style=self.styles["panel_title"]).grid( + row=0, column=0, sticky="w", padx=theme.SP_5, pady=(theme.SP_5, theme.SP_2)) + ttk.Label(main, text="8 haneli bayi numarasını girin. Bu akış yalnızca bu cihazı etkiler.", + style=self.styles["panel_muted"]).grid(row=1, column=0, sticky="w", padx=theme.SP_5) + ttk.Label(main, textvariable=self.identity_var, style=self.styles["panel_label"]).grid( + row=1, column=1, sticky="e", padx=theme.SP_5) + entry = ttk.Entry(main, textvariable=self.dealer_var, style=self.styles["entry"], width=22, + state="readonly" if self.dealer_locked else "normal") + self.dealer_entry = entry + entry.grid(row=2, column=0, sticky="w", padx=theme.SP_5, pady=(theme.SP_4, theme.SP_2)) + entry.focus_set() + self.change_dealer_button = ttk.Button( + main, text="Bayi no değiştir", style=self.styles["quiet_button"], + command=self._unlock_dealer, + state="normal" if self.dealer_locked else "disabled") + self.change_dealer_button.grid(row=2, column=1, sticky="w", padx=theme.SP_3, + pady=(theme.SP_4, theme.SP_2)) + ttk.Button(main, text="Ön kontrolü çalıştır", style=self.styles["accent_button"], + command=self._run_install).grid(row=3, column=0, sticky="w", padx=theme.SP_5, pady=theme.SP_4) + ttk.Label(main, textvariable=self.status_var, style=self.styles["panel_muted"]).grid( + row=4, column=0, sticky="w", padx=theme.SP_5, pady=(0, theme.SP_4)) + + output = ttk.Frame(self, style=self.styles["panel"]) + output.pack(fill="both", expand=True, padx=theme.SP_6, pady=theme.SP_4) + ttk.Label(output, text="Kurulum çıktısı", style=self.styles["panel_title"]).pack( + anchor="w", padx=theme.SP_4, pady=(theme.SP_3, theme.SP_2)) + self.output = tk.Text(output, height=8, wrap="none", state="disabled", + font=("TkFixedFont", 10), relief="flat", padx=8, pady=8) + self.output.pack(fill="both", expand=True, padx=theme.SP_3, pady=(0, theme.SP_3)) + ttk.Button(self, text="Çalışma alanı seçimine dön", style=self.styles["quiet_button"], + command=self._back).pack(anchor="w", padx=theme.SP_6, pady=(0, theme.SP_5)) + + def _run_install(self) -> None: + dealer = self.dealer_var.get().strip() + problem = dealer_error(dealer) + if problem: + self.status_var.set(problem) + return + try: + saved = save_dealer(dealer) + except OSError as exc: + self.status_var.set(f"Ayar kaydedilemedi: {exc}") + return + self.ctx.active_dealer = saved["dealer_id"] + self.ctx.active_user = saved["active_user"] + self.dealer_locked = True + self.dealer_entry.configure(state="readonly") + self.change_dealer_button.configure(state="normal") + operation = operation_by_id("install-local") + if operation is None: + self.status_var.set("Kurulum operasyonu katalogda bulunamadı.") + return + target = Target("dealer", dealer) + try: + plan = self.backend.build_command(operation, target, {}, check_mode=False) + except Exception as exc: + self.status_var.set(f"Plan üretilemedi: {exc}") + return + if not messagebox.askyesno("Kurulumu onaylayın", "Bu cihaz için kurulum başlatılsın mı?\n\n" + "\n".join(plan.lines), default="no"): + self.status_var.set("İptal edildi; hiçbir komut çalıştırılmadı.") + return + self.status_var.set("Kurulum çalışıyor…") + self._append("\n" + "\n".join(plan.lines) + "\n") + + def worker() -> None: + try: + result = self.backend.run(plan, on_line=self.output_queue.put) + self.output_queue.put(f"\n{'[OK]' if result.ok else '[HATA]'} Çıkış kodu: {result.exit_code}\n") + except Exception as exc: + self.output_queue.put(f"[HATA] {exc}\n") + threading.Thread(target=worker, daemon=True).start() + + def _identity_text(self) -> str: + dealer = self.dealer_var.get().strip() + return f"Aktif kullanıcı: bf-{dealer}" if dealer else "Aktif kullanıcı ayarlanmadı" + + def _on_dealer_changed(self) -> None: + self.identity_var.set(self._identity_text()) + + def _unlock_dealer(self) -> None: + if not messagebox.askyesno( + "Bayi numarasını değiştir", + "Mevcut bayi kimliği kilidini açıp yeni bir numara girmek istiyor musunuz?", + default="no", + ): + return + self.dealer_locked = False + self.dealer_entry.configure(state="normal") + self.dealer_var.set("") + self.change_dealer_button.configure(state="disabled") + self.status_var.set("Yeni bayi numarası girin; kurulum onayında kaydedilecek.") + self.dealer_entry.focus_set() + + def _append(self, text: str) -> None: + self.output.configure(state="normal") + self.output.insert("end", text) + self.output.see("end") + self.output.configure(state="disabled") + + def _drain_output(self) -> None: + try: + while True: + self._append(self.output_queue.get_nowait()) + self.status_var.set("Kurulum çıktısı güncelleniyor…") + except queue.Empty: + pass + self.after(100, self._drain_output) + + def _back(self) -> None: + self.destroy() + ModePicker(self.root, self.backend, self.ctx) + + +class RemotePortalSurface(SurfaceFrame): + """Fleet portal surface; its state never reuses installer controls.""" + + def __init__(self, root: tk.Tk, backend: Any, ctx: GuiContext) -> None: + super().__init__(root, backend, ctx) + root.title("Blueforce Field OS — Uzaktan Yönetim") + root.geometry("1180x760") + root.minsize(860, 620) + self.search_var = tk.StringVar() + self.selected_host: str | None = None + self.preferences = load_preferences() + self.status_var = tk.StringVar(value="Bir cihaz seçin") + self.output_queue: queue.Queue[str] = queue.Queue() + self._render() + root.bind("", self._on_resize) + self.search_var.trace_add("write", lambda *_: self._render_devices()) + self._render_devices() + + def _render(self) -> None: + self.title_row("Uzaktan yönetim portalı", "Envanter, sağlık ve kontrollü uzaktan operasyonlar · " + + (f"Aktif kullanıcı: {self.ctx.active_user}" if self.ctx.active_user else "Aktif kullanıcı ayarlanmadı")) + body = ttk.Frame(self, style=self.styles["frame"]) + body.pack(fill="both", expand=True, padx=theme.SP_6) + body.columnconfigure(1, weight=1) + body.rowconfigure(1, weight=1) + nav = ttk.Frame(body, style=self.styles["panel"]) + nav.grid(row=0, column=0, rowspan=2, sticky="nsew", padx=(0, theme.SP_4)) + ttk.Label(nav, text="PORTAL", style=self.styles["panel_title"]).pack(anchor="w", padx=theme.SP_4, pady=theme.SP_4) + for label in ("Genel bakış", "Cihazlar", "Operasyonlar", "Denetim kaydı"): + ttk.Button(nav, text=label, style=self.styles["quiet_button"], state="disabled" if label != "Cihazlar" else "normal").pack(fill="x", padx=theme.SP_3, pady=2) + ttk.Button(nav, text="Kurulum alanına geç", style=self.styles["quiet_button"], command=self._back).pack(side="bottom", fill="x", padx=theme.SP_3, pady=theme.SP_3) + ttk.Button(nav, text="Ayarlar · bayi no değiştir", style=self.styles["quiet_button"], command=self._change_dealer).pack(side="bottom", fill="x", padx=theme.SP_3, pady=(0, theme.SP_3)) + + metrics = ttk.Frame(body, style=self.styles["frame"]) + metrics.grid(row=0, column=1, sticky="ew", pady=(0, theme.SP_4)) + for col, (label, value) in enumerate(self._metrics()): + metrics.columnconfigure(col, weight=1) + card = ttk.Frame(metrics, style=self.styles["panel"]) + card.grid(row=0, column=col, sticky="ew", padx=(0, theme.SP_3) if col < 3 else (0, 0)) + ttk.Label(card, text=label, style=self.styles["panel_title"]).pack(anchor="w", padx=theme.SP_3, pady=(theme.SP_3, 0)) + ttk.Label(card, text=value, style=self.styles["panel_label"]).pack(anchor="w", padx=theme.SP_3, pady=(theme.SP_1, theme.SP_3)) + + devices = ttk.Frame(body, style=self.styles["panel"]) + devices.grid(row=1, column=1, sticky="nsew") + devices.columnconfigure(0, weight=1) + devices.columnconfigure(1, weight=1) + devices.rowconfigure(2, weight=1) + ttk.Label(devices, text="Cihaz envanteri", style=self.styles["panel_title"]).grid(row=0, column=0, sticky="w", padx=theme.SP_4, pady=(theme.SP_4, theme.SP_2)) + ttk.Entry(devices, textvariable=self.search_var, style=self.styles["entry"]).grid(row=1, column=0, sticky="ew", padx=theme.SP_4, pady=(0, theme.SP_3)) + columns = ("host", "wave", "address") + self.tree = ttk.Treeview(devices, columns=columns, show="headings", selectmode="browse") + for column, heading in zip(columns, ("CİHAZ", "DALGA", "ADRES")): + self.tree.heading(column, text=heading) + self.tree.column(column, width=180, anchor="w") + self.tree.grid(row=2, column=0, sticky="nsew", padx=(theme.SP_4, theme.SP_2), pady=(0, theme.SP_3)) + self.tree.bind("<>", self._select_device) + + self.detail = ttk.Frame(devices, style=self.styles["panel"]) + self.detail.grid(row=2, column=1, sticky="nsew", padx=(theme.SP_2, theme.SP_4), pady=(0, theme.SP_3)) + ttk.Label(self.detail, text="Cihaz detayları", style=self.styles["panel_title"]).pack( + anchor="w", padx=theme.SP_4, pady=(theme.SP_4, theme.SP_2)) + self.detail_value = ttk.Label(self.detail, text="Bir cihaz seçin", style=self.styles["panel_label"], justify="left") + self.detail_value.pack(anchor="w", padx=theme.SP_4, pady=(0, theme.SP_3)) + self.detail_hint = ttk.Label(self.detail, text="Envanterden bir satır seçtiğinizde kimlik ve hedef bilgisi burada görünür.", + style=self.styles["panel_muted"], wraplength=260, justify="left") + self.detail_hint.pack(anchor="w", padx=theme.SP_4) + + action = ttk.Frame(self, style=self.styles["panel"]) + action.pack(fill="x", padx=theme.SP_6, pady=(theme.SP_4, theme.SP_3)) + ttk.Label(action, textvariable=self.status_var, style=self.styles["panel_muted"]).pack(side="left", padx=theme.SP_4, pady=theme.SP_3) + self.operation = ttk.Combobox(action, state="readonly", width=32, values=tuple(op.label for op in self.backend.list_operations() if not op.destructive)) + self.operation.pack(side="right", padx=(theme.SP_2, theme.SP_4), pady=theme.SP_3) + ttk.Button(action, text="Salt-okunur çalıştır", style=self.styles["accent_button"], command=self._run_read_only).pack(side="right", pady=theme.SP_3) + + def _metrics(self) -> tuple[tuple[str, str], ...]: + groups = parse_inventory(self.ctx.inventory) if self.ctx.inventory else {} + hosts = sorted({host for values in groups.values() for host in values}) + return (("TOPLAM CİHAZ", str(len(hosts))), ("ÇEVRİMİÇİ", "—"), + ("ÇALIŞAN İŞLEM", "yok"), ("UYARI", "yok")) + + def _on_resize(self, event) -> None: + if event.widget is not self.root or not hasattr(self, "detail"): + return + if event.width < 1050: + self.detail.grid_remove() + else: + self.detail.grid() + + def _render_devices(self) -> None: + if not hasattr(self, "tree"): + return + for item in self.tree.get_children(): + self.tree.delete(item) + groups = parse_inventory(self.ctx.inventory) if self.ctx.inventory else {} + query = self.search_var.get().strip().lower() + hosts = sorted({host for values in groups.values() for host in values}) + for host in hosts: + if query and query not in host.lower(): + continue + wave = next((name for name, values in groups.items() if host in values), "—") + self.tree.insert("", "end", iid=host, values=(host, wave, "envanterden okunur")) + + def _select_device(self, _event=None) -> None: + selected = self.tree.selection() + self.selected_host = selected[0] if selected else None + self.status_var.set(f"Seçili cihaz: {self.selected_host}" if self.selected_host else "Bir cihaz seçin") + if self.selected_host: + item = self.tree.item(self.selected_host) + values = item.get("values", ()) + self.detail_value.configure(text=( + f"{self.selected_host}\n\n" + f"Durum: bağlantı bekleniyor\n" + f"Dalga: {values[1] if len(values) > 1 else '—'}\n" + f"Adres: {values[2] if len(values) > 2 else '—'}" + )) + self.detail_hint.configure(text="Salt-okunur operasyonlar seçili cihaza uygulanır. Yıkıcı işlemler ayrıca onay ve yetki kapısından geçer.") + + def _run_read_only(self) -> None: + if not self.selected_host: + messagebox.showinfo("Cihaz seçin", "Önce envanterden bir cihaz seçin.") + return + label = self.operation.get() + operation = next((op for op in self.backend.list_operations() if op.label == label), None) + if operation is None: + messagebox.showinfo("Operasyon seçin", "Salt-okunur bir operasyon seçin.") + return + self.status_var.set(f"Çalıştırılıyor: {operation.label} — {self.selected_host}") + # The existing backend remains the only command authority. + try: + plan = self.backend.build_command(operation, Target("dealer", self.selected_host.replace("bf-", "")), {}, check_mode=True) + if not messagebox.askyesno("Salt-okunur operasyon", "Komut çalıştırılsın mı?\n\n" + "\n".join(plan.lines), default="no"): + self.status_var.set("İptal edildi") + return + def worker() -> None: + try: + result = self.backend.run(plan) + self.root.after(0, lambda: self.status_var.set( + f"Tamamlandı: çıkış kodu {result.exit_code}")) + except Exception as exc: + message = str(exc) + self.root.after(0, lambda: self.status_var.set(f"Hata: {message}")) + threading.Thread(target=worker, daemon=True).start() + except Exception as exc: + self.status_var.set(f"Hata: {exc}") + + def _back(self) -> None: + self.destroy() + ModePicker(self.root, self.backend, self.ctx) + + def _change_dealer(self) -> None: + dialog = tk.Toplevel(self.root) + dialog.title("Bayi numarasını değiştir") + dialog.transient(self.root) + dialog.grab_set() + value = tk.StringVar(value=self.preferences.get("dealer_id", "")) + ttk.Label(dialog, text="Yeni bayi no (8 hane)", style=self.styles["panel_title"]).pack( + padx=theme.SP_5, pady=(theme.SP_5, theme.SP_2), anchor="w") + entry = ttk.Entry(dialog, textvariable=value, width=20, style=self.styles["entry"]) + entry.pack(padx=theme.SP_5, pady=(0, theme.SP_4), anchor="w") + def apply() -> None: + problem = dealer_error(value.get().strip()) + if problem: + messagebox.showerror("Geçersiz bayi numarası", problem, parent=dialog) + return + if not messagebox.askyesno("Kimliği değiştir", "Aktif kullanıcı bf-" + value.get().strip() + " olarak değiştirilsin mi?", parent=dialog, default="no"): + return + saved = save_dealer(value.get().strip()) + self.ctx.active_dealer = saved["dealer_id"] + self.ctx.active_user = saved["active_user"] + dialog.destroy() + self._back() + ttk.Button(dialog, text="Kaydet ve portalı yenile", style=self.styles["accent_button"], command=apply).pack( + padx=theme.SP_5, pady=(0, theme.SP_5), anchor="w") + entry.focus_set() + + +def launch_surface(root: tk.Tk, backend: Any, ctx: GuiContext, mode: str | None = None) -> SurfaceFrame: + """Launch one independent surface, or the neutral mode picker.""" + surface = {"install": InstallerSurface, "portal": RemotePortalSurface}.get(mode, ModePicker) + return surface(root, backend, ctx) diff --git a/docs/35-GUI-SURFACES.md b/docs/35-GUI-SURFACES.md new file mode 100644 index 0000000..5df0491 --- /dev/null +++ b/docs/35-GUI-SURFACES.md @@ -0,0 +1,36 @@ +# GUI yüzeyleri: kurulum ve uzaktan yönetim + +## Karar + +Python GUI tek bir "yönetim ekranı" olmaktan çıkarılır. Açılışta iki bağımsız çalışma alanı sunulur: + +1. **Kurulum konsolu** — tek fiziksel/uzak cihazın tanımlanması, ön kontrolü, kurulumu ve doğrulaması. +2. **Uzaktan yönetim portalı** — envanterdeki bilgisayarların izlenmesi, seçilmesi ve kontrollü operasyonların çalıştırılması. + +İki yüzey yalnızca `CatalogueBackend`/gerçek backend sözleşmesini paylaşır. Görünüm, navigation, seçili cihaz, form state'i ve çıktı state'i paylaşılmaz. + +## Aktif kullanıcı ve bayi kimliği + +Geçerli bayi numarası uygulamanın aktif kullanıcı kimliğidir: `bf-`. İlk geçerli kurulum onayında yerel GUI tercihlerine kaydedilir ve sonraki açılışlarda otomatik yüklenir. Kurulum ekranındaki alan kayıtlı durumda kilitlidir; değişiklik yalnızca **Bayi no değiştir** onayıyla açılır. Portalın ayarlar bölümü aynı değişikliği açık onayla yapar. Bu tercih uygulama kimliğidir; işletim sistemindeki `blueforce` bakım hesabını sessizce yeniden adlandırmaz. + +## Kurulum konsolu + +- Tek hedef: 8 haneli bayi numarası. +- Dört adımlı zihinsel model: tanımla → ön kontrol → kur/kaydet → doğrula. +- Filo, dalga ve toplu operasyon kontrolleri gösterilmez. +- Komut backend tarafından üretilir, onaydan önce görünür; canlı çıktı ayrı paneldedir. + +## Uzaktan yönetim portalı + +- Ana yüzey envanter tablosudur; arama ve cihaz seçimi ilk sınıf kontrollerdir. +- Portal navigasyonu kurulum akışından ayrıdır. +- İlk sürüm salt-okunur operasyonları açık tutar; yıkıcı filo operasyonları mevcut güvenlik kapıları ve ayrıntılı operasyon ekranına taşınır. +- Portalın sonraki genişlemesi cihaz detay drawer'ı, operasyon kuyruğu ve denetim kaydıdır. + +## Komut satırı + +```text +python3 -m bfos.ui_gui # çalışma alanı seçimi +python3 -m bfos.ui_gui --install # kurulum konsolu +python3 -m bfos.ui_gui --portal # uzaktan yönetim portalı +``` diff --git a/tests/test_bfos_gui_static.py b/tests/test_bfos_gui_static.py index 1a3f5f8..2b743cb 100644 --- a/tests/test_bfos_gui_static.py +++ b/tests/test_bfos_gui_static.py @@ -1046,6 +1046,41 @@ class TestConsoleLayoutContract(StaticCase): def setUpClass(cls) -> None: super().setUpClass() cls.source = UI_GUI_PATH.read_text(encoding="utf-8") + cls.surfaces_source = (REPO_ROOT / "bfos" / "ui_surfaces.py").read_text( + encoding="utf-8" + ) + + def test_install_and_portal_are_separate_surfaces(self) -> None: + for needle in ( + "class InstallerSurface(SurfaceFrame):", + "class RemotePortalSurface(SurfaceFrame):", + "def launch_surface(", + "--install", + "--portal", + ): + with self.subTest(needle=needle): + source = self.surfaces_source if needle.startswith("class") or needle.startswith("def") else self.source + self.assertIn(needle, source) + + def test_legacy_full_flag_opens_the_portal_surface(self) -> None: + self.assertIn('args.portal or args.full', self.source) + + def test_surfaces_share_backend_but_not_view_state(self) -> None: + self.assertIn("They share only the backend contract", self.surfaces_source) + self.assertIn("self.selected_host", self.surfaces_source) + self.assertIn("self.dealer_var", self.surfaces_source) + self.assertIn("ModePicker", self.surfaces_source) + + def test_active_dealer_preference_is_stable_and_changeable(self) -> None: + from bfos.preferences import clear_dealer, load_preferences, save_dealer + + path = Path(self.tmpdir.name) / "blueforce-gui.json" + save_dealer("12010193", path) + self.assertEqual(load_preferences(path)["active_user"], "bf-12010193") + save_dealer("12010999", path) + self.assertEqual(load_preferences(path)["active_user"], "bf-12010999") + clear_dealer(path) + self.assertEqual(load_preferences(path), {}) def test_the_view_has_no_hardcoded_colour(self) -> None: """Renk token'dir, literal degil (docs/DESIGN.md kural 1)."""