From ad4fec923003711b174e50b9ddeba5afd9f06e92 Mon Sep 17 00:00:00 2001 From: MischaPanch Date: Mon, 27 Oct 2025 21:23:37 +0100 Subject: [PATCH] Enhance dashboard functionality Added an overview (landing page showing current and available config) and advanced stats windows, controlled by top-right menu Stats calculation is no longer optional, but we use a trivial token estimator by default --- scripts/demo_run_tools.py | 5 +- src/serena/agent.py | 51 +- src/serena/analytics.py | 18 +- src/serena/config/serena_config.py | 7 +- src/serena/dashboard.py | 106 +++ src/serena/resources/dashboard/dashboard.css | 620 ++++++++++++++++++ src/serena/resources/dashboard/dashboard.js | 444 ++++++++++--- src/serena/resources/dashboard/index.html | 352 +++------- .../resources/serena_config.template.yml | 14 +- src/serena/tools/tools_base.py | 2 +- 10 files changed, 1234 insertions(+), 385 deletions(-) create mode 100644 src/serena/resources/dashboard/dashboard.css diff --git a/scripts/demo_run_tools.py b/scripts/demo_run_tools.py index d52cfb00..dad0c1cd 100644 --- a/scripts/demo_run_tools.py +++ b/scripts/demo_run_tools.py @@ -12,7 +12,7 @@ from serena.constants import REPO_ROOT from serena.tools import FindFileTool, FindReferencingSymbolsTool, GetSymbolsOverviewTool, SearchForPatternTool if __name__ == "__main__": - agent = SerenaAgent(project=REPO_ROOT, serena_config=SerenaConfig(gui_log_window_enabled=False, web_dashboard=False)) + agent = SerenaAgent(project=REPO_ROOT, serena_config=SerenaConfig(gui_log_window_enabled=False, web_dashboard=True)) # apply a tool find_refs_tool = agent.get_tool(FindReferencingSymbolsTool) @@ -21,6 +21,7 @@ if __name__ == "__main__": overview_tool = agent.get_tool(GetSymbolsOverviewTool) result = agent.execute_task( - lambda: overview_tool.apply("src/solidlsp/ls.py"), + lambda: overview_tool.apply_ex(relative_file_path="src/solidlsp/ls.py"), ) pprint(json.loads(result)) + # input("Press Enter to continue...") diff --git a/src/serena/agent.py b/src/serena/agent.py index dfe8c1ad..8236db55 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -127,25 +127,9 @@ class SerenaAgent: if self._gui_log_viewer is not None: self._gui_log_viewer.set_tool_names(tool_names) - self._tool_usage_stats: ToolUsageStats | None = None - if self.serena_config.record_tool_usage_stats: - token_count_estimator = RegisteredTokenCountEstimator[self.serena_config.token_count_estimator] - log.info(f"Tool usage statistics recording is enabled with token count estimator: {token_count_estimator.name}.") - self._tool_usage_stats = ToolUsageStats(token_count_estimator) - - # start the dashboard (web frontend), registering its log handler - if self.serena_config.web_dashboard: - self._dashboard_thread, port = SerenaDashboardAPI( - get_memory_log_handler(), tool_names, agent=self, tool_usage_stats=self._tool_usage_stats - ).run_in_thread() - dashboard_url = f"http://127.0.0.1:{port}/dashboard/index.html" - log.info("Serena web dashboard started at %s", dashboard_url) - if self.serena_config.web_dashboard_open_on_launch: - # open the dashboard URL in the default web browser (using a separate process to control - # output redirection) - process = multiprocessing.Process(target=self._open_dashboard, args=(dashboard_url,)) - process.start() - process.join(timeout=1) + token_count_estimator = RegisteredTokenCountEstimator[self.serena_config.token_count_estimator] + log.info(f"Will record tool usage statistics with token count estimator: {token_count_estimator.name}.") + self._tool_usage_stats = ToolUsageStats(token_count_estimator) # log fundamental information log.info(f"Starting Serena server (version={serena_version()}, process id={os.getpid()}, parent process id={os.getppid()})") @@ -192,6 +176,22 @@ class SerenaAgent: except Exception as e: log.error(f"Error activating project '{project}' at startup: {e}", exc_info=e) + # start the dashboard (web frontend), registering its log handler + # should be the last thing to happen in the initialization since the dashboard + # may access various parts of the agent + if self.serena_config.web_dashboard: + self._dashboard_thread, port = SerenaDashboardAPI( + get_memory_log_handler(), tool_names, agent=self, tool_usage_stats=self._tool_usage_stats + ).run_in_thread() + dashboard_url = f"http://127.0.0.1:{port}/dashboard/index.html" + log.info("Serena web dashboard started at %s", dashboard_url) + if self.serena_config.web_dashboard_open_on_launch: + # open the dashboard URL in the default web browser (using a separate process to control + # output redirection) + process = multiprocessing.Process(target=self._open_dashboard, args=(dashboard_url,)) + process.start() + process.join(timeout=1) + def get_context(self) -> SerenaAgentContext: return self._context @@ -234,18 +234,15 @@ class SerenaAgent: tool_inclusion_definitions.append(project.project_config) return tool_inclusion_definitions - def record_tool_usage_if_enabled(self, input_kwargs: dict, tool_result: str | dict, tool: Tool) -> None: + def record_tool_usage(self, input_kwargs: dict, tool_result: str | dict, tool: Tool) -> None: """ Record the usage of a tool with the given input and output strings if tool usage statistics recording is enabled. """ tool_name = tool.get_name() - if self._tool_usage_stats is not None: - input_str = str(input_kwargs) - output_str = str(tool_result) - log.debug(f"Recording tool usage for tool '{tool_name}'") - self._tool_usage_stats.record_tool_usage(tool_name, input_str, output_str) - else: - log.debug(f"Tool usage statistics recording is disabled, not recording usage of '{tool_name}'.") + input_str = str(input_kwargs) + output_str = str(tool_result) + log.debug(f"Recording tool usage for tool '{tool_name}'") + self._tool_usage_stats.record_tool_usage(tool_name, input_str, output_str) @staticmethod def _open_dashboard(url: str) -> None: diff --git a/src/serena/analytics.py b/src/serena/analytics.py index c773fc08..add6fd09 100644 --- a/src/serena/analytics.py +++ b/src/serena/analytics.py @@ -69,12 +69,26 @@ class AnthropicTokenCount(TokenCountEstimator): return self._send_count_tokens_request(text).input_tokens +class CharCountEstimator(TokenCountEstimator): + """ + A naive character count estimator that estimates tokens based on character count. + """ + + def __init__(self, avg_chars_per_token: int = 4): + self._avg_chars_per_token = avg_chars_per_token + + def estimate_token_count(self, text: str) -> int: + # Assuming an average of 4 characters per token + return len(text) // self._avg_chars_per_token + + _registered_token_estimator_instances_cache: dict[RegisteredTokenCountEstimator, TokenCountEstimator] = {} class RegisteredTokenCountEstimator(Enum): TIKTOKEN_GPT4O = "TIKTOKEN_GPT4O" ANTHROPIC_CLAUDE_SONNET_4 = "ANTHROPIC_CLAUDE_SONNET_4" + CHAR_COUNT = "CHAR_COUNT" @classmethod def get_valid_names(cls) -> list[str]: @@ -89,8 +103,10 @@ class RegisteredTokenCountEstimator(Enum): return TiktokenCountEstimator(model_name="gpt-4o") case RegisteredTokenCountEstimator.ANTHROPIC_CLAUDE_SONNET_4: return AnthropicTokenCount(model_name="claude-sonnet-4-20250514") + case RegisteredTokenCountEstimator.CHAR_COUNT: + return CharCountEstimator(avg_chars_per_token=4) case _: - raise ValueError(f"Unknown token count estimator: {self.value}") + raise ValueError(f"Unknown token count estimator: {self}") def load_estimator(self) -> TokenCountEstimator: estimator_instance = _registered_token_estimator_instances_cache.get(self) diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 5b9d4462..c462d561 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -340,10 +340,8 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): """ whether to apply JetBrains mode """ - record_tool_usage_stats: bool = False - """Whether to record tool usage statistics, they will be shown in the web dashboard if recording is active. - """ - token_count_estimator: str = RegisteredTokenCountEstimator.TIKTOKEN_GPT4O.name + + token_count_estimator: str = RegisteredTokenCountEstimator.CHAR_COUNT.name """Only relevant if `record_tool_usage` is True; the name of the token count estimator to use for tool usage statistics. See the `RegisteredTokenCountEstimator` enum for available options. @@ -457,7 +455,6 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): instance.excluded_tools = loaded_commented_yaml.get("excluded_tools", []) instance.included_optional_tools = loaded_commented_yaml.get("included_optional_tools", []) instance.jetbrains = loaded_commented_yaml.get("jetbrains", False) - instance.record_tool_usage_stats = loaded_commented_yaml.get("record_tool_usage_stats", False) instance.token_count_estimator = loaded_commented_yaml.get( "token_count_estimator", RegisteredTokenCountEstimator.TIKTOKEN_GPT4O.name ) diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index c4a5f91d..0468e0fa 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -39,6 +39,18 @@ class ResponseToolStats(BaseModel): stats: dict[str, dict[str, int]] +class ResponseConfigOverview(BaseModel): + active_project: dict[str, str | None] + context: dict[str, str] + modes: list[dict[str, str]] + active_tools: list[str] + tool_stats_summary: dict[str, dict[str, int]] + registered_projects: list[dict[str, str | bool]] + available_tools: list[dict[str, str | bool]] + available_modes: list[dict[str, str | bool]] + available_contexts: list[dict[str, str | bool]] + + class SerenaDashboardAPI: log = logging.getLogger(__qualname__) @@ -104,6 +116,11 @@ class SerenaDashboardAPI: estimator_name = self._tool_usage_stats.token_estimator_name if self._tool_usage_stats else "unknown" return {"token_count_estimator_name": estimator_name} + @self._app.route("/get_config_overview", methods=["GET"]) + def get_config_overview() -> dict[str, Any]: + result = self._get_config_overview() + return result.model_dump() + @self._app.route("/shutdown", methods=["PUT"]) def shutdown() -> dict[str, str]: self._shutdown() @@ -129,6 +146,95 @@ class SerenaDashboardAPI: if self._tool_usage_stats is not None: self._tool_usage_stats.clear() + def _get_config_overview(self) -> ResponseConfigOverview: + from serena.config.context_mode import SerenaAgentContext, SerenaAgentMode + + # Get active project info + project = self._agent.get_active_project() + active_project_name = project.project_name if project else None + project_info = { + "name": active_project_name, + "language": project.project_config.language.value if project else None, + } + + # Get context info + context = self._agent.get_context() + context_info = { + "name": context.name, + "description": context.description, + } + + # Get active modes + modes = self._agent.get_active_modes() + modes_info = [{"name": mode.name, "description": mode.description} for mode in modes] + active_mode_names = [mode.name for mode in modes] + + # Get active tools + active_tools = self._agent.get_active_tool_names() + + # Get registered projects + registered_projects: list[dict[str, str | bool]] = [] + for proj in self._agent.serena_config.projects: + registered_projects.append( + { + "name": proj.project_name, + "path": str(proj.project_root), + "is_active": proj.project_name == active_project_name, + } + ) + + # Get all available tools (excluding active ones) + all_tool_names = sorted([tool.get_name_from_cls() for tool in self._agent._all_tools.values()]) + available_tools: list[dict[str, str | bool]] = [] + for tool_name in all_tool_names: + if tool_name not in active_tools: + available_tools.append( + { + "name": tool_name, + "is_active": False, + } + ) + + # Get all available modes + all_mode_names = SerenaAgentMode.list_registered_mode_names() + available_modes: list[dict[str, str | bool]] = [] + for mode_name in all_mode_names: + available_modes.append( + { + "name": mode_name, + "is_active": mode_name in active_mode_names, + } + ) + + # Get all available contexts + all_context_names = SerenaAgentContext.list_registered_context_names() + available_contexts: list[dict[str, str | bool]] = [] + for context_name in all_context_names: + available_contexts.append( + { + "name": context_name, + "is_active": context_name == context.name, + } + ) + + # Get basic tool stats (just num_calls for overview) + tool_stats_summary = {} + if self._tool_usage_stats is not None: + full_stats = self._tool_usage_stats.get_tool_stats_dict() + tool_stats_summary = {name: {"num_calls": stats["num_times_called"]} for name, stats in full_stats.items()} + + return ResponseConfigOverview( + active_project=project_info, + context=context_info, + modes=modes_info, + active_tools=active_tools, + tool_stats_summary=tool_stats_summary, + registered_projects=registered_projects, + available_tools=available_tools, + available_modes=available_modes, + available_contexts=available_contexts, + ) + def _shutdown(self) -> None: log.info("Shutting down Serena") if self._shutdown_callback: diff --git a/src/serena/resources/dashboard/dashboard.css b/src/serena/resources/dashboard/dashboard.css new file mode 100644 index 00000000..6f26f466 --- /dev/null +++ b/src/serena/resources/dashboard/dashboard.css @@ -0,0 +1,620 @@ +:root { + /* Light theme variables */ + --bg-primary: #f5f5f5; + --bg-secondary: #ffffff; + --text-primary: #000000; + --text-secondary: #333333; + --text-muted: #666666; + --border-color: #ddd; + --btn-primary: #eaa45d; + --btn-hover: #dca662; + --btn-disabled: #6c757d; + --shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + --tool-highlight: #ffff00; + --tool-highlight-text: #000000; + --log-debug: #808080; + --log-info: #000000; + --log-warning: #FF8C00; + --log-error: #FF0000; + --stats-header: #f8f9fa; +} + +[data-theme="dark"] { + /* Dark theme variables */ + --bg-primary: #1a1a1a; + --bg-secondary: #2d2d2d; + --text-primary: #ffffff; + --text-secondary: #e0e0e0; + --text-muted: #b0b0b0; + --border-color: #444; + --btn-primary: #eaa45d; + --btn-hover: #dca662; + --btn-disabled: #6c757d; + --shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + --tool-highlight: #ffd700; + --tool-highlight-text: #000000; + --log-debug: #808080; + --log-info: #ffffff; + --log-warning: #FF8C00; + --log-error: #FF0000; + --stats-header: #3a3a3a; +} + +body { + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + margin: 0; + padding: 20px; + padding-top: 130px; + background-color: var(--bg-primary); + color: var(--text-primary); + transition: background-color 0.3s ease, color 0.3s ease; +} + +.header { + position: fixed; + top: 0; + left: 0; + right: 0; + background-color: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + padding: 20px; + display: flex; + justify-content: space-between; + align-items: center; + z-index: 1000; + transition: background-color 0.3s ease, border-color 0.3s ease; + min-height: 70px; + box-shadow: var(--shadow); +} + +.logo-container { + flex: 1; +} + +.logo-container img { + max-width: 300px; + height: auto; + display: block; +} + +.header-nav { + position: relative; + display: flex; + align-items: center; + gap: 10px; +} + +.menu-button { + background-color: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-color); + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + font-size: 16px; + transition: background-color 0.3s ease, border-color 0.3s ease; + display: flex; + align-items: center; + gap: 8px; +} + +.menu-button:hover { + background-color: var(--border-color); +} + +.menu-dropdown { + position: absolute; + top: 45px; + right: 0; + background-color: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 4px; + box-shadow: var(--shadow); + min-width: 200px; + z-index: 999; + transition: background-color 0.3s ease, border-color 0.3s ease; +} + +.menu-dropdown a { + display: block; + padding: 12px 20px; + color: var(--text-primary); + text-decoration: none; + transition: background-color 0.3s ease; +} + +.menu-dropdown a:hover { + background-color: var(--border-color); +} + +.menu-dropdown a.active { + background-color: var(--btn-primary); + color: white; +} + +.menu-dropdown hr { + border: none; + border-top: 1px solid var(--border-color); + margin: 5px 0; +} + +.page-view { + max-width: 1600px; + margin: 0 auto; +} + +/* Overview Page Layout */ +.overview-container { + display: grid; + grid-template-columns: 1fr 400px; + gap: 20px; +} + +.overview-left { + min-width: 0; +} + +.overview-right { + min-width: 0; +} + +/* Overview Page Styles */ +.config-section, +.basic-stats-section, +.projects-section { + background-color: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 5px; + padding: 20px; + margin-bottom: 20px; + transition: background-color 0.3s ease, border-color 0.3s ease; +} + +.config-section h2, +.basic-stats-section h2 { + margin-top: 0; + color: var(--text-secondary); +} + +/* Collapsible Headers */ +.collapsible-header { + margin-top: 0; + margin-bottom: 0; + font-size: 18px; + color: var(--text-secondary); + cursor: pointer; + user-select: none; + display: flex; + justify-content: space-between; + align-items: center; +} + +.collapsible-header:hover { + color: var(--text-primary); +} + +.toggle-icon { + transition: transform 0.3s ease; + font-size: 14px; +} + +.toggle-icon.expanded { + transform: rotate(-180deg); +} + +.collapsible-content { + margin-top: 15px; + max-height: 400px; + overflow-y: auto; +} + +.config-grid { + display: grid; + grid-template-columns: 180px 1fr; + gap: 12px; + margin-bottom: 20px; +} + +.config-label { + font-weight: bold; + color: var(--text-secondary); +} + +.config-value { + color: var(--text-primary); +} + +.config-list { + list-style: none; + padding: 0; + margin: 0; +} + +.config-list li { + padding: 4px 0; +} + +.tools-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 8px; + margin-top: 10px; +} + +.tool-item { + background-color: var(--bg-primary); + padding: 6px 10px; + border-radius: 3px; + font-size: 13px; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: default; +} + +/* Projects List */ +.project-item { + padding: 10px 12px; + margin: 5px 0; + border-radius: 4px; + background-color: var(--bg-primary); + border: 1px solid var(--border-color); + transition: background-color 0.2s ease; +} + +.project-item:hover { + background-color: var(--border-color); +} + +.project-item.active { + background-color: var(--btn-primary); + color: white; + border-color: var(--btn-primary); +} + +.project-name { + font-weight: bold; + margin-bottom: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.project-path { + font-size: 11px; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.project-item.active .project-path { + color: rgba(255, 255, 255, 0.8); +} + +/* Generic Item Styles for Tools/Modes/Contexts */ +.info-item { + padding: 8px 12px; + margin: 5px 0; + border-radius: 4px; + background-color: var(--bg-primary); + border: 1px solid var(--border-color); + transition: background-color 0.2s ease; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: default; +} + +.info-item:hover { + background-color: var(--border-color); +} + +.info-item.active { + background-color: var(--btn-primary); + color: white; + border-color: var(--btn-primary); + font-weight: bold; +} + +/* Basic Stats Styles */ +.basic-stats-section { + background-color: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 5px; + padding: 20px; + margin-bottom: 20px; + transition: background-color 0.3s ease, border-color 0.3s ease; +} + +.basic-stats-section h2 { + margin-top: 0; + color: var(--text-secondary); +} + +.stat-bar-container { + display: flex; + align-items: center; + margin: 8px 0; + gap: 12px; +} + +.stat-tool-name { + min-width: 200px; + max-width: 200px; + font-weight: bold; + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: default; +} + +.bar-wrapper { + flex: 1; + height: 24px; + background-color: var(--border-color); + border-radius: 3px; + overflow: hidden; + position: relative; +} + +.bar { + height: 100%; + background-color: var(--btn-primary); + transition: width 0.5s ease; + border-radius: 3px; +} + +.stat-count { + min-width: 60px; + text-align: right; + font-weight: bold; + color: var(--text-primary); +} + +.no-stats-message { + text-align: center; + color: var(--text-muted); + font-style: italic; + padding: 20px; +} + +/* Log Container Styles */ +.log-container { + background-color: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 5px; + height: calc(100vh - 260px); + max-height: 700px; + overflow-y: auto; + overflow-x: auto; + padding: 10px; + white-space: pre-wrap; + font-size: 12px; + line-height: 1.4; + color: var(--text-primary); + transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease; +} + +.controls { + position: sticky; + top: 90px; + z-index: 100; + background-color: var(--bg-primary); + padding: 10px 0; + margin-bottom: 10px; + text-align: center; + display: flex; + justify-content: center; + align-items: center; + gap: 10px; + flex-wrap: wrap; + transition: background-color 0.3s ease; +} + +.btn { + background-color: var(--btn-primary); + color: white; + border: none; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: background-color 0.3s ease; +} + +.btn:hover { + background-color: var(--btn-hover); +} + +.btn:disabled { + background-color: var(--btn-disabled); + cursor: not-allowed; +} + +.theme-toggle { + display: flex; + align-items: center; + gap: 5px; + background-color: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 8px 16px; + cursor: pointer; + font-size: 16px; + transition: background-color 0.3s ease, border-color 0.3s ease; +} + +.theme-toggle:hover { + background-color: var(--border-color); +} + +.theme-toggle span { + line-height: 1; +} + +.log-debug { + color: var(--log-debug); +} + +.log-info { + color: var(--log-info); +} + +.log-warning { + color: var(--log-warning); +} + +.log-error { + color: var(--log-error); +} + +.log-default { + color: var(--log-info); +} + +/* Tool name highlighting */ +.tool-name { + background-color: var(--tool-highlight); + color: var(--tool-highlight-text); + font-weight: bold; +} + +.loading { + text-align: center; + color: var(--text-muted); + font-style: italic; +} + +.error-message { + color: var(--log-error); + text-align: center; + margin: 10px 0; +} + +/* Advanced Stats Styles */ +.charts-container { + display: flex; + flex-wrap: wrap; + gap: 15px; + justify-content: space-between; + max-width: 1400px; + margin: 0 auto; +} + +.chart-group { + flex: 1; + min-width: 280px; + max-width: 320px; + text-align: center; +} + +.chart-wide { + flex: 0 0 100%; + min-width: 100%; + margin-top: 10px; +} + +.chart-group h3 { + margin: 0 0 10px 0; + color: var(--text-secondary); +} + +.stats-summary { + margin: 0 auto; + border-collapse: collapse; + background: var(--bg-secondary); + border-radius: 5px; + overflow: hidden; + box-shadow: var(--shadow); + transition: background-color 0.3s ease, box-shadow 0.3s ease; +} + +.stats-summary th, +.stats-summary td { + padding: 10px 20px; + text-align: left; + border-bottom: 1px solid var(--border-color); + color: var(--text-primary); + transition: border-color 0.3s ease, color 0.3s ease; +} + +.stats-summary th { + background-color: var(--stats-header); + font-weight: bold; + transition: background-color 0.3s ease; +} + +.stats-summary tr:last-child td { + border-bottom: none; +} + +@media (max-width: 1024px) { + .overview-container { + grid-template-columns: 1fr; + } + + .overview-right { + order: -1; + } +} + +@media (max-width: 768px) { + body { + padding-top: 140px; + } + + .header { + flex-direction: column; + gap: 10px; + padding: 10px 15px; + } + + .logo-container { + width: 100%; + text-align: center; + } + + .logo-container img { + max-width: 200px; + } + + .header-nav { + width: 100%; + justify-content: center; + } + + .charts-container { + flex-direction: column; + } + + .chart-group, + .chart-wide { + min-width: auto; + max-width: none; + } + + .controls { + flex-direction: column; + gap: 5px; + } + + .config-grid { + grid-template-columns: 1fr; + } + + .tools-grid { + grid-template-columns: 1fr; + } + + .stat-bar-container { + flex-wrap: wrap; + } + + .stat-tool-name { + min-width: 100%; + } +} diff --git a/src/serena/resources/dashboard/dashboard.js b/src/serena/resources/dashboard/dashboard.js index 5ce12df4..b1c53bc3 100644 --- a/src/serena/resources/dashboard/dashboard.js +++ b/src/serena/resources/dashboard/dashboard.js @@ -30,7 +30,7 @@ class LogMessage { } escapeHtml (convertString) { - if (typeof convertString !== 'string') return convertString; + if (typeof convertString !== 'string') return convertString; const patterns = { '<' : '<', @@ -49,47 +49,322 @@ class Dashboard { constructor() { let self = this; + // Page state + this.currentPage = 'overview'; + this.configData = null; + + // Tool names and stats this.toolNames = []; this.currentMaxIdx = -1; this.pollInterval = null; + this.configPollInterval = null; this.failureCount = 0; + + // jQuery elements this.$logContainer = $('#log-container'); this.$errorContainer = $('#error-container'); this.$loadButton = $('#load-logs'); - this.$shutdownButton = $('#shutdown'); - this.$toggleStats = $('#toggle-stats'); - this.$statsSection = $('#stats-section'); - this.$refreshStats = $('#refresh-stats'); - this.$clearStats = $('#clear-stats'); + this.$menuToggle = $('#menu-toggle'); + this.$menuDropdown = $('#menu-dropdown'); + this.$menuShutdown = $('#menu-shutdown'); this.$themeToggle = $('#theme-toggle'); this.$themeIcon = $('#theme-icon'); this.$themeText = $('#theme-text'); + this.$configDisplay = $('#config-display'); + this.$basicStatsDisplay = $('#basic-stats-display'); + this.$statsSection = $('#stats-section'); + this.$refreshStats = $('#refresh-stats'); + this.$clearStats = $('#clear-stats'); + this.$projectsDisplay = $('#projects-display'); + this.$projectsHeader = $('#projects-header'); + this.$availableToolsDisplay = $('#available-tools-display'); + this.$availableModesDisplay = $('#available-modes-display'); + this.$availableContextsDisplay = $('#available-contexts-display'); + // Chart references this.countChart = null; this.tokensChart = null; this.inputChart = null; this.outputChart = null; - // register event handlers + // Register event handlers this.$loadButton.click(this.loadLogs.bind(this)); - this.$shutdownButton.click(this.shutdown.bind(this)); - this.$toggleStats.click(this.toggleStats.bind(this)); + this.$menuShutdown.click(this.shutdown.bind(this)); + this.$menuToggle.click(this.toggleMenu.bind(this)); + this.$themeToggle.click(this.toggleTheme.bind(this)); this.$refreshStats.click(this.loadStats.bind(this)); this.$clearStats.click(this.clearStats.bind(this)); - this.$themeToggle.click(this.toggleTheme.bind(this)); - // initialize theme + // Page navigation + $('[data-page]').click(function(e) { + e.preventDefault(); + const page = $(this).data('page'); + self.navigateToPage(page); + }); + + // Close menu when clicking outside + $(document).click(function(e) { + if (!$(e.target).closest('.header-nav').length) { + self.$menuDropdown.hide(); + } + }); + + // Collapsible sections + $('.collapsible-header').click(function() { + const $header = $(this); + const $content = $header.next('.collapsible-content'); + const $icon = $header.find('.toggle-icon'); + + $content.slideToggle(300); + $icon.toggleClass('expanded'); + }); + + // Initialize theme this.initializeTheme(); - // initialize the application + // Initialize the application this.loadToolNames().then(function() { - // Load logs on page load after tool names are loaded - self.loadLogs(); + // Start on overview page + self.loadConfigOverview(); + self.startConfigPolling(); }); } + toggleMenu() { + this.$menuDropdown.toggle(); + } + + navigateToPage(page) { + // Hide menu + this.$menuDropdown.hide(); + + // Hide all pages + $('.page-view').hide(); + + // Show selected page + $('#page-' + page).show(); + + // Update menu active state + $('[data-page]').removeClass('active'); + $('[data-page="' + page + '"]').addClass('active'); + + // Update current page + this.currentPage = page; + + // Stop all polling + this.stopPolling(); + + // Start appropriate polling for the page + if (page === 'overview') { + this.loadConfigOverview(); + this.startConfigPolling(); + } else if (page === 'logs') { + this.loadLogs(); + } else if (page === 'stats') { + this.loadStats(); + } + } + + stopPolling() { + if (this.pollInterval) { + clearInterval(this.pollInterval); + this.pollInterval = null; + } + if (this.configPollInterval) { + clearInterval(this.configPollInterval); + this.configPollInterval = null; + } + } + + // ===== Config Overview Methods ===== + + loadConfigOverview() { + let self = this; + $.ajax({ + url: '/get_config_overview', + type: 'GET', + success: function(response) { + self.configData = response; + self.displayConfig(response); + self.displayBasicStats(response.tool_stats_summary); + self.displayProjects(response.registered_projects); + self.displayAvailableTools(response.available_tools); + self.displayAvailableModes(response.available_modes); + self.displayAvailableContexts(response.available_contexts); + }, + error: function(xhr, status, error) { + console.error('Error loading config overview:', error); + self.$configDisplay.html('
Error loading configuration
'); + self.$basicStatsDisplay.html('
Error loading stats
'); + self.$projectsDisplay.html('
Error loading projects
'); + self.$availableToolsDisplay.html('
Error loading tools
'); + self.$availableModesDisplay.html('
Error loading modes
'); + self.$availableContextsDisplay.html('
Error loading contexts
'); + } + }); + } + + startConfigPolling() { + // Poll every 2 seconds for config updates + this.configPollInterval = setInterval(this.loadConfigOverview.bind(this), 2000); + } + + displayConfig(config) { + // Check if tools section is currently expanded + const $existingContent = $('#tools-content'); + const wasExpanded = $existingContent.is(':visible'); + + let html = '
'; + + // Project info + html += '
Active Project:
'; + html += '
' + (config.active_project.name || 'None') + '
'; + + html += '
Language:
'; + html += '
' + (config.active_project.language || 'N/A') + '
'; + + // Context info + html += '
Context:
'; + html += '
' + config.context.name + '
'; + + // Modes info + html += '
Active Modes:
'; + html += '
'; + if (config.modes.length > 0) { + html += '
    '; + config.modes.forEach(function(mode) { + html += '
  • ' + mode.name + '
  • '; + }); + html += '
