- 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.
706 lines
34 KiB
Python
706 lines
34 KiB
Python
"""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)
|