mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-16 00:15:18 +00:00
Merge pull request #1380 from oraios/dashboard-manager
Introduce DashboardManager with three interface modes for the dashboard
This commit is contained in:
11 files changed
+710
-93
No files matched your search
@@ -41,6 +41,8 @@ Status of the `main` branch. Changes prior to the next official version change w
|
||||
- Fix: Lean4 stale cache — empty document symbol responses (returned before `lake build` completes) are no longer persisted, preventing symbols from being permanently hidden #1356
|
||||
|
||||
Dashboard:
|
||||
- Add configurable dashboard interface mode (new global configuration setting `web_dashboard_interface`):
|
||||
Three modes (browser, native app with tray, tray manager for aggregating multiple instances) are supported, depending on the OS
|
||||
- Fix: Memory leaks in frontend when using Chromium-based browsers/Windows webview #1389
|
||||
|
||||
# v1.1.2 (2026-04-14)
|
||||
|
||||
@@ -19,10 +19,13 @@ Both can be configured in Serena's [configuration](050_configuration) file (`ser
|
||||
|
||||
## The Serena Dashboard
|
||||
|
||||
The dashboard is a web application, which is opened via a native application wrapper on supported platforms (e.g. Windows),
|
||||
where it will appear in the system tray. **Click on the tray icon** to open the application window.
|
||||
The dashboard is a web application, which is opened in one of three ways:
|
||||
|
||||
On other platforms, where native app support is difficult to provide (e.g. Linux), the dashboard will be opened in your default web browser instead.
|
||||
* via a native application wrapper with accompanying tray icon (on supported platforms)
|
||||
* via a tray manager that aggregates multiple instances (on supported platforms)
|
||||
* via your default web browser (supported on all platforms)
|
||||
|
||||
Configure your preferred interface via the setting `web_dashoard_interface` in [Serena's global configuration file](global-config) (see below).
|
||||
|
||||
By default, the dashboard can be accessed at `http://localhost:24282/dashboard/index.html`,
|
||||
but a higher port may be used if the default port is unavailable/multiple instances are running.
|
||||
@@ -45,6 +48,7 @@ The dashboard provides ...
|
||||
You can configure settings for the dashboard in the [global configuration file](global-config).
|
||||
|
||||
In particular, you can configure
|
||||
* the interface through which the dashboard is accessed (see above)
|
||||
* whether the dashboard is enabled at all.
|
||||
We highly recommend keeping it enabled, as it provides valuable insights into Serena's operation and allows you to adjust configuration conveniently.
|
||||
* whether the dashboard window is opened automatically when Serena is started (see below).
|
||||
@@ -54,11 +58,12 @@ In particular, you can configure
|
||||
|
||||
When Serena is started, the Dashboard window is opened by default in order to make users aware of its existence.
|
||||
|
||||
If you prefer not to have this happen (e.g., to avoid focus stealing), you can prevent it from opening
|
||||
If you prefer not to have this happen, you can prevent it from opening
|
||||
by setting `web_dashboard_open_on_launch: False` in the [global configuration file](global-config) or by passing `--open-web-dashboard False`
|
||||
to the `start-mcp-server` CLI command.
|
||||
|
||||
On platforms supporting the tray icon, you can conveniently open the dashboard at any time by clicking on the tray icon, so automatic
|
||||
On platforms supporting the tray icon (see also configuration option `web_dashboard_interface`),
|
||||
you can conveniently open the dashboard at any time by clicking on the tray icon, so automatic
|
||||
opening is not a requirement to be able to access the dashboard on these platforms.
|
||||
|
||||
On other platforms, you may still access it by
|
||||
|
||||
@@ -21,3 +21,15 @@ def serena_version() -> str:
|
||||
except:
|
||||
pass
|
||||
return version
|
||||
|
||||
|
||||
def _init_log_configuration() -> None:
|
||||
from sensai.util import logging
|
||||
|
||||
def _configure() -> None:
|
||||
logging.getLogger("PIL").setLevel(logging.WARNING)
|
||||
|
||||
logging.set_configure_callback(_configure)
|
||||
|
||||
|
||||
_init_log_configuration()
|
||||
+199
-71
@@ -7,12 +7,13 @@ import multiprocessing
|
||||
import os
|
||||
import platform
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable, Iterator, Sequence
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from logging import Logger
|
||||
from typing import TYPE_CHECKING, Optional, TypeVar
|
||||
|
||||
@@ -36,7 +37,7 @@ from serena.config.serena_config import (
|
||||
SerenaPaths,
|
||||
ToolInclusionDefinition,
|
||||
)
|
||||
from serena.dashboard import SerenaDashboardAPI, SerenaDashboardViewer
|
||||
from serena.dashboard import SerenaDashboardAPI, SerenaDashboardTrayManager, SerenaDashboardViewer, open_url_in_browser
|
||||
from serena.ls_manager import LanguageServerManager
|
||||
from serena.project import MemoriesManager, Project
|
||||
from serena.prompt_factory import SerenaPromptFactory
|
||||
@@ -321,6 +322,178 @@ class ProjectPromptProvisionStatus:
|
||||
return self._get_session_status(session_id).project_activation_message_provided
|
||||
|
||||
|
||||
class DashboardManager:
|
||||
class Mode(Enum):
|
||||
BROWSER = "browser"
|
||||
"""
|
||||
Open the dashboard in the default browser; supported on all platforms.
|
||||
"""
|
||||
WEBVIEW = "app"
|
||||
"""
|
||||
Open the dashboard via a native window (using pywebview) which minimises to the tray;
|
||||
supported on Windows and macOS (but on macOS, tray apps for multiple instances accumulate
|
||||
in the top bar, which users may not want)
|
||||
"""
|
||||
TRAY_MANAGER = "tray_manager"
|
||||
"""
|
||||
Register dashboard instance with a central manager tray app (single tray icon for all instances),
|
||||
spawning the tray manager if not already running; supported on macOS and Windows.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def from_platform(cls) -> "DashboardManager.Mode":
|
||||
match platform.system():
|
||||
case "Windows":
|
||||
return cls.WEBVIEW
|
||||
case "Darwin":
|
||||
# TODO: Switch to TRAY_MANAGER once support is tested
|
||||
return cls.BROWSER
|
||||
case _:
|
||||
return cls.BROWSER
|
||||
|
||||
def is_supported(self) -> bool:
|
||||
"""
|
||||
:return: whether the mode is supported on the current platform
|
||||
"""
|
||||
if self == DashboardManager.Mode.WEBVIEW:
|
||||
return SerenaDashboardViewer.is_current_platform_supported()
|
||||
elif self == DashboardManager.Mode.TRAY_MANAGER:
|
||||
return SerenaDashboardTrayManager.is_current_platform_supported()
|
||||
else:
|
||||
return True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
port: int,
|
||||
host_listen_address: str,
|
||||
open_dashboard_on_launch: bool,
|
||||
active_project: Project | None = None,
|
||||
mode_str: str | None = None,
|
||||
):
|
||||
# determine requested mode
|
||||
if mode_str is not None:
|
||||
try:
|
||||
mode = self.Mode(mode_str)
|
||||
except ValueError:
|
||||
mode = self.Mode.from_platform()
|
||||
log.warning(f"Invalid dashboard interface mode '{mode_str}' specified; falling back to platform default '{mode.value}'.")
|
||||
else:
|
||||
mode = self.Mode.from_platform()
|
||||
|
||||
# check for mode compatibility
|
||||
if not mode.is_supported():
|
||||
fallback_mode = self.Mode.from_platform()
|
||||
log.warning(
|
||||
f"Dashboard interface mode '{mode.value}' is not supported on the current platform; "
|
||||
"falling back to '{fallback_mode.value}'."
|
||||
)
|
||||
mode = fallback_mode
|
||||
|
||||
self._port = port
|
||||
self._mode = mode
|
||||
self._dashboard_viewer_process: multiprocessing.Process | None = None
|
||||
self._tray_manager_lock = threading.Lock()
|
||||
|
||||
dashboard_host = host_listen_address
|
||||
if dashboard_host == "0.0.0.0":
|
||||
dashboard_host = "localhost"
|
||||
self.url = f"http://{dashboard_host}:{port}/dashboard/index.html"
|
||||
|
||||
# handle startup
|
||||
match self._mode:
|
||||
case self.Mode.WEBVIEW:
|
||||
self._start_dashboard_viewer(minimized=not open_dashboard_on_launch)
|
||||
case self.Mode.TRAY_MANAGER:
|
||||
init_fn = lambda: self._tray_manager_register(open_on_launch=open_dashboard_on_launch, active_project=active_project)
|
||||
threading.Thread(target=init_fn, name="init-DashboardTrayManager", daemon=True).start()
|
||||
case self.Mode.BROWSER:
|
||||
if open_dashboard_on_launch:
|
||||
if not system_has_usable_display():
|
||||
log.info("Not opening the Serena dashboard because no usable display was detected.")
|
||||
else:
|
||||
self.open_dashboard_in_browser()
|
||||
|
||||
def open_dashboard_in_browser(self) -> None:
|
||||
open_url_in_browser(self.url, use_subprocess=True)
|
||||
|
||||
@staticmethod
|
||||
def _start_dashboard_viewer_process_function(url: str, minimized: bool, parent_process_id: int) -> None:
|
||||
"""
|
||||
Main function of the subprocess for starting the dashboard viewer
|
||||
"""
|
||||
try:
|
||||
SerenaDashboardViewer(url, start_minimized=minimized, parent_process_id=parent_process_id).run()
|
||||
except webview.errors.WebViewException as e:
|
||||
log.warning(f"Could not open Serena Dashboard viewer. Cause:\n{e}")
|
||||
# Fall back to opening the browser window if the window was supposed to be shown directly
|
||||
if not minimized:
|
||||
open_url_in_browser(url, use_subprocess=True)
|
||||
|
||||
def _start_dashboard_viewer(self, minimized: bool) -> None:
|
||||
"""
|
||||
Starts the dashboard viewer (in a separate process) or, if the current platform does not support it,
|
||||
opens the dashboard in the default web browser.
|
||||
|
||||
:param minimized: whether the dashboard viewer should be started minimized (if supported on the current platform).
|
||||
If the viewer is not supported on the current platform, then this controls whether to open the browser window.
|
||||
"""
|
||||
self._dashboard_viewer_process = multiprocessing.Process(
|
||||
target=self._start_dashboard_viewer_process_function, args=(self.url, minimized, os.getpid()), daemon=True
|
||||
)
|
||||
self._dashboard_viewer_process.start()
|
||||
|
||||
def _tray_manager_register(self, open_on_launch: bool, active_project: Project | None) -> None:
|
||||
"""
|
||||
Ensure the tray manager is running and register this dashboard instance with it.
|
||||
|
||||
If the current platform supports the native tray manager, this method starts
|
||||
the manager (if not already running) and registers the instance. Otherwise,
|
||||
it falls back to opening the dashboard in the default web browser.
|
||||
|
||||
:param open_on_launch: whether the dashboard should be opened immediately
|
||||
"""
|
||||
with LogTime("Dashboard tray manager initialisation"):
|
||||
with self._tray_manager_lock:
|
||||
# ensure the singleton tray manager process is running
|
||||
SerenaDashboardTrayManager.ensure_running()
|
||||
|
||||
# determine the current project name (if any)
|
||||
project_name = active_project.project_name if active_project is not None else None
|
||||
|
||||
# register this instance with the tray manager
|
||||
SerenaDashboardTrayManager.register_instance(
|
||||
port=self._port,
|
||||
dashboard_url=self.url,
|
||||
project=project_name,
|
||||
started_at=datetime.now().isoformat(timespec="seconds"),
|
||||
open_viewer=open_on_launch,
|
||||
)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""
|
||||
Frees resources, terminating the dashboard viewer process (if any) and unregistering from the tray manager (if applicable).
|
||||
"""
|
||||
if self._dashboard_viewer_process is not None:
|
||||
log.info("Stopping the dashboard viewer process ...")
|
||||
self._dashboard_viewer_process.terminate()
|
||||
self._dashboard_viewer_process = None
|
||||
|
||||
if self._mode == self.Mode.TRAY_MANAGER:
|
||||
with self._tray_manager_lock:
|
||||
SerenaDashboardTrayManager.unregister_instance(port=self._port)
|
||||
|
||||
def update_active_project(self, active_project: Project | None) -> None:
|
||||
"""
|
||||
Updates the active project (where applicable).
|
||||
|
||||
:param active_project: the currently active project or None if no project is active
|
||||
"""
|
||||
if self._mode == self.Mode.TRAY_MANAGER:
|
||||
with self._tray_manager_lock:
|
||||
project_name = active_project.project_name if active_project is not None else None
|
||||
SerenaDashboardTrayManager.update_project(port=self._port, project=project_name)
|
||||
|
||||
|
||||
class SerenaAgent:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -344,8 +517,9 @@ class SerenaAgent:
|
||||
if necessary.
|
||||
"""
|
||||
self._active_project: Project | None = None
|
||||
self._project_activation_callback = project_activation_callback
|
||||
self._gui_log_viewer: Optional["GuiLogViewer"] = None
|
||||
self._dashboard_viewer_process: multiprocessing.Process | None = None
|
||||
self._dashboard_manager: DashboardManager | None = None
|
||||
self._project_prompt_status = ProjectPromptProvisionStatus()
|
||||
self._mode_overrides = modes
|
||||
self.version = serena_version()
|
||||
@@ -365,9 +539,6 @@ class SerenaAgent:
|
||||
self.serena_config.get_registered_project(project, autoregister=True) if project is not None else None
|
||||
)
|
||||
|
||||
# dashboard URL (set when dashboard is started)
|
||||
self._dashboard_url: str | None = None
|
||||
|
||||
# adjust log level
|
||||
serena_log_level = self.serena_config.log_level
|
||||
if Logger.root.level != serena_log_level:
|
||||
@@ -445,7 +616,6 @@ class SerenaAgent:
|
||||
|
||||
# Initialize the prompt factory
|
||||
self.prompt_factory = SerenaPromptFactory()
|
||||
self._project_activation_callback = project_activation_callback
|
||||
|
||||
# activate the given project (if any), also updating the active modes
|
||||
# Note: We cannot update the active tools yet, because the base toolset has not been computed yet
|
||||
@@ -475,16 +645,17 @@ class SerenaAgent:
|
||||
self._dashboard_thread, port = SerenaDashboardAPI(
|
||||
get_memory_log_handler(), tool_names, agent=self, tool_usage_stats=self._tool_usage_stats
|
||||
).run_in_thread(host=self.serena_config.web_dashboard_listen_address)
|
||||
dashboard_host = self.serena_config.web_dashboard_listen_address
|
||||
if dashboard_host == "0.0.0.0":
|
||||
dashboard_host = "localhost"
|
||||
dashboard_url = f"http://{dashboard_host}:{port}/dashboard/index.html"
|
||||
self._dashboard_url = dashboard_url
|
||||
log.info("Serena web dashboard started at %s", dashboard_url)
|
||||
self._start_dashboard_viewer(minimized=not self.serena_config.web_dashboard_open_on_launch)
|
||||
self._dashboard_manager = DashboardManager(
|
||||
port,
|
||||
self.serena_config.web_dashboard_listen_address,
|
||||
self.serena_config.web_dashboard_open_on_launch,
|
||||
self._active_project,
|
||||
mode_str=self.serena_config.web_dashboard_interface,
|
||||
)
|
||||
log.info("Serena web dashboard started at %s", self._dashboard_manager.url)
|
||||
# inform the GUI window (if any)
|
||||
if self._gui_log_viewer is not None:
|
||||
self._gui_log_viewer.set_dashboard_url(dashboard_url)
|
||||
self._gui_log_viewer.set_dashboard_url(self._dashboard_manager.url)
|
||||
|
||||
self._send_usage_info()
|
||||
|
||||
@@ -652,44 +823,9 @@ class SerenaAgent:
|
||||
"""
|
||||
:return: the URL of the web dashboard, or None if the dashboard is not running
|
||||
"""
|
||||
return self._dashboard_url
|
||||
|
||||
@staticmethod
|
||||
def _start_dashboard_viewer_process_function(url: str, minimized: bool, parent_process_id: int) -> None:
|
||||
"""
|
||||
Main function of the subprocess for starting the dashboard viewer
|
||||
"""
|
||||
try:
|
||||
SerenaDashboardViewer(url, start_minimized=minimized, parent_process_id=parent_process_id).run()
|
||||
except webview.errors.WebViewException as e:
|
||||
log.warning(f"Could not open Serena Dashboard viewer. Cause:\n{e}")
|
||||
# Fall back to opening the browser window if the window was supposed to be shown directly
|
||||
if not minimized:
|
||||
SerenaAgent._open_dashboard_in_browser(url)
|
||||
|
||||
def _start_dashboard_viewer(self, minimized: bool) -> None:
|
||||
"""
|
||||
Starts the dashboard viewer (in a separate process) or, if the current platform does not support it,
|
||||
opens the dashboard in the default web browser.
|
||||
|
||||
:param minimized: whether the dashboard viewer should be started minimized (if supported on the current platform).
|
||||
If the viewer is not supported on the current platform, then this controls whether to open the browser window.
|
||||
"""
|
||||
if not system_has_usable_display():
|
||||
log.info("Not starting the Serena dashboard viewer because no usable display was detected.")
|
||||
return
|
||||
|
||||
url = self.get_dashboard_url()
|
||||
assert url is not None
|
||||
if SerenaDashboardViewer.is_current_platform_supported():
|
||||
self._dashboard_viewer_process = multiprocessing.Process(
|
||||
target=self._start_dashboard_viewer_process_function, args=(url, minimized, os.getpid()), daemon=True
|
||||
)
|
||||
self._dashboard_viewer_process.start()
|
||||
else:
|
||||
log.info("Not starting Serena dashboard viewer because the current platform does not support it; using browser-based fallback")
|
||||
if not minimized:
|
||||
self._open_dashboard_in_browser(url)
|
||||
if self._dashboard_manager is None:
|
||||
return None
|
||||
return self._dashboard_manager.url
|
||||
|
||||
def open_dashboard(self) -> bool:
|
||||
"""
|
||||
@@ -697,27 +833,16 @@ class SerenaAgent:
|
||||
|
||||
:return: True if the dashboard was opened, False if it could not be opened
|
||||
"""
|
||||
if self._dashboard_url is None:
|
||||
if self._dashboard_manager is None:
|
||||
raise Exception("Dashboard is not running.")
|
||||
|
||||
if not system_has_usable_display():
|
||||
log.warning("Not opening the Serena web dashboard because no usable display was detected.")
|
||||
return False
|
||||
|
||||
self._open_dashboard_in_browser(self._dashboard_url)
|
||||
self._dashboard_manager.open_dashboard_in_browser()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _open_dashboard_in_browser(url: str) -> None:
|
||||
# Use a subprocess to avoid any output from webbrowser.open being written to stdout
|
||||
subprocess.Popen(
|
||||
[sys.executable, "-c", f"import webbrowser; webbrowser.open({url!r})"],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=False,
|
||||
)
|
||||
|
||||
def get_exposed_tool_instances(self) -> list["Tool"]:
|
||||
"""
|
||||
:return: the tool instances which are exposed (e.g. to the MCP client).
|
||||
@@ -983,6 +1108,10 @@ class SerenaAgent:
|
||||
if self._project_activation_callback is not None:
|
||||
self._project_activation_callback()
|
||||
|
||||
# notify the dashboard manager of the project change (if applicable)
|
||||
if self._dashboard_manager:
|
||||
self._dashboard_manager.update_active_project(self._active_project)
|
||||
|
||||
return True
|
||||
|
||||
def activate_project_from_path_or_name(
|
||||
@@ -1126,10 +1255,9 @@ class SerenaAgent:
|
||||
log.info("Stopping the GUI log window ...")
|
||||
self._gui_log_viewer.stop()
|
||||
self._gui_log_viewer = None
|
||||
if self._dashboard_viewer_process:
|
||||
log.info("Stopping the dashboard viewer process ...")
|
||||
self._dashboard_viewer_process.terminate()
|
||||
self._dashboard_viewer_process = None
|
||||
if self._dashboard_manager:
|
||||
self._dashboard_manager.shutdown()
|
||||
self._dashboard_manager = None
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""
|
||||
|
||||
+2
-3
@@ -479,11 +479,10 @@ class TopLevelCommands(AutoRegisteringGroup):
|
||||
@click.argument("url", type=str)
|
||||
@click.option("--width", type=int, default=1400, show_default=True, help="Window width.")
|
||||
@click.option("--height", type=int, default=900, show_default=True, help="Window height.")
|
||||
@click.option("--minimized", is_flag=True, default=False, help="Whether to start minimized/in tray.")
|
||||
def dashboard_viewer(url: str, width: int, height: int, minimized: bool) -> None:
|
||||
def dashboard_viewer(url: str, width: int, height: int) -> None:
|
||||
from serena.dashboard import SerenaDashboardViewer
|
||||
|
||||
viewer = SerenaDashboardViewer(url, start_minimized=minimized, width=width, height=height)
|
||||
viewer = SerenaDashboardViewer(url, width=width, height=height)
|
||||
viewer.run()
|
||||
|
||||
|
||||
|
||||
@@ -692,6 +692,7 @@ class SerenaConfig(SharedConfig):
|
||||
trace_lsp_communication: bool = False
|
||||
web_dashboard: bool = True
|
||||
web_dashboard_open_on_launch: bool = True
|
||||
web_dashboard_interface: str | None = None
|
||||
web_dashboard_listen_address: str = "127.0.0.1"
|
||||
jetbrains_plugin_server_address: str = "127.0.0.1"
|
||||
tool_timeout: float = DEFAULT_TOOL_TIMEOUT
|
||||
|
||||
@@ -32,3 +32,10 @@ SERENA_LOG_FORMAT = "%(levelname)-5s %(asctime)-15s [%(threadName)s] %(name)s:%(
|
||||
|
||||
LOG_MESSAGES_BUFFER_SIZE = 2500
|
||||
"""The maximum number of log messages to keep in the buffer (for the dashboard)."""
|
||||
|
||||
|
||||
class SerenaPorts:
|
||||
TRAY_MANAGER_PORT = 0x5EA0
|
||||
PROJECT_SERVER_PORT = 0x5EA1
|
||||
JETBRAINS_PLUGIN_SERVER_BASE_PORT = 0x5EA2
|
||||
DASHBOARD_API_BASE_PORT = 0x5EDA
|
||||
+458
-11
@@ -1,22 +1,29 @@
|
||||
import json
|
||||
import multiprocessing
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from html import escape
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Self
|
||||
from typing import TYPE_CHECKING, Any, Optional, Self
|
||||
|
||||
import psutil
|
||||
from flask import Flask, Response, redirect, request, send_from_directory
|
||||
from PIL import Image
|
||||
from pydantic import BaseModel
|
||||
from sensai.util import logging
|
||||
from sensai.util.pickle import dump_pickle, load_pickle
|
||||
|
||||
from serena.analytics import ToolUsageStats
|
||||
from serena.config.serena_config import SerenaConfig, SerenaPaths
|
||||
from serena.constants import SERENA_DASHBOARD_DIR
|
||||
from serena.constants import SERENA_DASHBOARD_DIR, SerenaPorts
|
||||
from serena.task_executor import TaskExecutor
|
||||
from serena.util.logging import MemoryLogHandler
|
||||
from serena.util.pywebview import WebViewWithTray
|
||||
@@ -180,6 +187,8 @@ class ReadNews:
|
||||
|
||||
|
||||
class SerenaDashboardAPI:
|
||||
BASE_PORT = SerenaPorts.DASHBOARD_API_BASE_PORT
|
||||
|
||||
log = logging.getLogger(__qualname__)
|
||||
|
||||
def __init__(
|
||||
@@ -773,16 +782,40 @@ class SerenaDashboardAPI:
|
||||
return port
|
||||
|
||||
def run_in_thread(self, host: str) -> tuple[threading.Thread, int]:
|
||||
port = self._find_first_free_port(0x5EDA, host)
|
||||
port = self._find_first_free_port(self.BASE_PORT, host)
|
||||
log.info("Starting dashboard (listen_address=%s, port=%d)", host, port)
|
||||
thread = threading.Thread(target=lambda: self.run(host=host, port=port), daemon=True)
|
||||
thread.start()
|
||||
return thread, port
|
||||
|
||||
|
||||
def open_url_in_browser(url: str, use_subprocess: bool = False) -> None:
|
||||
"""
|
||||
Opens the given URL in the user's default web browser,
|
||||
optionally using a subprocess to ensure that no output is written to stdout
|
||||
(highly problematic when run within a stdio MCP server context)
|
||||
|
||||
:param url: the URL to open
|
||||
:param use_subprocess: whether to use a subprocess to opening the URL, making stdio contamination impossible
|
||||
"""
|
||||
if use_subprocess:
|
||||
# Use a subprocess to avoid any output from webbrowser.open being written to stdout
|
||||
subprocess.Popen(
|
||||
[sys.executable, "-c", f"import webbrowser; webbrowser.open({url!r})"],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=False,
|
||||
)
|
||||
else:
|
||||
import webbrowser
|
||||
|
||||
webbrowser.open(url)
|
||||
|
||||
|
||||
class SerenaDashboardViewer(WebViewWithTray):
|
||||
"""
|
||||
Minimal pywebview wrapper with optional system tray.
|
||||
Minimal pywebview wrapper that opens a dashboard in a native window with optional system tray.
|
||||
"""
|
||||
|
||||
DEBUG = False
|
||||
@@ -797,6 +830,15 @@ class SerenaDashboardViewer(WebViewWithTray):
|
||||
parent_process_id: int | None = None,
|
||||
tray: bool = True,
|
||||
):
|
||||
"""
|
||||
:param url: the URL to open
|
||||
:param width: the width of the window
|
||||
:param height: the height of the window
|
||||
:param start_minimized: whether to start the window minimized (to the tray if tray is enabled)
|
||||
:param parent_process_id: the process ID of the parent Serena agent process, which is monitored for termination,
|
||||
automatically closing the dashboard when the parent process dies
|
||||
:param tray: whether to use a system tray icon (which the app minimizes to when the window is closed)
|
||||
"""
|
||||
dashboard_path = Path(SERENA_DASHBOARD_DIR)
|
||||
|
||||
# .ico is Windows-only; macOS expects a PNG for the window/dock icon.
|
||||
@@ -822,21 +864,426 @@ class SerenaDashboardViewer(WebViewWithTray):
|
||||
@staticmethod
|
||||
def is_current_platform_supported() -> bool:
|
||||
"""
|
||||
:return: whether the current platform supports the dashboard viewer
|
||||
:return: whether the current platform supports the native dashboard viewer (and tray manager).
|
||||
"""
|
||||
# The dashboard viewer (with system tray) is technically supported only on Windows and macOS.
|
||||
# Linux support is problematic; see https://github.com/oraios/serena/pull/1117#issuecomment-4128753943
|
||||
# supported on Windows and macOS; Linux support is problematic
|
||||
# (see https://github.com/oraios/serena/pull/1117#issuecomment-4128753943)
|
||||
supported_platforms = [
|
||||
"win32",
|
||||
# NOTE: Disabling macOS support for now, because the tray behaviour is suboptimal (too many icons when
|
||||
# subagents are spawned, etc.)
|
||||
# "darwin"
|
||||
"darwin",
|
||||
]
|
||||
return sys.platform in supported_platforms
|
||||
|
||||
def run(self) -> None:
|
||||
if self.DEBUG:
|
||||
logging.configure(level=logging.DEBUG)
|
||||
logging.configure(level=logging.DEBUG, stream=sys.stderr)
|
||||
logging.add_file_logger(SerenaPaths().get_next_log_file_path("dashboard-viewer"))
|
||||
|
||||
super().run()
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrayManagedInstance:
|
||||
"""A registered Serena dashboard instance managed by the tray manager."""
|
||||
|
||||
port: int
|
||||
"""the port on which the dashboard API is listening"""
|
||||
|
||||
parent_process: psutil.Process
|
||||
"""the process of the Serena agent owning this dashboard instance"""
|
||||
|
||||
dashboard_url: str
|
||||
"""the full URL to the dashboard frontend"""
|
||||
|
||||
project: str | None
|
||||
"""the name of the active project, or None if no project is activated"""
|
||||
|
||||
started_at: str
|
||||
"""ISO 8601 timestamp of when the agent instance was started"""
|
||||
|
||||
|
||||
class SerenaDashboardTrayManager:
|
||||
"""
|
||||
Singleton process managing a system tray icon for all Serena dashboard instances.
|
||||
|
||||
Runs a Flask backend on a fixed port and displays a single tray icon that
|
||||
aggregates all running Serena instances. Individual dashboard viewers are
|
||||
spawned on demand when the user clicks a menu item.
|
||||
|
||||
The manager is started as a detached process by the first Serena agent that
|
||||
needs it and terminates automatically when no dashboard instances remain.
|
||||
"""
|
||||
|
||||
DEBUG = False
|
||||
|
||||
PORT = SerenaPorts.TRAY_MANAGER_PORT
|
||||
|
||||
HOST = "127.0.0.1"
|
||||
"""listen address (local only)"""
|
||||
|
||||
ALIVE_CHECK_INTERVAL_SECONDS = 3
|
||||
"""interval in seconds between alive checks of registered instances"""
|
||||
|
||||
def __init__(self, use_pywebview: bool = False, alive_check_use_pid: bool = True) -> None:
|
||||
"""
|
||||
:param use_pywebview: whether to use pywebview-based viewer applications (separate child processes)
|
||||
for opening dashboards; if False, open them directly in the user's default web browser.
|
||||
:param alive_check_use_pid: whether to use the process ID for alive checks of registered instances.
|
||||
If True, the manager will check whether the process with the registered PID is still running.
|
||||
If False, the manager will perform an HTTP request to the instance's heartbeat endpoint to check
|
||||
if it's alive.
|
||||
"""
|
||||
import pystray
|
||||
|
||||
self._instances: dict[int, TrayManagedInstance] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._tray_icon: Optional["pystray.Icon"] = None
|
||||
self._alive_check_use_pid = alive_check_use_pid
|
||||
self._app = Flask(__name__)
|
||||
self._setup_routes()
|
||||
self._use_pywebview = use_pywebview
|
||||
|
||||
def _setup_routes(self) -> None:
|
||||
@self._app.route("/health", methods=["GET"])
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "alive"}
|
||||
|
||||
@self._app.route("/register", methods=["POST"])
|
||||
def register() -> dict[str, str]:
|
||||
data = request.get_json()
|
||||
instance = TrayManagedInstance(
|
||||
port=data["port"],
|
||||
parent_process=psutil.Process(data["pid"]),
|
||||
dashboard_url=data["dashboard_url"],
|
||||
project=data.get("project"),
|
||||
started_at=data["started_at"],
|
||||
)
|
||||
log.info("Registering instance on port %d (project=%s)", instance.port, instance.project)
|
||||
with self._lock:
|
||||
self._instances[instance.port] = instance
|
||||
self._update_menu()
|
||||
|
||||
# open a viewer immediately if requested
|
||||
if data.get("open_viewer", False):
|
||||
self._open_dashboard(instance)
|
||||
|
||||
return {"status": "registered"}
|
||||
|
||||
@self._app.route("/update_project", methods=["POST"])
|
||||
def update_project() -> dict[str, str]:
|
||||
data = request.get_json()
|
||||
port = data["port"]
|
||||
project = data.get("project")
|
||||
with self._lock:
|
||||
if port in self._instances:
|
||||
self._instances[port].project = project
|
||||
self._update_menu()
|
||||
log.info("Updated project for instance on port %d to '%s'", port, project)
|
||||
return {"status": "updated"}
|
||||
|
||||
@self._app.route("/unregister", methods=["POST"])
|
||||
def unregister() -> dict[str, str]:
|
||||
data = request.get_json()
|
||||
port = data["port"]
|
||||
with self._lock:
|
||||
self._instances.pop(port, None)
|
||||
self._update_menu()
|
||||
log.info("Unregistered instance on port %d", port)
|
||||
return {"status": "unregistered"}
|
||||
|
||||
def _update_menu(self) -> None:
|
||||
if self._tray_icon:
|
||||
self._tray_icon.update_menu()
|
||||
|
||||
def _build_menu_items(self) -> tuple[Any, ...]:
|
||||
"""
|
||||
Callable that returns the current tray menu items.
|
||||
|
||||
Invoked dynamically by pystray each time the menu is shown.
|
||||
When there is exactly one instance, it is marked as the default action
|
||||
so that a left-click on the tray icon opens the viewer immediately.
|
||||
When there are multiple instances, a hidden default item forces the menu
|
||||
to appear on left-click (Windows only; macOS always shows the menu).
|
||||
"""
|
||||
from pystray import MenuItem as Item
|
||||
|
||||
with self._lock:
|
||||
instances = list(self._instances.values())
|
||||
|
||||
if not instances:
|
||||
return (Item("No instances", None, enabled=False),)
|
||||
|
||||
# determine whether a single-instance shortcut applies
|
||||
is_single = len(instances) == 1
|
||||
|
||||
items: list[Any] = []
|
||||
|
||||
# for multi-instance: add a hidden default item that opens the menu on left-click
|
||||
if not is_single:
|
||||
|
||||
def _force_show_menu(icon: Any, _item: Any) -> None:
|
||||
if hasattr(icon, "_show_menu"):
|
||||
icon._show_menu()
|
||||
|
||||
items.append(Item("Instances", _force_show_menu, default=True, visible=False))
|
||||
|
||||
for inst in sorted(instances, key=lambda i: i.started_at):
|
||||
label = f"{inst.project or 'SerenaAgent'} ({inst.started_at})"
|
||||
|
||||
# closure to capture the current instance
|
||||
def _make_callback(instance: TrayManagedInstance) -> Callable:
|
||||
def _callback(_icon: Any, _item: Any) -> None:
|
||||
self._open_dashboard(instance)
|
||||
|
||||
return _callback
|
||||
|
||||
items.append(Item(label, _make_callback(inst), default=is_single))
|
||||
|
||||
return tuple(items)
|
||||
|
||||
def _open_dashboard(self, instance: TrayManagedInstance) -> None:
|
||||
"""Opens the dashboard of the given instance."""
|
||||
if self._use_pywebview:
|
||||
# spawn pywebview process showing the dashboard
|
||||
process = multiprocessing.Process(
|
||||
target=self._run_viewer,
|
||||
args=(instance.dashboard_url,),
|
||||
daemon=True,
|
||||
)
|
||||
process.start()
|
||||
else:
|
||||
open_url_in_browser(instance.dashboard_url)
|
||||
|
||||
@staticmethod
|
||||
def _run_viewer(url: str) -> None:
|
||||
"""Process target for running a dashboard viewer window."""
|
||||
SerenaDashboardViewer(url, tray=False, start_minimized=False).run()
|
||||
|
||||
def _alive_check_loop(self) -> None:
|
||||
"""Periodically check whether registered instances are still reachable.
|
||||
|
||||
Removes unreachable instances and terminates the manager when none remain.
|
||||
"""
|
||||
time.sleep(max(1, 5 - self.ALIVE_CHECK_INTERVAL_SECONDS)) # initial delay to allow instances to start and register
|
||||
|
||||
while True:
|
||||
time.sleep(self.ALIVE_CHECK_INTERVAL_SECONDS)
|
||||
|
||||
try:
|
||||
dead_ports: list[int] = []
|
||||
|
||||
if self._alive_check_use_pid:
|
||||
with self._lock:
|
||||
ports_and_processes = [(port, inst.parent_process) for port, inst in self._instances.items()]
|
||||
|
||||
dead_ports = []
|
||||
for port, process in ports_and_processes:
|
||||
is_dead = False
|
||||
try:
|
||||
if not process.is_running():
|
||||
is_dead = True
|
||||
except psutil.NoSuchProcess:
|
||||
# parent process already exited
|
||||
is_dead = True
|
||||
if is_dead:
|
||||
dead_ports.append(port)
|
||||
else:
|
||||
# collect ports to check
|
||||
with self._lock:
|
||||
ports_to_check = list(self._instances.keys())
|
||||
|
||||
# probe each instance
|
||||
for port in ports_to_check:
|
||||
try:
|
||||
url = f"http://127.0.0.1:{port}/heartbeat"
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
urllib.request.urlopen(req, timeout=1)
|
||||
except Exception:
|
||||
dead_ports.append(port)
|
||||
|
||||
# remove dead instances
|
||||
if dead_ports:
|
||||
with self._lock:
|
||||
for port in dead_ports:
|
||||
self._instances.pop(port, None)
|
||||
log.info("Removed unreachable instance on port %d", port)
|
||||
|
||||
# terminate if no instances remain
|
||||
with self._lock:
|
||||
remaining = len(self._instances)
|
||||
if remaining == 0:
|
||||
log.info("No dashboard instances remaining; shutting down tray manager")
|
||||
if self._tray_icon is not None:
|
||||
self._tray_icon.stop()
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
log.error("Error during alive check loop: %s", e, exc_info=e)
|
||||
|
||||
def run(self) -> None:
|
||||
"""Run the tray manager (blocking). Starts Flask, alive-check thread, and tray icon."""
|
||||
import pystray
|
||||
|
||||
if self.DEBUG:
|
||||
logging.configure(level=logging.DEBUG, stream=sys.stderr)
|
||||
logging.add_file_logger(SerenaPaths().get_next_log_file_path("tray-manager"))
|
||||
|
||||
log.info("Starting tray manager")
|
||||
|
||||
dashboard_path = Path(SERENA_DASHBOARD_DIR)
|
||||
|
||||
# select the appropriate icon for the platform
|
||||
icon_filename = "serena-icon-tray-mac.png" if sys.platform == "darwin" else "serena-icon-48.png"
|
||||
icon_img = Image.open(dashboard_path / icon_filename)
|
||||
|
||||
# start Flask in a background thread
|
||||
flask_thread = threading.Thread(
|
||||
target=lambda: self._app.run(host=self.HOST, port=self.PORT, debug=False, use_reloader=False, threaded=True),
|
||||
daemon=True,
|
||||
)
|
||||
flask_thread.start()
|
||||
|
||||
# start alive-check in a background thread
|
||||
alive_thread = threading.Thread(target=self._alive_check_loop, daemon=True)
|
||||
alive_thread.start()
|
||||
|
||||
# set up tray icon with a dynamic menu (callable returns items on each open)
|
||||
kwargs: dict[str, Any] = {}
|
||||
if sys.platform == "darwin":
|
||||
from AppKit import NSApplication
|
||||
|
||||
kwargs["darwin_nsapplication"] = NSApplication.sharedApplication()
|
||||
|
||||
self._tray_icon = pystray.Icon(
|
||||
"serena_tray_manager",
|
||||
icon_img,
|
||||
"Serena",
|
||||
menu=pystray.Menu(self._build_menu_items),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# blocks until stop() is called
|
||||
self._tray_icon.run()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Class-level helpers (used by agents to interact with the manager)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def is_current_platform_supported(cls) -> bool:
|
||||
"""
|
||||
:return: whether the current platform supports the tray manager
|
||||
"""
|
||||
# supported_platforms = ["win32", "darwin"]
|
||||
# return sys.platform in supported_platforms
|
||||
# TODO: Enable all platforms for the time being for users to try out (only Windows tested)
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def is_running(cls) -> bool:
|
||||
"""
|
||||
:return: True if a tray manager process is already listening on the fixed port
|
||||
"""
|
||||
try:
|
||||
url = f"http://{cls.HOST}:{cls.PORT}/health"
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
urllib.request.urlopen(req, timeout=2)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def ensure_running(cls) -> None:
|
||||
"""Ensure a tray manager process is running, starting one if necessary."""
|
||||
log.info("Ensuring dashboard tray manager availability")
|
||||
if cls.is_running():
|
||||
log.info("Dashboard tray manager is already running")
|
||||
return
|
||||
|
||||
# spawn a detached process
|
||||
log.info("Starting new dashboard tray manager process")
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-c",
|
||||
"from serena.dashboard import SerenaDashboardTrayManager; SerenaDashboardTrayManager().run()",
|
||||
]
|
||||
kwargs: dict[str, Any] = {
|
||||
"stdin": subprocess.DEVNULL,
|
||||
"stdout": subprocess.DEVNULL,
|
||||
"stderr": subprocess.DEVNULL,
|
||||
}
|
||||
if sys.platform == "win32":
|
||||
# CREATE_NO_WINDOW suppresses the console; CREATE_NEW_PROCESS_GROUP
|
||||
# isolates the child from the parent's Ctrl+C group.
|
||||
# Note: DETACHED_PROCESS must NOT be combined with CREATE_NO_WINDOW.
|
||||
CREATE_NEW_PROCESS_GROUP = 0x00000200
|
||||
CREATE_NO_WINDOW = 0x08000000
|
||||
kwargs["creationflags"] = CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW
|
||||
else:
|
||||
kwargs["start_new_session"] = True
|
||||
|
||||
subprocess.Popen(cmd, **kwargs)
|
||||
|
||||
# wait for the manager to become available
|
||||
for _ in range(30):
|
||||
time.sleep(0.1)
|
||||
if cls.is_running():
|
||||
log.info("Dashboard tray manager started successfully")
|
||||
return
|
||||
log.warning("Dashboard tray manager did not start within the expected time")
|
||||
|
||||
@classmethod
|
||||
def register_instance(cls, port: int, dashboard_url: str, project: str | None, started_at: str, open_viewer: bool = False) -> None:
|
||||
"""Register a dashboard instance with the running tray manager.
|
||||
|
||||
:param port: the port of the dashboard API (used for alive checks)
|
||||
:param dashboard_url: the full URL to the dashboard frontend
|
||||
:param project: the currently active project name, or None
|
||||
:param started_at: ISO 8601 timestamp of when the agent was started
|
||||
:param open_viewer: whether the tray manager should immediately open a viewer for this instance
|
||||
"""
|
||||
url = f"http://{cls.HOST}:{cls.PORT}/register"
|
||||
data = json.dumps(
|
||||
{
|
||||
"port": port,
|
||||
"pid": os.getpid(),
|
||||
"dashboard_url": dashboard_url,
|
||||
"project": project,
|
||||
"started_at": started_at,
|
||||
"open_viewer": open_viewer,
|
||||
}
|
||||
).encode()
|
||||
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
|
||||
try:
|
||||
urllib.request.urlopen(req, timeout=2)
|
||||
except Exception as e:
|
||||
log.warning("Failed to register with tray manager: %s", e)
|
||||
|
||||
@classmethod
|
||||
def update_project(cls, port: int, project: str | None) -> None:
|
||||
"""Notify the tray manager of a project change for the given instance.
|
||||
|
||||
:param port: the port of the dashboard API
|
||||
:param project: the new active project name, or None
|
||||
"""
|
||||
url = f"http://{cls.HOST}:{cls.PORT}/update_project"
|
||||
data = json.dumps({"port": port, "project": project}).encode()
|
||||
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
|
||||
try:
|
||||
urllib.request.urlopen(req, timeout=2)
|
||||
except Exception as e:
|
||||
log.warning("Failed to update project with tray manager: %s", e)
|
||||
|
||||
@classmethod
|
||||
def unregister_instance(cls, port: int) -> None:
|
||||
"""Unregister a dashboard instance from the tray manager.
|
||||
|
||||
:param port: the port of the dashboard API to unregister
|
||||
"""
|
||||
url = f"http://{cls.HOST}:{cls.PORT}/unregister"
|
||||
data = json.dumps({"port": port}).encode()
|
||||
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
|
||||
try:
|
||||
urllib.request.urlopen(req, timeout=2)
|
||||
except Exception as e:
|
||||
log.warning("Failed to unregister from tray manager: %s", e)
|
||||
@@ -18,6 +18,7 @@ from sensai.util.string import ToStringMixin
|
||||
|
||||
import serena.jetbrains.jetbrains_types as jb
|
||||
from serena.config.serena_config import RegisteredProject
|
||||
from serena.constants import SerenaPorts
|
||||
from serena.jetbrains.jetbrains_types import PluginStatusDTO
|
||||
from serena.project import Project
|
||||
from serena.util.class_decorators import singleton
|
||||
@@ -174,7 +175,7 @@ class JetBrainsPluginClient(ToStringMixin):
|
||||
Provides simple methods to interact with all available endpoints.
|
||||
"""
|
||||
|
||||
BASE_PORT = 0x5EA2
|
||||
BASE_PORT = SerenaPorts.JETBRAINS_PLUGIN_SERVER_BASE_PORT
|
||||
PLUGIN_REQUEST_TIMEOUT = 300
|
||||
"""
|
||||
the timeout used for request handling within the plugin (a constant in the plugin)
|
||||
|
||||
@@ -8,7 +8,7 @@ from pydantic import BaseModel
|
||||
from sensai.util.logging import LogTime
|
||||
|
||||
from serena.config.serena_config import LanguageBackend, SerenaConfig
|
||||
from serena.jetbrains.jetbrains_plugin_client import JetBrainsPluginClient
|
||||
from serena.constants import SerenaPorts
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from serena.project import Project
|
||||
@@ -42,7 +42,7 @@ class ProjectServer:
|
||||
:class:`~serena.tools.query_project_tools.QueryProjectTool`.
|
||||
"""
|
||||
|
||||
PORT = JetBrainsPluginClient.BASE_PORT - 1
|
||||
PORT = SerenaPorts.PROJECT_SERVER_PORT
|
||||
|
||||
def __init__(self) -> None:
|
||||
from serena.agent import SerenaAgent
|
||||
|
||||
@@ -46,6 +46,21 @@ web_dashboard: True
|
||||
# See also: https://oraios.github.io/serena/02-usage/060_dashboard.html
|
||||
web_dashboard_open_on_launch: True
|
||||
|
||||
# defines the interface (application mode) used for the web dashboard (if enabled).
|
||||
# If empty/null, use platform-dependent default. Otherwise, possible values:
|
||||
# * browser: the dashboard is opened in the default browser (if `web_dashboard_open_on_launch` is true)
|
||||
# This is supported on all platforms.
|
||||
# * app: the dashboard is opened in a separate native-like app window with accompanying tray icon, whose
|
||||
# lifecycle is tied to the Serena process.
|
||||
# If `web_dashboard_open_on_launch` is false, the dashboard can be conveniently accessed via the tray icon.
|
||||
# This is supported on Windows and macOS, but note that on macOS, where tray icons are very visible,
|
||||
# this may result in too many icons being displayed when using multi-agent setups.
|
||||
# * tray_manager: use a global tray icon to provide access to the dashboards of all running Serena instances,
|
||||
# opening the dashboard in browser tabs when selected from the tray menu.
|
||||
# This is EXPERIMENTAL. It is tested on Windows only. We will establish macOS support, but it is yet untested.
|
||||
# On Linux, this cannot be universally supported, but it may work in some desktop environments.
|
||||
web_dashboard_interface:
|
||||
|
||||
# the address the web dashboard will listen on (bind address).
|
||||
web_dashboard_listen_address: 127.0.0.1
|
||||
|
||||
|
||||
Reference in new issue
Block a user