Phase 4 — Frontend#
SAF is deliberately frontend-agnostic. The same backend could be driven from Streamlit, from a custom React app, or from a plain HTTP client. This tutorial uses Plotly Dash.
This phase explains the Dash concepts only as far as you need them to wire the SAF backend. Every one of them is covered in depth in the official documentation, and each section below links to the matching page. If Dash is new to you, start with the Dash fundamentals and come back here.
Where the frontend lives#
All frontend code lives under src/saf/solutions/game_of_life/ui/:
ui/
├── app.py # DashProxy app instance (auto-generated, don't touch)
├── assets/ # CSS, icons, images
├── components/ # Reusable Dash components
└── pages/
├── about_page.py # Landing page (auto-generated)
├── game_of_life_page.py # ← the file you will edit
└── page.py # Page router (auto-generated, don't touch)
You already have a game_of_life_page.py skeleton from saf add-step. You are going to
replace its body with a fully functional page.
The full frontend#
Here is the file you are aiming at. Expand the dropdown to skim it now; the rest of the phase dissects it one block at a time.
Complete game_of_life_page.py
"""Frontend of the game of life page."""
import json
import logging
from ansys.saf.glow.client import DashClient, callback
from ansys.saf.glow.solution import MethodStatus
import dash
from dash import Patch
from dash_extensions.enrich import Input, Output, State, dcc, html
from dash_iconify import DashIconify
import dash_mantine_components as dmc
import numpy as np
import plotly.graph_objects as go
from saf.solutions.examples.solution.definition import ExamplesSolution
from saf.solutions.examples.solution.logic.game_of_life import PatternLibrary
logger = logging.getLogger(__name__)
dash.register_page(
__name__,
name="Conway's Game of Life",
path_template="/projects/<project_id>/game-of-life",
icon_asset_name="game-icons--crossed-air-flows.svg",
icon_asset_path="icons",
)
def layout(project: ExamplesSolution) -> html.Div:
"""Layout of the game of life page."""
step = project.steps.game_of_life_step
return html.Div(
[
html.H1(
"Conway's Game of Life",
className="display-3",
style={"font-size": "40px", "font-weight": "bold"},
),
dmc.Blockquote(
[
"This is an implementation of ",
dmc.Anchor(
"Conway's Game of Life",
href="https://en.wikipedia.org/wiki/Conway%27s_Game_of_Life",
target="_blank",
underline="always",
),
". It demonstrates how to build a simple SAF application "
"from backend transactions to frontend visualization.",
],
icon=DashIconify(icon="material-symbols:info", width=30),
style={"font-size": "18px", "fontStyle": "italic"},
),
dmc.Space(h=20),
dmc.Grid(
[
dmc.GridCol(
[
dmc.Divider(
label="Initialization",
styles={"label": {"fontSize": "18px"}},
),
dmc.Select(
label="Select Pattern",
id="pattern-select",
value="blinker",
searchable=True,
clearable=False,
data=[
{
"value": pattern,
"label": pattern.replace("_", " ").title(),
}
for pattern in PatternLibrary().get_pattern_list() + ["random"]
],
),
dmc.Space(h=20),
dmc.NumberInput(
label="Max Iterations",
id="max-iterations",
value=10,
min=1,
type="number",
style={"width": "100%"},
),
dmc.Space(h=20),
html.Div("Grid Size"),
dmc.Slider(
id="grid-size",
value=20,
min=5,
max=100,
step=1,
),
dmc.Space(h=20),
dmc.Button(
"Start Simulation",
leftSection=html.Img(src=dash.get_asset_url("icons/mdi--play.svg")),
id="start-simulation",
variant="filled",
size="sm",
style={"width": "100%"},
),
dmc.Space(h=40),
dmc.Divider(
label="Simulation Status",
styles={"label": {"fontSize": "18px"}},
),
dmc.Group(
[
html.Div("Iteration:"),
dmc.Badge("0", id="iteration-counter", size="lg"),
dmc.Progress(
value=0,
id="progress-bar",
size="xl",
radius="xl",
style={"width": "100%"},
),
],
),
],
span=3,
),
dmc.GridCol(
dcc.Graph(
id="heatmap",
figure=go.Figure(
data=go.Heatmap(
zmin=0,
zmax=1,
colorscale=[[0, "black"], [1, "white"]],
xgap=1,
ygap=1,
showscale=False,
),
layout=go.Layout(
xaxis=dict(
visible=False,
scaleanchor="y",
),
yaxis=dict(visible=False, autorange="reversed"),
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="#222",
autosize=True,
margin=dict(l=0, r=0, t=0, b=0),
),
),
style={
"width": "800px",
"height": "600px",
"display": "block",
"marginLeft": "auto",
"marginRight": "auto",
},
config={"displayModeBar": False},
),
span=9,
style={
"display": "flex",
"justifyContent": "center",
"alignItems": "center",
},
),
],
justify="center",
style={
"margin": "0 auto",
"maxWidth": "1200px",
"width": "100%",
"paddingRight": "60px",
"paddingLeft": "20px",
},
),
DashClient.create_event_listener(step, stream_name="my-stream", id="update-heatmap"),
DashClient.create_event_listener(step, stream_name="simulate", id="termination"),
html.Div(id="notifications-container"),
],
style={"paddingLeft": "20px"},
)
@callback(
Output("heatmap", "figure", allow_duplicate=True),
Input("pattern-select", "value"),
Input("grid-size", "value"),
State("url", "pathname"),
)
def update_initial_pattern(pattern_select: str, grid_size: int, project: ExamplesSolution) -> Patch:
"""Update the initial pattern."""
step = project.steps.game_of_life_step
step.selected_pattern = pattern_select
step.grid_size = grid_size
step.display_initial_state()
patched_figure = Patch()
patched_figure["data"][0]["z"] = np.array(step.initial_grid_state)
return patched_figure
@callback(
Output("notifications-container", "children"),
Output("start-simulation", "loading"),
Output("start-simulation", "disabled"),
Output("max-iterations", "disabled"),
Output("grid-size", "disabled"),
Output("pattern-select", "disabled"),
Output("iteration-counter", "children", allow_duplicate=True),
Output("progress-bar", "value", allow_duplicate=True),
Input("start-simulation", "n_clicks"),
State("pattern-select", "value"),
State("max-iterations", "value"),
State("grid-size", "value"),
State("url", "pathname"),
prevent_initial_call=True,
)
def start_simulation(
start_simulation: int,
pattern: str,
max_iterations: int,
grid_size: int,
project: ExamplesSolution,
) -> tuple[dmc.Notification, bool, bool, bool, bool, bool, str, int]:
"""Trigger the simulation."""
step = project.steps.game_of_life_step
step.selected_pattern = pattern
step.max_iterations = max_iterations
step.grid_size = grid_size
try:
step.simulate()
except Exception as e:
return (
dmc.Notification(
title="Error",
message=str(e),
color="red",
id={"type": "notification", "index": "simulation-error"},
autoClose=False,
action="show",
),
False,
False,
False,
False,
False,
"0/{}".format(max_iterations),
0,
)
return (
dmc.Notification(
title="Simulation Started",
message=f"Simulation started with pattern: {pattern}",
color="lime",
id={"type": "notification", "index": "simulation-started"},
autoClose=True,
action="show",
),
True,
True,
True,
True,
True,
"0/{}".format(max_iterations),
0,
)
@callback(
Output("heatmap", "figure"),
Output("iteration-counter", "children"),
Output("progress-bar", "value"),
Input("update-heatmap", "message"),
State("max-iterations", "value"),
prevent_initial_call=True,
)
def update_graph(message: dict, max_iterations: int) -> tuple[Patch, str, int]:
"""Update the grid."""
data = json.loads(message.get("data"))
patched_figure = Patch()
patched_figure["data"][0]["z"] = np.array(data.get("grid"))
iteration = data.get("iteration", 0)
return (
patched_figure,
f"{iteration}/{max_iterations}",
iteration / max_iterations * 100,
)
@callback(
Output("notifications-container", "children"),
Output("start-simulation", "loading"),
Output("start-simulation", "disabled"),
Output("max-iterations", "disabled"),
Output("grid-size", "disabled"),
Output("pattern-select", "disabled"),
Input("termination", "message"),
State("url", "pathname"),
prevent_initial_call=True,
)
def enable_new_simulation(
message: dict, project: ExamplesSolution
) -> tuple[dmc.Notification, bool, bool, bool, bool, bool]:
"""Enable starting a new simulation after termination."""
step = project.steps.game_of_life_step
status = step.get_method_state("simulate").status
if status == MethodStatus.Completed:
notification = dmc.Notification(
title="Simulation Completed",
message="The simulation has completed successfully.",
color="lime",
id={"type": "notification", "index": "simulation-completed"},
autoClose=True,
action="show",
)
elif status == MethodStatus.Failed:
notification = dmc.Notification(
title="Simulation Failed",
message="The simulation has failed or was terminated.",
color="red",
id={"type": "notification", "index": "simulation-failed"},
autoClose=True,
action="show",
)
return notification, False, False, False, False, False
Note
As in phase 3, the snippets on this page are pulled straight from the
SAF examples solution. Wherever you
see saf.solutions.examples or ExamplesSolution, substitute
saf.solutions.game_of_life and GameOfLifeSolution.
Anatomy of a SAF Dash page#
Key concept — SAF Dash page
Every SAF Dash page, no matter how complex, is built from the same three blocks:
Imports and page registration: declare the URL under which the page is reachable.
The ``layout`` function: receives a
project(an instance of yourSolutionclass) and returns a Dash component tree. This is where you build the visual.Callbacks: decorated functions that fire in response to UI events (click, change, …) or backend events (streams).
Recognizing those three blocks is enough to find your way around any SAF page, including ones you did not write.
The rest of this phase walks through the three blocks in order.
Page registration#
Key concept — Page registration
dash.register_page declares the URL of the page, its label in the navigation tree and
its icon. Its path_template must contain <project_id>, because every piece of
state in SAF is scoped to a project: that placeholder is what lets the runtime resolve
which project’s data the page works on.
See Multi-page apps and URL support in the Dash
documentation, and in particular the
variable paths section, which explains
the <variable_name> syntax SAF relies on.
dash.register_page(
__name__,
name="Conway's Game of Life",
path_template="/projects/<project_id>/game-of-life",
icon_asset_name="game-icons--crossed-air-flows.svg",
icon_asset_path="icons",
)
Two things to note:
nameis the label shown in the navigation tree;icon_asset_nameandicon_asset_pathpoint at an SVG underui/assets/and are optional.path_templateis what makes the page addressable: the runtime substitutes the real project identifier when the user navigates to it.
The imports at the top of the file follow the same split you saw in the backend — SAF primitives, Dash/Plotly components, and finally your own solution:
import json
import logging
from ansys.saf.glow.client import DashClient, callback
from ansys.saf.glow.solution import MethodStatus
import dash
from dash import Patch
from dash_extensions.enrich import Input, Output, State, dcc, html
from dash_iconify import DashIconify
import dash_mantine_components as dmc
import numpy as np
import plotly.graph_objects as go
from saf.solutions.examples.solution.definition import ExamplesSolution
from saf.solutions.examples.solution.logic.game_of_life import PatternLibrary
Importing your Solution class from definition.py gives every callback a typed handle
on the whole solution — you get IDE completion on project.steps.game_of_life_step and its
fields, for free.
The layout function#
Key concept — layout(project)
The layout function is the entry point of the page. Its signature is fixed: SAF calls
it with the Solution instance matching the <project_id> in the URL, and expects a
Dash component tree in return.
Annotating the parameter with your own Solution class turns project into a
typed handle on the whole workflow: project.steps.game_of_life_step and its fields
come with full IDE completion. Reading a field inside the layout is how you seed a control
with a value already stored in the backend.
See Dash layout for the general principles, and layout in Dash pages for the function form used here.
The signature of the layout is fixed: SAF calls it with the project matching the
<project_id> in the URL.
def layout(project: GameOfLifeSolution) -> html.Div:
step = project.steps.game_of_life_step
return html.Div([...])
From there, you dereference the step you care about and use its fields as initial values for the controls.
The layout of this page is a two-column grid:
Left column (3/12) — the controls: a pattern picker, a max-iterations input, a grid-size slider, a Start simulation button, and a small status area (iteration counter + progress bar).
Right column (9/12) — a Plotly
Heatmapfigure driven by the grid data.
The full layout function
def layout(project: ExamplesSolution) -> html.Div:
"""Layout of the game of life page."""
step = project.steps.game_of_life_step
return html.Div(
[
html.H1(
"Conway's Game of Life",
className="display-3",
style={"font-size": "40px", "font-weight": "bold"},
),
dmc.Blockquote(
[
"This is an implementation of ",
dmc.Anchor(
"Conway's Game of Life",
href="https://en.wikipedia.org/wiki/Conway%27s_Game_of_Life",
target="_blank",
underline="always",
),
". It demonstrates how to build a simple SAF application "
"from backend transactions to frontend visualization.",
],
icon=DashIconify(icon="material-symbols:info", width=30),
style={"font-size": "18px", "fontStyle": "italic"},
),
dmc.Space(h=20),
dmc.Grid(
[
dmc.GridCol(
[
dmc.Divider(
label="Initialization",
styles={"label": {"fontSize": "18px"}},
),
dmc.Select(
label="Select Pattern",
id="pattern-select",
value="blinker",
searchable=True,
clearable=False,
data=[
{
"value": pattern,
"label": pattern.replace("_", " ").title(),
}
for pattern in PatternLibrary().get_pattern_list() + ["random"]
],
),
dmc.Space(h=20),
dmc.NumberInput(
label="Max Iterations",
id="max-iterations",
value=10,
min=1,
type="number",
style={"width": "100%"},
),
dmc.Space(h=20),
html.Div("Grid Size"),
dmc.Slider(
id="grid-size",
value=20,
min=5,
max=100,
step=1,
),
dmc.Space(h=20),
dmc.Button(
"Start Simulation",
leftSection=html.Img(src=dash.get_asset_url("icons/mdi--play.svg")),
id="start-simulation",
variant="filled",
size="sm",
style={"width": "100%"},
),
dmc.Space(h=40),
dmc.Divider(
label="Simulation Status",
styles={"label": {"fontSize": "18px"}},
),
dmc.Group(
[
html.Div("Iteration:"),
dmc.Badge("0", id="iteration-counter", size="lg"),
dmc.Progress(
value=0,
id="progress-bar",
size="xl",
radius="xl",
style={"width": "100%"},
),
],
),
],
span=3,
),
dmc.GridCol(
dcc.Graph(
id="heatmap",
figure=go.Figure(
data=go.Heatmap(
zmin=0,
zmax=1,
colorscale=[[0, "black"], [1, "white"]],
xgap=1,
ygap=1,
showscale=False,
),
layout=go.Layout(
xaxis=dict(
visible=False,
scaleanchor="y",
),
yaxis=dict(visible=False, autorange="reversed"),
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="#222",
autosize=True,
margin=dict(l=0, r=0, t=0, b=0),
),
),
style={
"width": "800px",
"height": "600px",
"display": "block",
"marginLeft": "auto",
"marginRight": "auto",
},
config={"displayModeBar": False},
),
span=9,
style={
"display": "flex",
"justifyContent": "center",
"alignItems": "center",
},
),
],
justify="center",
style={
"margin": "0 auto",
"maxWidth": "1200px",
"width": "100%",
"paddingRight": "60px",
"paddingLeft": "20px",
},
),
DashClient.create_event_listener(step, stream_name="my-stream", id="update-heatmap"),
DashClient.create_event_listener(step, stream_name="simulate", id="termination"),
html.Div(id="notifications-container"),
],
style={"paddingLeft": "20px"},
)
Most of it is plain Dash and Mantine markup, documented outside SAF:
Dash HTML components for
html.Divand friends — one class per HTML tag.Dash core components for
dcc.Graph,dcc.Storeand the other interactive building blocks.Dash Mantine Components for the grid, the dropdown, the slider and the buttons used in this page.
Plotly heatmaps for the figure that renders the grid of cells.
The two SAF-specific lines are right at the end:
DashClient.create_event_listener(step, stream_name="my-stream", id="update-heatmap"),
DashClient.create_event_listener(step, stream_name="simulate", id="termination"),
Key concept — Event listener
DashClient.create_event_listener(step, stream_name=..., id=...) is the frontend
counterpart of the backend’s raise_event. It inserts an invisible component in the
layout that subscribes to a named backend stream and updates its message property
every time an event arrives.
The id you give it is what a callback targets with Input("<id>", "message"). One
listener per stream: here, one for the progress stream and one for the termination stream.
Practice
Replace the whole content of
game_of_life_page.pywith the complete listing above — imports,dash.register_pageandlayout. Remember to substitute thesaf.solutions.examplesnamespace andExamplesSolutionwith your own.The “Start Simulation” button uses an icon that the template does not ship. Download mdi–play.svg and drop it into
src/saf/solutions/game_of_life/ui/assets/icons/. Without it the button renders with a broken image.
Save the file and continue — the page is not usable until the callbacks are in place.
Callbacks 101#
Key concept — Callback
A callback is a function decorated with @callback that reacts to changes in the
application. It is described by three kinds of arguments:
Input: a value SAF watches. When it changes, the callback fires.State: a value SAF reads but does not watch.Output: a value SAF writes back to the browser once the callback returns.
Callbacks are where the frontend drives the backend — which makes them the frontend mirror of the backend’s transaction methods.
See Basic callbacks for Input and
Output, Dash app with state
for State, and Advanced callbacks for
everything beyond the basics.
A callback in Dash is a decorated function that reacts to changes in the UI:
@callback(
Output("some-component-id", "some-property"),
Input("triggering-component-id", "triggering-property"),
State("read-only-component-id", "read-only-property"),
)
def my_callback(triggering_value, read_only_value):
...
return new_value_for_the_output
Two rules to internalize:
The number of
Input+Statearguments in the decorator must match the number of function parameters. SAF passes you an extraprojectparameter automatically when the lastStateisState("url", "pathname").The number of
Outputentries in the decorator must match the number of items in thereturnstatement.
Tip
The mental model between a SAF backend @transaction and a Dash @callback is
intentionally similar:
SAF |
Dash |
|---|---|
|
|
|
|
Callback #1 — Preview the initial pattern#
Key concept — DashClient
The DashClient is the bridge between the Dash page and the SAF backend. Through the
typed project handle it exposes the step model as if it were a local object:
assigning to a field (
step.grid_size = 20) persists it in the backend;reading a field (
step.initial_grid_state) fetches its current value;calling a transaction (
step.display_initial_state()) executes it.
There is no requests.post anywhere in a SAF page; the client turns each of those
Pythonic operations into the corresponding REST API call under the hood.
Whenever the user picks a new pattern or moves the grid-size slider, you want the heatmap to
redraw immediately. There is no long computation involved — the blocking
display_initial_state transaction is perfect for the job.
@callback(
Output("heatmap", "figure", allow_duplicate=True),
Input("pattern-select", "value"),
Input("grid-size", "value"),
State("url", "pathname"),
)
def update_initial_pattern(pattern_select: str, grid_size: int, project: ExamplesSolution) -> Patch:
"""Update the initial pattern."""
step = project.steps.game_of_life_step
step.selected_pattern = pattern_select
step.grid_size = grid_size
step.display_initial_state()
patched_figure = Patch()
patched_figure["data"][0]["z"] = np.array(step.initial_grid_state)
return patched_figure
Notice the flow:
Access the step through
project.steps.game_of_life_step.Write the two input fields (
selected_pattern,grid_size) — SAF persists them.Invoke the transaction. Because it is blocking, execution pauses until it returns.
Read the output field (
initial_grid_state) and use it to patch the figure.
The interaction with the backend is entirely Pythonic. There is no requests.post call
anywhere; SAF turns the field assignments and the display_initial_state() call into REST
API calls under the hood.
Tip
dash.Patch returns a lightweight diff instead of a full figure. It keeps the payload
small when only one property changes. See
Partial property updates for the full list
of operations a Patch supports.
Callback #2 — Launch the long-running simulation#
Key concept — Calling a long-running transaction
Calling a @long_running transaction from a callback returns immediately: the
callback must not — and cannot — wait for the result. Its only job is to fire and forget
the transaction and to put the UI in its “running” state, typically by disabling the
controls that would start a second run.
Everything that happens afterwards is driven by events, in separate callbacks.
Clicking “Start simulation” must launch the long-running simulate transaction and disable
the controls until it terminates. Because simulate is decorated with @long_running, the
call returns immediately — you do not wait for the loop to finish here.
@callback(
Output("notifications-container", "children"),
Output("start-simulation", "loading"),
Output("start-simulation", "disabled"),
Output("max-iterations", "disabled"),
Output("grid-size", "disabled"),
Output("pattern-select", "disabled"),
Output("iteration-counter", "children", allow_duplicate=True),
Output("progress-bar", "value", allow_duplicate=True),
Input("start-simulation", "n_clicks"),
State("pattern-select", "value"),
State("max-iterations", "value"),
State("grid-size", "value"),
State("url", "pathname"),
prevent_initial_call=True,
)
def start_simulation(
start_simulation: int,
pattern: str,
max_iterations: int,
grid_size: int,
project: ExamplesSolution,
) -> tuple[dmc.Notification, bool, bool, bool, bool, bool, str, int]:
"""Trigger the simulation."""
step = project.steps.game_of_life_step
step.selected_pattern = pattern
step.max_iterations = max_iterations
step.grid_size = grid_size
try:
step.simulate()
except Exception as e:
return (
dmc.Notification(
title="Error",
message=str(e),
color="red",
id={"type": "notification", "index": "simulation-error"},
autoClose=False,
action="show",
),
False,
False,
False,
False,
False,
"0/{}".format(max_iterations),
0,
)
return (
dmc.Notification(
title="Simulation Started",
message=f"Simulation started with pattern: {pattern}",
color="lime",
id={"type": "notification", "index": "simulation-started"},
autoClose=True,
action="show",
),
True,
True,
True,
True,
True,
"0/{}".format(max_iterations),
0,
)
Two subtleties:
prevent_initial_call=Trueprevents the callback from firing when the page first loads — otherwise it would start a simulation nobody asked for. See Prevent callbacks from being executed on initial load.Because
step.simulate()is asynchronous, the callback exits after triggering it. All the live updates happen in the next two callbacks.
Callback #3 — React to progress events#
Key concept — Consuming an event
An event listener is just another callback Input: Input("<listener-id>",
"message"). The payload published by the backend arrives as a JSON string in
message["data"], so decode it with json.loads before use.
The callback fires once per event, so keep its body cheap.
Every time the backend calls self.transaction.raise_event(stream_name="my-stream", ...),
the event listener you declared in the layout fires. You wire a callback to it exactly like
you would with any UI event:
@callback(
Output("heatmap", "figure"),
Output("iteration-counter", "children"),
Output("progress-bar", "value"),
Input("update-heatmap", "message"),
State("max-iterations", "value"),
prevent_initial_call=True,
)
def update_graph(message: dict, max_iterations: int) -> tuple[Patch, str, int]:
"""Update the grid."""
data = json.loads(message.get("data"))
patched_figure = Patch()
patched_figure["data"][0]["z"] = np.array(data.get("grid"))
iteration = data.get("iteration", 0)
return (
patched_figure,
f"{iteration}/{max_iterations}",
iteration / max_iterations * 100,
)
The Input("update-heatmap", "message") matches the id="update-heatmap" on the event
listener component you created in the layout.
Tip
This callback fires once per event. On a grid of 20 x 20 cells running for 10 iterations, that means 11 callbacks: one for generation 0 (emitted before the loop) and one for each of the 10 iterations. Keep the callback body inexpensive.
Callback #4 — React to the termination event#
Key concept — get_method_state()
A termination event tells you that a long-running transaction is over, but not how it
ended. step.get_method_state("simulate").status gives you the terminal status:
Completed on success or Failed if the transaction raises. Use it to restore the
UI and show the corresponding notification.
The last callback listens to the simulate stream — the one automatically emitted because
you set enable_termination_event=True on the transaction. When it fires, the simulation
is over (either successfully or not) and it is time to re-enable the controls.
@callback(
Output("notifications-container", "children"),
Output("start-simulation", "loading"),
Output("start-simulation", "disabled"),
Output("max-iterations", "disabled"),
Output("grid-size", "disabled"),
Output("pattern-select", "disabled"),
Input("termination", "message"),
State("url", "pathname"),
prevent_initial_call=True,
)
def enable_new_simulation(
message: dict, project: ExamplesSolution
) -> tuple[dmc.Notification, bool, bool, bool, bool, bool]:
"""Enable starting a new simulation after termination."""
step = project.steps.game_of_life_step
status = step.get_method_state("simulate").status
if status == MethodStatus.Completed:
notification = dmc.Notification(
title="Simulation Completed",
message="The simulation has completed successfully.",
color="lime",
id={"type": "notification", "index": "simulation-completed"},
autoClose=True,
action="show",
)
elif status == MethodStatus.Failed:
notification = dmc.Notification(
title="Simulation Failed",
message="The simulation has failed or was terminated.",
color="red",
id={"type": "notification", "index": "simulation-failed"},
autoClose=True,
action="show",
)
return notification, False, False, False, False, False
Notice how step.get_method_state("simulate").status gives you the terminal status of the
transaction: Completed on success, Failed if it raised, or Terminated if it was
cancelled. Use it to show the right notification to the user.
The end-to-end flow#
sequenceDiagram
participant U as User
participant D as Dash callbacks
participant B as SAF backend
participant L as Event listeners
U->>D: Selects pattern
D->>B: display_initial_state() (blocking)
B-->>D: initial_grid_state
D-->>U: Heatmap updated
U->>D: Clicks "Start simulation"
D->>B: simulate() (long_running, returns immediately)
D-->>U: Buttons disabled + "Started" notification
loop for each generation
B->>L: raise_event(stream="my-stream")
L->>D: update_graph callback
D-->>U: Heatmap + counter update
end
B->>L: termination event (stream="simulate")
L->>D: enable_new_simulation callback
D-->>U: Buttons re-enabled + "Completed" notification
Run the solution#
You now have a complete SAF solution. Time to see it in action.
Practice
From the root of the game-of-life folder, run:
saf run --debug
A desktop window opens. Click Game of Life in the navigation tree on the left.
Pick different patterns from the dropdown. The heatmap updates instantly (that is
display_initial_statefiring).Drag the grid-size slider. Same behavior.
Click Start simulation. The controls grey out, a green notification pops up, and the heatmap animates generation by generation until it reaches
max_iterationsor every cell dies.Wait for the run to complete. A completion notification appears and the controls re-enable.
If something goes wrong, you have two places to look: the terminal, where SAF prints
tracebacks from failing transactions, and the Dash error overlay in the corner of the page,
which --debug enables to report callback errors in the UI itself. That overlay is part
of Dash dev tools, which also gives you a
callback graph showing the order and
duration of every callback. Common issues:
Missing field in
upload. The UI reads an emptyinitial_grid_state.Missing field in
download. The transaction sees a stale value.Wrong decorator order (
@long_runningabove@transaction). The method runs synchronously and blocks the UI.Missing
InputorStatein a@callback. The number of dependencies in the decorator no longer matches the number of function parameters, and Dash raises a mismatched-arguments error.Missing
Output, or areturnstatement that does not return as many values as there areOutputentries.Unknown component identifier. An
Input,StateorOutputthat points at anidabsent from the layout — including the event listener idsupdate-heatmapandtermination— makes the callback silently never fire.Typos in field, property or
idnames.step.grid_sizesinstead ofstep.grid_size, or"value"instead of"checked", fail at runtime only.Syntax errors in the page module. The page fails to import and disappears from the navigation tree altogether.
Key takeaways#
Important
The frontend interacts with the backend through the
DashClient. Assign to a field and it is persisted. Call a transaction method and it runs.Callbacks and transactions mirror each other:
Input/Statematchesdownload,Outputmatchesupload.Backend events are delivered to the frontend through
DashClient.create_event_listener(step, stream_name=..., id=...)components declared in the layout, and consumed with a regular@callback(Input("<id>", "message")).The
enable_termination_event=Trueflag on a@transactiongives you a “method finished” event on a stream named after the method. Perfect for re-enabling UI controls after a long-running run.Use
dash.Patch()to send only the diff of a figure or component tree back to the browser; it keeps the update payload small.Type hints on layout arguments (
project: GameOfLifeSolution) unlock IDE completion on step fields — a small effort with a big payoff.