Skip to content

API Reference

panel_flowdash

panel-flowdash: Dataflow and draggable grid based dashboard editor for Panel.

__all__ = ['ComponentSpec', 'DashboardEdge', 'DashboardItem', 'DashboardModel', 'DashboardStore', 'DataflowGraph', 'InputPort', 'OutputPort', 'PanelAppMetadata', 'RegistryEntry', '__version__', 'build_component_spec', 'build_component_specs', 'build_node_state_class', 'build_session_state_class', 'check_requirements', 'panel_app', 'register'] module-attribute

__version__ = importlib.metadata.version(__name__) module-attribute

panel_app = register module-attribute

ComponentSpec dataclass

Full specification of a component's ports and metadata.

component_id instance-attribute

default_size instance-attribute

description instance-attribute

icon instance-attribute

inputs instance-attribute

outputs instance-attribute

tags instance-attribute

title instance-attribute

DashboardEdge dataclass

A connection between two component ports.

source instance-attribute

source_port instance-attribute

target instance-attribute

target_port instance-attribute

from_dict(data) classmethod

to_dict()

DashboardItem dataclass

A component instance on the dashboard.

x, y store the ReactFlow node canvas position. Grid layout (widths, heights, visibility) lives in DashboardModel.tile_layout.

component_id instance-attribute

config = field(default_factory=dict) class-attribute instance-attribute

instance_id instance-attribute

x = 0 class-attribute instance-attribute

y = 0 class-attribute instance-attribute

from_dict(data) classmethod

to_dict()

DashboardModel dataclass

A persisted dashboard: nodes + edges + tile layout.

dashboard_id instance-attribute

edges = field(default_factory=list) class-attribute instance-attribute

items = field(default_factory=list) class-attribute instance-attribute

tile_layout = field(default_factory=list) class-attribute instance-attribute

title instance-attribute

user_id instance-attribute

version = 2 class-attribute instance-attribute

from_dict(data) classmethod

to_dict()

DashboardStore

SQLite-backed store for dashboard models.

create_dashboard(user_id, title)

delete_dashboard(user_id, dashboard_id)

list_dashboards(user_id)

load_dashboard(user_id, dashboard_id)

rename_dashboard(user_id, dashboard_id, new_title)

save_dashboard(dashboard)

DataflowGraph

Manages node state instances and wires edges with runtime validation.

edges property

All current edges.

node_ids property

All current node instance IDs.

add_edge(source_id, source_port, target_id, target_port)

Wire an edge between two ports.

Returns True on success, or an error message string on failure.

add_node(instance_id, component_id)

Create a new node state instance.

get_state(instance_id)

Get the state instance for a node.

remove_edge(source_id, source_port, target_id, target_port)

Remove an edge, unsubscribe the watcher, and reset target to default.

remove_node(instance_id)

Remove a node and any edges connected to it.

InputPort dataclass

Describes a single input port on a component node.

blocking = True class-attribute instance-attribute

default = None class-attribute instance-attribute

label = None class-attribute instance-attribute

name instance-attribute

required = True class-attribute instance-attribute

type = None class-attribute instance-attribute

OutputPort dataclass

Describes a single output port on a component node.

label = None class-attribute instance-attribute

name instance-attribute

type = None class-attribute instance-attribute

PanelAppMetadata dataclass

Metadata attached to a component or page by the @register decorator.

component = False class-attribute instance-attribute

config_schema = None class-attribute instance-attribute

default_size = None class-attribute instance-attribute

description = None class-attribute instance-attribute

icon = None class-attribute instance-attribute

max_size = None class-attribute instance-attribute

min_size = None class-attribute instance-attribute

page = True class-attribute instance-attribute

provides = field(default_factory=list) class-attribute instance-attribute

requires = field(default_factory=list) class-attribute instance-attribute

sidebar = False class-attribute instance-attribute

singleton = False class-attribute instance-attribute

tags = field(default_factory=list) class-attribute instance-attribute

title = None class-attribute instance-attribute

from_app(app) classmethod

Extract metadata from an app object.

RegistryEntry dataclass

A registered component/page with its metadata.

app instance-attribute

app_id instance-attribute

metadata instance-attribute

module_name instance-attribute

name instance-attribute

page_path instance-attribute

section instance-attribute

title property

Human-readable title.

build_component_spec(entry)

Build a ComponentSpec from a registry entry.

build_component_specs(registry)

Build specs for all component-enabled entries in a registry.

build_node_state_class(spec)

Create a Parameterized subclass with one param per port (inputs + outputs).

build_session_state_class(registry)

Build a Parameterized subclass with one param per declared state key.

Scans the registry for all provides and requires keys and creates a dynamic class whose parameters represent shared session state.

check_requirements(state, requires)

Check which required keys are unsatisfied on the given state instance.

Returns a list of dicts describing each unsatisfied requirement. An empty list means all requirements are met.

register(*, page=True, component=False, sidebar=False, title=None, icon=None, description=None, tags=None, default_size=None, min_size=None, max_size=None, singleton=False, provides=None, requires=None, config_schema=None)

Metadata-only decorator for app exports.

Annotates an app object/callable without altering runtime behavior.

__main__

Allow running as python -m panel_flowdash serve.

app

Application builder: scans a project directory and constructs the Panel app.

COMPONENTS_ROUTE = '/components' module-attribute

DASH_ROUTE_PREFIX = '/dash/' module-attribute

logger = logging.getLogger('panel_flowdash') module-attribute

build_app_class(project_dir, *, store, title='FlowDash', default_page=None)

Build a Viewer class configured for the given project directory.

Returns a class (not an instance) that can be used with pn.serve. The class also exposes a build_routes() classmethod for route generation.

build_registry(project_dir)

Scan a project directory for page/component modules.

Expects a structure like: project_dir/ SectionA/ page1.py (exports app) page2.py SectionB/ widget.py

Each .py file must export an app object decorated with @register.

command

Command-line interface for panel-flowdash.

main(args=None)

Entry point for the panel-flowdash CLI.

serve

The flowdash serve subcommand.

log = logging.getLogger(__name__) module-attribute
Serve

Bases: Serve

Serve a flowdash dashboard application from a project directory.

args = (('directory', Argument(metavar='DIRECTORY', help='Path to the project directory containing page/component modules.')), ('--db-path', Argument(action='store', type=str, default=None, help='Path to the SQLite database file. Defaults to <directory>/dashboards.db.')), ('--title', Argument(action='store', type=str, default='FlowDash', help='Application title shown in the browser tab.')), *((name, arg) for name, arg in (_PanelServe.args) if name not in _EXCLUDED_ARGS)) class-attribute instance-attribute
help = 'Launch the FlowDash dashboard server from a project directory.' class-attribute instance-attribute
name = 'serve' class-attribute instance-attribute
customize_applications(args, applications)
customize_kwargs(args, server_kwargs)
invoke(args)

component_spec

Component specification with typed ports for the dataflow editor.

ComponentSpec dataclass

Full specification of a component's ports and metadata.

component_id instance-attribute
default_size instance-attribute
description instance-attribute
icon instance-attribute
inputs instance-attribute
outputs instance-attribute
tags instance-attribute
title instance-attribute

InputPort dataclass

Describes a single input port on a component node.

blocking = True class-attribute instance-attribute
default = None class-attribute instance-attribute
label = None class-attribute instance-attribute
name instance-attribute
required = True class-attribute instance-attribute
type = None class-attribute instance-attribute

OutputPort dataclass

Describes a single output port on a component node.

label = None class-attribute instance-attribute
name instance-attribute
type = None class-attribute instance-attribute

build_component_spec(entry)

Build a ComponentSpec from a registry entry.

build_component_specs(registry)

Build specs for all component-enabled entries in a registry.

dashboard_store

SQLite-backed persistence for dashboard graphs.

DashboardEdge dataclass

A connection between two component ports.

source instance-attribute
source_port instance-attribute
target instance-attribute
target_port instance-attribute
from_dict(data) classmethod
to_dict()

DashboardItem dataclass

A component instance on the dashboard.

x, y store the ReactFlow node canvas position. Grid layout (widths, heights, visibility) lives in DashboardModel.tile_layout.

component_id instance-attribute
config = field(default_factory=dict) class-attribute instance-attribute
instance_id instance-attribute
x = 0 class-attribute instance-attribute
y = 0 class-attribute instance-attribute
from_dict(data) classmethod
to_dict()

DashboardModel dataclass

A persisted dashboard: nodes + edges + tile layout.

dashboard_id instance-attribute
edges = field(default_factory=list) class-attribute instance-attribute
items = field(default_factory=list) class-attribute instance-attribute
tile_layout = field(default_factory=list) class-attribute instance-attribute
title instance-attribute
user_id instance-attribute
version = 2 class-attribute instance-attribute
from_dict(data) classmethod
to_dict()

DashboardStore

SQLite-backed store for dashboard models.

create_dashboard(user_id, title)
delete_dashboard(user_id, dashboard_id)
list_dashboards(user_id)
load_dashboard(user_id, dashboard_id)
rename_dashboard(user_id, dashboard_id, new_title)
save_dashboard(dashboard)

dataflow_engine

Dataflow wiring engine with runtime validation.

Each node in the graph gets a NodeState (a dynamic Parameterized subclass) whose parameters correspond to the node's declared input and output ports. Edges are wired via param.watch: when a source port changes, the value is assigned to the target port inside a try/except so that runtime type errors (e.g. param validation failures) are caught and reported via an error callback.

DataflowGraph

Manages node state instances and wires edges with runtime validation.

edges property

All current edges.

node_ids property

All current node instance IDs.

add_edge(source_id, source_port, target_id, target_port)

Wire an edge between two ports.

Returns True on success, or an error message string on failure.

add_node(instance_id, component_id)

Create a new node state instance.

get_state(instance_id)

Get the state instance for a node.

remove_edge(source_id, source_port, target_id, target_port)

Remove an edge, unsubscribe the watcher, and reset target to default.

remove_node(instance_id)

Remove a node and any edges connected to it.

build_node_state_class(spec)

Create a Parameterized subclass with one param per port (inputs + outputs).

registry

Component registry: the register decorator and metadata model.

panel_app = register module-attribute

PanelAppMetadata dataclass

Metadata attached to a component or page by the @register decorator.

component = False class-attribute instance-attribute
config_schema = None class-attribute instance-attribute
default_size = None class-attribute instance-attribute
description = None class-attribute instance-attribute
icon = None class-attribute instance-attribute
max_size = None class-attribute instance-attribute
min_size = None class-attribute instance-attribute
page = True class-attribute instance-attribute
provides = field(default_factory=list) class-attribute instance-attribute
requires = field(default_factory=list) class-attribute instance-attribute
sidebar = False class-attribute instance-attribute
singleton = False class-attribute instance-attribute
tags = field(default_factory=list) class-attribute instance-attribute
title = None class-attribute instance-attribute
from_app(app) classmethod

Extract metadata from an app object.

RegistryEntry dataclass

A registered component/page with its metadata.

app instance-attribute
app_id instance-attribute
metadata instance-attribute
module_name instance-attribute
name instance-attribute
page_path instance-attribute
section instance-attribute
title property

Human-readable title.

register(*, page=True, component=False, sidebar=False, title=None, icon=None, description=None, tags=None, default_size=None, min_size=None, max_size=None, singleton=False, provides=None, requires=None, config_schema=None)

Metadata-only decorator for app exports.

Annotates an app object/callable without altering runtime behavior.

session_state

Per-session shared state built from registry provides/requires declarations.

build_session_state_class(registry)

Build a Parameterized subclass with one param per declared state key.

Scans the registry for all provides and requires keys and creates a dynamic class whose parameters represent shared session state.

check_requirements(state, requires)

Check which required keys are unsatisfied on the given state instance.

Returns a list of dicts describing each unsatisfied requirement. An empty list means all requirements are met.

Modules

Registry

panel_flowdash.registry

Component registry: the register decorator and metadata model.

_APP_METADATA_BY_ID = {} module-attribute

panel_app = register module-attribute

PanelAppMetadata dataclass

Metadata attached to a component or page by the @register decorator.

Source code in src/panel_flowdash/registry.py
@dataclass(frozen=True)
class PanelAppMetadata:
    """Metadata attached to a component or page by the @register decorator."""

    page: bool = True
    component: bool = False
    sidebar: bool = False
    title: str | None = None
    icon: str | None = None
    description: str | None = None
    tags: list[str] = field(default_factory=list)
    default_size: dict[str, Any] | None = None
    min_size: dict[str, Any] | None = None
    max_size: dict[str, Any] | None = None
    singleton: bool = False
    provides: list[str] = field(default_factory=list)
    requires: list[Any] = field(default_factory=list)
    config_schema: dict[str, Any] | None = None

    @classmethod
    def from_app(cls, app: Any) -> PanelAppMetadata:
        """Extract metadata from an app object."""
        metadata = getattr(app, "__panel_app_metadata__", None)
        if metadata is None:
            metadata = _APP_METADATA_BY_ID.get(id(app))
        if metadata is None:
            return cls()
        if isinstance(metadata, cls):
            return metadata
        if isinstance(metadata, dict):
            return cls(**metadata)
        raise TypeError("Unsupported panel app metadata type.")

component = False class-attribute instance-attribute

config_schema = None class-attribute instance-attribute

default_size = None class-attribute instance-attribute

description = None class-attribute instance-attribute

icon = None class-attribute instance-attribute

max_size = None class-attribute instance-attribute

min_size = None class-attribute instance-attribute

page = True class-attribute instance-attribute

provides = field(default_factory=list) class-attribute instance-attribute

requires = field(default_factory=list) class-attribute instance-attribute

sidebar = False class-attribute instance-attribute

singleton = False class-attribute instance-attribute

tags = field(default_factory=list) class-attribute instance-attribute

title = None class-attribute instance-attribute

from_app(app) classmethod

Extract metadata from an app object.

Source code in src/panel_flowdash/registry.py
@classmethod
def from_app(cls, app: Any) -> PanelAppMetadata:
    """Extract metadata from an app object."""
    metadata = getattr(app, "__panel_app_metadata__", None)
    if metadata is None:
        metadata = _APP_METADATA_BY_ID.get(id(app))
    if metadata is None:
        return cls()
    if isinstance(metadata, cls):
        return metadata
    if isinstance(metadata, dict):
        return cls(**metadata)
    raise TypeError("Unsupported panel app metadata type.")

