Blueforce-Linux/bfos/ui_surfaces.py
emirhanmamak-blueforce 503f4df2ae Enhance WireGuard restart playbook with operation wave validation
- Updated `bf-wireguard-restart.yml` to include dynamic host targeting based on `operation_wave`.
- Added pre-tasks to assert that the operation wave is approved and confirmed, ensuring proper execution context.
- Modified the service restart task to use a more structured command format.
- Adjusted `operations.py` and `ui_gui.py` to reflect changes in operation scope from "simple" to "wave" for relevant operations.
- Improved UI handling in `ui_gui.py` and `ui_surfaces.py` for better output management and user interaction.
- Updated log rotation configuration to target specific log files.
- Enhanced systemd service configurations for WireGuard to improve restart behavior.
- Refined firstboot scripts to ensure proper handling of dealer ID and state management.
- Improved error handling and rollback mechanisms in identity and installation scripts.
2026-09-17 17:06:58 +03:00

706 lines
34 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 os
import queue
import subprocess
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._alive = True
self._after_id: str | None = None
self.styles = theme.apply_theme(root)
super().__init__(root, style=self.styles["frame"])
self.pack(fill="both", expand=True)
def destroy(self) -> None:
"""Cancel pending after() callbacks before tearing down widgets."""
self._alive = False
after_id, self._after_id = self._after_id, None
if after_id is not None:
try:
self.after_cancel(after_id)
except Exception:
pass
super().destroy()
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.has_root = getattr(os, "geteuid", lambda: 0)() == 0
self.identity_var = tk.StringVar(value=self._identity_text())
self.dealer_var.trace_add("write", lambda *_: self._on_dealer_changed())
initial_status = (
"Hazır — bayi numarası bekleniyor"
if self.has_root
else "Yönetici yetkisi yok — kurulum için uygulamayı 'sudo bf' ile açın"
)
self.status_var = tk.StringVar(value=initial_status)
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))
self.install_button = ttk.Button(
main, text="Kurulumu başlat", style=self.styles["accent_button"],
command=self._run_install,
state="normal" if self.has_root else "disabled",
)
self.install_button.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:
if not self.has_root:
self.status_var.set("Kurulum engellendi — yönetici yetkisi için: sudo bf")
self._append(
"[ENGELLENDİ] Kurulum yönetici yetkisi ister.\n"
"Bu uygulamayı terminalden 'sudo bf' ile yeniden başlatın.\n"
)
return
dealer = self.dealer_var.get().strip()
problem = dealer_error(dealer)
if problem:
self.status_var.set(problem)
return
if self.preferences.get("dealer_id") and self.preferences["dealer_id"] != dealer:
if not messagebox.askyesno(
"Kimlik geçişini onaylayın",
"Mevcut Linux hesabı, SSH anahtarı, RustDesk ve sudo sahiplikleri yeni kimliğe taşınacak. Devam edilsin mi?",
default="no",
):
self.status_var.set("Kimlik geçişi iptal edildi; mevcut kimlik korundu.")
return
script = self.ctx.repo_root / "scripts" / "identity" / "bf-reidentity"
if not script.is_file():
self.status_var.set("Kimlik geçiş komutu bulunamadı.")
return
self.status_var.set("Kimlik geçişi çalışıyor…")
result = subprocess.run([str(script), dealer], capture_output=True, text=True, check=False)
self._append(result.stdout + result.stderr)
if result.returncode != 0:
self.status_var.set("Kimlik geçişi başarısız; eski kimlik korundu.")
return
saved = load_preferences()
else:
try:
saved = save_dealer(dealer)
except (OSError, PermissionError, ValueError) 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
preview = self._preview_plan(plan, operation, target)
if not messagebox.askyesno("Kurulumu onaylayın", "Bu cihaz için kurulum başlatılsın mı?\n\n" + preview, default="no"):
self.status_var.set("İptal edildi; hiçbir komut çalıştırılmadı.")
return
if plan.destructive and not messagebox.askyesno(
"İKİNCİ ONAY — yıkıcı işlem",
"Bu kurulum seçili cihazda DEĞİŞİKLİK YAPAR (dosya, servis ve sahiplikler).\n"
"Bu onay yalnız bu kurulum için geçerlidir. Gerçekten başlatılsın mı?",
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:
try:
self.output.configure(state="normal")
self.output.insert("end", text)
self.output.see("end")
self.output.configure(state="disabled")
except tk.TclError:
pass
def _preview_plan(self, plan, operation, target) -> str:
"""Yerel plan önizlemesi: komutlar + notlar + yıkıcı/yetki uyarısı."""
lines = ["Çalıştırılacak komut(lar):", ""]
if plan.lines:
for line in plan.lines:
lines.append(f" {line}")
else:
lines.append(" (komut üretilemedi)")
lines.append("")
lines.append(f"İşlem : {operation.label} [{operation.id}]")
lines.append(f"Hedef : {plan.target_label or target.label}")
if plan.needs_root:
lines.append("Yetki : root gerekir — konsolu sudo ile açın.")
for note in plan.notes:
lines.append(f"Not : {note}")
if plan.destructive:
lines.append("")
lines.append("UYARI: Bu YIKICI bir işlemdir; seçili cihazda değişiklik yapar.")
return "\n".join(lines)
def _drain_output(self) -> None:
if not self._alive:
return
drained: list[str] = []
try:
while True:
drained.append(self.output_queue.get_nowait())
except queue.Empty:
pass
for line in drained:
try:
self._append(line)
except tk.TclError:
return
if drained:
terminal = next(
(line.strip() for line in reversed(drained)
if line.strip().startswith("[OK]") or line.strip().startswith("[HATA]")),
None,
)
try:
self.status_var.set(terminal if terminal is not None else "Kurulum çıktısı güncelleniyor…")
except tk.TclError:
return
if self._alive:
try:
self._after_id = self.after(100, self._drain_output)
except tk.TclError:
pass
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._run_done = False
self._render()
root.bind("<Configure>", self._on_resize)
self.search_var.trace_add("write", lambda *_: self._render_devices())
self._render_devices()
self._drain_output()
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))
cards = self._metrics()
for col, (label, value) in enumerate(cards):
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 < len(cards) - 1 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)
log = ttk.Frame(self, style=self.styles["panel"])
log.pack(fill="x", padx=theme.SP_6, pady=(0, theme.SP_4))
ttk.Label(log, text="Çalışma çı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(log, height=4, wrap="none", state="disabled",
font=("TkFixedFont", 10), relief="flat", padx=8, pady=8)
self.output.pack(fill="x", padx=theme.SP_3, pady=(0, 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})
# parse_inventory yalnız host adlarını verir; çevrimiçi/işlem durumu
# için ajan-izleme entegrasyonu gerekir — sahte veri gösterilmez.
return (
("TOPLAM CİHAZ", str(len(hosts))),
("CANLI DURUM", "İzleme entegrasyonu gerekir — canlı veri 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), "—")
# parse_inventory host adından öte adres vermez; bilinmeyen adres "—" kalır.
self.tree.insert("", "end", iid=host, values=(host, wave, "—"))
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
host = self.selected_host
dealer = host[3:] if host.startswith("bf-") else host
problem = dealer_error(dealer)
if problem:
messagebox.showerror("Geçersiz cihaz kimliği", problem)
self.status_var.set(problem)
return
target = Target("dealer", dealer)
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, {}, check_mode=True)
except Exception as exc:
self.status_var.set(f"Hata: {exc}")
return
grants = self._collect_grants(operation, target)
if grants is None:
self.status_var.set("İptal edildi")
return
if grants:
try:
plan = self.backend.build_command(operation, target, {}, grants=grants, check_mode=True)
except Exception as exc:
self.status_var.set(f"Hata: {exc}")
return
if plan.destructive and not hasattr(self.backend, "gate_steps"):
self.status_var.set("Yıkıcı işlem engellendi — ana konsoldan (sudo bf) iki onay ile çalıştırın.")
messagebox.showerror(
"Yıkıcı işlem engellendi",
"Bu plan değişiklik yapar; portal yalnız salt-okunur çalıştırır.\n"
"Yıkıcı işlem için ana konsolu kullanın (sudo bf).",
)
return
preview = "\n".join(plan.lines) if plan.lines else "(komut üretilemedi)"
for note in plan.notes:
preview += f"\nNot: {note}"
if not messagebox.askyesno("Salt-okunur operasyon", "Komut çalıştırılsın mı?\n\n" + preview, default="no"):
self.status_var.set("İptal edildi")
return
self._run_done = False
self.status_var.set(f"Çalıştırılıyor: {operation.label} — {self.selected_host}")
self._append("\n" + "\n".join(plan.lines) + "\n" if plan.lines else "\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 TypeError:
try:
result = self.backend.run(plan)
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")
except Exception as exc:
self.output_queue.put(f"[HATA] {exc}\n")
threading.Thread(target=worker, daemon=True).start()
def _collect_grants(self, operation, target) -> dict | None:
"""Backend kapılarını sorar; iptalde None döner (ana konsol ile aynı sıra)."""
gate_steps = getattr(self.backend, "gate_steps", None)
if not callable(gate_steps):
return {}
try:
steps = gate_steps(operation, target)
except CommandError as exc:
messagebox.showerror("Kapı onayları alınamadı", str(exc))
return None
grants: dict = {}
for step in steps:
kind = getattr(step, "kind", "approval")
prompt = getattr(step, "prompt", None) or "Onay"
message = getattr(step, "message", None) or "Devam edilsin mi?"
if kind == "token":
token = getattr(step, "token", "") or ""
answer = self._token_dialog(prompt, message)
if answer is None or answer.strip() != token:
self.status_var.set("Kapı onayı verilmedi; işlem iptal edildi.")
return None
elif not messagebox.askyesno(prompt, message, default="no"):
self.status_var.set("Kapı onayı verilmedi; işlem iptal edildi.")
return None
for name in (getattr(step, "unlocks", ()) or ()):
grants[name] = True
return grants
def _token_dialog(self, title: str, message: str) -> str | None:
dialog = tk.Toplevel(self.root)
dialog.title(title)
dialog.transient(self.root)
dialog.grab_set()
value = tk.StringVar()
ttk.Label(dialog, text=message, style=self.styles["panel_title"],
wraplength=320, justify="left").pack(
padx=theme.SP_5, pady=(theme.SP_5, theme.SP_2), anchor="w")
entry = ttk.Entry(dialog, textvariable=value, width=28, style=self.styles["entry"])
entry.pack(padx=theme.SP_5, pady=(0, theme.SP_4), anchor="w")
result: dict = {"value": None}
def confirm() -> None:
result["value"] = value.get()
dialog.destroy()
def cancel() -> None:
dialog.destroy()
ttk.Button(dialog, text="Onayla", style=self.styles["accent_button"],
command=confirm).pack(padx=theme.SP_5, pady=(0, theme.SP_2), anchor="w")
ttk.Button(dialog, text="Vazgeç", style=self.styles["quiet_button"],
command=cancel).pack(padx=theme.SP_5, pady=(0, theme.SP_5), anchor="w")
entry.focus_set()
dialog.wait_window()
return result["value"]
def _append(self, text: str) -> None:
try:
self.output.configure(state="normal")
except tk.TclError:
return
try:
self.output.insert("end", text)
self.output.see("end")
self.output.configure(state="disabled")
except tk.TclError:
pass
def _drain_output(self) -> None:
if not self._alive:
return
drained: list[str] = []
try:
while True:
drained.append(self.output_queue.get_nowait())
except queue.Empty:
pass
for line in drained:
try:
self._append(line)
except tk.TclError:
return
if drained:
terminal = next(
(line.strip() for line in reversed(drained)
if line.strip().startswith("[OK]") or line.strip().startswith("[HATA]")),
None,
)
try:
if terminal is not None:
self.status_var.set(terminal)
self._run_done = True
elif not self._run_done:
self.status_var.set("Çalışma çıktısı güncelleniyor…")
except tk.TclError:
return
if self._alive:
try:
self._after_id = self.after(100, self._drain_output)
except tk.TclError:
pass
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
try:
saved = save_dealer(value.get().strip())
except (OSError, PermissionError, ValueError) as exc:
messagebox.showerror("Kayıt başarısız", f"Bayi numarası kaydedilemedi: {exc}", parent=dialog)
self.status_var.set(f"Ayar kaydedilemedi: {exc}")
return
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)