- 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.
491 lines
19 KiB
Python
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"]
|