RegistryEntry dataclass

A registered component/page with its metadata.

Source code in src/panel_flowdash/registry.py
@dataclass(frozen=True)
class RegistryEntry:
    """A registered component/page with its metadata."""

    app_id: str
    section: str
    name: str
    page_path: str
    module_name: str
    app: Any
    metadata: PanelAppMetadata

    @property
    def title(self) -> str:
        """Human-readable title."""
        return self.metadata.title or self.name.replace("_", " ")

app instance-attribute

app_id instance-attribute

metadata instance-attribute

module_name instance-attribute

name instance-attribute

page_path instance-attribute

section instance-attribute

title property

Human-readable title.

register(*, page=True, component=False, sidebar=False, title=None, icon=None, description=None, tags=None, default_size=None, min_size=None, max_size=None, singleton=False, provides=None, requires=None, config_schema=None)

Metadata-only decorator for app exports.

Annotates an app object/callable without altering runtime behavior.

Source code in src/panel_flowdash/registry.py
def register(
    *,
    page: bool = True,
    component: bool = False,
    sidebar: bool = False,
    title: str | None = None,
    icon: str | None = None,
    description: str | None = None,
    tags: list[str] | None = None,
    default_size: dict[str, Any] | None = None,
    min_size: dict[str, Any] | None = None,
    max_size: dict[str, Any] | None = None,
    singleton: bool = False,
    provides: list[str] | None = None,
    requires: list[Any] | None = None,
    config_schema: dict[str, Any] | None = None,
):
    """Metadata-only decorator for app exports.

    Annotates an app object/callable without altering runtime behavior.
    """
    metadata = PanelAppMetadata(
        page=page,
        component=component,
        sidebar=sidebar,
        title=title,
        icon=icon,
        description=description,
        tags=list(tags or []),
        default_size=default_size,
        min_size=min_size,
        max_size=max_size,
        singleton=singleton,
        provides=list(provides or []),
        requires=list(requires or []),
        config_schema=config_schema,
    )

    def _decorator(app):
        _APP_METADATA_BY_ID[id(app)] = metadata
        try:
            app.__panel_app_metadata__ = metadata
        except Exception:
            pass
        return app

    return _decorator

Component Spec

panel_flowdash.component_spec

Component specification with typed ports for the dataflow editor.

_BASE_PARAMS = set(param.Parameterized.param) module-attribute

ComponentSpec dataclass

Full specification of a component's ports and metadata.

Source code in src/panel_flowdash/component_spec.py
@dataclass(frozen=True)
class ComponentSpec:
    """Full specification of a component's ports and metadata."""

    component_id: str
    title: str
    description: str | None
    icon: str | None
    tags: list[str]
    outputs: list[OutputPort]
    inputs: list[InputPort]
    default_size: dict[str, Any] | None

component_id instance-attribute

default_size instance-attribute

description instance-attribute

icon instance-attribute

inputs instance-attribute

outputs instance-attribute

tags instance-attribute

title instance-attribute

InputPort dataclass

Describes a single input port on a component node.

Source code in src/panel_flowdash/component_spec.py
@dataclass(frozen=True)
class InputPort:
    """Describes a single input port on a component node."""

    name: str
    type: str | None = None
    label: str | None = None
    required: bool = True
    blocking: bool = True
    default: Any = None

blocking = True class-attribute instance-attribute

default = None class-attribute instance-attribute

label = None class-attribute instance-attribute

name instance-attribute

required = True class-attribute instance-attribute

type = None class-attribute instance-attribute

OutputPort dataclass

Describes a single output port on a component node.

Source code in src/panel_flowdash/component_spec.py
@dataclass(frozen=True)
class OutputPort:
    """Describes a single output port on a component node."""

    name: str
    type: str | None = None
    label: str | None = None

label = None class-attribute instance-attribute

name instance-attribute

type = None class-attribute instance-attribute

PanelAppMetadata dataclass

Metadata attached to a component or page by the @register decorator.

Source code in src/panel_flowdash/registry.py
@dataclass(frozen=True)
class PanelAppMetadata:
    """Metadata attached to a component or page by the @register decorator."""

    page: bool = True
    component: bool = False
    sidebar: bool = False
    title: str | None = None
    icon: str | None = None
    description: str | None = None
    tags: list[str] = field(default_factory=list)
    default_size: dict[str, Any] | None = None
    min_size: dict[str, Any] | None = None
    max_size: dict[str, Any] | None = None
    singleton: bool = False
    provides: list[str] = field(default_factory=list)
    requires: list[Any] = field(default_factory=list)
    config_schema: dict[str, Any] | None = None

    @classmethod
    def from_app(cls, app: Any) -> PanelAppMetadata:
        """Extract metadata from an app object."""
        metadata = getattr(app, "__panel_app_metadata__", None)
        if metadata is None:
            metadata = _APP_METADATA_BY_ID.get(id(app))
        if metadata is None:
            return cls()
        if isinstance(metadata, cls):
            return metadata
        if isinstance(metadata, dict):
            return cls(**metadata)
        raise TypeError("Unsupported panel app metadata type.")

component = False class-attribute instance-attribute

config_schema = None class-attribute instance-attribute

default_size = None class-attribute instance-attribute

description = None class-attribute instance-attribute

icon = None class-attribute instance-attribute

max_size = None class-attribute instance-attribute

min_size = None class-attribute instance-attribute

page = True class-attribute instance-attribute

provides = field(default_factory=list) class-attribute instance-attribute

requires = field(default_factory=list) class-attribute instance-attribute

sidebar = False class-attribute instance-attribute

singleton = False class-attribute instance-attribute

tags = field(default_factory=list) class-attribute instance-attribute

title = None class-attribute instance-attribute

from_app(app) classmethod

Extract metadata from an app object.

Source code in src/panel_flowdash/registry.py
@classmethod
def from_app(cls, app: Any) -> PanelAppMetadata:
    """Extract metadata from an app object."""
    metadata = getattr(app, "__panel_app_metadata__", None)
    if metadata is None:
        metadata = _APP_METADATA_BY_ID.get(id(app))
    if metadata is None:
        return cls()
    if isinstance(metadata, cls):
        return metadata
    if isinstance(metadata, dict):
        return cls(**metadata)
    raise TypeError("Unsupported panel app metadata type.")

RegistryEntry dataclass

A registered component/page with its metadata.

Source code in src/panel_flowdash/registry.py
@dataclass(frozen=True)
class RegistryEntry:
    """A registered component/page with its metadata."""

    app_id: str
    section: str
    name: str
    page_path: str
    module_name: str
    app: Any
    metadata: PanelAppMetadata

    @property
    def title(self) -> str:
        """Human-readable title."""
        return self.metadata.title or self.name.replace("_", " ")

app instance-attribute

app_id instance-attribute

metadata instance-attribute

module_name instance-attribute

name instance-attribute

page_path instance-attribute

section instance-attribute

title property

Human-readable title.

_ports_from_metadata(metadata)

Source code in src/panel_flowdash/component_spec.py
def _ports_from_metadata(
    metadata: PanelAppMetadata,
) -> tuple[list[OutputPort], list[InputPort]]:
    outputs = []
    for item in metadata.provides:
        if isinstance(item, str):
            outputs.append(OutputPort(name=item))
        elif isinstance(item, dict):
            outputs.append(
                OutputPort(
                    name=item["key"],
                    type=item.get("type"),
                    label=item.get("label"),
                )
            )

    inputs = []
    for item in metadata.requires:
        if isinstance(item, str):
            inputs.append(InputPort(name=item))
        elif isinstance(item, dict):
            inputs.append(
                InputPort(
                    name=item.get("key", ""),
                    type=item.get("type"),
                    label=item.get("label"),
                    required=item.get("required", True),
                    blocking=item.get("blocking", True),
                    default=item.get("fallback"),
                )
            )

    return outputs, inputs

_ports_from_viewer_class(viewer_cls)

Source code in src/panel_flowdash/component_spec.py
def _ports_from_viewer_class(
    viewer_cls: type,
) -> tuple[list[OutputPort], list[InputPort]]:
    instance = viewer_cls()
    output_info = instance.param.outputs()

    outputs = []
    for name, (ptype, _method, _index) in output_info.items():
        if ptype is None:
            type_str = None
        elif isinstance(ptype, type):
            type_str = ptype.__name__
        else:
            type_str = type(ptype).__name__
        outputs.append(OutputPort(name=name, type=type_str))

    inputs = []
    for pname, p in viewer_cls.param.objects("existing").items():
        if pname in _BASE_PARAMS or pname.startswith("_"):
            continue
        type_str = type(p).__name__ if p else None
        inputs.append(
            InputPort(
                name=pname,
                type=type_str,
                required=False,
                blocking=False,
            )
        )

    return outputs, inputs

build_component_spec(entry)

Build a ComponentSpec from a registry entry.

Source code in src/panel_flowdash/component_spec.py
def build_component_spec(entry: RegistryEntry) -> ComponentSpec:
    """Build a ComponentSpec from a registry entry."""
    app = entry.app
    metadata = entry.metadata

    if isinstance(app, type) and issubclass(app, Viewer):
        outputs, inputs = _ports_from_viewer_class(app)
        dec_outputs, dec_inputs = _ports_from_metadata(metadata)
        if dec_outputs:
            outputs = dec_outputs
        if dec_inputs:
            inputs = dec_inputs
    else:
        outputs, inputs = _ports_from_metadata(metadata)

    return ComponentSpec(
        component_id=entry.app_id,
        title=entry.title,
        description=metadata.description,
        icon=metadata.icon,
        tags=metadata.tags,
        outputs=outputs,
        inputs=inputs,
        default_size=metadata.default_size,
    )

build_component_specs(registry)

Build specs for all component-enabled entries in a registry.

Source code in src/panel_flowdash/component_spec.py
def build_component_specs(
    registry: dict[str, RegistryEntry],
) -> dict[str, ComponentSpec]:
    """Build specs for all component-enabled entries in a registry."""
    specs = {}
    for app_id, entry in registry.items():
        if entry.metadata.component:
            specs[app_id] = build_component_spec(entry)
    return specs

Dataflow Engine

panel_flowdash.dataflow_engine

Dataflow wiring engine with runtime validation.

Each node in the graph gets a NodeState (a dynamic Parameterized subclass) whose parameters correspond to the node's declared input and output ports. Edges are wired via param.watch: when a source port changes, the value is assigned to the target port inside a try/except so that runtime type errors (e.g. param validation failures) are caught and reported via an error callback.

_RESERVED_PARAMS = set(param.Parameterized.param) module-attribute

ComponentSpec dataclass

Full specification of a component's ports and metadata.

Source code in src/panel_flowdash/component_spec.py
@dataclass(frozen=True)
class ComponentSpec:
    """Full specification of a component's ports and metadata."""

    component_id: str
    title: str
    description: str | None
    icon: str | None
    tags: list[str]
    outputs: list[OutputPort]
    inputs: list[InputPort]
    default_size: dict[str, Any] | None

component_id instance-attribute

default_size instance-attribute

description instance-attribute

icon instance-attribute

inputs instance-attribute

outputs instance-attribute

tags instance-attribute

title instance-attribute

DataflowGraph

Manages node state instances and wires edges with runtime validation.

