Skip to content

Actions

Actions are module methods that can be called from the frontend. When a user clicks a button, submits a form, or triggers any interactive element, an action is invoked.

The action system provides:

  • Decorator-based registration — Mark methods with @action
  • Automatic async handling — Sync and async methods work the same
  • Auto-commit — State changes are pushed automatically after actions
  • Structured results — Return results that control the UI (open modals, show notifications)

Defining Actions

Use the @action decorator to mark a method as callable from the frontend:

from ContaraNAS.core.action import action


class MyModule(Module):
    @action
    async def refresh_data(self) -> None:
        """Refresh data from external source"""
        new_data = await self.fetch_data()
        if self._typed_state:
            self._typed_state.data = new_data
            # Auto-committed by @action decorator

Requirements

  1. The method must be a member of a Module subclass
  2. The method must be decorated with @action
  3. The method can be sync or async (async recommended)
  4. The method name becomes the action identifier

Sync vs Async

Both sync and async methods work, but async is recommended for I/O operations:

# Async (recommended for I/O)
@action
async def fetch_from_api(self) -> None:
    data = await self.client.get("/data")
    self._typed_state.data = data


# Sync (fine for quick operations)
@action
def increment_counter(self) -> None:
    self._typed_state.counter += 1

The @action decorator handles both transparently.

Wiring Actions to UI

Connect actions to interactive components using the on_click prop:

from ContaraNAS.core.ui import Button


class MyModule(Module):
    @action
    async def do_something(self) -> None:
        pass

    def get_tile(self) -> Tile:
        return Tile(
            icon="Box",
            title="My Module",
            actions=[
                Button(label="Do Something", on_click=self.do_something),
            ],
        )

When serialized, the action reference becomes:

{
    "type": "button",
    "label": "Do Something",
    "on_click": {
        "__action__": "do_something"
    }
}

The frontend uses this to call the correct action when clicked.

ActionRef: Actions with Parameters

When you need to bind specific parameter values to an action at UI construction time, use ActionRef:

from ContaraNAS.core.action import ActionRef, action


class SteamModule(Module):
    @action
    async def open_library(self, library_path: str) -> OpenModal:
        """Open the modal for a specific library"""
        return OpenModal(modal_id=f"library_{library_path}")

    def get_tile(self) -> Tile:
        return Tile(
            icon="Gamepad",
            title="Steam",
            content=[
                # Each button calls open_library with a different path
                Button(
                    label="Main Library",
                    on_click=ActionRef(self.open_library, library_path="/home/steam"),
                ),
                Button(
                    label="Games Drive",
                    on_click=ActionRef(self.open_library, library_path="/mnt/games"),
                ),
            ],
        )

How ActionRef Works

ActionRef wraps an action method and pre-binds parameter values:

# Create a reference to an action with parameters
ref = ActionRef(self.some_action, param1="value1", param2=42)

# When serialized, it becomes:
# {"__action__": "some_action", "__params__": {"param1": "value1", "param2": 42}}

The frontend automatically passes these parameters when invoking the action.

When to Use ActionRef

Use ActionRef when:

  • You need the same action to behave differently based on context
  • Building dynamic lists where each item triggers the action with different data
  • Creating buttons in loops that each need different parameter values
# Dynamic button list example
def get_tile(self) -> Tile:
    buttons = []
    for lib in self.state.libraries:
        buttons.append(
            Button(
                label=lib["name"],
                on_click=ActionRef(self.open_library, library_path=lib["path"]),
            )
        )

    return Tile(
        icon="Folder",
        title="Libraries",
        content=[Stack(direction="vertical", children=buttons)],
    )

ActionRef Requirements

  1. The method must be decorated with @action
  2. Parameter names must match the action's parameter names
  3. Parameter values are serialized as JSON (use basic types)
# This will raise ValueError - method not decorated with @action
ActionRef(self.some_regular_method, param="value")  # Error!


# Correct usage
@action
async def my_action(self, name: str, count: int = 0) -> None:
    pass


ActionRef(self.my_action, name="test", count=5)  # OK

Action Parameters

Actions can accept parameters from the frontend:

@action
async def update_name(self, name: str) -> None:
    """Update the name field"""
    if self._typed_state:
        self._typed_state.name = name


@action
async def set_count(self, count: int = 0) -> None:
    """Set counter to specific value"""
    if self._typed_state:
        self._typed_state.count = count

Form Data

When a button is clicked inside a form, field values are passed as parameters:

from ContaraNAS.core.ui import Stack, Input, Button

# In your UI definition
form = Stack(
    direction="vertical",
    gap="4",
    children=[
        Input(name="username", label="Username"),
        Input(name="password", label="Password", input_type="password"),
        Button(label="Login", on_click=self.login),
    ],
)


# In your action
@action
async def login(self, username: str = "", password: str = "") -> None:
    """Handle login form submission"""
    if not username or not password:
        return Notify(message="Username and password required", variant="error")

    # Perform login...

Type Coercion

Parameter values come from the frontend as strings or basic JSON types. Use type hints and defaults:

@action
async def set_settings(
        self,
        name: str = "",
        count: int = 0,
        enabled: bool = False,
        ratio: float = 1.0,
) -> None:
    """Parameters are coerced to their annotated types"""
    pass

Action Results

Actions can return results that control the frontend behavior.

Available Results

Result Purpose
OpenModal(modal_id) Open a modal dialog
CloseModal(modal_id) Close a modal dialog
Notify(message, variant) Show a notification
Refresh() Force UI refresh

Importing Results

from ContaraNAS.core.action import (
    action,
    ActionRef,
    OpenModal,
    CloseModal,
    Notify,
    Refresh,
)

OpenModal

Open a modal by ID:

@action
async def show_details(self):
    """Open the details modal"""
    return OpenModal(modal_id="game_details")

CloseModal

Close a specific modal or the current modal:

@action
async def save_and_close(self):
    """Save data and close the modal"""
    # Save logic...

    return CloseModal(modal_id="edit_form")


# Or close whatever modal is open
@action
async def cancel(self):
    return CloseModal()  # No modal_id closes current modal

Notify

Show a temporary notification:

@action
async def save_settings(self):
    """Save and notify user"""
    # Save logic...

    return Notify(
        message="Settings saved successfully!",
        variant="success",
    )


@action
async def delete_item(self):
    """Delete with error handling"""
    try:
        await self.do_delete()
        return Notify(message="Item deleted", variant="success")
    except Exception as e:
        return Notify(message=f"Delete failed: {e}", variant="error")

Notify Props

Prop Type Default Description
message str Required Notification text
variant "info" | "success" | "warning" | "error" "info" Notification style
title str \| None None Optional title

Refresh

Force the UI to refresh:

@action
async def force_refresh(self):
    """Refresh everything"""
    await self.reload_data()
    return Refresh()

Multiple Results

Return a list to trigger multiple results:

@action
async def save_and_close(self) -> list:
    """Save, notify, and close modal"""
    # Save logic...

    return [
        Notify(message="Saved!", variant="success"),
        CloseModal(modal_id="edit_form"),
    ]


@action
async def submit_form(self, **form_data) -> list:
    """Validate, save, and provide feedback"""
    errors = self.validate(form_data)
    if errors:
        return [Notify(message=errors[0], variant="error")]

    await self.save(form_data)

    return [
        Notify(message="Form submitted", variant="success"),
        CloseModal(),
        Refresh(),
    ]

Auto-Commit Behavior

The @action decorator automatically commits state if it's dirty:

@action
async def increment(self) -> None:
    """State is auto-committed after this action"""
    if self._typed_state:
        self._typed_state.counter += 1
        # No need to call commit() - it happens automatically

This means:

  1. Action runs
  2. If self._typed_state.is_dirty is True, commit() is called
  3. Commit triggers event to push state to frontend

When Auto-Commit Happens

Auto-commit occurs after the action completes, before returning results:

@action
async def update(self) -> Notify:
    self._typed_state.value = 42  # Marks state dirty

    # Auto-commit happens here, before returning

    return Notify(message="Updated!")  # Frontend receives new state + notification

Manual Commit

You can still call commit() manually during an action:

@action
async def long_operation(self) -> None:
    """Update progress during operation"""
    for i in range(10):
        await self.process_batch(i)
        self._typed_state.progress = (i + 1) * 10
        self._typed_state.commit()  # Push update immediately

    self._typed_state.progress = 100
    # Final auto-commit happens here

