Source code for exlab_wizard.ui.components.bandwidth_schedule_editor

"""Bandwidth schedule editor (Frontend Spec §7.7.3).

Lives inside the Equipment Add/Edit sub-dialog. Two modes:

* **Unlimited** -- no cap, no schedule.
* **Limit upload bandwidth** -- a default cap (Mbps) plus zero-or-more
  schedule windows (Days, From, To, Upload Mbps).

Validation:

* Each row requires ``From < To``.
* Rows whose Days overlap each other render a non-blocking warning.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any

from exlab_wizard.logging import get_logger

_log = get_logger(__name__)

MODE_UNLIMITED = "unlimited"
MODE_LIMIT = "limit"

DAYS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")


[docs] @dataclass class ScheduleWindow: """One row in the schedule table.""" days: list[str] = field(default_factory=list) from_time: str = "08:00" to_time: str = "18:00" upload_mbps: int | None = None # None = unlimited within window
[docs] @dataclass class BandwidthSchedule: """The full editor state.""" mode: str = MODE_UNLIMITED default_upload_mbps: int | None = None windows: list[ScheduleWindow] = field(default_factory=list)
[docs] def validate_window(window: ScheduleWindow) -> str | None: """Return an error string if ``window`` is invalid, else ``None``. Time strings are compared lexicographically because we use 24-hour HH:MM strings (sortable by character order). """ if window.from_time >= window.to_time: return f"From ({window.from_time}) must be earlier than To ({window.to_time})" if window.upload_mbps is not None and window.upload_mbps < 0: return "Upload (Mbps) must be non-negative" return None
[docs] def find_overlaps(windows: list[ScheduleWindow]) -> list[tuple[int, int]]: """Return pairs of indices whose Days and time ranges overlap. Pairs are returned in canonical order ``(i, j)`` with ``i < j``. """ overlaps: list[tuple[int, int]] = [] for i, a in enumerate(windows): for j in range(i + 1, len(windows)): b = windows[j] if not set(a.days) & set(b.days): continue if a.to_time <= b.from_time or b.to_time <= a.from_time: continue overlaps.append((i, j)) return overlaps
[docs] def schedule_props(schedule: BandwidthSchedule) -> dict[str, Any]: """Compute renderable props for a :class:`BandwidthSchedule`.""" return { "mode": schedule.mode, "default_upload_mbps": schedule.default_upload_mbps, "windows": [ { "days": list(w.days), "from_time": w.from_time, "to_time": w.to_time, "upload_mbps": w.upload_mbps, "error": validate_window(w), } for w in schedule.windows ], "overlaps": find_overlaps(schedule.windows), }
[docs] def bandwidth_schedule_editor(schedule: BandwidthSchedule) -> Any: """Build the schedule editor.""" props = schedule_props(schedule) try: from nicegui import ui except Exception: return props column = ui.column().classes("w-full").style("gap: 0.5rem;") with column: ui.label("Bandwidth schedule").style( "font-family: var(--font-mono); " "font-size: var(--text-xs); " "letter-spacing: 0.08em; " "text-transform: uppercase; " "color: var(--color-muted);" ) with ui.row().classes("items-center"): ui.radio( ["Unlimited", "Limit upload bandwidth"], value=( "Unlimited" if schedule.mode == MODE_UNLIMITED else "Limit upload bandwidth" ), ) if schedule.mode == MODE_LIMIT: ui.number( label="Default upload (Mbps)", value=schedule.default_upload_mbps, ) for _idx, window in enumerate(schedule.windows): with ui.row().classes("items-center w-full"): ui.label(",".join(window.days) or "(no days)") ui.label(window.from_time) ui.label(window.to_time) ui.label( "unlimited" if window.upload_mbps is None else f"{window.upload_mbps} Mbps", ) err = validate_window(window) if err: ui.label(err).style("color: var(--color-danger);") for i, j in props["overlaps"]: ui.label( f"Window {i + 1} overlaps window {j + 1}", ).style("color: var(--color-warning);") return column