From 817131c6ceac41067b9a949729238f9b41f1c1b2 Mon Sep 17 00:00:00 2001 From: Eric Gustin <34000337+EricGustin@users.noreply.github.com> Date: Mon, 28 Apr 2025 09:22:55 -0800 Subject: [PATCH] Add `arcade dashboard` CLI Command (#330) * `arcade dashboard` opens the Arcade Dashboard in a web browser. Defaults to `https://api.arcade.dev/dashboard`, but is configurable via flags. * `arcade dashboard --local` opens your locally hosted Arcade Dashboard in a web browser. * Performs a health check of the engine and will print a warning to the console if the Engine is not healthy / not running. ---------------------------------------- * Inspiration from https://minikube.sigs.k8s.io/docs/handbook/dashboard/ --- arcade/arcade/cli/main.py | 64 +++++++++++++++++++++++- arcade/tests/cli/test_dashboard.py | 80 ++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 arcade/tests/cli/test_dashboard.py diff --git a/arcade/arcade/cli/main.py b/arcade/arcade/cli/main.py index fe605c30..3375385b 100644 --- a/arcade/arcade/cli/main.py +++ b/arcade/arcade/cli/main.py @@ -609,6 +609,62 @@ def deploy( raise typer.Exit(code=1) +@cli.command(help="Open the Arcade Dashboard in a web browser", rich_help_panel="User") +def dashboard( + host: str = typer.Option( + PROD_ENGINE_HOST, + "-h", + "--host", + help="The Arcade Engine host that serves the dashboard.", + ), + port: Optional[int] = typer.Option( + None, + "-p", + "--port", + help="The port of the Arcade Engine.", + ), + local: bool = typer.Option( + False, + "--local", + "-l", + help="Open the local dashboard instead of the default remote dashboard.", + ), + force_tls: bool = typer.Option( + False, + "--tls", + help="Whether to force TLS for the connection to the Arcade Engine.", + ), + force_no_tls: bool = typer.Option( + False, + "--no-tls", + help="Whether to disable TLS for the connection to the Arcade Engine.", + ), +) -> None: + """Opens the Arcade Dashboard in a web browser. + + The Dashboard is a web-based Arcade user interface that is served by the Arcade Engine. + """ + if local: + host = "localhost" + + # Construct base URL (for both health check and dashboard) + base_url = compute_base_url(force_tls, force_no_tls, host, port) + dashboard_url = f"{base_url}/dashboard" + + # Try to hit /health endpoint on engine and warn if it is down + config = validate_and_get_config() + with Arcade(api_key=config.api.key, base_url=base_url) as client: + log_engine_health(client) + + # Open the dashboard in a browser + console.print(f"Opening Arcade Dashboard at {dashboard_url}") + if not webbrowser.open(dashboard_url): + console.print( + f"If a browser doesn't open automatically, copy this URL and paste it into your browser: {dashboard_url}", + style="dim", + ) + + @cli.callback() def main_callback( ctx: typer.Context, @@ -621,7 +677,13 @@ def main_callback( help="Print version and exit.", ), ) -> None: - excluded_commands = {login.__name__, logout.__name__, serve.__name__, workerup.__name__} + excluded_commands = { + login.__name__, + logout.__name__, + serve.__name__, + workerup.__name__, + dashboard.__name__, + } if ctx.invoked_subcommand in excluded_commands: return diff --git a/arcade/tests/cli/test_dashboard.py b/arcade/tests/cli/test_dashboard.py new file mode 100644 index 00000000..3be0da00 --- /dev/null +++ b/arcade/tests/cli/test_dashboard.py @@ -0,0 +1,80 @@ +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from arcade.cli.constants import PROD_ENGINE_HOST +from arcade.cli.main import cli + +runner = CliRunner() + + +@pytest.mark.parametrize( + "args, expected_url", + [ + ([], f"https://{PROD_ENGINE_HOST}/dashboard"), + (["--local"], "http://localhost:9099/dashboard"), + (["--host", "custom.host.com"], "https://custom.host.com/dashboard"), + (["-h", "api.arcade.dev", "-p", "9099"], "https://api.arcade.dev:9099/dashboard"), + (["--local", "--port", "9099"], "http://localhost:9099/dashboard"), + (["--local", "--tls"], "https://localhost:9099/dashboard"), + (["--no-tls"], f"http://{PROD_ENGINE_HOST}/dashboard"), + ], +) +def test_dashboard_url_construction(args, expected_url): + """Test that the dashboard command constructs the correct URL with various args.""" + with ( + patch("webbrowser.open") as mock_open, + patch("arcade.cli.main.validate_and_get_config") as mock_validate, + patch("arcade.cli.main.log_engine_health") as mock_health_check, + ): + # Setup mocks + mock_open.return_value = True # Successfully opened browser + mock_validate.return_value = MagicMock() + mock_health_check.return_value = None # Successful health check + + # Run command + result = runner.invoke(cli, ["dashboard", *args]) + + assert result.exit_code == 0 + mock_open.assert_called_once_with(expected_url) + mock_health_check.assert_called_once() + + +def test_fallback_when_browser_fails(): + """Test fallback message when browser.open fails.""" + with ( + patch("webbrowser.open") as mock_open, + patch("arcade.cli.main.validate_and_get_config") as mock_validate, + patch("arcade.cli.main.log_engine_health") as mock_health_check, + patch("arcade.cli.main.console.print") as mock_print, + ): + mock_open.return_value = False # Failed to open browser + mock_validate.return_value = MagicMock() + mock_health_check.return_value = None + + result = runner.invoke(cli, ["dashboard"]) + + assert result.exit_code == 0 + mock_print.assert_any_call( + f"If a browser doesn't open automatically, copy this URL and paste it into your browser: https://{PROD_ENGINE_HOST}/dashboard", + style="dim", + ) + + +def test_health_check_success(): + """Test successful health check.""" + with ( + patch("webbrowser.open") as mock_open, + patch("arcade.cli.main.validate_and_get_config") as mock_validate, + patch("arcade.cli.main.log_engine_health") as mock_health_check, + ): + mock_open.return_value = True + mock_validate.return_value = MagicMock() + mock_health_check.return_value = None # Successful health check + + result = runner.invoke(cli, ["dashboard"]) + + assert result.exit_code == 0 + mock_health_check.assert_called_once() + mock_open.assert_called_once()