move rhailib to herolib

This commit is contained in:
Timur Gordon
2025-08-21 14:32:24 +02:00
parent aab2b6f128
commit aa0248ef17
121 changed files with 16412 additions and 0 deletions

View File

@@ -0,0 +1,388 @@
use gloo_net::http::Request;
use gloo_timers::callback::Interval;
use serde::{Deserialize, Serialize};
use wasm_bindgen_futures::spawn_local;
use web_sys::HtmlInputElement;
use yew::prelude::*;
use yew::{html, Component, Context, Html, TargetCast};
// --- Data Structures (placeholders, to be refined based on backend API) ---
#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
pub struct QueueStats {
pub current_size: u32,
pub color_code: String, // e.g., "green", "yellow", "red"
}
#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
pub struct TaskSummary {
pub hash: String,
pub created_at: i64,
pub status: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct TaskDetails {
pub hash: String,
pub created_at: i64,
pub status: String,
pub script_content: String,
pub result: Option<String>,
pub error: Option<String>,
}
// Combined structure for initial fetch
#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
pub struct WorkerDataResponse {
pub queue_stats: Option<QueueStats>,
pub tasks: Vec<TaskSummary>,
}
// --- Component ---
pub enum Msg {
UpdateWorkerName(String),
FetchData,
SetWorkerData(Result<WorkerDataResponse, String>),
SetQueueStats(Result<QueueStats, String>),
ViewTaskDetails(String), // Task hash
SetTaskDetails(Result<TaskDetails, String>),
ClearTaskDetails,
IntervalTick, // For interval timer, to trigger queue stats fetch
}
pub struct App {
worker_name_input: String,
worker_name_to_monitor: Option<String>,
tasks_list: Vec<TaskSummary>,
current_queue_stats: Option<QueueStats>,
selected_task_details: Option<TaskDetails>,
error_message: Option<String>,
is_loading_initial_data: bool,
is_loading_task_details: bool,
queue_poll_timer: Option<Interval>,
}
impl Component for App {
type Message = Msg;
type Properties = ();
fn create(_ctx: &Context<Self>) -> Self {
Self {
worker_name_input: "".to_string(),
worker_name_to_monitor: None,
tasks_list: Vec::new(),
current_queue_stats: None,
selected_task_details: None,
error_message: None,
is_loading_initial_data: false,
is_loading_task_details: false,
queue_poll_timer: None,
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
Msg::UpdateWorkerName(name) => {
self.worker_name_input = name;
true
}
Msg::FetchData => {
if self.worker_name_input.trim().is_empty() {
self.error_message = Some("Please enter a worker name.".to_string());
return true;
}
let worker_name = self.worker_name_input.trim().to_string();
self.worker_name_to_monitor = Some(worker_name.clone());
self.error_message = None;
self.tasks_list.clear();
self.current_queue_stats = None;
self.selected_task_details = None;
self.is_loading_initial_data = true;
let link = ctx.link().clone();
let tasks_url = format!("/api/worker/{}/tasks_and_stats", worker_name);
spawn_local(async move {
match Request::get(&tasks_url).send().await {
Ok(response) => {
if response.ok() {
match response.json::<WorkerDataResponse>().await {
Ok(data) => link.send_message(Msg::SetWorkerData(Ok(data))),
Err(e) => link.send_message(Msg::SetWorkerData(Err(format!(
"Failed to parse worker data: {}",
e
)))),
}
} else {
link.send_message(Msg::SetWorkerData(Err(format!(
"API error: {} {}",
response.status(),
response.status_text()
))));
}
}
Err(e) => link.send_message(Msg::SetWorkerData(Err(format!(
"Network error fetching worker data: {}",
e
)))),
}
});
// Set up polling for queue stats
let link_for_timer = ctx.link().clone();
let timer = Interval::new(5000, move || {
// Poll every 5 seconds
link_for_timer.send_message(Msg::IntervalTick);
});
if let Some(old_timer) = self.queue_poll_timer.take() {
old_timer.cancel(); // Cancel previous timer if any
}
self.queue_poll_timer = Some(timer);
true
}
Msg::IntervalTick => {
if let Some(worker_name) = &self.worker_name_to_monitor {
let queue_stats_url = format!("/api/worker/{}/queue_stats", worker_name);
let link = ctx.link().clone();
spawn_local(async move {
match Request::get(&queue_stats_url).send().await {
Ok(response) => {
if response.ok() {
match response.json::<QueueStats>().await {
Ok(stats) => {
link.send_message(Msg::SetQueueStats(Ok(stats)))
}
Err(e) => link.send_message(Msg::SetQueueStats(Err(
format!("Failed to parse queue stats: {}", e),
))),
}
} else {
link.send_message(Msg::SetQueueStats(Err(format!(
"API error (queue_stats): {} {}",
response.status(),
response.status_text()
))));
}
}
Err(e) => link.send_message(Msg::SetQueueStats(Err(format!(
"Network error fetching queue stats: {}",
e
)))),
}
});
}
false // No direct re-render, SetQueueStats will trigger it
}
Msg::SetWorkerData(Ok(data)) => {
self.tasks_list = data.tasks;
self.current_queue_stats = data.queue_stats;
self.error_message = None;
self.is_loading_initial_data = false;
true
}
Msg::SetWorkerData(Err(err_msg)) => {
self.error_message = Some(err_msg);
self.is_loading_initial_data = false;
if let Some(timer) = self.queue_poll_timer.take() {
timer.cancel();
}
true
}
Msg::SetQueueStats(Ok(stats)) => {
self.current_queue_stats = Some(stats);
// Don't clear main error message here, as this is a background update
true
}
Msg::SetQueueStats(Err(err_msg)) => {
log::error!("Failed to update queue stats: {}", err_msg);
// Optionally show a non-blocking error for queue stats
self.current_queue_stats = None;
true
}
Msg::ViewTaskDetails(hash) => {
self.is_loading_task_details = true;
self.selected_task_details = None; // Clear previous details
let task_details_url = format!("/api/task/{}", hash);
let link = ctx.link().clone();
spawn_local(async move {
match Request::get(&task_details_url).send().await {
Ok(response) => {
if response.ok() {
match response.json::<TaskDetails>().await {
Ok(details) => {
link.send_message(Msg::SetTaskDetails(Ok(details)))
}
Err(e) => link.send_message(Msg::SetTaskDetails(Err(format!(
"Failed to parse task details: {}",
e
)))),
}
} else {
link.send_message(Msg::SetTaskDetails(Err(format!(
"API error (task_details): {} {}",
response.status(),
response.status_text()
))));
}
}
Err(e) => link.send_message(Msg::SetTaskDetails(Err(format!(
"Network error fetching task details: {}",
e
)))),
}
});
true
}
Msg::SetTaskDetails(Ok(details)) => {
self.selected_task_details = Some(details);
self.error_message = None; // Clear general error if task details load
self.is_loading_task_details = false;
true
}
Msg::SetTaskDetails(Err(err_msg)) => {
self.error_message = Some(format!("Error loading task details: {}", err_msg));
self.selected_task_details = None;
self.is_loading_task_details = false;
true
}
Msg::ClearTaskDetails => {
self.selected_task_details = None;
true
}
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let link = ctx.link();
let on_worker_name_input = link.callback(|e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
Msg::UpdateWorkerName(input.value())
});
html! {
<div class="container">
<h1>{ "Rhai Worker Monitor" }</h1>
<div class="input-group">
<input type="text"
placeholder="Enter Worker Name (e.g., worker_default)"
value={self.worker_name_input.clone()}
oninput={on_worker_name_input.clone()}
disabled={self.is_loading_initial_data}
onkeypress={link.callback(move |e: KeyboardEvent| {
if e.key() == "Enter" { Msg::FetchData } else { Msg::UpdateWorkerName(e.target_unchecked_into::<HtmlInputElement>().value()) }
})}
/>
<button onclick={link.callback(|_| Msg::FetchData)} disabled={self.is_loading_initial_data || self.worker_name_input.trim().is_empty()}>
{ if self.is_loading_initial_data { "Loading..." } else { "Load Worker Data" } }
</button>
</div>
if let Some(err) = &self.error_message {
<p class="error">{ err }</p>
}
if self.worker_name_to_monitor.is_some() && !self.is_loading_initial_data && self.error_message.is_none() {
<h2>{ format!("Monitoring: {}", self.worker_name_to_monitor.as_ref().unwrap()) }</h2>
<h3>{ "Queue Status" }</h3>
<div class="queue-visualization">
{
if let Some(stats) = &self.current_queue_stats {
// TODO: Implement actual color coding and bar visualization
html! { <p>{format!("Tasks in queue: {} ({})", stats.current_size, stats.color_code)}</p> }
} else {
html! { <p>{ "Loading queue stats..." }</p> }
}
}
</div>
<h3>{ "Tasks" }</h3>
{ self.view_tasks_table(ctx) }
{ self.view_selected_task_details(ctx) }
} else if self.is_loading_initial_data {
<p>{ "Loading worker data..." }</p>
}
</div>
}
}
}
impl App {
fn view_tasks_table(&self, ctx: &Context<Self>) -> Html {
if self.tasks_list.is_empty()
&& self.worker_name_to_monitor.is_some()
&& !self.is_loading_initial_data
{
return html! { <p>{ "No tasks found for this worker, or worker not found." }</p> };
}
if !self.tasks_list.is_empty() {
html! {
<table class="task-table">
<thead>
<tr>
<th>{ "Hash (click to view)" }</th>
<th>{ "Created At (UTC)" }</th>
<th>{ "Status" }</th>
</tr>
</thead>
<tbody>
{ for self.tasks_list.iter().map(|task| self.view_task_row(ctx, task)) }
</tbody>
</table>
}
} else {
html! {}
}
}
fn view_task_row(&self, ctx: &Context<Self>, task: &TaskSummary) -> Html {
let task_hash_clone = task.hash.clone();
let created_at_str = chrono::DateTime::from_timestamp(task.created_at, 0).map_or_else(
|| "Invalid date".to_string(),
|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string(),
);
html! {
<tr onclick={ctx.link().callback(move |_| Msg::ViewTaskDetails(task_hash_clone.clone()))}
style="cursor: pointer;">
<td>{ task.hash.chars().take(12).collect::<String>() }{ "..." }</td>
<td>{ created_at_str }</td>
<td>{ &task.status }</td>
</tr>
}
}
fn view_selected_task_details(&self, ctx: &Context<Self>) -> Html {
if self.is_loading_task_details {
return html! { <p>{ "Loading task details..." }</p> };
}
if let Some(details) = &self.selected_task_details {
let created_at_str = chrono::DateTime::from_timestamp(details.created_at, 0)
.map_or_else(
|| "Invalid date".to_string(),
|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string(),
);
html! {
<div class="task-details-modal">
<h4>{ format!("Task Details: {}", details.hash) }</h4>
<p><strong>{ "Created At: " }</strong>{ created_at_str }</p>
<p><strong>{ "Status: " }</strong>{ &details.status }</p>
<p><strong>{ "Script Content:" }</strong></p>
<pre>{ &details.script_content }</pre>
if let Some(result) = &details.result {
<p><strong>{ "Result:" }</strong></p>
<pre>{ result }</pre>
}
if let Some(error) = &details.error {
<p><strong>{ "Error:" }</strong></p>
<pre style="color: red;">{ error }</pre>
}
<button onclick={ctx.link().callback(|_| Msg::ClearTaskDetails)}>{ "Close Details" }</button>
</div>
}
} else {
html! {}
}
}
}

View File

@@ -0,0 +1,184 @@
// The 'app' module is shared between the server and the client.
mod app;
// --- SERVER-SIDE CODE --- //
#[cfg(feature = "server")]
mod server {
use axum::{
extract::{Path, State},
http::{Method, StatusCode},
routing::get,
Json, Router,
};
use deadpool_redis::{Config, Pool, Runtime};
use redis::{from_redis_value, AsyncCommands, FromRedisValue, Value};
use std::collections::HashMap;
use std::env;
use std::net::SocketAddr;
use tower_http::cors::{Any, CorsLayer};
use tower_http::services::ServeDir;
// Import the shared application state and data structures
use crate::app::{QueueStats, TaskDetails, TaskSummary, WorkerDataResponse};
const REDIS_TASK_DETAILS_PREFIX: &str = "rhai_task_details:";
const REDIS_QUEUE_PREFIX: &str = "rhai_tasks:";
// The main function to run the server
pub async fn run() {
let redis_url = env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1/".to_string());
let cfg = Config::from_url(redis_url);
let pool = cfg
.create_pool(Some(Runtime::Tokio1))
.expect("Failed to create Redis pool");
let cors = CorsLayer::new()
.allow_methods([Method::GET])
.allow_origin(Any);
let app = Router::new()
.route(
"/api/worker/:worker_name/tasks_and_stats",
get(get_worker_data),
)
.route("/api/worker/:worker_name/queue_stats", get(get_queue_stats))
.route("/api/task/:hash", get(get_task_details))
.nest_service("/", ServeDir::new("dist"))
.with_state(pool)
.layer(cors);
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
println!("Backend server listening on http://{}", addr);
println!("Serving static files from './dist' directory.");
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}
// --- API Handlers (Live Redis Data) ---
async fn get_worker_data(
State(pool): State<Pool>,
Path(worker_name): Path<String>,
) -> Result<Json<WorkerDataResponse>, (StatusCode, String)> {
let mut conn = pool.get().await.map_err(internal_error)?;
let queue_key = format!("{}{}", REDIS_QUEUE_PREFIX, worker_name);
let task_ids: Vec<String> = conn
.lrange(&queue_key, 0, -1)
.await
.map_err(internal_error)?;
let mut tasks = Vec::new();
for task_id in task_ids {
let task_key = format!("{}{}", REDIS_TASK_DETAILS_PREFIX, task_id);
let task_details: redis::Value =
conn.hgetall(&task_key).await.map_err(internal_error)?;
if let Ok(summary) = task_summary_from_redis_value(&task_details) {
tasks.push(summary);
}
}
let queue_stats = get_queue_stats_internal(&mut conn, &worker_name).await?;
Ok(Json(WorkerDataResponse {
tasks,
queue_stats: Some(queue_stats),
}))
}
async fn get_queue_stats(
State(pool): State<Pool>,
Path(worker_name): Path<String>,
) -> Result<Json<QueueStats>, (StatusCode, String)> {
let mut conn = pool.get().await.map_err(internal_error)?;
let stats = get_queue_stats_internal(&mut conn, &worker_name).await?;
Ok(Json(stats))
}
async fn get_task_details(
State(pool): State<Pool>,
Path(hash): Path<String>,
) -> Result<Json<TaskDetails>, (StatusCode, String)> {
let mut conn = pool.get().await.map_err(internal_error)?;
let task_key = format!("{}{}", REDIS_TASK_DETAILS_PREFIX, hash);
let task_details: redis::Value = conn.hgetall(&task_key).await.map_err(internal_error)?;
let details = task_details_from_redis_value(&task_details).map_err(internal_error)?;
Ok(Json(details))
}
// --- Internal Helper Functions ---
async fn get_queue_stats_internal(
conn: &mut deadpool_redis::Connection,
worker_name: &str,
) -> Result<QueueStats, (StatusCode, String)> {
let queue_key = format!("{}{}", REDIS_QUEUE_PREFIX, worker_name);
let size: u32 = conn.llen(&queue_key).await.map_err(internal_error)?;
let color_code = match size {
0..=10 => "green",
11..=50 => "yellow",
_ => "red",
}
.to_string();
Ok(QueueStats {
current_size: size,
color_code,
})
}
fn internal_error<E: std::error::Error>(err: E) -> (StatusCode, String) {
(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
}
fn task_summary_from_redis_value(v: &Value) -> redis::RedisResult<TaskSummary> {
let map: HashMap<String, String> = from_redis_value(v)?;
Ok(TaskSummary {
hash: map.get("hash").cloned().unwrap_or_default(),
created_at: map
.get("createdAt")
.and_then(|s| s.parse().ok())
.unwrap_or_default(),
status: map
.get("status")
.cloned()
.unwrap_or_else(|| "Unknown".to_string()),
})
}
fn task_details_from_redis_value(v: &Value) -> redis::RedisResult<TaskDetails> {
let map: HashMap<String, String> = from_redis_value(v)?;
Ok(TaskDetails {
hash: map.get("hash").cloned().unwrap_or_default(),
created_at: map
.get("createdAt")
.and_then(|s| s.parse().ok())
.unwrap_or_default(),
status: map
.get("status")
.cloned()
.unwrap_or_else(|| "Unknown".to_string()),
script_content: map.get("script").cloned().unwrap_or_default(),
result: map.get("output").cloned(),
error: map.get("error").cloned(),
})
}
}
// --- MAIN ENTRY POINTS --- //
// Main function for the server binary
#[cfg(feature = "server")]
#[tokio::main]
async fn main() {
server::run().await;
}
// Main function for the WASM client (compiles when 'server' feature is not enabled)
#[cfg(not(feature = "server"))]
fn main() {
wasm_logger::init(wasm_logger::Config::default());
log::info!("Rhai Worker UI starting...");
yew::Renderer::<app::App>::new().render();
}