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: