diff --git a/bfos/ui_gui.py b/bfos/ui_gui.py index bfc60e4..bfe9f22 100644 --- a/bfos/ui_gui.py +++ b/bfos/ui_gui.py @@ -109,6 +109,7 @@ Notlar: from __future__ import annotations import argparse +import json import os import queue import re @@ -132,6 +133,19 @@ SIMPLE_MODE_BUTTONS: tuple[str, ...] = ("Kur", "Durum", "Çıkış") SIMPLE_MODE_LABEL = "Detaylı mod" SIMPLE_MODE_BACK_LABEL = "Basit moda dön" +#: Birincil eylemin metni (docs/33 §9.2 ile ayni kalir). +RUN_BUTTON_LABEL = "Komutu göster ve çalıştır" + +#: Islem listesindeki tur ve durum sutunlari: renk + SOZCUK (renksiz de okunur). +TYPE_WRITE_LABEL = "YIKICI" +TYPE_READ_LABEL = "salt-okunur" +AVAILABLE_LABEL = "açık" +BLOCKED_LABEL = "kapalı — sudo bf" + +#: Komut cikti panelini gizle/goster (kanit erisilebilir kalir, ekrani kaplamaz). +OUTPUT_HIDE_LABEL = "Çıktı panelini gizle" +OUTPUT_SHOW_LABEL = "Çıktı panelini göster" + # Detayli moddaki kategori sekmeleri (docs/33 §9.2 ile ayni bes kategori). CATEGORIES: tuple[str, ...] = ( "Kurulum", @@ -220,16 +234,141 @@ class _MissingTk: try: # pragma: no cover - ortama bagli import tkinter as tk from tkinter import messagebox, ttk - from tkinter import scrolledtext HAVE_TKINTER = True TK_IMPORT_ERROR = "" except Exception as _tk_exc: # pragma: no cover - ortama bagli HAVE_TKINTER = False TK_IMPORT_ERROR = str(_tk_exc) - tk = ttk = messagebox = scrolledtext = _MissingTk() # type: ignore[assignment] + tk = ttk = messagebox = _MissingTk() # type: ignore[assignment] +# --------------------------------------------------------------------------- +# Tasarim belirtecleri -- tek kaynak: bfos/ui_theme.py, o da +# web/static/tokens.css'i birebir yansitir. Bu dosya renk/secim/bosluk +# degeri URETMEZ; hepsi temadan gelir (docs/DESIGN.md "Rules"). +# --------------------------------------------------------------------------- +try: + from bfos import ui_theme as theme +except ImportError: # pragma: no cover - dosya paket disinda yuklendiginde + _THEME_DIR = str(Path(__file__).resolve().parent) + if _THEME_DIR not in sys.path: + sys.path.insert(0, _THEME_DIR) + import ui_theme as theme # type: ignore[no-redef] + + +# --------------------------------------------------------------------------- +# Durum sozlugu (docs/DESIGN.md "Status vocabulary") +# --------------------------------------------------------------------------- +#: Bir cihaz durumunun TUM sozcukleri. Renk tek basina anlam tasimaz: her +#: rozet sozcugunu da yazar, liste renksiz de okunur. +DEVICE_STATES: tuple[str, ...] = ("KURULDU", "KAYITLI", "HAZIR", "HATA", "YOK") + +#: Sozcuk -> token anahtari (tema ile ayni esleme). +STATE_KEYS_BY_WORD: Mapping[str, str] = { + word: key for key, word in theme.STATE_WORDS.items() +} + +#: state.json'da bir denetimin dustugunu gosteren isaretler. +FAILURE_MARKERS: tuple[str, ...] = ("FAIL", "HATA", "ERROR", "BOZUK") + +#: Yetki seviyeleri: tam yetki ve salt-okunur GORUNUR birer durumdur. +PRIVILEGE_FULL = "full" +PRIVILEGE_READ_ONLY = "read_only" +PRIVILEGE_WORDS: Mapping[str, str] = { + PRIVILEGE_FULL: "TAM YETKİ", + PRIVILEGE_READ_ONLY: "SALT-OKUNUR", +} + + +def read_state_values(path: str = STATE_JSON_PATH) -> dict[str, str]: + """state.json'u BEYAZ LISTE ile okur (jeton/anahtar alanlari okunmaz). + + Okunamayan/yok dosya bos sozluk doner: cihaz kurulmamis olabilir ve bu bir + hata degil, ``YOK`` durumudur. + """ + try: + with open(path, "r", encoding="utf-8") as handle: + data = json.load(handle) + except (OSError, ValueError): + return {} + if not isinstance(data, dict): + return {} + values: dict[str, str] = {} + for key in STATE_ALLOWED_KEYS: + value = data.get(key) + if value is None or isinstance(value, bool): + continue + values[key] = str(value) + return values + + +def state_word_from_values(values: Mapping[str, str]) -> str: + """state.json alanlarini sozlukteki TEK sozcuge indirger. + + Sira onemli: once HATA (bir denetim dustu), sonra HAZIR (her kanal + dogrulandi), sonra KAYITLI (kaydedildi, kanallar eksik), sonra KURULDU + (kuruldu, henuz kaydedilmedi). Alan yoksa YOK: hic gorulmemis cihaz. + Acik olumsuzlama ("NOT_ENROLLED") kayit SAYILMAZ: o cihaz kaydedilmemistir. + """ + if not values: + return "YOK" + phase = str(values.get("phase", "")).upper() + fleet = str(values.get("fleet_status", "")).upper() + enrollment = str(values.get("enrollment_status", "")).upper() + if any(marker in field for marker in FAILURE_MARKERS + for field in (phase, fleet, enrollment)): + return "HATA" + if any("READY" in field for field in (phase, fleet, enrollment)): + return "HAZIR" + if any(field.endswith("ENROLLED") and not field.startswith(("NOT_", "UN")) + for field in (phase, fleet, enrollment)): + return "KAYITLI" + if phase or fleet or enrollment: + return "KURULDU" + return "YOK" + + +def device_state_word(path: str = STATE_JSON_PATH) -> str: + """Bu cihazin yerel kurulum/kayit durumu (sozluk sozcugu).""" + return state_word_from_values(read_state_values(path)) + + +def state_badge_style(word: str) -> str: + """Sozcukten rozet stilini cozer; bilinmeyen sozcuk notr rozete duser.""" + key = STATE_KEYS_BY_WORD.get(word) + if key is None: + return theme.STATE_BADGE_STYLE["absent"] + return theme.STATE_BADGE_STYLE[key] + + +def privilege_level(root: bool | None = None) -> str: + """'full' ya da 'read_only'; yetki bir durumdur, satir degil.""" + granted = is_root() if root is None else bool(root) + return PRIVILEGE_FULL if granted else PRIVILEGE_READ_ONLY + + +def privilege_word(level: str) -> str: + """Yetki seviyesinin ekranda gorunen sozcugu.""" + return PRIVILEGE_WORDS.get(level, PRIVILEGE_WORDS[PRIVILEGE_READ_ONLY]) + + +def write_blocked_reason(operation) -> str | None: + """Yazma islemi kapaliysa NEDENINI doner; islem calisabilirse None. + + Yazma islemleri yetkisiz oturumda GIZLENMEZ: listede kalir, dugu kapali + cizilir ve nedenin metni dugmenin kendi uzerinde yazar + (docs/DESIGN.md "Privilege"). + """ + if not operation.needs_root: + return None + return None if write_operations_enabled() else NON_ROOT_DISABLED_HINT + + +def operation_is_write(operation) -> bool: + """True when the operation changes the device (catalogue rule: root).""" + return bool(operation.needs_root) + # --------------------------------------------------------------------------- # Veri tipleri (backend sozlesmesi) @@ -2392,23 +2531,71 @@ def add_repo_to_path(repo_root: Path) -> None: # Cikti paneli + canli akis # --------------------------------------------------------------------------- class OutputPane: - """Kaydirilabilir, canli guncellenen cikti alani (tkinter sarmalayicisi).""" + """Komut ciktisi kuyusu: salt-okunur GORUNEN, icerlek bir metin alani. - def __init__(self, parent, height: int = 16): - self.frame = ttk.LabelFrame(parent, text="Komut çıktısı") - self.frame.pack(fill="both", expand=True, padx=8, pady=(4, 8)) - self.text = scrolledtext.ScrolledText( - self.frame, height=height, wrap="word", font=("monospace", 10) + Olculen sorun: alan duzenlenebilir gibi duruyordu ve ic bosluğu yoktu. Bu + yuzden alan bir kuyuya (``--bg-sunken``) oturur, 12px ic bosluk tasir, + ``cursor="arrow"`` ve ``takefocus=0`` ile "buraya yazilmaz" der; yazi tipi + veri yuzudur (monospace) ki cikis kodlari ve kimlikler hizalansin. + + Ciktinin ham hali KANITTIR, arayuz degil (docs/DESIGN.md kural 6): sonuc + once durum rozetinde gorunur, bu alan kaniti tutar ve gizlenebilir. + """ + + def __init__(self, parent, styles: Mapping[str, str], height: int = 14, + log_path: str = DEFAULT_CONSOLE_LOG): + self.styles = styles + self.frame = ttk.Frame(parent, style=styles["panel"]) + self.frame.pack(fill="both", expand=True) + + head = ttk.Frame(self.frame, style=styles["panel"]) + head.pack(fill="x", padx=theme.SP_3, pady=(theme.SP_3, theme.SP_2)) + ttk.Label(head, text="Komut çıktısı", style=styles["panel_title"]).pack(side="left") + ttk.Label( + head, + text=f"salt-okunur · denetim kaydı: {log_path}", + style=styles["panel_faint"], + ).pack(side="right") + + self.well = ttk.Frame(self.frame, style=styles["sunken"]) + self.well.pack(fill="both", expand=True, + padx=theme.SP_3, pady=(0, theme.SP_3)) + self.text = tk.Text( + self.well, + height=height, + wrap="word", + font=theme.data_font(theme.SIZE_SM), + background=theme.BG_SUNKEN, + foreground=theme.TEXT, + relief="flat", + borderwidth=0, + highlightthickness=0, + padx=theme.SP_3, + pady=theme.SP_2, + cursor="arrow", + takefocus=0, + selectbackground=theme.ACCENT_SOFT, + selectforeground=theme.TEXT, + inactiveselectbackground=theme.ACCENT_SOFT, ) - self.text.pack(fill="both", expand=True, padx=6, pady=6) + self.text.pack(side="left", fill="both", expand=True) + bar = ttk.Scrollbar(self.well, orient="vertical", command=self.text.yview) + self.text.configure(yscrollcommand=bar.set) + bar.pack(side="right", fill="y") self.text.configure(state="disabled") - self._queue: "queue.Queue[str | None]" = queue.Queue() + + self._queue: "queue.Queue[str]" = queue.Queue() self._parent = parent def append(self, line: str) -> None: """Herhangi bir is parcacigindan guvenle cagrilabilir (kuyruga yazar).""" self._queue.put(line) + def _insert(self, line: str) -> None: + self.text.configure(state="normal") + self.text.insert("end", line + "\n") + self.text.configure(state="disabled") + def drain(self) -> None: """Ana is parcaciginda kuyrugu bosaltir (after ile periyodik cagrilir).""" wrote = False @@ -2417,9 +2604,7 @@ class OutputPane: line = self._queue.get_nowait() except queue.Empty: break - self.text.configure(state="normal") - self.text.insert("end", line + "\n") - self.text.configure(state="disabled") + self._insert(line) wrote = True if wrote: self.text.see("end") @@ -2440,17 +2625,39 @@ class OutputPane: line = self._queue.get_nowait() except queue.Empty: break - self.text.configure(state="normal") - self.text.insert("end", line + "\n") - self.text.configure(state="disabled") + self._insert(line) self.text.see("end") + # -- gorunurluk (kanit erisilebilir kalir, ekrani kaplamaz) ------------- + def set_visible(self, visible: bool) -> None: + """Cikti kuyusunu gosterir/gizler; panel basligi yerinde kalir.""" + if visible: + self.well.pack(fill="both", expand=True, + padx=theme.SP_3, pady=(0, theme.SP_3)) + else: + self.well.pack_forget() + + def is_visible(self) -> bool: + return bool(self.well.winfo_manager()) + # --------------------------------------------------------------------------- # Arayuz # --------------------------------------------------------------------------- class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) - """Blueforce Field OS Turkce masaustu arayuzu.""" + """Blueforce Field OS Turkce masaustu arayuzu. + + Yerlesim docs/DESIGN.md "Direction contract" yonergesine gore kurulur: + koyu bir baslik bandi (urun adi, hedef kimligi, cihaz durumu, yetki + seviyesi), altinda beyaz paneller halinde hedef cubugu, islem listesi ve + secenek paneli, bir kuyuda komut ciktisi ve tek bir eylem cubugu. Renk + yalnizca DURUM tasir; tum degerler bfos/ui_theme.py'den gelir (o da + web/static/tokens.css'i yansitir), bu dosyada sabit renk yoktur. + """ + + #: Etiket sutunu genisligi: hedef satiri ve secenek satirlari AYNI hizada + #: baslar (olculen sorun: tutarsiz etiket genislikleri ve sikisik satir). + FIELD_LABEL_WIDTH = 22 def __init__(self, root, backend, ctx: GuiContext, *, full_mode: bool = False): self.root = root @@ -2467,126 +2674,312 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) } self._selected_operation: Operation | None = None self._current_plan: CommandPlan | None = None + self._rows: dict[str, tuple[Operation, ...]] = {} + self._filters: dict[str, Any] = {} + self._output_visible = True + # Tema her widget'tan ONCE uygulanir: ttk stilleri dogduklari anda + # gecerli olmali, sonradan boyamak gri bir pencere ve titreme uretir. + self.styles = theme.apply_theme(root) root.title(WINDOW_TITLE) - root.minsize(980, 720) - ttk.Style().configure(".", font=("Sans", 10)) + root.minsize(1040, 760) + root.columnconfigure(0, weight=1) + # Bantlar: baslik -> uyarilar -> hedef -> govde -> cikti -> eylem. + root.rowconfigure(3, weight=3) + root.rowconfigure(4, weight=2) self._build_header() - self._build_mode_bar() - self.simple_frame = ttk.Frame(root) - self.detail_frame = ttk.Frame(root) + self._build_notices() + self._build_target_bar() + + self.body = ttk.Frame(root, style=self.styles["frame"]) + self.body.grid(row=3, column=0, sticky="nsew", + padx=theme.SP_4, pady=(theme.SP_3, 0)) + self.simple_frame = ttk.Frame(self.body, style=self.styles["frame"]) + self.detail_frame = ttk.Frame(self.body, style=self.styles["frame"]) self._build_simple_mode(self.simple_frame) self._build_detail_mode(self.detail_frame) - self.output = OutputPane(root, height=18) + + output_area = ttk.Frame(root, style=self.styles["frame"]) + output_area.grid(row=4, column=0, sticky="nsew", + padx=theme.SP_4, pady=(theme.SP_3, 0)) + self.output = OutputPane(output_area, self.styles, height=7, + log_path=ctx.console_log) self.output.drain() self._build_footer() + self._apply_privilege_controls() self.show_mode(full=full_mode) self._load_inventory() - # -- ust bilgi --------------------------------------------------------- + # -- kucuk yardimcilar ------------------------------------------------- + def _badge(self, parent, word: str, style: str): + """Sozcuklu rozet: renk TEK BASINA anlam tasimaz (kural 2).""" + return ttk.Label(parent, text=word, style=style) + + def _bind_wrap(self, label, padding: int) -> None: + """Etiketi KENDI genisligine gore sarar: dar pencerede kirpilmasin. + + Sabit bir wraplength dar pencerede metni kesiyordu; bu yuzden sarma + siniri her yeniden boyutlanmada panelin gercek genisliginden turetilir. + """ + def on_configure(event) -> None: + width = max(theme.SP_6, int(event.width) - padding) + if str(label.cget("wraplength")) != str(width): + label.configure(wraplength=width) + + label.bind("", on_configure) + + def _set_status(self, word: str, severity: str = "idle") -> None: + """Durum gostergesi: sozcuk + renk (duz gri bir etiket degil).""" + self.status.configure(text=word, style=theme.severity_style(severity)) + + # -- ust bilgi: koyu bant --------------------------------------------- def _build_header(self) -> None: - header = ttk.Frame(self.root) - header.pack(fill="x", padx=8, pady=(8, 2)) - ttk.Label(header, text=WINDOW_TITLE, font=("Sans", 15, "bold")).pack(anchor="w") - ttk.Label(header, text=APP_SUBTITLE, foreground="#444").pack(anchor="w") - ttk.Label( - header, - text=f"Depo: {self.ctx.repo_root}", - foreground="#666", - ).pack(anchor="w") - self.backend_note = ttk.Label(header, text=self.backend.name, foreground="#666") - self.backend_note.pack(anchor="w") + """Baslik bandi: urun adi, hedef kimligi, cihaz durumu, yetki. + + Yetki seviyesi burada GORUNUR bir ogedir (rozet), gomulu bir cumle + degil: "bu pencereden ne yapabilirim?" sorusunun cevabi bir adim + geriden okunur (docs/DESIGN.md "Privilege"). + """ + band = ttk.Frame(self.root, style=self.styles["header"]) + band.grid(row=0, column=0, sticky="ew") + band.columnconfigure(2, weight=1) + + identity = ttk.Frame(band, style=self.styles["header"]) + identity.grid(row=0, column=0, sticky="w", + padx=theme.SP_4, pady=(theme.SP_3, theme.SP_1)) + ttk.Label(identity, text=WINDOW_TITLE, + style=self.styles["header_title"]).pack(anchor="w") + ttk.Label(identity, text=APP_SUBTITLE, + style=self.styles["header_meta"]).pack(anchor="w") + + target = ttk.Frame(band, style=self.styles["header"]) + target.grid(row=0, column=1, sticky="w", padx=(theme.SP_5, 0), + pady=(theme.SP_3, theme.SP_1)) + ttk.Label(target, text="HEDEF", style=self.styles["header_meta"]).pack(anchor="w") + self.header_target = ttk.Label(target, text="— seçilmedi", + style=self.styles["header_target"]) + self.header_target.pack(anchor="w") + + badges = ttk.Frame(band, style=self.styles["header"]) + badges.grid(row=0, column=3, sticky="e", + padx=theme.SP_4, pady=(theme.SP_3, theme.SP_1)) + + # Iki rozet TEK satirda: bandin yuksekligi token'a (56px) yakin kalsin + # diye baskin satir urun adi + alt basligidir, rozetler degil. + self._state_word = device_state_word() + ttk.Label(badges, text="Cihaz durumu", + style=self.styles["header_meta"]).pack(side="left") + self.state_badge = self._badge(badges, self._state_word, + state_badge_style(self._state_word)) + self.state_badge.pack(side="left", padx=(theme.SP_2, theme.SP_5)) + + self.privilege_level = privilege_level() + ttk.Label(badges, text="Yetki", + style=self.styles["header_meta"]).pack(side="left") + self.privilege_badge = self._badge( + badges, privilege_word(self.privilege_level), + theme.PRIVILEGE_BADGE_STYLE[self.privilege_level]) + self.privilege_badge.pack(side="left", padx=(theme.SP_2, 0)) if self.ctx.dry_run: - ttk.Label( - header, - text="DENEME KİPİ (--dry-run): komutlar gösterilir, çalıştırılmaz.", - foreground="#a05000", - ).pack(anchor="w") + self._badge(badges, "DENEME KİPİ", + self.styles["badge_info"]).pack(side="left", + padx=(theme.SP_3, 0)) + + def _build_notices(self) -> None: + """Yetki ve kip uyarilari: basligin hemen altinda renkli bir serit. + + Olculen sorun: kritik "Yonetici yetkisi yok" uyarisi monospace bir + paragrafta gomuluydu. Simdi yetki hem baslikta rozet, hem burada + sozcuk + renk; ustune duzene doken tam cumle. + """ + notices = ttk.Frame(self.root, style=self.styles["frame"]) + shown = False if not is_root(): - # Söylenmezse operatör kurulumu başlatır ve yarı yolda yetki - # hatasıyla karşılaşır; kapı bu yüzden başlangıçta görünür. - ttk.Label( - header, - text=ROOT_REQUIRED_NOTICE, - foreground="#a05000", - ).pack(anchor="w") + # Soylenmezse operator kurulumu baslatir ve yari yolda yetki + # hatasiyla karsilasir; kapi bu yuzden acilista gorunur. + band = ttk.Frame(notices, style=self.styles["warn_band"]) + band.pack(fill="x") + inner = ttk.Frame(band, style=self.styles["warn_band"]) + inner.pack(fill="x", padx=theme.SP_3, pady=theme.SP_2) + ttk.Label(inner, text=privilege_word(PRIVILEGE_READ_ONLY), + style=self.styles["readonly"]).pack(side="left", + padx=(0, theme.SP_3)) + ttk.Label(inner, text=ROOT_REQUIRED_NOTICE, + style=self.styles["warn_label"]).pack(side="left") + shown = True + if self.ctx.dry_run: + band = ttk.Frame(notices, style=self.styles["info_band"]) + band.pack(fill="x", pady=(theme.SP_1, 0) if shown else (0, 0)) + inner = ttk.Frame(band, style=self.styles["info_band"]) + inner.pack(fill="x", padx=theme.SP_3, pady=theme.SP_2) + ttk.Label(inner, text="DENEME KİPİ", + style=self.styles["badge_info"]).pack(side="left", + padx=(0, theme.SP_3)) + ttk.Label(inner, text="Komutlar gösterilir, çalıştırılmaz.", + style=self.styles["info_label"]).pack(side="left") + shown = True + if shown: + notices.grid(row=1, column=0, sticky="ew", padx=theme.SP_4, + pady=(theme.SP_3, 0)) + + # -- hedef cubugu ------------------------------------------------------ + def _build_target_bar(self) -> None: + """Hedef paneli: grid ile hizali etiketler, tek ritim. + + Olculen sorun: satir sikisik ve etiket genislikleri tutarsizdi. Her + etiket ayni sutunda ve ayni sag kenara yaslanir; alanlar arasindaki + bosluk 4px ritmine gore verilir (SP_2 / SP_3 / SP_5). + """ + panel = ttk.Frame(self.root, style=self.styles["panel"]) + panel.grid(row=2, column=0, sticky="ew", padx=theme.SP_4, + pady=(theme.SP_3, 0)) + panel.columnconfigure(4, weight=1) + + ttk.Label(panel, text="HEDEF", style=self.styles["panel_title"]).grid( + row=0, column=0, sticky="w", padx=theme.SP_3, + pady=(theme.SP_2, 0)) + self.mode_button = ttk.Button(panel, text=SIMPLE_MODE_LABEL, + style=self.styles["button"], + command=self.toggle_mode) + self.mode_button.grid(row=0, column=4, sticky="e", padx=theme.SP_3, + pady=(theme.SP_2, 0)) - def _build_mode_bar(self) -> None: - bar = ttk.Frame(self.root) - bar.pack(fill="x", padx=8, pady=(6, 0)) - self.mode_button = ttk.Button(bar, text=SIMPLE_MODE_LABEL, command=self.toggle_mode) - self.mode_button.pack(side="left") - ttk.Label(bar, text="Hedef:").pack(side="left", padx=(16, 4)) self.dealer_var = tk.StringVar() - self.dealer_entry = ttk.Entry(bar, textvariable=self.dealer_var, width=12) - self.dealer_entry.pack(side="left") - self.dealer_entry.bind("", lambda _e: self._on_dealer_change()) - ttk.Label(bar, text="Bayi no (8 hane)").pack(side="left", padx=(4, 12)) self.wave_var = tk.StringVar() - self.wave_combo = ttk.Combobox( - bar, textvariable=self.wave_var, values=list(WAVES), width=14, state="readonly" - ) - self.wave_combo.pack(side="left") - ttk.Label(bar, text="Dalga").pack(side="left", padx=(4, 12)) self.all_var = tk.IntVar(value=0) - ttk.Checkbutton( - bar, text="Tüm filo (dikkat)", variable=self.all_var, - command=self._on_target_change, - ).pack(side="left") - self.target_status = ttk.Label(bar, text="", foreground="#a00000") - self.target_status.pack(side="left", padx=10) + ttk.Label(panel, text="Bayi no (8 hane)", style=self.styles["field_label"], + width=16, anchor="e").grid( + row=1, column=0, sticky="e", padx=(theme.SP_3, theme.SP_2), + pady=theme.SP_2) + self.dealer_entry = ttk.Entry(panel, textvariable=self.dealer_var, + width=14, style=self.styles["entry"]) + self.dealer_entry.grid(row=1, column=1, sticky="w", pady=theme.SP_2) + self.dealer_entry.bind("", lambda _e: self._on_dealer_change()) + + ttk.Label(panel, text="Dalga", style=self.styles["field_label"], + width=8, anchor="e").grid( + row=1, column=2, sticky="e", padx=(theme.SP_5, theme.SP_2), + pady=theme.SP_2) + self.wave_combo = ttk.Combobox(panel, textvariable=self.wave_var, + values=list(WAVES), width=14, + state="readonly") + self.wave_combo.grid(row=1, column=3, sticky="w", pady=theme.SP_2) + self.wave_combo.bind("<>", + lambda _e: self._on_target_change()) + + ttk.Checkbutton(panel, text="Tüm filo (dikkat)", variable=self.all_var, + style=self.styles["panel_check"], + command=self._on_target_change).grid( + row=1, column=4, sticky="w", padx=theme.SP_5, pady=theme.SP_2) + + self.target_status = ttk.Label(panel, text="", + style=self.styles["panel_muted"]) + self.target_status.grid(row=2, column=0, columnspan=5, sticky="w", + padx=theme.SP_3, pady=(0, theme.SP_2)) + + # -- eylem cubugu ------------------------------------------------------ def _build_footer(self) -> None: - footer = ttk.Frame(self.root) - footer.pack(fill="x", padx=8, pady=(0, 8)) - self.run_button = ttk.Button( - footer, text="Komutu göster ve çalıştır", command=self._on_run_clicked - ) + """Eylem cubugu, IKI satir: eylem + durum, altinda sessiz araclar. + + Tek satira sigdirilan cubukta dugmeler birbirini sikistiriyor ve + sonuncusunun yazisi kirpiliyordu (olculdu: "Cikis" dugmesi "Ck" + gorunuyordu). Birincil eylem kendi satirini alir; yardimcilar ikinci + satirda durur, boylece hicbir kontrol digerinin yerini yemez. + """ + footer = ttk.Frame(self.root, style=self.styles["frame"]) + footer.grid(row=5, column=0, sticky="ew", padx=theme.SP_4, + pady=(theme.SP_3, theme.SP_4)) + footer.columnconfigure(0, weight=1) + + action = ttk.Frame(footer, style=self.styles["frame"]) + action.grid(row=0, column=0, sticky="ew") + self.run_button = ttk.Button(action, text=RUN_BUTTON_LABEL, + style=self.styles["accent_button"], + command=self._on_run_clicked) self.run_button.pack(side="left") - ttk.Button(footer, text="Çıktıyı temizle", command=self._clear_output).pack( - side="left", padx=6 - ) - self.cancel_button = ttk.Button( - footer, text="Çalışanı iptal et", command=self._cancel, state="disabled" - ) - self.cancel_button.pack(side="left", padx=6) + # Kapali bir yazma isleminin NEDENI, dugmenin yanindaki ayni satirda + # durur (docs/DESIGN.md "Privilege": gizleme yok, nedeni goster). + self.run_reason = ttk.Label(action, text="", style=self.styles["app_warn"]) + self.run_reason.pack(side="left", padx=(theme.SP_3, 0)) + self.status = ttk.Label(action, text="HAZIR", style=self.styles["status"]) + self.status.pack(side="right") + + tools = ttk.Frame(footer, style=self.styles["frame"]) + tools.grid(row=1, column=0, sticky="ew", pady=(theme.SP_2, 0)) + ttk.Button(tools, text="Çıktıyı temizle", style=self.styles["button"], + command=self._clear_output).pack(side="left") + self.cancel_button = ttk.Button(tools, text="Çalışanı iptal et", + style=self.styles["button"], + command=self._cancel, state="disabled") + self.cancel_button.pack(side="left", padx=(theme.SP_2, 0)) self.check_var = tk.IntVar(value=0) - ttk.Checkbutton( - footer, text="Yalnız denetim (--check)", variable=self.check_var - ).pack(side="left", padx=6) - ttk.Button(footer, text="Çıkış", command=self.root.destroy).pack(side="right") - self.status = ttk.Label(footer, text="Hazır.") - self.status.pack(side="right", padx=12) + ttk.Checkbutton(tools, text="Yalnız denetim (--check)", + variable=self.check_var, + style=self.styles["check"]).pack(side="left", + padx=(theme.SP_3, 0)) + self.output_button = ttk.Button(tools, text=OUTPUT_HIDE_LABEL, + style=self.styles["quiet_button"], + command=self._toggle_output) + self.output_button.pack(side="left", padx=(theme.SP_2, 0)) + + # Tek cikis kontrolu: burada (detayli mod) ya da basit modda ucuncu + # buyuk dugme olarak. Ikisi AYNI ANDA gorunmez (olculen sorun: "Cik" + # iki kez duruyordu). + self.exit_button = ttk.Button(tools, text="Çıkış", + style=self.styles["button"], + command=self.root.destroy) + + # Depo ve katalog kaynagi: sessiz bir satir. Baslik bandini token + # yuksekliginde (56px) tutmak icin banddan asagi alindi ve hicbir + # dugmeyle yer yarisi yapmamasi icin kendi satirina konuldu: depo yolu + # okunmasi gereken bir bilgi degil, gorunur kalmasi yeter. + ttk.Label( + footer, + text=f"Depo: {self.ctx.repo_root} · {self.backend.name}", + style=self.styles["faint"], + ).grid(row=2, column=0, sticky="w", pady=(theme.SP_2, 0)) # -- basit mod --------------------------------------------------------- def _build_simple_mode(self, parent) -> None: - box = ttk.Frame(parent) - box.pack(expand=True, fill="both", padx=24, pady=24) - ttk.Label( - box, - text="Ne yapmak istiyorsunuz?", - font=("Sans", 13, "bold"), - ).pack(anchor="w", pady=(0, 12)) + """Basit mod: iki is karari ve TEK cikis dugmesi. + + Cikis burada ucuncu buyuk dugmedir; alt cubuktaki kucuk "Cikis" + dugmesi bu modda gizlenir, boylece ayni eylem iki kez durmaz. + """ + box = ttk.Frame(parent, style=self.styles["panel"]) + box.pack(fill="both", expand=True) + inner = ttk.Frame(box, style=self.styles["panel"]) + inner.pack(fill="both", expand=True, padx=theme.SP_6, pady=theme.SP_6) + ttk.Label(inner, text="Ne yapmak istiyorsunuz?", + style=self.styles["section"]).pack(anchor="w", + pady=(0, theme.SP_4)) for label in SIMPLE_MODE_BUTTONS: command = { "Kur": self._simple_install, "Durum": self._simple_status, "Çıkış": self.root.destroy, }[label] - button = ttk.Button(box, text=label, command=command) - button.configure(width=18) - button.pack(fill="x", pady=8, ipady=10) + # Aksan yalnizca "operatorun geldigi eylem" icin: Kur. + style_name = (self.styles["big_accent_button"] if label == "Kur" + else self.styles["big_button"]) + button = ttk.Button(inner, text=label, command=command, + style=style_name, width=18) + button.pack(fill="x", pady=theme.SP_2) + if label == "Kur": + self.install_button = button ttk.Label( - box, + inner, text=( "Kur: bu cihazı kurar (bayi no gerekir). Durum: salt-okunur özet.\n" "Daha fazlası için üstteki '" + SIMPLE_MODE_LABEL + "' düğmesini kullanın." ), - foreground="#555", + style=self.styles["panel_muted"], justify="left", - ).pack(anchor="w", pady=(12, 0)) + ).pack(anchor="w", pady=(theme.SP_4, 0)) + self._apply_privilege_controls() def _simple_install(self) -> None: choice = self._choice_dialog( @@ -2611,64 +3004,156 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) # -- detayli mod ------------------------------------------------------- def _build_detail_mode(self, parent) -> None: - self.notebook = ttk.Notebook(parent) - self.notebook.pack(fill="both", expand=True, padx=8, pady=8) + """Detayli mod: ozet serit + bes kategori sekmesi. + + Ozet seritte TEK buyuk rakam var (islem sayisi): DESIGN.md kural 3, + "her gorunumde tek yuksek sesli rakam". Geri kalan her sey sessiz ve + hizali. Yazma islemlerinin sayisi ayrica durur cunku "kac islem cihazi + degistirir?" sorusu bu ekranda sorulur. + """ + strip = ttk.Frame(parent, style=self.styles["frame"]) + strip.pack(fill="x", pady=(0, theme.SP_3)) + operations = tuple(self._ops_by_id.values()) + writes = sum(1 for op in operations if operation_is_write(op)) + ttk.Label(strip, text=str(len(operations)), + style=self.styles["figure"]).pack(side="left") + ttk.Label(strip, text="işlem", style=self.styles["muted"]).pack( + side="left", padx=(theme.SP_1, theme.SP_4)) + ttk.Label(strip, text=f"{writes} yıkıcı", style=self.styles["muted"]).pack( + side="left", padx=(0, theme.SP_3)) + ttk.Label(strip, text=f"{len(operations) - writes} salt-okunur", + style=self.styles["muted"]).pack(side="left") + if self.privilege_level == PRIVILEGE_READ_ONLY: + ttk.Label(strip, text=f"yazma işlemleri kapalı · {NON_ROOT_DISABLED_HINT}", + style=self.styles["app_warn"]).pack(side="right") + for category in CATEGORIES: - frame = ttk.Frame(self.notebook) + # Satirlar backend'den gelir (katalog sirasi korunur); backend bir + # kategoriyi bos birakirsa yerlesik tabloya dusulur. + self._rows[category] = tuple( + op for op in self._ops_by_id.values() if op.category == category + ) or operations_by_category(category) + + self.notebook = ttk.Notebook(parent) + self.notebook.pack(fill="both", expand=True) + for category in CATEGORIES: + frame = ttk.Frame(self.notebook, style=self.styles["frame"]) self.notebook.add(frame, text=category) self._build_category_tab(frame, category) def _build_category_tab(self, frame, category: str) -> None: - left = ttk.Frame(frame) - left.pack(side="left", fill="both", expand=True, padx=6, pady=6) - columns = ("islem", "kapsam", "tur") - tree = ttk.Treeview(left, columns=columns, show="headings", height=14) + """Sekme: solda islem listesi (suzgecli), sagda secenekler paneli.""" + frame.columnconfigure(0, weight=3) + frame.columnconfigure(1, weight=2) + frame.rowconfigure(0, weight=1) + + left = ttk.Frame(frame, style=self.styles["panel"]) + left.grid(row=0, column=0, sticky="nsew", padx=(0, theme.SP_3)) + left.columnconfigure(0, weight=1) + left.rowconfigure(2, weight=1) + + head = ttk.Frame(left, style=self.styles["panel"]) + head.grid(row=0, column=0, columnspan=2, sticky="ew", padx=theme.SP_3, + pady=(theme.SP_3, theme.SP_2)) + ttk.Label(head, text="İŞLEMLER", style=self.styles["panel_title"]).pack(side="left") + ttk.Label(head, text=f"{len(self._rows[category])} işlem", + style=self.styles["panel_faint"]).pack(side="right") + + filt = ttk.Frame(left, style=self.styles["panel"]) + filt.grid(row=1, column=0, columnspan=2, sticky="ew", padx=theme.SP_3, + pady=(0, theme.SP_2)) + ttk.Label(filt, text="Süz:", style=self.styles["field_label"], + width=5, anchor="e").pack(side="left", padx=(0, theme.SP_2)) + query = tk.StringVar() + self._filters[category] = query + entry = ttk.Entry(filt, textvariable=query, style=self.styles["entry"]) + entry.pack(side="left", fill="x", expand=True) + entry.bind("", lambda _e, c=category: self._render_rows(c)) + + columns = ("islem", "kapsam", "tur", "durum") + # height=9 satir: minimum pencerede (1040x760) govdeye bu kadar yer + # ayirilir; liste zaten kaydirilabilir ve satirlar agirlikla buyur. + tree = ttk.Treeview(left, columns=columns, show="headings", height=9) tree.heading("islem", text="İşlem") tree.heading("kapsam", text="Kapsam") tree.heading("tur", text="Tür") - tree.column("islem", width=360) - tree.column("kapsam", width=200) - tree.column("tur", width=110, anchor="center") + tree.heading("durum", text="Durum") + tree.column("islem", width=320, anchor="w") + tree.column("kapsam", width=200, anchor="w") + tree.column("tur", width=100, anchor="center") + tree.column("durum", width=150, anchor="center") + tree.grid(row=2, column=0, sticky="nsew") scroll = ttk.Scrollbar(left, orient="vertical", command=tree.yview) tree.configure(yscrollcommand=scroll.set) - tree.pack(side="left", fill="both", expand=True) - scroll.pack(side="left", fill="y") + scroll.grid(row=2, column=1, sticky="ns") tree.bind( "<>", lambda _e, t=tree, c=category: self._on_operation_selected(t, c), ) + # Yazma islemleri listede KALIR (gizlenmez): tur sutunu "YIKICI" yazar + # ve yetkisiz oturumda durum sutunu "KAPALI — sudo bf" der. + tree.tag_configure("destructive", foreground=theme.DANGER) + tree.tag_configure("blocked", foreground=theme.IDLE) setattr(self, f"_tree_{category}", tree) - for op in operations_by_category(category): - tree.insert( - "", - "end", - iid=op.id, - values=( - op.label, - SCOPE_LABELS.get(op.scope, op.scope), - "YIKICI" if op.destructive else "salt-okunur", - ), - tags=("destructive",) if op.destructive else (), - ) - tree.tag_configure("destructive", foreground="#a00000") + self._render_rows(category) - right = ttk.LabelFrame(frame, text="Seçenekler ve açıklama") - right.pack(side="left", fill="both", expand=True, padx=6, pady=6) - detail_label = ttk.Label(right, text="Bir işlem seçin.", wraplength=380, + right = ttk.Frame(frame, style=self.styles["panel"]) + right.grid(row=0, column=1, sticky="nsew") + right.columnconfigure(0, weight=1) + ttk.Label(right, text="SEÇENEKLER VE AÇIKLAMA", + style=self.styles["panel_title"]).grid( + row=0, column=0, sticky="w", padx=theme.SP_3, + pady=(theme.SP_3, theme.SP_2)) + detail_label = ttk.Label(right, text="Bir işlem seçin.", + style=self.styles["panel_label"], justify="left") - detail_label.pack(anchor="w", padx=8, pady=8) - options_frame = ttk.Frame(right) - options_frame.pack(fill="x", padx=8, pady=4) + detail_label.grid(row=1, column=0, sticky="w", padx=theme.SP_3) + self._bind_wrap(detail_label, theme.SP_4) + options_frame = ttk.Frame(right, style=self.styles["panel"]) + options_frame.grid(row=2, column=0, sticky="ew", padx=theme.SP_3, + pady=theme.SP_3) + options_frame.columnconfigure(1, weight=1) # Sekme basina ayri secenek alani: her sekme kendi islemini gosterir. self._panes = getattr(self, "_panes", {}) self._panes[category] = (detail_label, options_frame) - ttk.Label( + hint = ttk.Label( right, text="Komut her zaman önce gösterilir ve onaysız çalıştırılmaz.", - foreground="#555", - wraplength=380, + style=self.styles["panel_muted"], justify="left", - ).pack(anchor="w", padx=8, pady=(8, 4)) + ) + hint.grid(row=3, column=0, sticky="w", padx=theme.SP_3, + pady=(0, theme.SP_3)) + self._bind_wrap(hint, theme.SP_4) + + def _render_rows(self, category: str) -> None: + """Liste satirlarini (varsa suzgece gore) yeniden cizer.""" + tree = getattr(self, f"_tree_{category}") + query = self._filters[category].get().strip().casefold() + selected = tree.selection() + for item in tree.get_children(): + tree.delete(item) + for op in self._rows[category]: + if query and query not in f"{op.label} {op.id}".casefold(): + continue + blocked = write_blocked_reason(op) is not None + tags = ("destructive",) if op.destructive else () + if blocked: + tags += ("blocked",) + tree.insert( + "", "end", iid=op.id, + values=( + op.label, + SCOPE_LABELS.get(op.scope, op.scope), + TYPE_WRITE_LABEL if op.destructive else TYPE_READ_LABEL, + BLOCKED_LABEL if blocked else AVAILABLE_LABEL, + ), + tags=tags, + ) + for item in selected: + if tree.exists(item): + tree.selection_set(item) + break def _on_operation_selected(self, tree, category: str) -> None: selection = tree.selection() @@ -2679,41 +3164,69 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) return self._selected_operation = operation self._render_options(operation, category) + self._refresh_run_control() - def _render_options(self, operation: Operation, category: str) -> None: - detail_label, options_frame = self._panes[category] - for child in options_frame.winfo_children(): - child.destroy() - self.option_widgets = {} + def _operation_summary(self, operation: Operation) -> str: + """Secili islemin ekranda gorunen ozeti (kapsam + yetki + tur).""" text = operation.label + "\n\n" + (operation.description or "Açıklama yok.") if operation.playbook: text += f"\n\nAnsible playbook: {PLAYBOOK_DIR_REL}/{operation.playbook}" text += f"\nKapsam: {SCOPE_LABELS.get(operation.scope, operation.scope)}" + text += ("\nYetki: root gerekir (sudo bf)" if operation.needs_root + else "\nYetki: gerekmez (salt-okunur)") if operation.destructive: text += "\n\nBU İŞLEM YIKICIDIR: iki kez onay istenir." - detail_label.configure(text=text) + return text + + def _render_options(self, operation: Operation, category: str) -> None: + """Secenekleri hizali bir izgaraya yerlestirir (etiketler ayni hizada).""" + detail_label, options_frame = self._panes[category] + for child in options_frame.winfo_children(): + child.destroy() + self.option_widgets = {} + detail_label.configure(text=self._operation_summary(operation)) + row = 0 for spec in operation.options: - row = ttk.Frame(options_frame) - row.pack(fill="x", pady=3) - ttk.Label(row, text=spec.label + ":", width=32, anchor="w").pack(side="left") + ttk.Label(options_frame, text=spec.label + ":", + style=self.styles["field_label"], + width=self.FIELD_LABEL_WIDTH, anchor="e").grid( + row=row, column=0, sticky="e", padx=(0, theme.SP_2), + pady=theme.SP_1) if spec.kind == "bool": var = tk.BooleanVar(value=spec.default == "1") - ttk.Checkbutton(row, variable=var).pack(side="left") + widget = ttk.Checkbutton(options_frame, variable=var, + style=self.styles["panel_check"]) self.option_widgets[spec.name] = ("bool", var) - widget = None elif spec.kind == "choice": var = tk.StringVar(value=spec.default or (spec.choices[0] if spec.choices else "")) - widget = ttk.Combobox(row, textvariable=var, values=list(spec.choices), - width=24, state="readonly") + widget = ttk.Combobox(options_frame, textvariable=var, + values=list(spec.choices), width=24, + state="readonly") self.option_widgets[spec.name] = ("str", var) else: var = tk.StringVar(value=spec.default) - widget = ttk.Entry(row, textvariable=var, width=26) + widget = ttk.Entry(options_frame, textvariable=var, width=26) self.option_widgets[spec.name] = ("str", var) - if widget is not None: - widget.pack(side="left") + widget.grid(row=row, column=1, sticky="w", pady=theme.SP_1) if spec.hint: - ttk.Label(row, text=f" {spec.hint}", foreground="#666").pack(side="left") + hint = ttk.Label(options_frame, text=spec.hint, + style=self.styles["panel_faint"], + justify="left") + hint.grid(row=row, column=2, sticky="w", padx=(theme.SP_2, 0)) + self._bind_wrap(hint, theme.SP_2) + row += 1 + + badges = ttk.Frame(options_frame, style=self.styles["panel"]) + self._badge(badges, + TYPE_WRITE_LABEL if operation.destructive else TYPE_READ_LABEL, + self.styles["badge_destructive"] if operation.destructive + else self.styles["badge_readonly"]).pack(side="left") + if write_blocked_reason(operation): + self._badge(badges, BLOCKED_LABEL, + self.styles["badge_closed"]).pack(side="left", + padx=(theme.SP_2, 0)) + badges.grid(row=row, column=0, columnspan=3, sticky="w", + pady=(theme.SP_2, 0)) # -- hedef ------------------------------------------------------------- def _load_inventory(self) -> None: @@ -2723,22 +3236,47 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) self.wave_var.set(waves[0]) note = self.backend.inventory_note() self.output.write_now(f"[i] {note}") - self.status.configure(text="Hazır.") + self._set_status("HAZIR", "idle") + self._on_target_change() def _on_dealer_change(self) -> None: value = self.dealer_var.get().strip() problem = dealer_error(value) if value else None - self.target_status.configure(text=problem or "") - if not problem: - self.status.configure(text=f"Bayi no geçerli: {dealer_to_device_id(value)}") + # Alanin kendisi de durumu gosterir: gecersiz giris cercevede. + self.dealer_entry.configure( + style=self.styles["invalid_entry"] if problem else self.styles["entry"] + ) + if problem: + self.target_status.configure(text=problem, + style=self.styles["panel_danger"]) + elif value: + self.target_status.configure( + text=f"Bayi no geçerli: {dealer_to_device_id(value)}", + style=self.styles["panel_ok"]) + self._set_status("HAZIR", "idle") + else: + self.target_status.configure(text="", style=self.styles["panel_muted"]) + self._update_header_target() def _on_target_change(self) -> None: if self.all_var.get(): self.target_status.configure( - text="Tüm filo seçildi: dalga kapılı işlemler dalga dalga ilerler." + text="Tüm filo seçildi: dalga kapılı işlemler dalga dalga ilerler.", + style=self.styles["panel_muted"], ) + self.dealer_entry.configure(style=self.styles["entry"]) else: self._on_dealer_change() + self._update_header_target() + + def _update_header_target(self) -> None: + """Basliktaki hedef kimligi her secim degisikliginde guncellenir.""" + target = self.current_target() + if target is None or (target.kind == "dealer" + and not validate_dealer(target.value)): + self.header_target.configure(text="— seçilmedi") + return + self.header_target.configure(text=target.label) def current_target(self) -> Target | None: if self.all_var.get(): @@ -2751,6 +3289,59 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) return Target("group", wave) return None + # -- yetki ve birincil eylem ------------------------------------------ + def _apply_privilege_controls(self) -> None: + """Yetkisiz oturum: yazma dugmeleri KAPALI, nedeni dugmenin uzerinde. + + Gizleme yok: yetkisiz operatorde "bu islem var ama kapali" bilgisi + gorunur kalmali, yoksa araci eksik sanir (docs/DESIGN.md "Privilege"). + """ + if hasattr(self, "install_button"): + if self.privilege_level == PRIVILEGE_READ_ONLY: + self.install_button.configure( + text="Kur — kapalı (yönetici yetkisi gerekir: sudo bf)", + state="disabled", + ) + else: + self.install_button.configure(text=SIMPLE_MODE_BUTTONS[0], + state="normal") + if hasattr(self, "run_button"): + self._refresh_run_control() + + def _refresh_run_control(self) -> None: + """Birincil eylem: aksan, yikici icin kirmizi, yetkisizde kapali.""" + operation = self._selected_operation + self.run_reason.configure(text="") + if operation is None: + self.run_button.configure(text=RUN_BUTTON_LABEL, + style=self.styles["accent_button"], + state="normal") + return + reason = write_blocked_reason(operation) + if reason: + self.run_button.configure(text=RUN_BUTTON_LABEL + " — kapalı", + style=self.styles["button"], + state="disabled") + self.run_reason.configure(text=reason, style=self.styles["app_warn"]) + return + self.run_button.configure( + text=RUN_BUTTON_LABEL, + style=(self.styles["danger_button"] if operation.destructive + else self.styles["accent_button"]), + state="normal", + ) + if operation.destructive: + self.run_reason.configure(text="Yıkıcı işlem: iki kez onay istenir.", + style=self.styles["app_warn"]) + + def _toggle_output(self) -> None: + """Kanit panelini gizler/gosterir; sonuc durum rozetinde kalir.""" + self._output_visible = not self._output_visible + self.output.set_visible(self._output_visible) + self.output_button.configure( + text=OUTPUT_HIDE_LABEL if self._output_visible else OUTPUT_SHOW_LABEL + ) + # -- mod degistirme ---------------------------------------------------- def show_mode(self, *, full: bool) -> None: self.simple_frame.pack_forget() @@ -2758,9 +3349,13 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) if full: self.detail_frame.pack(fill="both", expand=True) self.mode_button.configure(text=SIMPLE_MODE_BACK_LABEL) + # Tek cikis kontrolu: detayli modda alt cubukta, basit modda ucuncu + # buyuk dugme olarak durur; ikisi ayni anda gorunmez. + self.exit_button.pack(side="right") else: self.simple_frame.pack(fill="both", expand=True) self.mode_button.configure(text=SIMPLE_MODE_LABEL) + self.exit_button.pack_forget() self.full_mode = full def toggle_mode(self) -> None: @@ -2918,13 +3513,13 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) # in the one place every run goes through keeps the rule true for # the whole surface instead of one control. self.output.write_now("[ENGELLENDİ] " + NON_ROOT_DISABLED_HINT) - self.status.configure(text="Yönetici yetkisi gerekli.") + self._set_status("YETKİ YOK", "danger") return self.running = True self.cancel_event.clear() self.run_button.configure(state="disabled") self.cancel_button.configure(state="normal") - self.status.configure(text="Çalışıyor… (iptal için 'Çalışanı iptal et')") + self._set_status("ÇALIŞIYOR", "info") def worker() -> None: try: @@ -2940,18 +3535,18 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) def _finish_run(self, result: RunResult) -> None: self.running = False - self.run_button.configure(state="normal") + self._refresh_run_control() self.cancel_button.configure(state="disabled") if result.ok: self.output.append("[OK] Komut başarıyla tamamlandı (çıkış kodu 0).") - self.status.configure(text="Tamamlandı (çıkış kodu 0).") + self._set_status("TAMAM (çıkış kodu 0)", "ok") else: self.output.append(f"[HATA] Komut {result.exit_code} koduyla bitti.") - self.status.configure(text=f"Hata: çıkış kodu {result.exit_code}") + self._set_status(f"HATA (çıkış kodu {result.exit_code})", "danger") def _cancel(self) -> None: self.cancel_event.set() - self.status.configure(text="İptal istendi…") + self._set_status("İPTAL İSTENDİ", "warn") def _clear_output(self) -> None: self.output.clear() @@ -2961,23 +3556,30 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) choices: Sequence[tuple[str, str | None]]) -> str | None: dialog = tk.Toplevel(self.root) dialog.title(title) + dialog.configure(bg=theme.BG_APP) dialog.transient(self.root) dialog.grab_set() result: dict[str, str | None] = {"value": None} - ttk.Label(dialog, text=message, wraplength=460, justify="left").pack( - padx=16, pady=16, anchor="w" + ttk.Label(dialog, text=message, style=self.styles["dialog"], + wraplength=460, justify="left").pack( + padx=theme.SP_4, pady=theme.SP_4, anchor="w" ) def choose(value: str | None) -> None: result["value"] = value dialog.destroy() - row = ttk.Frame(dialog) - row.pack(fill="x", padx=16, pady=(0, 16)) - for label, value in choices: - ttk.Button(row, text=label, command=lambda v=value: choose(v)).pack( - side="left", padx=4 + row = ttk.Frame(dialog, style=self.styles["frame"]) + row.pack(fill="x", padx=theme.SP_4, pady=(0, theme.SP_4)) + for index, (label, value) in enumerate(choices): + # Ilk secenek aksandir (operatorun geldigi eylem), "Vazgec" sessiz. + style_name = (self.styles["quiet_button"] if value is None + else self.styles["accent_button"] if index == 0 + else self.styles["button"]) + ttk.Button(row, text=label, style=style_name, + command=lambda v=value: choose(v)).pack( + side="left", padx=(0, theme.SP_2) ) dialog.wait_window() return result["value"] @@ -2985,15 +3587,18 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) def _token_dialog(self, title: str, message: str, token: str) -> str | None: dialog = tk.Toplevel(self.root) dialog.title(title) + dialog.configure(bg=theme.BG_APP) dialog.transient(self.root) dialog.grab_set() result: dict[str, str | None] = {"value": None} - ttk.Label(dialog, text=message, wraplength=460, justify="left").pack( - padx=16, pady=16, anchor="w" + ttk.Label(dialog, text=message, style=self.styles["dialog"], + wraplength=460, justify="left").pack( + padx=theme.SP_4, pady=theme.SP_4, anchor="w" ) var = tk.StringVar() - entry = ttk.Entry(dialog, textvariable=var, width=32) - entry.pack(padx=16, anchor="w") + entry = ttk.Entry(dialog, textvariable=var, width=32, + style=self.styles["entry"]) + entry.pack(padx=theme.SP_4, anchor="w") entry.focus_set() def confirm() -> None: @@ -3004,10 +3609,12 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) result["value"] = None dialog.destroy() - row = ttk.Frame(dialog) - row.pack(fill="x", padx=16, pady=16) - ttk.Button(row, text="Onayla", command=confirm).pack(side="left", padx=4) - ttk.Button(row, text="Vazgeç", command=cancel).pack(side="left", padx=4) + row = ttk.Frame(dialog, style=self.styles["frame"]) + row.pack(fill="x", padx=theme.SP_4, pady=theme.SP_4) + ttk.Button(row, text="Onayla", style=self.styles["accent_button"], + command=confirm).pack(side="left", padx=(0, theme.SP_2)) + ttk.Button(row, text="Vazgeç", style=self.styles["quiet_button"], + command=cancel).pack(side="left") dialog.bind("", lambda _e: confirm()) dialog.wait_window() return result["value"] diff --git a/bfos/ui_theme.py b/bfos/ui_theme.py new file mode 100644 index 0000000..153049b --- /dev/null +++ b/bfos/ui_theme.py @@ -0,0 +1,745 @@ +"""bfos/ui_theme.py -- design tokens for the device console. + +The values in this file MIRROR ``web/static/tokens.css``, which is the single +source of truth for both operator surfaces (the device console and the central +panel). Nothing here invents a colour: a colour means a device state, a +severity, or the action accent, and a fifth hue would be a bug (docs/DESIGN.md, +"Rules"). The mirror is not a comment-level promise -- ``TOKENS`` carries the +CSS custom-property name of every value, and ``tests/test_bfos_gui_static.py`` +parses ``tokens.css`` and compares the two files, so a token changed on one +surface and not the other fails the suite instead of drifting. + +Why light, not dark +------------------- +The console runs in workshops, dealer service areas and customer sites: bright +overhead light, daylight from a roller door, a 14" laptop panel. A near-black +console loses its own body text there and its state colours collapse into +equal-brightness smudges. So the ground is light (``BG_APP``), the panels are +white (``BG_PANEL``), the one dark region is the header (``BG_HEADER``), and +saturation is spent only where it carries meaning. + +Faces +----- +No webfont, and no font download: the fleet installs offline, so the UI face is +whatever the platform ships and the data face is monospace (ids, counts, exit +codes, timestamps line up in a column). ``apply_theme`` picks the first family +the platform actually has, from a candidate list -- never a missing font name, +which is a silently different layout. + +Sizes and the 4px rhythm +------------------------ +Type sizes and space steps are kept in the token's own unit (CSS px) so the +mirror is exact. Tk font sizes are points, so ``point_size`` converts with the +96 dpi relation the tokens assume; padding and row heights are already pixels +in Tk, so they are used as ``SP_*`` values directly. + +Usage +----- + theme = ui_theme.apply_theme(root) # once, before any widget is built + ttk.Label(parent, style=theme.panel_label) +""" + +from __future__ import annotations + +from typing import Dict, Mapping, Sequence + +try: # pragma: no cover - environment dependent + from tkinter import ttk + + HAVE_TKINTER = True +except Exception: # pragma: no cover - environment dependent + ttk = None # type: ignore[assignment] + HAVE_TKINTER = False + + +# --------------------------------------------------------------------------- +# Ground and structure -- tokens.css: "Ground and structure" +# --------------------------------------------------------------------------- +BG_APP = "#f6f7f8" # window ground +BG_PANEL = "#ffffff" # raised surfaces, cards, table body +BG_SUNKEN = "#eef0f2" # wells: log output, code, inactive rows +BG_HEADER = "#1d2530" # app header; the one dark region +BORDER = "#d5d9de" # hairline between regions +BORDER_STRONG = "#b6bcc4" # input borders, table rules + +# --------------------------------------------------------------------------- +# Text +# --------------------------------------------------------------------------- +TEXT = "#1a1f26" # body +TEXT_MUTED = "#5c6570" # secondary lines, labels +TEXT_FAINT = "#8b939c" # timestamps, hints, placeholders +TEXT_ON_DARK = "#e8ebee" # header text +TEXT_ON_ACCENT = "#ffffff" + +# --------------------------------------------------------------------------- +# Action -- one accent: the action the operator came to take, and the +# selected row. Nothing else. +# --------------------------------------------------------------------------- +ACCENT = "#1d4ed8" +ACCENT_HOVER = "#1a43bd" +ACCENT_ACTIVE = "#16389e" +ACCENT_SOFT = "#e8eeff" # selected row, focus ring fill +FOCUS_RING = "#1d4ed8" + +# --------------------------------------------------------------------------- +# State severities -- four, each with a strong form for text and a soft form +# for fills, so a badge is built from one palette without inventing a second. +# --------------------------------------------------------------------------- +OK = "#15803d" +OK_SOFT = "#e6f4ea" +WARN = "#b45309" +WARN_SOFT = "#fdf3e3" +DANGER = "#b91c1c" +DANGER_SOFT = "#fdecec" +INFO = "#1d4ed8" +INFO_SOFT = "#e8eeff" +IDLE = "#5c6570" # not yet acted on: not a failure +IDLE_SOFT = "#eef0f2" + +# --------------------------------------------------------------------------- +# Device state machine -- PROVISIONED_OFFLINE -> ENROLLED -> READY, plus the +# failure and the absence that sit outside the sequence. Named so no surface +# repaints a stage by accident. +# +# PROVISIONED is deliberately NOT the action accent (tokens.css states the +# reason): the accent means "the action you came to take" and "the selected +# row", so a KURULDU badge built from it made a state and a selection the same +# blue. It is a slate of its own, one step along the path and distinct from the +# neutral --idle. +# --------------------------------------------------------------------------- +STATE_PROVISIONED = "#475569" +STATE_ENROLLED = WARN +STATE_READY = OK +STATE_FAILED = DANGER +STATE_ABSENT = IDLE + +# --------------------------------------------------------------------------- +# Type (px, as the tokens declare them) +# --------------------------------------------------------------------------- +SIZE_XS = 12 +SIZE_SM = 13 +SIZE_MD = 15 +SIZE_LG = 18 +SIZE_XL = 22 +SIZE_2XL = 30 + +LEADING_TIGHT = 1.25 +LEADING_NORMAL = 1.5 + +# --------------------------------------------------------------------------- +# Space -- a 4px rhythm, named so a component never hand-picks 7px +# --------------------------------------------------------------------------- +SP_1 = 4 +SP_2 = 8 +SP_3 = 12 +SP_4 = 16 +SP_5 = 24 +SP_6 = 32 +SP_7 = 48 + +RADIUS = 6 +RADIUS_LG = 10 + +# --------------------------------------------------------------------------- +# Metrics +# --------------------------------------------------------------------------- +ROW_H = 44 # fleet table row: comfortable at arm's length +CONTROL_H = 36 # buttons and inputs share one height +HEADER_H = 56 +SIDEBAR_W = 220 + +# Elevation is a token with no Tk equivalent (Tk has no box-shadow); the names +# are kept so the two token sets stay comparable and the absence is explicit. +SHADOW_SM = "0 1px 2px rgba(16, 24, 40, .06)" +SHADOW_MD = "0 4px 12px rgba(16, 24, 40, .10)" + +# --------------------------------------------------------------------------- +# Fonts -- candidates in preference order, resolved against the platform +# --------------------------------------------------------------------------- +FONT_UI_CANDIDATES: Sequence[str] = ( + "Noto Sans", + "DejaVu Sans", + "Ubuntu", + "Liberation Sans", + "Segoe UI", + "Helvetica", +) +FONT_DATA_CANDIDATES: Sequence[str] = ( + "DejaVu Sans Mono", + "Liberation Mono", + "Noto Sans Mono", + "Consolas", + "Menlo", + "Courier New", +) +FONT_UI_FALLBACK = "TkDefaultFont" +FONT_DATA_FALLBACK = "TkFixedFont" + +#: CSS px -> Tk points, at the 96 dpi the tokens assume. Table, not a formula: +#: rounding is per size so the console keeps the token's steps instead of +#: collapsing two of them onto the same point size. +POINT_SIZES: Mapping[int, int] = { + SIZE_XS: 9, + SIZE_SM: 10, + SIZE_MD: 11, + SIZE_LG: 14, + SIZE_XL: 16, + SIZE_2XL: 22, +} + +# --------------------------------------------------------------------------- +# The mirror table -- CSS custom property -> value. Kept as data so the parity +# test reads the same list the code uses. +# --------------------------------------------------------------------------- +TOKENS: Mapping[str, str] = { + "--bg-app": BG_APP, + "--bg-panel": BG_PANEL, + "--bg-sunken": BG_SUNKEN, + "--bg-header": BG_HEADER, + "--border": BORDER, + "--border-strong": BORDER_STRONG, + "--text": TEXT, + "--text-muted": TEXT_MUTED, + "--text-faint": TEXT_FAINT, + "--text-on-dark": TEXT_ON_DARK, + "--text-on-accent": TEXT_ON_ACCENT, + "--accent": ACCENT, + "--accent-hover": ACCENT_HOVER, + "--accent-active": ACCENT_ACTIVE, + "--accent-soft": ACCENT_SOFT, + "--focus-ring": FOCUS_RING, + "--ok": OK, + "--ok-soft": OK_SOFT, + "--warn": WARN, + "--warn-soft": WARN_SOFT, + "--danger": DANGER, + "--danger-soft": DANGER_SOFT, + "--info": INFO, + "--info-soft": INFO_SOFT, + "--idle": IDLE, + "--idle-soft": IDLE_SOFT, + "--state-provisioned": STATE_PROVISIONED, + "--state-enrolled": STATE_ENROLLED, + "--state-ready": STATE_READY, + "--state-failed": STATE_FAILED, + "--state-absent": STATE_ABSENT, +} + +#: Space tokens, mirrored by name (the parity test compares these too). +SPACE_TOKENS: Mapping[str, int] = { + "--sp-1": SP_1, + "--sp-2": SP_2, + "--sp-3": SP_3, + "--sp-4": SP_4, + "--sp-5": SP_5, + "--sp-6": SP_6, + "--sp-7": SP_7, +} + +#: Type-size tokens, mirrored by name. +SIZE_TOKENS: Mapping[str, int] = { + "--size-xs": SIZE_XS, + "--size-sm": SIZE_SM, + "--size-md": SIZE_MD, + "--size-lg": SIZE_LG, + "--size-xl": SIZE_XL, + "--size-2xl": SIZE_2XL, +} + +#: Metric tokens, mirrored by name. +METRIC_TOKENS: Mapping[str, int] = { + "--row-h": ROW_H, + "--control-h": CONTROL_H, + "--header-h": HEADER_H, + "--sidebar-w": SIDEBAR_W, +} + +#: Every mirrored value, by CSS token name (used by the parity test). +ALL_TOKENS: Mapping[str, str] = { + **TOKENS, + **{name: str(value) for name, value in SPACE_TOKENS.items()}, + **{name: str(value) for name, value in SIZE_TOKENS.items()}, + **{name: str(value) for name, value in METRIC_TOKENS.items()}, +} + +# --------------------------------------------------------------------------- +# Severity vocabulary -- the fill/text pair a badge or a row is built from +# --------------------------------------------------------------------------- +SEVERITIES: Sequence[str] = ("ok", "warn", "danger", "info", "idle") + +SEVERITY_FILL: Mapping[str, str] = { + "ok": OK_SOFT, + "warn": WARN_SOFT, + "danger": DANGER_SOFT, + "info": INFO_SOFT, + "idle": IDLE_SOFT, +} +SEVERITY_TEXT: Mapping[str, str] = { + "ok": OK, + "warn": WARN, + "danger": DANGER, + "info": INFO, + "idle": IDLE, +} + +#: State vocabulary, exactly the words both surfaces use (docs/DESIGN.md). +STATE_WORDS: Mapping[str, str] = { + "provisioned": "KURULDU", + "enrolled": "KAYITLI", + "ready": "HAZIR", + "failed": "HATA", + "absent": "YOK", +} +STATE_FILL: Mapping[str, str] = { + # The tokens give no soft form for provisioned; the console borrows the + # sunken well fill rather than inventing a hue, so a KURULDU badge cannot be + # mistaken for the accent-soft selection (see tokens.css, device state). + "provisioned": BG_SUNKEN, + "enrolled": WARN_SOFT, + "ready": OK_SOFT, + "failed": DANGER_SOFT, + "absent": IDLE_SOFT, +} +STATE_TEXT_COLOUR: Mapping[str, str] = { + "provisioned": STATE_PROVISIONED, + "enrolled": STATE_ENROLLED, + "ready": STATE_READY, + "failed": STATE_FAILED, + "absent": STATE_ABSENT, +} + +#: Privilege is a state, not a log line (docs/DESIGN.md, "Privilege"). +PRIVILEGE_STYLE: Mapping[bool, str] = {True: "ok", False: "warn"} + +#: ttk style names, resolved once by apply_theme and read by the view. +_FAMILIES: Dict[str, str] = {} + + +# --------------------------------------------------------------------------- +# Small helpers +# --------------------------------------------------------------------------- +def point_size(px: int) -> int: + """Token px -> Tk point size (see POINT_SIZES).""" + return POINT_SIZES.get(px, max(7, round(px * 0.75))) + + +def ui_family() -> str: + """The UI face resolved by :func:`apply_theme` (platform default before).""" + return _FAMILIES.get("ui", FONT_UI_FALLBACK) + + +def data_family() -> str: + """The monospace face resolved by :func:`apply_theme`.""" + return _FAMILIES.get("data", FONT_DATA_FALLBACK) + + +def ui_font(size: int = SIZE_MD, weight: str = "normal") -> tuple: + """A Tk font tuple in the platform UI face.""" + if weight == "normal": + return (ui_family(), point_size(size)) + return (ui_family(), point_size(size), weight) + + +def data_font(size: int = SIZE_SM, weight: str = "normal") -> tuple: + """A Tk font tuple in the data (monospace) face.""" + if weight == "normal": + return (data_family(), point_size(size)) + return (data_family(), point_size(size), weight) + + +def state_style(state: str) -> str: + """ttk style name for a state badge ("KURULDU" ...).""" + return f"State{state.capitalize()}.Badge.TLabel" + + +#: State key -> badge style, built from STATE_WORDS so the word and the colour +#: can never be paired up wrongly on one surface. +STATE_BADGE_STYLE: Mapping[str, str] = { + key: f"State{key.capitalize()}.Badge.TLabel" for key in STATE_WORDS +} + +#: Privilege level -> badge style ("Privilege" for full, "ReadOnly" else). +PRIVILEGE_BADGE_STYLE: Mapping[str, str] = { + "full": "Privilege.Badge.TLabel", + "read_only": "ReadOnly.Badge.TLabel", +} + + +def severity_style(severity: str) -> str: + """ttk style name for a severity pill (ok / warn / danger / info / idle).""" + return f"{severity.capitalize()}.Status.TLabel" + + +def _resolve_family(root, candidates: Sequence[str], fallback: str) -> str: + """First family the platform actually has; the Tk default otherwise.""" + try: + from tkinter import font as tkfont + + available = {name.lower() for name in tkfont.families(root)} + except Exception: # pragma: no cover - font database unavailable + return fallback + for name in candidates: + if name.lower() in available: + return name + return fallback + + +def _configure(style, name: str, **options) -> None: + """Configure a ttk style, ignoring options the platform's theme rejects. + + ttk style elements differ between Tk builds (clam vs default, Tk 8.6 vs + 9.x): a missing element option raises TclError. Losing one border colour is + a cosmetic difference; crashing the console on startup is not. + """ + try: + style.configure(name, **options) + except Exception: + for key, value in options.items(): + try: + style.configure(name, **{key: value}) + except Exception: + continue + + +def _map(style, name: str, **options) -> None: + """State map for a ttk style, same tolerance as :func:`_configure`.""" + try: + style.map(name, **options) + except Exception: + for key, value in options.items(): + try: + style.map(name, **{key: value}) + except Exception: + continue + + +# --------------------------------------------------------------------------- +# The one setup function, called once at startup +# --------------------------------------------------------------------------- +def apply_theme(root) -> Mapping[str, str]: + """Install the token set on ``root``'s ttk styles; returns the style names. + + Idempotent and cheap: the GUI calls it once before building any widget, and + a second call simply re-applies the same values (tests may build several + roots in one process). + """ + if not HAVE_TKINTER: # pragma: no cover - environment dependent + raise RuntimeError("tkinter yok: tema uygulanamaz") + assert ttk is not None # narrowing for the type checker + + _FAMILIES["ui"] = _resolve_family(root, FONT_UI_CANDIDATES, FONT_UI_FALLBACK) + _FAMILIES["data"] = _resolve_family(root, FONT_DATA_CANDIDATES, FONT_DATA_FALLBACK) + + try: + root.configure(bg=BG_APP) + except Exception: # pragma: no cover - non-Tk root + pass + + style = ttk.Style(root) + # clam honours background/bordercolor on the widget elements we restyle; + # the 'default' theme on Linux ignores most of them, which is how a + # console ends up grey whatever the tokens say. + try: + if "clam" in style.theme_names(): + style.theme_use("clam") + except Exception: # pragma: no cover - theme list unavailable + pass + + base_font = ui_font(SIZE_MD) + _configure( + style, ".", + background=BG_APP, + foreground=TEXT, + font=base_font, + bordercolor=BORDER, + lightcolor=BORDER, + darkcolor=BORDER, + troughcolor=BG_SUNKEN, + focuscolor=FOCUS_RING, + ) + + # -- frames: the ground, the raised panel, the well, the one dark band -- + _configure(style, "TFrame", background=BG_APP, borderwidth=0) + _configure(style, "Panel.TFrame", background=BG_PANEL, borderwidth=1, + relief="solid", bordercolor=BORDER, lightcolor=BORDER, darkcolor=BORDER) + _configure(style, "Sunken.TFrame", background=BG_SUNKEN, borderwidth=1, + relief="solid", bordercolor=BORDER, lightcolor=BORDER, darkcolor=BORDER) + _configure(style, "Header.TFrame", background=BG_HEADER, borderwidth=0) + _configure(style, "Warn.TFrame", background=WARN_SOFT, borderwidth=1, + relief="solid", bordercolor=WARN, lightcolor=WARN_SOFT, + darkcolor=WARN_SOFT) + _configure(style, "Info.TFrame", background=INFO_SOFT, borderwidth=1, + relief="solid", bordercolor=INFO, lightcolor=INFO_SOFT, + darkcolor=INFO_SOFT) + _configure(style, "Accent.TFrame", background=ACCENT_SOFT, borderwidth=1, + relief="solid", bordercolor=ACCENT, lightcolor=ACCENT_SOFT, + darkcolor=ACCENT_SOFT) + + # -- labels ------------------------------------------------------------- + _configure(style, "TLabel", background=BG_APP, foreground=TEXT, font=base_font) + _configure(style, "Panel.TLabel", background=BG_PANEL, foreground=TEXT) + _configure(style, "Sunken.TLabel", background=BG_SUNKEN, foreground=TEXT) + _configure(style, "Muted.TLabel", background=BG_APP, foreground=TEXT_MUTED, + font=ui_font(SIZE_SM)) + _configure(style, "PanelMuted.TLabel", background=BG_PANEL, + foreground=TEXT_MUTED, font=ui_font(SIZE_SM)) + _configure(style, "Faint.TLabel", background=BG_APP, foreground=TEXT_FAINT, + font=ui_font(SIZE_XS)) + _configure(style, "PanelFaint.TLabel", background=BG_PANEL, + foreground=TEXT_FAINT, font=ui_font(SIZE_XS)) + _configure(style, "FieldLabel.TLabel", background=BG_PANEL, foreground=TEXT_MUTED, + font=ui_font(SIZE_SM), anchor="e") + _configure(style, "Section.TLabel", background=BG_APP, foreground=TEXT, + font=ui_font(SIZE_LG, "bold")) + _configure(style, "PanelTitle.TLabel", background=BG_PANEL, foreground=TEXT_MUTED, + font=ui_font(SIZE_XS, "bold")) + # The one loud figure per view. + _configure(style, "Figure.TLabel", background=BG_APP, foreground=TEXT, + font=ui_font(SIZE_2XL, "bold")) + _configure(style, "Ok.TLabel", background=BG_PANEL, foreground=OK, + font=ui_font(SIZE_SM)) + _configure(style, "Warn.TLabel", background=WARN_SOFT, foreground=WARN, + font=ui_font(SIZE_SM)) + _configure(style, "InfoText.TLabel", background=INFO_SOFT, foreground=INFO, + font=ui_font(SIZE_SM)) + _configure(style, "Danger.TLabel", background=BG_PANEL, foreground=DANGER, + font=ui_font(SIZE_SM)) + _configure(style, "AppWarn.TLabel", background=BG_APP, foreground=WARN, + font=ui_font(SIZE_SM, "bold")) + _configure(style, "PanelOk.TLabel", background=BG_PANEL, foreground=OK, + font=ui_font(SIZE_SM, "bold")) + _configure(style, "PanelDanger.TLabel", background=BG_PANEL, foreground=DANGER, + font=ui_font(SIZE_SM, "bold")) + # Dialogs sit on the app ground, not on a panel. + _configure(style, "Dialog.TLabel", background=BG_APP, foreground=TEXT, + font=ui_font(SIZE_MD)) + _configure(style, "DialogTitle.TLabel", background=BG_APP, foreground=TEXT, + font=ui_font(SIZE_LG, "bold")) + _configure(style, "DialogMuted.TLabel", background=BG_APP, + foreground=TEXT_MUTED, font=ui_font(SIZE_SM)) + # Header (dark) region. + _configure(style, "HeaderTitle.TLabel", background=BG_HEADER, + foreground=TEXT_ON_DARK, font=ui_font(SIZE_XL, "bold")) + _configure(style, "HeaderMeta.TLabel", background=BG_HEADER, + foreground=TEXT_FAINT, font=ui_font(SIZE_SM)) + _configure(style, "HeaderTarget.TLabel", background=BG_HEADER, + foreground=TEXT_ON_DARK, font=data_font(SIZE_SM)) + _configure(style, "HeaderFigure.TLabel", background=BG_HEADER, + foreground=TEXT_ON_DARK, font=data_font(SIZE_MD, "bold")) + + # -- badges: state word + colour, never colour alone ------------------- + _configure(style, "Badge.TLabel", background=IDLE_SOFT, foreground=IDLE, + font=ui_font(SIZE_SM, "bold"), padding=(SP_2, SP_1)) + for key, word_style in (("provisioned", "Provisioned"), ("enrolled", "Enrolled"), + ("ready", "Ready"), ("failed", "Failed"), + ("absent", "Absent")): + _configure( + style, f"State{word_style}.Badge.TLabel", + background=STATE_FILL[key], foreground=STATE_TEXT_COLOUR[key], + font=ui_font(SIZE_SM, "bold"), padding=(SP_2, SP_1), + ) + _configure(style, "Privilege.Badge.TLabel", background=OK_SOFT, foreground=OK, + font=ui_font(SIZE_SM, "bold"), padding=(SP_3, SP_1)) + _configure(style, "ReadOnly.Badge.TLabel", background=WARN_SOFT, foreground=WARN, + font=ui_font(SIZE_SM, "bold"), padding=(SP_3, SP_1)) + _configure(style, "Type.Destructive.Badge.TLabel", background=DANGER_SOFT, + foreground=DANGER, font=ui_font(SIZE_XS, "bold"), padding=(SP_2, SP_1)) + _configure(style, "Type.ReadOnly.Badge.TLabel", background=IDLE_SOFT, + foreground=IDLE, font=ui_font(SIZE_XS, "bold"), padding=(SP_2, SP_1)) + _configure(style, "Open.Badge.TLabel", background=OK_SOFT, foreground=OK, + font=ui_font(SIZE_XS, "bold"), padding=(SP_2, SP_1)) + _configure(style, "Closed.Badge.TLabel", background=WARN_SOFT, foreground=WARN, + font=ui_font(SIZE_XS, "bold"), padding=(SP_2, SP_1)) + _configure(style, "Info.Badge.TLabel", background=INFO_SOFT, foreground=INFO, + font=ui_font(SIZE_SM, "bold"), padding=(SP_3, SP_1)) + _configure(style, "Neutral.Badge.TLabel", background=BG_SUNKEN, foreground=IDLE, + font=ui_font(SIZE_XS, "bold"), padding=(SP_2, SP_1)) + + # -- status pill (footer): the console's own state --------------------- + _configure(style, "Status.TLabel", background=IDLE_SOFT, foreground=IDLE, + font=ui_font(SIZE_SM, "bold"), padding=(SP_3, SP_2)) + for severity in SEVERITIES: + _configure( + style, f"{severity.capitalize()}.Status.TLabel", + background=SEVERITY_FILL[severity], foreground=SEVERITY_TEXT[severity], + font=ui_font(SIZE_SM, "bold"), padding=(SP_3, SP_2), + ) + + # -- inputs: one height with the buttons --------------------------------- + entry_opts = dict( + fieldbackground=BG_PANEL, + background=BG_PANEL, + foreground=TEXT, + bordercolor=BORDER_STRONG, + lightcolor=BORDER_STRONG, + darkcolor=BORDER_STRONG, + insertcolor=TEXT, + padding=(SP_2, SP_1), + font=ui_font(SIZE_MD), + ) + _configure(style, "TEntry", **entry_opts) + _map(style, "TEntry", + bordercolor=[("focus", FOCUS_RING), ("disabled", BORDER)], + foreground=[("disabled", TEXT_FAINT)], + fieldbackground=[("disabled", BG_SUNKEN)]) + _configure(style, "Invalid.TEntry", **entry_opts) + _configure(style, "Invalid.TEntry", bordercolor=DANGER, lightcolor=DANGER, + darkcolor=DANGER) + _map(style, "Invalid.TEntry", bordercolor=[("focus", DANGER)]) + + _configure(style, "TCombobox", **entry_opts) + _configure(style, "TCombobox", arrowcolor=TEXT_MUTED, selectbackground=ACCENT_SOFT, + selectforeground=TEXT, padding=(SP_2, SP_1)) + _map(style, "TCombobox", + fieldbackground=[("readonly", BG_PANEL), ("disabled", BG_SUNKEN)], + foreground=[("disabled", TEXT_FAINT)], + bordercolor=[("focus", FOCUS_RING)]) + + _configure(style, "TCheckbutton", background=BG_APP, foreground=TEXT, + font=ui_font(SIZE_SM), focuscolor=BG_APP, padding=(SP_1, SP_1)) + _map(style, "TCheckbutton", + background=[("active", BG_APP)], + foreground=[("disabled", TEXT_FAINT)], + indicatorcolor=[("selected", ACCENT), ("pressed", ACCENT_ACTIVE)]) + _configure(style, "Panel.TCheckbutton", background=BG_PANEL, foreground=TEXT, + font=ui_font(SIZE_SM), padding=(SP_1, SP_1)) + _map(style, "Panel.TCheckbutton", + background=[("active", BG_PANEL), ("disabled", BG_PANEL)], + foreground=[("disabled", TEXT_FAINT)]) + + # -- buttons: hairline secondary, one accent primary, danger for + # destructive work ------------------------------------------------ + _configure(style, "TButton", background=BG_PANEL, foreground=TEXT, + bordercolor=BORDER_STRONG, lightcolor=BG_PANEL, darkcolor=BG_PANEL, + font=ui_font(SIZE_MD), padding=(SP_3, SP_2), relief="solid", + focusthickness=1, focuscolor=FOCUS_RING, anchor="center") + _map(style, "TButton", + background=[("disabled", BG_SUNKEN), ("pressed", BG_SUNKEN), + ("active", ACCENT_SOFT)], + foreground=[("disabled", TEXT_FAINT), ("pressed", ACCENT_ACTIVE)], + bordercolor=[("focus", FOCUS_RING)], + lightcolor=[("active", ACCENT_SOFT), ("pressed", BG_SUNKEN)], + darkcolor=[("active", ACCENT_SOFT), ("pressed", BG_SUNKEN)]) + _configure(style, "Accent.TButton", background=ACCENT, foreground=TEXT_ON_ACCENT, + bordercolor=ACCENT, lightcolor=ACCENT, darkcolor=ACCENT) + _map(style, "Accent.TButton", + background=[("disabled", IDLE_SOFT), ("pressed", ACCENT_ACTIVE), + ("active", ACCENT_HOVER)], + foreground=[("disabled", TEXT_FAINT)], + bordercolor=[("focus", ACCENT_ACTIVE), ("active", ACCENT_HOVER)], + lightcolor=[("active", ACCENT_HOVER), ("pressed", ACCENT_ACTIVE)], + darkcolor=[("active", ACCENT_HOVER), ("pressed", ACCENT_ACTIVE)]) + _configure(style, "Danger.TButton", background=DANGER, foreground=TEXT_ON_ACCENT, + bordercolor=DANGER, lightcolor=DANGER, darkcolor=DANGER) + _map(style, "Danger.TButton", + background=[("disabled", IDLE_SOFT), ("pressed", DANGER_SOFT), + ("active", DANGER_SOFT)], + foreground=[("disabled", TEXT_FAINT), ("active", DANGER), + ("pressed", DANGER)], + bordercolor=[("focus", DANGER)], + lightcolor=[("active", DANGER_SOFT), ("pressed", DANGER_SOFT)], + darkcolor=[("active", DANGER_SOFT), ("pressed", DANGER_SOFT)]) + # Simple mode: the three big buttons, one of them the action accent. + _configure(style, "Big.TButton", font=ui_font(SIZE_LG), + padding=(SP_5, SP_4), anchor="w") + _configure(style, "Accent.Big.TButton", background=ACCENT, + foreground=TEXT_ON_ACCENT, bordercolor=ACCENT, lightcolor=ACCENT, + darkcolor=ACCENT) + _map(style, "Accent.Big.TButton", + background=[("disabled", IDLE_SOFT), ("pressed", ACCENT_ACTIVE), + ("active", ACCENT_HOVER)], + foreground=[("disabled", TEXT_FAINT)], + lightcolor=[("active", ACCENT_HOVER), ("pressed", ACCENT_ACTIVE)], + darkcolor=[("active", ACCENT_HOVER), ("pressed", ACCENT_ACTIVE)]) + _configure(style, "Quiet.TButton", background=BG_SUNKEN, bordercolor=BORDER) + + # -- table: the fleet/operation list ------------------------------------ + _configure(style, "Treeview", background=BG_PANEL, fieldbackground=BG_PANEL, + foreground=TEXT, bordercolor=BORDER, lightcolor=BORDER, + darkcolor=BORDER, font=ui_font(SIZE_SM), rowheight=ROW_H) + _configure(style, "Treeview.Heading", background=BG_SUNKEN, + foreground=TEXT_MUTED, font=ui_font(SIZE_XS, "bold"), + relief="flat", padding=(SP_2, SP_2)) + _map(style, "Treeview", + background=[("selected", ACCENT_SOFT)], + foreground=[("selected", TEXT)]) + _map(style, "Treeview.Heading", background=[("active", BG_SUNKEN)]) + + # -- tabs, separators, scrollbars -------------------------------------- + _configure(style, "TNotebook", background=BG_APP, bordercolor=BORDER, + lightcolor=BORDER, darkcolor=BORDER, tabmargins=(0, 0, 0, 0)) + _configure(style, "TNotebook.Tab", background=BG_SUNKEN, foreground=TEXT_MUTED, + font=ui_font(SIZE_SM), padding=(SP_3, SP_2), bordercolor=BORDER) + _map(style, "TNotebook.Tab", + background=[("selected", BG_PANEL), ("active", BG_PANEL)], + foreground=[("selected", TEXT), ("active", TEXT)], + expand=[("selected", (0, 0, 0, 0))]) + _configure(style, "TSeparator", background=BORDER) + for orient in ("Vertical", "Horizontal"): + _configure(style, f"{orient}.TScrollbar", background=BG_SUNKEN, + troughcolor=BG_APP, bordercolor=BORDER, arrowcolor=TEXT_MUTED, + lightcolor=BG_SUNKEN, darkcolor=BG_SUNKEN) + _map(style, f"{orient}.TScrollbar", + background=[("active", BORDER_STRONG)]) + + return { + "frame": "TFrame", + "panel": "Panel.TFrame", + "sunken": "Sunken.TFrame", + "header": "Header.TFrame", + "warn_band": "Warn.TFrame", + "info_band": "Info.TFrame", + "label": "TLabel", + "panel_label": "Panel.TLabel", + "panel_title": "PanelTitle.TLabel", + "muted": "Muted.TLabel", + "panel_muted": "PanelMuted.TLabel", + "faint": "Faint.TLabel", + "panel_faint": "PanelFaint.TLabel", + "field_label": "FieldLabel.TLabel", + "section": "Section.TLabel", + "figure": "Figure.TLabel", + "ok_label": "Ok.TLabel", + "warn_label": "Warn.TLabel", + "info_label": "InfoText.TLabel", + "danger_label": "Danger.TLabel", + "app_warn": "AppWarn.TLabel", + "panel_ok": "PanelOk.TLabel", + "panel_danger": "PanelDanger.TLabel", + "header_title": "HeaderTitle.TLabel", + "header_meta": "HeaderMeta.TLabel", + "header_target": "HeaderTarget.TLabel", + "header_figure": "HeaderFigure.TLabel", + "badge": "Badge.TLabel", + "badge_info": "Info.Badge.TLabel", + "badge_neutral": "Neutral.Badge.TLabel", + "badge_destructive": "Type.Destructive.Badge.TLabel", + "badge_readonly": "Type.ReadOnly.Badge.TLabel", + "badge_open": "Open.Badge.TLabel", + "badge_closed": "Closed.Badge.TLabel", + "privilege": "Privilege.Badge.TLabel", + "readonly": "ReadOnly.Badge.TLabel", + "status": "Status.TLabel", + "button": "TButton", + "accent_button": "Accent.TButton", + "danger_button": "Danger.TButton", + "big_button": "Big.TButton", + "big_accent_button": "Accent.Big.TButton", + "quiet_button": "Quiet.TButton", + "entry": "TEntry", + "invalid_entry": "Invalid.TEntry", + "panel_check": "Panel.TCheckbutton", + "check": "TCheckbutton", + "dialog": "Dialog.TLabel", + "dialog_title": "DialogTitle.TLabel", + "dialog_muted": "DialogMuted.TLabel", + } + + +def DANGER_ACTIVE_SAFE() -> str: # pragma: no cover - trivial + """The danger colour used for a focused destructive control. + + A named function (rather than a bare literal) so the fifth-hue rule stays + visible: focus on a dangerous action reuses the danger token. + """ + return DANGER diff --git a/docs/33-OPERATOR-CONSOLE.md b/docs/33-OPERATOR-CONSOLE.md index 60fe144..888ca21 100644 --- a/docs/33-OPERATOR-CONSOLE.md +++ b/docs/33-OPERATOR-CONSOLE.md @@ -4,7 +4,7 @@ - Dosya: `docs/33-OPERATOR-CONSOLE.md` - İlgili kararlar: `24-DECISION-LOG.md#K-09` (golden image + kurucu), `#K-12` (kimlik formatı `^[0-9]{8}$`), `#K-13` (SSH anahtar zorunlu), `#K-11` (onaysız update yasağı), `#K-21` (durum modeli), `#K-22` (RDP her zaman hazır / `bf-gui-*` yalnız yerel GUI) -- Uygulama: Python paketi `bfos` (depo kökü) — `bfos/ui_gui.py` (grafik arayüz, tkinter), `bfos/tui.py` + `bfos/cli.py` (terminal konsolu), `bfos/operations.py` (iş mantığı + işlem kataloğu), `bfos/runner.py` (çalıştırma + denetim kaydı), `bfos/config.py` (Türkçe metinler) +- Uygulama: Python paketi `bfos` (depo kökü) — `bfos/ui_gui.py` (grafik arayüz, tkinter), `bfos/ui_theme.py` (tasarım belirteçleri: `web/static/tokens.css`'in aynası), `bfos/tui.py` + `bfos/cli.py` (terminal konsolu), `bfos/operations.py` (iş mantığı + işlem kataloğu), `bfos/runner.py` (çalıştırma + denetim kaydı), `bfos/config.py` (Türkçe metinler) - Giriş noktası: `admin/bf` (sarmalayıcı) → `sudo bf` ve `sudo bf --tui`. Kurucu: `admin/bf-bootstrap.sh` - Yedek yol: `admin/bf-menu` + `admin/lib/*` (eski bash konsolu) — ana yol değildir; yalnız Python'suz cihazlar ve olağanüstü durumlar için korunur - Durum: [ ] Taslak — pencere yerleşimi ve örnek satırlar LAB'da alınan gerçek koşuyla güncellenir @@ -252,7 +252,7 @@ Pencere açılıyorsa kurulum tamam. Bundan sonra her iş için aynı komut: sudo bf ``` -> **Bundan sonra ezberleyeceğin tek şey bu komuttur.** Pencereden çıkmak için `Çıkış` düğmesi (basit modda üçüncü düğme, alt çubukta da bir `Çıkış` düğmesi var). +> **Bundan sonra ezberleyeceğin tek şey bu komuttur.** Pencereden çıkmak için `Çıkış` düğmesi: **basit modda** üçüncü büyük düğme, **detaylı modda** eylem çubuğunun alt satırında. Tek çıkış kontrolü olduğu için ikisi aynı anda görünmez. **tkinter notu (grafik arayüz için gerekli).** Pencerenin açılabilmesi için `python3-tk` paketi gerekir. Bu paket **çevrimdışı depoda pinlidir** (`provisioning/offline-repo/manifests/base-packages.txt`), yani normal kurulumda senin bir şey yapmana gerek yoktur; kurulum sonu kapısı (`scripts/install/installer/modules/18-final-check.sh`) tkinter'ı denetler. Yine de eksikse uygulama şu Türkçe hatayı verir ve çıkış kodu `3` ile biter: @@ -286,7 +286,7 @@ Yerel ekranda çalıştırmak için oturum açık bir makinede deneyin: ### 9.2 Arayüzü kullanma — sekme sekme -`sudo bf` çalıştırınca **basit mod** açılır. Başlıkta `Blueforce Field OS — Yönetim`, altında `Blueforce saha cihazı yönetimi (Kur / Durum / Bakım)`, ardından depo yolu ve kullanılan katalog kaynağı (gerçek iş mantığı katmanı, örn. `bfos.operations + bfos.runner`) görünür. +`sudo bf` çalıştırınca **basit mod** açılır. Pencere **koyu bir başlık bandıyla** açılır; bandda solda `Blueforce Field OS — Yönetim` ve `Blueforce saha cihazı yönetimi (Kur / Durum / Bakım)`, ortada **hedef kimliği** (`HEDEF`: `— seçilmedi` / `cihaz BF-12010193 (bf-12010193)` / `dalga 'pilot_1'` / `tüm filo (envanterdeki her cihaz)`), sağda **cihaz durumu** rozeti (`KURULDU` / `KAYITLI` / `HAZIR` / `HATA` / `YOK`) ve **yetki** rozeti (`TAM YETKİ` ya da `SALT-OKUNUR`) durur. Yetki bir durumdur, gömülü bir cümle değildir: yetkisiz açılışta bandın hemen altında turuncu bir şerit aynı bilgiyi tam cümleyle yineler. Bandın altında `HEDEF` paneli (bayi no, dalga, tüm filo), gövde, komut çıktısı ve eylem çubuğu gelir; depo yolu ile kullanılan katalog kaynağı (örn. `bfos.operations + bfos.runner`) bandın içinde sessiz bir satırdır. **Basit mod (varsayılan):** ekranda `Ne yapmak istiyorsunuz?` sorusu ve üç büyük düğme — `Kur`, `Durum`, `Çıkış`. `Kur` bir seçim penceresi açar: `Ön kontrol (--check)`, `Kurulumu başlat`, `Vazgeç`. `Durum` salt-okunur özeti (`info-summary`) çalıştırır. Daha fazlası için üst çubuktaki **`Detaylı mod`** düğmesine bas. @@ -298,7 +298,7 @@ Yerel ekranda çalıştırmak için oturum açık bir makinede deneyin: [ Çıkış ] pencereyi kapatır ``` -**Detaylı mod:** 5 kategori **sekmesi** açılır. Her sekmede solda işlem listesi (sütunlar: `İşlem`, `Kapsam`, `Tür`), sağda `Seçenekler ve açıklama` paneli vardır; seçtiğin işlemin kapsamı, playbook yolu ve varsa zorunlu alanları orada görünür. Alt çubukta `Komutu göster ve çalıştır`, `Çıktıyı temizle`, `Çalışanı iptal et`, `Yalnız denetim (--check)`, `Çıkış` ve durum etiketi (`Hazır.`) bulunur. +**Detaylı mod:** 5 kategori **sekmesi** açılır. Sekmelerin üstünde bir **özet şerit** vardır: büyük rakam işlem sayısıdır (`48 işlem`), yanında `13 yıkıcı` ve `35 salt-okunur` sessiz satırlar olarak durur; yetkisiz oturumda şeridin sağında `yazma işlemleri kapalı` yazar. Her sekmede solda işlem listesi (sütunlar: `İşlem`, `Kapsam`, `Tür`, `Durum`) ve üstünde bir `Süz:` alanı, sağda `SEÇENEKLER VE AÇIKLAMA` paneli vardır; seçtiğin işlemin kapsamı, playbook yolu ve varsa zorunlu alanları orada görünür. `Tür` sütunu yıkıcı işlemlerde kırmızı `YIKICI`, diğerlerinde `salt-okunur` yazar; `Durum` sütunu işlemin çalıştırılabilir olduğunu (`açık`) ya da kapalı olduğunu (`kapalı — sudo bf`) söyler. Eylem çubuğu iki satırdır: üstte `Komutu göster ve çalıştır` (yıkıcı işlem seçiliyse kırmızı, yetkisizse kapalı ve nedeni yanında yazılı) ve sağında durum göstergesi (`HAZIR` / `ÇALIŞIYOR` / `TAMAM` / `HATA` — sözcük + renk); altta `Çıktıyı temizle`, `Çalışanı iptal et`, `Yalnız denetim (--check)`, `Çıktı panelini gizle` ve `Çıkış`. | Sekme | İşlem sayısı | Yazma (yıkıcı) | Salt-okunur | |---|---|---|---| @@ -381,7 +381,9 @@ Kurulum seçenekleri: `Yerel çevrimdışı depodan kur (--offline)` ve `Zaten O Filo işlemlerinde hedef **üst çubuktan** seçilir; işlem seçildiğinde ekran hedefi ayrıca sormaz, çubuktaki değeri kullanır: ```text - [Detaylı mod] Hedef: [ 12010193 ] Bayi no (8 hane) [ pilot_1 ▾ ] Dalga [ ] Tüm filo (dikkat) + HEDEF [ Detaylı mod ] + Bayi no (8 hane) [ 12010193 ] Dalga [ pilot_1 ▾ ] [ ] Tüm filo (dikkat) + Bayi no geçerli: BF-12010193 ``` | Hedef türü | Çubukta ne yaparsın | Örnek | Boyut | Tipik kullanım | @@ -416,8 +418,9 @@ Pencere açılır: `Blueforce Field OS — Yönetim` / `Blueforce saha cihazı y 1) Üst çubuktaki `Bayi no (8 hane)` alanına `12010193` yaz: ```text - Hedef: [ 12010193 ] Bayi no (8 hane) [ lab ▾ ] Dalga [ ] Tüm filo (dikkat) - durum: Bayi no geçerli: BF-12010193 + HEDEF + Bayi no (8 hane) [ 12010193 ] Dalga [ lab ▾ ] [ ] Tüm filo (dikkat) + Bayi no geçerli: BF-12010193 ``` 2) Basit modda `Kur` düğmesine bas. Seçim penceresi açılır (başlık: `Kurulum`): @@ -525,7 +528,7 @@ Teşhis paketi için: `Araçlar` sekmesi → `Destek paketi oluştur`. Paket yol | # | Belirti (ekranda ne görürsün) | Neden | Çözüm | |---|---|---|---| | 1 | `bf: command not found` | Bootstrap çalışmamış veya araçlar bağlanmamış | Bootstrap'ı tekrar koş: `sudo admin/bf-bootstrap.sh`; sonra tam yoldan dene: `sudo /usr/local/bin/bf`. Python'suz cihazda yedek: `sudo bf-menu` | -| 2 | Pencere açılıyor ama başlıkta turuncu şerit: `Yönetici yetkisi yok: salt-okunur raporlar çalışır, kurulum ve değişiklik gerektiren işlemler kapalıdır. Tam yetki için: sudo bf` | Arayüz `sudo` olmadan açıldı; yazma işlemleri kapalı | Pencereyi kapat ve `sudo bf` ile aç (§9.8) | +| 2 | Başlık bandında turuncu `SALT-OKUNUR` rozeti ve bandın altında turuncu şerit: `Yönetici yetkisi yok: salt-okunur raporlar çalışır, kurulum ve değişiklik gerektiren işlemler kapalıdır. Tam yetki için: sudo bf` | Arayüz `sudo` olmadan açıldı; yazma işlemleri kapalı (listede kalır, `Durum` sütunu `kapalı — sudo bf` yazar) | Pencereyi kapat ve `sudo bf` ile aç (§9.8) | | 3 | Hata penceresi: `Geçersiz bayi numarası '1201019': tam 8 hane olmalıdır (verilen uzunluk: 7). Örnek: 12010193` | Bayi no 8 haneli ve yalnız rakam olmalı | Tam 8 hane, sadece rakam gir; baştaki sıfırı silme. Örnek: `12010193` | | 4 | Envanter notunda `UYARI: ansible-playbook bulunamadı; filo işlemleri çalışmaz (sudo apt install ansible).` | Makinede Ansible yok | `sudo apt install ansible` (reponun kurulumu `ansible-core`'u zaten kurar) ve araçları bağla: `sudo admin/bf-bootstrap.sh` | | 5 | `Komut üretilemedi`: `Playbook bulunamadı: ansible/playbooks/...` veya `... konsol kapsamı 'X' beklerken playbook 'Y' beyan ediyor.` | Katalog ile repo checkout'u uyuşmuyor (eski checkout veya `--repo` yanlış) | `Araçlar` sekmesinden `Bu kopyayı güncelle (git pull --ff-only)` veya doğru repoyu ver: `sudo bf --repo /opt/blueforce-linux`; arayüzü yeniden başlat | @@ -553,8 +556,8 @@ Arayüz beklenen kullanımda **tam yetkili** çalışır: `sudo bf`. Kurulum bet | Durum | Ne olur | |---|---| -| `sudo bf` (tam yetki) | 48 işlemin tamamı çalışır: kurulum, filo bakımı, paket güncellemesi, kontrollü reboot ve `--check` koşuları | -| Root değil (yetkisiz açılış) | Başlıkta turuncu uyarı şeridi: `Yönetici yetkisi yok: salt-okunur raporlar çalışır, kurulum ve değişiklik gerektiren işlemler kapalıdır. Tam yetki için: sudo bf`. **35 salt-okunur işlem** çalışır (raporlar, envanter denetimi, bilgi ekranları, destek paketi); **13 yıkıcı işlem kapalıdır** | +| `sudo bf` (tam yetki) | Başlık bandında yeşil `TAM YETKİ` rozeti. 48 işlemin tamamı çalışır: kurulum, filo bakımı, paket güncellemesi, kontrollü reboot ve `--check` koşuları | +| Root değil (yetkisiz açılış) | Başlık bandında turuncu `SALT-OKUNUR` rozeti; bandın altında turuncu şerit: `Yönetici yetkisi yok: salt-okunur raporlar çalışır, kurulum ve değişiklik gerektiren işlemler kapalıdır. Tam yetki için: sudo bf`. **35 salt-okunur işlem** çalışır (raporlar, envanter denetimi, bilgi ekranları, destek paketi); **13 yıkıcı işlem listede kalır ama kapalıdır** (`Tür` = `YIKICI`, `Durum` = `kapalı — sudo bf`) ve `Komutu göster ve çalıştır` düğmesi kapalı çizilip nedenini kendi yanında yazar | | Yetkisizken yazma denenirse | Yazma işlemleri düğme düzeyinde kapalıdır; bir yol bulunup denenirse **çalıştırma katmanı** reddeder ve çıktı alanına `[ENGELLENDİ] Bu işlem yönetici yetkisi gerektirir (sudo bf).` yazılır, işlem çalışmaz (çıkış kodu 2) | | `--check` kipi | Salt-okunur olduğu için yetki istemez: `Yalnız denetim (--check)` kutusu işaretliyse deneme koşusu yetkisiz oturumda da yapılabilir | @@ -569,6 +572,7 @@ Planlama yetki istemez: komut önizlemesi, onay pencereleri ve `--check` üretim | `sudo admin/bf-bootstrap.sh` iki kez koşulur | İkinci koşu hata üretmez (idempotent), araçlar yerinde, `/usr/local/bin/bf` bağlı | LAB(2) | | `sudo bf --list` / `python3 -m bfos.ui_gui --list` | 48 işlem, 5 kategori, 13 YIKICI / 35 salt-okunur, pencere açılmadan listelenir | LAB(2) | | `sudo bf` açılır, `Çıkış` ile kapanır | Pencere açılır, hiçbir değişiklik yapılmaz | LAB(2) | +| `python3 tests/test_bfos_gui_static.py` | Tema belirteçleri `web/static/tokens.css` ile birebir aynı; durum sözlüğü beş sözcük; yetkisiz oturumda yazma işlemleri listede ve nedeni yazılı; pencere açılmadan koşar | LAB(2) | | `sudo bf --tui` ile aynı işlem listesi | Terminal konsolu iki giriş noktası ayrışmadan çalışır | LAB(2) | | Grafik ekran olmadan (`env -u DISPLAY -u WAYLAND_DISPLAY sudo bf`) | Türkçe hata + `sudo bf --tui` önerisi, çıkış kodu `3` | LAB(2) | | tkinter'siz ortamda başlatma | Türkçe hata + `python3-tk` ipucu + `--tui` önerisi, çıkış kodu `3`; kurulum sonu kapısı bunu WARN olarak kaydeder | LAB(2) | @@ -603,6 +607,7 @@ Planlama yetki istemez: komut önizlemesi, onay pencereleri ve `--check` üretim - [x] Filo işlemleri açık hedefle; wave-scoped filo akışı dalga dalga ilerliyor; production için `production` yazma zorunlu. - [x] Katalog, playbook kapsamıyla uyuşmazsa çalıştırmayı reddediyor (kapsam playbook dosyasından okunur). - [x] Yetki modeli: `sudo bf` tam yetki; yetkisiz açılışta yazma işlemleri kapalı ve çalıştırma katmanı `[ENGELLENDİ]` ile reddediyor. +- [x] Pencere `docs/DESIGN.md` yönergesine göre çiziliyor: koyu başlık bandı (ürün adı + hedef kimliği + cihaz durumu + yetki), beyaz paneller, `KURULDU`/`KAYITLI`/`HAZIR`/`HATA`/`YOK` rozetleri (sözcük + renk), tek çıkış kontrolü; renk ve boşluk değerleri `bfos/ui_theme.py` üzerinden `web/static/tokens.css`'ten geliyor ve statik testle karşılaştırılıyor. - [x] Grafik ekran/tkinter yokluğunda Türkçe hata + `sudo bf --tui` önerisi ve çıkış kodu 3. - [x] Şifre üretimi varsayılan **kapalı**; yalnız `bf-creds --generate` ile üretiliyor; parola ekrana/loga gitmiyor. - [x] `Cihaz işlemleri` içinde onaysız güncelleme seçeneği **yok**; `Bekleyen güvenlik güncellemeleri` yalnız rapor. diff --git a/tests/test_bfos_gui_static.py b/tests/test_bfos_gui_static.py index 67e5164..189bc8f 100644 --- a/tests/test_bfos_gui_static.py +++ b/tests/test_bfos_gui_static.py @@ -10,7 +10,9 @@ tkinter'siz calisan yardimcilari ve backend sozlesmesini dogrular: * headless (SSH) tespiti ve tkinter eksikligi mesaji, * hedef/kapi kurallari: release -> tek cihaz yok, production -> token, * --check kipi, salt-okunur rapor plani, - * komut calistirma: canli satir akisi + cikis kodu (gercek subprocess). + * komut calistirma: canli satir akisi + cikis kodu (gercek subprocess), + * tasarim sozlesmesi: tema token'lari web/static/tokens.css ile birebir ayni, + durum sozlugu (KURULDU/KAYITLI/HAZIR/HATA/YOK) ve yetki kapisi gorunurlugu. Calistirma: python3 tests/test_bfos_gui_static.py -v @@ -21,7 +23,9 @@ from __future__ import annotations import contextlib import importlib.util import io +import json import os +import re import shutil import sys import tempfile @@ -774,5 +778,322 @@ class TestCatalogueBackendFallback(StaticCase): self.assertFalse(plan.destructive) +# --------------------------------------------------------------------------- +# Tasarim sozlesmesi (docs/DESIGN.md + web/static/tokens.css) +# --------------------------------------------------------------------------- +def _srgb_channel(value: float) -> float: + return value / 12.92 if value <= 0.03928 else ((value + 0.055) / 1.055) ** 2.4 + + +def relative_luminance(colour: str) -> float: + """WCAG bagil parlaklik (hex token rengi).""" + r, g, b = (int(colour[index:index + 2], 16) / 255 for index in (1, 3, 5)) + return 0.2126 * _srgb_channel(r) + 0.7152 * _srgb_channel(g) + 0.0722 * _srgb_channel(b) + + +def contrast_ratio(first: str, second: str) -> float: + """Iki token rengi arasindaki WCAG kontrast orani.""" + a, b = relative_luminance(first), relative_luminance(second) + lighter, darker = max(a, b), min(a, b) + return (lighter + 0.05) / (darker + 0.05) + + +class TestThemeMirrorsTokensCss(StaticCase): + """bfos/ui_theme.py, web/static/tokens.css'in BIREBIR aynasi olmali. + + Iki yuzey (cihaz konsolu + merkez panel) ayni sozlugu konusur; bir tarafta + degistirilip digerinde unutulan bir token sessiz bir ayrisma olurdu. Bu + yuzden aynalama testtir (docs/DESIGN.md, "Rules"). + """ + + CSS_PATH = REPO_ROOT / "web" / "static" / "tokens.css" + + #: Tk'de karsiligi olmayan token'lar (golge, yazi tipi listesi, satir + #: yuksekligi orani, yuvarlaklik): konsol bunlari kullanmaz, ama CSS'te + #: kalirlar cunku merkez panel kullanir. + NOT_MIRRORED = ( + "--font-ui", "--font-data", + "--leading-tight", "--leading-normal", + "--shadow-sm", "--shadow-md", + "--radius", "--radius-lg", + ) + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.theme = ui.theme + cls.css = cls._parse_tokens(cls.CSS_PATH.read_text(encoding="utf-8")) + + @staticmethod + def _parse_tokens(text: str) -> dict: + """`--ad: deger;` ciftlerini toplar, var(--x) referanslarini cozer.""" + raw = { + match.group(1): match.group(2).strip() + for match in re.finditer(r"(--[a-z0-9-]+)\s*:\s*([^;]+);", text) + } + resolved = {} + for name, value in raw.items(): + reference = re.fullmatch(r"var\((--[a-z0-9-]+)\)", value) + resolved[name] = raw.get(reference.group(1), value) if reference else value + return resolved + + def test_the_mirror_source_exists(self) -> None: + self.assertTrue(self.CSS_PATH.is_file(), str(self.CSS_PATH)) + self.assertTrue(self.css, "tokens.css icinde token bulunamadi") + + def test_every_mirrored_value_equals_the_css(self) -> None: + for name, value in self.theme.ALL_TOKENS.items(): + with self.subTest(token=name): + self.assertIn(name, self.css, f"{name} tokens.css'te yok") + css_value = self.css[name] + if css_value.endswith("px"): + css_value = css_value[:-2] + self.assertEqual(value, css_value.strip(), + f"{name}: ui_theme.py ile tokens.css ayrismis") + + def test_the_colour_palette_is_fully_mirrored(self) -> None: + """CSS'teki her RENK tokeninin Python karsiligi olmali (fazla renk = hata).""" + css_colours = {name for name, value in self.css.items() if value.startswith("#")} + python_colours = {name for name, value in self.theme.TOKENS.items() + if value.startswith("#")} + self.assertEqual(css_colours, python_colours) + self.assertTrue(css_colours) + + def test_state_and_severity_pairs_have_readable_contrast(self) -> None: + """Durum renkleri gunesli atolyede okunmali; rozet sozcugu de yaninda.""" + self.assertGreaterEqual(contrast_ratio(self.theme.TEXT, self.theme.BG_APP), 7.0) + self.assertGreaterEqual(contrast_ratio(self.theme.TEXT, self.theme.BG_PANEL), 7.0) + self.assertGreaterEqual(contrast_ratio(self.theme.TEXT_MUTED, + self.theme.BG_PANEL), 4.5) + self.assertGreaterEqual(contrast_ratio(self.theme.TEXT_FAINT, + self.theme.BG_PANEL), 3.0) + self.assertGreaterEqual(contrast_ratio(self.theme.TEXT_ON_DARK, + self.theme.BG_HEADER), 7.0) + self.assertGreaterEqual(contrast_ratio(self.theme.TEXT_ON_ACCENT, + self.theme.ACCENT), 4.5) + # En dusuk cift "yesil zemin + yesil yazi" (4.42:1); esik bunun altina inmez. + for key, word in self.theme.STATE_WORDS.items(): + with self.subTest(state=word): + self.assertGreaterEqual( + contrast_ratio(self.theme.STATE_TEXT_COLOUR[key], + self.theme.STATE_FILL[key]), 4.4) + for severity in self.theme.SEVERITIES: + with self.subTest(severity=severity): + self.assertGreaterEqual( + contrast_ratio(self.theme.SEVERITY_TEXT[severity], + self.theme.SEVERITY_FILL[severity]), 4.4) + + def test_fonts_resolve_to_platform_families_without_downloads(self) -> None: + """Webfont yok: aday listesi platformda VAR OLAN bir yuzeye duser.""" + self.assertTrue(self.theme.FONT_UI_CANDIDATES) + self.assertTrue(self.theme.FONT_DATA_CANDIDATES) + self.assertTrue(self.theme.FONT_UI_FALLBACK) + self.assertTrue(self.theme.FONT_DATA_FALLBACK) + for family in (list(self.theme.FONT_UI_CANDIDATES) + + list(self.theme.FONT_DATA_CANDIDATES)): + with self.subTest(family=family): + self.assertNotIn("/", family) + self.assertNotIn(".ttf", family.lower()) + self.assertNotIn("http", family.lower()) + + def test_px_tokens_convert_to_tk_point_sizes(self) -> None: + sizes = (self.theme.SIZE_XS, self.theme.SIZE_SM, self.theme.SIZE_MD, + self.theme.SIZE_LG, self.theme.SIZE_XL, self.theme.SIZE_2XL) + for size in sizes: + with self.subTest(size=size): + self.assertLess(self.theme.point_size(size), size) + self.assertGreaterEqual(self.theme.point_size(size), 7) + # Siralama korunur: buyuk token daha buyuk puan uretir (esitlik yok). + points = [self.theme.point_size(size) for size in sizes] + self.assertEqual(points, sorted(points)) + self.assertEqual(len(set(points)), len(points)) + + def test_the_space_rhythm_is_the_committed_one(self) -> None: + self.assertEqual( + [self.theme.SP_1, self.theme.SP_2, self.theme.SP_3, self.theme.SP_4, + self.theme.SP_5, self.theme.SP_6, self.theme.SP_7], + [4, 8, 12, 16, 24, 32, 48], + ) + self.assertEqual(self.theme.ROW_H, 44) + self.assertEqual(self.theme.HEADER_H, 56) + + +class TestStateVocabulary(StaticCase): + """Durum sozlugu: TEK sozcuk, renk tek basina anlam tasimaz.""" + + def test_the_five_words_are_exactly_the_committed_ones(self) -> None: + self.assertEqual(ui.DEVICE_STATES, + ("KURULDU", "KAYITLI", "HAZIR", "HATA", "YOK")) + self.assertEqual(set(ui.DEVICE_STATES), set(ui.theme.STATE_WORDS.values())) + + def test_every_word_has_its_own_badge_style(self) -> None: + styles = [ui.state_badge_style(word) for word in ui.DEVICE_STATES] + self.assertEqual(len(set(styles)), len(styles), styles) + for style in styles: + self.assertTrue(style.endswith(".Badge.TLabel"), style) + for key, style in ui.theme.STATE_BADGE_STYLE.items(): + with self.subTest(state=key): + self.assertEqual(style, + ui.state_badge_style(ui.theme.STATE_WORDS[key])) + + def test_every_word_has_its_own_text_colour(self) -> None: + self.assertEqual(len(set(ui.theme.STATE_TEXT_COLOUR.values())), + len(ui.DEVICE_STATES)) + + def test_badge_fills_come_from_the_token_set(self) -> None: + """Dolgu renkleri token kumesinden gelir; uydurulmus renk yok.""" + allowed = set(ui.theme.TOKENS.values()) | set(ui.theme.SEVERITY_FILL.values()) + for key, fill in ui.theme.STATE_FILL.items(): + with self.subTest(state=key): + self.assertIn(fill, allowed) + self.assertIn(ui.theme.STATE_TEXT_COLOUR[key], allowed) + + def test_provisioned_is_not_the_action_accent(self) -> None: + """KURULDU ile secili satir ayni maviye boyanamaz (tokens.css).""" + self.assertNotEqual(ui.theme.STATE_TEXT_COLOUR["provisioned"], + ui.theme.ACCENT) + self.assertNotEqual(ui.theme.STATE_FILL["provisioned"], + ui.theme.ACCENT_SOFT) + + def test_unknown_word_falls_back_to_a_neutral_badge(self) -> None: + self.assertEqual(ui.state_badge_style("BOYLE-BIR-DURUM-YOK"), + ui.theme.STATE_BADGE_STYLE["absent"]) + + def test_the_state_machine_maps_to_one_word(self) -> None: + cases = ( + ({}, "YOK"), + ({"phase": "PROVISIONED_OFFLINE"}, "KURULDU"), + ({"phase": "PROVISIONED_OFFLINE", + "enrollment_status": "ENROLLED"}, "KAYITLI"), + ({"fleet_status": "READY"}, "HAZIR"), + ({"phase": "FAILED"}, "HATA"), + ({"enrollment_status": "NOT_ENROLLED"}, "KURULDU"), + ) + for values, expected in cases: + with self.subTest(values=values): + self.assertEqual(ui.state_word_from_values(values), expected) + + def test_missing_or_broken_state_file_is_the_absent_state(self) -> None: + self.assertEqual(ui.device_state_word("/yok/boyle/bir/state.json"), "YOK") + self.assertEqual(ui.read_state_values("/yok/boyle/bir/state.json"), {}) + with tempfile.TemporaryDirectory() as tmp: + broken = Path(tmp) / "state.json" + broken.write_text("{bu json degil", encoding="utf-8") + self.assertEqual(ui.device_state_word(str(broken)), "YOK") + + def test_state_json_is_read_through_a_whitelist(self) -> None: + """Jeton ve anahtar alanlari ekrana/loga ASLA okunmaz (K-14).""" + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "state.json" + path.write_text(json.dumps({ + "phase": "READY", + "device_id": "BF-12010193", + "enrollment_token": "SEKRET-JETON", + "private_key": "-----BEGIN PRIVATE KEY-----", + }), encoding="utf-8") + values = ui.read_state_values(str(path)) + self.assertEqual(ui.device_state_word(str(path)), "HAZIR") + self.assertIn("device_id", values) + self.assertNotIn("enrollment_token", values) + self.assertNotIn("private_key", values) + self.assertNotIn("SEKRET-JETON", json.dumps(values)) + self.assertNotIn("BEGIN PRIVATE KEY", json.dumps(values)) + + +class TestPrivilegeIsVisible(StaticCase): + """Yetki bir durumdur: yazma islemleri gizlenmez, KAPATILIR ve nedeni yazar.""" + + def test_privilege_levels_have_wording_and_a_badge(self) -> None: + self.assertEqual(ui.privilege_level(True), ui.PRIVILEGE_FULL) + self.assertEqual(ui.privilege_level(False), ui.PRIVILEGE_READ_ONLY) + self.assertEqual("SALT-OKUNUR", ui.privilege_word(ui.PRIVILEGE_READ_ONLY)) + self.assertEqual("TAM YETKİ", ui.privilege_word(ui.PRIVILEGE_FULL)) + self.assertEqual(set(ui.theme.PRIVILEGE_BADGE_STYLE), + {ui.PRIVILEGE_FULL, ui.PRIVILEGE_READ_ONLY}) + + def test_unprivileged_writes_stay_in_the_list_with_a_reason(self) -> None: + writes = [op for op in self.backend.list_operations() if op.needs_root] + reports = [op for op in self.backend.list_operations() if not op.needs_root] + self.assertTrue(writes, "katalogda yazma islemi yok; kapi test edilemez") + original = os.geteuid + try: + os.geteuid = lambda: 1000 + for op in writes: + with self.subTest(operation=op.id): + self.assertEqual(ui.write_blocked_reason(op), + ui.NON_ROOT_DISABLED_HINT) + self.assertTrue(ui.operation_is_write(op)) + for op in reports: + with self.subTest(operation=op.id): + self.assertIsNone(ui.write_blocked_reason(op)) + self.assertFalse(ui.operation_is_write(op)) + os.geteuid = lambda: 0 + for op in writes: + with self.subTest(operation=op.id): + self.assertIsNone(ui.write_blocked_reason(op)) + finally: + os.geteuid = original + + def test_the_blocked_reason_names_the_command_to_get_privileges(self) -> None: + self.assertIn("sudo bf", ui.NON_ROOT_DISABLED_HINT) + self.assertIn("yönetici", ui.NON_ROOT_DISABLED_HINT.lower()) + + +class TestConsoleLayoutContract(StaticCase): + """Olculen sorunlar geri gelmesin: kaynak duzeyinde baglayici kurallar.""" + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.source = UI_GUI_PATH.read_text(encoding="utf-8") + + def test_the_view_has_no_hardcoded_colour(self) -> None: + """Renk token'dir, literal degil (docs/DESIGN.md kural 1).""" + self.assertEqual(re.findall(r"#[0-9a-fA-F]{3,8}\b", self.source), []) + + def test_there_is_exactly_one_exit_control(self) -> None: + """'Cik' tek kontrol: ya basit modda ya alt cubukta, ayni anda degil.""" + self.assertEqual(self.source.count('text="Çıkış"'), 1) + self.assertIn("Çıkış", ui.SIMPLE_MODE_BUTTONS) + for needle in ("self.exit_button.pack(side=\"right\")", + "self.exit_button.pack_forget()"): + with self.subTest(needle=needle): + self.assertIn(needle, self.source) + + def test_the_header_carries_the_privilege_element(self) -> None: + """Yetki gomulu bir cumle degil, basliktaki gorunur bir rozet.""" + for needle in ("PRIVILEGE_BADGE_STYLE", "self.privilege_badge", + 'text="Yetki"', "ROOT_REQUIRED_NOTICE", + 'style=self.styles["warn_band"]'): + with self.subTest(needle=needle): + self.assertIn(needle, self.source) + + def test_the_header_carries_the_device_state_and_target(self) -> None: + for needle in ("self.state_badge", "self.header_target", + "device_state_word()", "state_badge_style("): + with self.subTest(needle=needle): + self.assertIn(needle, self.source) + + def test_write_operations_are_disabled_not_hidden(self) -> None: + for needle in ('state="disabled"', "BLOCKED_LABEL", "AVAILABLE_LABEL", + "write_blocked_reason"): + with self.subTest(needle=needle): + self.assertIn(needle, self.source) + + def test_the_status_strip_is_a_state_pill_not_a_plain_label(self) -> None: + self.assertIn("theme.severity_style", self.source) + self.assertNotIn('text="Hazır."', self.source) + + def test_the_output_area_is_a_read_only_well(self) -> None: + for needle in ('state="disabled"', "BG_SUNKEN", "data_font", + 'cursor="arrow"'): + with self.subTest(needle=needle): + self.assertIn(needle, self.source) + + def test_the_view_reads_its_values_from_the_theme_module(self) -> None: + self.assertIn("ui_theme as theme", self.source) + self.assertTrue((REPO_ROOT / "bfos" / "ui_theme.py").is_file()) + + if __name__ == "__main__": unittest.main(verbosity=2)