389 lines
16 KiB
Rust
389 lines
16 KiB
Rust
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! {}
|
|
}
|
|
}
|
|
}
|