feat(gui): implement installer and remote portal surfaces with user preferences

This commit is contained in:
emirhanmamak-blueforce 2026-09-17 09:50:40 +03:00
parent 04134c3096
commit 708453677d
5 changed files with 590 additions and 1 deletions

53
bfos/preferences.py Normal file
View file

@ -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

View file

@ -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

442
bfos/ui_surfaces.py Normal file
View file

@ -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("<Configure>", 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("<<TreeviewSelect>>", 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)

36
docs/35-GUI-SURFACES.md Normal file
View file

@ -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-<bayi-no>`. İ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ı
```

View file

@ -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)."""