Source code for exlab_wizard.ui.pages.wizard_equipment

"""Add-Equipment wizard (GUI/Orchestrator Redesign §6).

Three-step wizard launched from the main-window toolbar:

1. Identity — equipment ID (validated against ``^[A-Z][A-Z0-9_]*$``) +
   label.
2. Paths — NAS root (the equipment's data dir is derived from the single
   app root, so it is not collected here).
3. Review & confirm — assembles a validated EquipmentConfig via the
   shared ``build_equipment_config()`` and posts it through
   ``POST /config/equipment``.

The sync-mode step is intentionally hidden: orchestrator / staging is
hidden at the UI layer (see
``docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md``),
so every equipment is created in ``nas`` mode (sync directly to NAS via
the single ``nas:`` remote). The dormant ``_render_sync_mode_step`` is
kept so re-listing it restores the step verbatim.

The render function is pure (state + callbacks); the actual NiceGUI
mount layer wires the on-confirm callback to the config router.
"""

from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass
from typing import Any

from exlab_wizard.config.models import EquipmentConfig
from exlab_wizard.constants.patterns import EQUIPMENT_ID_PATTERN
from exlab_wizard.logging import get_logger
from exlab_wizard.ui.equipment_form import build_equipment_config

_log = get_logger(__name__)


# The "sync_mode" step is intentionally omitted: orchestrator / staging is
# hidden at the UI layer (see
# docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md).
# Every equipment is created in ``nas`` mode. Re-listing "sync_mode" here and
# in ``EQUIPMENT_STEP_TITLES`` / ``_STEP_RENDERERS`` below restores the step
# verbatim (the renderer is kept, dormant).
EQUIPMENT_WIZARD_STEPS: tuple[str, ...] = (
    "identity",
    "paths",
    "review",
)

EQUIPMENT_STEP_TITLES: dict[str, str] = {
    "identity": "Identity",
    "paths": "Paths",
    "review": "Review & confirm",
}


[docs] @dataclass class EquipmentWizardState: """Mutable state for the in-flight Add-Equipment wizard.""" active_step: str = EQUIPMENT_WIZARD_STEPS[0] # Step 1 equipment_id: str = "" label: str = "" # Step 2 -- the equipment's data dir is derived from the single app root # (``<config.paths.local_root>/<id>``), so only the NAS root is collected. nas_root: str = "" # sync_mode is retained but no longer operator-selectable: the sync-mode # wizard step is hidden (orchestrator/staging hidden — see module note), # so every equipment is created in "nas" mode. The field stays so the # dormant ``_render_sync_mode_step`` and ``SyncMode.STAGE`` backend remain # one edit away from re-enabling. sync_mode: str = "nas" # Review step last_error: str | None = None confirmed: bool = False
[docs] def can_advance(state: EquipmentWizardState) -> bool: """Return True if the active step has the data it needs to advance. Pure function — surfaces the per-step gate so unit tests can assert the wizard's progression without rendering NiceGUI. """ match state.active_step: case "identity": return bool( state.equipment_id and EQUIPMENT_ID_PATTERN.fullmatch(state.equipment_id) and state.label.strip() ) case "paths": return bool(state.nas_root.strip()) case "sync_mode": # rclone.conf migration (Phase 8): neither mode collects a # per-equipment transport here -- the ``nas:`` remote defines the # NAS connection and ``orchestrator.staging_remote`` defines the # staging hop -- so picking the mode is enough to advance. return True case "review": return True return False
[docs] def assemble_equipment_config( state: EquipmentWizardState, ) -> EquipmentConfig: """Build the final EquipmentConfig from the wizard's state. Raises a pydantic ValidationError if the state isn't valid; the caller surfaces that to the operator. """ return build_equipment_config( equipment_id=state.equipment_id, label=state.label, nas_root=state.nas_root, sync_mode=state.sync_mode, )
[docs] def render_wizard_equipment( *, state: EquipmentWizardState | None = None, on_confirm: Callable[[EquipmentConfig], None] | None = None, on_cancel: Callable[[], None] | None = None, ) -> Any: # pragma: no cover -- NiceGUI render, driven by e2e """Render the self-contained Add-Equipment wizard. Next / Back navigation is handled *inside* the render: the step body and footer live in one ``@ui.refreshable`` so advancing mutates ``state.active_step`` and re-renders in place. The wizard therefore keeps a single ``EquipmentWizardState`` for its whole lifetime -- the caller creates it once and never round-trips through a page navigation that would reset it. Only ``on_confirm`` (post the assembled ``EquipmentConfig``) and ``on_cancel`` (leave the wizard) cross back to the host. Rendered as a full-page card since the wizard owns the ``/wizard/equipment`` route. """ s = state or EquipmentWizardState() try: from nicegui import ui except Exception: return {"state": s} # Handle to the live Next button. ``_body`` rewrites this on every # re-render; ``_sync_next`` toggles the button's enabled state as the # operator edits a step -- without a re-render, so input focus is # kept while typing. next_btn: dict[str, Any] = {} with ui.card().classes("w-full h-full p-6").props('data-testid="wizard-equipment"') as dialog: ui.label("Add Equipment").style( "font-family: var(--font-display); font-size: var(--text-lg); " "color: var(--color-heading); font-weight: 600;" ) def _sync_next() -> None: """Re-evaluate ``can_advance`` and enable/disable Next in place.""" btn = next_btn.get("btn") if btn is not None: btn.set_enabled(can_advance(s)) def _step_forward() -> None: """Advance one step, but only when the current step is valid.""" if not can_advance(s): return idx = EQUIPMENT_WIZARD_STEPS.index(s.active_step) if idx + 1 < len(EQUIPMENT_WIZARD_STEPS): s.active_step = EQUIPMENT_WIZARD_STEPS[idx + 1] _body.refresh() def _step_back() -> None: """Return to the previous step, keeping every entered value.""" idx = EQUIPMENT_WIZARD_STEPS.index(s.active_step) if idx > 0: s.active_step = EQUIPMENT_WIZARD_STEPS[idx - 1] _body.refresh() @ui.refreshable def _body() -> None: next_btn.pop("btn", None) ui.label(EQUIPMENT_STEP_TITLES[s.active_step]).style( "color: var(--color-muted); margin-bottom: var(--sp-3);" ).props(f'data-testid="wizard-equipment-step-{s.active_step}"') # Step renderers wire radios to ``_body.refresh`` (a changed # radio swaps which sub-form is shown) and text inputs to # ``_sync_next`` (re-checks the Next gate without a re-render). _STEP_RENDERERS[s.active_step](s, _body.refresh, _sync_next) with ( ui.row() .classes("items-center w-full") .style("margin-top: var(--sp-4); gap: var(--sp-2);") ): if on_cancel is not None: cancel_cb = on_cancel ui.button("Cancel").props('flat data-testid="wizard-equipment-cancel"').on( "click", lambda _evt: cancel_cb() ) if s.active_step != EQUIPMENT_WIZARD_STEPS[0]: ui.button("Back").props('flat data-testid="wizard-equipment-back"').on( "click", lambda _evt: _step_back() ) ui.space() if s.active_step == "review": ui.button("Confirm").props( 'color=primary data-testid="wizard-equipment-confirm"' ).on("click", lambda _evt: _maybe_confirm(s, on_confirm)) else: btn = ui.button("Next").props( 'color=primary data-testid="wizard-equipment-next"' ) btn.on("click", lambda _evt: _step_forward()) btn.set_enabled(can_advance(s)) next_btn["btn"] = btn _body() return dialog
def _maybe_confirm( state: EquipmentWizardState, on_confirm: Callable[[EquipmentConfig], None] | None, ) -> None: # pragma: no cover -- NiceGUI render, driven by e2e if on_confirm is None: return try: eq = assemble_equipment_config(state) except Exception as exc: state.last_error = str(exc) return on_confirm(eq) state.confirmed = True def _render_identity_step( state: EquipmentWizardState, refresh_body: Callable[[], object], sync_next: Callable[[], object], ) -> None: # pragma: no cover -- NiceGUI render, driven by e2e del refresh_body # identity has no structural (radio) controls try: from nicegui import ui except Exception: return ui.input(label="Equipment ID (^[A-Z][A-Z0-9_]*$)", on_change=lambda _e: sync_next()).props( 'data-testid="wizard-equipment-id"' ).bind_value(state, "equipment_id") ui.input(label="Label", on_change=lambda _e: sync_next()).props( 'data-testid="wizard-equipment-label"' ).bind_value(state, "label") def _render_paths_step( state: EquipmentWizardState, refresh_body: Callable[[], object], sync_next: Callable[[], object], ) -> None: # pragma: no cover -- NiceGUI render, driven by e2e del refresh_body # paths has no structural (radio) controls try: from nicegui import ui except Exception: return # The equipment's data dir is derived from the single app root # (Settings -> Data folder); only the NAS root is collected here. ui.input(label="NAS root", on_change=lambda _e: sync_next()).props( 'data-testid="wizard-equipment-nas-root"' ).bind_value(state, "nas_root") def _render_sync_mode_step( state: EquipmentWizardState, refresh_body: Callable[[], object], sync_next: Callable[[], object], ) -> None: # pragma: no cover -- NiceGUI render, driven by e2e try: from nicegui import ui except Exception: return del sync_next # the sync-mode step has only the radio (no text inputs) with ui.row().classes("items-center"): ui.radio( ["nas", "stage"], value=state.sync_mode, on_change=lambda _e: refresh_body() ).props('data-testid="wizard-equipment-sync-mode"').bind_value(state, "sync_mode") if state.sync_mode == "nas": # rclone.conf migration: nas-mode no longer collects a per-equipment # SFTP/SMB transport here. The connection is defined once by the # single ``nas:`` remote in the operator's rclone.conf (confirmed # via Settings -> NAS Remote -> Test connection). ui.label( "This device syncs directly to the NAS using the rclone remote " "configured in Settings -> NAS Remote. No per-equipment connection " "is needed here." ).style("color: var(--color-muted); font-size: var(--text-sm);").props( 'data-testid="wizard-equipment-nas-note"' ) else: # stage # rclone.conf migration (Phase 8): stage-mode no longer collects a # per-equipment mount/subpath here. The staging hop is defined once # by ``orchestrator.staging_remote`` / ``staging_base_root`` (a # second rclone remote configured in Settings). ui.label( "This device pushes runs to the staging-PC rclone remote " "configured under orchestrator.staging_remote. No per-equipment " "connection is needed here." ).style("color: var(--color-muted); font-size: var(--text-sm);").props( 'data-testid="wizard-equipment-stage-note"' ) def _render_review_step( state: EquipmentWizardState, refresh_body: Callable[[], object], sync_next: Callable[[], object], ) -> None: # pragma: no cover -- NiceGUI render, driven by e2e del refresh_body, sync_next # review is a static summary, no inputs try: from nicegui import ui except Exception: return ui.label("Review your equipment configuration:").style("color: var(--color-muted);") with ui.column().style("font-family: var(--font-mono);"): ui.label(f"ID: {state.equipment_id}") ui.label(f"Label: {state.label}") ui.label(f"NAS root: {state.nas_root}") if state.sync_mode == "nas": ui.label( "This device syncs directly to the NAS via the rclone remote " "configured in Settings → NAS Remote." ).style( "color: var(--color-muted); margin-top: var(--sp-2); " "font-size: var(--text-xs); font-style: italic;" ).props('data-testid="wizard-equipment-credential-hint"') if state.last_error: ui.label(f"Error: {state.last_error}").style("color: var(--color-danger);").props( 'data-testid="wizard-equipment-error"' ) _StepRenderer = Callable[[EquipmentWizardState, Callable[[], object], Callable[[], object]], None] _STEP_RENDERERS: dict[str, _StepRenderer] = { "identity": _render_identity_step, "paths": _render_paths_step, # "sync_mode": _render_sync_mode_step, # hidden — see EQUIPMENT_WIZARD_STEPS note "review": _render_review_step, }