feat(gui): implement installer and remote portal surfaces with user preferences
This commit is contained in:
parent
04134c3096
commit
708453677d
5 changed files with 590 additions and 1 deletions
53
bfos/preferences.py
Normal file
53
bfos/preferences.py
Normal 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
|
||||||
|
|
@ -1295,16 +1295,27 @@ class GuiContext:
|
||||||
inventory: Path | None = None
|
inventory: Path | None = None
|
||||||
console_log: str = DEFAULT_CONSOLE_LOG
|
console_log: str = DEFAULT_CONSOLE_LOG
|
||||||
dry_run: bool = False
|
dry_run: bool = False
|
||||||
|
active_dealer: str | None = None
|
||||||
|
active_user: str | None = None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def detect(cls, repo: str | None = None, inventory: str | None = None,
|
def detect(cls, repo: str | None = None, inventory: str | None = None,
|
||||||
dry_run: bool = False) -> "GuiContext":
|
dry_run: bool = False) -> "GuiContext":
|
||||||
root = Path(repo).expanduser().resolve() if repo else repo_root_from_file()
|
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(
|
return cls(
|
||||||
repo_root=root,
|
repo_root=root,
|
||||||
inventory=resolve_inventory(root, inventory),
|
inventory=resolve_inventory(root, inventory),
|
||||||
console_log=os.environ.get("BF_CONSOLE_LOG", DEFAULT_CONSOLE_LOG),
|
console_log=os.environ.get("BF_CONSOLE_LOG", DEFAULT_CONSOLE_LOG),
|
||||||
dry_run=dry_run,
|
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",
|
parser.add_argument("--full", action="store_true",
|
||||||
help="Doğrudan detaylı modda başlat (kategori sekmeleri).")
|
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("--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("--inventory", default=None, help="Ansible envanteri (varsayılan: otomatik).")
|
||||||
parser.add_argument("--dry-run", action="store_true",
|
parser.add_argument("--dry-run", action="store_true",
|
||||||
|
|
@ -4360,7 +4375,15 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
|
||||||
root = tk.Tk()
|
root = tk.Tk()
|
||||||
try:
|
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
|
except tk.TclError as exc: # DISPLAY var ama erisilemiyor
|
||||||
print(window_start_error(exc), file=sys.stderr)
|
print(window_start_error(exc), file=sys.stderr)
|
||||||
return 3
|
return 3
|
||||||
|
|
|
||||||
442
bfos/ui_surfaces.py
Normal file
442
bfos/ui_surfaces.py
Normal 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
36
docs/35-GUI-SURFACES.md
Normal 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ı
|
||||||
|
```
|
||||||
|
|
@ -1046,6 +1046,41 @@ class TestConsoleLayoutContract(StaticCase):
|
||||||
def setUpClass(cls) -> None:
|
def setUpClass(cls) -> None:
|
||||||
super().setUpClass()
|
super().setUpClass()
|
||||||
cls.source = UI_GUI_PATH.read_text(encoding="utf-8")
|
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:
|
def test_the_view_has_no_hardcoded_colour(self) -> None:
|
||||||
"""Renk token'dir, literal degil (docs/DESIGN.md kural 1)."""
|
"""Renk token'dir, literal degil (docs/DESIGN.md kural 1)."""
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue