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¶
- The method must be a member of a
Modulesubclass - The method must be decorated with
@action - The method can be sync or async (async recommended)
- 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:
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¶
- The method must be decorated with
@action - Parameter names must match the action's parameter names
- 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¶
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:
- Action runs
- If
self._typed_state.is_dirtyis True,commit()is called - 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 names —
refresh_librarynotrefresh - 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¶
- State Management — How state and commit work
- Interactive Components — Buttons and forms
- Modal Component — OpenModal and CloseModal usage