diff --git a/docs/usage/troubleshooting.mdx b/docs/usage/troubleshooting.mdx index 83eb90127..864cb7c37 100644 --- a/docs/usage/troubleshooting.mdx +++ b/docs/usage/troubleshooting.mdx @@ -120,6 +120,14 @@ Dependency errors may arise if the required Python packages are not installed or 4. **Reinstall Dependencies**: If dependency issues persist, delete the `env` folder, recreate the virtual environment, and reinstall dependencies. + +5. **Debug Panel** + You can view live app state, tracked variables, and error messages to assist in real-time debugging with the debug panel. In `preswald.toml`, set: + ```toml + [debug] + enabled = true + ``` + and click the toggle that appears in the app. --- ## **Need More Help?** diff --git a/examples/iris/preswald.toml b/examples/iris/preswald.toml index 7bb0960f4..168115978 100644 --- a/examples/iris/preswald.toml +++ b/examples/iris/preswald.toml @@ -17,4 +17,7 @@ path = "data/iris.csv" [logging] level = "INFO" # Options: DEBUG, INFO, WARNING, ERROR, CRITICAL -format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" \ No newline at end of file +format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + +[debug] +enabled = true \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index fae9a0950..5ec7b921a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,7 +1,9 @@ -import { useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Route, Routes } from 'react-router-dom'; import { BrowserRouter as Router } from 'react-router-dom'; +import DebugPanel from '@/components/common/DebugPanel'; + import Layout from './components/Layout'; import LoadingState from './components/LoadingState'; import Dashboard from './components/pages/Dashboard'; @@ -13,6 +15,7 @@ const App = () => { const [config, setConfig] = useState(null); const [isConnected, setIsConnected] = useState(false); const [areComponentsLoading, setAreComponentsLoading] = useState(true); + const [debugMode, setDebugMode] = useState(false); useEffect(() => { comm.connect(); @@ -38,6 +41,32 @@ const App = () => { return () => document.removeEventListener('visibilitychange', updateTitle); }, [config]); + useEffect(() => { + fetch('/api/config') + .then((res) => res.text()) + .then((htmlText) => { + //parse html text for debug flag + const parser = new DOMParser(); + const doc = parser.parseFromString(htmlText, 'text/html'); + + const scripts = Array.from(doc.scripts); + + for (let script of scripts) { + if (script.textContent.includes('window.PRESWALD_BRANDING')) { + const match = script.textContent.match(/window\.PRESWALD_BRANDING\s*=\s*(\{.*\});/s); + if (match && match[1]) { + const brandingConfig = JSON.parse(match[1]); + setDebugMode(brandingConfig?.debug || false); + return; + } + } + } + + throw new Error('PRESWALD_BRANDING not found'); + }) + .catch((err) => console.error('Error fetching config:', err)); + }, []); + const handleMessage = (message) => { console.log('[App] Received message:', message); @@ -58,6 +87,7 @@ const App = () => { case 'config': setConfig(message.config); + setDebugMode(message.config?.debug || false); break; case 'initial_state': @@ -162,6 +192,7 @@ const App = () => { handleComponentUpdate={handleComponentUpdate} /> )} + {debugMode && } ); diff --git a/frontend/src/components/DynamicComponents.jsx b/frontend/src/components/DynamicComponents.jsx index 055b44116..b18c8eb8e 100644 --- a/frontend/src/components/DynamicComponents.jsx +++ b/frontend/src/components/DynamicComponents.jsx @@ -272,6 +272,7 @@ const MemoizedComponent = memo( {...props} sourceId={component.config?.source || null} sourceData={component.config?.data || null} + apiKey={component.config?.apiKey || null} value={component.value || component.state || { messages: [] }} onChange={(value) => { handleUpdate(componentId, value); diff --git a/frontend/src/components/common/DebugPanel.jsx b/frontend/src/components/common/DebugPanel.jsx new file mode 100644 index 000000000..b0fe62f30 --- /dev/null +++ b/frontend/src/components/common/DebugPanel.jsx @@ -0,0 +1,67 @@ +import React, { useEffect, useState } from 'react'; + +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { ScrollArea } from '@/components/ui/scroll-area'; + +import { comm } from '@/utils/websocket'; + +const DebugPanel = () => { + const [visible, setVisible] = useState(false); + const [debugState, setDebugState] = useState({}); + + useEffect(() => { + const unsubscribe = comm.subscribe((message) => { + if (message.type === '__debug__') { + console.log('[DebugPanel] Received debug state:', message); + setDebugState(message.payload); + } + }); + + comm.connect(); + + return () => { + unsubscribe(); + }; + }, []); + + const [openSections, setOpenSections] = useState({}); + + const toggleSection = (key) => { + setOpenSections((prev) => ({ + ...prev, + [key]: !prev[key], + })); + }; + + return ( +
+ + {visible && ( + + + {Object.entries(debugState).map(([key, value]) => ( +
+ + {openSections[key] && ( +
+                    {JSON.stringify(value, null, 2)}
+                  
+ )} +
+ ))} +
+
+ )} +
+ ); +}; + +export default DebugPanel; diff --git a/frontend/src/components/widgets/ChatWidget.jsx b/frontend/src/components/widgets/ChatWidget.jsx index 0f9d9abd3..16f9f0d91 100644 --- a/frontend/src/components/widgets/ChatWidget.jsx +++ b/frontend/src/components/widgets/ChatWidget.jsx @@ -339,4 +339,4 @@ const ChatWidget = ({ ); }; -export default ChatWidget; +export default ChatWidget; \ No newline at end of file diff --git a/frontend/src/utils/websocket.js b/frontend/src/utils/websocket.js index cc6c34bf6..439d7e016 100644 --- a/frontend/src/utils/websocket.js +++ b/frontend/src/utils/websocket.js @@ -62,6 +62,7 @@ class WebSocketClient { this.socket.onmessage = async (event) => { try { if (typeof event.data === 'string') { + // Normal text message — parse as JSON // Normal text message — parse as JSON const data = JSON.parse(event.data); console.log('[WebSocket] JSON Message received:', { @@ -105,6 +106,11 @@ class WebSocketClient { this.connections = data.connections || []; console.log('[WebSocket] Connections updated:', this.connections); break; + + case '__debug__': + window.__DEBUG_STATE__ = data.payload; + console.log('[WebSocket] Debug state updated:', window.__DEBUG_STATE__); + break; } this._notifySubscribers(data); diff --git a/preswald/engine/base_service.py b/preswald/engine/base_service.py index 77c251ab6..e8178e17f 100644 --- a/preswald/engine/base_service.py +++ b/preswald/engine/base_service.py @@ -1,10 +1,11 @@ import logging import os import time +import types from collections.abc import Callable -from threading import Lock -from typing import Any, Callable, Dict, Optional from contextlib import contextmanager +from threading import Lock +from typing import Any from preswald.engine.runner import ScriptRunner from preswald.engine.utils import ( @@ -13,8 +14,9 @@ compress_data, optimize_plotly_data, ) -from preswald.interfaces.workflow import Workflow, Atom from preswald.interfaces.component_return import ComponentReturn +from preswald.interfaces.workflow import Atom, Workflow + from .managers.data import DataManager from .managers.layout import LayoutManager @@ -89,6 +91,65 @@ def initialize(cls, script_path=None): cls._instance._initialize_data_manager(script_path) return cls._instance + @classmethod + def get_tracked_variables(cls): + """Aggregate variables from all active ScriptRunner instances.""" + tracked_variables = [] + for runner in cls.get_instance().script_runners.values(): + local_vars = runner._script_locals + for key, value in local_vars.items(): + if not isinstance(value, types.ModuleType) and not isinstance( + value, types.FunctionType + ): + tracked_variables.append({"name": key, "value": str(value)}) + return tracked_variables + + @classmethod + def get_state_snapshot(cls): + """Get all info about the current state of the instance for the debug panel.""" + try: + instance = cls.get_instance() + + # Get components hierarchy + components = instance.get_rendered_components() + components_cleaned = [] + rows = components.get("rows", []) + for row in rows: + for component in row: + component_id = component.get("id") + if not component_id: + continue + else: + components_cleaned.append( + { + "id": component_id, + "type": component.get("type"), + "state": cls.get_instance()._component_states.get( + component_id + ), + } + ) + + # Get tracked variables + workflow_variables = [ + instance._workflow.context.variables if instance._workflow else None + ] + + script_variables = instance.get_tracked_variables() + variables = script_variables + workflow_variables + + # Get errors + errors = instance.error_log if hasattr(instance, "error_log") else [] + + return { + "components": components_cleaned, + "variables": variables, + "errors": errors, + } + except Exception as e: + logger.error(f"Error generating state snapshot: {e}", exc_info=True) + return {"components": {}, "variables": {}, "errors": [{"message": str(e)}]} + @property def script_path(self) -> str | None: return self._script_path @@ -304,7 +365,7 @@ def get_rendered_components(self): def get_workflow(self) -> Workflow: return self._workflow - async def handle_client_message(self, client_id: str, message: Dict[str, Any]): + async def handle_client_message(self, client_id: str, message: dict[str, Any]): """Process incoming messages from clients""" start_time = time.time() try: diff --git a/preswald/engine/managers/branding.py b/preswald/engine/managers/branding.py index a9fa1b93a..f3634e0c7 100644 --- a/preswald/engine/managers/branding.py +++ b/preswald/engine/managers/branding.py @@ -28,6 +28,7 @@ def get_branding_config(self, script_path: str | None = None) -> dict[str, Any]: "logo": "/images/logo.png", "favicon": f"/images/favicon.ico?timestamp={time.time()}", "primaryColor": "#000000", + "debug": False, } if script_path: @@ -46,6 +47,11 @@ def get_branding_config(self, script_path: str | None = None) -> dict[str, Any]: branding["primaryColor"] = branding_config.get( "primaryColor", branding["primaryColor"] ) + + # Read the debug flag from the [debug] section + if "debug" in config: + branding["debug"] = config["debug"].get("enabled", False) + except Exception as e: logger.error(f"Error loading branding config: {e}") self._ensure_default_assets() diff --git a/preswald/engine/runner.py b/preswald/engine/runner.py index 1ca5845e5..d7a25706a 100644 --- a/preswald/engine/runner.py +++ b/preswald/engine/runner.py @@ -51,6 +51,7 @@ def __init__( self._run_count = 0 self._lock = threading.Lock() self._script_globals = {} + self._script_locals = {} from .service import PreswaldService # deferred import to avoid cyclic dependency self._service = PreswaldService.get_instance() @@ -344,10 +345,10 @@ async def run_script(self): script_dir = os.path.dirname(os.path.realpath(self.script_path)) os.chdir(script_dir) - def compile_and_run(src_code, script_path, script_globals, execution_context): + def compile_and_run(src_code, script_path, script_globals, script_locals, execution_context): code = compile(src_code, script_path, "exec") logger.debug(f"[ScriptRunner] Script compiled {script_path=}") - exec(code, script_globals) + exec(code, script_globals, script_locals) logger.debug(f"[ScriptRunner] Script executed {execution_context}") try: @@ -355,10 +356,10 @@ def compile_and_run(src_code, script_path, script_globals, execution_context): # Attempt reactive transformation tree, _ = transform_source(raw_code, filename=self.script_path) self._script_globals["workflow"] = workflow - compile_and_run(tree, self.script_path, self._script_globals, "(reactive)") + compile_and_run(tree, self.script_path, self._script_globals, self._script_locals, "(reactive)") workflow.execute_relevant_atoms() else: - compile_and_run(raw_code, self.script_path, self._script_globals, "(non-reactive)") + compile_and_run(raw_code, self.script_path, self._script_globals, self._script_locals, "(non-reactive)") workflow.reset() # just to be safe except Exception as transform_error: @@ -377,7 +378,7 @@ def compile_and_run(src_code, script_path, script_globals, execution_context): "widget_states": self.widget_states, } - compile_and_run(raw_code, self.script_path, self._script_globals, "(fallback, non-reactive)") + compile_and_run(raw_code, self.script_path, self._script_globals, self._script_locals, "(fallback, non-reactive)") os.chdir(current_working_dir) diff --git a/preswald/engine/server_service.py b/preswald/engine/server_service.py index 707260e6e..2fa772e96 100644 --- a/preswald/engine/server_service.py +++ b/preswald/engine/server_service.py @@ -82,3 +82,20 @@ async def _broadcast_connections(self): except Exception as e: logger.error(f"Error broadcasting connections: {e}") # Don't raise the exception to prevent disrupting the main flow + + async def _broadcast_debug_state(self): + """Broadcast debug state snapshots to all clients""" + try: + state_snapshot = self.get_state_snapshot() + + # Broadcast the state snapshot to all connected WebSocket clients + for websocket in self.websocket_connections.values(): + await websocket.send_json( + { + "type": "__debug__", + "payload": state_snapshot, + } + ) + logger.info("[Debug] Debug state broadcasted to all clients") + except Exception as e: + logger.error(f"Error broadcasting debug state: {e}") diff --git a/preswald/engine/service.py b/preswald/engine/service.py index 84cdddcac..e429688ce 100644 --- a/preswald/engine/service.py +++ b/preswald/engine/service.py @@ -40,3 +40,13 @@ def initialize(cls, script_path=None): def get_instance(cls): """Get the service instance""" return ServiceImpl.get_instance() + + @classmethod + def get_state_snapshot(cls): + """Get all info about current state of instance for debug panel""" + return { + "components": cls.get_instance().components, + "variables": cls.get_instance().variable_store, + "errors": cls.get_instance().error_log, + "execution_graph": cls.get_instance().workflow_graph.serialize(), + } diff --git a/preswald/interfaces/components.py b/preswald/interfaces/components.py index 5c41b997d..3e1a69ad8 100644 --- a/preswald/interfaces/components.py +++ b/preswald/interfaces/components.py @@ -1101,4 +1101,4 @@ def convert_to_serializable(obj): # except Exception as e: # logger.error(f"WebSocket send failed for {component_id}: {e}") # else: -# logger.warning(f"No active WebSocket found for client ID: {client_id}") +# logger.warning(f"No active WebSocket found for client ID: {client_id}") \ No newline at end of file diff --git a/preswald/main.py b/preswald/main.py index 3606e9c2f..ca5f245a3 100644 --- a/preswald/main.py +++ b/preswald/main.py @@ -96,9 +96,22 @@ async def websocket_endpoint(websocket: WebSocket, client_id: str): try: await app.state.service.register_client(client_id, websocket) try: - while not app.state.service._is_shutting_down: - message = await websocket.receive_json() - await app.state.service.handle_client_message(client_id, message) + branding = app.state.service.branding_manager.get_branding_config( + app.state.service.script_path + ) + if branding["debug"]: + while not app.state.service._is_shutting_down: + await app.state.service._broadcast_debug_state() + message = await websocket.receive_json() + await app.state.service.handle_client_message( + client_id, message + ) + else: + while not app.state.service._is_shutting_down: + message = await websocket.receive_json() + await app.state.service.handle_client_message( + client_id, message + ) except WebSocketDisconnect: logger.info(f"Client disconnected: {client_id}") finally: