Blueforce-Linux/bfos/ui_surfaces.py

442 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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