Skip to content

Commit 4d9ab40

Browse files
Merge pull request #240 from ezmsg-org/feature/dashboard-integration
Feature: Dashboard integration
2 parents de8644e + 20c7b08 commit 4d9ab40

12 files changed

Lines changed: 504 additions & 17 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,4 +95,4 @@ These publications provide insights into the practical applications and impact o
9595

9696
## Financial Support
9797

98-
`ezmsg` is supported by Johns Hopkins University (JHU), the JHU Applied Physics Laboratory (APL), and by the Wyss Center for Bio and Neuro Engineering.
98+
`ezmsg` is supported by Johns Hopkins University (JHU), the JHU Applied Physics Laboratory (APL), Blackrock Neurotech and by the Wyss Center for Bio and Neuro Engineering.

pyproject.toml

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "ezmsg"
3-
version = "3.8.0"
3+
version = "3.9.0"
44
description = "A simple DAG-based computation model"
55
authors = [
66
{ name = "Griffin Milsap", email = "griffin.milsap@gmail.com" },
@@ -52,9 +52,6 @@ docs = [
5252
axisarray = [
5353
"numpy>=2.2.6",
5454
]
55-
perf = [
56-
"xarray",
57-
]
5855

5956
[project.scripts]
6057
ezmsg = "ezmsg.core.command:cmdline"
@@ -63,6 +60,12 @@ ezmsg = "ezmsg.core.command:cmdline"
6360
axisarray = [
6461
"numpy>=2.2.6",
6562
]
63+
perf = [
64+
"xarray>=2025.6.1",
65+
]
66+
dashboard = [
67+
"ezmsg-dashboard; python_version >= '3.11'",
68+
]
6669

6770
[tool.pytest.ini_options]
6871
addopts = ["--import-mode=importlib"]

src/ezmsg/core/command.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,17 @@
1010
from .commands.start import handle_start
1111
from .netprotocol import (
1212
Address,
13+
DEFAULT_HOST,
1314
GRAPHSERVER_ADDR_ENV,
1415
GRAPHSERVER_PORT_DEFAULT,
1516
PUBLISHER_START_PORT_ENV,
1617
PUBLISHER_START_PORT_DEFAULT,
1718
)
19+
from .commands.dashboard import (
20+
DASHBOARD_ADDR_ENV,
21+
DASHBOARD_INSTALL_HINT,
22+
DASHBOARD_PORT_DEFAULT,
23+
)
1824

1925

2026
def build_parser() -> argparse.ArgumentParser:
@@ -30,6 +36,7 @@ def build_parser() -> argparse.ArgumentParser:
3036
epilog=f"""
3137
You can also change server configuration with environment variables.
3238
GraphServer will be hosted on ${GRAPHSERVER_ADDR_ENV} (default port: {GRAPHSERVER_PORT_DEFAULT}).
39+
Dashboard will be hosted on ${DASHBOARD_ADDR_ENV} (default: {DEFAULT_HOST}:{DASHBOARD_PORT_DEFAULT}, or graph port + 1).
3340
Publishers will be assigned available ports starting from {PUBLISHER_START_PORT_DEFAULT}. (Change with ${PUBLISHER_START_PORT_ENV})
3441
""",
3542
)
@@ -55,7 +62,12 @@ def cmdline(argv: list[str] | None = None) -> None:
5562

5663
result = args._handler(args)
5764
if inspect.isawaitable(result):
58-
asyncio.run(result)
65+
try:
66+
asyncio.run(result)
67+
except KeyboardInterrupt:
68+
# asyncio.run() re-raises KeyboardInterrupt after cancelling the main
69+
# task on Ctrl+C, even when command cleanup has already completed.
70+
pass
5971

6072

6173
async def run_command(
@@ -64,8 +76,10 @@ async def run_command(
6476
target: str = "live",
6577
compact: int | None = None,
6678
nobrowser: bool = False,
79+
dashboard: int | bool | None = None,
6780
) -> None:
6881
handlers = {
82+
"dashboard": None,
6983
"serve": handle_serve,
7084
"start": handle_start,
7185
"shutdown": handle_shutdown,
@@ -74,11 +88,25 @@ async def run_command(
7488
}
7589
if cmd not in handlers:
7690
raise ValueError(f"Unknown ezmsg command '{cmd}'")
91+
if cmd == "dashboard":
92+
try:
93+
from ezmsg.dashboard.server import handle_dashboard
94+
except ImportError as exc:
95+
raise RuntimeError(DASHBOARD_INSTALL_HINT) from exc
96+
handlers["dashboard"] = handle_dashboard
7797
args = argparse.Namespace(
7898
command=cmd,
7999
address=str(graph_address),
100+
graph_address=str(graph_address),
80101
target=target,
81102
compact=compact,
82103
nobrowser=nobrowser,
104+
dashboard=dashboard,
105+
host="127.0.0.1",
106+
port=8000,
107+
open_browser=False,
108+
log_level="info",
83109
)
84-
await handlers[cmd](args)
110+
result = handlers[cmd](args)
111+
if inspect.isawaitable(result):
112+
await result

src/ezmsg/core/commands/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import argparse
22

3+
from .dashboard_cmd import setup_dashboard_cmdline
34
from .graphviz import setup_graphviz_cmdline
45
from .mermaid import setup_mermaid_cmdline
56
from .serve import setup_serve_cmdline
@@ -8,6 +9,7 @@
89

910

1011
def setup_core_cmdline(subparsers: argparse._SubParsersAction) -> None:
12+
setup_dashboard_cmdline(subparsers)
1113
setup_serve_cmdline(subparsers)
1214
setup_start_cmdline(subparsers)
1315
setup_shutdown_cmdline(subparsers)

src/ezmsg/core/commands/common.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import argparse
22

3-
from ..netprotocol import Address, GRAPHSERVER_PORT_DEFAULT
3+
from ..graphserver import GraphService
4+
from ..netprotocol import Address
45

56

67
def add_address_argument(parser: argparse.ArgumentParser) -> None:
@@ -21,5 +22,5 @@ def add_compact_argument(parser: argparse.ArgumentParser) -> None:
2122

2223
def graph_address_from_args(args: argparse.Namespace) -> Address:
2324
if args.address is None:
24-
return Address("127.0.0.1", GRAPHSERVER_PORT_DEFAULT)
25+
return GraphService.default_address()
2526
return Address.from_string(args.address)
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import argparse
2+
import os
3+
from typing import Any
4+
5+
from ..netprotocol import (
6+
Address,
7+
DEFAULT_HOST,
8+
GRAPHSERVER_ADDR_ENV,
9+
GRAPHSERVER_PORT_DEFAULT,
10+
)
11+
12+
DASHBOARD_ADDR_ENV = "EZMSG_DASHBOARD_ADDR"
13+
DASHBOARD_PORT_DEFAULT = GRAPHSERVER_PORT_DEFAULT + 1
14+
DASHBOARD_INSTALL_HINT = (
15+
"Dashboard support requires the optional `ezmsg-dashboard` package. "
16+
"Install it with `pip install ezmsg-dashboard`."
17+
)
18+
19+
20+
class DashboardDependencyError(RuntimeError):
21+
pass
22+
23+
24+
def add_dashboard_argument(parser: argparse.ArgumentParser) -> None:
25+
parser.add_argument(
26+
"--dashboard",
27+
nargs="?",
28+
const=True,
29+
default=None,
30+
type=int,
31+
metavar="PORT",
32+
help=(
33+
"Serve the optional ezmsg dashboard alongside the graph server. "
34+
"If PORT is omitted, ezmsg uses the configured dashboard address or graph port + 1."
35+
),
36+
)
37+
38+
39+
def default_graph_address() -> Address:
40+
address_str = os.environ.get(
41+
GRAPHSERVER_ADDR_ENV, f"{DEFAULT_HOST}:{GRAPHSERVER_PORT_DEFAULT}"
42+
)
43+
return Address.from_string(address_str)
44+
45+
46+
def dashboard_address(
47+
graph_address: Address | None = None, dashboard_port: int | None = None
48+
) -> Address:
49+
if DASHBOARD_ADDR_ENV in os.environ:
50+
address = Address.from_string(os.environ[DASHBOARD_ADDR_ENV])
51+
else:
52+
resolved_graph_address = graph_address or default_graph_address()
53+
address = Address(resolved_graph_address.host, resolved_graph_address.port + 1)
54+
55+
if dashboard_port is not None:
56+
return Address(address.host, dashboard_port)
57+
return address
58+
59+
60+
def require_dashboard_dependency() -> Any:
61+
try:
62+
from ezmsg.dashboard.server import start_dashboard_server
63+
except ImportError as exc:
64+
raise DashboardDependencyError(DASHBOARD_INSTALL_HINT) from exc
65+
return start_dashboard_server
66+
67+
68+
def start_dashboard(graph_address: Address, dashboard_port: int | None = None) -> Any:
69+
start_dashboard_server = require_dashboard_dependency()
70+
71+
address = dashboard_address(graph_address, dashboard_port=dashboard_port)
72+
return start_dashboard_server(
73+
graph_address=graph_address,
74+
host=address.host,
75+
port=address.port,
76+
log_level="warning",
77+
)
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import argparse
2+
import logging
3+
4+
from .dashboard import DASHBOARD_INSTALL_HINT
5+
6+
logger = logging.getLogger("ezmsg")
7+
8+
9+
def _warn_dashboard_dependency_missing(_: argparse.Namespace) -> None:
10+
logger.warning(DASHBOARD_INSTALL_HINT)
11+
12+
13+
def _setup_dashboard_fallback(subparsers: argparse._SubParsersAction) -> None:
14+
parser = subparsers.add_parser(
15+
"dashboard",
16+
help="launch the optional ezmsg dashboard server",
17+
description="Launch the optional ezmsg dashboard server.",
18+
)
19+
parser.add_argument("--graph-address", default=None, help="Address of the ezmsg graph server.")
20+
parser.add_argument("--host", default="127.0.0.1", help="HTTP bind host for the dashboard.")
21+
parser.add_argument("--port", type=int, default=8000, help="HTTP bind port for the dashboard.")
22+
parser.add_argument(
23+
"--open-browser",
24+
action="store_true",
25+
help="Open the dashboard in a browser after startup.",
26+
)
27+
parser.add_argument(
28+
"--log-level",
29+
default="info",
30+
choices=["critical", "error", "warning", "info", "debug", "trace"],
31+
help="Uvicorn log verbosity.",
32+
)
33+
parser.set_defaults(_handler=_warn_dashboard_dependency_missing)
34+
35+
36+
def setup_dashboard_cmdline(subparsers: argparse._SubParsersAction) -> None:
37+
try:
38+
from ezmsg.dashboard.server import setup_dashboard_cmdline as setup_optional_dashboard
39+
except ImportError:
40+
_setup_dashboard_fallback(subparsers)
41+
return
42+
43+
setup_optional_dashboard(subparsers)

src/ezmsg/core/commands/serve.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44

55
from ..graphserver import GraphService
66
from .common import add_address_argument, graph_address_from_args
7+
from .dashboard import (
8+
DashboardDependencyError,
9+
add_dashboard_argument,
10+
start_dashboard,
11+
)
712

813
logger = logging.getLogger("ezmsg")
914

@@ -14,17 +19,29 @@ async def handle_serve(args: argparse.Namespace) -> None:
1419

1520
logger.info(f"GraphServer Address: {graph_address}")
1621
graph_server = graph_service.create_server()
22+
dashboard_server = None
1723

1824
try:
25+
if args.dashboard is not None:
26+
dashboard_port = args.dashboard if type(args.dashboard) is int else None
27+
dashboard_server = start_dashboard(
28+
graph_service.address, dashboard_port=dashboard_port
29+
)
30+
logger.info(f"Dashboard Address: {dashboard_server.url}")
1931
logger.info("Servers running...")
2032
await asyncio.to_thread(graph_server.join)
21-
except KeyboardInterrupt:
33+
except (KeyboardInterrupt, asyncio.CancelledError):
2234
logger.info("Interrupt detected; shutting down servers")
35+
except DashboardDependencyError as exc:
36+
logger.warning(str(exc))
2337
finally:
38+
if dashboard_server is not None:
39+
dashboard_server.stop()
2440
graph_server.stop()
2541

2642

2743
def setup_serve_cmdline(subparsers: argparse._SubParsersAction) -> None:
2844
parser = subparsers.add_parser("serve")
2945
add_address_argument(parser)
46+
add_dashboard_argument(parser)
3047
parser.set_defaults(_handler=handle_serve)

src/ezmsg/core/commands/start.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,30 @@
77
from ..graphserver import GraphService
88
from ..netprotocol import close_stream_writer
99
from .common import add_address_argument, graph_address_from_args
10+
from .dashboard import (
11+
DashboardDependencyError,
12+
add_dashboard_argument,
13+
require_dashboard_dependency,
14+
)
1015

1116
logger = logging.getLogger("ezmsg")
1217

1318

1419
async def handle_start(args: argparse.Namespace) -> None:
1520
graph_address = graph_address_from_args(args)
1621
graph_service = GraphService(graph_address)
22+
cmd = [sys.executable, "-m", "ezmsg.core", "serve", f"--address={graph_address}"]
23+
if args.dashboard is not None:
24+
try:
25+
require_dashboard_dependency()
26+
except DashboardDependencyError as exc:
27+
logger.warning(str(exc))
28+
return
29+
cmd.append("--dashboard")
30+
if type(args.dashboard) is int:
31+
cmd.append(str(args.dashboard))
1732

18-
popen = subprocess.Popen(
19-
[sys.executable, "-m", "ezmsg.core", "serve", f"--address={graph_address}"]
20-
)
33+
popen = subprocess.Popen(cmd)
2134

2235
while True:
2336
try:
@@ -33,4 +46,5 @@ async def handle_start(args: argparse.Namespace) -> None:
3346
def setup_start_cmdline(subparsers: argparse._SubParsersAction) -> None:
3447
parser = subparsers.add_parser("start")
3548
add_address_argument(parser)
49+
add_dashboard_argument(parser)
3650
parser.set_defaults(_handler=handle_start)

src/ezmsg/util/perf/run.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,9 +145,8 @@ def benchmark(
145145
)
146146

147147
try:
148-
communications = (
149-
DEFAULT_COMMS if comms is None else [Communication(c) for c in comms]
150-
)
148+
communication_names = DEFAULT_COMMS if comms is None else list(comms)
149+
communications = [Communication(c) for c in communication_names]
151150
except ValueError:
152151
ez.logger.error(
153152
f"Invalid test communications requested. Valid communications: {', '.join([c.value for c in Communication])}"

0 commit comments

Comments
 (0)