"""Node-type-aware metadata pane for the rebuilt main window.
GUI/Orchestrator Redesign §4.4. Renders the right-pane Metadata tab
content based on the kind of tree node selected.
Pure render function: takes the selected node id + a payload dict shaped
by the caller (typically derived from GET /tree + GET /run responses)
and dispatches to the per-kind renderer. The Problems tab is rendered
separately by the existing problems-row machinery.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
from exlab_wizard.ui.components.empty_state import empty_state
from exlab_wizard.ui.components.file_type_icon import file_type_icon
from exlab_wizard.ui.components.sync_status_icon import (
FileSyncView,
file_sync_view,
sync_pair_icons,
sync_rollup_icon,
)
# Node-kind discriminators consumed by the dispatcher.
NODE_KIND_EQUIPMENT = "equipment"
NODE_KIND_RECEIVED_EQUIPMENT = "received_equipment"
NODE_KIND_PROJECT = "project"
NODE_KIND_RUNS_FOLDER = "runs_folder"
NODE_KIND_TEST_RUNS_FOLDER = "test_runs_folder"
NODE_KIND_RUN = "run"
NODE_KIND_RECEIVED_RUN = "received_run"
# Selected-file/folder sub-card kinds (Phase 4 / Option B), set by the mount
# layer on the payload it assembles (see mount._build_selected_file).
SELECTED_KIND_FILE = "file"
SELECTED_KIND_FOLDER = "folder"
[docs]
def selected_file_card_title(payload: dict[str, Any]) -> str:
"""Return the sub-card heading for a selected file/folder payload (pure).
``"Selected folder"`` for a folder payload (``kind == "folder"``),
``"Selected file"`` otherwise. Pure so the title mapping is testable
without spinning up NiceGUI.
"""
if payload.get("kind") == SELECTED_KIND_FOLDER:
return "Selected folder"
return "Selected file"
def _kv(key: str, value: Any) -> None: # pragma: no cover -- NiceGUI render, driven by e2e
try:
from nicegui import ui
except Exception:
return
with ui.row().classes("items-center").style("flex-wrap: nowrap;"):
ui.label(f"{key}:").style(
"color: var(--color-muted); width: 12rem; min-width: 12rem; white-space: nowrap;"
)
# Values overflow rather than wrap (long paths stay on one line); the
# metadata pane is horizontally scrollable so they remain reachable.
ui.label(str(value) if value is not None else "-").style(
"font-family: var(--font-mono); white-space: nowrap;"
)
def _kv_sync(
key: str, status: Any, render_icon: Callable[[FileSyncView], Any]
) -> None: # pragma: no cover -- NiceGUI render, driven by e2e
"""Key/value row whose value is a sync icon (file pair or folder rollup).
Mirrors :func:`_kv`'s label column but renders the status through the
tolerant presentation map: a ``None`` / unknown status renders nothing
rather than raising (spec §4.5). ``render_icon`` is the icon renderer to
use for the value cell -- :func:`sync_pair_icons` for a file's two-icon
pair, :func:`sync_rollup_icon` for a folder's single rollup icon.
"""
try:
from nicegui import ui
except Exception:
return
with ui.row().classes("items-center w-full"):
ui.label(f"{key}:").style("color: var(--color-muted); width: 12rem; min-width: 12rem;")
render_icon(file_sync_view(status))
def _kv_sync_pair(key: str, status: Any) -> None: # pragma: no cover -- NiceGUI render
"""Key/value row whose value is a file's two-icon (local + NAS) pair."""
_kv_sync(key, status, sync_pair_icons)
def _kv_sync_rollup(key: str, status: Any) -> None: # pragma: no cover -- NiceGUI render
"""Key/value row whose value is a folder's single rollup sync icon."""
_kv_sync(key, status, sync_rollup_icon)
def _render_selected_file_card(
payload: dict[str, Any],
) -> None: # pragma: no cover -- NiceGUI render, driven by e2e
"""Render the SELECTED FILE / SELECTED FOLDER sub-card (Phase 4, §4.4).
Appended beneath the node-kind content. A file shows
name/size/modified/sync/path; a tombstone ("On NAS") omits the size row
and notes that the local copy is gone. A folder shows
name/item-count/sync-rollup/path with no size row -- the recursive total
is deferred (spec §11).
"""
try:
from nicegui import ui
except Exception:
return
is_folder = payload.get("kind") == SELECTED_KIND_FOLDER
testid = "metadata-selected-folder" if is_folder else "metadata-selected-file"
with (
ui.column()
.classes("w-full")
.style(
"gap: 0.4rem; margin-top: 0.75rem; padding-top: 0.75rem; "
"border-top: 1px solid var(--color-rule);"
)
.props(f'data-testid="{testid}"')
):
ui.label(selected_file_card_title(payload).upper()).style(
"font-size: var(--text-xs); text-transform: uppercase; letter-spacing: 0.08em; "
"color: var(--color-muted); font-weight: 600;"
)
with ui.row().classes("items-center").style("gap: 0.4rem;"):
file_type_icon(payload.get("name", ""), is_dir=is_folder)
ui.label(payload.get("name", "")).style(
"font-family: var(--font-display); color: var(--color-heading); font-weight: 600;"
)
if is_folder:
_kv("Items", payload.get("item_count"))
_kv_sync_rollup("Sync", payload.get("rollup"))
_kv("Path", payload.get("path"))
else:
tombstone = bool(payload.get("tombstone"))
if not tombstone:
_kv("Size", payload.get("size"))
_kv("Modified", payload.get("modified"))
_kv_sync_pair(
"Sync",
payload.get("sync_status") or ("on_nas" if tombstone else None),
)
_kv("Path", payload.get("path"))
if tombstone:
ui.label("On NAS -- local copy cleared.").props(
'data-testid="metadata-selected-file-tombstone-note"'
).style("color: var(--color-muted); margin-top: 0.25rem;")
def _render_equipment(
payload: dict[str, Any],
) -> None: # pragma: no cover -- NiceGUI render, driven by e2e
try:
from nicegui import ui
except Exception:
return
ui.label(payload.get("label", payload.get("id", ""))).style(
"font-family: var(--font-display); font-size: var(--text-md); "
"color: var(--color-heading); font-weight: 600;"
)
_kv("ID", payload.get("id"))
_kv("Label", payload.get("label"))
_kv("Sync mode", payload.get("sync_mode"))
_kv("Local root", payload.get("local_root"))
_kv("NAS root", payload.get("nas_root"))
if payload.get("sync_mode") == "stage":
ui.label(
"Stage mode: this device pushes runs to a connected PC's staging "
"area. Per-run sync status tops out at 'relayed' locally; the "
"connected PC owns the onward NAS sync."
).style("color: var(--color-muted); margin-top: 0.5rem;").props(
'data-testid="metadata-stage-ceiling-note"'
)
def _render_received_equipment(
payload: dict[str, Any],
) -> None: # pragma: no cover -- NiceGUI render, driven by e2e
try:
from nicegui import ui
except Exception:
return
ui.label(payload.get("label", payload.get("id", ""))).style(
"font-family: var(--font-display); font-size: var(--text-md); "
"color: var(--color-heading); font-weight: 600;"
)
ui.element("span").props('data-testid="metadata-relay-badge"').style(
"background: var(--color-info); color: var(--color-on-info); "
"padding: 2px 8px; border-radius: 4px; font-size: var(--text-xs);"
)
_kv("Relay source", payload.get("source_host"))
_kv("Equipment ID", payload.get("id"))
_kv("Equipment label", payload.get("label"))
def _render_project(
payload: dict[str, Any],
) -> None: # pragma: no cover -- NiceGUI render, driven by e2e
_kv("Name", payload.get("name"))
_kv("LIMS short id", payload.get("short_id"))
_kv("Objective", payload.get("objective"))
_kv("Run count", payload.get("run_count"))
_kv("Test run count", payload.get("test_run_count"))
def _render_runs_folder(
kind: str, payload: dict[str, Any]
) -> None: # pragma: no cover -- NiceGUI render, driven by e2e
try:
from nicegui import ui
except Exception:
return
label = "Runs/" if kind == NODE_KIND_RUNS_FOLDER else "TestRuns/"
ui.label(f"{label} (group)").style(
"font-family: var(--font-display); color: var(--color-heading);"
)
_kv("Path", payload.get("path"))
_kv("Child run count", payload.get("run_count"))
def _render_run(
payload: dict[str, Any],
*,
on_run_staging_action: Callable[[str, str], None] | None,
) -> None: # pragma: no cover -- NiceGUI render, driven by e2e
try:
from nicegui import ui
except Exception:
return
ui.label(payload.get("label", payload.get("name", ""))).style(
"font-family: var(--font-display); font-size: var(--text-md); "
"color: var(--color-heading); font-weight: 600;"
)
_kv("Kind", payload.get("run_kind"))
_kv("Operator", payload.get("operator"))
_kv("Objective", payload.get("objective"))
_kv("Template", payload.get("template"))
_kv("Created", payload.get("created_at"))
_kv("LIMS project", payload.get("lims_project"))
_kv("Sync status", payload.get("sync_status"))
if on_run_staging_action is not None:
with ui.row().classes("items-center").style("gap: 0.5rem; margin-top: 0.5rem;"):
run_path = payload.get("path", "")
ui.button("Force sync").props('flat data-testid="metadata-run-force-sync"').on(
"click",
lambda _evt: on_run_staging_action(run_path, "force_sync"),
)
ui.button("Clear verified").props('flat data-testid="metadata-run-clear-verified"').on(
"click",
lambda _evt: on_run_staging_action(run_path, "clear_verified"),
)
ui.button("View log").props('flat data-testid="metadata-run-view-log"').on(
"click",
lambda _evt: on_run_staging_action(run_path, "view_log"),
)
def _render_received_run(
payload: dict[str, Any],
) -> None: # pragma: no cover -- NiceGUI render, driven by e2e
_kv("Run path", payload.get("path"))
_kv("Lifecycle state", payload.get("ingest_state"))
_kv("Files received", payload.get("files_received"))
_kv("Bytes received", payload.get("bytes_received"))
_kv("Last activity", payload.get("last_activity_at"))