diff --git a/bfos/ui_gui.py b/bfos/ui_gui.py index 92b97b9..691a771 100644 --- a/bfos/ui_gui.py +++ b/bfos/ui_gui.py @@ -155,6 +155,29 @@ CATEGORIES: tuple[str, ...] = ( "Cihaz bilgisi", ) +#: Kenar cubugu sayfalari: bes katalog kategorisi + oturum etkinlik sayfasi. +#: Kategoriler backend sozlesmesidir (testler sabitler); "Etkinlik" yalnizca +#: bu oturumda calisanlari listeler, denetim kaydini acmaz. +ACTIVITY_PAGE = "Etkinlik" +SIDEBAR_PAGES: tuple[str, ...] = CATEGORIES + (ACTIVITY_PAGE,) + +#: Sayfa -> cizgi ikon adi (bfos/ui_widgets.py cizim sozlugu). +PAGE_ICONS: Mapping[str, str] = { + "Kurulum": "install", + "Cihaz işlemleri": "device", + "Merkez hazırlığı": "wave", + "Araçlar": "tool", + "Cihaz bilgisi": "info", + ACTIVITY_PAGE: "activity", +} + +#: Hedef kapsami secimi: parca anahtari -> Turkce etiket. +SCOPE_OPTIONS: tuple[tuple[str, str], ...] = ( + ("device", "Cihaz"), + ("wave", "Dalga"), + ("all", "Tüm filo"), +) + # Onayli dalga sirasi (docs/10; admin/lib/targets.sh BF_WAVES ile ayni). WAVES: tuple[str, ...] = ("lab", "pilot_1", "pilot_2", "wave_1", "wave_2", "production") PRODUCTION_WAVE = "production" @@ -256,6 +279,14 @@ except ImportError: # pragma: no cover - dosya paket disinda yuklendiginde sys.path.insert(0, _THEME_DIR) import ui_theme as theme # type: ignore[no-redef] +try: + from bfos import ui_widgets as widgets +except ImportError: # pragma: no cover - dosya paket disinda yuklendiginde + _WIDGETS_DIR = str(Path(__file__).resolve().parent) + if _WIDGETS_DIR not in sys.path: + sys.path.insert(0, _WIDGETS_DIR) + import ui_widgets as widgets # type: ignore[no-redef] + # --------------------------------------------------------------------------- # Durum sozlugu (docs/DESIGN.md "Status vocabulary") @@ -2666,7 +2697,6 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) self.cancel_event = threading.Event() self.running = False self.option_widgets: dict[str, tuple[str, Any]] = {} - self._panes: dict[str, tuple[Any, Any]] = {} # Islem tanimlari BACKEND'den gelir (yerlesik katalog ya da gercek # bfos.operations): secenek adlari backend ile ayni olmak zorundadir. self._ops_by_id: dict[str, Operation] = { @@ -2677,6 +2707,17 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) self._rows: dict[str, tuple[Operation, ...]] = {} self._filters: dict[str, Any] = {} self._output_visible = True + # Uygulama kabugu durumu: secili sayfa, secili satir, oturum + # etkinlik kaydi ve calisma kronometresi (sunum katmani; backend + # kurallarini degistirmez). + self._nav_buttons: dict[str, Any] = {} + self._pages: dict[str, Any] = {} + self._browser_inner: dict[str, Any] = {} + self._detail_body: dict[str, Any] = {} + self._activity: list[tuple[str, str, str, str, str]] = [] + self._elapsed = widgets.Elapsed() + self._exec_title_text = "Etkin işlem yok." + self._current_page = CATEGORIES[0] # Tema her widget'tan ONCE uygulanir: ttk stilleri dogduklari anda # gecerli olmali, sonradan boyamak gri bir pencere ve titreme uretir. @@ -2705,6 +2746,7 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) 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._build_exec_header(output_area) self.output = OutputPane(output_area, self.styles, height=7, log_path=ctx.console_log) self.output.drain() @@ -2713,6 +2755,7 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) self.show_mode(full=full_mode) self._load_inventory() + self._tick_exec() # -- kucuk yardimcilar ------------------------------------------------- def _badge(self, parent, word: str, style: str): @@ -2831,16 +2874,18 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) # -- hedef cubugu ------------------------------------------------------ def _build_target_bar(self) -> None: - """Hedef paneli: grid ile hizali etiketler, tek ritim. + """Hedef secer: parcali kapsam denetimi + baglama duyarli girdi. - 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). + Uc kapsam birbirini dislar (parcali denetimde tek secim gorunur); + basliktaki hedef kimligi her degisiklikte aynalanir, boylece kapsam + sayfalar arasi gezinirken de gorunur. Tum filo secimi yuksek etkili + bir uyari seridi acar. Alan adlari ve dogrulama akisi korunur + (dealer_entry / wave_combo / all_var / target_status). """ 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) + panel.columnconfigure(1, weight=1) ttk.Label(panel, text="HEDEF", style=self.styles["panel_title"]).grid( row=0, column=0, sticky="w", padx=theme.SP_3, @@ -2848,42 +2893,170 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) 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, + self.mode_button.grid(row=0, column=2, sticky="e", padx=theme.SP_3, pady=(theme.SP_2, 0)) self.dealer_var = tk.StringVar() self.wave_var = tk.StringVar() self.all_var = tk.IntVar(value=0) - 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()) + self.scope_control = widgets.SegmentedControl( + panel, self.styles, SCOPE_OPTIONS, "device", + command=self._on_scope_selected) + self.scope_control.grid(row=1, column=0, sticky="w", + padx=theme.SP_3, pady=(theme.SP_2, 0)) - 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, + inputs = ttk.Frame(panel, style=self.styles["panel"]) + inputs.grid(row=2, column=0, columnspan=3, sticky="ew", + padx=theme.SP_3, pady=(theme.SP_1, 0)) + inputs.columnconfigure(1, weight=1) + + self._dealer_row = ttk.Frame(inputs, style=self.styles["panel"]) + ttk.Label(self._dealer_row, text="Bayi no (8 hane)", + style=self.styles["field_label"], + width=16, anchor="e").pack(side="left", + padx=(0, theme.SP_2)) + self.dealer_entry = ttk.Entry(self._dealer_row, + textvariable=self.dealer_var, + width=14, style=self.styles["entry"]) + self.dealer_entry.pack(side="left") + self.dealer_entry.bind("", + lambda _e: self._on_dealer_change()) + + self._wave_row = ttk.Frame(inputs, style=self.styles["panel"]) + ttk.Label(self._wave_row, text="Dalga", + style=self.styles["field_label"], + width=16, anchor="e").pack(side="left", + padx=(0, theme.SP_2)) + self.wave_combo = ttk.Combobox(self._wave_row, + 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.pack(side="left") 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._fleet_banner = ttk.Frame(inputs, style=self.styles["warn_band"]) + fleet_inner = ttk.Frame(self._fleet_banner, + style=self.styles["warn_band"]) + fleet_inner.pack(fill="x", padx=theme.SP_3, pady=theme.SP_2) + ttk.Label( + fleet_inner, + text=("Tüm filo seçili: işlem filonun TAMAMINI etkiler. " + "Dalga kapılı işlemler dalga dalga ilerler; yetki ve " + "kapı onayları her dalga için yeniden sorulur."), + style=self.styles["warn_label"], + justify="left", + ).pack(anchor="w") + self._bind_wrap(fleet_inner, fleet_inner.winfo_children()[0], + theme.SP_6) + + self._prod_note = ttk.Label( + inputs, + text=("production dalgası: çalıştırma sırasında “production” " + "sözcüğünü yazmanız istenir."), + style=self.styles["app_warn"], + justify="left", + ) 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)) + self.target_status.grid(row=3, column=0, columnspan=3, sticky="w", + padx=theme.SP_3, pady=(theme.SP_1, theme.SP_2)) + self._sync_target_inputs() + + def _on_scope_selected(self, key: str) -> None: + """Parcali denetim degisimi: ilgili girdi gosterilir, kapsam aynalanir.""" + self.all_var.set(1 if key == "all" else 0) + if key == "device": + self.dealer_entry.focus_set() + self._sync_target_inputs() + self._on_target_change() + + def _sync_target_inputs(self) -> None: + """Yalnizca secili kapsamin girdisini gosterir (digerleri gizlenir).""" + scope = (self.scope_control.get() if hasattr(self, "scope_control") + else "device") + if scope == "device": + self._wave_row.pack_forget() + self._fleet_banner.pack_forget() + self._prod_note.pack_forget() + self._dealer_row.pack(fill="x") + elif scope == "wave": + self._dealer_row.pack_forget() + self._fleet_banner.pack_forget() + self._wave_row.pack(fill="x") + if self.wave_var.get().strip() == PRODUCTION_WAVE: + self._prod_note.pack(fill="x", pady=(theme.SP_1, 0)) + else: + self._prod_note.pack_forget() + else: + self._dealer_row.pack_forget() + self._wave_row.pack_forget() + self._prod_note.pack_forget() + self._fleet_banner.pack(fill="x") + + # -- yurutm cekmecesi basligi ------------------------------------------ + def _build_exec_header(self, parent) -> None: + """Yurutme cekmecesi basligi: durum rozeti + islem/hedef/sure. + + Bosken tek sessiz satirdir ("Etkin işlem yok."); calisirken islem + adi, hedef ve gecen sureyi gosterir; bitince sonucu ve toplam sureyi + yazar. Kanit (log kuyusu) altinda aynen durur. + """ + strip = ttk.Frame(parent, style=self.styles["panel"]) + strip.pack(fill="x", padx=theme.SP_3, pady=(theme.SP_3, 0)) + self.exec_pill = ttk.Label(strip, text="BOŞTA", + style=theme.severity_style("idle")) + self.exec_pill.pack(side="left") + self.exec_title = ttk.Label(strip, text=self._exec_title_text, + style=self.styles["panel_muted"]) + self.exec_title.pack(side="left", padx=(theme.SP_3, 0)) + self.exec_elapsed = ttk.Label(strip, text="", + style=self.styles["panel_faint"]) + self.exec_elapsed.pack(side="right") + + def _tick_exec(self) -> None: + """Kronometre guncelleme: calisirken sure, degilse sessizlik.""" + if self.running: + self.exec_elapsed.configure(text=self._elapsed.text()) + elif not self._elapsed_text_hold(): + self.exec_elapsed.configure(text="") + self.root.after(500, self._tick_exec) + + def _elapsed_text_hold(self) -> bool: + """Bitis suresi gosterimde kalsin mi (sonucu gorunur tutar).""" + return bool(self.exec_elapsed.cget("text")) + + def _set_exec_running(self, operation_label: str, + target_label: str) -> None: + self._elapsed.start() + self._exec_title_text = f"{operation_label} · {target_label}" + self.exec_title.configure(text=self._exec_title_text) + self.exec_pill.configure(text="ÇALIŞIYOR", + style=theme.severity_style("info")) + + def _set_exec_finished(self, ok: bool, exit_code: int) -> None: + self._elapsed.stop() + total = self._elapsed.text() + if ok: + self.exec_pill.configure(text="TAMAM", + style=theme.severity_style("ok")) + self.exec_title.configure( + text=f"{self._exec_title_text} · {total} sürdü") + else: + self.exec_pill.configure(text="HATA", + style=theme.severity_style("danger")) + self.exec_title.configure( + text=(f"{self._exec_title_text} · çıkış kodu {exit_code} · " + f"{total} sürdü")) + + def _set_exec_idle(self, note: str = "Etkin işlem yok.") -> None: + self._exec_title_text = note + self.exec_title.configure(text=note) + self.exec_pill.configure(text="BOŞTA", + style=theme.severity_style("idle")) + self.exec_elapsed.configure(text="") # -- eylem cubugu ------------------------------------------------------ def _build_footer(self) -> None: @@ -2947,20 +3120,62 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) style=self.styles["faint"], ).grid(row=2, column=0, sticky="w", pady=(theme.SP_2, 0)) - # -- basit mod --------------------------------------------------------- + # -- basit mod: genel bakis ------------------------------------------------ def _build_simple_mode(self, parent) -> None: - """Basit mod: iki is karari ve TEK cikis dugmesi. + """Genel Bakis: durum kartlari + hizli islemler (form degil). - Cikis burada ucuncu buyuk dugmedir; alt cubuktaki kucuk "Cikis" - dugmesi bu modda gizlenir, boylece ayni eylem iki kez durmaz. + Operator 3 saniyede anlar: hedef ne, cihaz ne durumda, yetki ne, + ne yapilabilir. Uc buyuk karar dugmesi korunur (Kur / Durum / + Cikis; aksan yalnizca Kur'da). Yetkisiz oturumda Kur kapali ve + nedeni dugmenin uzerinde yazar. """ - box = ttk.Frame(parent, style=self.styles["panel"]) - box.pack(fill="both", expand=True) + page = ttk.Frame(parent, style=self.styles["frame"]) + page.pack(fill="both", expand=True) + page.columnconfigure(0, weight=1) + page.columnconfigure(1, weight=1) + + ttk.Label(page, text="Genel Bakış", + style=self.styles["section"]).grid( + row=0, column=0, columnspan=2, sticky="w", + pady=(0, theme.SP_3)) + + self._overview_cards = ttk.Frame(page, style=self.styles["frame"]) + self._overview_cards.grid(row=1, column=0, columnspan=2, + sticky="ew", pady=(0, theme.SP_3)) + for column in range(4): + self._overview_cards.columnconfigure(column, weight=1) + self._overview_values: dict[str, Any] = {} + for column, (key, title) in enumerate(( + ("target", "HEDEF"), + ("state", "CİHAZ DURUMU"), + ("privilege", "YETKİ"), + ("inventory", "ENVANTER"), + )): + card = ttk.Frame(self._overview_cards, style=self.styles["panel"]) + card.grid(row=0, column=column, sticky="nsew", + padx=(0, theme.SP_3) if column < 3 else (0, 0)) + ttk.Label(card, text=title, + style=self.styles["panel_title"]).pack( + anchor="w", padx=theme.SP_3, pady=(theme.SP_3, theme.SP_1)) + value = ttk.Label(card, text="—", + style=self.styles["panel_label"], + justify="left") + value.pack(anchor="w", padx=theme.SP_3) + sub = ttk.Label(card, text="", style=self.styles["panel_muted"], + justify="left") + sub.pack(anchor="w", padx=theme.SP_3, + pady=(theme.SP_1, theme.SP_3)) + self._bind_wrap(card, sub, theme.SP_7) + self._overview_values[key] = (value, sub) + self._refresh_overview() + + box = ttk.Frame(page, style=self.styles["panel"]) + box.grid(row=2, column=0, columnspan=2, sticky="ew") 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)) + inner.pack(fill="x", padx=theme.SP_6, pady=theme.SP_5) + ttk.Label(inner, text="Hızlı işlemler", + style=self.styles["panel_title"]).pack( + anchor="w", pady=(0, theme.SP_3)) for label in SIMPLE_MODE_BUTTONS: command = { "Kur": self._simple_install, @@ -2986,6 +3201,59 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) ).pack(anchor="w", pady=(theme.SP_4, 0)) self._apply_privilege_controls() + def _overview_card(self, key: str, value: str, sub: str = "", + value_style: str | None = None) -> None: + """Genel Bakis kartini gunceller (hedef degisince aynalanir).""" + if not hasattr(self, "_overview_values"): + return + value_label, sub_label = self._overview_values[key] + value_label.configure(text=value) + if value_style is not None: + value_label.configure(style=value_style) + sub_label.configure(text=sub) + + def _refresh_overview(self) -> None: + """Kartlar: hedef kimligi, cihaz durumu, yetki, envanter ozeti.""" + if not hasattr(self, "_overview_values"): + return + target = self.current_target() if hasattr(self, "dealer_var") else None + if target is None or (target.kind == "dealer" + and not validate_dealer(target.value)): + scope_word = {"device": "cihaz", "wave": "dalga", + "all": "tüm filo"}.get( + self.scope_control.get() + if hasattr(self, "scope_control") else "device", "cihaz") + self._overview_card("target", "— seçilmedi", + f"Hedef türü: {scope_word}. " + "Yukarıdaki Hedef panelinden seçin.") + else: + kind_word = {"dealer": "Tek cihaz", "group": "Dalga grubu", + "all": "Tüm filo"}.get(target.kind, target.kind) + self._overview_card("target", target.label, kind_word) + word = device_state_word() + self._overview_card("state", word, + "state.json cihaz kaydı", + state_badge_style(word)) + priv_level = (self.privilege_level + if hasattr(self, "privilege_level") + else privilege_level()) + if priv_level == PRIVILEGE_FULL: + self._overview_card("privilege", privilege_word(priv_level), + "Kurulum ve değişiklik açık.", + self.styles["panel_ok"]) + else: + self._overview_card("privilege", privilege_word(priv_level), + "Salt-okunur raporlar açık. " + "Değişiklik için: sudo bf", + self.styles["panel_danger"]) + waves = self.backend.list_waves() if hasattr(self, "backend") else () + note = self.backend.inventory_note() if hasattr(self, "backend") else "" + self._overview_card( + "inventory", + f"{len(waves)} dalga" if waves else "envanter yok", + ", ".join(waves[:3]) + ("…" if len(waves) > 3 else "") + if waves else note[:80]) + def _simple_install(self) -> None: choice = self._choice_dialog( "Kurulum", @@ -3007,14 +3275,14 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) return self._execute(operation) - # -- detayli mod ------------------------------------------------------- + # -- detayli mod: uygulama kabugu ---------------------------------------- def _build_detail_mode(self, parent) -> None: - """Detayli mod: ozet serit + bes kategori sekmesi. + """Detayli mod: kenar cubugu + sayfa alani (uygulama kabugu). - 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. + Kenar cubugunda bes katalog kategorisi (adetleriyle) ve Etkinlik + sayfasi vardir. Her kategori sayfasi solda islem tarayici, sagda + islem ayrintisidir. Ozet seritte TEK buyuk rakam (islem sayisi) + durur; yikici/salt-okunur dagilimi sessiz metindir. """ strip = ttk.Frame(parent, style=self.styles["frame"]) strip.pack(fill="x", pady=(0, theme.SP_3)) @@ -3039,33 +3307,128 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) 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) + shell = ttk.Frame(parent, style=self.styles["frame"]) + shell.pack(fill="both", expand=True) + shell.columnconfigure(0, minsize=theme.SIDEBAR_W) + shell.columnconfigure(1, weight=1) + shell.rowconfigure(0, weight=1) - def _build_category_tab(self, frame, category: str) -> None: - """Sekme: solda islem listesi (suzgecli), sagda secenekler paneli.""" - frame.columnconfigure(0, weight=3) - frame.columnconfigure(1, weight=2) - frame.rowconfigure(0, weight=1) + self._sidebar = ttk.Frame(shell, style=self.styles["panel"]) + self._sidebar.grid(row=0, column=0, sticky="nsw", + padx=(0, theme.SP_3)) + ttk.Label(self._sidebar, text="BÖLÜMLER", + style=self.styles["panel_title"]).pack( + anchor="w", padx=theme.SP_3, pady=(theme.SP_3, theme.SP_2)) + for page in SIDEBAR_PAGES: + self._build_nav_item(self._sidebar, page) - 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) + content = ttk.Frame(shell, style=self.styles["frame"]) + content.grid(row=0, column=1, sticky="nsew") + content.columnconfigure(0, weight=1) + content.rowconfigure(0, weight=1) + for page in SIDEBAR_PAGES: + if page == ACTIVITY_PAGE: + frame = self._build_activity_page(content) + else: + frame = self._build_category_page(content, page) + self._pages[page] = frame + self._select_page(self._current_page) + self.root.bind("", self._on_window_resized) - head = ttk.Frame(left, style=self.styles["panel"]) - head.grid(row=0, column=0, columnspan=2, sticky="ew", padx=theme.SP_3, + def _nav_count(self, page: str) -> int: + if page == ACTIVITY_PAGE: + return len(self._activity) + return len(self._rows.get(page, ())) + + def _build_nav_item(self, parent, page: str) -> None: + """Kenar cubugu satiri: ikon + etiket + adet (klavye ile erisilir).""" + row = tk.Frame(parent, background=theme.BG_PANEL, + highlightthickness=1, + highlightbackground=theme.BG_PANEL, + takefocus=1) + row.pack(fill="x", padx=theme.SP_2, pady=1) + icon = widgets.line_icon(row, PAGE_ICONS.get(page, "info"), + size=20, colour=theme.TEXT_MUTED, + bg=theme.BG_PANEL) + icon.pack(side="left", padx=(theme.SP_2, 0), pady=theme.SP_2) + label = tk.Label(row, text=page, background=theme.BG_PANEL, + foreground=theme.TEXT, + font=theme.ui_font(theme.SIZE_SM)) + label.pack(side="left", padx=(theme.SP_2, 0)) + count = tk.Label(row, text=str(self._nav_count(page)), + background=theme.BG_SUNKEN, foreground=theme.IDLE, + font=theme.ui_font(theme.SIZE_XS)) + count.pack(side="right", padx=theme.SP_2) + self._nav_buttons[page] = (row, icon, label, count) + + def activate(_event=None, page=page) -> None: + self._select_page(page) + + for widget in (row, icon, label, count): + widget.bind("", activate) + widget.bind("", activate) + widget.bind("", activate) + row.bind("", lambda _e, r=row: r.configure( + highlightbackground=theme.FOCUS_RING)) + row.bind("", lambda _e, r=row: r.configure( + highlightbackground=r.cget("background"))) + + def _paint_nav(self) -> None: + """Secili sayfa vurgulu; gerisi sessiz (vurgu = aksan, tek yerde).""" + for page, (row, icon, label, count) in self._nav_buttons.items(): + selected = page == self._current_page + bg = theme.ACCENT_SOFT if selected else theme.BG_PANEL + row.configure(background=bg, highlightbackground=bg) + icon.configure(background=bg) + label.configure(background=bg) + count.configure( + background=theme.ACCENT_SOFT if selected else theme.BG_SUNKEN) + + def _select_page(self, page: str) -> None: + """Sayfa degisimi: hedef ve yurutme cubugu sabit kalir.""" + if page not in self._pages: + return + self._current_page = page + for name, frame in self._pages.items(): + if name == page: + frame.grid(row=0, column=0, sticky="nsew") + else: + frame.grid_remove() + if page == ACTIVITY_PAGE: + self._render_activity() + self._paint_nav() + + def _on_window_resized(self, event) -> None: + """Dar pencerede kenar cubugu katlanir (icerik nefes alir).""" + if event.widget is not self.root: + return + if event.width < 900: + self._sidebar.grid_remove() + else: + self._sidebar.grid() + + def _build_category_page(self, parent, category: str): + """Kategori sayfasi: solda islem tarayici, sagda ayrinti paneli.""" + page = ttk.Frame(parent, style=self.styles["frame"]) + page.columnconfigure(0, weight=3) + page.columnconfigure(1, weight=2) + page.rowconfigure(0, weight=1) + + browser = ttk.Frame(page, style=self.styles["panel"]) + browser.grid(row=0, column=0, sticky="nsew", padx=(0, theme.SP_3)) + browser.columnconfigure(0, weight=1) + browser.rowconfigure(2, weight=1) + + head = ttk.Frame(browser, style=self.styles["panel"]) + head.grid(row=0, column=0, 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="İŞ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, + filt = ttk.Frame(browser, style=self.styles["panel"]) + filt.grid(row=1, column=0, 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)) @@ -3073,167 +3436,377 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) 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)) + entry.bind("", + lambda _e, c=category: self._render_browser(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.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) - 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) - self._render_rows(category) + holder, inner = widgets.scrollable_rows(browser, self.styles) + holder.grid(row=2, column=0, sticky="nsew", + padx=(theme.SP_2, 0), pady=(0, theme.SP_2)) + self._browser_inner[category] = inner + self._render_browser(category) - 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", + detail = ttk.Frame(page, style=self.styles["panel"]) + detail.grid(row=0, column=1, sticky="nsew") + detail.columnconfigure(0, weight=1) + detail.rowconfigure(1, weight=1) + ttk.Label(detail, text="İŞLEM AYRINTISI", 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.grid(row=1, column=0, sticky="w", padx=theme.SP_3) - self._bind_wrap(right, detail_label, theme.SP_6) - 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) + body_holder, body = widgets.scrollable_rows(detail, self.styles) + body_holder.grid(row=1, column=0, sticky="nsew", + padx=(theme.SP_2, 0), pady=(0, theme.SP_2)) + self._detail_body[category] = body hint = ttk.Label( - right, + detail, text="Komut her zaman önce gösterilir ve onaysız çalıştırılmaz.", style=self.styles["panel_muted"], justify="left", ) - hint.grid(row=3, column=0, sticky="w", padx=theme.SP_3, + hint.grid(row=2, column=0, sticky="w", padx=theme.SP_3, pady=(0, theme.SP_3)) - self._bind_wrap(right, hint, theme.SP_6) + self._bind_wrap(detail, hint, theme.SP_6) + placeholder = ttk.Label( + body, + text="Soldan bir işlem seçin; açıklaması, kapsamı, yetkisi ve " + "komut önizlemesi burada görünür.", + style=self.styles["panel_muted"], + justify="left", + ) + placeholder.pack(anchor="w", padx=theme.SP_3, pady=theme.SP_3) + self._bind_wrap(body, placeholder, theme.SP_7) + return page - def _render_rows(self, category: str) -> None: - """Liste satirlarini (varsa suzgece gore) yeniden cizer.""" - tree = getattr(self, f"_tree_{category}") + def _render_browser(self, category: str) -> None: + """Tarayici satirlari: ikon + ad + aciklama + rozetler + ok.""" + inner = self._browser_inner[category] + for child in inner.winfo_children(): + child.destroy() query = self._filters[category].get().strip().casefold() - selected = tree.selection() - for item in tree.get_children(): - tree.delete(item) + shown = 0 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 + self._build_operation_row(inner, category, op) + shown += 1 + if not shown: + ttk.Label(inner, text="Aramaya uyan işlem yok.", + style=self.styles["panel_muted"]).pack( + anchor="w", padx=theme.SP_3, pady=theme.SP_3) - def _on_operation_selected(self, tree, category: str) -> None: - selection = tree.selection() - if not selection: - return - operation = self._ops_by_id.get(selection[0]) - if operation is None: - return + def _build_operation_row(self, parent, category: str, + operation: Operation) -> None: + """Tek tarayici satiri: tiklanabilir, klavye ile secilebilir.""" + blocked = write_blocked_reason(operation) is not None + selected = (self._selected_operation is not None + and self._selected_operation.id == operation.id) + bg = theme.ACCENT_SOFT if selected else theme.BG_PANEL + row = tk.Frame(parent, background=bg, takefocus=1, + highlightthickness=1, + highlightbackground=(theme.FOCUS_RING if selected + else bg)) + row.pack(fill="x", padx=theme.SP_2, pady=1) + + bar = tk.Frame(row, background=(theme.ACCENT if selected + else bg), + width=3) + bar.pack(side="left", fill="y") + icon_colour = theme.DANGER if operation.destructive else theme.IDLE + icon = widgets.line_icon( + row, PAGE_ICONS.get(category, "info"), size=22, + colour=icon_colour, bg=bg) + icon.pack(side="left", padx=(theme.SP_2, 0), pady=theme.SP_2) + + texts = tk.Frame(row, background=bg) + texts.pack(side="left", fill="x", expand=True, + padx=(theme.SP_2, 0), pady=theme.SP_1) + title = tk.Label(texts, text=operation.label, anchor="w", + background=bg, + foreground=(theme.IDLE if blocked else theme.TEXT), + font=theme.ui_font(theme.SIZE_SM, "bold")) + title.pack(fill="x") + short_desc = operation.description or "Açıklama yok." + if len(short_desc) > 96: + short_desc = short_desc[:95] + "…" + desc = tk.Label(texts, anchor="w", justify="left", + text=short_desc, + background=bg, foreground=theme.TEXT_MUTED, + font=theme.ui_font(theme.SIZE_XS)) + desc.pack(fill="x") + + badges = tk.Frame(row, background=bg) + badges.pack(side="left", padx=theme.SP_2) + type_style = (self.styles["badge_destructive"] if operation.destructive + else self.styles["badge_readonly"]) + ttk.Label(badges, + text=(TYPE_WRITE_LABEL if operation.destructive + else TYPE_READ_LABEL), + style=type_style).pack(anchor="e", pady=(0, 2)) + ttk.Label(badges, text=BLOCKED_LABEL if blocked else AVAILABLE_LABEL, + style=(self.styles["badge_closed"] if blocked + else self.styles["badge_open"])).pack(anchor="e") + chev = widgets.line_icon(row, "chevron", size=16, + colour=theme.TEXT_FAINT, bg=bg) + chev.pack(side="right", padx=(0, theme.SP_2)) + + def activate(_event=None, op=operation) -> None: + self._select_operation(op, category) + + for widget in (row, icon, texts, title, desc, badges, chev, bar): + widget.bind("", activate) + row.bind("", activate) + row.bind("", activate) + row.bind("", lambda _e, r=row: r.configure( + highlightbackground=theme.FOCUS_RING)) + row.bind("", lambda _e, r=row, s=selected: r.configure( + highlightbackground=theme.FOCUS_RING if s else r.cget("background"))) + + def _select_operation(self, operation: Operation, category: str) -> None: self._selected_operation = operation - self._render_options(operation, category) + self._selected_category = category + self._render_browser(category) + self._render_details(operation, category) self._refresh_run_control() - 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." - return text + def _build_activity_page(self, parent): + """Etkinlik sayfasi: bu oturumda biten calismalar (zaman/islem/hedef/sonuc).""" + page = ttk.Frame(parent, style=self.styles["frame"]) + page.columnconfigure(0, weight=1) + page.rowconfigure(1, weight=1) + ttk.Label(page, text="Etkinlik", + style=self.styles["section"]).grid( + row=0, column=0, sticky="w", pady=(0, theme.SP_1)) + ttk.Label( + page, + text=("Bu oturumda çalıştırılan işlemler. Değişikliklerin tam " + "denetim kaydı sunucuda tutulur; burada sır (gizli bilgi) " + "gösterilmez."), + style=self.styles["muted"], + justify="left", + ).grid(row=1, column=0, sticky="w", pady=(0, theme.SP_2)) + holder, inner = widgets.scrollable_rows(page, self.styles) + holder.grid(row=2, column=0, sticky="nsew") + self._browser_inner[ACTIVITY_PAGE] = inner + self._render_activity() + return page - 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(): + def _record_activity(self, operation_label: str, target_label: str, + ok: bool, detail: str) -> None: + """Oturum kaydina bir satir ekler (sunu sozlugu degistirmez).""" + import datetime + stamp = datetime.datetime.now().strftime("%H:%M:%S") + word = "TAMAM" if ok else "HATA" + self._activity.append((stamp, operation_label, target_label, word, + detail)) + count_widget = self._nav_buttons.get(ACTIVITY_PAGE) + if count_widget: + count_widget[3].configure(text=str(len(self._activity))) + if self._current_page == ACTIVITY_PAGE: + self._render_activity() + + def _render_activity(self) -> None: + inner = self._browser_inner.get(ACTIVITY_PAGE) + if inner is None: + return + for child in inner.winfo_children(): + child.destroy() + if not self._activity: + ttk.Label(inner, text="Henüz işlem çalıştırılmadı.", + style=self.styles["panel_label"]).pack( + anchor="w", padx=theme.SP_3, pady=(theme.SP_3, 0)) + ttk.Label( + inner, + text=("Bir işlem çalıştırdığınızda zamanı, adı, hedefi ve " + "sonucu burada listelenir."), + style=self.styles["panel_muted"], + justify="left", + ).pack(anchor="w", padx=theme.SP_3, pady=(0, theme.SP_3)) + return + for stamp, label, target, word, detail in reversed(self._activity): + row = ttk.Frame(inner, style=self.styles["panel"]) + row.pack(fill="x", padx=theme.SP_3, pady=theme.SP_1) + self._badge(row, word, + self.styles["badge_open"] if word == "TAMAM" + else self.styles["badge_destructive"]).pack(side="left") + ttk.Label(row, text=f"{stamp} · {label}", + style=self.styles["panel_label"]).pack( + side="left", padx=(theme.SP_2, 0)) + ttk.Label(row, text=f"{target} · {detail}", + style=self.styles["panel_muted"]).pack( + side="left", padx=(theme.SP_2, 0)) + + def _render_details(self, operation: Operation, category: str) -> None: + """Ayrinti paneli: baslik + aciklama + meta + uyari + secenek + onizleme. + + Birincil eylem buradaki "Komutu göster ve çalıştır" düğmesidir + (alt çubuktakiyle aynı akışa girer); tehlike stili YALNIZCA bu + düğmededir, panelin tamamı kırmızıya boyanmaz. + """ + body = self._detail_body[category] + for child in body.winfo_children(): child.destroy() self.option_widgets = {} - detail_label.configure(text=self._operation_summary(operation)) - row = 0 - for spec in operation.options: - 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") - widget = ttk.Checkbutton(options_frame, variable=var, - style=self.styles["panel_check"]) - self.option_widgets[spec.name] = ("bool", var) - elif spec.kind == "choice": - var = tk.StringVar(value=spec.default or (spec.choices[0] if spec.choices else "")) - widget = ttk.Combobox(options_frame, textvariable=var, - values=list(spec.choices), width=22, - state="readonly") - self.option_widgets[spec.name] = ("str", var) - else: - var = tk.StringVar(value=spec.default) - widget = ttk.Entry(options_frame, textvariable=var, width=22) - self.option_widgets[spec.name] = ("str", var) - widget.grid(row=row, column=1, sticky="w", pady=theme.SP_1) - if spec.hint: - # Kisa ipucu: sabit sarma siniri yeterli ve kendi kendini - # besleme riski yok (etiketin kendi genisligine baglanmaz). - ttk.Label(options_frame, text=spec.hint, - style=self.styles["panel_faint"], - wraplength=theme.SP_7 * 3, - justify="left").grid(row=row, column=2, sticky="w", - padx=(theme.SP_2, 0)) - 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)) + ttk.Label(body, text=operation.label, + style=self.styles["section"]).pack( + anchor="w", padx=theme.SP_3, pady=(theme.SP_3, theme.SP_1)) + desc = ttk.Label(body, text=operation.description or "Açıklama yok.", + style=self.styles["panel_label"], justify="left") + desc.pack(anchor="w", padx=theme.SP_3) + self._bind_wrap(body, desc, theme.SP_7) + if operation.playbook: + ttk.Label( + body, + text=f"Ansible playbook: {PLAYBOOK_DIR_REL}/{operation.playbook}", + style=self.styles["panel_faint"], + justify="left", + ).pack(anchor="w", padx=theme.SP_3, pady=(theme.SP_1, 0)) + + target = self.current_target() + target_word = (target.label if target is not None else "— seçilmedi") + widgets.meta_grid(body, self.styles, ( + ("HEDEF", target_word), + ("KAPSAM", SCOPE_LABELS.get(operation.scope, operation.scope)), + ("YETKİ", ("root gerekir (sudo bf)" if operation.needs_root + else "gerekmez (salt-okunur)")), + ("TÜR", ("YIKICI — iki kez onay" if operation.destructive + else "salt-okunur")), + )).pack(fill="x", padx=theme.SP_3, pady=(theme.SP_3, 0)) + + reason = write_blocked_reason(operation) + if operation.destructive: + self._detail_banner( + body, + "BU İŞLEM YIKICIDIR: komut önce gösterilir, sonra iki kez " + "onay istenir.", danger=True) + if operation.scope in ("wave", "release") or ( + target is not None and target.kind == "all"): + self._detail_banner( + body, + "Filo kapsamı: etki alanı geniştir. Onay penceresinde hedefi " + "yeniden denetleyin.", danger=False) + if reason: + self._detail_banner(body, reason, danger=False) + + if operation.options: + form = ttk.Frame(body, style=self.styles["panel"]) + form.pack(fill="x", padx=theme.SP_3, pady=(theme.SP_3, 0)) + form.columnconfigure(1, weight=1) + row = 0 + for spec in operation.options: + ttk.Label(form, 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") + widget = ttk.Checkbutton(form, variable=var, + style=self.styles["panel_check"]) + self.option_widgets[spec.name] = ("bool", var) + elif spec.kind == "choice": + var = tk.StringVar( + value=spec.default or (spec.choices[0] + if spec.choices else "")) + widget = ttk.Combobox(form, textvariable=var, + values=list(spec.choices), width=22, + state="readonly") + self.option_widgets[spec.name] = ("str", var) + else: + var = tk.StringVar(value=spec.default) + widget = ttk.Entry(form, textvariable=var, width=22) + self.option_widgets[spec.name] = ("str", var) + widget.grid(row=row, column=1, sticky="w", pady=theme.SP_1) + if spec.hint: + ttk.Label(form, text=spec.hint, + style=self.styles["panel_faint"], + wraplength=theme.SP_7 * 3, + justify="left").grid( + row=row, column=2, sticky="w", padx=(theme.SP_2, 0)) + row += 1 + + preview = widgets.CollapsiblePanel(body, self.styles, + "Komut önizlemesi") + preview.pack(fill="x", padx=theme.SP_3, pady=(theme.SP_3, 0)) + preview_text_box = tk.Text( + preview.body, height=6, 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) + preview_text_box.pack(fill="x", expand=True) + preview_text_box.insert( + "end", "Henüz oluşturulmadı. Aşağıdaki düğme komutu GERÇEK " + "backend ile üretir; hiçbir şey çalıştırmaz.") + preview_text_box.configure(state="disabled") + ttk.Button(preview.body, text="Önizlemeyi oluştur", + style=self.styles["quiet_button"], + command=lambda: self._refresh_preview( + operation, preview, preview_text_box)).pack( + anchor="w", pady=(theme.SP_2, 0)) + + action_row = ttk.Frame(body, style=self.styles["panel"]) + action_row.pack(fill="x", padx=theme.SP_3, pady=theme.SP_3) + self._detail_run_button = ttk.Button( + action_row, text=RUN_BUTTON_LABEL, + style=(self.styles["danger_button"] if operation.destructive + else self.styles["accent_button"]), + command=lambda: self._execute(operation)) + self._detail_run_button.pack(side="left") + if reason: + self._detail_run_button.configure(text=RUN_BUTTON_LABEL + " — kapalı", + style=self.styles["button"], + state="disabled") + ttk.Label(action_row, text=reason, + style=self.styles["app_warn"], + justify="left").pack(side="left", + padx=(theme.SP_3, 0)) + elif operation.destructive: + ttk.Label(action_row, text="Yıkıcı işlem: iki kez onay istenir.", + style=self.styles["app_warn"], + justify="left").pack(side="left", + padx=(theme.SP_3, 0)) + + def _detail_banner(self, parent, text: str, danger: bool) -> None: + band = ttk.Frame( + parent, + style=(self.styles["warn_band"] if not danger + else self.styles["panel"])) + band.pack(fill="x", padx=theme.SP_3, pady=(theme.SP_2, 0)) + label = ttk.Label(band, text=text, + style=(self.styles["warn_label"] if not danger + else self.styles["panel_danger"]), + justify="left") + label.pack(anchor="w", padx=theme.SP_3, pady=theme.SP_2) + self._bind_wrap(band, label, theme.SP_7) + + def _refresh_preview(self, operation: Operation, panel, + box) -> None: + """Komut onizlemesi: gercek backend uretir, calistirma YOK. + + Kapilarin grant bayraklari henuz verilmedigi icin gosterilen komut + "temel" komuttur; kesin komut onaylardan sonra yeniden gosterilir + (akisi `_execute` korur). Backend reddi satir icinde Turkce yazi + olarak gorunur. + """ + target = (self.current_target() + if operation.is_fleet or operation.needs_dealer else None) + options = self._collect_options(operation) + try: + plan = self.backend.build_command( + operation, target, options, + check_mode=bool(self.check_var.get())) + lines = list(preview_text(plan, operation=operation, + target=target).splitlines()) + except (CommandError, BackendUnavailable) as exc: + lines = [f"Önizleme üretilemedi: {exc}"] + box.configure(state="normal") + box.delete("1.0", "end") + for line in lines: + box.insert("end", line + "\n") + box.configure(state="disabled") + panel.set_expanded(True) # -- hedef ------------------------------------------------------------- def _load_inventory(self) -> None: @@ -3277,23 +3850,42 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) self._update_header_target() def _update_header_target(self) -> None: - """Basliktaki hedef kimligi her secim degisikliginde guncellenir.""" + """Basliktaki hedef kimligi her secim degisikliginde guncellenir. + + Ayni anda Genel Bakis karti tazelenir ve uretim/tum-filo notlari + kapsama gore esitlenir. Secili islemin secenegi yoksa (kayip veri + riski yok) ayrintidaki hedef satiri da tazelenir; secenekli + islemlerde operatorun girdisi korunur (calisma ani penceresi + zaten guncel hedefi gosterir). + """ 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) + else: + self.header_target.configure(text=target.label) + self._sync_target_inputs() + self._refresh_overview() + operation = self._selected_operation + category = getattr(self, "_selected_category", None) + if (operation is not None and not operation.options + and category in self._detail_body): + self._render_details(operation, category) def current_target(self) -> Target | None: - if self.all_var.get(): + # Parcali denetim tek dogruluk kaynagidir: gorunur kapsam neyse hedef + # odur. (Eskiden bayi metni dalgayi ezerdi; tum-filo uyarisi ekranda + # dururken komut tek cihaza gitmemelidir.) + scope = (self.scope_control.get() if hasattr(self, "scope_control") + else "device") + if scope == "all" or self.all_var.get(): return Target("all", "all") + if scope == "wave": + wave = self.wave_var.get().strip() + return Target("group", wave) if wave else None dealer = self.dealer_var.get().strip() if dealer: return Target("dealer", dealer) - wave = self.wave_var.get().strip() - if wave: - return Target("group", wave) return None # -- yetki ve birincil eylem ------------------------------------------ @@ -3462,15 +4054,45 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) self, plan: CommandPlan, operation: Operation, target: Target | None, title: str, ) -> bool: - """Komutu gosteren onay penceresi (yikici islemde uyari vurgusu).""" - preview = preview_text(plan, operation=operation, target=target) - return bool( - messagebox.askyesno( - title, - preview + "\n\nBu komut çalıştırılsın mı?", - icon="warning" if plan.destructive else "question", - default="no" if plan.destructive else "yes", - ) + """Komutu gosteren onay penceresi: ISLEM / HEDEF / KAPSAM / ETKI. + + Yikici planda odak ve varsayilan Vazgec'tedir (Enter ile yikim + onaylanamaz); filo/uretim kapsami ayrica vurgulanir. Onaysiz hicbir + sey calismaz (akisi `_execute` korur). + """ + target_label = target.label if target is not None else "—" + scope_label = SCOPE_LABELS.get(operation.scope, operation.scope) + if plan.destructive: + effect = ("Seçili hedefte kalıcı değişiklik yapar; hizmetler " + "kesintiye uğrayabilir.") + elif plan.is_report_only: + effect = "Salt-okunur rapor: hiçbir şey çalıştırılmaz." + else: + effect = "Salt-okunur denetim: sistemde değişiklik yapmaz." + if plan.notes: + effect += " " + " ".join(plan.notes) + scope_warning = None + if target is not None and target.kind == "all": + scope_warning = ("KAPSAM — TÜM FİLO: bu komut filodaki TÜM " + "cihazları etkiler.") + elif ((target is not None and target.kind == "group" + and target.value == PRODUCTION_WAVE) + or operation.scope == "release"): + scope_warning = ("KAPSAM — ÜRETİM: etkisi en geniş dalga. " + "Hedefi ve komutu satır satır denetleyin.") + preview_lines = (list(plan.lines) if plan.lines + else list(plan.report_lines or ())) + return widgets.ask_confirmation( + self.root, self.styles, + title=title, + operation=operation.label, + target_label=target_label, + scope_label=scope_label, + effect=effect, + preview_lines=preview_lines, + confirm_label=operation.label, + scope_warning=scope_warning, + danger=bool(plan.destructive), ) def _collect_grants( @@ -3521,12 +4143,18 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) # the whole surface instead of one control. self.output.write_now("[ENGELLENDİ] " + NON_ROOT_DISABLED_HINT) self._set_status("YETKİ YOK", "danger") + self._set_exec_idle("Engellendi: yetki yok.") return self.running = True self.cancel_event.clear() self.run_button.configure(state="disabled") self.cancel_button.configure(state="normal") self._set_status("ÇALIŞIYOR", "info") + operation = self._ops_by_id.get(plan.operation_id) + self._run_label = (operation.label if operation is not None + else plan.operation_id) + self._run_target = plan.target_label or "—" + self._set_exec_running(self._run_label, self._run_target) def worker() -> None: try: @@ -3544,12 +4172,18 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) self.running = False self._refresh_run_control() self.cancel_button.configure(state="disabled") + self._set_exec_finished(bool(result.ok), result.exit_code) + label = getattr(self, "_run_label", "işlem") + target = getattr(self, "_run_target", "—") if result.ok: self.output.append("[OK] Komut başarıyla tamamlandı (çıkış kodu 0).") self._set_status("TAMAM (çıkış kodu 0)", "ok") + self._record_activity(label, target, True, "çıkış kodu 0") else: self.output.append(f"[HATA] Komut {result.exit_code} koduyla bitti.") self._set_status(f"HATA (çıkış kodu {result.exit_code})", "danger") + self._record_activity(label, target, False, + f"çıkış kodu {result.exit_code}") def _cancel(self) -> None: self.cancel_event.set() @@ -3592,39 +4226,26 @@ class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz) return result["value"] 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, style=self.styles["dialog"], - wraplength=460, justify="left").pack( - padx=theme.SP_4, pady=theme.SP_4, anchor="w" + """Yazili kapi onayi: operator jetonu birebir yazar (karsilastirma ayni). + + Pencere yalnizca metni toplar; eslesme kurali `_collect_grants` + icinde degismeden durur. Filo/uretim kapsami pencerede ayrica + vurgulanir. + """ + target = self.current_target() + scope_warning = None + if target is not None and target.kind == "all": + scope_warning = ("KAPSAM — TÜM FİLO: yazdığınız sözcük filonun " + "TAMAMI için kapıyı açar.") + elif target is not None and target.kind == "group" and target.value == PRODUCTION_WAVE: + scope_warning = ("KAPSAM — ÜRETİM: yazdığınız sözcük üretim " + "dalgası için kapıyı açar.") + return widgets.ask_token( + self.root, self.styles, + title=title, + message=message, + scope_warning=scope_warning, ) - var = tk.StringVar() - 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: - result["value"] = var.get().strip() - dialog.destroy() - - def cancel() -> None: - result["value"] = None - dialog.destroy() - - 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_widgets.py b/bfos/ui_widgets.py new file mode 100644 index 0000000..9269002 --- /dev/null +++ b/bfos/ui_widgets.py @@ -0,0 +1,489 @@ +"""bfos/ui_widgets.py -- reusable presentational components for the console. + +What this module is +------------------- +The small widget vocabulary the redesigned operator console is built from: +line icons (Tk canvas, shipped locally, no assets), a segmented scope +control, a collapsible panel, a two-column meta grid, a scrollable row +container, an elapsed timer, and two safety dialogs (plan confirmation and +typed-token gate). + +What this module is NOT +----------------------- +It knows nothing about operations, targets, gates, commands, auditing or +execution. It receives plain strings and token colours and returns plain +widget handles and booleans. Every behavioural decision (which gates exist, +what a token must equal, when a command may run) stays in ``bfos/ui_gui.py`` +and the backend behind it. + +Design rules (docs/DESIGN.md, docs/34-CONSOLE-VISUAL-CONCEPT.md): + +* no colour literals -- every fill/foreground comes from ``ui_theme``, +* no user-visible English -- caller-facing labels are Turkish, +* status is word + colour, never colour alone (callers pass the words), +* tkinter is optional: the module imports without it so static tests never + need a display (mirrors the ``ui_gui.py`` guard). +""" + +from __future__ import annotations + +import time +from pathlib import Path +from typing import Any, Callable, Mapping, Sequence + +try: # pragma: no cover - environment dependent + import tkinter as tk + from tkinter import ttk + + HAVE_TKINTER = True +except Exception: # pragma: no cover - environment dependent + tk = ttk = None # type: ignore[assignment] + HAVE_TKINTER = False + +# The view layer only calls these with a live display; Any keeps the optional +# import from tripping the type checker (same pattern as bfos/ui_gui.py). +tk: Any +ttk: Any + +try: + from bfos import ui_theme as theme +except ImportError: # pragma: no cover - loaded outside the package + import sys + + _WIDGETS_DIR = str(Path(__file__).resolve().parent) + if _WIDGETS_DIR not in sys.path: + sys.path.insert(0, _WIDGETS_DIR) + import ui_theme as theme # type: ignore[no-redef] + + +# --------------------------------------------------------------------------- +# Line icons -- one stroke vocabulary, drawn on a Tk canvas +# --------------------------------------------------------------------------- +def draw_icon(canvas, name: str, size: int, colour: str) -> None: + """Draw a ``name`` line icon centred on ``canvas`` (already sized). + + Coordinates are derived from ``size`` so every icon scales together; the + stroke is a single colour passed by the caller (a theme token), never a + literal. Unknown names fall back to a neutral square so a typo degrades + to a box instead of an exception. + """ + pad = max(2, size // 6) + x0, y0, x1, y1 = pad, pad, size - pad, size - pad + w = 2 + line = lambda *pts: canvas.create_line(*pts, fill=colour, width=w) + rect = lambda a, b, c, d: canvas.create_rectangle( + a, b, c, d, outline=colour, width=w + ) + + if name == "device": # monitor: screen + stand + rect(x0, y0, x1, y1 - 5) + mid = (x0 + x1) / 2 + line(mid, y1 - 5, mid, y1 - 1) + line(mid - 4, y1 - 1, mid + 4, y1 - 1) + elif name == "install": # arrow down into a tray + mid = (x0 + x1) / 2 + line(mid, y0, mid, y1 - 5) + line(mid, y0, mid - 4, y0 + 4) + line(mid, y0, mid + 4, y0 + 4) + line(x0, y1 - 2, x1, y1 - 2) + line(x0, y1 - 2, x0, y1 - 5) + line(x1, y1 - 2, x1, y1 - 5) + elif name == "health": # check mark in a circle + canvas.create_oval(x0, y0, x1, y1, outline=colour, width=w) + line(x0 + 3, (y0 + y1) / 2, (x0 + x1) / 2 - 1, y1 - 4) + line((x0 + x1) / 2 - 1, y1 - 4, x1 - 3, y0 + 4) + elif name == "info": # small i in a circle + canvas.create_oval(x0, y0, x1, y1, outline=colour, width=w) + mid = (x0 + x1) / 2 + line(mid, y0 + 4, mid, y0 + 5) + line(mid, y0 + 7, mid, y1 - 4) + elif name == "tool": # sliders: two horizontal tracks with knobs + third = (y1 - y0) / 3 + line(x0, y0 + third, x1, y0 + third) + line(x0, y0 + 2 * third, x1, y0 + 2 * third) + canvas.create_oval(x0 + 2, y0 + third - 3, x0 + 8, y0 + third + 3, + outline=colour, width=w) + canvas.create_oval(x1 - 8, y0 + 2 * third - 3, x1 - 2, + y0 + 2 * third + 3, outline=colour, width=w) + elif name == "wave": # signal arcs from a dot + canvas.create_oval(x0 + 1, y1 - 5, x0 + 5, y1 - 1, + fill=colour, outline=colour) + canvas.create_arc(x0 - 1, y0 + 3, x1 - 3, y1 + 1, start=270, extent=90, + outline=colour, width=w, style="arc") + canvas.create_arc(x0 + 3, y0 - 1, x1 + 1, y1 - 3, start=270, extent=90, + outline=colour, width=w, style="arc") + elif name == "fleet": # four small squares: many machines + mx, my = (x0 + x1) / 2, (y0 + y1) / 2 + rect(x0, y0, mx - 1, my - 1) + rect(mx + 1, y0, x1, my - 1) + rect(x0, my + 1, mx - 1, y1) + rect(mx + 1, my + 1, x1, y1) + elif name == "activity": # clock: recent runs + canvas.create_oval(x0, y0, x1, y1, outline=colour, width=w) + mid = ((x0 + x1) / 2, (y0 + y1) / 2) + line(*mid, *mid, mid[0], y0 + 5) + line(*mid, *mid, mid[0] + 4, mid[1] + 1) + elif name == "warn": # triangle with a mark + mid = (x0 + x1) / 2 + line(mid, y0 + 1, x1 - 1, y1 - 1) + line(x1 - 1, y1 - 1, x0 + 1, y1 - 1) + line(x0 + 1, y1 - 1, mid, y0 + 1) + line(mid, y0 + 6, mid, y0 + 10) + line(mid, y1 - 5, mid, y1 - 4) + elif name == "shield": # privilege outline + mid = (x0 + x1) / 2 + line(x0 + 1, y0 + 1, mid, y0 + 1) + line(mid, y0 + 1, x1 - 1, y0 + 1) + line(x1 - 1, y0 + 1, x1 - 1, y0 + 7) + line(x1 - 1, y0 + 7, mid, y1 - 1) + line(mid, y1 - 1, x0 + 1, y0 + 7) + line(x0 + 1, y0 + 7, x0 + 1, y0 + 1) + elif name == "chevron": # row affordance: there is a next step + line(x0 + 2, y0 + 2, x1 - 2, (y0 + y1) / 2) + line(x1 - 2, (y0 + y1) / 2, x0 + 2, y1 - 2) + else: # neutral box: a missing icon is visible, never fatal + rect(x0 + 2, y0 + 2, x1 - 2, y1 - 2) + + +def line_icon(parent, name: str, size: int = 22, colour: str | None = None, + bg: str | None = None): + """A canvas carrying one line icon (offline, no image assets).""" + canvas = tk.Canvas( + parent, + width=size, + height=size, + background=bg or theme.BG_PANEL, + highlightthickness=0, + borderwidth=0, + ) + draw_icon(canvas, name, size, colour or theme.TEXT_MUTED) + return canvas + + +# --------------------------------------------------------------------------- +# Segmented control -- exactly one scope is on +# --------------------------------------------------------------------------- +class SegmentedControl: + """Three-way scope switch: [Cihaz | Dalga | Tüm filo]. + + Exactly one segment is selected and the selection is always visible, so + the operator can never believe a device is targeted while the fleet is. + Implemented on ttk buttons (keyboard-focusable, native tab order); the + selected segment carries the accent style, the rest the quiet style. + """ + + def __init__(self, parent, styles: Mapping[str, str], + options: Sequence[tuple[str, str]], + initial: str, + command: Callable[[str], None] | None = None) -> None: + self.styles = styles + self.command = command + self.frame = ttk.Frame(parent, style=styles["panel"]) + self._buttons: dict[str, Any] = {} + self._value = initial + for key, label in options: + button = ttk.Button(self.frame, text=label, + command=lambda k=key: self.set(k)) + button.pack(side="left", padx=(0, theme.SP_1)) + self._buttons[key] = button + self._paint() + + def _paint(self) -> None: + for key, button in self._buttons.items(): + if key == self._value: + button.configure(style=self.styles["accent_button"], + state="disabled") + else: + button.configure(style=self.styles["button"], state="normal") + + def set(self, key: str, notify: bool = True) -> None: + if key not in self._buttons or key == self._value: + return + self._value = key + self._paint() + if notify and self.command is not None: + self.command(key) + + def get(self) -> str: + return self._value + + def grid(self, **kwargs) -> None: # honour the caller's geometry + self.frame.grid(**kwargs) + + def pack(self, **kwargs) -> None: + self.frame.pack(**kwargs) + + +# --------------------------------------------------------------------------- +# Collapsible panel -- long detail hidden until asked for +# --------------------------------------------------------------------------- +class CollapsiblePanel: + """A titled section whose body folds away (command preview, details).""" + + def __init__(self, parent, styles: Mapping[str, str], title: str, + *, expanded: bool = False, + show_label: str = "Göster", + hide_label: str = "Gizle") -> None: + self.styles = styles + self.show_label = show_label + self.hide_label = hide_label + self.expanded = expanded + self.frame = ttk.Frame(parent, style=styles["panel"]) + self.toggle = ttk.Button(self.frame, style=styles["quiet_button"], + command=self.toggle_body) + self.toggle.pack(fill="x") + self.body = ttk.Frame(self.frame, style=styles["panel"]) + self._title = title + self._paint() + + def _paint(self) -> None: + marker = "▾" if self.expanded else "▸" + action = self.hide_label if self.expanded else self.show_label + self.toggle.configure(text=f"{marker} {self._title} — {action}") + if self.expanded: + self.body.pack(fill="both", expand=True, + pady=(theme.SP_2, 0)) + else: + self.body.pack_forget() + + def toggle_body(self) -> None: + self.expanded = not self.expanded + self._paint() + + def set_expanded(self, expanded: bool) -> None: + if expanded != self.expanded: + self.toggle_body() + + def pack(self, **kwargs) -> None: + self.frame.pack(**kwargs) + + def grid(self, **kwargs) -> None: + self.frame.grid(**kwargs) + + +# --------------------------------------------------------------------------- +# Meta grid -- label/value pairs with one shared label column +# --------------------------------------------------------------------------- +def meta_grid(parent, styles: Mapping[str, str], + pairs: Sequence[tuple[str, str]], + label_width: int = 14) -> Any: + """Two-column fact block (İŞLEM/HEDEF/KAPSAM/ETKİ); labels right-aligned.""" + frame = ttk.Frame(parent, style=styles["panel"]) + frame.columnconfigure(1, weight=1) + for row, (label, value) in enumerate(pairs): + ttk.Label(frame, text=label, style=styles["field_label"], + width=label_width, anchor="e").grid( + row=row, column=0, sticky="ne", padx=(0, theme.SP_2), + pady=theme.SP_1) + text = ttk.Label(frame, text=value, style=styles["panel_label"], + justify="left") + text.grid(row=row, column=1, sticky="nw", pady=theme.SP_1) + return frame + + +# --------------------------------------------------------------------------- +# Scrollable row container -- the operation browser lives here +# --------------------------------------------------------------------------- +def scrollable_rows(parent, styles: Mapping[str, str]): + """A canvas + scrollbar pair with an inner frame rows pack into.""" + frame = ttk.Frame(parent, style=styles["panel"]) + canvas = tk.Canvas(frame, background=theme.BG_PANEL, + highlightthickness=0, borderwidth=0) + scroll = ttk.Scrollbar(frame, orient="vertical", command=canvas.yview) + canvas.configure(yscrollcommand=scroll.set) + scroll.pack(side="right", fill="y") + canvas.pack(side="left", fill="both", expand=True) + inner = ttk.Frame(canvas, style=styles["panel"]) + window = canvas.create_window((0, 0), window=inner, anchor="nw") + + def on_inner_configure(_event=None) -> None: + canvas.configure(scrollregion=canvas.bbox("all")) + + def on_canvas_configure(event) -> None: + canvas.itemconfigure(window, width=event.width) + + inner.bind("", on_inner_configure) + canvas.bind("", on_canvas_configure) + return frame, inner + + +# --------------------------------------------------------------------------- +# Elapsed timer -- mm:ss for the running state, no widget of its own +# --------------------------------------------------------------------------- +class Elapsed: + """Monotonic stopwatch; the drawer polls :meth:`text` via ``after``.""" + + def __init__(self) -> None: + self._start = 0.0 + self._end = 0.0 + + def start(self) -> None: + self._start = time.monotonic() + self._end = 0.0 + + def stop(self) -> None: + self._end = time.monotonic() + + def seconds(self) -> float: + if not self._start: + return 0.0 + return (self._end or time.monotonic()) - self._start + + def text(self) -> str: + total = int(self.seconds()) + return f"{total // 60:02d}:{total % 60:02d}" + + +# --------------------------------------------------------------------------- +# Safety dialogs -- the confirm gate and the typed-token gate, as windows +# --------------------------------------------------------------------------- +def _base_dialog(root, title: str, min_width: int = 560): + dialog = tk.Toplevel(root) + dialog.title(title) + dialog.configure(bg=theme.BG_APP) + dialog.transient(root) + dialog.grab_set() + dialog.minsize(min_width, 200) + dialog.bind("", lambda _e: dialog.destroy()) + return dialog + + +def _dialog_buttons(dialog, styles: Mapping[str, str], + confirm_label: str, danger: bool, + on_confirm, cancel_label: str = "Vazgeç") -> Any: + row = ttk.Frame(dialog, style=styles["frame"]) + row.pack(fill="x", padx=theme.SP_4, pady=theme.SP_4) + cancel = ttk.Button(row, text=cancel_label, + style=styles["quiet_button"], + command=dialog.destroy) + cancel.pack(side="right") + confirm = ttk.Button( + row, text=confirm_label, + style=styles["danger_button"] if danger else styles["accent_button"], + command=on_confirm, + ) + confirm.pack(side="right", padx=(0, theme.SP_2)) + return confirm, cancel + + +def ask_confirmation(root, styles: Mapping[str, str], *, + title: str, + operation: str, + target_label: str, + scope_label: str, + effect: str, + preview_lines: Sequence[str], + confirm_label: str, + scope_warning: str | None = None, + danger: bool = False) -> bool: + """Plan confirmation: OPERATION / TARGET / SCOPE / EFFECT + preview. + + Returns True only on an explicit confirm click. For destructive plans + the Cancel control takes the initial focus and the Return key, so Enter + can never confirm destruction; the dialog closes (False) on Escape. + """ + result: dict[str, bool] = {"value": False} + dialog = _base_dialog(root, title) + + ttk.Label(dialog, text=title, style=styles["dialog_title"], + wraplength=520, justify="left").pack( + padx=theme.SP_4, pady=(theme.SP_4, theme.SP_2), anchor="w") + + if scope_warning: + banner = ttk.Frame(dialog, style=styles["warn_band"]) + banner.pack(fill="x", padx=theme.SP_4, pady=(0, theme.SP_2)) + inner = ttk.Frame(banner, style=styles["warn_band"]) + inner.pack(fill="x", padx=theme.SP_3, pady=theme.SP_2) + ttk.Label(inner, text=scope_warning, + style=styles["warn_label"], + wraplength=480, justify="left").pack(anchor="w") + + grid = meta_grid(dialog, styles, ( + ("İŞLEM", operation), + ("HEDEF", target_label), + ("KAPSAM", scope_label), + ("ETKİ", effect), + )) + grid.pack(fill="x", padx=theme.SP_4, pady=(0, theme.SP_2)) + + preview = CollapsiblePanel(dialog, styles, "Komut önizlemesi", + expanded=danger) + preview.pack(fill="both", expand=True, padx=theme.SP_4) + text = tk.Text( + preview.body, + height=min(12, max(4, len(preview_lines))), + 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, + ) + text.pack(fill="both", expand=True) + for line in preview_lines: + text.insert("end", line + "\n") + text.configure(state="disabled") + + def confirm() -> None: + result["value"] = True + dialog.destroy() + + confirm_btn, cancel_btn = _dialog_buttons( + dialog, styles, confirm_label, danger, confirm) + if danger: + cancel_btn.focus_set() # Enter must never confirm destruction + else: + confirm_btn.focus_set() + dialog.bind("", lambda _e: confirm() if not danger else None) + dialog.wait_window() + return result["value"] + + +def ask_token(root, styles: Mapping[str, str], *, + title: str, + message: str, + scope_warning: str | None = None, + confirm_label: str = "Onayla") -> str | None: + """Typed-token gate: the operator retypes the exact token to proceed. + + Empty input or window close aborts (None). The comparison itself stays + with the caller in ``ui_gui.py``; this window only collects the text. + """ + result: dict[str, str | None] = {"value": None} + dialog = _base_dialog(root, title, min_width=480) + + ttk.Label(dialog, text=title, style=styles["dialog_title"]).pack( + padx=theme.SP_4, pady=(theme.SP_4, theme.SP_2), anchor="w") + if scope_warning: + banner = ttk.Frame(dialog, style=styles["warn_band"]) + banner.pack(fill="x", padx=theme.SP_4, pady=(0, theme.SP_2)) + inner = ttk.Frame(banner, style=styles["warn_band"]) + inner.pack(fill="x", padx=theme.SP_3, pady=theme.SP_2) + ttk.Label(inner, text=scope_warning, + style=styles["warn_label"], + wraplength=420, justify="left").pack(anchor="w") + ttk.Label(dialog, text=message, style=styles["dialog"], + wraplength=440, justify="left").pack( + padx=theme.SP_4, pady=(0, theme.SP_3), anchor="w") + + var = tk.StringVar() + entry = ttk.Entry(dialog, textvariable=var, width=32, + style=styles["entry"]) + entry.pack(padx=theme.SP_4, anchor="w") + entry.focus_set() + + def confirm() -> None: + result["value"] = var.get().strip() or None + dialog.destroy() + + confirm_btn, _cancel_btn = _dialog_buttons( + dialog, styles, confirm_label, False, confirm) + confirm_btn.focus_set() + dialog.bind("", lambda _e: confirm()) + dialog.wait_window() + return result["value"] diff --git a/docs/33-OPERATOR-CONSOLE.md b/docs/33-OPERATOR-CONSOLE.md index 888ca21..eb39ca4 100644 --- a/docs/33-OPERATOR-CONSOLE.md +++ b/docs/33-OPERATOR-CONSOLE.md @@ -15,13 +15,13 @@ ## 1. Amaç -**Bu araç nedir?** `bf`, cihaza `sudo bf` yazınca açılan **grafik operatör uygulamasıdır**. Varsayılan olarak **basit mod** gelir: ekranda üç büyük düğme vardır — `Kur`, `Durum`, `Çıkış`. Üst çubuktaki `Detaylı mod` düğmesiyle **5 kategori sekmesi** açılır: Kurulum, Cihaz işlemleri, Merkez hazırlığı, Araçlar, Cihaz bilgisi. Listeden bir işlem seçip `Komutu göster ve çalıştır` düğmesine basarsın; uygulama senin adına doğru komutu üretir, çalıştırmadan **önce gösterip onay ister** ve ne yaptığını Türkçe satırlarla akıtır. +**Bu araç nedir?** `bf`, cihaza `sudo bf` yazınca açılan **grafik operatör uygulamasıdır**. Varsayılan olarak **basit mod** gelir: ekranda üç büyük düğme vardır — `Kur`, `Durum`, `Çıkış`. Üst çubuktaki `Detaylı mod` düğmesiyle **kenar çubuklu kabuk** açılır: 5 katalog bölümü (Kurulum, Cihaz işlemleri, Merkez hazırlığı, Araçlar, Cihaz bilgisi) + Etkinlik. Tarayıcıdan bir işlem seçip `Komutu göster ve çalıştır` düğmesine basarsın; uygulama senin adına doğru komutu üretir, çalıştırmadan **önce gösterip onay ister** ve ne yaptığını Türkçe satırlarla akıtır. -**Neyi kolaylaştırır?** Normalde cihazı kurmak, durumunu okumak, teşhis paketi almak veya bir filo işlemi başlatmak için onlarca komutu ve doğru sırasını bilmek gerekir. `bf` bunları sekmeler ve arama yapılabilir listeler altında toplar, her komutu **çalıştırmadan önce** gösterir, tehlikeli işlemlerde ikinci kez sorar, `production` dalgası ve sürüm kapısı için ayrı onay ister ve her işlemi bir denetim kaydına (`/var/log/blueforce-console.log`, mod `600`) yazar. Kısacası: ezberleyeceğin tek şey `sudo bf` komutudur. +**Neyi kolaylaştırır?** Normalde cihazı kurmak, durumunu okumak, teşhis paketi almak veya bir filo işlemi başlatmak için onlarca komutu ve doğru sırasını bilmek gerekir. `bf` bunları bölümler ve arama yapılabilir listeler altında toplar, her komutu **çalıştırmadan önce** gösterir, tehlikeli işlemlerde ikinci kez sorar, `production` dalgası ve sürüm kapısı için ayrı onay ister ve her işlemi bir denetim kaydına (`/var/log/blueforce-console.log`, mod `600`) yazar. Kısacası: ezberleyeceğin tek şey `sudo bf` komutudur. ## 2. Kapsam -- Kapsam içi: Ubuntu'da ilk kurulum (git'ten indir + bootstrap), basit mod ve 5 kategori sekmesi, üst çubuktan hedef seçimi (bayi no / dalga / tüm filo), uçtan uca örnek seans, yetki modeli (`sudo bf` = tam yetki), şifre dosyası (`bf-creds`) davranışı, grafik arayüz açılamadığında ne olacağı, en sık hatalar ve güvenlik kuralları. +- Kapsam içi: Ubuntu'da ilk kurulum (git'ten indir + bootstrap), basit mod ve kenar çubuklu 5 bölüm, parçalı seçiciden hedef seçimi (cihaz / dalga / tüm filo), uçtan uca örnek seans, yetki modeli (`sudo bf` = tam yetki), şifre dosyası (`bf-creds`) davranışı, grafik arayüz açılamadığında ne olacağı, en sık hatalar ve güvenlik kuralları. - Kapsam dışı: ISO/firstboot/offline provisioning yolu (26, 27 — artık **ikincil/opsiyonel**), merkez sunucularının kurulumu (07, 09 — arayüz yalnız **hazırlar ve denetler**, kurmaz), update onay zincirinin merkez tarafı (10, 21), rol bazlı eğitim müfredatı (31). ## 3. Kararlar @@ -29,7 +29,7 @@ ```text KARAR: Cihaz ve filo işlemlerinin tek operatör giriş noktası `sudo bf` grafik uygulamasıdır (`bfos/ui_gui.py`, tkinter). Operatör komut ezberlemez; basit modda üç düğmeden, - detaylı modda kategori sekmesinden seçer. + detaylı modda kenar çubuğundaki bölümden seçer. GEREKÇE: Operatör profili Linux/DevOps'a yeni başlayan kişidir; serbest komut yazımı 700 cihazda yazım hatasını filo arızasına çevirir (K-11 blast-radius). Arayüz her komutu çalıştırmadan önce gösterir ve onay ister; dalga kapılı playbook'lara bayrak sessizce @@ -139,9 +139,9 @@ Git tabanlı kurulum "hangi sürüm sahada?" sorusunu `git log` ile yanıtlanabi - Katalog tablosu ile playbook'lar arasında kapsam (scope) uyuşmazlığı olursa komut üretilmez ve arayüz **çalıştırmayı reddeder**; bu, playbook güncellemesiyle katalog kaydının birlikte güncellenmesini zorunlu kılar. - Grafik arayüz bir pencere sistemine ihtiyaç duyar: `DISPLAY`/`WAYLAND_DISPLAY` yoksa (başsız sunucu, X yönlendirmesiz SSH) açılmaz — o durumda terminal konsoluna düşülür. -- Cihaz işlemleri sekmesindeki filo işlemleri için makinede `ansible-playbook` gerekir; yoksa arayüz envanter notunda `UYARI: ansible-playbook bulunamadı; filo işlemleri çalışmaz` satırını gösterir ve komut üretilemez. +- Cihaz işlemleri bölümündeki filo işlemleri için makinede `ansible-playbook` gerekir; yoksa arayüz envanter notunda `UYARI: ansible-playbook bulunamadı; filo işlemleri çalışmaz` satırını gösterir ve komut üretilemez. - Çok özel/tek seferlik işler katalogda yoksa operatör kabuğa döner (kataloğa ekleme PR ile yapılır, `bfos/operations.py`). -- Grafik katalog, terminal konsolunun kapsamının tamamını taşır: 48 işlemin hepsi `Detaylı mod` sekmelerinden erişilebilir (ölçüldü; `Araçlar` sekmesinde 9 işlem, `Cihaz bilgisi` sekmesinde ortam/kimlik/ağ/kaynak/servis ekranları). `sudo bf --tui` kolaylık için vardır, eksik bir yetenek için değil. +- Grafik katalog, terminal konsolunun kapsamının tamamını taşır: 48 işlemin hepsi `Detaylı mod` bölümlerinden erişilebilir (ölçüldü; `Araçlar` bölümünde 9 işlem, `Cihaz bilgisi` bölümünde ortam/kimlik/ağ/kaynak/servis ekranları). `sudo bf --tui` kolaylık için vardır, eksik bir yetenek için değil. - Her onay ayrı bir denetim satırı üretir; komutun ham çıktısı kayda kopyalanmaz (K-14). ## 8. Riskler @@ -284,23 +284,26 @@ Yerel ekranda çalıştırmak için oturum açık bir makinede deneyin: python3 -m bfos.ui_gui ``` -### 9.2 Arayüzü kullanma — sekme sekme +### 9.2 Arayüzü kullanma — bölüm bölüm -`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. +`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 (parçalı kapsam seçici + bağlamsal girdi), gövde, yürütme çekmecesi (başlık + 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. +**Basit mod (varsayılan):** ekranda **Genel Bakış** vardır: dört durum kartı — `HEDEF` (büyük kimlik + tür satırı), `CİHAZ DURUMU` (rozet + sözcük), `YETKİ` (rozet + `sudo bf` ipucu), `ENVANTER` (dalga sayısı + ilk dalgalar) — ve altında `Hızlı işlemler` kutusunda üç 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. Yetkisiz oturumda `Kur` kapalıdır ve nedeni düğmenin üstünde yazar. Daha fazlası için üst çubuktaki **`Detaylı mod`** düğmesine bas. ```text - Ne yapmak istiyorsunuz? + Genel Bakış + [HEDEF: BF-12010193] [DURUM: HAZIR] [YETKİ: ...] [ENVANTER: 6 dalga] + + Hızlı işlemler [ Kur ] bu cihazı kurar (bayi no gerekir) [ Durum ] salt-okunur özet [ Çıkış ] pencereyi kapatır ``` -**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ış`. +**Detaylı mod:** solda bir **kenar çubuğu** açılır: beş katalog bölümü (adetleriyle — `Kurulum`, `Cihaz işlemleri`, `Merkez hazırlığı`, `Araçlar`, `Cihaz bilgisi`) ve `Etkinlik` sayfası. Bölümlerin ü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 bölümde solda **işlem tarayıcı** (üstte `Süz:` alanı; satırlar: ikon + ad + tek satır açıklama + tür ve durum rozetleri + ok), sağda **`İŞLEM AYRINTISI`** paneli vardır: başlık, açıklama, `HEDEF`/`KAPSAM`/`YETKİ`/`TÜR` ızgarası, koşullu uyarılar, seçenek formu, katlanır **komut önizlemesi** (`Önizlemeyi oluştur` düğmesi komutu gerçek backend ile üretir, çalıştırmaz) ve birincil `Komutu göster ve çalıştır` düğmesi. Rozetler: türde yıkıcı işlemlerde kırmızı `YIKICI`, diğerlerinde `salt-okunur`; durumda `açık` ya da `kapalı — sudo bf`. Yazma işlemleri listede kalır, solar, nedenini yazar — gizlenmez. `Etkinlik` sayfası bu oturumda biten çalışmaları (zaman, işlem, hedef, sonuç) listeler; sır göstermez. 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ış`. Çıktı panelinin üstünde **yürütme başlığı** vardır: boşken `BOŞTA` + `Etkin işlem yok.`, çalışırken `ÇALIŞIYOR` + `işlem · hedef` + geçen süre, bitince `TAMAM`/`HATA` + süre + çıkış kodu. -| Sekme | İşlem sayısı | Yazma (yıkıcı) | Salt-okunur | +| Bölüm | İşlem sayısı | Yazma (yıkıcı) | Salt-okunur | |---|---|---|---| | **Kurulum** | 6 | 3 (`Bu cihazı kur`, `Başarısız kuruluma devam et`, `Tek modül çalıştır`) | 3 (ön kontrol, modül durumu, kurulum günlüğü) | | **Cihaz işlemleri** | 21 | 9 (servis yeniden başlatmalar, grafik oturumu aç/kapat, betik, reboot, paket güncellemesi) | 12 (11 filo raporu + log toplama) | @@ -309,7 +312,7 @@ Yerel ekranda çalıştırmak için oturum açık bir makinede deneyin: | **Cihaz bilgisi** | 8 | 0 | 8 | | **Toplam** | **48** | **13** | **35** | -Sekme sekme hangi işler var: +Bölüm bölüm hangi işler var: **1) Kurulum (bu cihaz)** — yalnız **senin oturduğun makineyi** kurar; hiçbir filo cihazına dokunmaz. Üst çubukta **bayi no (8 hane)** dolu olmalıdır; boşsa `Komutu göster ve çalıştır` bir uyarı penceresi açar. @@ -368,7 +371,7 @@ Kurulum seçenekleri: `Yerel çevrimdışı depodan kur (--offline)` ve `Zaten O | 3 | Yapılandırılmış merkez uç noktaları | WireGuard hub, izleme ve depo anahtarları (**secret gösterilmez**) | | 4 | Merkez hazırlık kontrol listesi | Kayıttan önce merkezde var olması gerekenler | -> `--tui` menüsünde bu sekmenin karşılığında ayrıca "Merkez hazırlık durumu", "uç noktaları dene" (DNS+TCP) ve "dalga geçiş kapısı durumu" ekranları vardır. +> `--tui` menüsünde bu bölümün karşılığında ayrıca "Merkez hazırlık durumu", "uç noktaları dene" (DNS+TCP) ve "dalga geçiş kapısı durumu" ekranları vardır. **4) Araçlar** — repo ve yerel yardımcılar. Yalnız `Bu kopyayı güncelle (git pull --ff-only)` yazma işlemidir; kalan 8'i salt-okunurdur: `İşlem kataloğunu göster (--list)`, `Yerel çevrimdışı kapısı (bf-check-local)`, `Kayıt kapısı (bf-check-enrollment)`, `Teslim kapısı (bf-check-ready)`, `Derin teşhis (bf-diagnostics)`, `Field OS sürüm kimliği (bf-release)`, `Destek paketi oluştur`, `Konsol denetim günlüğü (son 40 satır)`. @@ -378,20 +381,21 @@ Kurulum seçenekleri: `Yerel çevrimdışı depodan kur (--offline)` ve `Zaten O ### 9.3 Hedef seçimi: tek cihaz mı dalga mı -Filo işlemlerinde hedef **üst çubuktan** seçilir; işlem seçildiğinde ekran hedefi ayrıca sormaz, çubuktaki değeri kullanır: +Filo işlemlerinde hedef **üst çubuktaki parçalı seçiciden** verilir; üç kapsam birbirini dışlar (`[Cihaz | Dalga | Tüm filo]`), görünür kapsam tektir ve işlem seçildiğinde ekran hedefi ayrıca sormaz, seçicideki değeri kullanır: ```text HEDEF [ Detaylı mod ] - Bayi no (8 hane) [ 12010193 ] Dalga [ pilot_1 ▾ ] [ ] Tüm filo (dikkat) + [ Cihaz ] [ Dalga ] [ Tüm filo ] + Bayi no (8 hane) [ 12010193 ] Bayi no geçerli: BF-12010193 ``` -| Hedef türü | Çubukta ne yaparsın | Örnek | Boyut | Tipik kullanım | +| Hedef türü | Seçicide ne yaparsın | Örnek | Boyut | Tipik kullanım | |---|---|---|---|---| -| Tek cihaz | Bayi no alanına 8 hane yaz | `12010193` | 1 | Arıza teşhisi, tek cihaz bakımı | -| Dalga / grup | `Dalga` açılır listesinden bir dalga seç | `lab` | 2 | Yeni playbook denemesi (önce LAB kuralı) | -| Dalga / grup | `Dalga` listesi | `pilot_1` / `pilot_2` / `wave_1` / `wave_2` | 5 / 20 / 50 / 100 | Kademeli yayılım (K-11) | -| Tüm filo | `Tüm filo (dikkat)` kutusunu işaretle | — | kalan | Yalnız onaylı; dalga dalga ilerler | +| Tek cihaz | `Cihaz` parçasını seç, bayi no alanına 8 hane yaz | `12010193` | 1 | Arıza teşhisi, tek cihaz bakımı | +| Dalga / grup | `Dalga` parçasını seç, listeden bir dalga seç | `lab` | 2 | Yeni playbook denemesi (önce LAB kuralı) | +| Dalga / grup | `Dalga` parçası | `pilot_1` / `pilot_2` / `wave_1` / `wave_2` | 5 / 20 / 50 / 100 | Kademeli yayılım (K-11) | +| Tüm filo | `Tüm filo` parçasını seç (şerit açılır) | — | kalan | Yalnız onaylı; dalga dalga ilerler | Bayi no alanına yazdıkça anında doğrulanır; geçerliyse durum satırı `Bayi no geçerli: BF-12010193` yazar, geçersizse kırmızı uyarı görünür (örn. `Geçersiz bayi numarası '1201019': tam 8 hane olmalıdır (verilen uzunluk: 7). Örnek: 12010193`). @@ -474,13 +478,13 @@ Pencere açılır: `Blueforce Field OS — Yönetim` / `Blueforce saha cihazı y [OK] Komut başarıyla tamamlandı (çıkış kodu 0). ``` -Kurulum sonunda cihaz `PROVISIONED_OFFLINE` olur; **`READY` değildir** — merkeze kayıt (enrollment) ve merkezi kanal kanıtı gerekir (K-21). Bunu görmek için `Cihaz bilgisi` sekmesinden `Kurulum/kayıt durumu (state.json beyaz liste)` ya da `Durum özeti` açılır. +Kurulum sonunda cihaz `PROVISIONED_OFFLINE` olur; **`READY` değildir** — merkeze kayıt (enrollment) ve merkezi kanal kanıtı gerekir (K-21). Bunu görmek için `Cihaz bilgisi` bölümünden `Kurulum/kayıt durumu (state.json beyaz liste)` ya da `Durum özeti` açılır. **Seans B — Günlük kontrol (salt-okunur rapor) ve teşhis paketi** ```text Üst çubuk: Hedef: [ 12010193 ] Dalga [ pilot_1 ▾ ] -Detaylı mod → Cihaz işlemleri sekmesi → listeden "Erişilebilirlik testi (ping)" satırını seç +Detaylı mod → Cihaz işlemleri bölümü → tarayıcıdan "Erişilebilirlik testi (ping)" satırını seç Alt çubuk: [ Komutu göster ve çalıştır ] ``` @@ -505,7 +509,7 @@ Onay penceresi: [OK] Komut başarıyla tamamlandı (çıkış kodu 0). ``` -Teşhis paketi için: `Araçlar` sekmesi → `Destek paketi oluştur`. Paket yolu çıktı alanında yazılır ve paket **parola, private key veya token içermez** (K-14). Denetim kaydını görmek için aynı sekmedeki `Konsol denetim günlüğü (son 40 satır)` açılır. +Teşhis paketi için: `Araçlar` bölümü → `Destek paketi oluştur`. Paket yolu çıktı alanında yazılır ve paket **parola, private key veya token içermez** (K-14). Denetim kaydını görmek için aynı bölümdeki `Konsol denetim günlüğü (son 40 satır)` açılır. ### 9.5 Kimlik bilgileri / şifre dosyası (opsiyonel — varsayılan KAPALI) @@ -528,10 +532,10 @@ 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 | 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) | +| 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 rozeti `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 | +| 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` bölümünden `Bu kopyayı güncelle (git pull --ff-only)` veya doğru repoyu ver: `sudo bf --repo /opt/blueforce-linux`; arayüzü yeniden başlat | Ek olarak sık görülen dört durum: diff --git a/docs/34-CONSOLE-VISUAL-CONCEPT.md b/docs/34-CONSOLE-VISUAL-CONCEPT.md new file mode 100644 index 0000000..b83b688 --- /dev/null +++ b/docs/34-CONSOLE-VISUAL-CONCEPT.md @@ -0,0 +1,182 @@ +# 34 — Konsol Görsel Konsepti (Uygulanan Tasarım) + +## 1. Amaç + +Operatör konsolunun (`bfos/ui_gui.py` + `bfos/ui_widgets.py`) görsel +dünyasını tek belgede sabitlemek: uygulama kabuğu, Genel Bakış, hedef +seçici, işlem tarayıcı, ayrıntı paneli, yürütme çekmecesi ve güvenlik +pencereleri. Kavram `docs/33-OPERATOR-CONSOLE.md` §9.2'nin uygulanan +hâlini anlatır; davranış sözleşmesini değiştirmez. + +## 2. Kapsam + +Yalnızca sunum katmanı: koyu başlık bandı, hedef paneli, kenar çubuğu, +sayfalar, rozetler, diyaloglar. Renk/boşluk/yazı değerleri +`bfos/ui_theme.py` üzerinden `web/static/tokens.css` aynasından gelir; +bu belgede renk kodu yoktur. Backend kuralları (`bfos/operations.py`, +`bfos/runner.py`), kapılar ve denetim kaydı kapsam dışıdır — aynen korunur. + +## 3. Kararlar + +1. **Uygulama kabuğu:** başlık bandı + hedef paneli her modda sabittir; + detaylı modda solda 220px kenar çubuğu (5 kategori + Etkinlik), sağda + sayfa alanı, altta yürütme çekmecesi ve eylem çubuğu. +2. **Genel Bakış ilk ekrandır:** 4 durum kartı (Hedef / Cihaz durumu / + Yetki / Envanter) + 3 hızlı işlem (Kur / Durum / Çıkış). Form yok. +3. **Hedef seçici parçalıdır:** `[Cihaz | Dalga | Tüm filo]`; görünür + kapsam tektir, girdiler bağlama duyarlıdır, tüm filo yüksek etkili + uyarı şeridi açar. +4. **İşlem tarayıcı satırlıdır:** ikon + ad + tek satır açıklama + + tür rozeti + durum rozeti + ok. Engelli işlem listede kalır, solar, + nedenini yazar. +5. **Ayrıntı paneli:** başlık, açıklama, HEDEF/KAPSAM/YETKİ/TÜR ızgarası, + koşullu uyarılar, seçenek formu, istek üzerine gerçek backend ile + üretilen komut önizlemesi, birincil eylem. +6. **Onay penceresi özeldir:** İŞLEM/HEDEF/KAPSAM/ETKİ bloğu + monospace + komut + `[Vazgeç]` / işlem adlı düğme. Yıkıcıda odak Vazgeç'tedir. +7. **Yürütme çekmecesi:** durum rozeti + `işlem · hedef · süre`; log + kuyusu salt-okunur kanıttır. Bitiş TAMAM/HATA + süre + çıkış kodudur. +8. **Etkinlik sayfası:** yalnız bu oturumun biten çalışmaları (zaman, + işlem, hedef, sonuç). Sır gösterilmez. + +## 4. Neden Bu Karar? + +Operatör 3 saniyede altı soruya yanıt almalıdır: hedef ne, durumu ne, +yetkim ne, ne yapabilirim, sorun var mı, bir şey çalışıyor mu? Eski +sekme+tablo düzeni bu soruları form kalabalığına gömüyordu. Kabuk her +soruya sabit bir yer verir; güvenlik bilgisi (kapsam, yetki, yıkıcılık) +her ekranda aynı görsel dilde yinelenir. + +## 5. Alternatifler + +- **Sekmeler yerinde kalsın:** reddedildi — 5 sekme + tablo, 48 işlemi + ayırt edilemez kılar ve Genel Bakış'a yer bırakmaz. +- **Web/Electron kabuğu:** reddedildi — çevrimdışı kurulum, `sudo bf` + ile başlatma ve `python3-tk` bağımlılığı korunmalıdır. +- **Koyu tema:** ertelendi — atölye ışığında açık zemin okunur; token + yapısı koyu temaya hazırdır ama öncelik açık temadır. + +## 6. Avantajlar + +- Kapsam her ekranda görünür (başlık + hedef paneli + onay penceresi). +- Yazma işlemleri gizlenmez, kapatılır ve nedeni yazılır (yetki öğretir). +- Tehlike stili yalnız tehlikeli eylemdedir; panel kırmızıya boyanmaz. +- Tüm simgeler Tk canvas çizimidir: çevrimdışı, lisanssız, varlıksız. +- Klavye: sekme sırası görsel sırayı izler, satırlar odaklanabilir, + `Enter` çalıştırır, `Esc` diyalogu kapatır (yıkıcıda asla onaylamaz). + +## 7. Dezavantajlar + +- Özel satır bileşeni `ttk.Treeview` yerleşik sıralamasından vazgeçer: + sütun sıralaması yoktur (arama + kategori yeterlidir). +- Ayrıntıdaki hedef satırı, seçenekli işlemlerde hedef değişince otomatik + tazelenmez (girdi korunur); güncel hedef çalışma anı penceresinde + gösterilir. + +## 8. Riskler + +- **Kapsam yanılgısı:** parçalı denetim + başlık aynası + onaydaki kapsam + şeridi üç katmanlı savunmadır; `current_target()` görünür kapsama göre + hedef üretir (bayi metni dalgayı ezemez). +- **Eski alışkanlık:** sekmeleri arayan operatör için §9.2 haritası ve + Genel Bakış'taki yönlendirme metni geçişi anlatır. + +## 9. Uygulama Planı + +### 9.1 Kabuk iskeleti + +```text ++---------------------------------------------------------------+ +| koyu bant: ürün | hedef | cihaz durumu | yetki | ++---------------------------------------------------------------+ +| şerit (salt-okunur / deneme kipi; etkinse) | ++---------------------------------------------------------------+ +| HEDEF: [Cihaz|Dalga|Tüm filo] + bağlamsal girdi | ++----------------+----------------------------------------------+ +| kenar çubuğu | sayfa (tarayıcı + ayrıntı / etkinlik) | +| +----------------------------------------------+ +| | yürütme çekmecesi (başlık + log kuyusu) | ++---------------------------------------------------------------+ +| eylem çubuğu: [birincil] neden ... durum / araçlar | ++---------------------------------------------------------------+ +``` + +### 9.2 Yedi ekran + +1. **Genel Bakış** (basit mod): 4 kart + Kur/Durum/Çıkış. Yetkisizde Kur + kapalı, nedeni düğmede. +2. **Hedef seçimi:** Cihaz → 8 hane doğrulamalı giriş; Dalga → salt-okunur + liste (`production` seçilince jeton notu); Tüm filo → tehlike şeridi. +3. **İşlem tarayıcı:** arama + satırlar (ikon, ad, açıklama, `YIKICI` / + `salt-okunur`, `açık` / `kapalı — sudo bf`, ok). Seçili satır aksan + dolgulu. +4. **Ayrıntı:** meta ızgara + uyarılar + seçenekler + katlanır komut + önizlemesi + birincil düğme (yıkıcıda kırmızı, engellide kapalı). +5. **Çalışma:** `ÇALIŞIYOR` rozeti + ad + hedef + `mm:ss` + iptal; bitiş + `TAMAM`/`HATA` + süre + çıkış kodu; kayıt Etkinlik'e düşer. +6. **Yıkıcı onay:** İŞLEM/HEDEF/KAPSAM/ETKİ + filo/üretim şeridi + + açık önizleme + `[Vazgeç]` (odak) / işlem adlı kırmızı düğme. +7. **Salt-okunur:** başlık rozeti + turuncu şerit; tüm yazma düğmeleri + kapalı + gerekçeli; `_start_run` kapıyı yeniden denetler. + +### 9.3 Token ve yazı + +Zemin `--bg-app`, paneller beyaz, tek koyu bant; vurgu mavisi yalnız +birincil eylem + seçili satır. Başlık 22, bölüm 18, gövde 15, ikincil 13, +üstveri 12, veri monospace 13. Ritim 4/8/12/16/24/32/48. Kenar çubuğu +220px, satır 44px, denetim 36px. Durum söz + renk + rozet (renk yalnız +asla). + +## 10. Test Planı + +- `tests/test_bfos.py` (155) ve `tests/test_bfos_gui_static.py` (95): + pencere açmaz; katalog, kapılar, önizleme, renk aynası sözleşmeleri. +- Canlı QA (`DISPLAY=:0`): `/tmp/bf-qa.py` geometri taraması (0 hatalı + öğe), 6 sayfa × tarayıcı/ayrıntı, hedef akışları, `import -window` + yakalamaları + Tesseract OCR metin denetimi. +- Mutasyon kanıtı: `ui_widgets.py` içine renk sayesi → renk testi + kırmızıya döner; geri alınır. +- `bash tests/run-all.sh` 12/12; `python3 -m bfos --tui` duman testi. + +## 11. Rollback + +Sunum katmanıdır: `bfos/ui_gui.py`, `bfos/ui_widgets.py`, +`docs/33`, `docs/34` ve bir test iddiası değişir. Geri alma = bu dosyaları +önceki sürüme döndürmek; backend, envanter ve denetim kaydı etkilenmez. +Commitlenmeden çalışılır; değişiklikler çalışma ağacında durur. + +## 12. Kontrol Listesi + +- [x] Kabuk + Genel Bakış + hedef seçici + tarayıcı + ayrıntı + çekmece +- [x] Onay/jeton pencereleri (odak ve varsayılan güvenli) +- [x] Salt-okunur sunumu (gizleme yok, gerekçe her yerde) +- [x] Etkinlik sayfası (oturum içi, sırsız) +- [x] Token/sözdizimi aynası (`tokens.css` ↔ `ui_theme.py`) korunur +- [x] Türkçe metin taraması (teknik adlar dışında İngilizce yok) +- [x] Ekran görüntüleri (`/tmp/bf-shots/r2-*`, `r3-*`, `r4-*`) +- [ ] Vision servisiyle görsel inceleme (servis 500 veriyor; OCR ile + doğrulandı, insan gözüyle bakılmadı) + +## 13. Açık Sorular + +- Koyu tema gerekli mi? (Token yapısı hazır; talep yok.) +- Etkinlik sayfası denetim günlüğünden güvenli özet okumalı mı? + (Bugün yalnız oturum içi; güvenlik garantisi değişmeden olmaz.) + +## Ek: Kabuk durum akışı + +```mermaid +flowchart TD + A[Genel Bakış] -->|Detaylı mod| B[Kenar çubuğu: 5 kategori + Etkinlik] + B --> C[İşlem tarayıcı] + C --> D[İşlem ayrıntısı] + D --> E[Komut önizlemesi] + E --> F{Onay penceresi} + F -->|Vazgeç| D + F -->|Onayla| G[Kapı jetonları] + G -->|iptal| D + G -->|tamam| H[Kesin komut onayı] + H -->|Vazgeç| D + H -->|Onayla| I[Yürütme çekmecesi: ÇALIŞIYOR] + I --> J[TAMAM / HATA + Etkinlik kaydı] +``` diff --git a/tests/test_bfos_gui_static.py b/tests/test_bfos_gui_static.py index 189bc8f..c0ef63a 100644 --- a/tests/test_bfos_gui_static.py +++ b/tests/test_bfos_gui_static.py @@ -1050,6 +1050,10 @@ class TestConsoleLayoutContract(StaticCase): 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), []) + widgets_path = REPO_ROOT / "bfos" / "ui_widgets.py" + widgets_source = widgets_path.read_text(encoding="utf-8") + self.assertEqual( + re.findall(r"#[0-9a-fA-F]{3,8}\b", widgets_source), []) def test_there_is_exactly_one_exit_control(self) -> None: """'Cik' tek kontrol: ya basit modda ya alt cubukta, ayni anda degil."""