Source code in src/panel_flowdash/dataflow_engine.py
class DataflowGraph:
    """Manages node state instances and wires edges with runtime validation."""

    def __init__(
        self,
        specs: dict[str, ComponentSpec],
        on_error: Callable[[str, str, str, str, Exception], None] | None = None,
    ):
        """Initialize the dataflow graph.

        Parameters
        ----------
        specs
            Component specs keyed by component_id.
        on_error
            Callback invoked when a runtime value assignment fails.
            Signature: (source_id, source_port, target_id, target_port, exception)
        """
        self._specs = specs
        self._state_classes: dict[str, type[param.Parameterized]] = {}
        self._nodes: dict[str, param.Parameterized] = {}
        self._node_specs: dict[str, ComponentSpec] = {}
        self._edges: list[dict[str, str]] = []
        self._watchers: dict[tuple, param.parameterized.Watcher] = {}
        self._on_error = on_error

        for comp_id, spec in specs.items():
            self._state_classes[comp_id] = build_node_state_class(spec)

    def add_node(self, instance_id: str, component_id: str) -> param.Parameterized:
        """Create a new node state instance."""
        spec = self._specs[component_id]
        cls = self._state_classes[component_id]
        state = cls(name=instance_id)
        self._nodes[instance_id] = state
        self._node_specs[instance_id] = spec
        return state

    def remove_node(self, instance_id: str):
        """Remove a node and any edges connected to it."""
        keys_to_remove = [k for k in self._watchers if k[0] == instance_id or k[2] == instance_id]
        for key in keys_to_remove:
            watcher = self._watchers.pop(key)
            src = self._nodes.get(key[0])
            if src is not None:
                src.param.unwatch(watcher)

        self._edges = [
            e for e in self._edges if e["source"] != instance_id and e["target"] != instance_id
        ]
        self._nodes.pop(instance_id, None)
        self._node_specs.pop(instance_id, None)

    def add_edge(
        self,
        source_id: str,
        source_port: str,
        target_id: str,
        target_port: str,
    ) -> bool | str:
        """Wire an edge between two ports.

        Returns True on success, or an error message string on failure.
        """
        source_state = self._nodes.get(source_id)
        target_state = self._nodes.get(target_id)
        if source_state is None or target_state is None:
            return "Source or target node not found."
        if not hasattr(source_state.param, source_port):
            return f"Output port '{source_port}' does not exist on source node."
        if not hasattr(target_state.param, target_port):
            return f"Input port '{target_port}' does not exist on target node."

        for e in self._edges:
            if e["target"] == target_id and e["target_port"] == target_port:
                return f"Input '{target_port}' already has a connection. Disconnect it first."

        if self._would_create_cycle(source_id, target_id):
            return "Connection rejected: would create a cycle."

        source_spec = self._node_specs.get(source_id)
        target_spec = self._node_specs.get(target_id)
        if source_spec and target_spec:
            error = self._check_type_compatibility(
                source_spec, source_port, target_spec, target_port
            )
            if error:
                return error

        def _propagate(
            event,
            _src_id=source_id,
            _src_port=source_port,
            _tgt_id=target_id,
            _tgt_port=target_port,
            _target=target_state,
        ):
            try:
                setattr(_target, _tgt_port, event.new)
            except Exception as exc:
                if self._on_error:
                    self._on_error(_src_id, _src_port, _tgt_id, _tgt_port, exc)

        watcher = source_state.param.watch(_propagate, source_port)
        edge_key = (source_id, source_port, target_id, target_port)
        self._watchers[edge_key] = watcher

        current = getattr(source_state, source_port)
        if current is not None:
            try:
                setattr(target_state, target_port, current)
            except Exception as exc:
                if self._on_error:
                    self._on_error(source_id, source_port, target_id, target_port, exc)

        self._edges.append(
            {
                "source": source_id,
                "source_port": source_port,
                "target": target_id,
                "target_port": target_port,
            }
        )
        return True

    def _would_create_cycle(self, source_id: str, target_id: str) -> bool:
        """Return True if adding an edge from source to target would create a cycle."""
        if source_id == target_id:
            return True
        visited = set()
        queue = [target_id]
        while queue:
            node = queue.pop(0)
            if node == source_id:
                return True
            if node in visited:
                continue
            visited.add(node)
            for e in self._edges:
                if e["source"] == node:
                    queue.append(e["target"])
        return False

    def _check_type_compatibility(
        self,
        source_spec: ComponentSpec,
        source_port: str,
        target_spec: ComponentSpec,
        target_port: str,
    ) -> str | None:
        """Return an error message if types are incompatible, None if OK."""
        source_type = None
        for port in source_spec.outputs:
            if port.name == source_port:
                source_type = port.type
                break

        target_type = None
        for port in target_spec.inputs:
            if port.name == target_port:
                target_type = port.type
                break

        if source_type is None or target_type is None:
            return None

        if source_type.lower() == target_type.lower():
            return None

        return (
            f"Type mismatch: output '{source_port}' produces '{source_type}' "
            f"but input '{target_port}' expects '{target_type}'."
        )

    def remove_edge(self, source_id: str, source_port: str, target_id: str, target_port: str):
        """Remove an edge, unsubscribe the watcher, and reset target to default."""
        self._edges = [
            e
            for e in self._edges
            if not (
                e["source"] == source_id
                and e["source_port"] == source_port
                and e["target"] == target_id
                and e["target_port"] == target_port
            )
        ]

        edge_key = (source_id, source_port, target_id, target_port)
        watcher = self._watchers.pop(edge_key, None)
        if watcher is not None:
            source_state = self._nodes.get(source_id)
            if source_state is not None:
                source_state.param.unwatch(watcher)

        target_state = self._nodes.get(target_id)
        if target_state is not None and hasattr(target_state, target_port):
            spec = self._node_specs.get(target_id)
            default = None
            if spec:
                for port in spec.inputs:
                    if port.name == target_port:
                        default = port.default
                        break
            setattr(target_state, target_port, default)

    def get_state(self, instance_id: str) -> param.Parameterized | None:
        """Get the state instance for a node."""
        return self._nodes.get(instance_id)

    @property
    def edges(self) -> list[dict[str, str]]:
        """All current edges."""
        return list(self._edges)

    @property
    def node_ids(self) -> list[str]:
        """All current node instance IDs."""
        return list(self._nodes.keys())

edges property

All current edges.

node_ids property

All current node instance IDs.

add_edge(source_id, source_port, target_id, target_port)

Wire an edge between two ports.

Returns True on success, or an error message string on failure.

Source code in src/panel_flowdash/dataflow_engine.py
def add_edge(
    self,
    source_id: str,
    source_port: str,
    target_id: str,
    target_port: str,
) -> bool | str:
    """Wire an edge between two ports.

    Returns True on success, or an error message string on failure.
    """
    source_state = self._nodes.get(source_id)
    target_state = self._nodes.get(target_id)
    if source_state is None or target_state is None:
        return "Source or target node not found."
    if not hasattr(source_state.param, source_port):
        return f"Output port '{source_port}' does not exist on source node."
    if not hasattr(target_state.param, target_port):
        return f"Input port '{target_port}' does not exist on target node."

    for e in self._edges:
        if e["target"] == target_id and e["target_port"] == target_port:
            return f"Input '{target_port}' already has a connection. Disconnect it first."

    if self._would_create_cycle(source_id, target_id):
        return "Connection rejected: would create a cycle."

    source_spec = self._node_specs.get(source_id)
    target_spec = self._node_specs.get(target_id)
    if source_spec and target_spec:
        error = self._check_type_compatibility(
            source_spec, source_port, target_spec, target_port
        )
        if error:
            return error

    def _propagate(
        event,
        _src_id=source_id,
        _src_port=source_port,
        _tgt_id=target_id,
        _tgt_port=target_port,
        _target=target_state,
    ):
        try:
            setattr(_target, _tgt_port, event.new)
        except Exception as exc:
            if self._on_error:
                self._on_error(_src_id, _src_port, _tgt_id, _tgt_port, exc)

    watcher = source_state.param.watch(_propagate, source_port)
    edge_key = (source_id, source_port, target_id, target_port)
    self._watchers[edge_key] = watcher

    current = getattr(source_state, source_port)
    if current is not None:
        try:
            setattr(target_state, target_port, current)
        except Exception as exc:
            if self._on_error:
                self._on_error(source_id, source_port, target_id, target_port, exc)

    self._edges.append(
        {
            "source": source_id,
            "source_port": source_port,
            "target": target_id,
            "target_port": target_port,
        }
    )
    return True

add_node(instance_id, component_id)

Create a new node state instance.

Source code in src/panel_flowdash/dataflow_engine.py
def add_node(self, instance_id: str, component_id: str) -> param.Parameterized:
    """Create a new node state instance."""
    spec = self._specs[component_id]
    cls = self._state_classes[component_id]
    state = cls(name=instance_id)
    self._nodes[instance_id] = state
    self._node_specs[instance_id] = spec
    return state

get_state(instance_id)

Get the state instance for a node.

Source code in src/panel_flowdash/dataflow_engine.py
def get_state(self, instance_id: str) -> param.Parameterized | None:
    """Get the state instance for a node."""
    return self._nodes.get(instance_id)

remove_edge(source_id, source_port, target_id, target_port)

Remove an edge, unsubscribe the watcher, and reset target to default.

Source code in src/panel_flowdash/dataflow_engine.py
def remove_edge(self, source_id: str, source_port: str, target_id: str, target_port: str):
    """Remove an edge, unsubscribe the watcher, and reset target to default."""
    self._edges = [
        e
        for e in self._edges
        if not (
            e["source"] == source_id
            and e["source_port"] == source_port
            and e["target"] == target_id
            and e["target_port"] == target_port
        )
    ]

    edge_key = (source_id, source_port, target_id, target_port)
    watcher = self._watchers.pop(edge_key, None)
    if watcher is not None:
        source_state = self._nodes.get(source_id)
        if source_state is not None:
            source_state.param.unwatch(watcher)

    target_state = self._nodes.get(target_id)
    if target_state is not None and hasattr(target_state, target_port):
        spec = self._node_specs.get(target_id)
        default = None
        if spec:
            for port in spec.inputs:
                if port.name == target_port:
                    default = port.default
                    break
        setattr(target_state, target_port, default)

remove_node(instance_id)

Remove a node and any edges connected to it.

Source code in src/panel_flowdash/dataflow_engine.py
def remove_node(self, instance_id: str):
    """Remove a node and any edges connected to it."""
    keys_to_remove = [k for k in self._watchers if k[0] == instance_id or k[2] == instance_id]
    for key in keys_to_remove:
        watcher = self._watchers.pop(key)
        src = self._nodes.get(key[0])
        if src is not None:
            src.param.unwatch(watcher)

    self._edges = [
        e for e in self._edges if e["source"] != instance_id and e["target"] != instance_id
    ]
    self._nodes.pop(instance_id, None)
    self._node_specs.pop(instance_id, None)

build_node_state_class(spec)

Create a Parameterized subclass with one param per port (inputs + outputs).

Source code in src/panel_flowdash/dataflow_engine.py
def build_node_state_class(spec: ComponentSpec) -> type[param.Parameterized]:
    """Create a Parameterized subclass with one param per port (inputs + outputs)."""
    params: dict[str, param.Parameter] = {}

    for port in spec.inputs:
        if port.name in _RESERVED_PARAMS:
            continue
        params[port.name] = param.Parameter(default=port.default, allow_None=True, allow_refs=True)

    for port in spec.outputs:
        if port.name in _RESERVED_PARAMS:
            continue
        if port.name not in params:
            params[port.name] = param.Parameter(
                default=None,
                allow_None=True,
                allow_refs=True,
            )

    class_name = f"NodeState_{spec.component_id.replace('/', '_')}"
    return type(class_name, (param.Parameterized,), params)

Dashboard Store

panel_flowdash.dashboard_store

SQLite-backed persistence for dashboard graphs.

DashboardEdge dataclass

A connection between two component ports.

Source code in src/panel_flowdash/dashboard_store.py
@dataclass
class DashboardEdge:
    """A connection between two component ports."""

    source: str
    source_port: str
    target: str
    target_port: str

    def to_dict(self) -> dict[str, str]:
        return {
            "source": self.source,
            "source_port": self.source_port,
            "target": self.target,
            "target_port": self.target_port,
        }

    @classmethod
    def from_dict(cls, data: dict[str, str]) -> DashboardEdge:
        return cls(
            source=data["source"],
            source_port=data["source_port"],
            target=data["target"],
            target_port=data["target_port"],
        )

source instance-attribute

source_port instance-attribute

target instance-attribute

target_port instance-attribute

from_dict(data) classmethod

Source code in src/panel_flowdash/dashboard_store.py
@classmethod
def from_dict(cls, data: dict[str, str]) -> DashboardEdge:
    return cls(
        source=data["source"],
        source_port=data["source_port"],
        target=data["target"],
        target_port=data["target_port"],
    )

to_dict()

Source code in src/panel_flowdash/dashboard_store.py
def to_dict(self) -> dict[str, str]:
    return {
        "source": self.source,
        "source_port": self.source_port,
        "target": self.target,
        "target_port": self.target_port,
    }

DashboardItem dataclass

A component instance on the dashboard.

x, y store the ReactFlow node canvas position. Grid layout (widths, heights, visibility) lives in DashboardModel.tile_layout.

Source code in src/panel_flowdash/dashboard_store.py
@dataclass
class DashboardItem:
    """A component instance on the dashboard.

    x, y store the ReactFlow node canvas position.
    Grid layout (widths, heights, visibility) lives in DashboardModel.tile_layout.
    """

    instance_id: str
    component_id: str
    x: float = 0
    y: float = 0
    config: dict[str, Any] = field(default_factory=dict)

    def to_dict(self) -> dict[str, Any]:
        return {
            "instance_id": self.instance_id,
            "component_id": self.component_id,
            "x": self.x,
            "y": self.y,
            "config": self.config,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> DashboardItem:
        return cls(
            instance_id=data["instance_id"],
            component_id=data["component_id"],
            x=data.get("x", 0),
            y=data.get("y", 0),
            config=data.get("config", {}),
        )

component_id instance-attribute

config = field(default_factory=dict) class-attribute instance-attribute

instance_id instance-attribute

x = 0 class-attribute instance-attribute

y = 0 class-attribute instance-attribute

from_dict(data) classmethod

Source code in src/panel_flowdash/dashboard_store.py
@classmethod
def from_dict(cls, data: dict[str, Any]) -> DashboardItem:
    return cls(
        instance_id=data["instance_id"],
        component_id=data["component_id"],
        x=data.get("x", 0),
        y=data.get("y", 0),
        config=data.get("config", {}),
    )

to_dict()

Source code in src/panel_flowdash/dashboard_store.py
def to_dict(self) -> dict[str, Any]:
    return {
        "instance_id": self.instance_id,
        "component_id": self.component_id,
        "x": self.x,
        "y": self.y,
        "config": self.config,
    }

DashboardModel dataclass

A persisted dashboard: nodes + edges + tile layout.

Source code in src/panel_flowdash/dashboard_store.py
@dataclass
class DashboardModel:
    """A persisted dashboard: nodes + edges + tile layout."""

    dashboard_id: str
    user_id: str
    title: str
    version: int = 2
    items: list[DashboardItem] = field(default_factory=list)
    edges: list[DashboardEdge] = field(default_factory=list)
    tile_layout: list[dict[str, Any]] = field(default_factory=list)

    def to_dict(self) -> dict[str, Any]:
        return {
            "version": self.version,
            "dashboard_id": self.dashboard_id,
            "user_id": self.user_id,
            "title": self.title,
            "items": [item.to_dict() for item in self.items],
            "edges": [edge.to_dict() for edge in self.edges],
            "tile_layout": self.tile_layout,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> DashboardModel:
        return cls(
            dashboard_id=data["dashboard_id"],
            user_id=data["user_id"],
            title=data["title"],
            version=data.get("version", 1),
            items=[DashboardItem.from_dict(i) for i in data.get("items", [])],
            edges=[DashboardEdge.from_dict(e) for e in data.get("edges", [])],
            tile_layout=data.get("tile_layout", []),
        )

dashboard_id instance-attribute

edges = field(default_factory=list) class-attribute instance-attribute

items = field(default_factory=list) class-attribute instance-attribute

tile_layout = field(default_factory=list) class-attribute instance-attribute

title instance-attribute

user_id instance-attribute

version = 2 class-attribute instance-attribute

from_dict(data) classmethod

Source code in src/panel_flowdash/dashboard_store.py
@classmethod
def from_dict(cls, data: dict[str, Any]) -> DashboardModel:
    return cls(
        dashboard_id=data["dashboard_id"],
        user_id=data["user_id"],
        title=data["title"],
        version=data.get("version", 1),
        items=[DashboardItem.from_dict(i) for i in data.get("items", [])],
        edges=[DashboardEdge.from_dict(e) for e in data.get("edges", [])],
        tile_layout=data.get("tile_layout", []),
    )

to_dict()

Source code in src/panel_flowdash/dashboard_store.py
def to_dict(self) -> dict[str, Any]:
    return {
        "version": self.version,
        "dashboard_id": self.dashboard_id,
        "user_id": self.user_id,
        "title": self.title,
        "items": [item.to_dict() for item in self.items],
        "edges": [edge.to_dict() for edge in self.edges],
        "tile_layout": self.tile_layout,
    }

DashboardStore

SQLite-backed store for dashboard models.

Source code in src/panel_flowdash/dashboard_store.py
class DashboardStore:
    """SQLite-backed store for dashboard models."""

    def __init__(self, db_path: str | Path):
        self._db_path = str(db_path)
        self._init_db()

    @contextmanager
    def _get_conn(self):
        conn = sqlite3.connect(self._db_path)
        conn.execute("PRAGMA journal_mode=WAL")
        conn.row_factory = sqlite3.Row
        try:
            yield conn
            conn.commit()
        except Exception:
            conn.rollback()
            raise
        finally:
            conn.close()

    def _init_db(self):
        with self._get_conn() as conn:
            conn.execute("""
                CREATE TABLE IF NOT EXISTS dashboards (
                    dashboard_id TEXT PRIMARY KEY,
                    user_id TEXT NOT NULL,
                    title TEXT NOT NULL,
                    version INTEGER NOT NULL DEFAULT 1,
                    items_json TEXT NOT NULL DEFAULT '[]',
                    edges_json TEXT NOT NULL DEFAULT '[]',
                    tile_layout_json TEXT NOT NULL DEFAULT '[]',
                    created_at TEXT NOT NULL DEFAULT (datetime('now')),
                    updated_at TEXT NOT NULL DEFAULT (datetime('now'))
                )
            """)
            conn.execute("""
                CREATE INDEX IF NOT EXISTS idx_dashboards_user
                ON dashboards (user_id)
            """)
            for col, default in [("edges_json", "'[]'"), ("tile_layout_json", "'[]'")]:
                try:
                    conn.execute(
                        f"ALTER TABLE dashboards ADD COLUMN {col} TEXT NOT NULL DEFAULT {default}"
                    )
                except sqlite3.OperationalError:
                    pass

    def list_dashboards(self, user_id: str) -> list[DashboardModel]:
        with self._get_conn() as conn:
            rows = conn.execute(
                "SELECT * FROM dashboards WHERE user_id = ? ORDER BY updated_at DESC",
                (user_id,),
            ).fetchall()
        return [self._row_to_model(row) for row in rows]

    def load_dashboard(self, user_id: str, dashboard_id: str) -> DashboardModel | None:
        with self._get_conn() as conn:
            row = conn.execute(
                "SELECT * FROM dashboards WHERE dashboard_id = ? AND user_id = ?",
                (dashboard_id, user_id),
            ).fetchone()
        if row is None:
            return None
        return self._row_to_model(row)

    def save_dashboard(self, dashboard: DashboardModel) -> None:
        items_json = json.dumps([item.to_dict() for item in dashboard.items])
        edges_json = json.dumps([edge.to_dict() for edge in dashboard.edges])
        tile_layout_json = json.dumps(dashboard.tile_layout)
        with self._get_conn() as conn:
            conn.execute(
                """
                INSERT INTO dashboards (dashboard_id, user_id, title, version, items_json, edges_json, tile_layout_json, updated_at)
                VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
                ON CONFLICT(dashboard_id) DO UPDATE SET
                    title = excluded.title,
                    version = excluded.version,
                    items_json = excluded.items_json,
                    edges_json = excluded.edges_json,
                    tile_layout_json = excluded.tile_layout_json,
                    updated_at = datetime('now')
                """,
                (
                    dashboard.dashboard_id,
                    dashboard.user_id,
                    dashboard.title,
                    dashboard.version,
                    items_json,
                    edges_json,
                    tile_layout_json,
                ),
            )

    def delete_dashboard(self, user_id: str, dashboard_id: str) -> bool:
        with self._get_conn() as conn:
            cursor = conn.execute(
                "DELETE FROM dashboards WHERE dashboard_id = ? AND user_id = ?",
                (dashboard_id, user_id),
            )
        return cursor.rowcount > 0

    def create_dashboard(self, user_id: str, title: str) -> DashboardModel:
        dashboard = DashboardModel(
            dashboard_id=uuid.uuid4().hex[:12],
            user_id=user_id,
            title=title,
        )
        self.save_dashboard(dashboard)
        return dashboard

    def rename_dashboard(self, user_id: str, dashboard_id: str, new_title: str) -> bool:
        with self._get_conn() as conn:
            cursor = conn.execute(
                "UPDATE dashboards SET title = ?, updated_at = datetime('now') WHERE dashboard_id = ? AND user_id = ?",
                (new_title, dashboard_id, user_id),
            )
        return cursor.rowcount > 0

    def _row_to_model(self, row: sqlite3.Row) -> DashboardModel:
        items = json.loads(row["items_json"])
        keys = row.keys()
        edges_raw = row["edges_json"] if "edges_json" in keys else "[]"
        tile_layout_raw = row["tile_layout_json"] if "tile_layout_json" in keys else "[]"
        edges = json.loads(edges_raw)
        tile_layout = json.loads(tile_layout_raw)
        return DashboardModel(
            dashboard_id=row["dashboard_id"],
            user_id=row["user_id"],
            title=row["title"],
            version=row["version"],
            items=[DashboardItem.from_dict(i) for i in items],
            edges=[DashboardEdge.from_dict(e) for e in edges],
            tile_layout=tile_layout,
        )

create_dashboard(user_id, title)

Source code in src/panel_flowdash/dashboard_store.py
def create_dashboard(self, user_id: str, title: str) -> DashboardModel:
    dashboard = DashboardModel(
        dashboard_id=uuid.uuid4().hex[:12],
        user_id=user_id,
        title=title,
    )
    self.save_dashboard(dashboard)
    return dashboard

delete_dashboard(user_id, dashboard_id)

Source code in src/panel_flowdash/dashboard_store.py
def delete_dashboard(self, user_id: str, dashboard_id: str) -> bool:
    with self._get_conn() as conn:
        cursor = conn.execute(
            "DELETE FROM dashboards WHERE dashboard_id = ? AND user_id = ?",
            (dashboard_id, user_id),
        )
    return cursor.rowcount > 0

list_dashboards(user_id)

Source code in src/panel_flowdash/dashboard_store.py
def list_dashboards(self, user_id: str) -> list[DashboardModel]:
    with self._get_conn() as conn:
        rows = conn.execute(
            "SELECT * FROM dashboards WHERE user_id = ? ORDER BY updated_at DESC",
            (user_id,),
        ).fetchall()
    return [self._row_to_model(row) for row in rows]

load_dashboard(user_id, dashboard_id)

Source code in src/panel_flowdash/dashboard_store.py
def load_dashboard(self, user_id: str, dashboard_id: str) -> DashboardModel | None:
    with self._get_conn() as conn:
        row = conn.execute(
            "SELECT * FROM dashboards WHERE dashboard_id = ? AND user_id = ?",
            (dashboard_id, user_id),
        ).fetchone()
    if row is None:
        return None
    return self._row_to_model(row)

rename_dashboard(user_id, dashboard_id, new_title)

Source code in src/panel_flowdash/dashboard_store.py
def rename_dashboard(self, user_id: str, dashboard_id: str, new_title: str) -> bool:
    with self._get_conn() as conn:
        cursor = conn.execute(
            "UPDATE dashboards SET title = ?, updated_at = datetime('now') WHERE dashboard_id = ? AND user_id = ?",
            (new_title, dashboard_id, user_id),
        )
    return cursor.rowcount > 0

save_dashboard(dashboard)

Source code in src/panel_flowdash/dashboard_store.py
def save_dashboard(self, dashboard: DashboardModel) -> None:
    items_json = json.dumps([item.to_dict() for item in dashboard.items])
    edges_json = json.dumps([edge.to_dict() for edge in dashboard.edges])
    tile_layout_json = json.dumps(dashboard.tile_layout)
    with self._get_conn() as conn:
        conn.execute(
            """
            INSERT INTO dashboards (dashboard_id, user_id, title, version, items_json, edges_json, tile_layout_json, updated_at)
            VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
            ON CONFLICT(dashboard_id) DO UPDATE SET
                title = excluded.title,
                version = excluded.version,
                items_json = excluded.items_json,
                edges_json = excluded.edges_json,
                tile_layout_json = excluded.tile_layout_json,
                updated_at = datetime('now')
            """,
            (
                dashboard.dashboard_id,
                dashboard.user_id,
                dashboard.title,
                dashboard.version,
                items_json,
                edges_json,
                tile_layout_json,
            ),
        )

App Builder

panel_flowdash.app

Application builder: scans a project directory and constructs the Panel app.

COMPONENTS_ROUTE = '/components' module-attribute

DASH_ROUTE_PREFIX = '/dash/' module-attribute

logger = logging.getLogger('panel_flowdash') module-attribute

DashboardEdge dataclass

A connection between two component ports.

Source code in src/panel_flowdash/dashboard_store.py
@dataclass
class DashboardEdge:
    """A connection between two component ports."""

    source: str
    source_port: str
    target: str
    target_port: str

    def to_dict(self) -> dict[str, str]:
        return {
            "source": self.source,
            "source_port": self.source_port,
            "target": self.target,
            "target_port": self.target_port,
        }

    @classmethod
    def from_dict(cls, data: dict[str, str]) -> DashboardEdge:
        return cls(
            source=data["source"],
            source_port=data["source_port"],
            target=data["target"],
            target_port=data["target_port"],
        )

source instance-attribute

source_port instance-attribute

target instance-attribute

target_port instance-attribute

from_dict(data) classmethod

Source code in src/panel_flowdash/dashboard_store.py
@classmethod
def from_dict(cls, data: dict[str, str]) -> DashboardEdge:
    return cls(
        source=data["source"],
        source_port=data["source_port"],
        target=data["target"],
        target_port=data["target_port"],
    )

to_dict()

Source code in src/panel_flowdash/dashboard_store.py
def to_dict(self) -> dict[str, str]:
    return {
        "source": self.source,
        "source_port": self.source_port,
        "target": self.target,
        "target_port": self.target_port,
    }

DashboardItem dataclass

A component instance on the dashboard.

x, y store the ReactFlow node canvas position. Grid layout (widths, heights, visibility) lives in DashboardModel.tile_layout.

Source code in src/panel_flowdash/dashboard_store.py
@dataclass
class DashboardItem:
    """A component instance on the dashboard.

    x, y store the ReactFlow node canvas position.
    Grid layout (widths, heights, visibility) lives in DashboardModel.tile_layout.
    """

    instance_id: str
    component_id: str
    x: float = 0
    y: float = 0
    config: dict[str, Any] = field(default_factory=dict)

    def to_dict(self) -> dict[str, Any]:
        return {
            "instance_id": self.instance_id,
            "component_id": self.component_id,
            "x": self.x,
            "y": self.y,
            "config": self.config,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> DashboardItem:
        return cls(
            instance_id=data["instance_id"],
            component_id=data["component_id"],
            x=data.get("x", 0),
            y=data.get("y", 0),
            config=data.get("config", {}),
        )

component_id instance-attribute

config = field(default_factory=dict) class-attribute instance-attribute

instance_id instance-attribute

x = 0 class-attribute instance-attribute

y = 0 class-attribute instance-attribute

from_dict(data) classmethod

Source code in src/panel_flowdash/dashboard_store.py
@classmethod
def from_dict(cls, data: dict[str, Any]) -> DashboardItem:
    return cls(
        instance_id=data["instance_id"],
        component_id=data["component_id"],
        x=data.get("x", 0),
        y=data.get("y", 0),
        config=data.get("config", {}),
    )

to_dict()

Source code in src/panel_flowdash/dashboard_store.py
def to_dict(self) -> dict[str, Any]:
    return {
        "instance_id": self.instance_id,
        "component_id": self.component_id,
        "x": self.x,
        "y": self.y,
        "config": self.config,
    }

DashboardModel dataclass

A persisted dashboard: nodes + edges + tile layout.

Source code in src/panel_flowdash/dashboard_store.py
@dataclass
class DashboardModel:
    """A persisted dashboard: nodes + edges + tile layout."""

    dashboard_id: str
    user_id: str
    title: str
    version: int = 2
    items: list[DashboardItem] = field(default_factory=list)
    edges: list[DashboardEdge] = field(default_factory=list)
    tile_layout: list[dict[str, Any]] = field(default_factory=list)

    def to_dict(self) -> dict[str, Any]:
        return {
            "version": self.version,
            "dashboard_id": self.dashboard_id,
            "user_id": self.user_id,
            "title": self.title,
            "items": [item.to_dict() for item in self.items],
            "edges": [edge.to_dict() for edge in self.edges],
            "tile_layout": self.tile_layout,
        }

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> DashboardModel:
        return cls(
            dashboard_id=data["dashboard_id"],
            user_id=data["user_id"],
            title=data["title"],
            version=data.get("version", 1),
            items=[DashboardItem.from_dict(i) for i in data.get("items", [])],
            edges=[DashboardEdge.from_dict(e) for e in data.get("edges", [])],
            tile_layout=data.get("tile_layout", []),
        )

dashboard_id instance-attribute

edges = field(default_factory=list) class-attribute instance-attribute

items = field(default_factory=list) class-attribute instance-attribute

tile_layout = field(default_factory=list) class-attribute instance-attribute

title instance-attribute

user_id instance-attribute

version = 2 class-attribute instance-attribute

from_dict(data) classmethod

Source code in src/panel_flowdash/dashboard_store.py
@classmethod
def from_dict(cls, data: dict[str, Any]) -> DashboardModel:
    return cls(
        dashboard_id=data["dashboard_id"],
        user_id=data["user_id"],
        title=data["title"],
        version=data.get("version", 1),
        items=[DashboardItem.from_dict(i) for i in data.get("items", [])],
        edges=[DashboardEdge.from_dict(e) for e in data.get("edges", [])],
        tile_layout=data.get("tile_layout", []),
    )

to_dict()

Source code in src/panel_flowdash/dashboard_store.py
def to_dict(self) -> dict[str, Any]:
    return {
        "version": self.version,
        "dashboard_id": self.dashboard_id,
        "user_id": self.user_id,
        "title": self.title,
        "items": [item.to_dict() for item in self.items],
        "edges": [edge.to_dict() for edge in self.edges],
        "tile_layout": self.tile_layout,
    }

DashboardStore

SQLite-backed store for dashboard models.

Source code in src/panel_flowdash/dashboard_store.py
class DashboardStore:
    """SQLite-backed store for dashboard models."""

    def __init__(self, db_path: str | Path):
        self._db_path = str(db_path)
        self._init_db()

    @contextmanager
    def _get_conn(self):
        conn = sqlite3.connect(self._db_path)
        conn.execute("PRAGMA journal_mode=WAL")
        conn.row_factory = sqlite3.Row
        try:
            yield conn
            conn.commit()
        except Exception:
            conn.rollback()
            raise
        finally:
            conn.close()

    def _init_db(self):
        with self._get_conn() as conn:
            conn.execute("""
                CREATE TABLE IF NOT EXISTS dashboards (
                    dashboard_id TEXT PRIMARY KEY,
                    user_id TEXT NOT NULL,
                    title TEXT NOT NULL,
                    version INTEGER NOT NULL DEFAULT 1,
                    items_json TEXT NOT NULL DEFAULT '[]',
                    edges_json TEXT NOT NULL DEFAULT '[]',
                    tile_layout_json TEXT NOT NULL DEFAULT '[]',
                    created_at TEXT NOT NULL DEFAULT (datetime('now')),
                    updated_at TEXT NOT NULL DEFAULT (datetime('now'))
                )
            """)
            conn.execute("""
                CREATE INDEX IF NOT EXISTS idx_dashboards_user
                ON dashboards (user_id)
            """)
            for col, default in [("edges_json", "'[]'"), ("tile_layout_json", "'[]'")]:
                try:
                    conn.execute(
                        f"ALTER TABLE dashboards ADD COLUMN {col} TEXT NOT NULL DEFAULT {default}"
                    )
                except sqlite3.OperationalError:
                    pass

    def list_dashboards(self, user_id: str) -> list[DashboardModel]:
        with self._get_conn() as conn:
            rows = conn.execute(
                "SELECT * FROM dashboards WHERE user_id = ? ORDER BY updated_at DESC",
                (user_id,),
            ).fetchall()
        return [self._row_to_model(row) for row in rows]

    def load_dashboard(self, user_id: str, dashboard_id: str) -> DashboardModel | None:
        with self._get_conn() as conn:
            row = conn.execute(
                "SELECT * FROM dashboards WHERE dashboard_id = ? AND user_id = ?",
                (dashboard_id, user_id),
            ).fetchone()
        if row is None:
            return None
        return self._row_to_model(row)

    def save_dashboard(self, dashboard: DashboardModel) -> None:
        items_json = json.dumps([item.to_dict() for item in dashboard.items])
        edges_json = json.dumps([edge.to_dict() for edge in dashboard.edges])
        tile_layout_json = json.dumps(dashboard.tile_layout)
        with self._get_conn() as conn:
            conn.execute(
                """
                INSERT INTO dashboards (dashboard_id, user_id, title, version, items_json, edges_json, tile_layout_json, updated_at)
                VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
                ON CONFLICT(dashboard_id) DO UPDATE SET
                    title = excluded.title,
                    version = excluded.version,
                    items_json = excluded.items_json,
                    edges_json = excluded.edges_json,
                    tile_layout_json = excluded.tile_layout_json,
                    updated_at = datetime('now')
                """,
                (
                    dashboard.dashboard_id,
                    dashboard.user_id,
                    dashboard.title,
                    dashboard.version,
                    items_json,
                    edges_json,
                    tile_layout_json,
                ),
            )

    def delete_dashboard(self, user_id: str, dashboard_id: str) -> bool:
        with self._get_conn() as conn:
            cursor = conn.execute(
                "DELETE FROM dashboards WHERE dashboard_id = ? AND user_id = ?",
                (dashboard_id, user_id),
            )
        return cursor.rowcount > 0

    def create_dashboard(self, user_id: str, title: str) -> DashboardModel:
        dashboard = DashboardModel(
            dashboard_id=uuid.uuid4().hex[:12],
            user_id=user_id,
            title=title,
        )
        self.save_dashboard(dashboard)
        return dashboard

    def rename_dashboard(self, user_id: str, dashboard_id: str, new_title: str) -> bool:
        with self._get_conn() as conn:
            cursor = conn.execute(
                "UPDATE dashboards SET title = ?, updated_at = datetime('now') WHERE dashboard_id = ? AND user_id = ?",
                (new_title, dashboard_id, user_id),
            )
        return cursor.rowcount > 0

    def _row_to_model(self, row: sqlite3.Row) -> DashboardModel:
        items = json.loads(row["items_json"])
        keys = row.keys()
        edges_raw = row["edges_json"] if "edges_json" in keys else "[]"
        tile_layout_raw = row["tile_layout_json"] if "tile_layout_json" in keys else "[]"
        edges = json.loads(edges_raw)
        tile_layout = json.loads(tile_layout_raw)
        return DashboardModel(
            dashboard_id=row["dashboard_id"],
            user_id=row["user_id"],
            title=row["title"],
            version=row["version"],
            items=[DashboardItem.from_dict(i) for i in items],
            edges=[DashboardEdge.from_dict(e) for e in edges],
            tile_layout=tile_layout,
        )

create_dashboard(user_id, title)

Source code in src/panel_flowdash/dashboard_store.py
def create_dashboard(self, user_id: str, title: str) -> DashboardModel:
    dashboard = DashboardModel(
        dashboard_id=uuid.uuid4().hex[:12],
        user_id=user_id,
        title=title,
    )
    self.save_dashboard(dashboard)
    return dashboard

delete_dashboard(user_id, dashboard_id)

Source code in src/panel_flowdash/dashboard_store.py
def delete_dashboard(self, user_id: str, dashboard_id: str) -> bool:
    with self._get_conn() as conn:
        cursor = conn.execute(
            "DELETE FROM dashboards WHERE dashboard_id = ? AND user_id = ?",
            (dashboard_id, user_id),
        )
    return cursor.rowcount > 0

list_dashboards(user_id)

Source code in src/panel_flowdash/dashboard_store.py
def list_dashboards(self, user_id: str) -> list[DashboardModel]:
    with self._get_conn() as conn:
        rows = conn.execute(
            "SELECT * FROM dashboards WHERE user_id = ? ORDER BY updated_at DESC",
            (user_id,),
        ).fetchall()
    return [self._row_to_model(row) for row in rows]

load_dashboard(user_id, dashboard_id)

Source code in src/panel_flowdash/dashboard_store.py
def load_dashboard(self, user_id: str, dashboard_id: str) -> DashboardModel | None:
    with self._get_conn() as conn:
        row = conn.execute(
            "SELECT * FROM dashboards WHERE dashboard_id = ? AND user_id = ?",
            (dashboard_id, user_id),
        ).fetchone()
    if row is None:
        return None
    return self._row_to_model(row)

rename_dashboard(user_id, dashboard_id, new_title)

Source code in src/panel_flowdash/dashboard_store.py
def rename_dashboard(self, user_id: str, dashboard_id: str, new_title: str) -> bool:
    with self._get_conn() as conn:
        cursor = conn.execute(
            "UPDATE dashboards SET title = ?, updated_at = datetime('now') WHERE dashboard_id = ? AND user_id = ?",
            (new_title, dashboard_id, user_id),
        )
    return cursor.rowcount > 0

save_dashboard(dashboard)

Source code in src/panel_flowdash/dashboard_store.py
def save_dashboard(self, dashboard: DashboardModel) -> None:
    items_json = json.dumps([item.to_dict() for item in dashboard.items])
    edges_json = json.dumps([edge.to_dict() for edge in dashboard.edges])
    tile_layout_json = json.dumps(dashboard.tile_layout)
    with self._get_conn() as conn:
        conn.execute(
            """
            INSERT INTO dashboards (dashboard_id, user_id, title, version, items_json, edges_json, tile_layout_json, updated_at)
            VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
            ON CONFLICT(dashboard_id) DO UPDATE SET
                title = excluded.title,
                version = excluded.version,
                items_json = excluded.items_json,
                edges_json = excluded.edges_json,
                tile_layout_json = excluded.tile_layout_json,
                updated_at = datetime('now')
            """,
            (
                dashboard.dashboard_id,
                dashboard.user_id,
                dashboard.title,
                dashboard.version,
                items_json,
                edges_json,
                tile_layout_json,
            ),
        )

DataflowGraph

Manages node state instances and wires edges with runtime validation.

Source code in src/panel_flowdash/dataflow_engine.py
class DataflowGraph:
    """Manages node state instances and wires edges with runtime validation."""

    def __init__(
        self,
        specs: dict[str, ComponentSpec],
        on_error: Callable[[str, str, str, str, Exception], None] | None = None,
    ):
        """Initialize the dataflow graph.

        Parameters
        ----------
        specs
            Component specs keyed by component_id.
        on_error
            Callback invoked when a runtime value assignment fails.
            Signature: (source_id, source_port, target_id, target_port, exception)
        """
        self._specs = specs
        self._state_classes: dict[str, type[param.Parameterized]] = {}
        self._nodes: dict[str, param.Parameterized] = {}
        self._node_specs: dict[str, ComponentSpec] = {}
        self._edges: list[dict[str, str]] = []
        self._watchers: dict[tuple, param.parameterized.Watcher] = {}
        self._on_error = on_error

        for comp_id, spec in specs.items():
            self._state_classes[comp_id] = build_node_state_class(spec)

    def add_node(self, instance_id: str, component_id: str) -> param.Parameterized:
        """Create a new node state instance."""
        spec = self._specs[component_id]
        cls = self._state_classes[component_id]
        state = cls(name=instance_id)
        self._nodes[instance_id] = state
        self._node_specs[instance_id] = spec
        return state

    def remove_node(self, instance_id: str):
        """Remove a node and any edges connected to it."""
        keys_to_remove = [k for k in self._watchers if k[0] == instance_id or k[2] == instance_id]
        for key in keys_to_remove:
            watcher = self._watchers.pop(key)
            src = self._nodes.get(key[0])
            if src is not None:
                src.param.unwatch(watcher)

        self._edges = [
            e for e in self._edges if e["source"] != instance_id and e["target"] != instance_id
        ]
        self._nodes.pop(instance_id, None)
        self._node_specs.pop(instance_id, None)

    def add_edge(
        self,
        source_id: str,
        source_port: str,
        target_id: str,
        target_port: str,
    ) -> bool | str:
        """Wire an edge between two ports.

        Returns True on success, or an error message string on failure.
        """
        source_state = self._nodes.get(source_id)
        target_state = self._nodes.get(target_id)
        if source_state is None or target_state is None:
            return "Source or target node not found."
        if not hasattr(source_state.param, source_port):
            return f"Output port '{source_port}' does not exist on source node."
        if not hasattr(target_state.param, target_port):
            return f"Input port '{target_port}' does not exist on target node."

        for e in self._edges:
            if e["target"] == target_id and e["target_port"] == target_port:
                return f"Input '{target_port}' already has a connection. Disconnect it first."

        if self._would_create_cycle(source_id, target_id):
            return "Connection rejected: would create a cycle."

        source_spec = self._node_specs.get(source_id)
        target_spec = self._node_specs.get(target_id)
        if source_spec and target_spec:
            error = self._check_type_compatibility(
                source_spec, source_port, target_spec, target_port
            )
            if error:
                return error

        def _propagate(
            event,
            _src_id=source_id,
            _src_port=source_port,
            _tgt_id=target_id,
            _tgt_port=target_port,
            _target=target_state,
        ):
            try:
                setattr(_target, _tgt_port, event.new)
            except Exception as exc:
                if self._on_error:
                    self._on_error(_src_id, _src_port, _tgt_id, _tgt_port, exc)

        watcher = source_state.param.watch(_propagate, source_port)
        edge_key = (source_id, source_port, target_id, target_port)
        self._watchers[edge_key] = watcher

        current = getattr(source_state, source_port)
        if current is not None:
            try:
                setattr(target_state, target_port, current)
            except Exception as exc:
                if self._on_error:
                    self._on_error(source_id, source_port, target_id, target_port, exc)

        self._edges.append(
            {
                "source": source_id,
                "source_port": source_port,
                "target": target_id,
                "target_port": target_port,
            }
        )
        return True

    def _would_create_cycle(self, source_id: str, target_id: str) -> bool:
        """Return True if adding an edge from source to target would create a cycle."""
        if source_id == target_id:
            return True
        visited = set()
        queue = [target_id]
        while queue:
            node = queue.pop(0)
            if node == source_id:
                return True
            if node in visited:
                continue
            visited.add(node)
            for e in self._edges:
                if e["source"] == node:
                    queue.append(e["target"])
        return False

    def _check_type_compatibility(
        self,
        source_spec: ComponentSpec,
        source_port: str,
        target_spec: ComponentSpec,
        target_port: str,
    ) -> str | None:
        """Return an error message if types are incompatible, None if OK."""
        source_type = None
        for port in source_spec.outputs:
            if port.name == source_port:
                source_type = port.type
                break

        target_type = None
        for port in target_spec.inputs:
            if port.name == target_port:
                target_type = port.type
                break

        if source_type is None or target_type is None:
            return None

        if source_type.lower() == target_type.lower():
            return None

        return (
            f"Type mismatch: output '{source_port}' produces '{source_type}' "
            f"but input '{target_port}' expects '{target_type}'."
        )

    def remove_edge(self, source_id: str, source_port: str, target_id: str, target_port: str):
        """Remove an edge, unsubscribe the watcher, and reset target to default."""
        self._edges = [
            e
            for e in self._edges
            if not (
                e["source"] == source_id
                and e["source_port"] == source_port
                and e["target"] == target_id
                and e["target_port"] == target_port
            )
        ]

        edge_key = (source_id, source_port, target_id, target_port)
        watcher = self._watchers.pop(edge_key, None)
        if watcher is not None:
            source_state = self._nodes.get(source_id)
            if source_state is not None:
                source_state.param.unwatch(watcher)

        target_state = self._nodes.get(target_id)
        if target_state is not None and hasattr(target_state, target_port):
            spec = self._node_specs.get(target_id)
            default = None
            if spec:
                for port in spec.inputs:
                    if port.name == target_port:
                        default = port.default
                        break
            setattr(target_state, target_port, default)

    def get_state(self, instance_id: str) -> param.Parameterized | None:
        """Get the state instance for a node."""
        return self._nodes.get(instance_id)

    @property
    def edges(self) -> list[dict[str, str]]:
        """All current edges."""
        return list(self._edges)

    @property
    def node_ids(self) -> list[str]:
        """All current node instance IDs."""
        return list(self._nodes.keys())

edges property

All current edges.

node_ids property

All current node instance IDs.

add_edge(source_id, source_port, target_id, target_port)

Wire an edge between two ports.

Returns True on success, or an error message string on failure.

Source code in src/panel_flowdash/dataflow_engine.py
def add_edge(
    self,
    source_id: str,
    source_port: str,
    target_id: str,
    target_port: str,
) -> bool | str:
    """Wire an edge between two ports.

    Returns True on success, or an error message string on failure.
    """
    source_state = self._nodes.get(source_id)
    target_state = self._nodes.get(target_id)
    if source_state is None or target_state is None:
        return "Source or target node not found."
    if not hasattr(source_state.param, source_port):
        return f"Output port '{source_port}' does not exist on source node."
    if not hasattr(target_state.param, target_port):
        return f"Input port '{target_port}' does not exist on target node."

    for e in self._edges:
        if e["target"] == target_id and e["target_port"] == target_port:
            return f"Input '{target_port}' already has a connection. Disconnect it first."

    if self._would_create_cycle(source_id, target_id):
        return "Connection rejected: would create a cycle."

    source_spec = self._node_specs.get(source_id)
    target_spec = self._node_specs.get(target_id)
    if source_spec and target_spec:
        error = self._check_type_compatibility(
            source_spec, source_port, target_spec, target_port
        )
        if error:
            return error

    def _propagate(
        event,
        _src_id=source_id,
        _src_port=source_port,
        _tgt_id=target_id,
        _tgt_port=target_port,
        _target=target_state,
    ):
        try:
            setattr(_target, _tgt_port, event.new)
        except Exception as exc:
            if self._on_error:
                self._on_error(_src_id, _src_port, _tgt_id, _tgt_port, exc)

    watcher = source_state.param.watch(_propagate, source_port)
    edge_key = (source_id, source_port, target_id, target_port)
    self._watchers[edge_key] = watcher

    current = getattr(source_state, source_port)
    if current is not None:
        try:
            setattr(target_state, target_port, current)
        except Exception as exc:
            if self._on_error:
                self._on_error(source_id, source_port, target_id, target_port, exc)

    self._edges.append(
        {
            "source": source_id,
            "source_port": source_port,
            "target": target_id,
            "target_port": target_port,
        }
    )
    return True

add_node(instance_id, component_id)

Create a new node state instance.

Source code in src/panel_flowdash/dataflow_engine.py
def add_node(self, instance_id: str, component_id: str) -> param.Parameterized:
    """Create a new node state instance."""
    spec = self._specs[component_id]
    cls = self._state_classes[component_id]
    state = cls(name=instance_id)
    self._nodes[instance_id] = state
    self._node_specs[instance_id] = spec
    return state

get_state(instance_id)

Get the state instance for a node.

Source code in src/panel_flowdash/dataflow_engine.py
def get_state(self, instance_id: str) -> param.Parameterized | None:
    """Get the state instance for a node."""
    return self._nodes.get(instance_id)

remove_edge(source_id, source_port, target_id, target_port)

Remove an edge, unsubscribe the watcher, and reset target to default.

Source code in src/panel_flowdash/dataflow_engine.py
def remove_edge(self, source_id: str, source_port: str, target_id: str, target_port: str):
    """Remove an edge, unsubscribe the watcher, and reset target to default."""
    self._edges = [
        e
        for e in self._edges
        if not (
            e["source"] == source_id
            and e["source_port"] == source_port
            and e["target"] == target_id
            and e["target_port"] == target_port
        )
    ]

    edge_key = (source_id, source_port, target_id, target_port)
    watcher = self._watchers.pop(edge_key, None)
    if watcher is not None:
        source_state = self._nodes.get(source_id)
        if source_state is not None:
            source_state.param.unwatch(watcher)

    target_state = self._nodes.get(target_id)
    if target_state is not None and hasattr(target_state, target_port):
        spec = self._node_specs.get(target_id)
        default = None
        if spec:
            for port in spec.inputs:
                if port.name == target_port:
                    default = port.default
                    break
        setattr(target_state, target_port, default)

remove_node(instance_id)

Remove a node and any edges connected to it.

Source code in src/panel_flowdash/dataflow_engine.py
def remove_node(self, instance_id: str):
    """Remove a node and any edges connected to it."""
    keys_to_remove = [k for k in self._watchers if k[0] == instance_id or k[2] == instance_id]
    for key in keys_to_remove:
        watcher = self._watchers.pop(key)
        src = self._nodes.get(key[0])
        if src is not None:
            src.param.unwatch(watcher)

    self._edges = [
        e for e in self._edges if e["source"] != instance_id and e["target"] != instance_id
    ]
    self._nodes.pop(instance_id, None)
    self._node_specs.pop(instance_id, None)

PanelAppMetadata dataclass

Metadata attached to a component or page by the @register decorator.

Source code in src/panel_flowdash/registry.py
@dataclass(frozen=True)
class PanelAppMetadata:
    """Metadata attached to a component or page by the @register decorator."""

    page: bool = True
    component: bool = False
    sidebar: bool = False
    title: str | None = None
    icon: str | None = None
    description: str | None = None
    tags: list[str] = field(default_factory=list)
    default_size: dict[str, Any] | None = None
    min_size: dict[str, Any] | None = None
    max_size: dict[str, Any] | None = None
    singleton: bool = False
    provides: list[str] = field(default_factory=list)
    requires: list[Any] = field(default_factory=list)
    config_schema: dict[str, Any] | None = None

    @classmethod
    def from_app(cls, app: Any) -> PanelAppMetadata:
        """Extract metadata from an app object."""
        metadata = getattr(app, "__panel_app_metadata__", None)
        if metadata is None:
            metadata = _APP_METADATA_BY_ID.get(id(app))
        if metadata is None:
            return cls()
        if isinstance(metadata, cls):
            return metadata
        if isinstance(metadata, dict):
            return cls(**metadata)
        raise TypeError("Unsupported panel app metadata type.")

component = False class-attribute instance-attribute

config_schema = None class-attribute instance-attribute

default_size = None class-attribute instance-attribute

description = None class-attribute instance-attribute

icon = None class-attribute instance-attribute

max_size = None class-attribute instance-attribute

min_size = None class-attribute instance-attribute

page = True class-attribute instance-attribute

provides = field(default_factory=list) class-attribute instance-attribute

requires = field(default_factory=list) class-attribute instance-attribute

sidebar = False class-attribute instance-attribute

singleton = False class-attribute instance-attribute

tags = field(default_factory=list) class-attribute instance-attribute

title = None class-attribute instance-attribute

from_app(app) classmethod

Extract metadata from an app object.

Source code in src/panel_flowdash/registry.py
@classmethod
def from_app(cls, app: Any) -> PanelAppMetadata:
    """Extract metadata from an app object."""
    metadata = getattr(app, "__panel_app_metadata__", None)
    if metadata is None:
        metadata = _APP_METADATA_BY_ID.get(id(app))
    if metadata is None:
        return cls()
    if isinstance(metadata, cls):
        return metadata
    if isinstance(metadata, dict):
        return cls(**metadata)
    raise TypeError("Unsupported panel app metadata type.")

RegistryEntry dataclass

A registered component/page with its metadata.

Source code in src/panel_flowdash/registry.py
@dataclass(frozen=True)
class RegistryEntry:
    """A registered component/page with its metadata."""

    app_id: str
    section: str
    name: str
    page_path: str
    module_name: str
    app: Any
    metadata: PanelAppMetadata

    @property
    def title(self) -> str:
        """Human-readable title."""
        return self.metadata.title or self.name.replace("_", " ")

app instance-attribute

app_id instance-attribute

metadata instance-attribute

module_name instance-attribute

name instance-attribute

page_path instance-attribute

section instance-attribute

title property

Human-readable title.

_DASHBOARD_ACTION_TYPE

Bases: TypedDict

Source code in src/panel_flowdash/app.py
class _DASHBOARD_ACTION_TYPE(t.TypedDict):
    label: str
    icon: str

icon instance-attribute

label instance-attribute

build_app_class(project_dir, *, store, title='FlowDash', default_page=None)

Build a Viewer class configured for the given project directory.

Returns a class (not an instance) that can be used with pn.serve. The class also exposes a build_routes() classmethod for route generation.

Source code in src/panel_flowdash/app.py
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
def build_app_class(
    project_dir: Path,
    *,
    store: DashboardStore,
    title: str = "FlowDash",
    default_page: str | None = None,
) -> type:
    """Build a Viewer class configured for the given project directory.

    Returns a class (not an instance) that can be used with pn.serve.
    The class also exposes a `build_routes()` classmethod for route generation.
    """
    registry = build_registry(project_dir)
    page_entries = {app_id: entry for app_id, entry in registry.items() if entry.metadata.page}
    component_entries = {
        app_id: entry for app_id, entry in registry.items() if entry.metadata.component
    }
    component_specs = build_component_specs(registry)
    session_state_class = build_session_state_class(registry)

    resolved_default_page = default_page or next(iter(page_entries), "")

    class FlowDashApp(pn.viewable.Viewer):
        """Dynamically generated FlowDash application."""

        _store = store
        _registry = registry
        _page_entries = page_entries
        _component_entries = component_entries
        _component_specs = component_specs
        _session_state_class = session_state_class
        _title = title
        _project_dir = project_dir
        _default_page = resolved_default_page

        _main_task: asyncio.Task | None = None

        def __init__(self):
            super().__init__()
            self._session_state = self._session_state_class()
            self._user_id = self._resolve_user_id()
            self._loading = False
            self._edge_id_map: dict[str, tuple[str, str, str, str]] = {}
            self._current_dashboard: DashboardModel | None = None
            self._tile_items: list[dict] = []
            self._tile_objects: list[pn.viewable.Viewable] = []
            self._sidebar_views: list[pn.viewable.Viewable] = []
            self._sidebar_container = pn.Column(sizing_mode="stretch_width")
            self._component_picker = self._make_component_picker()
            self._component_status = pn.pane.Alert("", alert_type="primary", visible=False)
            self._dataflow_graph = DataflowGraph(
                self._component_specs,
                on_error=self._on_wiring_error,
            )
            self._flow_canvas = self._build_flow_canvas()
            self._component_view = self._build_component_view()
            self._page = pmui.Page(
                title=self._title,
                theme_config={"palette": {"primary": {"main": "#0072B5"}}},
                sidebar=self.get_sidebar(),
            )
            pn.state.onload(self._load_page_layout)

        def _resolve_user_id(self) -> str:
            if pn.state.user:
                return pn.state.user
            return "default"

        @staticmethod
        @cache
        def _accepted_injected_params(app):
            if inspect.isclass(app) and issubclass(app, pn.viewable.Viewer):
                return {
                    p
                    for p in ("config", "executor", "instance_config", "context")
                    if hasattr(app, p)
                }
            return inspect.signature(app).parameters.keys() & {
                "config",
                "executor",
                "instance_config",
                "context",
            }

        def _add_kwargs_dict(self, app, *, context: str, instance_config: dict | None = None):
            params = self._accepted_injected_params(app)
            kwargs = {}
            if "context" in params:
                kwargs["context"] = context
            if "instance_config" in params and instance_config is not None:
                kwargs["instance_config"] = instance_config
            if "config" in params:
                kwargs["config"] = self._session_state
            return kwargs

        def _entry_from_key(self, key):
            app_id = "/".join(key)
            return self._page_entries.get(app_id)

        async def _instantiate_entry(
            self,
            entry: RegistryEntry,
            *,
            context: str,
            instance_config: dict | None = None,
        ):
            unsatisfied = check_requirements(self._session_state, entry.metadata.requires)
            blocking = [u for u in unsatisfied if u["blocking"]]
            if blocking:
                keys = ", ".join(u["key"] for u in blocking)
                return pn.pane.Alert(
                    f"**{entry.title}** is waiting for: `{keys}`",
                    alert_type="warning",
                )

            app = entry.app
            if not callable(app):
                return pn.panel(app)
            kwargs = self._add_kwargs_dict(app, context=context, instance_config=instance_config)
            if inspect.iscoroutinefunction(app):
                return await app(**kwargs)
            return await asyncio.to_thread(lambda: pn.panel(app(**kwargs)))

        async def _render_page(self, key):
            entry = self._entry_from_key(key)
            if entry is None:
                return f"Unknown page: {'/'.join(key)}"

            if self._main_task is not None and not self._main_task.done():
                self._main_task.cancel()

            try:
                coroutine = self._instantiate_entry(entry, context="page")
                self._main_task = asyncio.create_task(coroutine)
                return await self._main_task
            except asyncio.CancelledError:
                return None
            except Exception as e:
                logger.exception("Error rendering page '%s'", "/".join(key))
                err_name = type(e).__name__
                return pn.pane.Alert(
                    f"**{err_name}**: {e}\n<hr>\n<pre> {escape(traceback.format_exc())}</pre>\n",
                    alert_type="danger",
                    styles={"color": "black"},
                )

        def _make_component_picker(self):
            groups: dict[str, dict[str, str]] = {}
            for app_id, entry in self._component_entries.items():
                section = entry.section.replace("_", " ")
                groups.setdefault(section, {})[entry.title] = app_id
            value = next(iter(self._component_entries), None)
            return pmui.Select(
                label="Component",
                groups=groups,
                value=value,
                searchable=True,
                filter_on_search=True,
                size="small",
            )

        def _build_flow_canvas(self):
            node_types = {}
            for comp_id, spec in self._component_specs.items():
                type_key = comp_id.replace("/", "__")
                node_types[type_key] = pr.NodeType(
                    type=type_key,
                    label=spec.title,
                    inputs=[
                        {"id": port.name, "label": port.label or port.name} for port in spec.inputs
                    ],
                    outputs=[
                        {"id": port.name, "label": port.label or port.name}
                        for port in spec.outputs
                    ],
                )

            flow = pr.ReactFlow(
                nodes=[],
                edges=[],
                node_types=node_types,
                editable=True,
                enable_connect=True,
                show_minimap=True,
                sizing_mode="stretch_both",
                min_height=600,
                stylesheets=[
                    """\
                .react-flow__node {
                  padding: 0;
                  border-radius: 6px;
                  border: 1px solid var(--xy-node-border, var(--panel-border-color));
                  background-color: var(--xy-node-background-color, var(--panel-background-color));
                  box-shadow: 0 1px 2px var(--panel-shadow-color);
                  color: var(--xy-node-color, var(--panel-on-background-color));
                  font-size: 13px;
                  min-width: 140px;
                }
                .react-flow__handle {
                  width: 14px;
                  height: 14px;
                  border: 1px solid black;
                  background: transparent;
                }"""
                ],
            )

            def _on_edge_added(event):
                if self._loading:
                    return
                edge = event.get("edge", event) if isinstance(event, dict) else {}
                src_id = edge.get("source", "")
                tgt_id = edge.get("target", "")
                src_handle = edge.get("sourceHandle", "")
                tgt_handle = edge.get("targetHandle", "")
                if src_id and tgt_id and src_handle and tgt_handle:
                    result = self._dataflow_graph.add_edge(src_id, src_handle, tgt_id, tgt_handle)
                    if result is True:
                        edge_id = edge.get("id", "")
                        if edge_id:
                            self._edge_id_map[edge_id] = (src_id, src_handle, tgt_id, tgt_handle)
                        pn.state.notifications.success(
                            f"Wired: {src_handle} → {tgt_handle}", duration=3000
                        )
                    else:
                        logger.warning("Edge rejected: %s", result)
                        pn.state.notifications.error(result, duration=5000)
                        flow.remove_edge(edge.get("id", ""))

            def _on_edge_deleted(event):
                if self._loading:
                    return
                edge_id = event.get("edge_id", "") if isinstance(event, dict) else ""
                if not edge_id:
                    return
                mapping = self._edge_id_map.pop(edge_id, None)
                if mapping:
                    self._dataflow_graph.remove_edge(*mapping)

            def _on_node_deleted(event):
                node_id = event.get("node_id", "") if isinstance(event, dict) else ""
                if node_id:
                    self._dataflow_graph.remove_node(node_id)
                    idx = next(
                        (
                            i
                            for i, item in enumerate(self._tile_items)
                            if item["instance_id"] == node_id
                        ),
                        None,
                    )
                    if idx is not None:
                        self._tile_items.pop(idx)
                        self._tile_objects.pop(idx)

            flow.on("edge_added", _on_edge_added)
            flow.on("edge_deleted", _on_edge_deleted)
            flow.on("node_deleted", _on_node_deleted)

            self._flow = flow
            return flow

        def _instantiate_for_node(self, entry, node_state):
            """Create a live component view wired to the node_state."""
            app_fn = entry.app

            if not callable(app_fn):
                return pn.panel(app_fn)

            if inspect.isclass(app_fn) and issubclass(app_fn, pn.viewable.Viewer):
                return self._instantiate_viewer_for_node(app_fn, entry, node_state)

            sig = inspect.signature(app_fn)
            kwargs = {}
            if "config" in sig.parameters:
                kwargs["config"] = node_state
            if "context" in sig.parameters:
                kwargs["context"] = "component"

            result = app_fn(**kwargs)
            return pn.panel(result)

        def _instantiate_viewer_for_node(self, viewer_cls, entry, node_state):
            """Instantiate a Viewer and wire its params to the node_state."""
            spec = self._component_specs.get(entry.app_id)
            instance = viewer_cls()

            input_names = [p.name for p in spec.inputs] if spec else []
            for name in input_names:
                if not hasattr(instance.param, name):
                    continue

                def _propagate_input(event, _name=name):
                    setattr(instance, _name, event.new)

                node_state.param.watch(_propagate_input, name)

            output_info = instance.param.outputs()
            for name, (_, method, _) in output_info.items():
                if not hasattr(node_state.param, name):
                    continue
                method_name = method.__name__ if callable(method) else method
                deps = instance.param.method_dependencies(method_name)
                dep_names = [d.name for d in deps if d.name != "name"]

                def _propagate_output(event, _method=method, _name=name):
                    try:
                        val = _method() if callable(_method) else getattr(instance, _method)()
                        setattr(node_state, _name, val)
                    except Exception as exc:
                        logger.error("Output '%s' failed: %s", _name, exc, exc_info=exc)

                if dep_names:
                    instance.param.watch(_propagate_output, dep_names)
                try:
                    val = method() if callable(method) else getattr(instance, method)()
                    setattr(node_state, name, val)
                except Exception:
                    pass

            return pn.panel(instance)

        def _build_component_view(self):
            self._add_button = pmui.Button(icon="add", color="primary", variant="outlined")
            self._clear_button = pmui.Button(
                icon="delete_sweep", color="danger", variant="outlined"
            )
            self._save_button = pmui.Button(icon="save", color="primary", variant="outlined")
            self._add_button.on_click(lambda _event: self._add_component_to_graph())
            self._clear_button.on_click(lambda _event: self._clear_components())
            self._save_button.on_click(lambda _event: self._save_current_dashboard())

            no_components = len(self._component_entries) == 0
            self._component_picker.disabled = no_components
            self._add_button.disabled = no_components

            if no_components:
                self._component_status.object = "No component-enabled apps found."
                self._component_status.alert_type = "warning"
                self._component_status.visible = True

            self._mode_toggle = pmui.RadioButtonGroup(
                options={":material/cable:": "wiring", ":material/dashboard:": "dashboard"},
                value="wiring",
                styles={"margin-left": "auto"},
            )
            self._workspace_area = pn.Column(
                self._flow_canvas,
                sizing_mode="stretch_both",
            )

            def _on_mode_change(event):
                if event.new == "dashboard":
                    self._workspace_area[:] = [self._tile_grid]
                    self._rebuild_tile_grid()
                else:
                    self._workspace_area[:] = [self._flow_canvas]

            self._mode_toggle.param.watch(_on_mode_change, "value")

            self._controls_row = pn.Row(
                self._component_picker,
                self._add_button,
                self._clear_button,
                self._save_button,
                self._mode_toggle,
                sizing_mode="stretch_width",
                align="center",
            )
            return pn.Column(
                self._controls_row,
                self._component_status,
                self._workspace_area,
                sizing_mode="stretch_both",
                styles={"height": "100%"},
            )

        @property
        def _tile_grid(self):
            if not hasattr(self, "_tile__grid"):
                self._tile__grid = TileGrid(
                    card=False,
                    close_action="hide",
                    editable=False,
                    local_save=False,
                    min_height=320,
                    sizing_mode="stretch_both",
                )
            return self._tile__grid

        def _on_wiring_error(self, source_id, source_port, target_id, target_port, exc):
            logger.error(
                "Runtime wiring error (%s.%s -> %s.%s): %s",
                source_id,
                source_port,
                target_id,
                target_port,
                exc,
                exc_info=exc,
            )
            pn.state.notifications.error(
                f"Runtime wiring error ({source_port} → {target_port}): {exc}",
                duration=5000,
            )

        def _set_component_status(self, message: str, *, alert_type: str = "primary"):
            self._component_status.object = message
            self._component_status.alert_type = alert_type
            self._component_status.visible = bool(message)

        def _add_component_to_graph(self):
            component_id = self._component_picker.value
            entry = self._component_entries.get(component_id)
            if entry is None:
                self._set_component_status("Select a valid component first.", alert_type="warning")
                return

            spec = self._component_specs.get(component_id)
            if spec is None:
                return

            type_key = component_id.replace("/", "__")
            instance_id = f"{type_key}_{uuid.uuid4().hex[:6]}"

            node_state = self._dataflow_graph.add_node(instance_id, component_id)

            try:
                view = self._instantiate_for_node(entry, node_state)
            except Exception as e:
                logger.exception("Failed to add component '%s'", component_id)
                self._dataflow_graph.remove_node(instance_id)
                self._set_component_status(f"Failed to add component: {e}", alert_type="danger")
                return

            node_count = len(self._tile_items)
            col = node_count % 3
            row = node_count // 3
            position = {"x": col * 350, "y": row * 250}

            node = pr.NodeSpec(
                id=instance_id,
                type=type_key,
                position=position,
                label=spec.title,
                data={"component_id": component_id},
            )
            node_dict = node.to_dict()
            node_dict["view"] = view
            self._flow.add_node(node_dict)

            self._tile_items.append(
                {"instance_id": instance_id, "component_id": component_id, "config": {}}
            )
            self._tile_objects.append(view)

            self._set_component_status(
                f"Added component `{entry.title}` ({instance_id}).",
                alert_type="success",
            )

        @pn.io.hold()
        def _rebuild_tile_grid(self):
            grid_views = []
            sidebar_views = []
            for i, item in enumerate(self._tile_items):
                component_id = item["component_id"]
                entry = self._component_entries.get(component_id)
                if entry is None:
                    continue
                view = self._tile_objects[i] if i < len(self._tile_objects) else None
                if view is None:
                    view = pn.pane.Markdown(f"*{entry.title}*")
                if entry.metadata.sidebar:
                    sidebar_views.append(view)
                else:
                    grid_views.append(view)
            self._tile_grid[:] = grid_views
            self._sidebar_views = sidebar_views
            self._sidebar_container.objects = sidebar_views
            pending = getattr(self, "_pending_tile_layout", [])
            if pending:
                self._tile_grid.layout = pending
                self._pending_tile_layout = []

        @pn.io.hold()
        def _clear_components(self):
            for node_id in list(self._dataflow_graph.node_ids):
                self._dataflow_graph.remove_node(node_id)
            self._tile_items = []
            self._tile_objects = []
            self._edge_id_map.clear()
            self._sidebar_views = []
            self._sidebar_container.objects = []
            self._flow.nodes = []
            self._flow.edges = []
            self._set_component_status("Cleared all component tiles.", alert_type="primary")

        def _save_current_dashboard(self):
            if self._current_dashboard is None:
                self._set_component_status(
                    "No dashboard loaded. Create one from the sidebar.",
                    alert_type="warning",
                )
                return

            positions = {}
            for node in self._flow.nodes:
                node_id = node.get("id", "")
                pos = node.get("position", {})
                positions[node_id] = (pos.get("x", 0), pos.get("y", 0))

            self._current_dashboard.items = [
                DashboardItem(
                    instance_id=item["instance_id"],
                    component_id=item["component_id"],
                    x=positions.get(item["instance_id"], (0, 0))[0],
                    y=positions.get(item["instance_id"], (0, 0))[1],
                    config=item.get("config", {}),
                )
                for item in self._tile_items
            ]
            self._current_dashboard.edges = [
                DashboardEdge(
                    source=edge["source"],
                    source_port=edge["source_port"],
                    target=edge["target"],
                    target_port=edge["target_port"],
                )
                for edge in self._dataflow_graph.edges
            ]
            self._current_dashboard.tile_layout = self._tile_grid.layout
            self._store.save_dashboard(self._current_dashboard)
            self._set_component_status(
                f'Dashboard "{self._current_dashboard.title}" saved.',
                alert_type="success",
            )

        @pn.io.hold()
        async def _load_dashboard(self, dashboard_id: str):
            dashboard = self._store.load_dashboard(self._user_id, dashboard_id)
            if dashboard is None:
                self._page.main = [
                    pn.pane.Alert(f"Dashboard not found: {dashboard_id}", alert_type="danger")
                ]
                return
            self._current_dashboard = dashboard
            self._loading = True

            for node_id in list(self._dataflow_graph.node_ids):
                self._dataflow_graph.remove_node(node_id)
            self._tile_items = []
            self._tile_objects = []
            self._edge_id_map.clear()
            self._flow.nodes = []
            self._flow.edges = []

            for item in dashboard.items:
                component_id = item.component_id
                entry = self._component_entries.get(component_id)
                if entry is None:
                    continue
                spec = self._component_specs.get(component_id)
                if spec is None:
                    continue

                instance_id = item.instance_id
                type_key = component_id.replace("/", "__")
                node_state = self._dataflow_graph.add_node(instance_id, component_id)

                try:
                    view = self._instantiate_for_node(entry, node_state)
                except Exception:
                    logger.exception(
                        "Error loading component '%s' (%s)", component_id, instance_id
                    )
                    self._dataflow_graph.remove_node(instance_id)
                    continue

                position = {"x": item.x, "y": item.y}
                node = pr.NodeSpec(
                    id=instance_id,
                    type=type_key,
                    position=position,
                    label=spec.title,
                    data={"component_id": component_id},
                )
                node_dict = node.to_dict()
                node_dict["view"] = view
                self._flow.add_node(node_dict)

                self._tile_items.append(item.to_dict())
                self._tile_objects.append(view)

            edge_counter = 0
            for edge in dashboard.edges:
                success = self._dataflow_graph.add_edge(
                    edge.source, edge.source_port, edge.target, edge.target_port
                )
                if success is True:
                    edge_counter += 1
                    edge_id = f"e{edge_counter}"
                    self._edge_id_map[edge_id] = (
                        edge.source,
                        edge.source_port,
                        edge.target,
                        edge.target_port,
                    )
                    self._flow.add_edge(
                        {
                            "id": edge_id,
                            "source": edge.source,
                            "target": edge.target,
                            "sourceHandle": edge.source_port,
                            "targetHandle": edge.target_port,
                            "markerEnd": {"type": "arrowclosed"},
                        }
                    )

            self._loading = False
            self._pending_tile_layout = dashboard.tile_layout or []
            self._set_component_status(
                f'Loaded dashboard "{dashboard.title}" with {len(self._tile_items)} tiles.',
                alert_type="primary",
            )
            self._show_view_mode()
            self._page.main = [self._component_view]

        def _create_new_dashboard(self, title_str: str):
            title_str = title_str.strip()
            if not title_str:
                self._set_component_status(
                    "Dashboard title cannot be empty.", alert_type="warning"
                )
                return
            dashboard = self._store.create_dashboard(self._user_id, title_str)
            self._current_dashboard = dashboard
            self._tile_items = []
            self._tile_objects = []
            self._set_component_status(
                f'Created new dashboard "{dashboard.title}".',
                alert_type="success",
            )
            self._refresh_sidebar_dashboards()
            if pn.state.location:
                pn.state.location.pathname = f"{DASH_ROUTE_PREFIX}{dashboard.dashboard_id}"
            self._show_edit_mode()
            self._page.main = [self._component_view]

        def _delete_dashboard(self, dashboard_id: str):
            self._store.delete_dashboard(self._user_id, dashboard_id)
            if self._current_dashboard and self._current_dashboard.dashboard_id == dashboard_id:
                self._current_dashboard = None
                self._tile_items = []
                self._tile_objects = []
            self._refresh_sidebar_dashboards()
            self._set_component_status("Dashboard deleted.", alert_type="primary")

        def _rename_dashboard(self, dashboard_id: str, new_title: str):
            new_title = new_title.strip()
            if not new_title:
                return
            self._store.rename_dashboard(self._user_id, dashboard_id, new_title)
            if self._current_dashboard and self._current_dashboard.dashboard_id == dashboard_id:
                self._current_dashboard.title = new_title
            self._refresh_sidebar_dashboards()

        def _refresh_sidebar_dashboards(self):
            dash_items = self._get_dashboard_menu_items()
            items = list(self._menu_list.items)
            items[-1] = {**items[-1], "items": dash_items}
            self._menu_list.items = items

        async def _load_page_layout(self):
            if pn.state.location is None:
                return
            pathname = pn.state.location.pathname

            if pathname == "/":
                if self._default_page:
                    pn.state.location.pathname = f"/{self._default_page}"
                    return await self._load_page_layout()
                self._page.main = [self._component_view]
                return

            if pathname == COMPONENTS_ROUTE:
                self._current_dashboard = None
                self._sidebar_container.objects = []
                self._show_edit_mode()
                self._page.main = [self._component_view]
                return

            if pathname.startswith(DASH_ROUTE_PREFIX):
                dashboard_id = pathname[len(DASH_ROUTE_PREFIX) :].strip("/")
                if dashboard_id:
                    await self._load_dashboard(dashboard_id)
                    return

            self._sidebar_container.objects = []
            key = tuple(pathname.strip("/").split("/"))
            if len(key) == 2 and self._entry_from_key(key):
                self._page.main = [await self._render_page(key)]
            else:
                self._page.main = [f"Invalid URL: {pathname}"]

        @pn.io.hold()
        def _show_edit_mode(self):
            self._controls_row.visible = True
            self._component_status.visible = bool(self._component_status.object)
            self._tile_grid.editable = True
            self._tile_grid.card = True
            if self._mode_toggle.value == "wiring":
                self._workspace_area[:] = [self._flow_canvas]
            else:
                self._workspace_area[:] = [self._tile_grid]
                self._rebuild_tile_grid()

        @pn.io.hold()
        def _show_view_mode(self):
            self._controls_row.visible = False
            self._component_status.visible = False
            self._tile_grid.param.update(card=False, editable=False)
            self._workspace_area[:] = [self._tile_grid]
            self._rebuild_tile_grid()

        _DASHBOARD_ACTIONS: t.TypeVar[list[_DASHBOARD_ACTION_TYPE, ...]] = (
            {"label": "Edit", "icon": "edit"},
            {"label": "Rename", "icon": "drive_file_rename_outline"},
            {"label": "Delete", "icon": "delete"},
        )

        def _get_dashboard_menu_items(self) -> list[dict]:
            items = []
            dashboards = self._store.list_dashboards(self._user_id)
            for d in dashboards:
                items.append(
                    {
                        "icon": "dashboard",
                        "label": d.title,
                        "path": f"{DASH_ROUTE_PREFIX}{d.dashboard_id}",
                        # "href": f"{DASH_ROUTE_PREFIX}{d.dashboard_id}",  # Else we can't click on the submenu
                        "disable_link": True,
                        "actions": self._DASHBOARD_ACTIONS,
                    }
                )
            items.append(
                {
                    "icon": "add",
                    "label": "New Dashboard",
                    "path": "__new_dashboard__",
                    "disable_link": True,
                    "actions": [{"label": "Create", "icon": "add", "inline": True}],
                }
            )
            return items

        def _dashboard_id_from_path(self, path: str) -> str | None:
            if path and path.startswith(DASH_ROUTE_PREFIX):
                return path[len(DASH_ROUTE_PREFIX) :].strip("/")
            return None

        def _on_action_edit(self, item):
            path = item.get("path", "")
            dashboard_id = self._dashboard_id_from_path(path)
            if dashboard_id:
                pn.state.execute(partial(self._load_dashboard_edit, dashboard_id))

        async def _load_dashboard_edit(self, dashboard_id: str):
            await self._load_dashboard(dashboard_id)
            self._show_edit_mode()

        @pn.io.hold()
        def _on_action_rename(self, item):
            path = item.get("path", "")
            dashboard_id = self._dashboard_id_from_path(path)
            if not dashboard_id:
                return
            self._dialog_name_input.value = item.get("label", "")
            self._dialog_context = {"action": "rename", "dashboard_id": dashboard_id}
            self._dialog.title = "Rename Dashboard"
            self._dialog.open = True

        @pn.io.hold()
        def _on_action_delete(self, item):
            path = item.get("path", "")
            dashboard_id = self._dashboard_id_from_path(path)
            if not dashboard_id:
                return
            self._dialog_name_input.value = item.get("label", "")
            self._dialog_name_input.disabled = True
            self._dialog_context = {"action": "delete", "dashboard_id": dashboard_id}
            self._dialog.title = "Delete Dashboard"
            self._dialog.open = True

        @pn.io.hold()
        def _on_action_create(self, item):
            self._dialog_name_input.value = ""
            self._dialog_name_input.disabled = False
            self._dialog_context = {"action": "create"}
            self._dialog.title = "Create Dashboard"
            self._dialog.open = True

        @pn.io.hold()
        def _on_dialog_confirm(self, _event):
            ctx = self._dialog_context
            self._dialog.open = False
            if not ctx:
                return
            action = ctx.get("action")
            if action == "create":
                t = self._dialog_name_input.value
                if t:
                    self._create_new_dashboard(t)
            elif action == "rename":
                new_t = self._dialog_name_input.value
                did = ctx.get("dashboard_id", "")
                if new_t and did:
                    self._rename_dashboard(did, new_t)
            elif action == "delete":
                did = ctx.get("dashboard_id", "")
                if did:
                    self._delete_dashboard(did)
            self._dialog_name_input.disabled = False
            self._dialog_context = {}

        @pn.io.hold()
        def _build_dialog(self):
            self._dialog_name_input = pmui.TextInput(
                label="Name",
                sizing_mode="stretch_width",
            )
            confirm_btn = pmui.Button(label="Confirm", color="primary")
            cancel_btn = pmui.Button(label="Cancel", color="light")
            confirm_btn.on_click(self._on_dialog_confirm)
            cancel_btn.on_click(lambda _: setattr(self._dialog, "open", False))
            self._dialog_context: dict = {}
            self._dialog = pmui.Dialog(
                objects=[
                    pn.Column(
                        self._dialog_name_input,
                        pn.Row(confirm_btn, cancel_btn),
                        sizing_mode="stretch_width",
                    )
                ],
                title="Dashboard",
                open=False,
                min_width=350,
            )
            return self._dialog

        def get_sidebar(self):
            sections: dict[str, list[RegistryEntry]] = {}
            for entry in self._page_entries.values():
                sections.setdefault(entry.section, []).append(entry)

            menu_items = [
                {
                    "label": section.replace("_", " "),
                    "selectable": False,
                    "icon": None,
                    "items": [
                        {
                            "icon": None,
                            "label": page_entry.title,
                            "path": page_entry.page_path,
                            "href": page_entry.page_path,
                            "disable_link": True,
                        }
                        for page_entry in sorted(section_apps, key=lambda e: e.name)
                    ],
                }
                for section, section_apps in sorted(sections.items())
            ]
            menu_items.append(
                {
                    "label": "Custom Apps",
                    "selectable": False,
                    "icon": None,
                    "items": self._get_dashboard_menu_items(),
                }
            )

            current_path = pn.state.location.pathname if pn.state.location is not None else ""
            pathname = "/" + (current_path.strip("/") or self._default_page)
            initial_active = next(
                (
                    (si, pi)
                    for si, s in enumerate(menu_items)
                    for pi, p in enumerate(s["items"])
                    if p["path"] == pathname
                ),
                None,
            )

            def on_click(event):
                if "path" not in event or pn.state.location is None:
                    return
                path = event["path"]
                if path == "__new_dashboard__":
                    return
                if path == pn.state.location.pathname:
                    return
                pn.state.location.pathname = path
                pn.state.execute(self._load_page_layout)

            self._menu_list = pmui.MenuList(
                items=menu_items,
                on_click=on_click,
                dense=True,
                expanded=list(range(len(menu_items))),
                active=initial_active,
                sizing_mode="stretch_width",
            )

            self._menu_list.on_action("Edit", self._on_action_edit)
            self._menu_list.on_action("Rename", self._on_action_rename)
            self._menu_list.on_action("Delete", self._on_action_delete)
            self._menu_list.on_action("Create", self._on_action_create)

            dialog = self._build_dialog()

            return [self._menu_list, dialog, self._sidebar_container]

        def __panel__(self):
            return self._page

        @classmethod
        def build_routes(cls) -> dict[str, type]:
            """Generate route mapping for pn.serve."""
            routes: dict[str, t.Any] = {
                "/": cls,
                COMPONENTS_ROUTE: cls,
                f"{DASH_ROUTE_PREFIX}[^/]+": cls,
            }
            for app_id in cls._page_entries:
                routes[f"/{app_id}"] = cls
            return routes

    return FlowDashApp

build_component_specs(registry)

Build specs for all component-enabled entries in a registry.

Source code in src/panel_flowdash/component_spec.py
def build_component_specs(
    registry: dict[str, RegistryEntry],
) -> dict[str, ComponentSpec]:
    """Build specs for all component-enabled entries in a registry."""
    specs = {}
    for app_id, entry in registry.items():
        if entry.metadata.component:
            specs[app_id] = build_component_spec(entry)
    return specs

build_registry(project_dir)

Scan a project directory for page/component modules.

Expects a structure like: project_dir/ SectionA/ page1.py (exports app) page2.py SectionB/ widget.py

Each .py file must export an app object decorated with @register.

Source code in src/panel_flowdash/app.py
def build_registry(project_dir: Path) -> dict[str, RegistryEntry]:
    """Scan a project directory for page/component modules.

    Expects a structure like:
        project_dir/
            SectionA/
                page1.py   (exports `app`)
                page2.py
            SectionB/
                widget.py

    Each .py file must export an `app` object decorated with @register.
    """
    registry: dict[str, RegistryEntry] = {}

    for section_dir in sorted(project_dir.glob("*")):
        if not section_dir.is_dir() or section_dir.name.startswith(("_", ".")):
            continue
        section = section_dir.name
        for module_path in sorted(section_dir.glob("*.py")):
            if module_path.name.startswith("_"):
                continue

            module_name = ".".join(module_path.relative_to(project_dir).with_suffix("").parts)
            try:
                module = importlib.import_module(module_name)
            except Exception as exc:
                warnings.warn(
                    f"Failed to import '{module_name}': {exc}",
                    stacklevel=2,
                )
                continue

            if not hasattr(module, "app"):
                warnings.warn(f"Module '{module_name}' has no 'app' export.", stacklevel=2)
                continue

            app = module.app
            metadata = PanelAppMetadata.from_app(app)
            if not metadata.page and not metadata.component:
                warnings.warn(
                    f"Module '{module_name}' ignored: page=False and component=False.",
                    stacklevel=2,
                )
                continue

            app_id = f"{section}/{module_path.stem}"
            registry[app_id] = RegistryEntry(
                app_id=app_id,
                section=section,
                name=module_path.stem,
                page_path=f"/{app_id}",
                module_name=module_name,
                app=app,
                metadata=metadata,
            )

    return registry

build_session_state_class(registry)

Build a Parameterized subclass with one param per declared state key.

Scans the registry for all provides and requires keys and creates a dynamic class whose parameters represent shared session state.

Source code in src/panel_flowdash/session_state.py
def build_session_state_class(
    registry: dict[str, RegistryEntry],
) -> type[param.Parameterized]:
    """Build a Parameterized subclass with one param per declared state key.

    Scans the registry for all `provides` and `requires` keys and creates
    a dynamic class whose parameters represent shared session state.
    """
    state_keys: dict[str, Any] = {}

    for entry in registry.values():
        for key in entry.metadata.provides:
            if isinstance(key, str) and key not in state_keys:
                state_keys[key] = None
            elif isinstance(key, dict):
                k = key.get("key", "")
                if k and k not in state_keys:
                    state_keys[k] = None
        for req in entry.metadata.requires:
            if isinstance(req, str):
                if req not in state_keys:
                    state_keys[req] = None
            elif isinstance(req, dict):
                k = req.get("key", "")
                if k and k not in state_keys:
                    state_keys[k] = req.get("fallback")

    params = {
        key: param.Parameter(default=default, allow_None=True)
        for key, default in state_keys.items()
    }

    return type("SessionState", (param.Parameterized,), params)

check_requirements(state, requires)

Check which required keys are unsatisfied on the given state instance.

Returns a list of dicts describing each unsatisfied requirement. An empty list means all requirements are met.

Source code in src/panel_flowdash/session_state.py
def check_requirements(state: param.Parameterized, requires: list) -> list[dict]:
    """Check which required keys are unsatisfied on the given state instance.

    Returns a list of dicts describing each unsatisfied requirement.
    An empty list means all requirements are met.
    """
    unsatisfied = []
    for req in requires:
        if isinstance(req, str):
            key, required, blocking, fallback = req, True, True, None
        else:
            key = req.get("key", "")
            required = req.get("required", True)
            blocking = req.get("blocking", True)
            fallback = req.get("fallback")

        if not key or not required:
            continue

        value = getattr(state, key, None)
        if value is None:
            unsatisfied.append({"key": key, "blocking": blocking, "fallback": fallback})

    return unsatisfied