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

491 lines
19 KiB
Python

"""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:
# Selected segment stays enabled (keyboard-focusable, in Tab order);
# selection is shown by the accent style alone, never by disabling.
for key, button in self._buttons.items():
if key == self._value:
button.configure(style=self.styles["accent_button"],
state="normal")
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("<Configure>", on_inner_configure)
canvas.bind("<Configure>", 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("<Escape>", 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("<Return>", 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("<Return>", lambda _e: confirm())
dialog.wait_window()
return result["value"]