Skip to content

Feature: Add Debug Panel #725

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 25 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a9069a3
fix: added scipy dependency
Ja-Tink Mar 6, 2025
30934ca
add: load api key for chat component from secrets.toml
joshlavroff Apr 4, 2025
f149b8c
fix: update components.py to newest release
joshlavroff Apr 5, 2025
674084e
add: update chat documentation to include secrets.toml API key support
joshlavroff Apr 5, 2025
e07f751
resolve merge
joshlavroff Apr 14, 2025
dee3a8a
Merge branch 'main' of https://github.com/ossd-s25/preswald into feat…
joshlavroff Apr 17, 2025
52e477a
add: logic for debug panel
joshlavroff Apr 18, 2025
e2a7ccd
add: debug flag in config endpoint
joshlavroff Apr 21, 2025
435f3df
Get debug value from config
Ja-Tink Apr 21, 2025
f40f68d
fix: move snapshot state function to base_service
joshlavroff Apr 21, 2025
451fe8d
Quick comment change
Ja-Tink Apr 21, 2025
3796bd7
fix: broadcasting debug info
joshlavroff Apr 21, 2025
46f9924
fix: await debug broadcast as app runs
joshlavroff Apr 21, 2025
b23480a
Changed message parsing logic in DebugPanel.jsx
Ja-Tink Apr 21, 2025
766df07
add: debug tracking for errors and components
joshlavroff Apr 21, 2025
9aac2e3
add: debug tracking for errors and components
joshlavroff Apr 21, 2025
ff32487
add: component type to debug panel
joshlavroff Apr 21, 2025
f0b431b
fix: remove critical logging for debug state update
joshlavroff Apr 21, 2025
194c393
Added dropdowns to debug panel
Ja-Tink Apr 21, 2025
ca39ccd
Draft of debug panel documentation
Ja-Tink May 7, 2025
e1aa686
fix: track local variables
joshlavroff May 7, 2025
e82d508
resolve merge conflicts
joshlavroff May 7, 2025
4d15b7a
merge newest
joshlavroff May 7, 2025
33db5be
Merge branch 'feature/debug-panel' of https://github.com/ossd-s25/pre…
joshlavroff May 7, 2025
2233680
fix: remove loading from secrets toml
joshlavroff May 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/usage/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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?**
Expand Down
5 changes: 4 additions & 1 deletion examples/iris/preswald.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"

[debug]
enabled = true
33 changes: 32 additions & 1 deletion frontend/src/App.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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();
Expand All @@ -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);

Expand All @@ -58,6 +87,7 @@ const App = () => {

case 'config':
setConfig(message.config);
setDebugMode(message.config?.debug || false);
break;

case 'initial_state':
Expand Down Expand Up @@ -162,6 +192,7 @@ const App = () => {
handleComponentUpdate={handleComponentUpdate}
/>
)}
{debugMode && <DebugPanel />}
</Layout>
</Router>
);
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/DynamicComponents.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
67 changes: 67 additions & 0 deletions frontend/src/components/common/DebugPanel.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="fixed bottom-4 right-4 z-50">
<Button onClick={() => setVisible(!visible)} className="mb-2">
{visible ? 'Hide Debug' : 'Show Debug'}
</Button>
{visible && (
<Card className="w-[400px] h-[500px] overflow-hidden shadow-lg border bg-white dark:bg-gray-900 text-sm">
<ScrollArea className="p-4 h-full">
{Object.entries(debugState).map(([key, value]) => (
<div key={key} className="mb-2">
<Button
onClick={() => toggleSection(key)}
className="w-full text-left font-semibold py-1 hover:underline"
>
{openSections[key] ? '▾' : '▸'} {key}
</Button>
{openSections[key] && (
<pre className="ml-4 whitespace-pre-wrap text-xs">
{JSON.stringify(value, null, 2)}
</pre>
)}
</div>
))}
</ScrollArea>
</Card>
)}
</div>
);
};

export default DebugPanel;
2 changes: 1 addition & 1 deletion frontend/src/components/widgets/ChatWidget.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -339,4 +339,4 @@ const ChatWidget = ({
);
};

export default ChatWidget;
export default ChatWidget;
6 changes: 6 additions & 0 deletions frontend/src/utils/websocket.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:', {
Expand Down Expand Up @@ -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);
Expand Down
69 changes: 65 additions & 4 deletions preswald/engine/base_service.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions preswald/engine/managers/branding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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()
Expand Down
11 changes: 6 additions & 5 deletions preswald/engine/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -344,21 +345,21 @@ 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:
if self._service.is_reactivity_enabled:
# 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:
Expand All @@ -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)

Expand Down
17 changes: 17 additions & 0 deletions preswald/engine/server_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
10 changes: 10 additions & 0 deletions preswald/engine/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}
Loading