'; + } else { + html += 'None'; + } + html += '
'; + + html += '
'; + + // Active tools - collapsible + html += '
'; + html += '

'; + html += 'Active Tools (' + config.active_tools.length + ')'; + html += '▼'; + html += '

'; + html += '
'; + config.active_tools.forEach(function(tool) { + html += '
' + tool + '
'; + }); + html += '
'; + html += '
'; + + this.$configDisplay.html(html); + + // Re-attach collapsible handler for the newly created tools header + $('#tools-header').click(function() { + const $header = $(this); + const $content = $('#tools-content'); + const $icon = $header.find('.toggle-icon'); + + $content.slideToggle(300); + $icon.toggleClass('expanded'); + }); + } + + displayBasicStats(stats) { + if (Object.keys(stats).length === 0) { + this.$basicStatsDisplay.html('
No tool usage stats collected yet.
'); + return; + } + + // Sort tools by call count (descending) + const sortedTools = Object.keys(stats).sort((a, b) => { + return stats[b].num_calls - stats[a].num_calls; + }); + + const maxCalls = Math.max(...sortedTools.map(tool => stats[tool].num_calls)); + + let html = ''; + sortedTools.forEach(function(toolName) { + const count = stats[toolName].num_calls; + const percentage = maxCalls > 0 ? (count / maxCalls * 100) : 0; + + html += '
'; + html += '
' + toolName + '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
' + count + '
'; + html += '
'; + }); + + this.$basicStatsDisplay.html(html); + } + + displayProjects(projects) { + if (!projects || projects.length === 0) { + this.$projectsDisplay.html('
No projects registered.
'); + return; + } + + let html = ''; + projects.forEach(function(project) { + const activeClass = project.is_active ? ' active' : ''; + html += '
'; + html += '
' + project.name + '
'; + html += '
' + project.path + '
'; + html += '
'; + }); + + this.$projectsDisplay.html(html); + } + + displayAvailableTools(tools) { + if (!tools || tools.length === 0) { + this.$availableToolsDisplay.html('
All tools are active.
'); + return; + } + + let html = ''; + tools.forEach(function(tool) { + html += '
' + tool.name + '
'; + }); + + this.$availableToolsDisplay.html(html); + } + + displayAvailableModes(modes) { + if (!modes || modes.length === 0) { + this.$availableModesDisplay.html('
No modes available.
'); + return; + } + + let html = ''; + modes.forEach(function(mode) { + const activeClass = mode.is_active ? ' active' : ''; + html += '
' + mode.name + '
'; + }); + + this.$availableModesDisplay.html(html); + } + + displayAvailableContexts(contexts) { + if (!contexts || contexts.length === 0) { + this.$availableContextsDisplay.html('
No contexts available.
'); + return; + } + + let html = ''; + contexts.forEach(function(context) { + const activeClass = context.is_active ? ' active' : ''; + html += '
' + context.name + '
'; + }); + + this.$availableContextsDisplay.html(html); + } + + // ===== Logs Methods ===== + displayLogMessage(message) { - $('#log-container').append(new LogMessage(message, this.toolNames).$elem); + this.$logContainer.append(new LogMessage(message, this.toolNames).$elem); } loadToolNames() { @@ -227,16 +502,7 @@ class Dashboard { this.pollInterval = setInterval(this.pollForNewLogs.bind(this), 1000); } - toggleStats() { - if (this.$statsSection.is(':visible')) { - this.$statsSection.hide(); - this.$toggleStats.text('Show Stats'); - } else { - this.$statsSection.show(); - this.$toggleStats.text('Hide Stats'); - this.loadStats(); - } - } + // ===== Stats Methods ===== loadStats() { let self = this; @@ -252,7 +518,6 @@ class Dashboard { }); } - clearStats() { let self = this; $.ajax({ @@ -292,12 +557,12 @@ class Dashboard { const inputTokens = names.map(n => stats[n].input_tokens); const outputTokens = names.map(n => stats[n].output_tokens); const totalTokens = names.map(n => stats[n].input_tokens + stats[n].output_tokens); - + // Calculate totals for summary table const totalCalls = counts.reduce((sum, count) => sum + count, 0); const totalInputTokens = inputTokens.reduce((sum, tokens) => sum + tokens, 0); const totalOutputTokens = outputTokens.reduce((sum, tokens) => sum + tokens, 0); - + // Generate consistent colors for tools const colors = this.generateColors(names.length); @@ -325,16 +590,16 @@ class Dashboard { // Tool calls pie chart this.countChart = new Chart(countCtx, { type: 'pie', - data: { - labels: names, - datasets: [{ + data: { + labels: names, + datasets: [{ data: counts, backgroundColor: colors - }] + }] }, options: { plugins: { - legend: { + legend: { display: true, labels: { color: textColor @@ -353,16 +618,16 @@ class Dashboard { // Input tokens pie chart this.inputChart = new Chart(inputCtx, { type: 'pie', - data: { - labels: names, - datasets: [{ + data: { + labels: names, + datasets: [{ data: inputTokens, backgroundColor: colors - }] + }] }, options: { plugins: { - legend: { + legend: { display: true, labels: { color: textColor @@ -381,16 +646,16 @@ class Dashboard { // Output tokens pie chart this.outputChart = new Chart(outputCtx, { type: 'pie', - data: { - labels: names, - datasets: [{ + data: { + labels: names, + datasets: [{ data: outputTokens, backgroundColor: colors - }] + }] }, options: { plugins: { - legend: { + legend: { display: true, labels: { color: textColor @@ -409,11 +674,11 @@ class Dashboard { // Combined input/output tokens bar chart this.tokensChart = new Chart(tokensCtx, { type: 'bar', - data: { - labels: names, + data: { + labels: names, datasets: [ - { - label: 'Input Tokens', + { + label: 'Input Tokens', data: inputTokens, backgroundColor: colors.map(color => color + '80'), // Semi-transparent borderColor: colors, @@ -421,8 +686,8 @@ class Dashboard { borderSkipped: false, yAxisID: 'y' }, - { - label: 'Output Tokens', + { + label: 'Output Tokens', data: outputTokens, backgroundColor: colors, yAxisID: 'y1' @@ -452,8 +717,8 @@ class Dashboard { display: true, position: 'left', beginAtZero: true, - title: { - display: true, + title: { + display: true, text: 'Input Tokens', color: textColor }, @@ -469,15 +734,15 @@ class Dashboard { display: true, position: 'right', beginAtZero: true, - title: { - display: true, + title: { + display: true, text: 'Output Tokens', color: textColor }, ticks: { color: textColor }, - grid: { + grid: { drawOnChartArea: false, color: gridColor } @@ -508,10 +773,12 @@ class Dashboard { $('#stats-summary').html(tableHtml); } + // ===== Theme Methods ===== + initializeTheme() { // Check if user has manually set a theme preference const savedTheme = localStorage.getItem('serena-theme'); - + if (savedTheme) { // User has manually set a preference, use it this.setTheme(savedTheme); @@ -519,7 +786,7 @@ class Dashboard { // No manual preference, detect system color scheme this.detectSystemTheme(); } - + // Listen for system theme changes this.setupSystemThemeListener(); } @@ -534,7 +801,7 @@ class Dashboard { setupSystemThemeListener() { // Listen for changes in system color scheme const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); - + const handleSystemThemeChange = (e) => { // Only auto-switch if user hasn't manually set a preference const savedTheme = localStorage.getItem('serena-theme'); @@ -543,7 +810,7 @@ class Dashboard { this.setTheme(newTheme); } }; - + // Add listener for system theme changes if (mediaQuery.addEventListener) { mediaQuery.addEventListener('change', handleSystemThemeChange); @@ -556,7 +823,7 @@ class Dashboard { toggleTheme() { const currentTheme = document.documentElement.getAttribute('data-theme') || 'light'; const newTheme = currentTheme === 'light' ? 'dark' : 'light'; - + // When user manually toggles, save their preference localStorage.setItem('serena-theme', newTheme); this.setTheme(newTheme); @@ -565,8 +832,8 @@ class Dashboard { setTheme(theme) { // Set the theme on the document element document.documentElement.setAttribute('data-theme', theme); - - // Update the toggle button + + // Update the theme toggle button if (theme === 'dark') { this.$themeIcon.text('☀️'); this.$themeText.text('Light'); @@ -574,13 +841,13 @@ class Dashboard { this.$themeIcon.text('🌙'); this.$themeText.text('Dark'); } - + // Update the logo based on theme this.updateLogo(theme); - + // Save to localStorage localStorage.setItem('serena-theme', theme); - + // Update charts if they exist this.updateChartsTheme(); } @@ -600,43 +867,47 @@ class Dashboard { const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; const textColor = isDark ? '#ffffff' : '#000000'; const gridColor = isDark ? '#444' : '#ddd'; - - // Update existing charts - if (this.countChart) { - this.countChart.options.scales.x.ticks.color = textColor; - this.countChart.options.scales.y.ticks.color = textColor; - this.countChart.options.scales.x.grid.color = gridColor; - this.countChart.options.scales.y.grid.color = gridColor; + + // Update existing charts if they exist and have the scales property + if (this.countChart && this.countChart.options.plugins) { + if (this.countChart.options.plugins.legend) { + this.countChart.options.plugins.legend.labels.color = textColor; + } this.countChart.update(); } - - if (this.inputChart) { - this.inputChart.options.scales.x.ticks.color = textColor; - this.inputChart.options.scales.y.ticks.color = textColor; - this.inputChart.options.scales.x.grid.color = gridColor; - this.inputChart.options.scales.y.grid.color = gridColor; + + if (this.inputChart && this.inputChart.options.plugins) { + if (this.inputChart.options.plugins.legend) { + this.inputChart.options.plugins.legend.labels.color = textColor; + } this.inputChart.update(); } - - if (this.outputChart) { - this.outputChart.options.scales.x.ticks.color = textColor; - this.outputChart.options.scales.y.ticks.color = textColor; - this.outputChart.options.scales.x.grid.color = gridColor; - this.outputChart.options.scales.y.grid.color = gridColor; + + if (this.outputChart && this.outputChart.options.plugins) { + if (this.outputChart.options.plugins.legend) { + this.outputChart.options.plugins.legend.labels.color = textColor; + } this.outputChart.update(); } - - if (this.tokensChart) { + + if (this.tokensChart && this.tokensChart.options.scales) { this.tokensChart.options.scales.x.ticks.color = textColor; this.tokensChart.options.scales.y.ticks.color = textColor; this.tokensChart.options.scales.y1.ticks.color = textColor; this.tokensChart.options.scales.x.grid.color = gridColor; this.tokensChart.options.scales.y.grid.color = gridColor; this.tokensChart.options.scales.y1.grid.color = gridColor; + this.tokensChart.options.scales.y.title.color = textColor; + this.tokensChart.options.scales.y1.title.color = textColor; + if (this.tokensChart.options.plugins && this.tokensChart.options.plugins.legend) { + this.tokensChart.options.plugins.legend.labels.color = textColor; + } this.tokensChart.update(); } } + // ===== Shutdown Method ===== + shutdown() { const self = this; const _shutdown = function () { @@ -658,5 +929,8 @@ class Dashboard { } else { console.log("Shutdown cancelled"); } + + // Close menu + self.$menuDropdown.hide(); } } diff --git a/src/serena/resources/dashboard/index.html b/src/serena/resources/dashboard/index.html index a303839a..9f0670e6 100644 --- a/src/serena/resources/dashboard/index.html +++ b/src/serena/resources/dashboard/index.html @@ -8,271 +8,112 @@ + - -
- -
+
+
+ +
+ +
-
- - - -
- 🌙 - Dark + +
+
+
+
+

Current Configuration

+
+
Loading configuration...
+
+
+ +
+

Tool Usage

+
+
Loading stats...
+
+
+
+ +
+
+

+ Registered Projects + ▼ +

+ +
+ +
+

+ Available Tools (Disabled) + ▼ +

+ +
+ +
+

+ Available Modes + ▼ +

+ +
+ +
+

+ Available Contexts + ▼ +

+ +
+
-
-
+ + -