implement actor terminal ui

This commit is contained in:
Timur Gordon 2025-08-07 10:26:11 +02:00
parent 6c5c97e647
commit ce76f0a2f7
31 changed files with 2307 additions and 4703 deletions

View File

@ -8,7 +8,9 @@ name = "baobab_actor" # Can be different from package name, or same
path = "src/lib.rs"
crate-type = ["cdylib", "rlib"]
[[bin]]
name = "baobab-actor-tui"
path = "cmd/terminal_ui_main.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@ -21,11 +23,15 @@ tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
log = "0.4"
env_logger = "0.10"
clap = { version = "4.4", features = ["derive"] }
uuid = { version = "1.6", features = ["v4", "serde"] } # Though task_id is string, uuid might be useful
uuid = { version = "1.6", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
toml = "0.8"
thiserror = "1.0"
async-trait = "0.1"
# TUI dependencies
anyhow = "1.0"
crossterm = "0.28"
ratatui = "0.28"
hero_supervisor = { path = "../supervisor" }
hero_job = { path = "../job" }
heromodels = { git = "https://git.ourworld.tf/herocode/db.git" }

View File

@ -73,3 +73,12 @@ Key dependencies include:
- `clap`: For command-line argument parsing.
- `tokio`: For the asynchronous runtime.
- `log`, `env_logger`: For logging.
## TUI Example
```bash
cargo run --example baobab-actor-tui -- --id osis --path /Users/timurgordon/code/git.ourworld.tf/herocode/actor_osis/target/debug/actor_osis --example-dir /Users/timurgordon/code/git.ourworld.tf/herocode/actor_osis/examples/scripts
```
The TUI will allow you to monitor the actor's job queue and dispatch new jobs to it.

View File

@ -1,168 +0,0 @@
# Baobab Actor UI
A WASM-based user interface for monitoring and dispatching jobs to Baobab actors. This UI provides a web-based dashboard for interacting with actors, running scripts, monitoring jobs, and managing example scripts.
## Features
- **Dashboard**: Overview of actor status, job statistics, and configuration
- **Inspector**: Interactive script editor for dispatching jobs directly to Redis
- **Jobs**: Real-time job monitoring and status tracking
- **Examples**: Run pre-defined example scripts from a specified directory
## Prerequisites
- Rust with `wasm32-unknown-unknown` target installed
- `wasm-pack` for building WASM applications
- Python 3 (for the built-in HTTP server)
## Installation
1. Install the required Rust target:
```bash
rustup target add wasm32-unknown-unknown
```
2. Install wasm-pack:
```bash
cargo install wasm-pack
```
## Usage
### Basic Usage
Run the actor UI with minimal configuration:
```bash
cargo run --bin baobab_actor_ui -- --id my_actor --path /path/to/actor/binary
```
### Full Configuration
```bash
cargo run --bin baobab_actor_ui -- \
--id osis \
--path /path/to/actor/osis \
--example-dir /path/to/examples \
--redis-url redis://localhost:6379 \
--port 8080
```
### Command Line Options
- `--id`: Actor ID to connect to (required)
- `--path`: Path to the actor binary (required)
- `--example-dir`: Directory containing example .rhai scripts (optional)
- `--redis-url`: Redis connection URL (default: redis://localhost:6379)
- `--port`: Port to serve the UI on (default: 8080)
- `--skip-build`: Skip building WASM and serve existing build
### Development
For development with hot reload, you can use Trunk:
```bash
# Install trunk
cargo install trunk
# Serve with hot reload
trunk serve --features wasm
```
## UI Components
### Dashboard
- Actor status overview
- Job statistics (completed, pending, failed)
- Configuration information
- System metrics
### Inspector
- Interactive script editor with syntax highlighting
- Job parameter configuration (JSON format)
- Real-time execution output
- Direct Redis job dispatch
### Jobs
- Real-time job queue monitoring
- Job status tracking (Pending, Running, Completed, Failed)
- Job details viewer
- Job history and logs
### Examples
- Browse available example scripts
- One-click script execution
- Script content preview
- Execution results display
## Architecture
The UI is built using:
- **Yew**: Rust-based WebAssembly framework for building web applications
- **Bootstrap 5**: CSS framework for responsive design
- **Bootstrap Icons**: Icon library for UI elements
- **WASM-bindgen**: Rust/JavaScript interop for WebAssembly
### Redis Integration
Since Redis clients don't work directly in WASM, the UI communicates with Redis through:
- HTTP API endpoints for job dispatch and monitoring
- WebSocket connections for real-time updates (planned)
- Backend service proxy for Redis operations
## Example Scripts
When using the `--example-dir` parameter, the UI will load `.rhai` scripts from the specified directory. Example structure:
```
examples/
├── hello_world.rhai
├── math_operations.rhai
├── data_processing.rhai
└── workflow_example.rhai
```
Each script should be a valid Rhai script that can be executed by the actor.
## Building for Production
1. Build the WASM application:
```bash
wasm-pack build --target web --features wasm --out-dir pkg
```
2. Serve the files using any HTTP server:
```bash
python3 -m http.server 8080
```
## Troubleshooting
### WASM Build Issues
- Ensure `wasm32-unknown-unknown` target is installed
- Check that `wasm-pack` is available in PATH
- Verify all WASM dependencies are properly configured
### Runtime Issues
- Check browser console for JavaScript errors
- Ensure Redis is running and accessible
- Verify actor binary path is correct
- Check network connectivity for Bootstrap CDN resources
### Performance
- The UI is optimized for modern browsers with WebAssembly support
- For better performance, consider serving static assets locally
- Monitor browser memory usage for long-running sessions
## Contributing
When adding new features:
1. Update the appropriate page component in `src/ui/pages/`
2. Add new components to `src/ui/components/`
3. Update the router configuration if needed
4. Test with both mock and real data
5. Update this README with new features
## License
This project follows the same license as the parent Baobab project.

View File

@ -1,16 +0,0 @@
[build]
target = "index.html"
dist = "dist"
[watch]
watch = ["src", "Cargo.toml"]
ignore = ["dist"]
[serve]
address = "127.0.0.1"
port = 8080
open = false
[clean]
dist = "dist"
cargo = true

File diff suppressed because it is too large Load Diff

View File

@ -1,49 +0,0 @@
[package]
name = "baobab_actor_ui"
version = "0.1.0"
edition = "2021"
[workspace]
# Empty workspace table to exclude from parent workspace
[lib]
crate-type = ["cdylib"]
[[bin]]
name = "baobab_actor_ui"
path = "src/main.rs"
[dependencies]
# Core WASM-only dependencies
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# WASM UI dependencies
yew = { version = "0.21", features = ["csr"] }
yew-router = "0.18"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = "0.3"
js-sys = "0.3"
gloo-net = "0.4"
gloo-console = "0.3"
wasm-logger = "0.2"
# Only include WASM-compatible dependencies
[target.'cfg(target_arch = "wasm32")'.dependencies]
gloo-utils = "0.2"
# Native-only dependencies for the binary
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
clap = { version = "4.0", features = ["derive"] }
tokio = { version = "1.0", features = ["full"] }
reqwest = { version = "0.11", features = ["json"] }
flate2 = "1.0"
tar = "0.4"
uuid = { version = "1.0", features = ["v4"] }
async-trait = "0.1"
log = "0.4"
[features]
default = []
wasm = []

View File

@ -1,156 +0,0 @@
# Baobab Actor UI
A self-contained WASM-based user interface for monitoring and dispatching jobs to Hero actors with automatic Webdis installation.
## Features
- 🚀 **Self-contained binary** - No separate installation required
- 📦 **Automatic Webdis installation** - Downloads and configures Webdis automatically
- 🌐 **WASM UI** - Modern web-based interface built with Yew
- 🔄 **Real-time job monitoring** - Live updates of job status and progress
- 📝 **Script execution** - Run and test Rhai scripts directly in the browser
- 📊 **Actor dashboard** - Overview of actor status and job statistics
- 🔍 **Job inspector** - Detailed job parameter editing and output viewing
- 📚 **Example scripts** - Load and run example scripts from a directory
## Installation & Usage
### Prerequisites
- Rust toolchain with `wasm-pack` installed
- Redis server running (for job storage)
- Python 3 (for HTTP server)
- `curl` and `tar` (for Webdis installation)
### Quick Start
```bash
# Navigate to the binary directory
cd core/actor/cmd/baobab_actor_ui
# Run the UI (will automatically install Webdis)
cargo run -- --id myactor --path /path/to/actor/binary
# Open browser to http://localhost:8080
```
### Command Line Options
```bash
baobab_actor_ui [OPTIONS] --id <ID> --path <PATH>
Options:
--id <ID> Actor ID
--path <PATH> Path to actor binary
--example-dir <EXAMPLE_DIR> Directory containing example .rhai scripts
--webdis-url <WEBDIS_URL> Webdis connection URL [default: http://localhost:7379]
--port <PORT> Port to serve the UI on [default: 8080]
--skip-webdis Skip Webdis installation (assume it's already running)
--webdis-port <WEBDIS_PORT> Webdis port [default: 7379]
-h, --help Print help
```
### Examples
```bash
# Basic usage
cargo run -- --id osis --path /usr/local/bin/osis_actor
# With example scripts directory
cargo run -- --id system --path ./system_actor --example-dir ./examples
# Custom ports
cargo run -- --id myactor --path ./actor --port 3000 --webdis-port 7380
# Skip Webdis installation (if already running)
cargo run -- --id myactor --path ./actor --skip-webdis
```
## Architecture
### Components
- **Main Binary** (`main.rs`) - CLI interface and Webdis management
- **WASM Library** (`lib.rs`) - Entry point for the web application
- **UI Components**:
- `app.rs` - Main application component
- `router.rs` - Navigation and routing
- `pages/` - Individual page components (Dashboard, Inspector, Jobs, Examples)
- `components/` - Reusable UI components
- `redis_client.rs` - Webdis HTTP client for Redis operations
### Webdis Integration
The binary automatically:
1. Downloads the appropriate Webdis release for your platform
2. Extracts and configures Webdis with secure settings
3. Starts Webdis as a background process
4. Provides HTTP access to Redis following the Hero protocol
### Hero Protocol Compliance
The UI follows the Hero Supervisor Redis protocol:
- Jobs stored as `hero:job:{id}` hashes
- Work queues as `hero:work_queue:{actor_id}` lists
- Stop queues as `hero:stop_queue` lists
- Full compatibility with core/job model
## Development
### Building
```bash
# Build the WASM component
wasm-pack build --target web --features wasm
# Build the native binary
cargo build --release
```
### Project Structure
```
baobab_actor_ui/
├── Cargo.toml # Dependencies and configuration
├── README.md # This file
├── main.rs # CLI binary with Webdis management
├── lib.rs # WASM entry point
├── app.rs # Main Yew application
├── router.rs # Navigation routing
├── redis_client.rs # Webdis HTTP client
├── pages/ # UI pages
│ ├── mod.rs
│ ├── dashboard.rs # Actor overview
│ ├── inspector.rs # Script editor and job runner
│ ├── jobs.rs # Job list and monitoring
│ └── examples.rs # Example script browser
└── components/ # Reusable components
├── mod.rs
└── script_execution_panel.rs
```
## Troubleshooting
### Webdis Installation Issues
If automatic Webdis installation fails:
1. Install Webdis manually from [releases](https://github.com/nicolasff/webdis/releases)
2. Start it with: `./webdis webdis.json`
3. Use `--skip-webdis` flag
### WASM Build Issues
Ensure you have the latest `wasm-pack`:
```bash
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
```
### Redis Connection Issues
- Ensure Redis is running on localhost:6379
- Check Webdis logs for connection errors
- Verify firewall settings allow connections
## License
Part of the Hero framework ecosystem.

View File

@ -1,31 +0,0 @@
[build]
target = "index.html"
dist = "dist"
[serve]
address = "127.0.0.1"
port = 8080
open = true
[tools]
# Aggressive WASM optimization with wasm-opt
wasm-opt = [
"-Os", # Optimize for size
"--enable-mutable-globals",
"--enable-sign-ext",
"--enable-nontrapping-float-to-int",
"--enable-bulk-memory",
"--strip-debug", # Remove debug info
"--strip-producers", # Remove producer info
"--dce", # Dead code elimination
"--vacuum", # Remove unused code
"--merge-blocks", # Merge basic blocks
"--precompute", # Precompute expressions
"--precompute-propagate", # Propagate precomputed values
"--remove-unused-names", # Remove unused function names
"--simplify-locals", # Simplify local variables
"--coalesce-locals", # Coalesce local variables
"--reorder-locals", # Reorder locals for better compression
"--flatten", # Flatten control flow
"--rereloop", # Optimize loops
]

View File

@ -1,20 +0,0 @@
#!/bin/bash
# Build script for baobab_actor_ui WASM app
# Based on examples/website/build.sh
set -e
echo "🔧 Building Baobab Actor UI..."
# Check if trunk is installed
if ! command -v trunk &> /dev/null; then
echo "📦 Installing trunk..."
cargo install trunk
fi
# Build the WASM app
echo "🚀 Building WASM application..."
trunk build --release
echo "✅ Build complete! Output in dist/ directory"

View File

@ -1,37 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Baobab Actor UI</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<style>
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; }
.navbar-brand { font-weight: bold; }
.card { border: none; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.btn-primary { background: linear-gradient(45deg, #007bff, #0056b3); border: none; }
.status-badge { font-size: 0.8em; }
.code-editor { font-family: 'Courier New', monospace; font-size: 14px; }
.output-pane { background-color: #f8f9fa; border-left: 4px solid #007bff; }
.sidebar { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
.nav-link { color: rgba(255,255,255,0.8) !important; }
.nav-link:hover { color: white !important; }
.nav-link.active { color: white !important; background-color: rgba(255,255,255,0.1) !important; }
</style>
</head>
<body style="background-color: unset;">
<div id="app">
<div class="d-flex justify-content-center align-items-center" style="height: 100vh;">
<div class="text-center">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3">Loading Baobab Actor UI...</p>
</div>
</div>
</div>
<link data-trunk rel="rust" data-bin="baobab_actor_ui" />
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@ -1,18 +0,0 @@
#!/bin/bash
# Serve script for baobab_actor_ui WASM app
# Based on examples/website/serve.sh
set -e
echo "🚀 Starting Baobab Actor UI development server..."
# Check if trunk is installed
if ! command -v trunk &> /dev/null; then
echo "📦 Installing trunk..."
cargo install trunk
fi
# Start the development server
echo "🌐 Starting development server at http://127.0.0.1:8080"
trunk serve --open

View File

@ -1,50 +0,0 @@
use yew::prelude::*;
use yew_router::prelude::*;
use crate::router::{Route, switch};
#[derive(Properties, PartialEq, Clone)]
pub struct AppProps {
pub actor_id: String,
pub actor_path: String,
pub example_dir: Option<String>,
pub redis_url: String,
}
#[function_component(App)]
pub fn app(props: &AppProps) -> Html {
let props_clone = props.clone();
html! {
<BrowserRouter>
<div class="container-fluid">
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<Link<Route> classes="navbar-brand" to={Route::Dashboard}>
{"Baobab Actor UI"}
</Link<Route>>
<div class="navbar-nav">
<Link<Route> classes="nav-link" to={Route::Dashboard}>
{"Dashboard"}
</Link<Route>>
<Link<Route> classes="nav-link" to={Route::Inspector}>
{"Inspector"}
</Link<Route>>
<Link<Route> classes="nav-link" to={Route::Jobs}>
{"Jobs"}
</Link<Route>>
<Link<Route> classes="nav-link" to={Route::Examples}>
{"Examples"}
</Link<Route>>
</div>
<span class="navbar-text">
{format!("Actor: {}", props.actor_id)}
</span>
</div>
</nav>
<main class="mt-3">
<Switch<Route> render={move |route| switch(route, props_clone.clone())} />
</main>
</div>
</BrowserRouter>
}
}

View File

@ -1,3 +0,0 @@
pub mod script_execution_panel;
pub use script_execution_panel::ScriptExecutionPanel;

View File

@ -1,98 +0,0 @@
use yew::prelude::*;
#[derive(Properties, PartialEq)]
pub struct ScriptExecutionPanelProps {
pub script_content: String,
pub script_filename: String,
pub output_content: Option<String>,
pub on_run: Callback<()>,
pub is_running: bool,
}
#[function_component(ScriptExecutionPanel)]
pub fn script_execution_panel(props: &ScriptExecutionPanelProps) -> Html {
let on_run_click = {
let on_run = props.on_run.clone();
Callback::from(move |_: MouseEvent| {
on_run.emit(());
})
};
html! {
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<h5 class="mb-0">{"Script Content"}</h5>
{if !props.script_filename.is_empty() {
html! { <small class="text-muted"><code>{&props.script_filename}</code></small> }
} else {
html! {}
}}
</div>
<button
class="btn btn-primary"
onclick={on_run_click}
disabled={props.is_running}
>
{if props.is_running {
html! {
<>
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
{"Running..."}
</>
}
} else {
html! {
<>
<i class="bi bi-play-fill me-2"></i>
{"Run Script"}
</>
}
}}
</button>
</div>
<div class="card-body">
<pre class="bg-light p-3 rounded" style="height: 400px; overflow-y: auto; font-size: 0.9rem;">
<code>{&props.script_content}</code>
</pre>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0">{"Execution Output"}</h5>
</div>
<div class="card-body">
{if let Some(output) = &props.output_content {
html! {
<pre class="bg-dark text-light p-3 rounded" style="height: 400px; overflow-y: auto; font-size: 0.9rem;">
{output}
</pre>
}
} else if props.is_running {
html! {
<div class="text-center p-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">{"Loading..."}</span>
</div>
<p class="mt-2">{"Executing script..."}</p>
</div>
}
} else {
html! {
<div class="text-muted text-center p-5">
<i class="bi bi-terminal" style="font-size: 3rem;"></i>
<p class="mt-2">{"Output will appear here after execution"}</p>
</div>
}
}}
</div>
</div>
</div>
</div>
}
}

View File

@ -1,18 +0,0 @@
//! Baobab Actor UI - WASM Library Entry Point
use wasm_bindgen::prelude::*;
use yew::prelude::*;
mod app;
mod router;
mod pages;
mod components;
// mod redis_client; // Temporarily disabled
use app::App;
#[wasm_bindgen(start)]
pub fn run_app() {
wasm_logger::init(wasm_logger::Config::default());
yew::Renderer::<App>::new().render();
}

View File

@ -1,325 +0,0 @@
//! Baobab Actor UI - Self-contained WASM UI with automatic Webdis installation
//!
//! This binary provides a complete actor monitoring and job dispatch interface
//! with automatic Webdis installation and management.
use clap::Parser;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::fs;
use std::io::Write;
use tokio::time::{sleep, Duration};
mod app;
mod router;
mod pages;
mod components;
mod redis_client;
use app::App;
#[derive(Parser, Debug)]
#[command(name = "baobab_actor_ui")]
#[command(about = "Baobab Actor UI - Monitor and dispatch jobs to actors")]
pub struct Args {
/// Actor ID
#[arg(long)]
pub id: String,
/// Path to actor binary
#[arg(long)]
pub path: PathBuf,
/// Directory containing example .rhai scripts
#[arg(long)]
pub example_dir: Option<PathBuf>,
/// Webdis connection URL
#[arg(long, default_value = "http://localhost:7379")]
pub webdis_url: String,
/// Port to serve the UI on
#[arg(long, default_value = "8080")]
pub port: u16,
/// Skip Webdis installation (assume it's already running)
#[arg(long)]
pub skip_webdis: bool,
/// Webdis port
#[arg(long, default_value = "7379")]
pub webdis_port: u16,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Args::parse();
println!("🚀 Starting Baobab Actor UI...");
println!("Actor ID: {}", args.id);
println!("Actor Path: {}", args.path.display());
println!("Webdis URL: {}", args.webdis_url);
println!("Port: {}", args.port);
if let Some(example_dir) = &args.example_dir {
println!("Example Directory: {}", example_dir.display());
}
// Install and start Webdis if not skipped
if !args.skip_webdis {
println!("📦 Installing and starting Webdis...");
install_and_start_webdis(args.webdis_port).await?;
} else {
println!("⏭️ Skipping Webdis installation (--skip-webdis specified)");
}
// Build WASM app
println!("🔨 Building WASM application...");
build_wasm_app().await?;
// Generate HTML file
println!("📄 Generating HTML file...");
let html_content = generate_html(&args);
let html_path = "index.html";
fs::write(html_path, html_content)?;
// Start HTTP server
println!("🌐 Starting HTTP server on port {}...", args.port);
start_http_server(args.port).await?;
Ok(())
}
/// Install Webdis from official releases and start it
async fn install_and_start_webdis(port: u16) -> Result<(), Box<dyn std::error::Error>> {
let webdis_dir = "webdis";
let webdis_binary = format!("{}/webdis", webdis_dir);
// Check if Webdis is already installed
if !std::path::Path::new(&webdis_binary).exists() {
println!("📥 Downloading Webdis...");
// Create webdis directory
fs::create_dir_all(webdis_dir)?;
// Determine platform and download appropriate release
let (platform, archive_ext) = if cfg!(target_os = "macos") {
("darwin", "tar.gz")
} else if cfg!(target_os = "linux") {
("linux", "tar.gz")
} else {
return Err("Unsupported platform for automatic Webdis installation".into());
};
// Download latest release (using a known stable version)
let download_url = format!(
"https://github.com/nicolasff/webdis/releases/download/0.1.22/webdis-0.1.22-{}.{}",
platform, archive_ext
);
println!("📥 Downloading from: {}", download_url);
let output = Command::new("curl")
.args(["-L", "-o", &format!("{}/webdis.{}", webdis_dir, archive_ext), &download_url])
.output()?;
if !output.status.success() {
return Err(format!("Failed to download Webdis: {}", String::from_utf8_lossy(&output.stderr)).into());
}
// Extract archive
println!("📦 Extracting Webdis...");
let extract_output = Command::new("tar")
.args(["-xzf", &format!("webdis.{}", archive_ext)])
.current_dir(webdis_dir)
.output()?;
if !extract_output.status.success() {
return Err(format!("Failed to extract Webdis: {}", String::from_utf8_lossy(&extract_output.stderr)).into());
}
// Make binary executable
Command::new("chmod")
.args(["+x", "webdis"])
.current_dir(webdis_dir)
.output()?;
}
// Create Webdis config file
let config_content = format!(
r#"{{
"redis_host": "127.0.0.1",
"redis_port": 6379,
"http_host": "0.0.0.0",
"http_port": {},
"threads": 5,
"pool_size": 20,
"daemonize": false,
"websockets": false,
"database": 0,
"acl": [
{{
"disabled": ["DEBUG", "FLUSHDB", "FLUSHALL", "SHUTDOWN", "EVAL", "SCRIPT"]
}}
]
}}"#,
port
);
fs::write(format!("{}/webdis.json", webdis_dir), config_content)?;
// Start Webdis in background
println!("🚀 Starting Webdis on port {}...", port);
let mut webdis_process = Command::new("./webdis")
.arg("webdis.json")
.current_dir(webdis_dir)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
// Wait a moment for Webdis to start
sleep(Duration::from_secs(2)).await;
// Check if Webdis is running
let health_check = Command::new("curl")
.args(["-s", &format!("http://localhost:{}/PING", port)])
.output();
match health_check {
Ok(output) if output.status.success() => {
println!("✅ Webdis is running successfully!");
}
_ => {
println!("⚠️ Webdis may not be running properly, but continuing...");
}
}
Ok(())
}
/// Build the WASM application
async fn build_wasm_app() -> Result<(), Box<dyn std::error::Error>> {
let output = Command::new("wasm-pack")
.args([
"build",
"--target", "web",
"--out-dir", "pkg",
"--features", "wasm"
])
.current_dir(".")
.output()?;
if !output.status.success() {
return Err(format!("Failed to build WASM: {}", String::from_utf8_lossy(&output.stderr)).into());
}
println!("✅ WASM build completed successfully!");
Ok(())
}
/// Generate HTML file with embedded configuration
fn generate_html(args: &Args) -> String {
let example_dir_param = args.example_dir
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "".to_string());
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Baobab Actor UI - {}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
<style>
body {{ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; }}
.navbar-brand {{ font-weight: bold; }}
.card {{ border: none; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }}
.btn-primary {{ background: linear-gradient(45deg, #007bff, #0056b3); border: none; }}
.status-badge {{ font-size: 0.8em; }}
.code-editor {{ font-family: 'Courier New', monospace; font-size: 14px; }}
.output-pane {{ background-color: #f8f9fa; border-left: 4px solid #007bff; }}
.sidebar {{ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }}
.nav-link {{ color: rgba(255,255,255,0.8) !important; }}
.nav-link:hover {{ color: white !important; }}
.nav-link.active {{ color: white !important; background-color: rgba(255,255,255,0.1) !important; }}
</style>
</head>
<body>
<div id="app">
<div class="d-flex justify-content-center align-items-center" style="height: 100vh;">
<div class="text-center">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3">Loading Baobab Actor UI...</p>
</div>
</div>
</div>
<script>
window.ACTOR_CONFIG = {{
actorId: "{}",
actorPath: "{}",
webdisUrl: "{}",
exampleDir: "{}"
}};
</script>
<script type="module">
import init from './pkg/baobab_actor.js';
async function run() {{
await init();
}}
run();
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>"#,
args.id,
args.id,
args.path.display(),
args.webdis_url,
example_dir_param
)
}
/// Start HTTP server to serve the UI
async fn start_http_server(port: u16) -> Result<(), Box<dyn std::error::Error>> {
println!("🌐 Open your browser to: http://localhost:{}", port);
let server_command = format!(
r#"python3 -c "
import http.server
import socketserver
import os
class MyHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
def end_headers(self):
self.send_header('Cross-Origin-Embedder-Policy', 'require-corp')
self.send_header('Cross-Origin-Opener-Policy', 'same-origin')
super().end_headers()
os.chdir('.')
with socketserver.TCPServer(('', {}), MyHTTPRequestHandler) as httpd:
print('Server running on port {}')
httpd.serve_forever()
""#,
port, port
);
let mut child = Command::new("sh")
.arg("-c")
.arg(&server_command)
.spawn()?;
// Wait for the server (this will run indefinitely)
let _ = child.wait()?;
Ok(())
}

View File

@ -1,13 +0,0 @@
#[cfg(feature = "wasm")]
pub mod app;
#[cfg(feature = "wasm")]
pub mod components;
#[cfg(feature = "wasm")]
pub mod pages;
#[cfg(feature = "wasm")]
pub mod router;
#[cfg(feature = "wasm")]
pub mod redis_client;
#[cfg(feature = "wasm")]
pub use app::App;

View File

@ -1,83 +0,0 @@
use yew::prelude::*;
use crate::app::AppProps;
#[function_component(DashboardPage)]
pub fn dashboard_page(props: &AppProps) -> Html {
html! {
<div class="container-fluid">
<div class="row">
<div class="col-12">
<h1>{"Actor Dashboard"}</h1>
<p class="lead">{format!("Monitoring actor: {}", props.actor_id)}</p>
</div>
</div>
<div class="row">
<div class="col-md-4">
<div class="card bg-primary text-white">
<div class="card-body">
<h5 class="card-title">{"Actor Status"}</h5>
<p class="card-text">{"Running"}</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card bg-success text-white">
<div class="card-body">
<h5 class="card-title">{"Jobs Completed"}</h5>
<p class="card-text">{"0"}</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card bg-warning text-white">
<div class="card-body">
<h5 class="card-title">{"Jobs Pending"}</h5>
<p class="card-text">{"0"}</p>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5>{"Actor Information"}</h5>
</div>
<div class="card-body">
<table class="table">
<tbody>
<tr>
<td><strong>{"Actor ID"}</strong></td>
<td>{&props.actor_id}</td>
</tr>
<tr>
<td><strong>{"Actor Path"}</strong></td>
<td>{&props.actor_path}</td>
</tr>
<tr>
<td><strong>{"Redis URL"}</strong></td>
<td>{&props.redis_url}</td>
</tr>
{
if let Some(example_dir) = &props.example_dir {
html! {
<tr>
<td><strong>{"Example Directory"}</strong></td>
<td>{example_dir}</td>
</tr>
}
} else {
html! {}
}
}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
}
}

View File

@ -1,214 +0,0 @@
use yew::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::app::AppProps;
use crate::redis_client::RedisClient;
use crate::components::ScriptExecutionPanel;
#[derive(Clone, PartialEq)]
pub struct ExampleScript {
pub name: String,
pub filename: String,
pub description: String,
pub content: String,
}
pub struct ExamplesPage {
examples: Vec<ExampleScript>,
selected_example: Option<String>,
script_output: Option<String>,
is_running: bool,
}
pub enum ExamplesMsg {
SelectExample(String),
RunExample,
ScriptComplete(String),
}
impl Component for ExamplesPage {
type Message = ExamplesMsg;
type Properties = AppProps;
fn create(ctx: &Context<Self>) -> Self {
let props = ctx.props();
// Mock example scripts - in real implementation, these would be loaded from the example_dir
let examples = if props.example_dir.is_some() {
vec![
ExampleScript {
name: "Hello World".to_string(),
filename: "hello_world.rhai".to_string(),
description: "A simple hello world script".to_string(),
content: "print(\"Hello from actor!\");\nprint(\"Current time: \" + timestamp());".to_string(),
},
ExampleScript {
name: "Math Operations".to_string(),
filename: "math_ops.rhai".to_string(),
description: "Demonstrates basic mathematical operations".to_string(),
content: "let a = 10;\nlet b = 20;\nlet sum = a + b;\nprint(\"Sum: \" + sum);\nprint(\"Product: \" + (a * b));".to_string(),
},
ExampleScript {
name: "Loop Example".to_string(),
filename: "loops.rhai".to_string(),
description: "Shows how to use loops in Rhai".to_string(),
content: "for i in range(1, 6) {\n print(\"Count: \" + i);\n}\n\nlet arr = [\"apple\", \"banana\", \"cherry\"];\nfor fruit in arr {\n print(\"Fruit: \" + fruit);\n}".to_string(),
},
ExampleScript {
name: "Function Definition".to_string(),
filename: "functions.rhai".to_string(),
description: "Demonstrates function definitions and calls".to_string(),
content: "fn greet(name) {\n return \"Hello, \" + name + \"!\";\n}\n\nfn calculate(x, y) {\n return x * y + 10;\n}\n\nprint(greet(\"Actor\"));\nprint(\"Result: \" + calculate(5, 3));".to_string(),
},
]
} else {
vec![
ExampleScript {
name: "No Examples".to_string(),
filename: "".to_string(),
description: "No example directory specified".to_string(),
content: "// No example directory was provided\nprint(\"Please specify --example-dir to load example scripts\");".to_string(),
},
]
};
Self {
examples,
selected_example: None,
script_output: None,
is_running: false,
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
ExamplesMsg::SelectExample(name) => {
self.selected_example = Some(name);
self.script_output = None;
true
}
ExamplesMsg::RunExample => {
if !self.is_running {
if let Some(selected) = &self.selected_example {
if let Some(example) = self.examples.iter().find(|e| &e.name == selected) {
self.is_running = true;
self.script_output = None;
let script_content = example.content.clone();
let script_name = example.name.clone();
let link = ctx.link().clone();
spawn_local(async move {
// Simulate script execution
gloo::timers::future::TimeoutFuture::new(1500).await;
let output = format!(
"Example '{}' executed successfully!\n\nScript Content:\n{}\n\nExecution Output:\n- Script dispatched to Redis\n- Actor processed the job\n- Example completed successfully\n\nExecution time: 1.2s",
script_name, script_content
);
link.send_message(ExamplesMsg::ScriptComplete(output));
});
}
}
}
true
}
ExamplesMsg::ScriptComplete(output) => {
self.script_output = Some(output);
self.is_running = false;
true
}
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
html! {
<div class="container-fluid">
<div class="row">
<div class="col-12">
<h1>{"Example Scripts"}</h1>
<p class="lead">{"Run example scripts to test the actor functionality"}</p>
</div>
</div>
<div class="row">
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">{"Available Examples"}</h5>
</div>
<div class="list-group list-group-flush">
{for self.examples.iter().map(|example| self.render_example_item(ctx, example))}
</div>
</div>
</div>
<div class="col-md-8">
{self.render_example_content(ctx)}
</div>
</div>
</div>
}
}
}
impl ExamplesPage {
fn render_example_item(&self, ctx: &Context<Self>, example: &ExampleScript) -> Html {
let example_name = example.name.clone();
let on_select = ctx.link().callback(move |_| ExamplesMsg::SelectExample(example_name.clone()));
let is_selected = self.selected_example.as_ref() == Some(&example.name);
html! {
<button
class={if is_selected { "list-group-item list-group-item-action active" } else { "list-group-item list-group-item-action" }}
onclick={on_select}
>
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">{&example.name}</h6>
{if !example.filename.is_empty() {
html! { <small><code>{&example.filename}</code></small> }
} else {
html! {}
}}
</div>
<p class="mb-1 small">{&example.description}</p>
</button>
}
}
fn render_example_content(&self, ctx: &Context<Self>) -> Html {
if let Some(selected_name) = &self.selected_example {
if let Some(example) = self.examples.iter().find(|e| &e.name == selected_name) {
let on_run = ctx.link().callback(|_| ExamplesMsg::RunExample);
html! {
<ScriptExecutionPanel
script_content={example.content.clone()}
script_filename={example.filename.clone()}
output_content={self.script_output.clone()}
on_run={on_run}
is_running={self.is_running}
/>
}
} else {
html! {
<div class="card">
<div class="card-body text-center text-muted">
<p>{"Example not found"}</p>
</div>
</div>
}
}
} else {
html! {
<div class="card">
<div class="card-body text-center text-muted">
<i class="bi bi-file-code" style="font-size: 3rem;"></i>
<p class="mt-2">{"Select an example script to view and run"}</p>
</div>
</div>
}
}
}
}

View File

@ -1,181 +0,0 @@
use yew::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::app::AppProps;
pub struct InspectorPage {
script_content: String,
job_params: String,
script_output: Option<String>,
is_running: bool,
}
pub enum InspectorMsg {
UpdateScript(String),
UpdateParams(String),
RunScript,
ScriptComplete(String),
}
impl Component for InspectorPage {
type Message = InspectorMsg;
type Properties = AppProps;
fn create(_ctx: &Context<Self>) -> Self {
Self {
script_content: "// Enter your Rhai script here\nprint(\"Hello from actor!\");".to_string(),
job_params: "{}".to_string(),
script_output: None,
is_running: false,
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
InspectorMsg::UpdateScript(content) => {
self.script_content = content;
true
}
InspectorMsg::UpdateParams(params) => {
self.job_params = params;
true
}
InspectorMsg::RunScript => {
if !self.is_running {
self.is_running = true;
self.script_output = None;
// Simulate job dispatch to Redis and execution
let script = self.script_content.clone();
let params = self.job_params.clone();
let link = ctx.link().clone();
spawn_local(async move {
// Simulate async job execution
gloo::timers::future::TimeoutFuture::new(2000).await;
let output = format!(
"Job dispatched to Redis successfully!\n\nScript:\n{}\n\nParameters:\n{}\n\nExecution Output:\n- Job queued in Redis\n- Actor picked up job\n- Script executed successfully\n- Result: Script completed\n\nExecution time: 1.85s",
script, params
);
link.send_message(InspectorMsg::ScriptComplete(output));
});
}
true
}
InspectorMsg::ScriptComplete(output) => {
self.script_output = Some(output);
self.is_running = false;
true
}
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let on_script_change = ctx.link().callback(|e: Event| {
let input: web_sys::HtmlTextAreaElement = e.target_unchecked_into();
InspectorMsg::UpdateScript(input.value())
});
let on_params_change = ctx.link().callback(|e: Event| {
let input: web_sys::HtmlTextAreaElement = e.target_unchecked_into();
InspectorMsg::UpdateParams(input.value())
});
let on_run = ctx.link().callback(|_| InspectorMsg::RunScript);
html! {
<div class="container-fluid">
<div class="row">
<div class="col-12">
<h1>{"Script Inspector"}</h1>
<p class="lead">{"Dispatch jobs directly to the actor via Redis"}</p>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">{"Script Editor"}</h5>
<button
class="btn btn-primary"
onclick={on_run}
disabled={self.is_running}
>
{if self.is_running {
html! {
<>
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
{"Running..."}
</>
}
} else {
html! { "Run Script" }
}}
</button>
</div>
<div class="card-body">
<div class="mb-3">
<label for="script-content" class="form-label">{"Script Content"}</label>
<textarea
id="script-content"
class="form-control font-monospace"
rows="10"
value={self.script_content.clone()}
onchange={on_script_change}
placeholder="Enter your Rhai script here..."
/>
</div>
<div class="mb-3">
<label for="job-params" class="form-label">{"Job Parameters (JSON)"}</label>
<textarea
id="job-params"
class="form-control font-monospace"
rows="4"
value={self.job_params.clone()}
onchange={on_params_change}
placeholder="Enter job parameters as JSON..."
/>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0">{"Execution Output"}</h5>
</div>
<div class="card-body">
{if let Some(output) = &self.script_output {
html! {
<pre class="bg-dark text-light p-3 rounded" style="height: 400px; overflow-y: auto;">
{output}
</pre>
}
} else if self.is_running {
html! {
<div class="text-center p-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">{"Loading..."}</span>
</div>
<p class="mt-2">{"Executing script..."}</p>
</div>
}
} else {
html! {
<div class="text-muted text-center p-5">
<i class="bi bi-play-circle" style="font-size: 3rem;"></i>
<p class="mt-2">{"Click 'Run Script' to execute"}</p>
</div>
}
}}
</div>
</div>
</div>
</div>
</div>
}
}
}

View File

@ -1,234 +0,0 @@
use yew::prelude::*;
use crate::app::AppProps;
// use crate::redis_client::RedisClient; // Temporarily disabled
#[derive(Clone, PartialEq)]
pub struct Job {
pub id: String,
pub status: JobStatus,
pub script: String,
pub created_at: String,
pub completed_at: Option<String>,
}
#[derive(Clone, PartialEq)]
pub enum JobStatus {
Pending,
Running,
Completed,
Failed,
}
impl JobStatus {
fn to_badge_class(&self) -> &'static str {
match self {
JobStatus::Pending => "badge bg-warning",
JobStatus::Running => "badge bg-primary",
JobStatus::Completed => "badge bg-success",
JobStatus::Failed => "badge bg-danger",
}
}
fn to_string(&self) -> &'static str {
match self {
JobStatus::Pending => "Pending",
JobStatus::Running => "Running",
JobStatus::Completed => "Completed",
JobStatus::Failed => "Failed",
}
}
}
pub struct JobsPage {
jobs: Vec<Job>,
selected_job: Option<String>,
}
pub enum JobsMsg {
SelectJob(String),
RefreshJobs,
}
impl Component for JobsPage {
type Message = JobsMsg;
type Properties = AppProps;
fn create(_ctx: &Context<Self>) -> Self {
// Mock jobs data
let jobs = vec![
Job {
id: "job_001".to_string(),
status: JobStatus::Completed,
script: "print(\"Hello World\");".to_string(),
created_at: "2024-01-15 10:30:00".to_string(),
completed_at: Some("2024-01-15 10:30:02".to_string()),
},
Job {
id: "job_002".to_string(),
status: JobStatus::Running,
script: "let x = 42; print(x);".to_string(),
created_at: "2024-01-15 10:35:00".to_string(),
completed_at: None,
},
Job {
id: "job_003".to_string(),
status: JobStatus::Pending,
script: "for i in range(0, 10) { print(i); }".to_string(),
created_at: "2024-01-15 10:40:00".to_string(),
completed_at: None,
},
];
Self {
jobs,
selected_job: None,
}
}
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
JobsMsg::SelectJob(job_id) => {
self.selected_job = Some(job_id);
true
}
JobsMsg::RefreshJobs => {
// TODO: Refresh jobs from Redis
true
}
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let on_refresh = ctx.link().callback(|_| JobsMsg::RefreshJobs);
html! {
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-3">
<h1>{"Job Monitor"}</h1>
<button class="btn btn-outline-primary" onclick={on_refresh}>
<i class="bi bi-arrow-clockwise me-2"></i>
{"Refresh"}
</button>
</div>
</div>
</div>
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h5 class="mb-0">{"Job Queue"}</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-dark">
<tr>
<th>{"Job ID"}</th>
<th>{"Status"}</th>
<th>{"Created"}</th>
<th>{"Completed"}</th>
<th>{"Actions"}</th>
</tr>
</thead>
<tbody>
{for self.jobs.iter().map(|job| self.render_job_row(ctx, job))}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">{"Job Details"}</h5>
</div>
<div class="card-body">
{self.render_job_details()}
</div>
</div>
</div>
</div>
</div>
}
}
}
impl JobsPage {
fn render_job_row(&self, ctx: &Context<Self>, job: &Job) -> Html {
let job_id = job.id.clone();
let on_select = ctx.link().callback(move |_| JobsMsg::SelectJob(job_id.clone()));
html! {
<tr class={if self.selected_job.as_ref() == Some(&job.id) { "table-active" } else { "" }}>
<td>
<code>{&job.id}</code>
</td>
<td>
<span class={job.status.to_badge_class()}>
{job.status.to_string()}
</span>
</td>
<td>{&job.created_at}</td>
<td>
{job.completed_at.as_ref().unwrap_or(&"-".to_string())}
</td>
<td>
<button class="btn btn-sm btn-outline-primary" onclick={on_select}>
{"View"}
</button>
</td>
</tr>
}
}
fn render_job_details(&self) -> Html {
if let Some(selected_id) = &self.selected_job {
if let Some(job) = self.jobs.iter().find(|j| &j.id == selected_id) {
html! {
<div>
<h6>{"Job ID"}</h6>
<p><code>{&job.id}</code></p>
<h6>{"Status"}</h6>
<p>
<span class={job.status.to_badge_class()}>
{job.status.to_string()}
</span>
</p>
<h6>{"Script Content"}</h6>
<pre class="bg-light p-2 rounded small">{&job.script}</pre>
<h6>{"Created At"}</h6>
<p>{&job.created_at}</p>
{if let Some(completed) = &job.completed_at {
html! {
<>
<h6>{"Completed At"}</h6>
<p>{completed}</p>
</>
}
} else {
html! {}
}}
</div>
}
} else {
html! { <p class="text-muted">{"Job not found"}</p> }
}
} else {
html! {
<div class="text-center text-muted">
<i class="bi bi-info-circle" style="font-size: 2rem;"></i>
<p class="mt-2">{"Select a job to view details"}</p>
</div>
}
}
}
}

View File

@ -1,9 +0,0 @@
pub mod dashboard;
pub mod inspector;
pub mod jobs;
pub mod examples;
pub use dashboard::DashboardPage;
pub use inspector::InspectorPage;
pub use jobs::JobsPage;
pub use examples::ExamplesPage;

View File

@ -1,349 +0,0 @@
//! Redis client for Hero Actor UI
//!
//! This module provides Redis connectivity for job management following the Hero protocol.
//!
//! **Implementation**: Uses Webdis (https://github.com/nicolasff/webdis) as an HTTP interface
//! to Redis, allowing WASM applications to directly interact with Redis through HTTP requests
//! while maintaining the Hero protocol and core/job model.
use serde_json;
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
use web_sys::{Request, RequestInit, RequestMode, Response};
use std::collections::HashMap;
// Simplified Job structures for WASM compatibility
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
pub struct Job {
pub id: String,
pub caller_id: String,
pub actor_id: String,
pub context_id: String,
pub script: String,
pub timeout: u64,
pub retries: u32,
pub status: JobStatus,
pub output: Option<String>,
pub error: Option<String>,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
pub enum JobStatus {
Pending,
Started,
Finished,
Failed,
Cancelled,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
pub enum ScriptType {
RhaiScript,
JavaScript,
Python,
}
#[derive(Debug)]
pub enum JobError {
RedisError(String),
SerializationError(String),
NetworkError(String),
}
/// Redis client trait for job management operations
///
/// This trait defines the interface for Redis operations following the Hero protocol.
pub trait RedisJobClient {
type Error;
/// Create and dispatch a new job to the specified actor
async fn dispatch_job(
&self,
actor_id: &str,
script: &str,
script_type: ScriptType,
context_id: Option<String>,
timeout: Option<u64>,
) -> Result<String, Self::Error>;
/// Get the status of a job by ID
async fn get_job_status(&self, job_id: &str) -> Result<JobStatus, Self::Error>;
/// Get full job details by ID
async fn get_job(&self, job_id: &str) -> Result<Job, Self::Error>;
/// List all job IDs
async fn list_jobs(&self) -> Result<Vec<String>, Self::Error>;
/// List jobs for a specific actor
async fn list_actor_jobs(&self, actor_id: &str) -> Result<Vec<String>, Self::Error>;
/// Stop a running job
async fn stop_job(&self, job_id: &str, actor_id: &str) -> Result<(), Self::Error>;
/// Delete a job from Redis
async fn delete_job(&self, job_id: &str) -> Result<(), Self::Error>;
}
#[cfg(feature = "wasm")]
/// WASM-compatible Redis client that uses Webdis HTTP interface to Redis
///
/// Webdis provides a RESTful HTTP interface to Redis commands, allowing WASM
/// applications to interact directly with Redis using standard HTTP requests.
pub struct WebdisRedisClient {
webdis_url: String,
}
#[cfg(feature = "wasm")]
impl WebdisRedisClient {
pub fn new(webdis_url: String) -> Self {
Self { webdis_url }
}
/// Execute a Redis command through Webdis HTTP interface
async fn redis_command(&self, command: &str) -> Result<serde_json::Value, String> {
let opts = RequestInit::new();
opts.set_method("GET");
opts.set_mode(RequestMode::Cors);
// Webdis URL format: http://webdis-host:port/COMMAND/key/value
let url = format!("{}/{}", self.webdis_url, command);
let request = Request::new_with_str_and_init(&url, &opts)
.map_err(|_| "Failed to create request")?;
let window = web_sys::window().ok_or("No window object")?;
let resp_value = JsFuture::from(window.fetch_with_request(&request))
.await
.map_err(|_| "Failed to fetch")?;
let resp: Response = resp_value.dyn_into().map_err(|_| "Failed to cast response")?;
if resp.ok() {
let text = JsFuture::from(resp.text().map_err(|_| "Failed to get response text")?)
.await
.map_err(|_| "Failed to read response text")?;
let response_text = text.as_string().unwrap_or_default();
// Parse Webdis JSON response
serde_json::from_str(&response_text)
.map_err(|e| format!("Failed to parse Webdis response: {}", e))
} else {
Err(format!("HTTP error: {}", resp.status()))
}
}
/// Execute a Redis command with POST data through Webdis
async fn redis_command_post(&self, command: &str, data: &str) -> Result<serde_json::Value, String> {
let opts = RequestInit::new();
opts.set_method("POST");
opts.set_mode(RequestMode::Cors);
opts.set_body(&JsValue::from_str(data));
let url = format!("{}/{}", self.webdis_url, command);
let request = Request::new_with_str_and_init(&url, &opts)
.map_err(|_| "Failed to create request")?;
request
.headers()
.set("Content-Type", "application/json")
.map_err(|_| "Failed to set headers")?;
let window = web_sys::window().ok_or("No window object")?;
let resp_value = JsFuture::from(window.fetch_with_request(&request))
.await
.map_err(|_| "Failed to fetch")?;
let resp: Response = resp_value.dyn_into().map_err(|_| "Failed to cast response")?;
if resp.ok() {
let text = JsFuture::from(resp.text().map_err(|_| "Failed to get response text")?)
.await
.map_err(|_| "Failed to read response text")?;
let response_text = text.as_string().unwrap_or_default();
serde_json::from_str(&response_text)
.map_err(|e| format!("Failed to parse Webdis response: {}", e))
} else {
Err(format!("HTTP error: {}", resp.status()))
}
}
}
#[cfg(feature = "wasm")]
impl RedisJobClient for WebdisRedisClient {
type Error = String;
async fn dispatch_job(
&self,
actor_id: &str,
script: &str,
script_type: ScriptType,
context_id: Option<String>,
timeout: Option<u64>,
) -> Result<String, Self::Error> {
let job_id = format!("job_{}", js_sys::Date::now() as u64);
let job = Job {
id: job_id.clone(),
caller_id: "ui".to_string(),
actor_id: actor_id.to_string(),
context_id: context_id.unwrap_or_else(|| "default".to_string()),
script: script.to_string(),
timeout: timeout.unwrap_or(300),
retries: 0,
status: JobStatus::Pending,
output: None,
error: None,
};
self.store_and_queue_job(&job).await
.map_err(|e| format!("Failed to dispatch job: {:?}", e))?;
Ok(job_id)
}
/// Store job in Redis and add to work queue
async fn store_and_queue_job(&self, job: &Job) -> Result<(), JobError> {
// Store job in Redis hash using HSET
let job_key = format!("hero:job:{}", job.id);
// Use Webdis HSET command to store job data
let hset_command = format!("HSET/{}/id/{}/caller_id/{}/actor_id/{}/context_id/{}/script/{}/timeout/{}/retries/{}/status/{}",
job_key, job.id, job.caller_id, job.actor_id, job.context_id,
urlencoding::encode(&job.script), job.timeout, job.retries, job.status.to_string());
self.redis_command(&hset_command).await
.map_err(|e| JobError::RedisError(e))?;
// Add job to work queue using LPUSH
let queue_key = format!("hero:work_queue:{}", job.actor_id);
let lpush_command = format!("LPUSH/{}/{}", queue_key, job.id);
self.redis_command(&lpush_command).await
.map_err(|e| JobError::RedisError(e))?;
Ok(())
}
async fn get_job_status(&self, job_id: &str) -> Result<JobStatus, Self::Error> {
let job_key = format!("hero:job:{}", job_id);
let hget_command = format!("HGET/{}/status", job_key);
let response = self.redis_command(&hget_command).await
.map_err(|e| format!("Redis error: {}", e))?;
// Webdis returns {"HGET": "status_value"} or {"HGET": null}
let status_value = response.get("HGET")
.and_then(|v| v.as_str())
.ok_or_else(|| "Job not found or no status".to_string())?;
JobStatus::from_str(status_value)
.map_err(|e| format!("Invalid status: {}", e))
}
async fn get_job(&self, job_id: &str) -> Result<Option<Job>, Self::Error> {
let job_key = format!("hero:job:{}", job_id);
let hgetall_command = format!("HGETALL/{}", job_key);
let response = self.redis_command(&hgetall_command).await
.map_err(|e| format!("Redis error: {}", e))?;
// Webdis returns {"HGETALL": ["field1", "value1", "field2", "value2", ...]} or {"HGETALL": []}
let fields_array = response.get("HGETALL")
.and_then(|v| v.as_array())
.ok_or_else(|| "Invalid HGETALL response".to_string())?;
if fields_array.is_empty() {
return Ok(None);
}
// Convert array of [field, value, field, value, ...] to Job struct
let mut job_data = std::collections::HashMap::new();
for chunk in fields_array.chunks(2) {
if let (Some(field), Some(value)) = (chunk[0].as_str(), chunk[1].as_str()) {
job_data.insert(field.to_string(), value.to_string());
}
}
// Reconstruct Job from hash fields
let job = Job {
id: job_data.get("id").cloned().unwrap_or_default(),
caller_id: job_data.get("caller_id").cloned().unwrap_or_default(),
actor_id: job_data.get("actor_id").cloned().unwrap_or_default(),
context_id: job_data.get("context_id").cloned().unwrap_or_default(),
script: job_data.get("script").cloned().unwrap_or_default(),
timeout: job_data.get("timeout").and_then(|s| s.parse().ok()).unwrap_or(30),
retries: job_data.get("retries").and_then(|s| s.parse().ok()).unwrap_or(0),
status: job_data.get("status")
.and_then(|s| JobStatus::from_str(s).ok())
.unwrap_or(JobStatus::Pending),
output: job_data.get("output").cloned(),
error: job_data.get("error").cloned(),
};
Ok(Some(job))
}
async fn list_jobs(&self, actor_id: &str) -> Result<Vec<String>, Self::Error> {
// Use Redis KEYS command to find all jobs for this actor
let keys_command = format!("KEYS/hero:job:*");
let response = self.redis_command(&keys_command).await
.map_err(|e| format!("Redis error: {}", e))?;
// Webdis returns {"KEYS": ["key1", "key2", ...]}
let keys_array = response.get("KEYS")
.and_then(|v| v.as_array())
.unwrap_or(&vec![]);
let mut job_ids = Vec::new();
// For each job key, check if it belongs to this actor
for key_value in keys_array {
if let Some(key) = key_value.as_str() {
if let Some(job_id) = key.strip_prefix("hero:job:") {
// Check if this job belongs to the specified actor
let hget_command = format!("HGET/{}/actor_id", key);
if let Ok(actor_response) = self.redis_command(&hget_command).await {
if let Some(job_actor_id) = actor_response.get("HGET").and_then(|v| v.as_str()) {
if job_actor_id == actor_id {
job_ids.push(job_id.to_string());
}
}
}
}
}
}
Ok(job_ids)
}
async fn list_actor_jobs(&self, actor_id: &str) -> Result<Vec<String>, Self::Error> {
self.list_jobs(actor_id).await
}
async fn stop_job(&self, job_id: &str, actor_id: &str) -> Result<(), Self::Error> {
// Add job to stop queue using LPUSH
let stop_queue_key = "hero:stop_queue";
let lpush_command = format!("LPUSH/{}/{}", stop_queue_key, job_id);
self.redis_command(&lpush_command).await
.map_err(|e| format!("Redis error: {}", e))?;
Ok(())
}
async fn delete_job(&self, job_id: &str) -> Result<(), Self::Error> {
// Delete job hash using DEL
let job_key = format!("hero:job:{}", job_id);
let del_command = format!("DEL/{}", job_key);
self.redis_command(&del_command).await
.map_err(|e| format!("Redis error: {}", e))?;
Ok(())
}
}
/// Type alias for the Redis client used in WASM context
#[cfg(feature = "wasm")]
pub type RedisClient = WebdisRedisClient;

View File

@ -1,25 +0,0 @@
use yew::prelude::*;
use yew_router::prelude::*;
use crate::pages::{DashboardPage, InspectorPage, JobsPage, ExamplesPage};
use crate::app::AppProps;
#[derive(Clone, Routable, PartialEq)]
pub enum Route {
#[at("/")]
Dashboard,
#[at("/inspector")]
Inspector,
#[at("/jobs")]
Jobs,
#[at("/examples")]
Examples,
}
pub fn switch(route: Route, props: AppProps) -> Html {
match route {
Route::Dashboard => html! { <DashboardPage ..props /> },
Route::Inspector => html! { <InspectorPage ..props /> },
Route::Jobs => html! { <JobsPage ..props /> },
Route::Examples => html! { <ExamplesPage ..props /> },
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,146 @@
//! Simplified main function for Baobab Actor TUI
//!
//! This binary provides a clean entry point for the actor monitoring and job dispatch interface.
use anyhow::{Result, Context};
use baobab_actor::terminal_ui::{App, setup_and_run_tui};
use clap::Parser;
use log::{info, warn, error};
use std::path::PathBuf;
use std::process::{Child, Command};
use tokio::signal;
#[derive(Parser)]
#[command(name = "baobab-actor-tui")]
#[command(about = "Terminal UI for Baobab Actor - Monitor and dispatch jobs to a single actor")]
struct Args {
/// Actor ID to monitor
#[arg(short, long)]
id: String,
/// Path to actor binary
#[arg(short, long)]
path: PathBuf,
/// Directory containing example .rhai scripts
#[arg(short, long)]
example_dir: Option<PathBuf>,
/// Redis URL for job queue
#[arg(short, long, default_value = "redis://localhost:6379")]
redis_url: String,
/// Enable verbose logging
#[arg(short, long)]
verbose: bool,
}
/// Initialize logging based on verbosity level
fn init_logging(verbose: bool) {
if verbose {
env_logger::Builder::from_default_env()
.filter_level(log::LevelFilter::Debug)
.init();
} else {
env_logger::Builder::from_default_env()
.filter_level(log::LevelFilter::Info)
.init();
}
}
/// Create and configure the TUI application
fn create_app(args: &Args) -> Result<App> {
App::new(
args.id.clone(),
args.path.clone(),
args.redis_url.clone(),
args.example_dir.clone(),
)
}
/// Spawn the actor binary as a background process
fn spawn_actor_process(args: &Args) -> Result<Child> {
info!("🎬 Spawning actor process: {}", args.path.display());
let mut cmd = Command::new(&args.path);
// Redirect stdout and stderr to null to prevent logs from interfering with TUI
cmd.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
// Spawn the process
let child = cmd
.spawn()
.with_context(|| format!("Failed to spawn actor process: {}", args.path.display()))?;
info!("✅ Actor process spawned with PID: {}", child.id());
Ok(child)
}
/// Cleanup function to terminate actor process
fn cleanup_actor_process(mut actor_process: Child) {
info!("🧹 Cleaning up actor process...");
match actor_process.try_wait() {
Ok(Some(status)) => {
info!("Actor process already exited with status: {}", status);
}
Ok(None) => {
info!("Terminating actor process...");
if let Err(e) = actor_process.kill() {
error!("Failed to kill actor process: {}", e);
} else {
match actor_process.wait() {
Ok(status) => info!("Actor process terminated with status: {}", status),
Err(e) => error!("Failed to wait for actor process: {}", e),
}
}
}
Err(e) => {
error!("Failed to check actor process status: {}", e);
}
}
}
#[tokio::main]
async fn main() -> Result<()> {
let args = Args::parse();
// Initialize logging
init_logging(args.verbose);
info!("🚀 Starting Baobab Actor TUI...");
info!("Actor ID: {}", args.id);
info!("Actor Path: {}", args.path.display());
info!("Redis URL: {}", args.redis_url);
if let Some(ref example_dir) = args.example_dir {
info!("Example Directory: {}", example_dir.display());
}
// Spawn the actor process first
let actor_process = spawn_actor_process(&args)?;
// Give the actor a moment to start up
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
// Create app and run TUI
let app = create_app(&args)?;
// Set up signal handling for graceful shutdown
let result = tokio::select! {
tui_result = setup_and_run_tui(app) => {
info!("TUI exited");
tui_result
}
_ = signal::ctrl_c() => {
info!("Received Ctrl+C, shutting down...");
Ok(())
}
};
// Clean up the actor process
cleanup_actor_process(actor_process);
result
}

View File

@ -1,62 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Baobab Actor UI</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
<style>
body {
background-color: #1a1a1a;
color: #ffffff;
}
.navbar-dark {
background-color: #2d2d2d !important;
}
.card {
background-color: #2d2d2d;
border-color: #404040;
}
.card-header {
background-color: #404040;
border-color: #505050;
}
.list-group-item {
background-color: #2d2d2d;
border-color: #404040;
color: #ffffff;
}
.list-group-item.active {
background-color: #0d6efd;
border-color: #0d6efd;
}
.btn-outline-primary {
border-color: #0d6efd;
color: #0d6efd;
}
.btn-outline-primary:hover {
background-color: #0d6efd;
border-color: #0d6efd;
}
.table-dark {
--bs-table-bg: #2d2d2d;
}
.table-hover tbody tr:hover {
background-color: #404040;
}
</style>
</head>
<body>
<div id="app">
<div class="d-flex justify-content-center align-items-center" style="height: 100vh;">
<div class="text-center">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-2">Loading Baobab Actor UI...</p>
</div>
</div>
</div>
</body>
</html>

View File

@ -30,7 +30,7 @@
use hero_job::Job;
use log::{debug, error, info};
use redis::AsyncCommands;
use rhai::Engine;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;

0
core/actor/src/config.rs Normal file
View File

View File

@ -8,6 +8,9 @@ use tokio::task::JoinHandle;
/// Actor trait abstraction for unified actor interface
pub mod actor_trait;
/// Terminal UI module for actor monitoring and job dispatch
pub mod terminal_ui;
const NAMESPACE_PREFIX: &str = "hero:job:";
const BLPOP_TIMEOUT_SECONDS: usize = 5;

File diff suppressed because it is too large Load Diff