Error Handling

Handle errors gracefully and provide user feedback:

@action
async def risky_operation(self) -> Notify:
    """Operation that might fail"""
    try:
        await self.do_risky_thing()
        return Notify(message="Success!", variant="success")
    except PermissionError:
        return Notify(
            message="Permission denied. Check file permissions.",
            variant="error",
        )
    except ConnectionError:
        return Notify(
            message="Network error. Check your connection.",
            variant="error",
        )
    except Exception as e:
        # Log the full error
        logger.exception("Unexpected error in risky_operation")
        return Notify(
            message="An unexpected error occurred.",
            variant="error",
        )

Updating State on Error

class State(ModuleState):
    error: str | None = None
    loading: bool = False


@action
async def fetch_data(self) -> None:
    """Fetch with error handling"""
    self._typed_state.loading = True
    self._typed_state.error = None
    self._typed_state.commit()

    try:
        data = await self.client.fetch()
        self._typed_state.data = data
    except Exception as e:
        self._typed_state.error = str(e)
    finally:
        self._typed_state.loading = False
        # Auto-committed

Getting Available Actions

The framework can list all actions on a module:

from ContaraNAS.core.action import get_actions

# Get all action methods from a module instance
actions = get_actions(module_instance)
# Returns: {"refresh": <method>, "save": <method>, ...}

Action Dispatcher

The ActionDispatcher routes action calls to the correct module:

from ContaraNAS.core.action import ActionDispatcher

dispatcher = ActionDispatcher()

# Register modules
dispatcher.register_module(steam_module)
dispatcher.register_module(sys_monitor_module)

# Dispatch an action
results = await dispatcher.dispatch(
    module_name="steam",
    action_name="refresh_library",
    payload={"force": True},
)

This is handled by the framework; you don't need to use it directly.

For complete module examples, see the GitHub repository.


Complete Example: Docker Module

A practical example showing actions for Docker container management:

from ContaraNAS.core.action import action, ActionRef, Notify, OpenModal, Refresh
from ContaraNAS.core.module import Module, ModuleState
from ContaraNAS.core.ui import Button, Stack, Tile, Badge, Stat


class DockerModule(Module):
    class State(ModuleState):
        containers: list[dict] = []
        running_count: int = 0
        stopped_count: int = 0

    @action
    async def restart_container(self, container_id: str, timeout: int = 30) -> Notify:
        """Restart a Docker container"""
        await self._docker.restart(container_id, timeout=timeout)
        return Notify(message="Container restarting...", variant="info")

    @action
    async def stop_container(self, container_id: str) -> Notify:
        """Stop a running container"""
        await self._docker.stop(container_id)
        await self._refresh_containers()
        return Notify(message="Container stopped", variant="success")

    @action
    async def view_logs(self, container_id: str, tail: int = 100) -> OpenModal:
        """Open container logs modal"""
        return OpenModal(modal_id=f"logs_{container_id}")

    @action
    async def pull_image(self, image: str) -> list:
        """Pull a Docker image with error handling"""
        try:
            await self._docker.pull(image)
            return [
                Notify(message=f"Pulled {image}", variant="success"),
                Refresh(),
            ]
        except Exception as e:
            return [Notify(message=f"Pull failed: {e}", variant="error")]

    def get_tile(self) -> Tile:
        return Tile(
            icon="Container",
            title="Docker",
            badge=Badge(text=f"{self._typed_state.running_count} running", variant="success"),
            stats=[
                Stat(label="Running", value=self._typed_state.running_count),
                Stat(label="Stopped", value=self._typed_state.stopped_count),
            ],
            actions=[
                Button(label="Refresh", on_click=self.refresh, variant="ghost"),
            ],
        )

Best Practices

Do

  • Use descriptive namesrefresh_library not refresh
  • Handle errors gracefully — Always provide user feedback
  • Keep actions focused — One action, one purpose
  • Use async for I/O — Database, network, file operations
  • Return appropriate results — Notify on success/failure

Don't

  • Don't have side effects without feedback — If something changes, tell the user
  • Don't forget error handling — Actions can fail
  • Don't block the event loop — Use async for slow operations
  • Don't expose internal methods — Only decorate public actions with @action

See Also