"""Settings dialog (Frontend Spec §7).
Two-pane modal with a left vertical-nav and a right content area;
setup-incomplete mode auto-selects the first incomplete one.
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass, field
from typing import Any
from pydantic import ValidationError
from exlab_wizard.config.models import Config
from exlab_wizard.logging import get_logger
from exlab_wizard.ui import notifications
from exlab_wizard.ui.components import credential_field, test_connection_panel
_log = get_logger(__name__)
SETTINGS_SECTIONS: tuple[str, ...] = (
"paths",
"lims",
"equipment",
"nas_cleanup",
# "operators" backs OperatorsConfig.allowlist (Frontend §7.9). It is a
# chip editor and is non-gating: the allowlist defaults to [] (any
# operator allowed) and it is never added to ``_missing_setup_sections``.
"operators",
"validator",
"logging",
"orchestrator",
"application",
)
# rclone.conf NAS-sync migration. The NAS-remote section is *not* part
# of the canonical onboarding-order constant (``SETTINGS_SECTIONS`` stays
# at the original eight); it is inserted dynamically after ``equipment``
# by :func:`settings_sections_for`. It lets the operator pick the single
# ``nas:`` remote (a dropdown of remotes detected via ``rclone listremotes``),
# set its base root and optional ``--config`` path, and Test-connection the
# typed values -- no NAS password is ever typed here.
NAS_REMOTE_SECTION = "nas_remote"
SECTION_TITLES: dict[str, str] = {
"paths": "Paths",
"lims": "LIMS",
"equipment": "Equipment List",
NAS_REMOTE_SECTION: "NAS Remote",
"nas_cleanup": "NAS Cleanup",
"operators": "Operators",
"validator": "Validator",
"logging": "Logging",
"orchestrator": "Workstation",
"application": "Application",
}
[docs]
def settings_sections_for(config: Config | None) -> tuple[str, ...]:
"""Return the visible section ids for ``config``.
The NAS-remote section is always inserted right after ``equipment`` so
the operator can configure and test the single ``nas:`` remote even
before (or independent of) adding nas-mode equipment. ``config`` is
accepted for signature symmetry with the other section helpers but no
longer gates visibility.
"""
out: list[str] = []
for section in SETTINGS_SECTIONS:
out.append(section)
if section == "equipment":
out.append(NAS_REMOTE_SECTION)
return tuple(out)
[docs]
@dataclass
class SettingsState:
"""Mutable state for the dialog."""
active_section: str = "paths"
incomplete_sections: tuple[str, ...] = ()
dirty_sections: set[str] = field(default_factory=set)
pending_change_count: int = 0
[docs]
def first_incomplete_section(incomplete: tuple[str, ...]) -> str | None:
"""Return the first section ID in canonical order that's incomplete.
The dynamic NAS-remote section is not part of the static
:data:`SETTINGS_SECTIONS` tuple, so it is folded into the canonical
order here (right after ``equipment``) -- otherwise an
``INCOMPLETE_NO_NAS_REMOTE`` install would auto-select nothing
and land the operator on the default section (rclone.conf migration).
"""
order: list[str] = []
for section in SETTINGS_SECTIONS:
order.append(section)
if section == "equipment":
order.append(NAS_REMOTE_SECTION)
for section in order:
if section in incomplete:
return section
return None
[docs]
def section_has_warning(state: SettingsState, section: str) -> bool:
"""Return ``True`` when the sidebar should decorate ``section``."""
return section in state.incomplete_sections
[docs]
def section_is_dirty(state: SettingsState, section: str) -> bool:
"""Return ``True`` when ``section`` has uncommitted edits."""
return section in state.dirty_sections
[docs]
def build_settings_draft(config: Config | None) -> Config:
"""Return the editable deep-copy draft the settings dialog mutates.
``None`` (a fresh install with no ``config.yaml``) yields a
``Config()`` carrying the §9 defaults so every field still has a
sensible starting value.
"""
return (config or Config()).model_copy(deep=True)
[docs]
def finalize_settings_draft(draft: Config) -> Config:
"""Re-validate a mutated draft into a clean :class:`Config`.
The dialog's two-way bindings mutate the draft without running
Pydantic validation (assignment validation is off on the model), so
the Save handler round-trips ``model_dump`` -> ``model_validate`` to
coerce widget types (e.g. ``ui.number`` floats back to ints) and
enforce the §9 cross-field invariants. Raises ``ValidationError``
when the edited values do not form a valid config.
``warnings=False`` on the dump silences Pydantic's "expected int,
got float" notice -- the float is an artefact of ``ui.number`` and
``model_validate`` coerces it back to ``int`` on the next line.
"""
return Config.model_validate(draft.model_dump(mode="python", warnings=False))
[docs]
def lims_credential_initial_state(*, present: bool) -> credential_field.CredentialState:
"""Return the credential-row state seeding the LIMS password field.
A password already in the OS keyring opens the row in the *Set*
resting state (``[Replace]`` / ``[Clear]``); an empty keyring opens
it in *Not set* (``[Set]``). ``resting`` matches ``state`` so a
cancelled *Replace* collapses back to where it started.
"""
name = credential_field.STATE_SET if present else credential_field.STATE_NOT_SET
return credential_field.CredentialState(state=name, resting=name)
[docs]
def render_settings_page(
*,
config: Config | None = None,
state: SettingsState | None = None,
on_save: Callable[[Config], None] | None = None,
on_discard: Callable[[SettingsState], None] | None = None,
on_select_section: Callable[[str], None] | None = None,
on_save_lims_password: Callable[[str], None] | None = None,
on_clear_lims_password: Callable[[], None] | None = None,
lims_password_present: bool = False,
nas_remote_available: Callable[[str], bool] | None = None,
on_test_connection: Callable[[str, str], Any] | None = None,
nas_remotes: Sequence[str] = (),
list_remotes: Callable[[str], Awaitable[Sequence[str]]] | None = None,
autostart_registered: bool = False,
on_set_autostart: Callable[[bool], bool | None] | None = None,
on_quit: Callable[[], None] | None = None,
tray_available: bool = False,
) -> Any:
"""Render the settings dialog.
``config`` is the live ``config.yaml`` model (or ``None`` on a fresh
install). The dialog edits an in-memory deep copy -- the *draft* --
so cancelling discards the edits; ``on_save`` receives the validated
:class:`Config` built from the draft when the operator clicks Save.
``on_select_section`` is invoked when the operator clicks a sidebar
nav row. The Phase 12 cut bound this to a no-op (the selection
cycle is handled by the host page); the e2e harness wires it to a
navigation hook so each section's body becomes assertable.
``on_save_lims_password`` / ``on_clear_lims_password`` back the LIMS
section's credential field. Per Frontend Spec §7.3 credentials are
independent of Save -- these write straight to the OS keyring at
click time, so the host wires them to a :class:`KeyringStore` rather
than to the draft. ``lims_password_present`` seeds the credential
row's resting state from whether the keyring already holds one.
The NAS-remote section (rclone.conf migration) is editable: the
operator picks the remote, base root, and optional ``--config`` path.
``nas_remotes`` seeds the remote dropdown (the boot-time
``rclone listremotes`` snapshot) and ``list_remotes(config_path)``
re-lists on demand from the typed config path.
``nas_remote_available(name)`` answers whether ``nas.remote`` is present
in the operator's ``rclone.conf`` (found / not-found badge), and
``on_test_connection(remote, config_path)`` runs the rclone probe
against the *typed* values, returning a :class:`TestConnectionResult`
(or an awaitable of one) for the inline panel. All are optional so unit
tests can render the section without a wired rclone driver.
"""
s = state or SettingsState()
if s.incomplete_sections and s.active_section not in s.incomplete_sections:
# Setup-incomplete mode: auto-select the first incomplete section
# unless the caller has already pinned a specific section to render
# (for example, after the operator clicks a sidebar nav row).
first = first_incomplete_section(s.incomplete_sections)
if first is not None:
s = SettingsState(
active_section=first,
incomplete_sections=s.incomplete_sections,
dirty_sections=s.dirty_sections,
pending_change_count=s.pending_change_count,
)
# The dialog mutates this draft in place via two-way bindings; the
# caller's ``config`` is never touched until ``on_save`` fires with
# the re-validated result.
draft = build_settings_draft(config)
# Section visibility is draft-derived: the NAS-credentials section
# appears only when password-requiring nas-mode equipment exists.
sections = settings_sections_for(draft)
payload = {
"active": s.active_section,
"save_label": save_button_label(s),
"sections": list(sections),
"warnings": [section for section in sections if section_has_warning(s, section)],
"config": draft.model_dump(mode="python"),
}
try:
from nicegui import ui
except Exception:
return payload
card = (
ui.card()
.props('data-testid="settings-dialog"')
.style(
"min-width: 880px; min-height: 600px; "
"padding: var(--sp-4); "
"background: var(--color-surface); "
"border-radius: var(--radius-md); "
"box-shadow: var(--shadow-md);"
)
)
with card:
if s.incomplete_sections:
ui.label(
"Setup incomplete. Configure the highlighted sections to start using ExLab-Wizard.",
).props('data-testid="settings-incomplete-banner"').style(
"padding: 0.75rem 1rem; "
"border-left: 4px solid var(--color-warning); "
"background: rgba(230,159,0,0.07); "
"border-radius: var(--radius);"
)
# Every section body is rendered up front, bound to the single
# shared ``draft``, and shown/hidden client-side. A nav click
# only toggles visibility -- it never reloads the page -- so
# edits made in one section survive switching to another.
section_bodies: dict[str, Any] = {}
def _select_section(section: str) -> None:
for name, body in section_bodies.items():
body.visible = name == section
if on_select_section is not None:
on_select_section(section)
with ui.splitter(value=22).classes("w-full") as split:
with split.before, ui.column().classes("w-full").style("gap: 0.25rem;"):
for section in sections:
nav_row = (
ui.row()
.classes("items-center w-full")
.props(f'data-testid="settings-nav-{section}"')
.style(
"padding: 0.5rem 0.75rem; cursor: pointer;",
)
)
nav_row.on(
"click",
lambda _evt, sec=section: _select_section(sec),
)
with nav_row:
ui.label(SECTION_TITLES[section]).style(
"font-family: var(--font-body); "
"font-size: var(--text-sm);"
+ (
" font-weight: 600; color: var(--color-heading);"
if section == s.active_section
else " color: var(--color-body);"
)
)
if section_is_dirty(s, section):
ui.label("•").style("color: var(--color-info);")
if section_has_warning(s, section):
ui.icon("warning").style("color: var(--color-warning);")
with split.after:
for section in sections:
body = ui.column().classes("w-full")
body.visible = section == s.active_section
with body:
_render_section_body(
section,
draft,
on_save_lims_password=on_save_lims_password,
on_clear_lims_password=on_clear_lims_password,
lims_password_present=lims_password_present,
nas_remote_available=nas_remote_available,
on_test_connection=on_test_connection,
nas_remotes=nas_remotes,
list_remotes=list_remotes,
autostart_registered=autostart_registered,
on_set_autostart=on_set_autostart,
on_quit=on_quit,
tray_available=tray_available,
)
section_bodies[section] = body
def _do_save(_evt: Any = None) -> None:
if on_save is None:
return
try:
validated = finalize_settings_draft(draft)
except ValidationError as exc:
first_error = exc.errors()[0]
loc = ".".join(str(p) for p in first_error.get("loc", ()))
notifications.notify_error(
f"Config invalid ({loc}): {first_error.get('msg', 'validation failed')}"
)
return
on_save(validated)
with (
ui.row()
.classes("items-center w-full justify-end")
.style(
"gap: var(--sp-3); padding-top: var(--sp-4);",
)
):
ui.button(
"Discard all",
on_click=lambda _evt: on_discard(s) if on_discard else None,
).props('flat data-testid="settings-discard"')
ui.button(
save_button_label(s),
on_click=_do_save,
).props('color=primary data-testid="settings-save"')
return card
def _render_chip_editor(
values: list[str],
*,
add_label: str,
testid: str,
validate: Callable[[str], str | None] | None = None,
on_reset: Callable[[], None] | None = None,
reset_label: str = "Reset to defaults",
empty_text: str = "(none)",
) -> None:
"""Reusable chip / list editor bound to a draft string list (T7 / T10).
Mutates ``values`` in place -- ``[+ Add]`` appends (rejecting blanks,
duplicates, and ``validate`` failures), each chip carries a delete, and
an optional ``[Reset]`` replaces the contents -- so persistence rides
the existing draft -> ``finalize_settings_draft`` -> Save path with no
new plumbing. Entries are stored verbatim (case-sensitive, no
lowercasing); whitespace is trimmed on add.
"""
from nicegui import ui
chips = ui.row().classes("items-center w-full").style("gap: 0.35rem; flex-wrap: wrap;")
def _render_chips() -> None:
chips.clear()
with chips:
if not values:
ui.label(empty_text).props(f'data-testid="{testid}-empty"').style(
"color: var(--color-muted);"
)
for idx, value in enumerate(values):
with (
ui.row()
.classes("items-center")
.props(f'data-testid="{testid}-chip"')
.style(
"gap: 0.15rem; background: var(--color-rule); "
"border-radius: var(--radius-sm); padding: 0.05rem 0.1rem 0.05rem 0.5rem;"
)
):
ui.label(value).style(
"font-family: var(--font-mono); font-size: var(--text-xs);"
)
ui.button(icon="close", on_click=lambda _e, i=idx: _remove(i)).props(
"flat dense round size=sm"
)
def _remove(idx: int) -> None:
if 0 <= idx < len(values):
del values[idx]
_render_chips()
_render_chips()
new_input = ui.input(label=add_label).props(f'data-testid="{testid}-input"')
def _add() -> None:
raw = (new_input.value or "").strip()
if not raw:
return
if validate is not None:
error = validate(raw)
if error is not None:
notifications.notify_error(error)
return
if raw in values:
notifications.notify_error(f"{raw!r} is already in the list")
return
values.append(raw)
_render_chips()
new_input.value = ""
with ui.row().classes("items-center").style("gap: 0.5rem;"):
ui.button("+ Add", on_click=lambda _e: _add()).props(f'flat data-testid="{testid}-add"')
if on_reset is not None:
def _reset() -> None:
on_reset()
_render_chips()
ui.button(reset_label, on_click=lambda _e: _reset()).props(
f'flat data-testid="{testid}-reset"'
)
def _render_section_body(
section: str,
draft: Config,
*,
on_save_lims_password: Callable[[str], None] | None = None,
on_clear_lims_password: Callable[[], None] | None = None,
lims_password_present: bool = False,
nas_remote_available: Callable[[str], bool] | None = None,
on_test_connection: Callable[[str, str], Any] | None = None,
nas_remotes: Sequence[str] = (),
list_remotes: Callable[[str], Awaitable[Sequence[str]]] | None = None,
autostart_registered: bool = False,
on_set_autostart: Callable[[bool], bool | None] | None = None,
on_quit: Callable[[], None] | None = None,
tray_available: bool = False,
) -> None:
"""Render the content for a single section, bound to ``draft``.
Every scalar field uses NiceGUI two-way binding against the
corresponding ``draft.<sub-block>`` attribute, so edits accumulate
on the draft and ``render_settings_page``'s Save handler can
re-validate and emit the finished :class:`Config`. The list-valued
sections (equipment, scanned extensions) render their current
entries read-only -- rich list editors are a follow-up; the
deadlock this unblocks is the scalar config fields.
The LIMS section's password credential is the exception to the
draft-binding rule: it is keyring-backed and writes at click time
via ``on_save_lims_password`` / ``on_clear_lims_password``.
"""
from nicegui import ui
with (
ui.column()
.classes("w-full")
.props(f'data-testid="settings-section-{section}"')
.style("gap: 0.5rem; padding: 0 1rem;")
):
ui.label(SECTION_TITLES[section]).style(
"font-family: var(--font-display); "
"font-size: var(--text-md); "
"color: var(--color-heading); "
"font-weight: 600;"
)
if section == "paths":
# A single configurable app root (defaults under the OS Documents
# folder). templates/, plugins/ and the experiment data/ root are
# derived from it -- shown read-only below so the layout is clear.
ui.input(label="Data folder", value=draft.paths.app_root).props(
'data-testid="settings-paths-app-root"'
).bind_value(draft.paths, "app_root")
ui.label("Derived locations (created automatically):").style(
"color: var(--color-muted); font-size: var(--text-sm);"
)
for caption, attr in (
("Templates", "templates_dir"),
("Plugins", "plugin_dir"),
("Data", "data_root"),
):
def _derived_label(value: str, *, prefix: str = caption) -> str:
"""Render a derived-dir label as ``Caption: <path>`` (prefix
is bound per-iteration via the default arg)."""
return f"{prefix}: {value}"
ui.label().props(f'data-testid="settings-paths-derived-{attr}"').style(
"color: var(--color-muted); font-family: var(--font-mono); "
"font-size: var(--text-xs);"
).bind_text_from(draft.paths, attr, backward=_derived_label)
elif section == "lims":
ui.input(label="Endpoint URL", value=draft.lims.endpoint).props(
'data-testid="settings-lims-endpoint"'
).bind_value(draft.lims, "endpoint")
ui.input(label="Operator email", value=draft.lims.email).props(
'data-testid="settings-lims-email"'
).bind_value(draft.lims, "email")
credential_field.credential_field(
label="LIMS password",
on_save=on_save_lims_password or (lambda _value: None),
on_clear=on_clear_lims_password or (lambda: None),
initial_state=lims_credential_initial_state(present=lims_password_present),
data_testid="settings-lims-password",
)
ui.number(label="Cache TTL (hours)", value=draft.lims.cache_ttl_hours).props(
'data-testid="settings-lims-cache-ttl"'
).bind_value(draft.lims, "cache_ttl_hours")
ui.input(label="Offline catalogue path", value=draft.lims.offline_catalogue_path).props(
'data-testid="settings-lims-offline-path"'
).bind_value(draft.lims, "offline_catalogue_path")
test_connection_panel.test_connection_panel(None)
elif section == "equipment":
_render_equipment_section(draft)
elif section == NAS_REMOTE_SECTION:
_render_nas_remote_section(
ui.column().classes("w-full"),
nas=draft.nas,
nas_remote_available=nas_remote_available or (lambda _name: False),
on_test_connection=on_test_connection,
nas_remotes=nas_remotes,
list_remotes=list_remotes,
)
elif section == "nas_cleanup":
ui.checkbox("Cleanup enabled", value=draft.nas_cleanup.enabled).bind_value(
draft.nas_cleanup, "enabled"
)
ui.number(
label="Minimum verify passes", value=draft.nas_cleanup.min_verify_passes
).bind_value(draft.nas_cleanup, "min_verify_passes")
ui.number(
label="Minimum age (hours)", value=draft.nas_cleanup.min_age_hours
).bind_value(draft.nas_cleanup, "min_age_hours")
ui.checkbox(
"Retain .exlab-wizard/ metadata", value=draft.nas_cleanup.retain_cache
).bind_value(draft.nas_cleanup, "retain_cache")
elif section == "operators":
# Frontend §7.9: empty allowlist = any operator; non-empty = the
# wizard renders a dropdown of these names and rejects free-text.
# Case-sensitive (OperatorsConfig is str_strip_whitespace, not
# lowercased) and non-gating.
ui.label(
"If empty, the operator field accepts any value. If non-empty, the wizard "
"shows a dropdown of these names and rejects free-text."
).style("color: var(--color-muted); font-size: var(--text-sm);")
_render_chip_editor(
draft.operators.allowlist,
add_label="Add operator username",
testid="settings-operators",
empty_text="Any operator allowed (allowlist empty)",
)
elif section == "validator":
ui.number(
label="Max content-scan size (MiB)",
value=draft.validator.content_scan_max_mib,
).bind_value(draft.validator, "content_scan_max_mib")
ui.label("Scanned file extensions").style("color: var(--color-body);")
def _reset_extensions() -> None:
from exlab_wizard.config.models import _default_content_scan_extensions
draft.validator.content_scan_extensions[:] = _default_content_scan_extensions()
_render_chip_editor(
draft.validator.content_scan_extensions,
add_label="Add extension (e.g. .txt)",
testid="settings-scan-ext",
validate=lambda v: None if v.startswith(".") else "Extensions must start with '.'",
on_reset=_reset_extensions,
)
elif section == "logging":
ui.radio(["DEBUG", "INFO", "WARN", "ERROR"], value=draft.logging.level).bind_value(
draft.logging, "level"
)
ui.number(
label="Central log size cap (MB)", value=draft.logging.central_log_max_mb
).bind_value(draft.logging, "central_log_max_mb")
ui.number(
label="Rotated log copies kept", value=draft.logging.central_log_keep
).bind_value(draft.logging, "central_log_keep")
elif section == "orchestrator":
# ``label`` is required: it identifies this workstation in every
# run's creation.json. The staging-root input is intentionally
# hidden (orchestrator/staging hidden — see
# docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md);
# ``orchestrator.staging_root`` stays blank, so this device never
# acts as a staging PC. The section id stays "orchestrator" so the
# setup gate / settings_sections_for keep working.
ui.input(label="Workstation label", value=draft.orchestrator.label).bind_value(
draft.orchestrator, "label"
)
elif section == "application":
# "Start at login" (T8): applied immediately (NOT draft-bound,
# §7.13). Seeded from the real registration state; on toggle it
# reflects the actual post-op ``is_registered()`` and reverts on
# failure. Disabled when no toggle is wired (headless/tests).
_guard = {"busy": False}
autostart_box: Any = None
def _on_autostart(event: Any) -> None:
if _guard["busy"] or on_set_autostart is None:
return
actual = on_set_autostart(bool(event.value))
if actual is not None and bool(actual) != bool(event.value):
# Programmatic revert re-fires on_change synchronously;
# the guard makes that re-entrant call a no-op.
_guard["busy"] = True
try:
autostart_box.value = bool(actual)
finally:
_guard["busy"] = False
autostart_box = ui.checkbox(
"Start ExLab-Wizard at login",
value=autostart_registered,
on_change=_on_autostart,
).props('data-testid="settings-autostart"')
if on_set_autostart is None:
autostart_box.props("disable")
# Real tray availability + window-on-close behavior (T11, §7.13).
tray_text = "available" if tray_available else "unavailable (window-only)"
ui.label(f"Show in system tray: {tray_text}").props(
'data-testid="settings-tray-status"'
)
ui.label(
"Closing the window keeps ExLab-Wizard running in the tray; "
"use Quit to exit completely."
).style("color: var(--color-muted); font-size: var(--text-sm);")
# "Quit ExLab-Wizard now" (T9): graceful shutdown behind a confirm,
# scheduled non-blocking by the host. Disabled when no hook wired.
quit_btn = ui.button("Quit ExLab-Wizard now").props('flat data-testid="settings-quit"')
if on_quit is None:
quit_btn.props("disable")
else:
def _confirm_quit() -> None:
confirm = ui.dialog()
with (
confirm,
ui.card().props('data-testid="settings-quit-dialog"'),
):
ui.label("Quit ExLab-Wizard?").style("font-weight: 600;")
ui.label("In-flight operations are allowed to finish first.").style(
"color: var(--color-muted);"
)
def _do_quit() -> None:
confirm.close()
on_quit()
with ui.row().classes("justify-end w-full").style("gap: 0.5rem;"):
ui.button("Cancel", on_click=lambda _e: confirm.close()).props("flat")
ui.button("Quit", on_click=lambda _e: _do_quit()).props(
'color=negative data-testid="settings-quit-confirm"'
)
confirm.open()
quit_btn.on("click", lambda _e: _confirm_quit())
# Redesign §6: the canonical equipment-config assembler now lives in
# ``ui/equipment_form`` so both the wizard and Settings can share it.
# This module re-exports it for backward compatibility with existing
# callers / imports.
from exlab_wizard.ui.equipment_form import build_equipment_config # noqa: E402
def _render_equipment_section(draft: Config) -> None:
"""Render the equipment list + a full add-equipment sub-form.
Adding an entry appends a validated :class:`EquipmentConfig` to
``draft.equipment`` and reflects it in the visible list; the whole
draft is re-validated and persisted when the operator clicks Save.
rclone.conf NAS-sync migration: nas-mode equipment no longer carry a
per-equipment SFTP/SMB transport -- the connection is defined once by
the ``nas:`` remote (see the NAS Remote section). The sub-form
therefore collects only identity + paths and builds a nas-mode entry
with ``transport=None``.
"""
from nicegui import ui
rows = ui.column().classes("w-full").style("gap: 0.25rem;")
def _render_rows() -> None:
rows.clear()
with rows:
if draft.equipment:
for entry in draft.equipment:
mode = getattr(entry.sync_mode, "value", str(entry.sync_mode))
ui.label(f"{entry.id} -- {entry.label} [{mode}]").props(
'data-testid="settings-equipment-row"'
)
else:
ui.label("No equipment configured yet.").props(
'data-testid="settings-equipment-empty"'
)
_render_rows()
eq_id = ui.input(label="Equipment ID (^[A-Z][A-Z0-9_]*$)").props(
'data-testid="settings-equipment-id"'
)
eq_label = ui.input(label="Label").props('data-testid="settings-equipment-label"')
eq_nas = ui.input(label="NAS root").props('data-testid="settings-equipment-nas-root"')
def _add(_evt: Any = None) -> None:
try:
entry = build_equipment_config(
equipment_id=eq_id.value or "",
label=eq_label.value or "",
nas_root=eq_nas.value or "",
sync_mode="nas",
)
except Exception as exc:
notifications.notify_error(f"Equipment invalid: {exc}")
return
if any(e.id == entry.id for e in draft.equipment):
notifications.notify_error(f"Equipment {entry.id!r} already exists")
return
draft.equipment.append(entry)
_render_rows()
for widget in (eq_id, eq_label, eq_nas):
widget.value = ""
notifications.notify_success(f"Equipment {entry.id!r} added")
ui.button("Add equipment", on_click=_add).props('data-testid="settings-equipment-add"')
def _remote_options(detected: Sequence[str], current: str) -> list[str]:
"""Dropdown options for the NAS-remote select.
``rclone listremotes`` entries carry a trailing ``":"``; the config
stores the bare name (the probe re-adds it as ``f"{remote}:"``), so
strip it here. The currently-configured ``current`` is unioned in even
when absent from ``detected`` -- a saved remote whose ``rclone.conf``
is presently unreadable must stay selectable.
"""
names = {name[:-1] if name.endswith(":") else name for name in detected if name}
if current:
names.add(current)
return sorted(names)
def _render_nas_remote_section(
container: Any,
*,
nas: Any,
nas_remote_available: Callable[[str], bool],
on_test_connection: Callable[[str, str], Any] | None,
nas_remotes: Sequence[str] = (),
list_remotes: Callable[[str], Awaitable[Sequence[str]]] | None = None,
) -> None:
"""Render the editable NAS-remote fields + a Test-connection panel.
rclone.conf NAS-sync migration. The operator never types a NAS
password; the app references a single ``nas:`` remote defined in their
``rclone.conf`` (created out-of-band with ``rclone config``). This
section lets them choose that remote from a dropdown of remotes
detected via ``rclone listremotes`` (``nas_remotes`` seeds it;
``list_remotes`` re-lists on demand using the typed config path), set
its base root and optional ``--config`` path, and run the rclone probe
against the *typed* values via ``on_test_connection(remote, config_path)``.
All three fields two-way-bind the draft ``nas`` block.
"""
import inspect
from nicegui import ui
remote = getattr(nas, "remote", "") or ""
base_root = getattr(nas, "base_root", "") or ""
config_path = getattr(nas, "rclone_config_path", "") or ""
with container:
ui.label(
"NAS sync targets a single rclone remote from your rclone.conf "
"(run `rclone config` to create one). Pick the remote, set its base "
"root, and optionally pin a config file. No password is stored here."
).style("font-size: var(--text-sm); color: var(--color-muted);")
with ui.row().classes("items-center w-full").style("gap: 0.5rem;"):
ui.label("Remote").style("color: var(--color-body); min-width: 6rem;")
# ``ui.select`` only accepts a value that is ``None`` or one of its
# options, but ``nas.remote`` is a ``str`` ("" when unset). Display
# the empty state as ``None`` and convert at the binding boundary:
# element ``None`` <-> model "" (a ``None`` reaching the field would
# fail validation on Save).
remote_select = (
ui.select(
_remote_options(nas_remotes, remote),
value=remote or None,
)
.props('data-testid="settings-nas-remote-name"')
.style("min-width: 16rem; font-family: var(--font-mono);")
.bind_value(nas, "remote", forward=lambda v: v or "", backward=lambda v: v or None)
)
async def _refresh() -> None:
"""Re-list remotes from the *typed* config path and rebuild options."""
if list_remotes is None:
return
current = getattr(nas, "remote", "") or ""
detected = await list_remotes(getattr(nas, "rclone_config_path", "") or "")
remote_select.set_options(_remote_options(detected, current), value=current or None)
ui.button(icon="refresh", on_click=_refresh).props(
'flat dense data-testid="settings-nas-remote-refresh"'
)
with ui.row().classes("items-center w-full").style("gap: 0.5rem;"):
ui.label("Base root").style("color: var(--color-body); min-width: 6rem;")
ui.input(value=base_root).props(
'data-testid="settings-nas-remote-base-root"'
).style("min-width: 16rem; font-family: var(--font-mono);").bind_value(
nas, "base_root"
)
with ui.row().classes("items-center w-full").style("gap: 0.5rem;"):
ui.label("Config path").style("color: var(--color-body); min-width: 6rem;")
ui.input(value=config_path, placeholder="(rclone default discovery)").props(
'data-testid="settings-nas-remote-config-path"'
).style("min-width: 16rem; font-family: var(--font-mono);").bind_value(
nas, "rclone_config_path"
)
if remote and nas_remote_available(remote):
badge_text = "Found in rclone.conf"
badge_color = "var(--color-success)"
else:
badge_text = "Not found — run `rclone config`"
badge_color = "var(--color-warning)"
ui.label(badge_text).props('data-testid="settings-nas-remote-status"').style(
f"color: {badge_color}; font-size: var(--text-sm); font-weight: 600;"
)
panel = ui.column().classes("w-full")
async def _test() -> None:
panel.clear()
if on_test_connection is None:
return
result = on_test_connection(
getattr(nas, "remote", "") or "",
getattr(nas, "rclone_config_path", "") or "",
)
if inspect.isawaitable(result):
result = await result
with panel:
test_connection_panel.test_connection_panel(result)
ui.button("Test connection", on_click=_test).props(
'flat data-testid="settings-nas-test-connection"'
)