mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
Scheduler cleanup (#5571)
Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
@@ -1,21 +1,10 @@
|
||||
use anyhow::{bail, Context, Result};
|
||||
use base64::engine::{general_purpose::STANDARD as BASE64_STANDARD, Engine};
|
||||
use goose::scheduler::{
|
||||
get_default_scheduled_recipes_dir, get_default_scheduler_storage_path, ScheduledJob,
|
||||
get_default_scheduled_recipes_dir, get_default_scheduler_storage_path, ScheduledJob, Scheduler,
|
||||
SchedulerError,
|
||||
};
|
||||
use goose::scheduler_factory::SchedulerFactory;
|
||||
use std::path::Path;
|
||||
|
||||
// Base64 decoding function - might be needed if recipe_source_arg can be base64
|
||||
// For now, handle_schedule_add will assume it's a path.
|
||||
async fn _decode_base64_recipe(source: &str) -> Result<String> {
|
||||
let bytes = BASE64_STANDARD
|
||||
.decode(source.as_bytes())
|
||||
.with_context(|| "Recipe source is not a valid path and not valid Base64.")?;
|
||||
String::from_utf8(bytes).with_context(|| "Decoded Base64 recipe source is not valid UTF-8.")
|
||||
}
|
||||
|
||||
fn validate_cron_expression(cron: &str) -> Result<()> {
|
||||
// Basic validation and helpful suggestions
|
||||
if cron.trim().is_empty() {
|
||||
@@ -84,7 +73,6 @@ pub async fn handle_schedule_add(
|
||||
schedule_id, cron, recipe_source_arg
|
||||
);
|
||||
|
||||
// Validate cron expression and provide helpful feedback
|
||||
validate_cron_expression(&cron)?;
|
||||
|
||||
// The Scheduler's add_scheduled_job will handle copying the recipe from recipe_source_arg
|
||||
@@ -102,7 +90,7 @@ pub async fn handle_schedule_add(
|
||||
|
||||
let scheduler_storage_path =
|
||||
get_default_scheduler_storage_path().context("Failed to get scheduler storage path")?;
|
||||
let scheduler = SchedulerFactory::create(scheduler_storage_path)
|
||||
let scheduler = Scheduler::new(scheduler_storage_path)
|
||||
.await
|
||||
.context("Failed to initialize scheduler")?;
|
||||
|
||||
@@ -148,11 +136,11 @@ pub async fn handle_schedule_add(
|
||||
pub async fn handle_schedule_list() -> Result<()> {
|
||||
let scheduler_storage_path =
|
||||
get_default_scheduler_storage_path().context("Failed to get scheduler storage path")?;
|
||||
let scheduler = SchedulerFactory::create(scheduler_storage_path)
|
||||
let scheduler = Scheduler::new(scheduler_storage_path)
|
||||
.await
|
||||
.context("Failed to initialize scheduler")?;
|
||||
|
||||
let jobs = scheduler.list_scheduled_jobs().await?;
|
||||
let jobs = scheduler.list_scheduled_jobs().await;
|
||||
if jobs.is_empty() {
|
||||
println!("No scheduled jobs found.");
|
||||
} else {
|
||||
@@ -183,7 +171,7 @@ pub async fn handle_schedule_list() -> Result<()> {
|
||||
pub async fn handle_schedule_remove(schedule_id: String) -> Result<()> {
|
||||
let scheduler_storage_path =
|
||||
get_default_scheduler_storage_path().context("Failed to get scheduler storage path")?;
|
||||
let scheduler = SchedulerFactory::create(scheduler_storage_path)
|
||||
let scheduler = Scheduler::new(scheduler_storage_path)
|
||||
.await
|
||||
.context("Failed to initialize scheduler")?;
|
||||
|
||||
@@ -210,7 +198,7 @@ pub async fn handle_schedule_remove(schedule_id: String) -> Result<()> {
|
||||
pub async fn handle_schedule_sessions(schedule_id: String, limit: Option<usize>) -> Result<()> {
|
||||
let scheduler_storage_path =
|
||||
get_default_scheduler_storage_path().context("Failed to get scheduler storage path")?;
|
||||
let scheduler = SchedulerFactory::create(scheduler_storage_path)
|
||||
let scheduler = Scheduler::new(scheduler_storage_path)
|
||||
.await
|
||||
.context("Failed to initialize scheduler")?;
|
||||
|
||||
@@ -246,7 +234,7 @@ pub async fn handle_schedule_sessions(schedule_id: String, limit: Option<usize>)
|
||||
pub async fn handle_schedule_run_now(schedule_id: String) -> Result<()> {
|
||||
let scheduler_storage_path =
|
||||
get_default_scheduler_storage_path().context("Failed to get scheduler storage path")?;
|
||||
let scheduler = SchedulerFactory::create(scheduler_storage_path)
|
||||
let scheduler = Scheduler::new(scheduler_storage_path)
|
||||
.await
|
||||
.context("Failed to initialize scheduler")?;
|
||||
|
||||
|
||||
@@ -16,8 +16,6 @@ pub struct CreateScheduleRequest {
|
||||
id: String,
|
||||
recipe_source: String,
|
||||
cron: String,
|
||||
#[serde(default)]
|
||||
execution_mode: Option<String>, // "foreground" or "background"
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, utoipa::ToSchema)]
|
||||
@@ -36,7 +34,6 @@ pub struct KillJobResponse {
|
||||
message: String,
|
||||
}
|
||||
|
||||
// Response for the inspect endpoint
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InspectJobResponse {
|
||||
@@ -51,15 +48,9 @@ pub struct RunNowResponse {
|
||||
session_id: String,
|
||||
}
|
||||
|
||||
// Query parameters for the sessions endpoint
|
||||
#[derive(Deserialize, utoipa::ToSchema, utoipa::IntoParams)]
|
||||
pub struct SessionsQuery {
|
||||
#[serde(default = "default_limit")]
|
||||
limit: u32,
|
||||
}
|
||||
|
||||
fn default_limit() -> u32 {
|
||||
50 // Default limit for sessions listed
|
||||
limit: usize,
|
||||
}
|
||||
|
||||
// Struct for the frontend session list
|
||||
@@ -151,10 +142,7 @@ async fn list_schedules(
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
tracing::info!("Server: Calling scheduler.list_scheduled_jobs()");
|
||||
let jobs = scheduler.list_scheduled_jobs().await.map_err(|e| {
|
||||
eprintln!("Error listing schedules: {:?}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
let jobs = scheduler.list_scheduled_jobs().await;
|
||||
Ok(Json(ListSchedulesResponse { jobs }))
|
||||
}
|
||||
|
||||
@@ -213,39 +201,40 @@ async fn run_now_handler(
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let (recipe_display_name, recipe_version_opt) = match scheduler.list_scheduled_jobs().await {
|
||||
Ok(jobs) => {
|
||||
if let Some(job) = jobs.into_iter().find(|job| job.id == id) {
|
||||
let recipe_display_name = std::path::Path::new(&job.source)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| id.clone());
|
||||
let (recipe_display_name, recipe_version_opt) = if let Some(job) = scheduler
|
||||
.list_scheduled_jobs()
|
||||
.await
|
||||
.into_iter()
|
||||
.find(|job| job.id == id)
|
||||
{
|
||||
let recipe_display_name = std::path::Path::new(&job.source)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| id.clone());
|
||||
|
||||
let recipe_version_opt = tokio::fs::read_to_string(&job.source)
|
||||
.await
|
||||
let recipe_version_opt =
|
||||
tokio::fs::read_to_string(&job.source)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|content: String| {
|
||||
goose::recipe::template_recipe::parse_recipe_content(
|
||||
&content,
|
||||
Some(
|
||||
std::path::Path::new(&job.source)
|
||||
.parent()
|
||||
.unwrap_or_else(|| std::path::Path::new(""))
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
),
|
||||
)
|
||||
.ok()
|
||||
.and_then(|content| {
|
||||
goose::recipe::template_recipe::parse_recipe_content(
|
||||
&content,
|
||||
Some(
|
||||
std::path::Path::new(&job.source)
|
||||
.parent()
|
||||
.unwrap_or_else(|| std::path::Path::new(""))
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
),
|
||||
)
|
||||
.ok()
|
||||
.map(|(r, _)| r.version)
|
||||
});
|
||||
.map(|(r, _)| r.version)
|
||||
});
|
||||
|
||||
(recipe_display_name, recipe_version_opt)
|
||||
} else {
|
||||
(id.clone(), None)
|
||||
}
|
||||
}
|
||||
Err(_) => (id.clone(), None),
|
||||
(recipe_display_name, recipe_version_opt)
|
||||
} else {
|
||||
(id.clone(), None)
|
||||
};
|
||||
|
||||
let recipe_version_tag = recipe_version_opt.as_deref().unwrap_or("");
|
||||
@@ -308,7 +297,7 @@ async fn sessions_handler(
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
match scheduler
|
||||
.sessions(&schedule_id_param, query_params.limit as usize)
|
||||
.sessions(&schedule_id_param, query_params.limit)
|
||||
.await
|
||||
{
|
||||
Ok(session_tuples) => {
|
||||
@@ -448,11 +437,7 @@ async fn update_schedule(
|
||||
}
|
||||
})?;
|
||||
|
||||
// Return the updated schedule
|
||||
let jobs = scheduler.list_scheduled_jobs().await.map_err(|e| {
|
||||
eprintln!("Error listing schedules after update: {:?}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
let jobs = scheduler.list_scheduled_jobs().await;
|
||||
let updated_job = jobs
|
||||
.into_iter()
|
||||
.find(|job| job.id == id)
|
||||
|
||||
@@ -39,7 +39,6 @@ use crate::permission::PermissionConfirmation;
|
||||
use crate::providers::base::Provider;
|
||||
use crate::providers::errors::ProviderError;
|
||||
use crate::recipe::{Author, Recipe, Response, Settings, SubRecipe};
|
||||
use crate::scheduler_trait::SchedulerTrait;
|
||||
use crate::security::security_inspector::SecurityInspector;
|
||||
use crate::tool_inspection::ToolInspectionManager;
|
||||
use crate::tool_monitor::RepetitionInspector;
|
||||
@@ -60,6 +59,7 @@ use super::platform_tools;
|
||||
use super::tool_execution::{ToolCallResult, CHAT_MODE_TOOL_SKIPPED_RESPONSE, DECLINED_RESPONSE};
|
||||
use crate::agents::subagent_task_config::TaskConfig;
|
||||
use crate::conversation::message::{Message, MessageContent, SystemNotificationType, ToolRequest};
|
||||
use crate::scheduler_trait::SchedulerTrait;
|
||||
use crate::session::extension_data::{EnabledExtensionsState, ExtensionState};
|
||||
use crate::session::{Session, SessionManager};
|
||||
|
||||
@@ -346,7 +346,6 @@ impl Agent {
|
||||
Ok(tool_futures)
|
||||
}
|
||||
|
||||
/// Set the scheduler service for this agent
|
||||
pub async fn set_scheduler(&self, scheduler: Arc<dyn SchedulerTrait>) {
|
||||
let mut scheduler_service = self.scheduler_service.lock().await;
|
||||
*scheduler_service = Some(scheduler);
|
||||
|
||||
@@ -9,11 +9,10 @@ use crate::mcp_utils::ToolResult;
|
||||
use chrono::Utc;
|
||||
use rmcp::model::{Content, ErrorCode, ErrorData};
|
||||
|
||||
use super::Agent;
|
||||
use crate::recipe::Recipe;
|
||||
use crate::scheduler_trait::SchedulerTrait;
|
||||
|
||||
use super::Agent;
|
||||
|
||||
impl Agent {
|
||||
/// Handle schedule management tool calls
|
||||
pub async fn handle_schedule_management(
|
||||
@@ -62,34 +61,24 @@ impl Agent {
|
||||
}
|
||||
}
|
||||
|
||||
/// List all scheduled jobs
|
||||
async fn handle_list_jobs(
|
||||
&self,
|
||||
scheduler: Arc<dyn SchedulerTrait>,
|
||||
) -> ToolResult<Vec<Content>> {
|
||||
match scheduler.list_scheduled_jobs().await {
|
||||
Ok(jobs) => {
|
||||
let jobs_json = serde_json::to_string_pretty(&jobs).map_err(|e| {
|
||||
ErrorData::new(
|
||||
ErrorCode::INTERNAL_ERROR,
|
||||
format!("Failed to serialize jobs: {}", e),
|
||||
None,
|
||||
)
|
||||
})?;
|
||||
Ok(vec![Content::text(format!(
|
||||
"Scheduled Jobs:\n{}",
|
||||
jobs_json
|
||||
))])
|
||||
}
|
||||
Err(e) => Err(ErrorData::new(
|
||||
let jobs = scheduler.list_scheduled_jobs().await;
|
||||
let jobs_json = serde_json::to_string_pretty(&jobs).map_err(|e| {
|
||||
ErrorData::new(
|
||||
ErrorCode::INTERNAL_ERROR,
|
||||
format!("Failed to list jobs: {}", e),
|
||||
format!("Failed to serialize jobs: {}", e),
|
||||
None,
|
||||
)),
|
||||
}
|
||||
)
|
||||
})?;
|
||||
Ok(vec![Content::text(format!(
|
||||
"Scheduled Jobs:\n{}",
|
||||
jobs_json
|
||||
))])
|
||||
}
|
||||
|
||||
/// Create a new scheduled job from a recipe file
|
||||
async fn handle_create_job(
|
||||
&self,
|
||||
scheduler: Arc<dyn SchedulerTrait>,
|
||||
@@ -123,19 +112,6 @@ impl Agent {
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("background");
|
||||
|
||||
// Validate execution_mode is either "foreground" or "background"
|
||||
if execution_mode != "foreground" && execution_mode != "background" {
|
||||
return Err(ErrorData::new(
|
||||
ErrorCode::INTERNAL_ERROR,
|
||||
format!(
|
||||
"Invalid execution_mode: {}. Must be 'foreground' or 'background'",
|
||||
execution_mode
|
||||
),
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
// Validate recipe file exists and is readable
|
||||
if !std::path::Path::new(recipe_path).exists() {
|
||||
return Err(ErrorData::new(
|
||||
ErrorCode::INTERNAL_ERROR,
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
#[cfg(test)]
|
||||
mod cron_parsing_tests {
|
||||
use crate::scheduler::normalize_cron_expression;
|
||||
use tokio_cron_scheduler::Job;
|
||||
|
||||
// Helper: drop the last field if we have 7 so tokio_cron_scheduler (6-field) can parse
|
||||
fn to_tokio_spec(spec: &str) -> String {
|
||||
let parts: Vec<&str> = spec.split_whitespace().collect();
|
||||
if parts.len() == 7 {
|
||||
parts[..6].join(" ")
|
||||
} else {
|
||||
spec.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_cron_expression() {
|
||||
// 5-field → 7-field
|
||||
assert_eq!(normalize_cron_expression("0 12 * * *"), "0 0 12 * * * *");
|
||||
assert_eq!(normalize_cron_expression("*/5 * * * *"), "0 */5 * * * * *");
|
||||
assert_eq!(normalize_cron_expression("0 0 * * 1"), "0 0 0 * * 1 *");
|
||||
|
||||
// 6-field → 7-field (append *)
|
||||
assert_eq!(normalize_cron_expression("0 0 12 * * *"), "0 0 12 * * * *");
|
||||
assert_eq!(
|
||||
normalize_cron_expression("*/30 */5 * * * *"),
|
||||
"*/30 */5 * * * * *"
|
||||
);
|
||||
|
||||
// Weekday expressions (unchanged apart from 7-field format)
|
||||
assert_eq!(normalize_cron_expression("0 * * * 1-5"), "0 0 * * * 1-5 *");
|
||||
assert_eq!(
|
||||
normalize_cron_expression("*/20 * * * 1-5"),
|
||||
"0 */20 * * * 1-5 *"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cron_expression_formats() {
|
||||
let samples = [
|
||||
"0 0 * * *", // 5-field
|
||||
"0 0 0 * * *", // 6-field
|
||||
"*/5 * * * *", // 5-field
|
||||
];
|
||||
for expr in samples {
|
||||
let norm = normalize_cron_expression(expr);
|
||||
let tokio_spec = to_tokio_spec(&norm);
|
||||
assert!(
|
||||
Job::new_async(&tokio_spec, |_id, _l| Box::pin(async {})).is_ok(),
|
||||
"failed to parse {} -> {}",
|
||||
expr,
|
||||
norm
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::agents::extension::PlatformExtensionContext;
|
||||
use crate::agents::Agent;
|
||||
use crate::config::paths::Paths;
|
||||
use crate::scheduler_factory::SchedulerFactory;
|
||||
use crate::scheduler::Scheduler;
|
||||
use crate::scheduler_trait::SchedulerTrait;
|
||||
use anyhow::Result;
|
||||
use lru::LruCache;
|
||||
@@ -35,7 +35,7 @@ impl AgentManager {
|
||||
async fn new(max_sessions: Option<usize>) -> Result<Self> {
|
||||
let schedule_file_path = Paths::data_dir().join("schedule.json");
|
||||
|
||||
let scheduler = SchedulerFactory::create(schedule_file_path).await?;
|
||||
let scheduler = Scheduler::new(schedule_file_path).await?;
|
||||
|
||||
let capacity = NonZeroUsize::new(max_sessions.unwrap_or(DEFAULT_MAX_SESSION))
|
||||
.unwrap_or_else(|| NonZeroUsize::new(100).unwrap());
|
||||
|
||||
@@ -14,7 +14,6 @@ pub mod providers;
|
||||
pub mod recipe;
|
||||
pub mod recipe_deeplink;
|
||||
pub mod scheduler;
|
||||
pub mod scheduler_factory;
|
||||
pub mod scheduler_trait;
|
||||
pub mod security;
|
||||
pub mod session;
|
||||
@@ -25,6 +24,3 @@ pub mod tool_inspection;
|
||||
pub mod tool_monitor;
|
||||
pub mod tracing;
|
||||
pub mod utils;
|
||||
|
||||
#[cfg(test)]
|
||||
mod cron_test;
|
||||
|
||||
+428
-1061
File diff suppressed because it is too large
Load Diff
@@ -1,26 +0,0 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::scheduler::{Scheduler, SchedulerError};
|
||||
use crate::scheduler_trait::SchedulerTrait;
|
||||
|
||||
/// Factory for creating scheduler instances
|
||||
pub struct SchedulerFactory;
|
||||
|
||||
impl SchedulerFactory {
|
||||
/// Create a scheduler instance
|
||||
pub async fn create(storage_path: PathBuf) -> Result<Arc<dyn SchedulerTrait>, SchedulerError> {
|
||||
tracing::info!("Creating scheduler");
|
||||
let scheduler = Scheduler::new(storage_path).await?;
|
||||
Ok(scheduler as Arc<dyn SchedulerTrait>)
|
||||
}
|
||||
|
||||
/// Create a scheduler (for testing or explicit use)
|
||||
pub async fn create_legacy(
|
||||
storage_path: PathBuf,
|
||||
) -> Result<Arc<dyn SchedulerTrait>, SchedulerError> {
|
||||
tracing::info!("Creating scheduler (explicit)");
|
||||
let scheduler = Scheduler::new(storage_path).await?;
|
||||
Ok(scheduler as Arc<dyn SchedulerTrait>)
|
||||
}
|
||||
}
|
||||
@@ -4,42 +4,22 @@ use chrono::{DateTime, Utc};
|
||||
use crate::scheduler::{ScheduledJob, SchedulerError};
|
||||
use crate::session::Session;
|
||||
|
||||
/// Common trait for all scheduler implementations
|
||||
#[async_trait]
|
||||
pub trait SchedulerTrait: Send + Sync {
|
||||
/// Add a new scheduled job
|
||||
async fn add_scheduled_job(&self, job: ScheduledJob) -> Result<(), SchedulerError>;
|
||||
|
||||
/// List all scheduled jobs
|
||||
async fn list_scheduled_jobs(&self) -> Result<Vec<ScheduledJob>, SchedulerError>;
|
||||
|
||||
/// Remove a scheduled job by ID
|
||||
async fn list_scheduled_jobs(&self) -> Vec<ScheduledJob>;
|
||||
async fn remove_scheduled_job(&self, id: &str) -> Result<(), SchedulerError>;
|
||||
|
||||
/// Pause a scheduled job
|
||||
async fn pause_schedule(&self, id: &str) -> Result<(), SchedulerError>;
|
||||
|
||||
/// Unpause a scheduled job
|
||||
async fn unpause_schedule(&self, id: &str) -> Result<(), SchedulerError>;
|
||||
|
||||
/// Run a job immediately
|
||||
async fn run_now(&self, id: &str) -> Result<String, SchedulerError>;
|
||||
|
||||
/// Get sessions for a scheduled job
|
||||
async fn sessions(
|
||||
&self,
|
||||
sched_id: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<(String, Session)>, SchedulerError>;
|
||||
|
||||
/// Update a schedule's cron expression
|
||||
async fn update_schedule(&self, sched_id: &str, new_cron: String)
|
||||
-> Result<(), SchedulerError>;
|
||||
|
||||
/// Kill a running job
|
||||
async fn kill_running_job(&self, sched_id: &str) -> Result<(), SchedulerError>;
|
||||
|
||||
/// Get information about a running job
|
||||
async fn get_running_job_info(
|
||||
&self,
|
||||
sched_id: &str,
|
||||
|
||||
@@ -40,9 +40,9 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_scheduled_jobs(&self) -> Result<Vec<ScheduledJob>, SchedulerError> {
|
||||
async fn list_scheduled_jobs(&self) -> Vec<ScheduledJob> {
|
||||
let jobs = self.jobs.lock().await;
|
||||
Ok(jobs.clone())
|
||||
jobs.clone()
|
||||
}
|
||||
|
||||
async fn remove_scheduled_job(&self, id: &str) -> Result<(), SchedulerError> {
|
||||
|
||||
@@ -1,401 +0,0 @@
|
||||
#![cfg(test)]
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use tempfile::TempDir;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use goose::agents::Agent;
|
||||
use goose::scheduler::{ScheduledJob, SchedulerError};
|
||||
use goose::scheduler_trait::SchedulerTrait;
|
||||
use goose::session::Session;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MockBehavior {
|
||||
Success,
|
||||
NotFound(String),
|
||||
AlreadyExists(String),
|
||||
InternalError(String),
|
||||
JobCurrentlyRunning(String),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ConfigurableMockScheduler {
|
||||
jobs: Arc<Mutex<HashMap<String, ScheduledJob>>>,
|
||||
running_jobs: Arc<Mutex<HashSet<String>>>,
|
||||
call_log: Arc<Mutex<Vec<String>>>,
|
||||
behaviors: Arc<Mutex<HashMap<String, MockBehavior>>>,
|
||||
#[allow(clippy::type_complexity)]
|
||||
sessions_data: Arc<Mutex<HashMap<String, Vec<(String, Session)>>>>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Default for ConfigurableMockScheduler {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigurableMockScheduler {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
jobs: Arc::new(Mutex::new(HashMap::new())),
|
||||
running_jobs: Arc::new(Mutex::new(HashSet::new())),
|
||||
call_log: Arc::new(Mutex::new(Vec::new())),
|
||||
behaviors: Arc::new(Mutex::new(HashMap::new())),
|
||||
sessions_data: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_calls(&self) -> Vec<String> {
|
||||
self.call_log.lock().await.clone()
|
||||
}
|
||||
|
||||
async fn log_call(&self, method: &str) {
|
||||
self.call_log.lock().await.push(method.to_string());
|
||||
}
|
||||
|
||||
async fn get_behavior(&self, method: &str) -> MockBehavior {
|
||||
self.behaviors
|
||||
.lock()
|
||||
.await
|
||||
.get(method)
|
||||
.cloned()
|
||||
.unwrap_or(MockBehavior::Success)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SchedulerTrait for ConfigurableMockScheduler {
|
||||
async fn add_scheduled_job(&self, job: ScheduledJob) -> Result<(), SchedulerError> {
|
||||
self.log_call("add_scheduled_job").await;
|
||||
|
||||
match self.get_behavior("add_scheduled_job").await {
|
||||
MockBehavior::Success => {
|
||||
let mut jobs = self.jobs.lock().await;
|
||||
if jobs.contains_key(&job.id) {
|
||||
return Err(SchedulerError::JobIdExists(job.id));
|
||||
}
|
||||
jobs.insert(job.id.clone(), job);
|
||||
Ok(())
|
||||
}
|
||||
MockBehavior::AlreadyExists(id) => Err(SchedulerError::JobIdExists(id)),
|
||||
MockBehavior::InternalError(msg) => Err(SchedulerError::SchedulerInternalError(msg)),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_scheduled_jobs(&self) -> Result<Vec<ScheduledJob>, SchedulerError> {
|
||||
self.log_call("list_scheduled_jobs").await;
|
||||
|
||||
match self.get_behavior("list_scheduled_jobs").await {
|
||||
MockBehavior::Success => {
|
||||
let jobs = self.jobs.lock().await;
|
||||
Ok(jobs.values().cloned().collect())
|
||||
}
|
||||
MockBehavior::InternalError(msg) => Err(SchedulerError::SchedulerInternalError(msg)),
|
||||
_ => Ok(vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_scheduled_job(&self, id: &str) -> Result<(), SchedulerError> {
|
||||
self.log_call("remove_scheduled_job").await;
|
||||
|
||||
match self.get_behavior("remove_scheduled_job").await {
|
||||
MockBehavior::Success => {
|
||||
let mut jobs = self.jobs.lock().await;
|
||||
if jobs.remove(id).is_some() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(SchedulerError::JobNotFound(id.to_string()))
|
||||
}
|
||||
}
|
||||
MockBehavior::NotFound(job_id) => Err(SchedulerError::JobNotFound(job_id)),
|
||||
MockBehavior::InternalError(msg) => Err(SchedulerError::SchedulerInternalError(msg)),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn pause_schedule(&self, id: &str) -> Result<(), SchedulerError> {
|
||||
self.log_call("pause_schedule").await;
|
||||
|
||||
match self.get_behavior("pause_schedule").await {
|
||||
MockBehavior::Success => {
|
||||
let jobs = self.jobs.lock().await;
|
||||
if jobs.contains_key(id) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(SchedulerError::JobNotFound(id.to_string()))
|
||||
}
|
||||
}
|
||||
MockBehavior::NotFound(job_id) => Err(SchedulerError::JobNotFound(job_id)),
|
||||
MockBehavior::JobCurrentlyRunning(job_id) => {
|
||||
Err(SchedulerError::AnyhowError(anyhow::anyhow!(
|
||||
"Cannot pause schedule '{}' while it's currently running",
|
||||
job_id
|
||||
)))
|
||||
}
|
||||
MockBehavior::InternalError(msg) => Err(SchedulerError::SchedulerInternalError(msg)),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn unpause_schedule(&self, id: &str) -> Result<(), SchedulerError> {
|
||||
self.log_call("unpause_schedule").await;
|
||||
|
||||
match self.get_behavior("unpause_schedule").await {
|
||||
MockBehavior::Success => {
|
||||
let jobs = self.jobs.lock().await;
|
||||
if jobs.contains_key(id) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(SchedulerError::JobNotFound(id.to_string()))
|
||||
}
|
||||
}
|
||||
MockBehavior::NotFound(job_id) => Err(SchedulerError::JobNotFound(job_id)),
|
||||
MockBehavior::InternalError(msg) => Err(SchedulerError::SchedulerInternalError(msg)),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_now(&self, id: &str) -> Result<String, SchedulerError> {
|
||||
self.log_call("run_now").await;
|
||||
|
||||
match self.get_behavior("run_now").await {
|
||||
MockBehavior::Success => {
|
||||
let jobs = self.jobs.lock().await;
|
||||
if jobs.contains_key(id) {
|
||||
Ok(format!("{}_session_{}", id, chrono::Utc::now().timestamp()))
|
||||
} else {
|
||||
Err(SchedulerError::JobNotFound(id.to_string()))
|
||||
}
|
||||
}
|
||||
MockBehavior::NotFound(job_id) => Err(SchedulerError::JobNotFound(job_id)),
|
||||
MockBehavior::InternalError(msg) => Err(SchedulerError::SchedulerInternalError(msg)),
|
||||
_ => Ok("mock_session_123".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn sessions(
|
||||
&self,
|
||||
sched_id: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<(String, Session)>, SchedulerError> {
|
||||
self.log_call("sessions").await;
|
||||
|
||||
match self.get_behavior("sessions").await {
|
||||
MockBehavior::Success => {
|
||||
let sessions_data = self.sessions_data.lock().await;
|
||||
let sessions = sessions_data.get(sched_id).cloned().unwrap_or_default();
|
||||
Ok(sessions.into_iter().take(limit).collect())
|
||||
}
|
||||
MockBehavior::NotFound(job_id) => Err(SchedulerError::JobNotFound(job_id)),
|
||||
MockBehavior::InternalError(msg) => Err(SchedulerError::SchedulerInternalError(msg)),
|
||||
_ => Ok(vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_schedule(
|
||||
&self,
|
||||
sched_id: &str,
|
||||
_new_cron: String,
|
||||
) -> Result<(), SchedulerError> {
|
||||
self.log_call("update_schedule").await;
|
||||
|
||||
match self.get_behavior("update_schedule").await {
|
||||
MockBehavior::Success => {
|
||||
let jobs = self.jobs.lock().await;
|
||||
if jobs.contains_key(sched_id) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(SchedulerError::JobNotFound(sched_id.to_string()))
|
||||
}
|
||||
}
|
||||
MockBehavior::NotFound(job_id) => Err(SchedulerError::JobNotFound(job_id)),
|
||||
MockBehavior::InternalError(msg) => Err(SchedulerError::SchedulerInternalError(msg)),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn kill_running_job(&self, sched_id: &str) -> Result<(), SchedulerError> {
|
||||
self.log_call("kill_running_job").await;
|
||||
|
||||
match self.get_behavior("kill_running_job").await {
|
||||
MockBehavior::Success => {
|
||||
let running_jobs = self.running_jobs.lock().await;
|
||||
if running_jobs.contains(sched_id) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(SchedulerError::AnyhowError(anyhow::anyhow!(
|
||||
"Schedule '{}' is not currently running",
|
||||
sched_id
|
||||
)))
|
||||
}
|
||||
}
|
||||
MockBehavior::NotFound(job_id) => Err(SchedulerError::JobNotFound(job_id)),
|
||||
MockBehavior::InternalError(msg) => Err(SchedulerError::SchedulerInternalError(msg)),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_running_job_info(
|
||||
&self,
|
||||
sched_id: &str,
|
||||
) -> Result<Option<(String, DateTime<Utc>)>, SchedulerError> {
|
||||
self.log_call("get_running_job_info").await;
|
||||
|
||||
match self.get_behavior("get_running_job_info").await {
|
||||
MockBehavior::Success => {
|
||||
let running_jobs = self.running_jobs.lock().await;
|
||||
if running_jobs.contains(sched_id) {
|
||||
Ok(Some((format!("{}_session", sched_id), Utc::now())))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
MockBehavior::NotFound(job_id) => Err(SchedulerError::JobNotFound(job_id)),
|
||||
MockBehavior::InternalError(msg) => Err(SchedulerError::SchedulerInternalError(msg)),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper for creating temp recipe files
|
||||
pub struct TempRecipe {
|
||||
pub path: PathBuf,
|
||||
_temp_dir: TempDir, // Keep alive
|
||||
}
|
||||
|
||||
pub fn create_temp_recipe(valid: bool, format: &str) -> TempRecipe {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let filename = format!("test_recipe.{}", format);
|
||||
let path = temp_dir.path().join(filename);
|
||||
|
||||
let content = if valid {
|
||||
match format {
|
||||
"json" => {
|
||||
r#"{
|
||||
"version": "1.0.0",
|
||||
"title": "Test Recipe",
|
||||
"description": "A test recipe",
|
||||
"prompt": "Hello world"
|
||||
}"#
|
||||
}
|
||||
"yaml" | "yml" => {
|
||||
r#"version: "1.0.0"
|
||||
title: "Test Recipe"
|
||||
description: "A test recipe"
|
||||
prompt: "Hello world"
|
||||
"#
|
||||
}
|
||||
_ => panic!("Unsupported format: {}", format),
|
||||
}
|
||||
} else {
|
||||
match format {
|
||||
"json" => r#"{"invalid": json syntax"#,
|
||||
"yaml" | "yml" => "invalid:\n - yaml: syntax: error",
|
||||
_ => "invalid content",
|
||||
}
|
||||
};
|
||||
|
||||
std::fs::write(&path, content).unwrap();
|
||||
TempRecipe {
|
||||
path,
|
||||
_temp_dir: temp_dir,
|
||||
}
|
||||
}
|
||||
|
||||
// Test builder for easy setup
|
||||
pub struct ScheduleToolTestBuilder {
|
||||
scheduler: Arc<ConfigurableMockScheduler>,
|
||||
}
|
||||
|
||||
impl Default for ScheduleToolTestBuilder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ScheduleToolTestBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
scheduler: Arc::new(ConfigurableMockScheduler::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn with_scheduler_behavior(self, method: &str, behavior: MockBehavior) -> Self {
|
||||
{
|
||||
let mut behaviors = self.scheduler.behaviors.lock().await;
|
||||
behaviors.insert(method.to_string(), behavior);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn with_existing_job(self, job_id: &str, cron: &str) -> Self {
|
||||
let job = ScheduledJob {
|
||||
id: job_id.to_string(),
|
||||
source: "/tmp/test.json".to_string(),
|
||||
cron: cron.to_string(),
|
||||
last_run: None,
|
||||
currently_running: false,
|
||||
paused: false,
|
||||
current_session_id: None,
|
||||
process_start_time: None,
|
||||
};
|
||||
{
|
||||
let mut jobs = self.scheduler.jobs.lock().await;
|
||||
jobs.insert(job.id.clone(), job);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn with_running_job(self, job_id: &str) -> Self {
|
||||
{
|
||||
let mut running_jobs = self.scheduler.running_jobs.lock().await;
|
||||
running_jobs.insert(job_id.to_string());
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn with_sessions_data(self, job_id: &str, sessions: Vec<(String, Session)>) -> Self {
|
||||
{
|
||||
let mut sessions_data = self.scheduler.sessions_data.lock().await;
|
||||
sessions_data.insert(job_id.to_string(), sessions);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn build(self) -> (Agent, Arc<ConfigurableMockScheduler>) {
|
||||
let agent = Agent::new();
|
||||
agent.set_scheduler(self.scheduler.clone()).await;
|
||||
(agent, self.scheduler)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_test_session_metadata(message_count: usize, working_dir: &str) -> Session {
|
||||
Session {
|
||||
id: "".to_string(),
|
||||
working_dir: PathBuf::from(working_dir),
|
||||
name: "Test session".to_string(),
|
||||
user_set_name: false,
|
||||
created_at: Default::default(),
|
||||
schedule_id: Some("test_job".to_string()),
|
||||
recipe: None,
|
||||
total_tokens: Some(100),
|
||||
input_tokens: Some(50),
|
||||
output_tokens: Some(50),
|
||||
accumulated_total_tokens: Some(100),
|
||||
accumulated_input_tokens: Some(50),
|
||||
accumulated_output_tokens: Some(50),
|
||||
extension_data: Default::default(),
|
||||
updated_at: Default::default(),
|
||||
conversation: None,
|
||||
message_count,
|
||||
user_recipe_values: None,
|
||||
session_type: Default::default(),
|
||||
}
|
||||
}
|
||||
@@ -1672,10 +1672,9 @@
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
@@ -2353,10 +2352,6 @@
|
||||
"cron": {
|
||||
"type": "string"
|
||||
},
|
||||
"execution_mode": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -4400,10 +4395,12 @@
|
||||
},
|
||||
"SessionsQuery": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"limit"
|
||||
],
|
||||
"properties": {
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,6 @@ export type CreateRecipeResponse = {
|
||||
|
||||
export type CreateScheduleRequest = {
|
||||
cron: string;
|
||||
execution_mode?: string | null;
|
||||
id: string;
|
||||
recipe_source: string;
|
||||
};
|
||||
@@ -732,7 +731,7 @@ export type SessionListResponse = {
|
||||
export type SessionType = 'user' | 'scheduled' | 'sub_agent' | 'hidden';
|
||||
|
||||
export type SessionsQuery = {
|
||||
limit?: number;
|
||||
limit: number;
|
||||
};
|
||||
|
||||
export type SetProviderRequest = {
|
||||
@@ -2209,8 +2208,8 @@ export type SessionsHandlerData = {
|
||||
*/
|
||||
id: string;
|
||||
};
|
||||
query?: {
|
||||
limit?: number;
|
||||
query: {
|
||||
limit: number;
|
||||
};
|
||||
url: '/schedule/{id}/sessions';
|
||||
};
|
||||
|
||||
@@ -1,894 +0,0 @@
|
||||
import React, { useState, useEffect, FormEvent, useCallback } from 'react';
|
||||
import { Card } from '../ui/card';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Select } from '../ui/Select';
|
||||
import cronstrue from 'cronstrue';
|
||||
import * as yaml from 'yaml';
|
||||
import { Recipe, decodeRecipe } from '../../recipe';
|
||||
import { getStorageDirectory } from '../../recipe/recipe_management';
|
||||
import ClockIcon from '../../assets/clock-icon.svg';
|
||||
|
||||
type FrequencyValue = 'once' | 'every' | 'daily' | 'weekly' | 'monthly';
|
||||
|
||||
type CustomIntervalUnit = 'minute' | 'hour' | 'day';
|
||||
|
||||
interface FrequencyOption {
|
||||
value: FrequencyValue;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface NewSchedulePayload {
|
||||
id: string;
|
||||
recipe_source: string;
|
||||
cron: string;
|
||||
execution_mode?: string;
|
||||
}
|
||||
|
||||
interface CreateScheduleModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (payload: NewSchedulePayload) => Promise<void>;
|
||||
isLoadingExternally: boolean;
|
||||
apiErrorExternally: string | null;
|
||||
initialDeepLink?: string | null;
|
||||
}
|
||||
|
||||
// Interface for clean extension in YAML
|
||||
interface CleanExtension {
|
||||
name: string;
|
||||
type: 'stdio' | 'sse' | 'builtin' | 'frontend' | 'streamable_http';
|
||||
cmd?: string;
|
||||
args?: string[];
|
||||
uri?: string;
|
||||
display_name?: string;
|
||||
tools?: unknown[];
|
||||
instructions?: string;
|
||||
env_keys?: string[];
|
||||
timeout?: number;
|
||||
description?: string;
|
||||
bundled?: boolean;
|
||||
}
|
||||
|
||||
// TODO: This 'Recipe' interface should be converted to match the OpenAPI spec for Recipe
|
||||
// once we have separated the recipe from the schedule in the frontend.
|
||||
// Interface for clean recipe in YAML
|
||||
interface CleanRecipe {
|
||||
title: string;
|
||||
description: string;
|
||||
instructions?: string;
|
||||
prompt?: string;
|
||||
activities?: string[];
|
||||
extensions?: CleanExtension[];
|
||||
author?: {
|
||||
contact?: string;
|
||||
metadata?: string;
|
||||
};
|
||||
schedule?: {
|
||||
foreground: boolean;
|
||||
fallback_to_background: boolean;
|
||||
window_title?: string;
|
||||
working_directory?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const frequencies: FrequencyOption[] = [
|
||||
{ value: 'once', label: 'Once' },
|
||||
{ value: 'every', label: 'Every...' },
|
||||
{ value: 'daily', label: 'Daily (at specific time)' },
|
||||
{ value: 'weekly', label: 'Weekly (at specific time/days)' },
|
||||
{ value: 'monthly', label: 'Monthly (at specific time/day)' },
|
||||
];
|
||||
|
||||
const customIntervalUnits: { value: CustomIntervalUnit; label: string }[] = [
|
||||
{ value: 'minute', label: 'minute(s)' },
|
||||
{ value: 'hour', label: 'hour(s)' },
|
||||
{ value: 'day', label: 'day(s)' },
|
||||
];
|
||||
|
||||
const daysOfWeekOptions: { value: string; label: string }[] = [
|
||||
{ value: '1', label: 'Mon' },
|
||||
{ value: '2', label: 'Tue' },
|
||||
{ value: '3', label: 'Wed' },
|
||||
{ value: '4', label: 'Thu' },
|
||||
{ value: '5', label: 'Fri' },
|
||||
{ value: '6', label: 'Sat' },
|
||||
{ value: '0', label: 'Sun' },
|
||||
];
|
||||
|
||||
const modalLabelClassName = 'block text-sm font-medium text-text-prominent mb-1';
|
||||
const cronPreviewTextColor = 'text-xs text-text-subtle mt-1';
|
||||
const cronPreviewSpecialNoteColor = 'text-xs text-text-warning mt-1';
|
||||
const checkboxLabelClassName = 'flex items-center text-sm text-text-default';
|
||||
const checkboxInputClassName =
|
||||
'h-4 w-4 text-accent-default border-border-subtle rounded focus:ring-accent-default mr-2';
|
||||
|
||||
type SourceType = 'file' | 'deeplink';
|
||||
type ExecutionMode = 'background' | 'foreground';
|
||||
|
||||
// Function to parse deep link and extract recipe config
|
||||
async function parseDeepLink(deepLink: string): Promise<Recipe | null> {
|
||||
try {
|
||||
const url = new URL(deepLink);
|
||||
if (url.protocol !== 'goose:' || (url.hostname !== 'bot' && url.hostname !== 'recipe')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const recipeParam = url.searchParams.get('config');
|
||||
if (!recipeParam) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await decodeRecipe(recipeParam);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse deep link:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Function to convert recipe to YAML with schedule configuration
|
||||
function recipeToYaml(recipe: Recipe, executionMode: ExecutionMode): string {
|
||||
// Create a clean recipe object for YAML conversion
|
||||
const cleanRecipe: CleanRecipe = {
|
||||
title: recipe.title,
|
||||
description: recipe.description,
|
||||
};
|
||||
|
||||
if (recipe.instructions) {
|
||||
cleanRecipe.instructions = recipe.instructions;
|
||||
}
|
||||
|
||||
if (recipe.prompt) {
|
||||
cleanRecipe.prompt = recipe.prompt;
|
||||
}
|
||||
|
||||
if (recipe.activities && recipe.activities.length > 0) {
|
||||
cleanRecipe.activities = recipe.activities;
|
||||
}
|
||||
|
||||
if (recipe.extensions && recipe.extensions.length > 0) {
|
||||
cleanRecipe.extensions = recipe.extensions.map((ext) => {
|
||||
const cleanExt: CleanExtension = {
|
||||
name: ext.name,
|
||||
type: 'builtin', // Default type, will be overridden below
|
||||
};
|
||||
|
||||
// Handle different extension types using type assertions
|
||||
if ('type' in ext && ext.type) {
|
||||
cleanExt.type = ext.type as CleanExtension['type'];
|
||||
|
||||
// Use type assertions to access properties safely
|
||||
const extAny = ext as Record<string, unknown>;
|
||||
|
||||
if (ext.type === 'sse' && extAny.uri) {
|
||||
cleanExt.uri = extAny.uri as string;
|
||||
} else if (ext.type === 'streamable_http' && extAny.uri) {
|
||||
cleanExt.uri = extAny.uri as string;
|
||||
} else if (ext.type === 'stdio') {
|
||||
if (extAny.cmd) {
|
||||
cleanExt.cmd = extAny.cmd as string;
|
||||
}
|
||||
if (extAny.args) {
|
||||
cleanExt.args = extAny.args as string[];
|
||||
}
|
||||
} else if (ext.type === 'builtin' && extAny.display_name) {
|
||||
cleanExt.display_name = extAny.display_name as string;
|
||||
}
|
||||
|
||||
// Handle frontend type separately to avoid TypeScript narrowing issues
|
||||
if ((ext.type as string) === 'frontend') {
|
||||
if (extAny.tools) {
|
||||
cleanExt.tools = extAny.tools as unknown[];
|
||||
}
|
||||
if (extAny.instructions) {
|
||||
cleanExt.instructions = extAny.instructions as string;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: try to infer type from available fields
|
||||
const extAny = ext as Record<string, unknown>;
|
||||
|
||||
if (extAny.cmd) {
|
||||
cleanExt.type = 'stdio';
|
||||
cleanExt.cmd = extAny.cmd as string;
|
||||
if (extAny.args) {
|
||||
cleanExt.args = extAny.args as string[];
|
||||
}
|
||||
} else if (extAny.command) {
|
||||
// Handle legacy 'command' field by converting to 'cmd'
|
||||
cleanExt.type = 'stdio';
|
||||
cleanExt.cmd = extAny.command as string;
|
||||
} else if (extAny.uri) {
|
||||
// Default to streamable_http for URI-based extensions for forward compatibility
|
||||
cleanExt.type = 'streamable_http';
|
||||
cleanExt.uri = extAny.uri as string;
|
||||
} else if (extAny.tools) {
|
||||
cleanExt.type = 'frontend';
|
||||
cleanExt.tools = extAny.tools as unknown[];
|
||||
if (extAny.instructions) {
|
||||
cleanExt.instructions = extAny.instructions as string;
|
||||
}
|
||||
} else {
|
||||
// Default to builtin if we can't determine type
|
||||
cleanExt.type = 'builtin';
|
||||
}
|
||||
}
|
||||
|
||||
// Add common optional fields
|
||||
if ('env_keys' in ext && ext.env_keys && ext.env_keys.length > 0) {
|
||||
cleanExt.env_keys = ext.env_keys;
|
||||
}
|
||||
|
||||
if ('timeout' in ext && ext.timeout) {
|
||||
cleanExt.timeout = ext.timeout as number;
|
||||
}
|
||||
|
||||
if ('description' in ext && ext.description) {
|
||||
cleanExt.description = ext.description as string;
|
||||
}
|
||||
|
||||
if ('bundled' in ext && ext.bundled !== undefined) {
|
||||
cleanExt.bundled = ext.bundled as boolean;
|
||||
}
|
||||
|
||||
return cleanExt;
|
||||
});
|
||||
}
|
||||
|
||||
if (recipe.author) {
|
||||
cleanRecipe.author = {
|
||||
contact: recipe.author.contact || undefined,
|
||||
metadata: recipe.author.metadata || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Add schedule configuration based on execution mode
|
||||
cleanRecipe.schedule = {
|
||||
foreground: executionMode === 'foreground',
|
||||
fallback_to_background: true, // Always allow fallback
|
||||
window_title: executionMode === 'foreground' ? `${recipe.title} - Scheduled` : undefined,
|
||||
};
|
||||
|
||||
return yaml.stringify(cleanRecipe);
|
||||
}
|
||||
|
||||
export const CreateScheduleModal: React.FC<CreateScheduleModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
isLoadingExternally,
|
||||
apiErrorExternally,
|
||||
initialDeepLink,
|
||||
}) => {
|
||||
const [scheduleId, setScheduleId] = useState<string>('');
|
||||
const [sourceType, setSourceType] = useState<SourceType>('file');
|
||||
const [executionMode, setExecutionMode] = useState<ExecutionMode>('background');
|
||||
const [recipeSourcePath, setRecipeSourcePath] = useState<string>('');
|
||||
const [deepLinkInput, setDeepLinkInput] = useState<string>('');
|
||||
const [parsedRecipe, setParsedRecipe] = useState<Recipe | null>(null);
|
||||
const [frequency, setFrequency] = useState<FrequencyValue>('daily');
|
||||
const [customIntervalValue, setCustomIntervalValue] = useState<number>(1);
|
||||
const [customIntervalUnit, setCustomIntervalUnit] = useState<CustomIntervalUnit>('minute');
|
||||
const [selectedDate, setSelectedDate] = useState<string>(
|
||||
() => new Date().toISOString().split('T')[0]
|
||||
);
|
||||
const [selectedTime, setSelectedTime] = useState<string>('09:00');
|
||||
const [selectedMinute, setSelectedMinute] = useState<string>('0');
|
||||
const [selectedDaysOfWeek, setSelectedDaysOfWeek] = useState<Set<string>>(new Set(['1']));
|
||||
const [selectedDayOfMonth, setSelectedDayOfMonth] = useState<string>('1');
|
||||
const [derivedCronExpression, setDerivedCronExpression] = useState<string>('');
|
||||
const [readableCronExpression, setReadableCronExpression] = useState<string>('');
|
||||
const [internalValidationError, setInternalValidationError] = useState<string | null>(null);
|
||||
|
||||
const handleDeepLinkChange = useCallback(
|
||||
async (value: string) => {
|
||||
setDeepLinkInput(value);
|
||||
setInternalValidationError(null);
|
||||
|
||||
if (value.trim()) {
|
||||
try {
|
||||
const recipe = await parseDeepLink(value.trim());
|
||||
if (recipe) {
|
||||
setParsedRecipe(recipe);
|
||||
// Auto-populate schedule ID from recipe title if available
|
||||
if (recipe.title && !scheduleId) {
|
||||
const cleanId = recipe.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, '-')
|
||||
.replace(/-+/g, '-');
|
||||
setScheduleId(cleanId);
|
||||
}
|
||||
} else {
|
||||
setParsedRecipe(null);
|
||||
setInternalValidationError(
|
||||
'Invalid deep link format. Please use a goose://bot or goose://recipe link.'
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
setParsedRecipe(null);
|
||||
setInternalValidationError(
|
||||
'Failed to parse deep link. Please ensure using a goose://bot or goose://recipe link and try again.'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setParsedRecipe(null);
|
||||
}
|
||||
},
|
||||
[scheduleId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Check for initial deep link from props when modal opens
|
||||
if (isOpen && initialDeepLink) {
|
||||
setSourceType('deeplink');
|
||||
handleDeepLinkChange(initialDeepLink);
|
||||
}
|
||||
}, [isOpen, initialDeepLink, handleDeepLinkChange]);
|
||||
|
||||
const resetForm = () => {
|
||||
setScheduleId('');
|
||||
setSourceType('file');
|
||||
setExecutionMode('background');
|
||||
setRecipeSourcePath('');
|
||||
setDeepLinkInput('');
|
||||
setParsedRecipe(null);
|
||||
setFrequency('daily');
|
||||
setCustomIntervalValue(1);
|
||||
setCustomIntervalUnit('minute');
|
||||
setSelectedDate(new Date().toISOString().split('T')[0]);
|
||||
setSelectedTime('09:00');
|
||||
setSelectedMinute('0');
|
||||
setSelectedDaysOfWeek(new Set(['1']));
|
||||
setSelectedDayOfMonth('1');
|
||||
setInternalValidationError(null);
|
||||
setReadableCronExpression('');
|
||||
};
|
||||
|
||||
const handleBrowseFile = async () => {
|
||||
// Default to global recipes directory, but fallback to local if needed
|
||||
const defaultPath = getStorageDirectory(true);
|
||||
const filePath = await window.electron.selectFileOrDirectory(defaultPath);
|
||||
if (filePath) {
|
||||
if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) {
|
||||
setRecipeSourcePath(filePath);
|
||||
setInternalValidationError(null);
|
||||
} else {
|
||||
setInternalValidationError('Invalid file type: Please select a YAML file (.yaml or .yml)');
|
||||
console.warn('Invalid file type: Please select a YAML file (.yaml or .yml)');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const generateCronExpression = (): string => {
|
||||
const timeParts = selectedTime.split(':');
|
||||
const minutePart = timeParts.length > 1 ? String(parseInt(timeParts[1], 10)) : '0';
|
||||
const hourPart = timeParts.length > 0 ? String(parseInt(timeParts[0], 10)) : '0';
|
||||
if (isNaN(parseInt(minutePart)) || isNaN(parseInt(hourPart))) {
|
||||
return 'Invalid time format.';
|
||||
}
|
||||
|
||||
// Temporal uses 5-field cron: minute hour day month dayofweek (no seconds)
|
||||
switch (frequency) {
|
||||
case 'once':
|
||||
if (selectedDate && selectedTime) {
|
||||
try {
|
||||
const dateObj = new Date(`${selectedDate}T${selectedTime}`);
|
||||
if (isNaN(dateObj.getTime())) return "Invalid date/time for 'once'.";
|
||||
return `${dateObj.getMinutes()} ${dateObj.getHours()} ${dateObj.getDate()} ${
|
||||
dateObj.getMonth() + 1
|
||||
} *`;
|
||||
} catch {
|
||||
return "Error parsing date/time for 'once'.";
|
||||
}
|
||||
}
|
||||
return 'Date and Time are required for "Once" frequency.';
|
||||
case 'every': {
|
||||
if (customIntervalValue <= 0) {
|
||||
return 'Custom interval value must be greater than 0.';
|
||||
}
|
||||
switch (customIntervalUnit) {
|
||||
case 'minute':
|
||||
return `*/${customIntervalValue} * * * *`;
|
||||
case 'hour':
|
||||
return `0 */${customIntervalValue} * * *`;
|
||||
case 'day':
|
||||
return `0 0 */${customIntervalValue} * *`;
|
||||
default:
|
||||
return 'Invalid custom interval unit.';
|
||||
}
|
||||
}
|
||||
case 'daily':
|
||||
return `${minutePart} ${hourPart} * * *`;
|
||||
case 'weekly': {
|
||||
if (selectedDaysOfWeek.size === 0) {
|
||||
return 'Select at least one day for weekly frequency.';
|
||||
}
|
||||
const days = Array.from(selectedDaysOfWeek)
|
||||
.sort((a, b) => parseInt(a) - parseInt(b))
|
||||
.join(',');
|
||||
return `${minutePart} ${hourPart} * * ${days}`;
|
||||
}
|
||||
case 'monthly': {
|
||||
const sDayOfMonth = parseInt(selectedDayOfMonth, 10);
|
||||
if (isNaN(sDayOfMonth) || sDayOfMonth < 1 || sDayOfMonth > 31) {
|
||||
return 'Invalid day of month (1-31) for monthly frequency.';
|
||||
}
|
||||
return `${minutePart} ${hourPart} ${sDayOfMonth} * *`;
|
||||
}
|
||||
default:
|
||||
return 'Invalid frequency selected.';
|
||||
}
|
||||
};
|
||||
const cron = generateCronExpression();
|
||||
setDerivedCronExpression(cron);
|
||||
try {
|
||||
if (
|
||||
cron.includes('Invalid') ||
|
||||
cron.includes('required') ||
|
||||
cron.includes('Error') ||
|
||||
cron.includes('Select at least one')
|
||||
) {
|
||||
setReadableCronExpression('Invalid cron details provided.');
|
||||
} else {
|
||||
setReadableCronExpression(cronstrue.toString(cron));
|
||||
}
|
||||
} catch {
|
||||
setReadableCronExpression('Could not parse cron string.');
|
||||
}
|
||||
}, [
|
||||
frequency,
|
||||
customIntervalValue,
|
||||
customIntervalUnit,
|
||||
selectedDate,
|
||||
selectedTime,
|
||||
selectedMinute,
|
||||
selectedDaysOfWeek,
|
||||
selectedDayOfMonth,
|
||||
]);
|
||||
|
||||
const handleDayOfWeekChange = (dayValue: string) => {
|
||||
setSelectedDaysOfWeek((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(dayValue)) {
|
||||
newSet.delete(dayValue);
|
||||
} else {
|
||||
newSet.add(dayValue);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const handleLocalSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setInternalValidationError(null);
|
||||
|
||||
if (!scheduleId.trim()) {
|
||||
setInternalValidationError('Schedule ID is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
let finalRecipeSource = '';
|
||||
|
||||
if (sourceType === 'file') {
|
||||
if (!recipeSourcePath) {
|
||||
setInternalValidationError('Recipe source file is required.');
|
||||
return;
|
||||
}
|
||||
finalRecipeSource = recipeSourcePath;
|
||||
} else if (sourceType === 'deeplink') {
|
||||
if (!deepLinkInput.trim()) {
|
||||
setInternalValidationError('Deep link is required.');
|
||||
return;
|
||||
}
|
||||
if (!parsedRecipe) {
|
||||
setInternalValidationError('Invalid deep link. Please check the format.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert recipe to YAML and save to a temporary file
|
||||
const yamlContent = recipeToYaml(parsedRecipe, executionMode);
|
||||
console.log('Generated YAML content:', yamlContent); // Debug log
|
||||
const tempFileName = `schedule-${scheduleId}-${Date.now()}.yaml`;
|
||||
const tempDir = window.electron.getConfig().GOOSE_WORKING_DIR || '.';
|
||||
const tempFilePath = `${tempDir}/${tempFileName}`;
|
||||
|
||||
// Write the YAML file
|
||||
const writeSuccess = await window.electron.writeFile(tempFilePath, yamlContent);
|
||||
if (!writeSuccess) {
|
||||
setInternalValidationError('Failed to create temporary recipe file.');
|
||||
return;
|
||||
}
|
||||
|
||||
finalRecipeSource = tempFilePath;
|
||||
} catch (error) {
|
||||
console.error('Failed to convert recipe to YAML:', error);
|
||||
setInternalValidationError('Failed to process the recipe from deep link.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!derivedCronExpression ||
|
||||
derivedCronExpression.includes('Invalid') ||
|
||||
derivedCronExpression.includes('required') ||
|
||||
derivedCronExpression.includes('Error') ||
|
||||
derivedCronExpression.includes('Select at least one')
|
||||
) {
|
||||
setInternalValidationError(`Invalid cron expression: ${derivedCronExpression}`);
|
||||
return;
|
||||
}
|
||||
if (frequency === 'weekly' && selectedDaysOfWeek.size === 0) {
|
||||
setInternalValidationError('For weekly frequency, select at least one day.');
|
||||
return;
|
||||
}
|
||||
|
||||
const newSchedulePayload: NewSchedulePayload = {
|
||||
id: scheduleId.trim(),
|
||||
recipe_source: finalRecipeSource,
|
||||
cron: derivedCronExpression,
|
||||
execution_mode: executionMode,
|
||||
};
|
||||
|
||||
await onSubmit(newSchedulePayload);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
resetForm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-40 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md bg-background-default shadow-xl rounded-3xl z-50 flex flex-col max-h-[90vh] overflow-hidden">
|
||||
<div className="px-8 pt-8 pb-4 flex-shrink-0 text-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<img src={ClockIcon} alt="Clock" className="w-11 h-11 mb-2" />
|
||||
<h2 className="text-base font-semibold text-text-prominent">Create New Schedule</h2>
|
||||
<p className="text-base text-text-subtle mt-2 max-w-sm">
|
||||
Create a new schedule using the settings below to do things like automatically run
|
||||
tasks or create files
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
id="new-schedule-form"
|
||||
onSubmit={handleLocalSubmit}
|
||||
className="px-8 py-4 space-y-4 flex-grow overflow-y-auto"
|
||||
>
|
||||
{apiErrorExternally && (
|
||||
<p className="text-text-error text-sm mb-3 p-2 bg-background-error border border-border-error rounded-md">
|
||||
{apiErrorExternally}
|
||||
</p>
|
||||
)}
|
||||
{internalValidationError && (
|
||||
<p className="text-text-error text-sm mb-3 p-2 bg-background-error border border-border-error rounded-md">
|
||||
{internalValidationError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="scheduleId-modal" className={modalLabelClassName}>
|
||||
Name:
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
id="scheduleId-modal"
|
||||
value={scheduleId}
|
||||
onChange={(e) => setScheduleId(e.target.value)}
|
||||
placeholder="e.g., daily-summary-job"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={modalLabelClassName}>Source:</label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex bg-gray-100 dark:bg-gray-700 rounded-full p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSourceType('file')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-full transition-all ${
|
||||
sourceType === 'file'
|
||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
YAML
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSourceType('deeplink')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-full transition-all ${
|
||||
sourceType === 'deeplink'
|
||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Deep link
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{sourceType === 'file' && (
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleBrowseFile}
|
||||
className="w-full justify-center rounded-full"
|
||||
>
|
||||
Browse for YAML file...
|
||||
</Button>
|
||||
{recipeSourcePath && (
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400 italic">
|
||||
Selected: {recipeSourcePath}
|
||||
</p>
|
||||
)}
|
||||
{executionMode === 'foreground' && (
|
||||
<div className="mt-2 p-2 bg-blue-50 dark:bg-blue-900/20 rounded-md border border-blue-200 dark:border-blue-800">
|
||||
<p className="text-xs text-blue-700 dark:text-blue-300">
|
||||
<strong>Note:</strong> For foreground execution with YAML files, add this to
|
||||
your recipe:
|
||||
</p>
|
||||
<pre className="text-xs text-blue-600 dark:text-blue-400 mt-1 font-mono bg-blue-100 dark:bg-blue-900/40 p-1 rounded">
|
||||
{`schedule:
|
||||
foreground: true
|
||||
fallback_to_background: true`}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sourceType === 'deeplink' && (
|
||||
<div>
|
||||
<Input
|
||||
type="text"
|
||||
value={deepLinkInput}
|
||||
onChange={(e) => handleDeepLinkChange(e.target.value)}
|
||||
placeholder="Paste goose://bot or goose://recipe link here..."
|
||||
className="rounded-full"
|
||||
/>
|
||||
{parsedRecipe && (
|
||||
<div className="mt-2 p-2 bg-green-100 dark:bg-green-900/30 rounded-md border border-green-500/50">
|
||||
<p className="text-xs text-green-700 dark:text-green-300 font-medium">
|
||||
✓ Recipe parsed successfully
|
||||
</p>
|
||||
<p className="text-xs text-green-600 dark:text-green-400">
|
||||
Title: {parsedRecipe.title}
|
||||
</p>
|
||||
<p className="text-xs text-green-600 dark:text-green-400">
|
||||
Description: {parsedRecipe.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={modalLabelClassName}>Execution Mode:</label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex bg-gray-100 dark:bg-gray-700 rounded-full p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExecutionMode('background')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-full transition-all ${
|
||||
executionMode === 'background'
|
||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Background
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExecutionMode('foreground')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-full transition-all ${
|
||||
executionMode === 'foreground'
|
||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Foreground
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 px-2">
|
||||
{executionMode === 'background' ? (
|
||||
<p>
|
||||
<strong>Background:</strong> Runs silently in the background without opening a
|
||||
window. Results are saved to session storage.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
<strong>Foreground:</strong> Opens in a desktop window when the Goose app is
|
||||
running. Falls back to background if the app is not available.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="frequency-modal" className={modalLabelClassName}>
|
||||
Frequency:
|
||||
</label>
|
||||
<Select
|
||||
instanceId="frequency-select-modal"
|
||||
options={frequencies}
|
||||
value={frequencies.find((f) => f.value === frequency)}
|
||||
onChange={(newValue: unknown) => {
|
||||
const selectedOption = newValue as FrequencyOption | null;
|
||||
if (selectedOption) setFrequency(selectedOption.value);
|
||||
}}
|
||||
placeholder="Select frequency..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{frequency === 'every' && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="customIntervalValue-modal" className={modalLabelClassName}>
|
||||
Every:
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
id="customIntervalValue-modal"
|
||||
min="1"
|
||||
max="999"
|
||||
value={customIntervalValue}
|
||||
onChange={(e) => setCustomIntervalValue(parseInt(e.target.value) || 1)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="customIntervalUnit-modal" className={modalLabelClassName}>
|
||||
Unit:
|
||||
</label>
|
||||
<Select
|
||||
instanceId="custom-interval-unit-select-modal"
|
||||
options={customIntervalUnits}
|
||||
value={customIntervalUnits.find((u) => u.value === customIntervalUnit)}
|
||||
onChange={(newValue: unknown) => {
|
||||
const selectedUnit = newValue as {
|
||||
value: CustomIntervalUnit;
|
||||
label: string;
|
||||
} | null;
|
||||
if (selectedUnit) setCustomIntervalUnit(selectedUnit.value);
|
||||
}}
|
||||
placeholder="Select unit..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{frequency === 'once' && (
|
||||
<>
|
||||
<div>
|
||||
<label htmlFor="onceDate-modal" className={modalLabelClassName}>
|
||||
Date:
|
||||
</label>
|
||||
<Input
|
||||
type="date"
|
||||
id="onceDate-modal"
|
||||
value={selectedDate}
|
||||
onChange={(e) => setSelectedDate(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="onceTime-modal" className={modalLabelClassName}>
|
||||
Time:
|
||||
</label>
|
||||
<Input
|
||||
type="time"
|
||||
id="onceTime-modal"
|
||||
value={selectedTime}
|
||||
onChange={(e) => setSelectedTime(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{(frequency === 'daily' || frequency === 'weekly' || frequency === 'monthly') && (
|
||||
<div>
|
||||
<label htmlFor="commonTime-modal" className={modalLabelClassName}>
|
||||
Time:
|
||||
</label>
|
||||
<Input
|
||||
type="time"
|
||||
id="commonTime-modal"
|
||||
value={selectedTime}
|
||||
onChange={(e) => setSelectedTime(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{frequency === 'weekly' && (
|
||||
<div>
|
||||
<label className={modalLabelClassName}>Days of Week:</label>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2 mt-1">
|
||||
{daysOfWeekOptions.map((day) => (
|
||||
<label key={day.value} className={checkboxLabelClassName}>
|
||||
<input
|
||||
type="checkbox"
|
||||
value={day.value}
|
||||
checked={selectedDaysOfWeek.has(day.value)}
|
||||
onChange={() => handleDayOfWeekChange(day.value)}
|
||||
className={checkboxInputClassName}
|
||||
/>
|
||||
{day.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{frequency === 'monthly' && (
|
||||
<div>
|
||||
<label htmlFor="monthlyDay-modal" className={modalLabelClassName}>
|
||||
Day of Month (1-31):
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
id="monthlyDay-modal"
|
||||
min="1"
|
||||
max="31"
|
||||
value={selectedDayOfMonth}
|
||||
onChange={(e) => setSelectedDayOfMonth(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 p-3 bg-gray-100 dark:bg-gray-700/50 rounded-md border border-gray-200 dark:border-gray-600">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Generated Cron:{' '}
|
||||
<code className="text-xs bg-gray-200 dark:bg-gray-600 p-1 rounded">
|
||||
{derivedCronExpression}
|
||||
</code>
|
||||
</p>
|
||||
<p className={`${cronPreviewTextColor} mt-2`}>
|
||||
<b>Human Readable:</b> {readableCronExpression}
|
||||
</p>
|
||||
<p className={cronPreviewTextColor}>
|
||||
Syntax: M H D M DoW (M=minute, H=hour, D=day, M=month, DoW=day of week: 0/7=Sun)
|
||||
</p>
|
||||
{frequency === 'once' && (
|
||||
<p className={cronPreviewSpecialNoteColor}>
|
||||
Note: "Once" schedules recur annually. True one-time tasks may need backend deletion
|
||||
after execution.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-[8px] ml-[-24px] mr-[-24px] pt-[16px]">
|
||||
<Button
|
||||
type="submit"
|
||||
form="new-schedule-form"
|
||||
variant="ghost"
|
||||
disabled={isLoadingExternally}
|
||||
className="w-full h-[60px] rounded-none border-t text-gray-900 dark:text-white hover:bg-gray-50 dark:border-gray-600 text-lg font-medium"
|
||||
>
|
||||
{isLoadingExternally ? 'Creating...' : 'Create Schedule'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleClose}
|
||||
disabled={isLoadingExternally}
|
||||
className="w-full h-[60px] rounded-none border-t text-gray-400 hover:bg-gray-50 dark:border-gray-600 text-lg font-regular"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,283 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import cronstrue from 'cronstrue';
|
||||
import { ScheduledJob } from '../../schedule';
|
||||
|
||||
type Period = 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year';
|
||||
|
||||
type ParsedCron = {
|
||||
period: Period;
|
||||
second: string;
|
||||
minute: string;
|
||||
hour: string;
|
||||
dayOfMonth: string;
|
||||
month: string;
|
||||
dayOfWeek: string;
|
||||
};
|
||||
|
||||
interface CronPickerProps {
|
||||
schedule: ScheduledJob | null;
|
||||
onChange: (cron: string) => void;
|
||||
}
|
||||
|
||||
const parseCron = (cron: string): ParsedCron => {
|
||||
const parts = cron.split(' ');
|
||||
if (parts.length === 5) {
|
||||
parts.unshift('0');
|
||||
}
|
||||
if (parts.length !== 6) {
|
||||
return {
|
||||
period: 'day',
|
||||
second: '0',
|
||||
minute: '0',
|
||||
hour: '14',
|
||||
dayOfMonth: '*',
|
||||
month: '*',
|
||||
dayOfWeek: '*',
|
||||
};
|
||||
}
|
||||
|
||||
const [second, minute, hour, dayOfMonth, month, dayOfWeek] = parts;
|
||||
|
||||
if (month !== '*' && dayOfMonth !== '*') {
|
||||
return { period: 'year', second, minute, hour, dayOfMonth, month, dayOfWeek };
|
||||
}
|
||||
if (dayOfMonth !== '*') {
|
||||
return { period: 'month', second, minute, hour, dayOfMonth, month, dayOfWeek };
|
||||
}
|
||||
if (dayOfWeek !== '*') {
|
||||
return { period: 'week', second, minute, hour, dayOfMonth, month, dayOfWeek };
|
||||
}
|
||||
if (hour !== '*') {
|
||||
return { period: 'day', second, minute, hour, dayOfMonth, month, dayOfWeek };
|
||||
}
|
||||
if (minute !== '*') {
|
||||
return { period: 'hour', second, minute, hour, dayOfMonth, month, dayOfWeek };
|
||||
}
|
||||
return { period: 'minute', second, minute, hour, dayOfMonth, month, dayOfWeek };
|
||||
};
|
||||
|
||||
const to24Hour = (hour12: number, isPM: boolean): number => {
|
||||
if (hour12 === 12) {
|
||||
return isPM ? 12 : 0;
|
||||
}
|
||||
return isPM ? hour12 + 12 : hour12;
|
||||
};
|
||||
|
||||
const to12Hour = (hour24: number): { hour: number; isPM: boolean } => {
|
||||
if (hour24 === 0) {
|
||||
return { hour: 12, isPM: false };
|
||||
}
|
||||
if (hour24 === 12) {
|
||||
return { hour: 12, isPM: true };
|
||||
}
|
||||
if (hour24 > 12) {
|
||||
return { hour: hour24 - 12, isPM: true };
|
||||
}
|
||||
return { hour: hour24, isPM: false };
|
||||
};
|
||||
|
||||
export const CronPicker: React.FC<CronPickerProps> = ({ schedule, onChange }) => {
|
||||
const [period, setPeriod] = useState<Period>('day');
|
||||
const [second, setSecond] = useState('0');
|
||||
const [minute, setMinute] = useState('0');
|
||||
const [hour12, setHour12] = useState(2);
|
||||
const [currentCron, setCurrentCron] = useState('');
|
||||
const [isPM, setIsPM] = useState(true);
|
||||
const [dayOfWeek, setDayOfWeek] = useState('1');
|
||||
const [dayOfMonth, setDayOfMonth] = useState('1');
|
||||
const [month, setMonth] = useState('1');
|
||||
|
||||
useEffect(() => {
|
||||
const parsed = parseCron(schedule?.cron || '');
|
||||
setPeriod(parsed.period);
|
||||
setSecond(parsed.second === '*' ? '0' : parsed.second);
|
||||
setMinute(parsed.minute === '*' ? '0' : parsed.minute);
|
||||
const hour24 = parsed.hour === '*' ? 14 : parseInt(parsed.hour, 10);
|
||||
const { hour, isPM: pm } = to12Hour(hour24);
|
||||
setHour12(hour);
|
||||
setIsPM(pm);
|
||||
setDayOfWeek(parsed.dayOfWeek === '*' ? '1' : parsed.dayOfWeek);
|
||||
setDayOfMonth(parsed.dayOfMonth === '*' ? '1' : parsed.dayOfMonth);
|
||||
setMonth(parsed.month === '*' ? '1' : parsed.month);
|
||||
}, [schedule]);
|
||||
|
||||
useEffect(() => {
|
||||
const hour24 = to24Hour(hour12, isPM);
|
||||
let cron: string;
|
||||
|
||||
switch (period) {
|
||||
case 'minute':
|
||||
cron = `${second} * * * * *`;
|
||||
break;
|
||||
case 'hour':
|
||||
cron = `${second} ${minute} * * * *`;
|
||||
break;
|
||||
case 'day':
|
||||
cron = `${second} ${minute} ${hour24} * * *`;
|
||||
break;
|
||||
case 'week':
|
||||
cron = `${second} ${minute} ${hour24} * * ${dayOfWeek}`;
|
||||
break;
|
||||
case 'month':
|
||||
cron = `${second} ${minute} ${hour24} ${dayOfMonth} * *`;
|
||||
break;
|
||||
case 'year':
|
||||
cron = `${second} ${minute} ${hour24} ${dayOfMonth} ${month} *`;
|
||||
break;
|
||||
default:
|
||||
cron = '0 0 0 * * *';
|
||||
}
|
||||
onChange(cron);
|
||||
setCurrentCron(cron);
|
||||
}, [period, second, minute, hour12, isPM, dayOfWeek, dayOfMonth, month, onChange]);
|
||||
|
||||
const getReadable = () => {
|
||||
if (!currentCron) {
|
||||
return '';
|
||||
}
|
||||
const cronWithoutSeconds = currentCron.split(' ').slice(1).join(' ');
|
||||
return cronstrue.toString(cronWithoutSeconds);
|
||||
};
|
||||
|
||||
const selectClassName = 'px-2 py-1 border rounded bg-white dark:bg-gray-800 dark:border-gray-600';
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">Every</span>
|
||||
<select
|
||||
value={period}
|
||||
onChange={(e) => setPeriod(e.target.value as Period)}
|
||||
className={selectClassName}
|
||||
>
|
||||
<option value="minute">Minute</option>
|
||||
<option value="hour">Hour</option>
|
||||
<option value="day">Day</option>
|
||||
<option value="week">Week</option>
|
||||
<option value="month">Month</option>
|
||||
<option value="year">Year</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{period === 'year' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">in</span>
|
||||
<select
|
||||
value={month}
|
||||
onChange={(e) => setMonth(e.target.value)}
|
||||
className={selectClassName}
|
||||
>
|
||||
<option value="1">January</option>
|
||||
<option value="2">February</option>
|
||||
<option value="3">March</option>
|
||||
<option value="4">April</option>
|
||||
<option value="5">May</option>
|
||||
<option value="6">June</option>
|
||||
<option value="7">July</option>
|
||||
<option value="8">August</option>
|
||||
<option value="9">September</option>
|
||||
<option value="10">October</option>
|
||||
<option value="11">November</option>
|
||||
<option value="12">December</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(period === 'month' || period === 'year') && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">on day</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="31"
|
||||
value={dayOfMonth}
|
||||
onChange={(e) => setDayOfMonth(e.target.value)}
|
||||
className="w-16 px-2 py-1 border rounded"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{period === 'week' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">on</span>
|
||||
<select
|
||||
value={dayOfWeek}
|
||||
onChange={(e) => setDayOfWeek(e.target.value)}
|
||||
className={selectClassName}
|
||||
>
|
||||
<option value="0">Sunday</option>
|
||||
<option value="1">Monday</option>
|
||||
<option value="2">Tuesday</option>
|
||||
<option value="3">Wednesday</option>
|
||||
<option value="4">Thursday</option>
|
||||
<option value="5">Friday</option>
|
||||
<option value="6">Saturday</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(period === 'day' || period === 'week' || period === 'month' || period === 'year') && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">at</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="12"
|
||||
value={hour12}
|
||||
onChange={(e) => setHour12(parseInt(e.target.value) || 1)}
|
||||
className="w-16 px-2 py-1 border rounded"
|
||||
/>
|
||||
<span className="text-sm">:</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="59"
|
||||
value={minute}
|
||||
onChange={(e) => setMinute(e.target.value.padStart(2, '0'))}
|
||||
className="w-16 px-2 py-1 border rounded"
|
||||
/>
|
||||
<select
|
||||
value={isPM ? 'PM' : 'AM'}
|
||||
onChange={(e) => setIsPM(e.target.value === 'PM')}
|
||||
className={selectClassName}
|
||||
>
|
||||
<option value="AM">AM</option>
|
||||
<option value="PM">PM</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{period === 'hour' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">at minute</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="59"
|
||||
value={minute}
|
||||
onChange={(e) => setMinute(e.target.value)}
|
||||
className="w-16 px-2 py-1 border rounded"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{period === 'minute' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">at second</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="59"
|
||||
value={second}
|
||||
onChange={(e) => setSecond(e.target.value)}
|
||||
className="w-16 px-2 py-1 border rounded"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500 mt-2">{getReadable()}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,551 +0,0 @@
|
||||
import React, { useState, useEffect, FormEvent } from 'react';
|
||||
import { Card } from '../ui/card';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Select } from '../ui/Select';
|
||||
import { ScheduledJob } from '../../schedule';
|
||||
import cronstrue from 'cronstrue';
|
||||
|
||||
type FrequencyValue = 'once' | 'every' | 'daily' | 'weekly' | 'monthly';
|
||||
|
||||
type CustomIntervalUnit = 'minute' | 'hour' | 'day';
|
||||
|
||||
interface FrequencyOption {
|
||||
value: FrequencyValue;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface EditScheduleModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (cron: string) => Promise<void>;
|
||||
schedule: ScheduledJob | null;
|
||||
isLoadingExternally?: boolean;
|
||||
apiErrorExternally?: string | null;
|
||||
}
|
||||
|
||||
const frequencies: FrequencyOption[] = [
|
||||
{ value: 'once', label: 'Once' },
|
||||
{ value: 'every', label: 'Every...' },
|
||||
{ value: 'daily', label: 'Daily (at specific time)' },
|
||||
{ value: 'weekly', label: 'Weekly (at specific time/days)' },
|
||||
{ value: 'monthly', label: 'Monthly (at specific time/day)' },
|
||||
];
|
||||
|
||||
const customIntervalUnits: { value: CustomIntervalUnit; label: string }[] = [
|
||||
{ value: 'minute', label: 'minute(s)' },
|
||||
{ value: 'hour', label: 'hour(s)' },
|
||||
{ value: 'day', label: 'day(s)' },
|
||||
];
|
||||
|
||||
const daysOfWeekOptions: { value: string; label: string }[] = [
|
||||
{ value: '1', label: 'Mon' },
|
||||
{ value: '2', label: 'Tue' },
|
||||
{ value: '3', label: 'Wed' },
|
||||
{ value: '4', label: 'Thu' },
|
||||
{ value: '5', label: 'Fri' },
|
||||
{ value: '6', label: 'Sat' },
|
||||
{ value: '0', label: 'Sun' },
|
||||
];
|
||||
|
||||
const modalLabelClassName = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1';
|
||||
const cronPreviewTextColor = 'text-xs text-gray-500 dark:text-gray-400 mt-1';
|
||||
const cronPreviewSpecialNoteColor = 'text-xs text-yellow-600 dark:text-yellow-500 mt-1';
|
||||
const checkboxLabelClassName = 'flex items-center text-sm text-textStandard dark:text-gray-300';
|
||||
const checkboxInputClassName =
|
||||
'h-4 w-4 text-indigo-600 border-gray-300 dark:border-gray-600 rounded focus:ring-indigo-500 mr-2';
|
||||
|
||||
// Helper function to parse cron expression and determine frequency
|
||||
const parseCronExpression = (cron: string) => {
|
||||
const parts = cron.split(' ');
|
||||
if (parts.length !== 5 && parts.length !== 6) return null;
|
||||
|
||||
// Handle both 5-field and 6-field cron expressions
|
||||
const [minutes, hours, dayOfMonth, month, dayOfWeek] =
|
||||
parts.length === 5 ? parts : parts.slice(1); // Skip seconds if present
|
||||
|
||||
// Check for custom intervals (every X minutes/hours/days)
|
||||
if (
|
||||
minutes.startsWith('*/') &&
|
||||
hours === '*' &&
|
||||
dayOfMonth === '*' &&
|
||||
month === '*' &&
|
||||
dayOfWeek === '*'
|
||||
) {
|
||||
const intervalValue = parseInt(minutes.substring(2));
|
||||
return {
|
||||
frequency: 'every' as FrequencyValue,
|
||||
customIntervalValue: intervalValue,
|
||||
customIntervalUnit: 'minute' as CustomIntervalUnit,
|
||||
};
|
||||
}
|
||||
if (
|
||||
minutes === '0' &&
|
||||
hours.startsWith('*/') &&
|
||||
dayOfMonth === '*' &&
|
||||
month === '*' &&
|
||||
dayOfWeek === '*'
|
||||
) {
|
||||
const intervalValue = parseInt(hours.substring(2));
|
||||
return {
|
||||
frequency: 'every' as FrequencyValue,
|
||||
customIntervalValue: intervalValue,
|
||||
customIntervalUnit: 'hour' as CustomIntervalUnit,
|
||||
};
|
||||
}
|
||||
if (
|
||||
minutes === '0' &&
|
||||
hours === '0' &&
|
||||
dayOfMonth.startsWith('*/') &&
|
||||
month === '*' &&
|
||||
dayOfWeek === '*'
|
||||
) {
|
||||
const intervalValue = parseInt(dayOfMonth.substring(2));
|
||||
return {
|
||||
frequency: 'every' as FrequencyValue,
|
||||
customIntervalValue: intervalValue,
|
||||
customIntervalUnit: 'day' as CustomIntervalUnit,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for specific patterns
|
||||
if (dayOfMonth !== '*' && month !== '*' && dayOfWeek === '*') {
|
||||
return { frequency: 'once' as FrequencyValue, minutes, hours, dayOfMonth, month };
|
||||
}
|
||||
if (
|
||||
minutes !== '*' &&
|
||||
hours !== '*' &&
|
||||
dayOfMonth === '*' &&
|
||||
month === '*' &&
|
||||
dayOfWeek === '*'
|
||||
) {
|
||||
return { frequency: 'daily' as FrequencyValue, minutes, hours };
|
||||
}
|
||||
if (
|
||||
minutes !== '*' &&
|
||||
hours !== '*' &&
|
||||
dayOfMonth === '*' &&
|
||||
month === '*' &&
|
||||
dayOfWeek !== '*'
|
||||
) {
|
||||
return { frequency: 'weekly' as FrequencyValue, minutes, hours, dayOfWeek };
|
||||
}
|
||||
if (
|
||||
minutes !== '*' &&
|
||||
hours !== '*' &&
|
||||
dayOfMonth !== '*' &&
|
||||
month === '*' &&
|
||||
dayOfWeek === '*'
|
||||
) {
|
||||
return { frequency: 'monthly' as FrequencyValue, minutes, hours, dayOfMonth };
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const EditScheduleModal: React.FC<EditScheduleModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
schedule,
|
||||
isLoadingExternally = false,
|
||||
apiErrorExternally = null,
|
||||
}) => {
|
||||
const [frequency, setFrequency] = useState<FrequencyValue>('daily');
|
||||
const [customIntervalValue, setCustomIntervalValue] = useState<number>(1);
|
||||
const [customIntervalUnit, setCustomIntervalUnit] = useState<CustomIntervalUnit>('minute');
|
||||
const [selectedDate, setSelectedDate] = useState<string>(
|
||||
() => new Date().toISOString().split('T')[0]
|
||||
);
|
||||
const [selectedTime, setSelectedTime] = useState<string>('09:00');
|
||||
const [selectedMinute] = useState<string>('0');
|
||||
const [selectedDaysOfWeek, setSelectedDaysOfWeek] = useState<Set<string>>(new Set(['1']));
|
||||
const [selectedDayOfMonth, setSelectedDayOfMonth] = useState<string>('1');
|
||||
const [derivedCronExpression, setDerivedCronExpression] = useState<string>('');
|
||||
const [readableCronExpression, setReadableCronExpression] = useState<string>('');
|
||||
const [internalValidationError, setInternalValidationError] = useState<string | null>(null);
|
||||
|
||||
// Initialize form from existing schedule
|
||||
useEffect(() => {
|
||||
if (schedule && isOpen) {
|
||||
const parsed = parseCronExpression(schedule.cron);
|
||||
|
||||
if (parsed) {
|
||||
setFrequency(parsed.frequency);
|
||||
|
||||
switch (parsed.frequency) {
|
||||
case 'once':
|
||||
// For 'once', we'd need to reconstruct the date from cron parts
|
||||
// This is complex, so we'll default to current date/time for now
|
||||
setSelectedDate(new Date().toISOString().split('T')[0]);
|
||||
setSelectedTime(
|
||||
`${parsed.hours?.padStart(2, '0')}:${parsed.minutes?.padStart(2, '0')}`
|
||||
);
|
||||
break;
|
||||
case 'every':
|
||||
if (parsed.customIntervalValue) {
|
||||
setCustomIntervalValue(parsed.customIntervalValue);
|
||||
}
|
||||
if (parsed.customIntervalUnit) {
|
||||
setCustomIntervalUnit(parsed.customIntervalUnit);
|
||||
}
|
||||
break;
|
||||
case 'daily':
|
||||
setSelectedTime(
|
||||
`${parsed.hours?.padStart(2, '0')}:${parsed.minutes?.padStart(2, '0')}`
|
||||
);
|
||||
break;
|
||||
case 'weekly':
|
||||
setSelectedTime(
|
||||
`${parsed.hours?.padStart(2, '0')}:${parsed.minutes?.padStart(2, '0')}`
|
||||
);
|
||||
if (parsed.dayOfWeek) {
|
||||
const days = parsed.dayOfWeek.split(',').map((d) => d.trim());
|
||||
setSelectedDaysOfWeek(new Set(days));
|
||||
}
|
||||
break;
|
||||
case 'monthly':
|
||||
setSelectedTime(
|
||||
`${parsed.hours?.padStart(2, '0')}:${parsed.minutes?.padStart(2, '0')}`
|
||||
);
|
||||
setSelectedDayOfMonth(parsed.dayOfMonth || '1');
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// If we can't parse the cron, default to daily at 9 AM
|
||||
setFrequency('daily');
|
||||
setSelectedTime('09:00');
|
||||
}
|
||||
|
||||
setInternalValidationError(null);
|
||||
}
|
||||
}, [schedule, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const generateCronExpression = (): string => {
|
||||
const timeParts = selectedTime.split(':');
|
||||
const minutePart = timeParts.length > 1 ? String(parseInt(timeParts[1], 10)) : '0';
|
||||
const hourPart = timeParts.length > 0 ? String(parseInt(timeParts[0], 10)) : '0';
|
||||
if (isNaN(parseInt(minutePart)) || isNaN(parseInt(hourPart))) {
|
||||
return 'Invalid time format.';
|
||||
}
|
||||
switch (frequency) {
|
||||
case 'once':
|
||||
if (selectedDate && selectedTime) {
|
||||
try {
|
||||
const dateObj = new Date(`${selectedDate}T${selectedTime}`);
|
||||
if (isNaN(dateObj.getTime())) return "Invalid date/time for 'once'.";
|
||||
return `${dateObj.getMinutes()} ${dateObj.getHours()} ${dateObj.getDate()} ${
|
||||
dateObj.getMonth() + 1
|
||||
} *`;
|
||||
} catch {
|
||||
return "Error parsing date/time for 'once'.";
|
||||
}
|
||||
}
|
||||
return 'Date and Time are required for "Once" frequency.';
|
||||
case 'every': {
|
||||
if (customIntervalValue <= 0) {
|
||||
return 'Custom interval value must be greater than 0.';
|
||||
}
|
||||
switch (customIntervalUnit) {
|
||||
case 'minute':
|
||||
return `*/${customIntervalValue} * * * *`;
|
||||
case 'hour':
|
||||
return `0 */${customIntervalValue} * * *`;
|
||||
case 'day':
|
||||
return `0 0 */${customIntervalValue} * *`;
|
||||
default:
|
||||
return 'Invalid custom interval unit.';
|
||||
}
|
||||
}
|
||||
case 'daily':
|
||||
return `${minutePart} ${hourPart} * * *`;
|
||||
case 'weekly': {
|
||||
if (selectedDaysOfWeek.size === 0) {
|
||||
return 'Select at least one day for weekly frequency.';
|
||||
}
|
||||
const days = Array.from(selectedDaysOfWeek)
|
||||
.sort((a, b) => parseInt(a) - parseInt(b))
|
||||
.join(',');
|
||||
return `${minutePart} ${hourPart} * * ${days}`;
|
||||
}
|
||||
case 'monthly': {
|
||||
const sDayOfMonth = parseInt(selectedDayOfMonth, 10);
|
||||
if (isNaN(sDayOfMonth) || sDayOfMonth < 1 || sDayOfMonth > 31) {
|
||||
return 'Invalid day of month (1-31) for monthly frequency.';
|
||||
}
|
||||
return `${minutePart} ${hourPart} ${sDayOfMonth} * *`;
|
||||
}
|
||||
default:
|
||||
return 'Invalid frequency selected.';
|
||||
}
|
||||
};
|
||||
const cron = generateCronExpression();
|
||||
setDerivedCronExpression(cron);
|
||||
try {
|
||||
if (
|
||||
cron.includes('Invalid') ||
|
||||
cron.includes('required') ||
|
||||
cron.includes('Error') ||
|
||||
cron.includes('Select at least one')
|
||||
) {
|
||||
setReadableCronExpression('Invalid cron details provided.');
|
||||
} else {
|
||||
setReadableCronExpression(cronstrue.toString(cron));
|
||||
}
|
||||
} catch {
|
||||
setReadableCronExpression('Could not parse cron string.');
|
||||
}
|
||||
}, [
|
||||
frequency,
|
||||
customIntervalValue,
|
||||
customIntervalUnit,
|
||||
selectedDate,
|
||||
selectedTime,
|
||||
selectedMinute,
|
||||
selectedDaysOfWeek,
|
||||
selectedDayOfMonth,
|
||||
]);
|
||||
|
||||
const handleDayOfWeekChange = (dayValue: string) => {
|
||||
setSelectedDaysOfWeek((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(dayValue)) {
|
||||
newSet.delete(dayValue);
|
||||
} else {
|
||||
newSet.add(dayValue);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const handleLocalSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setInternalValidationError(null);
|
||||
|
||||
if (
|
||||
!derivedCronExpression ||
|
||||
derivedCronExpression.includes('Invalid') ||
|
||||
derivedCronExpression.includes('required') ||
|
||||
derivedCronExpression.includes('Error') ||
|
||||
derivedCronExpression.includes('Select at least one')
|
||||
) {
|
||||
setInternalValidationError(`Invalid cron expression: ${derivedCronExpression}`);
|
||||
return;
|
||||
}
|
||||
if (frequency === 'weekly' && selectedDaysOfWeek.size === 0) {
|
||||
setInternalValidationError('For weekly frequency, select at least one day.');
|
||||
return;
|
||||
}
|
||||
|
||||
await onSubmit(derivedCronExpression);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-40 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md bg-background-default shadow-xl rounded-lg z-50 flex flex-col max-h-[90vh] overflow-hidden">
|
||||
<div className="px-6 pt-6 pb-4 flex-shrink-0">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Edit Schedule: {schedule?.id || ''}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<form
|
||||
id="edit-schedule-form"
|
||||
onSubmit={handleLocalSubmit}
|
||||
className="px-6 py-4 space-y-4 flex-grow overflow-y-auto"
|
||||
>
|
||||
{apiErrorExternally && (
|
||||
<p className="text-red-500 text-sm mb-3 p-2 bg-red-100 dark:bg-red-900/30 rounded-md border border-red-500/50">
|
||||
{apiErrorExternally}
|
||||
</p>
|
||||
)}
|
||||
{internalValidationError && (
|
||||
<p className="text-red-500 text-sm mb-3 p-2 bg-red-100 dark:bg-red-900/30 rounded-md border border-red-500/50">
|
||||
{internalValidationError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="frequency-modal" className={modalLabelClassName}>
|
||||
Frequency:
|
||||
</label>
|
||||
<Select
|
||||
instanceId="frequency-select-modal"
|
||||
options={frequencies}
|
||||
value={frequencies.find((f) => f.value === frequency)}
|
||||
onChange={(newValue: unknown) => {
|
||||
const selectedOption = newValue as FrequencyOption | null;
|
||||
if (selectedOption) setFrequency(selectedOption.value);
|
||||
}}
|
||||
placeholder="Select frequency..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{frequency === 'every' && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="customIntervalValue-modal" className={modalLabelClassName}>
|
||||
Every:
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
id="customIntervalValue-modal"
|
||||
min="1"
|
||||
max="999"
|
||||
value={customIntervalValue}
|
||||
onChange={(e) => setCustomIntervalValue(parseInt(e.target.value) || 1)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="customIntervalUnit-modal" className={modalLabelClassName}>
|
||||
Unit:
|
||||
</label>
|
||||
<Select
|
||||
instanceId="custom-interval-unit-select-modal"
|
||||
options={customIntervalUnits}
|
||||
value={customIntervalUnits.find((u) => u.value === customIntervalUnit)}
|
||||
onChange={(newValue: unknown) => {
|
||||
const selectedUnit = newValue as {
|
||||
value: CustomIntervalUnit;
|
||||
label: string;
|
||||
} | null;
|
||||
if (selectedUnit) setCustomIntervalUnit(selectedUnit.value);
|
||||
}}
|
||||
placeholder="Select unit..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{frequency === 'once' && (
|
||||
<>
|
||||
<div>
|
||||
<label htmlFor="onceDate-modal" className={modalLabelClassName}>
|
||||
Date:
|
||||
</label>
|
||||
<Input
|
||||
type="date"
|
||||
id="onceDate-modal"
|
||||
value={selectedDate}
|
||||
onChange={(e) => setSelectedDate(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="onceTime-modal" className={modalLabelClassName}>
|
||||
Time:
|
||||
</label>
|
||||
<Input
|
||||
type="time"
|
||||
id="onceTime-modal"
|
||||
value={selectedTime}
|
||||
onChange={(e) => setSelectedTime(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{(frequency === 'daily' || frequency === 'weekly' || frequency === 'monthly') && (
|
||||
<div>
|
||||
<label htmlFor="commonTime-modal" className={modalLabelClassName}>
|
||||
Time:
|
||||
</label>
|
||||
<Input
|
||||
type="time"
|
||||
id="commonTime-modal"
|
||||
value={selectedTime}
|
||||
onChange={(e) => setSelectedTime(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{frequency === 'weekly' && (
|
||||
<div>
|
||||
<label className={modalLabelClassName}>Days of Week:</label>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2 mt-1">
|
||||
{daysOfWeekOptions.map((day) => (
|
||||
<label key={day.value} className={checkboxLabelClassName}>
|
||||
<input
|
||||
type="checkbox"
|
||||
value={day.value}
|
||||
checked={selectedDaysOfWeek.has(day.value)}
|
||||
onChange={() => handleDayOfWeekChange(day.value)}
|
||||
className={checkboxInputClassName}
|
||||
/>
|
||||
{day.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{frequency === 'monthly' && (
|
||||
<div>
|
||||
<label htmlFor="monthlyDay-modal" className={modalLabelClassName}>
|
||||
Day of Month (1-31):
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
id="monthlyDay-modal"
|
||||
min="1"
|
||||
max="31"
|
||||
value={selectedDayOfMonth}
|
||||
onChange={(e) => setSelectedDayOfMonth(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 p-3 bg-gray-100 dark:bg-gray-700/50 rounded-md border border-gray-200 dark:border-gray-600">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Generated Cron:{' '}
|
||||
<code className="text-xs bg-gray-200 dark:bg-gray-600 p-1 rounded">
|
||||
{derivedCronExpression}
|
||||
</code>
|
||||
</p>
|
||||
<p className={`${cronPreviewTextColor} mt-2`}>
|
||||
<b>Human Readable:</b> {readableCronExpression}
|
||||
</p>
|
||||
<p className={cronPreviewTextColor}>
|
||||
Syntax: M H D M DoW (M=minute, H=hour, D=day, M=month, DoW=day of week: 0/7=Sun)
|
||||
</p>
|
||||
{frequency === 'once' && (
|
||||
<p className={cronPreviewSpecialNoteColor}>
|
||||
Note: "Once" schedules recur annually. True one-time tasks may need backend deletion
|
||||
after execution.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-[8px] ml-[-24px] mr-[-24px] pt-[16px]">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleClose}
|
||||
disabled={isLoadingExternally}
|
||||
className="w-full h-[60px] rounded-none border-t text-gray-400 hover:bg-gray-50 dark:border-gray-600 text-lg font-regular"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="edit-schedule-form"
|
||||
variant="ghost"
|
||||
disabled={isLoadingExternally}
|
||||
className="w-full h-[60px] rounded-none border-t text-gray-900 dark:text-white hover:bg-gray-50 dark:border-gray-600 text-lg font-medium"
|
||||
>
|
||||
{isLoadingExternally ? 'Updating...' : 'Update Schedule'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Button } from '../ui/button';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import BackButton from '../ui/BackButton';
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
ScheduledJob,
|
||||
} from '../../schedule';
|
||||
import SessionHistoryView from '../sessions/SessionHistoryView';
|
||||
import { EditScheduleModal } from './EditScheduleModal';
|
||||
import { ScheduleModal, NewSchedulePayload } from './ScheduleModal';
|
||||
import { toastError, toastSuccess } from '../../toasts';
|
||||
import { Loader2, Pause, Play, Edit, Square, Eye } from 'lucide-react';
|
||||
import cronstrue from 'cronstrue';
|
||||
@@ -42,292 +42,125 @@ interface ScheduleDetailViewProps {
|
||||
onNavigateBack: () => void;
|
||||
}
|
||||
|
||||
// Memoized ScheduleInfoCard component to prevent unnecessary re-renders of static content
|
||||
const ScheduleInfoCard = React.memo<{
|
||||
scheduleDetails: ScheduledJob;
|
||||
}>(({ scheduleDetails }) => {
|
||||
const readableCron = useMemo(() => {
|
||||
try {
|
||||
return cronstrue.toString(scheduleDetails.cron);
|
||||
} catch (e) {
|
||||
console.warn(`Could not parse cron string "${scheduleDetails.cron}":`, e);
|
||||
return scheduleDetails.cron;
|
||||
}
|
||||
}, [scheduleDetails.cron]);
|
||||
|
||||
const formattedLastRun = useMemo(() => {
|
||||
return formatToLocalDateWithTimezone(scheduleDetails.last_run);
|
||||
}, [scheduleDetails.last_run]);
|
||||
|
||||
const formattedProcessStartTime = useMemo(() => {
|
||||
return scheduleDetails.process_start_time
|
||||
? formatToLocalDateWithTimezone(scheduleDetails.process_start_time)
|
||||
: null;
|
||||
}, [scheduleDetails.process_start_time]);
|
||||
|
||||
return (
|
||||
<Card className="p-4 bg-background-card shadow mb-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-text-prominent">{scheduleDetails.id}</h3>
|
||||
<div className="mt-2 md:mt-0 flex items-center gap-2">
|
||||
{scheduleDetails.currently_running && (
|
||||
<div className="text-sm text-green-500 dark:text-green-400 font-semibold flex items-center">
|
||||
<span className="inline-block w-2 h-2 bg-green-500 dark:bg-green-400 rounded-full mr-1 animate-pulse"></span>
|
||||
Currently Running
|
||||
</div>
|
||||
)}
|
||||
{scheduleDetails.paused && (
|
||||
<div className="text-sm text-orange-500 dark:text-orange-400 font-semibold flex items-center">
|
||||
<Pause className="w-3 h-3 mr-1" />
|
||||
Paused
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-text-default">
|
||||
<span className="font-semibold">Schedule:</span> {readableCron}
|
||||
</p>
|
||||
<p className="text-sm text-text-default">
|
||||
<span className="font-semibold">Cron Expression:</span> {scheduleDetails.cron}
|
||||
</p>
|
||||
<p className="text-sm text-text-default">
|
||||
<span className="font-semibold">Recipe Source:</span> {scheduleDetails.source}
|
||||
</p>
|
||||
<p className="text-sm text-text-default">
|
||||
<span className="font-semibold">Last Run:</span> {formattedLastRun}
|
||||
</p>
|
||||
{scheduleDetails.execution_mode && (
|
||||
<p className="text-sm text-text-default">
|
||||
<span className="font-semibold">Execution Mode:</span>{' '}
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
scheduleDetails.execution_mode === 'foreground'
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{scheduleDetails.execution_mode === 'foreground' ? '🖥️ Foreground' : '⚡ Background'}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{scheduleDetails.currently_running && scheduleDetails.current_session_id && (
|
||||
<p className="text-sm text-text-default">
|
||||
<span className="font-semibold">Current Session:</span>{' '}
|
||||
{scheduleDetails.current_session_id}
|
||||
</p>
|
||||
)}
|
||||
{scheduleDetails.currently_running && formattedProcessStartTime && (
|
||||
<p className="text-sm text-text-default">
|
||||
<span className="font-semibold">Process Started:</span> {formattedProcessStartTime}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
ScheduleInfoCard.displayName = 'ScheduleInfoCard';
|
||||
|
||||
const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onNavigateBack }) => {
|
||||
const [sessions, setSessions] = useState<ScheduleSessionMeta[]>([]);
|
||||
const [isLoadingSessions, setIsLoadingSessions] = useState(false);
|
||||
const [sessionsError, setSessionsError] = useState<string | null>(null);
|
||||
const [runNowLoading, setRunNowLoading] = useState(false);
|
||||
|
||||
const [scheduleDetails, setScheduleDetails] = useState<ScheduledJob | null>(null);
|
||||
const [isLoadingSchedule, setIsLoadingSchedule] = useState(false);
|
||||
const [scheduleError, setScheduleError] = useState<string | null>(null);
|
||||
|
||||
// Individual loading states for each action to prevent double-clicks
|
||||
const [pauseUnpauseLoading, setPauseUnpauseLoading] = useState(false);
|
||||
const [killJobLoading, setKillJobLoading] = useState(false);
|
||||
const [inspectJobLoading, setInspectJobLoading] = useState(false);
|
||||
const [isActionLoading, setIsActionLoading] = useState(false);
|
||||
|
||||
// Track if we explicitly killed a job to distinguish from natural completion
|
||||
const [jobWasKilled, setJobWasKilled] = useState(false);
|
||||
const [selectedSession, setSelectedSession] = useState<Session | null>(null);
|
||||
const [isLoadingSession, setIsLoadingSession] = useState(false);
|
||||
const [sessionError, setSessionError] = useState<string | null>(null);
|
||||
|
||||
const [selectedSessionDetails, setSelectedSessionDetails] = useState<Session | null>(null);
|
||||
const [isLoadingSessionDetails, setIsLoadingSessionDetails] = useState(false);
|
||||
const [sessionDetailsError, setSessionDetailsError] = useState<string | null>(null);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [editApiError, setEditApiError] = useState<string | null>(null);
|
||||
const [isEditSubmitting, setIsEditSubmitting] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const fetchScheduleSessions = useCallback(async (sId: string) => {
|
||||
if (!sId) return;
|
||||
const fetchSessions = async (sId: string) => {
|
||||
setIsLoadingSessions(true);
|
||||
setSessionsError(null);
|
||||
try {
|
||||
const fetchedSessions = await getScheduleSessions(sId, 20);
|
||||
setSessions((prevSessions) => {
|
||||
// Only update if sessions actually changed to prevent unnecessary re-renders
|
||||
if (JSON.stringify(prevSessions) !== JSON.stringify(fetchedSessions)) {
|
||||
return fetchedSessions as ScheduleSessionMeta[];
|
||||
}
|
||||
return prevSessions;
|
||||
});
|
||||
const data = await getScheduleSessions(sId, 20);
|
||||
setSessions(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch schedule sessions:', err);
|
||||
setSessionsError(err instanceof Error ? err.message : 'Failed to fetch schedule sessions');
|
||||
setSessionsError(err instanceof Error ? err.message : 'Failed to fetch sessions');
|
||||
} finally {
|
||||
setIsLoadingSessions(false);
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
|
||||
const fetchScheduleDetails = useCallback(
|
||||
async (sId: string, isRefresh = false) => {
|
||||
if (!sId) return;
|
||||
if (!isRefresh) setIsLoadingSchedule(true);
|
||||
setScheduleError(null);
|
||||
try {
|
||||
const allSchedules = await listSchedules();
|
||||
const schedule = allSchedules.find((s) => s.id === sId);
|
||||
if (schedule) {
|
||||
setScheduleDetails((prevDetails) => {
|
||||
// Only update if schedule details actually changed
|
||||
if (!prevDetails || JSON.stringify(prevDetails) !== JSON.stringify(schedule)) {
|
||||
// Only reset runNowLoading if we explicitly killed the job
|
||||
if (!schedule.currently_running && runNowLoading && jobWasKilled) {
|
||||
setRunNowLoading(false);
|
||||
setJobWasKilled(false);
|
||||
}
|
||||
return schedule;
|
||||
}
|
||||
return prevDetails;
|
||||
});
|
||||
} else {
|
||||
setScheduleError('Schedule not found');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch schedule details:', err);
|
||||
setScheduleError(err instanceof Error ? err.message : 'Failed to fetch schedule details');
|
||||
} finally {
|
||||
if (!isRefresh) setIsLoadingSchedule(false);
|
||||
const fetchSchedule = async (sId: string) => {
|
||||
setIsLoadingSchedule(true);
|
||||
setScheduleError(null);
|
||||
try {
|
||||
const allSchedules = await listSchedules();
|
||||
const schedule = allSchedules.find((s) => s.id === sId);
|
||||
if (schedule) {
|
||||
setScheduleDetails(schedule);
|
||||
} else {
|
||||
setScheduleError('Schedule not found');
|
||||
}
|
||||
},
|
||||
[runNowLoading, jobWasKilled]
|
||||
);
|
||||
} catch (err) {
|
||||
setScheduleError(err instanceof Error ? err.message : 'Failed to fetch schedule');
|
||||
} finally {
|
||||
setIsLoadingSchedule(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (scheduleId && !selectedSessionDetails) {
|
||||
fetchScheduleSessions(scheduleId);
|
||||
fetchScheduleDetails(scheduleId);
|
||||
} else if (!scheduleId) {
|
||||
setSessions([]);
|
||||
setSessionsError(null);
|
||||
setRunNowLoading(false);
|
||||
setSelectedSessionDetails(null);
|
||||
setScheduleDetails(null);
|
||||
setScheduleError(null);
|
||||
setJobWasKilled(false); // Reset kill flag when changing schedules
|
||||
if (scheduleId && !selectedSession) {
|
||||
fetchSessions(scheduleId);
|
||||
fetchSchedule(scheduleId);
|
||||
}
|
||||
}, [scheduleId, fetchScheduleSessions, fetchScheduleDetails, selectedSessionDetails]);
|
||||
}, [scheduleId, selectedSession]);
|
||||
|
||||
const handleRunNow = async () => {
|
||||
if (!scheduleId) return;
|
||||
setRunNowLoading(true);
|
||||
setIsActionLoading(true);
|
||||
try {
|
||||
const newSessionId = await runScheduleNow(scheduleId);
|
||||
if (newSessionId === 'CANCELLED') {
|
||||
toastSuccess({
|
||||
title: 'Job Cancelled',
|
||||
msg: 'The job was cancelled while starting up.',
|
||||
});
|
||||
toastSuccess({ title: 'Job Cancelled', msg: 'The job was cancelled while starting up.' });
|
||||
} else {
|
||||
toastSuccess({
|
||||
title: 'Schedule Triggered',
|
||||
msg: `Successfully triggered schedule. New session ID: ${newSessionId}`,
|
||||
});
|
||||
toastSuccess({ title: 'Schedule Triggered', msg: `New session: ${newSessionId}` });
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (scheduleId) {
|
||||
fetchScheduleSessions(scheduleId);
|
||||
fetchScheduleDetails(scheduleId);
|
||||
}
|
||||
}, 1000);
|
||||
await fetchSessions(scheduleId);
|
||||
await fetchSchedule(scheduleId);
|
||||
} catch (err) {
|
||||
console.error('Failed to run schedule now:', err);
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to trigger schedule';
|
||||
toastError({ title: 'Run Schedule Error', msg: errorMsg });
|
||||
} finally {
|
||||
setRunNowLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePauseSchedule = async () => {
|
||||
if (!scheduleId) return;
|
||||
setPauseUnpauseLoading(true);
|
||||
try {
|
||||
await pauseSchedule(scheduleId);
|
||||
toastSuccess({
|
||||
title: 'Schedule Paused',
|
||||
msg: `Successfully paused schedule "${scheduleId}"`,
|
||||
toastError({
|
||||
title: 'Run Schedule Error',
|
||||
msg: err instanceof Error ? err.message : 'Failed to trigger schedule',
|
||||
});
|
||||
fetchScheduleDetails(scheduleId);
|
||||
} catch (err) {
|
||||
console.error('Failed to pause schedule:', err);
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to pause schedule';
|
||||
toastError({ title: 'Pause Schedule Error', msg: errorMsg });
|
||||
} finally {
|
||||
setPauseUnpauseLoading(false);
|
||||
setIsActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnpauseSchedule = async () => {
|
||||
if (!scheduleId) return;
|
||||
setPauseUnpauseLoading(true);
|
||||
const handlePauseToggle = async () => {
|
||||
if (!scheduleId || !scheduleDetails) return;
|
||||
setIsActionLoading(true);
|
||||
try {
|
||||
await unpauseSchedule(scheduleId);
|
||||
toastSuccess({
|
||||
title: 'Schedule Unpaused',
|
||||
msg: `Successfully unpaused schedule "${scheduleId}"`,
|
||||
});
|
||||
fetchScheduleDetails(scheduleId);
|
||||
if (scheduleDetails.paused) {
|
||||
await unpauseSchedule(scheduleId);
|
||||
toastSuccess({ title: 'Schedule Unpaused', msg: `Unpaused "${scheduleId}"` });
|
||||
} else {
|
||||
await pauseSchedule(scheduleId);
|
||||
toastSuccess({ title: 'Schedule Paused', msg: `Paused "${scheduleId}"` });
|
||||
}
|
||||
await fetchSchedule(scheduleId);
|
||||
} catch (err) {
|
||||
console.error('Failed to unpause schedule:', err);
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to unpause schedule';
|
||||
toastError({ title: 'Unpause Schedule Error', msg: errorMsg });
|
||||
toastError({
|
||||
title: 'Pause/Unpause Error',
|
||||
msg: err instanceof Error ? err.message : 'Operation failed',
|
||||
});
|
||||
} finally {
|
||||
setPauseUnpauseLoading(false);
|
||||
setIsActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenEditModal = () => {
|
||||
setEditApiError(null);
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseEditModal = () => {
|
||||
setIsEditModalOpen(false);
|
||||
setEditApiError(null);
|
||||
};
|
||||
|
||||
const handleKillRunningJob = async () => {
|
||||
const handleKill = async () => {
|
||||
if (!scheduleId) return;
|
||||
setKillJobLoading(true);
|
||||
setIsActionLoading(true);
|
||||
try {
|
||||
const result = await killRunningJob(scheduleId);
|
||||
toastSuccess({
|
||||
title: 'Job Killed',
|
||||
msg: result.message,
|
||||
});
|
||||
// Mark that we explicitly killed this job
|
||||
setJobWasKilled(true);
|
||||
// Clear the runNowLoading state immediately when job is killed
|
||||
setRunNowLoading(false);
|
||||
fetchScheduleDetails(scheduleId);
|
||||
toastSuccess({ title: 'Job Killed', msg: result.message });
|
||||
await fetchSchedule(scheduleId);
|
||||
} catch (err) {
|
||||
console.error('Failed to kill running job:', err);
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to kill running job';
|
||||
toastError({ title: 'Kill Job Error', msg: errorMsg });
|
||||
toastError({
|
||||
title: 'Kill Job Error',
|
||||
msg: err instanceof Error ? err.message : 'Failed to kill job',
|
||||
});
|
||||
} finally {
|
||||
setKillJobLoading(false);
|
||||
setIsActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInspectRunningJob = async () => {
|
||||
const handleInspect = async () => {
|
||||
if (!scheduleId) return;
|
||||
setInspectJobLoading(true);
|
||||
setIsActionLoading(true);
|
||||
try {
|
||||
const result = await inspectRunningJob(scheduleId);
|
||||
if (result.sessionId) {
|
||||
@@ -339,130 +172,62 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
||||
msg: `Session: ${result.sessionId}\nRunning for: ${duration}`,
|
||||
});
|
||||
} else {
|
||||
toastSuccess({
|
||||
title: 'Job Inspection',
|
||||
msg: 'No detailed information available for this job',
|
||||
});
|
||||
toastSuccess({ title: 'Job Inspection', msg: 'No detailed information available' });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to inspect running job:', err);
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to inspect running job';
|
||||
toastError({ title: 'Inspect Job Error', msg: errorMsg });
|
||||
} finally {
|
||||
setInspectJobLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditScheduleSubmit = async (cron: string) => {
|
||||
if (!scheduleId) return;
|
||||
|
||||
setIsEditSubmitting(true);
|
||||
setEditApiError(null);
|
||||
try {
|
||||
await updateSchedule(scheduleId, cron);
|
||||
toastSuccess({
|
||||
title: 'Schedule Updated',
|
||||
msg: `Successfully updated schedule "${scheduleId}"`,
|
||||
toastError({
|
||||
title: 'Inspect Job Error',
|
||||
msg: err instanceof Error ? err.message : 'Failed to inspect job',
|
||||
});
|
||||
fetchScheduleDetails(scheduleId);
|
||||
setIsEditModalOpen(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to update schedule:', err);
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to update schedule';
|
||||
setEditApiError(errorMsg);
|
||||
toastError({ title: 'Update Schedule Error', msg: errorMsg });
|
||||
} finally {
|
||||
setIsEditSubmitting(false);
|
||||
setIsActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Optimized periodic refresh for schedule details to keep the running status up to date
|
||||
useEffect(() => {
|
||||
const handleModalSubmit = async (payload: NewSchedulePayload | string) => {
|
||||
if (!scheduleId) return;
|
||||
|
||||
// Initial fetch
|
||||
fetchScheduleDetails(scheduleId);
|
||||
|
||||
// Set up periodic refresh every 8 seconds (longer to reduce flashing)
|
||||
const intervalId = setInterval(() => {
|
||||
if (
|
||||
scheduleId &&
|
||||
!selectedSessionDetails &&
|
||||
!runNowLoading &&
|
||||
!pauseUnpauseLoading &&
|
||||
!killJobLoading &&
|
||||
!inspectJobLoading &&
|
||||
!isEditSubmitting
|
||||
) {
|
||||
fetchScheduleDetails(scheduleId, true); // Pass true to indicate this is a refresh
|
||||
}
|
||||
}, 8000);
|
||||
|
||||
// Clean up on unmount or when scheduleId changes
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [
|
||||
scheduleId,
|
||||
fetchScheduleDetails,
|
||||
selectedSessionDetails,
|
||||
runNowLoading,
|
||||
pauseUnpauseLoading,
|
||||
killJobLoading,
|
||||
inspectJobLoading,
|
||||
isEditSubmitting,
|
||||
]);
|
||||
|
||||
// Monitor schedule state changes and reset loading states appropriately
|
||||
useEffect(() => {
|
||||
if (scheduleDetails) {
|
||||
// Only reset runNowLoading if we explicitly killed the job
|
||||
// This prevents interfering with natural job completion
|
||||
if (!scheduleDetails.currently_running && runNowLoading && jobWasKilled) {
|
||||
setRunNowLoading(false);
|
||||
setJobWasKilled(false); // Reset the flag
|
||||
}
|
||||
setIsActionLoading(true);
|
||||
try {
|
||||
await updateSchedule(scheduleId, payload as string);
|
||||
toastSuccess({ title: 'Schedule Updated', msg: `Updated "${scheduleId}"` });
|
||||
await fetchSchedule(scheduleId);
|
||||
setIsModalOpen(false);
|
||||
} catch (err) {
|
||||
toastError({
|
||||
title: 'Update Schedule Error',
|
||||
msg: err instanceof Error ? err.message : 'Failed to update schedule',
|
||||
});
|
||||
} finally {
|
||||
setIsActionLoading(false);
|
||||
}
|
||||
}, [scheduleDetails, runNowLoading, jobWasKilled]);
|
||||
};
|
||||
|
||||
const loadAndShowSessionDetails = async (sessionId: string) => {
|
||||
setIsLoadingSessionDetails(true);
|
||||
setSessionDetailsError(null);
|
||||
setSelectedSessionDetails(null);
|
||||
const loadSession = async (sessionId: string) => {
|
||||
setIsLoadingSession(true);
|
||||
setSessionError(null);
|
||||
try {
|
||||
const response = await getSession<true>({
|
||||
path: { session_id: sessionId },
|
||||
throwOnError: true,
|
||||
});
|
||||
setSelectedSessionDetails(response.data);
|
||||
setSelectedSession(response.data);
|
||||
} catch (err) {
|
||||
console.error(`Failed to load session details for ${sessionId}:`, err);
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to load session details.';
|
||||
setSessionDetailsError(errorMsg);
|
||||
toastError({
|
||||
title: 'Failed to load session details',
|
||||
msg: errorMsg,
|
||||
});
|
||||
const msg = err instanceof Error ? err.message : 'Failed to load session';
|
||||
setSessionError(msg);
|
||||
toastError({ title: 'Failed to load session', msg });
|
||||
} finally {
|
||||
setIsLoadingSessionDetails(false);
|
||||
setIsLoadingSession(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSessionCardClick = (sessionIdFromCard: string) => {
|
||||
loadAndShowSessionDetails(sessionIdFromCard);
|
||||
};
|
||||
|
||||
if (selectedSessionDetails) {
|
||||
if (selectedSession) {
|
||||
return (
|
||||
<SessionHistoryView
|
||||
session={selectedSessionDetails}
|
||||
isLoading={isLoadingSessionDetails}
|
||||
error={sessionDetailsError}
|
||||
onBack={() => {
|
||||
setSelectedSessionDetails(null);
|
||||
setSessionDetailsError(null);
|
||||
}}
|
||||
onRetry={() => loadAndShowSessionDetails(selectedSessionDetails?.id)}
|
||||
session={selectedSession}
|
||||
isLoading={isLoadingSession}
|
||||
error={sessionError}
|
||||
onBack={() => setSelectedSession(null)}
|
||||
onRetry={() => loadSession(selectedSession.id)}
|
||||
showActionButtons={true}
|
||||
/>
|
||||
);
|
||||
@@ -473,13 +238,21 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
||||
<div className="h-screen w-full flex flex-col items-center justify-center bg-white dark:bg-gray-900 text-text-default p-8">
|
||||
<BackButton onClick={onNavigateBack} />
|
||||
<h1 className="text-2xl font-medium text-text-prominent mt-4">Schedule Not Found</h1>
|
||||
<p className="text-text-subtle mt-2">
|
||||
No schedule ID was provided. Please return to the schedules list and select a schedule.
|
||||
</p>
|
||||
<p className="text-text-subtle mt-2">No schedule ID provided. Return to schedules list.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const readableCron = scheduleDetails
|
||||
? (() => {
|
||||
try {
|
||||
return cronstrue.toString(scheduleDetails.cron);
|
||||
} catch {
|
||||
return scheduleDetails.cron;
|
||||
}
|
||||
})()
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full flex flex-col bg-background-default text-text-default">
|
||||
<div className="px-8 pt-6 pb-4 border-b border-border-subtle flex-shrink-0">
|
||||
@@ -494,7 +267,7 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
||||
<h2 className="text-xl font-semibold text-text-prominent mb-3">Schedule Information</h2>
|
||||
{isLoadingSchedule && (
|
||||
<div className="flex items-center text-text-subtle">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading schedule details...
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading schedule...
|
||||
</div>
|
||||
)}
|
||||
{scheduleError && (
|
||||
@@ -502,8 +275,55 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
||||
Error: {scheduleError}
|
||||
</p>
|
||||
)}
|
||||
{!isLoadingSchedule && !scheduleError && scheduleDetails && (
|
||||
<ScheduleInfoCard scheduleDetails={scheduleDetails} />
|
||||
{scheduleDetails && (
|
||||
<Card className="p-4 bg-background-card shadow mb-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-text-prominent">
|
||||
{scheduleDetails.id}
|
||||
</h3>
|
||||
<div className="mt-2 md:mt-0 flex items-center gap-2">
|
||||
{scheduleDetails.currently_running && (
|
||||
<div className="text-sm text-green-500 dark:text-green-400 font-semibold flex items-center">
|
||||
<span className="inline-block w-2 h-2 bg-green-500 dark:bg-green-400 rounded-full mr-1 animate-pulse"></span>
|
||||
Currently Running
|
||||
</div>
|
||||
)}
|
||||
{scheduleDetails.paused && (
|
||||
<div className="text-sm text-orange-500 dark:text-orange-400 font-semibold flex items-center">
|
||||
<Pause className="w-3 h-3 mr-1" />
|
||||
Paused
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-text-default">
|
||||
<span className="font-semibold">Schedule:</span> {readableCron}
|
||||
</p>
|
||||
<p className="text-sm text-text-default">
|
||||
<span className="font-semibold">Cron Expression:</span> {scheduleDetails.cron}
|
||||
</p>
|
||||
<p className="text-sm text-text-default">
|
||||
<span className="font-semibold">Recipe Source:</span> {scheduleDetails.source}
|
||||
</p>
|
||||
<p className="text-sm text-text-default">
|
||||
<span className="font-semibold">Last Run:</span>{' '}
|
||||
{formatToLocalDateWithTimezone(scheduleDetails.last_run)}
|
||||
</p>
|
||||
{scheduleDetails.currently_running && scheduleDetails.current_session_id && (
|
||||
<p className="text-sm text-text-default">
|
||||
<span className="font-semibold">Current Session:</span>{' '}
|
||||
{scheduleDetails.current_session_id}
|
||||
</p>
|
||||
)}
|
||||
{scheduleDetails.currently_running && scheduleDetails.process_start_time && (
|
||||
<p className="text-sm text-text-default">
|
||||
<span className="font-semibold">Process Started:</span>{' '}
|
||||
{formatToLocalDateWithTimezone(scheduleDetails.process_start_time)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -512,67 +332,67 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
||||
<div className="flex flex-col md:flex-row gap-2">
|
||||
<Button
|
||||
onClick={handleRunNow}
|
||||
disabled={runNowLoading || scheduleDetails?.currently_running === true}
|
||||
disabled={isActionLoading || scheduleDetails?.currently_running}
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
{runNowLoading ? 'Triggering...' : 'Run Schedule Now'}
|
||||
Run Schedule Now
|
||||
</Button>
|
||||
|
||||
{scheduleDetails && !scheduleDetails.currently_running && (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleOpenEditModal}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
variant="outline"
|
||||
className="w-full md:w-auto flex items-center gap-2 text-blue-600 dark:text-blue-400 border-blue-300 dark:border-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20"
|
||||
disabled={runNowLoading || pauseUnpauseLoading || isEditSubmitting}
|
||||
disabled={isActionLoading}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
Edit Schedule
|
||||
</Button>
|
||||
<Button
|
||||
onClick={scheduleDetails.paused ? handleUnpauseSchedule : handlePauseSchedule}
|
||||
onClick={handlePauseToggle}
|
||||
variant="outline"
|
||||
className={`w-full md:w-auto flex items-center gap-2 ${
|
||||
scheduleDetails.paused
|
||||
? 'text-green-600 dark:text-green-400 border-green-300 dark:border-green-600 hover:bg-green-50 dark:hover:bg-green-900/20'
|
||||
: 'text-orange-600 dark:text-orange-400 border-orange-300 dark:border-orange-600 hover:bg-orange-50 dark:hover:bg-orange-900/20'
|
||||
}`}
|
||||
disabled={runNowLoading || pauseUnpauseLoading || isEditSubmitting}
|
||||
disabled={isActionLoading}
|
||||
>
|
||||
{scheduleDetails.paused ? (
|
||||
<>
|
||||
<Play className="w-4 h-4" />
|
||||
{pauseUnpauseLoading ? 'Unpausing...' : 'Unpause Schedule'}
|
||||
Unpause Schedule
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pause className="w-4 h-4" />
|
||||
{pauseUnpauseLoading ? 'Pausing...' : 'Pause Schedule'}
|
||||
Pause Schedule
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{scheduleDetails && scheduleDetails.currently_running && (
|
||||
{scheduleDetails?.currently_running && (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleInspectRunningJob}
|
||||
onClick={handleInspect}
|
||||
variant="outline"
|
||||
className="w-full md:w-auto flex items-center gap-2 text-blue-600 dark:text-blue-400 border-blue-300 dark:border-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20"
|
||||
disabled={inspectJobLoading}
|
||||
disabled={isActionLoading}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
{inspectJobLoading ? 'Inspecting...' : 'Inspect Running Job'}
|
||||
Inspect Running Job
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleKillRunningJob}
|
||||
onClick={handleKill}
|
||||
variant="outline"
|
||||
className="w-full md:w-auto flex items-center gap-2 text-red-600 dark:text-red-400 border-red-300 dark:border-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
disabled={killJobLoading}
|
||||
disabled={isActionLoading}
|
||||
>
|
||||
<Square className="w-4 h-4" />
|
||||
{killJobLoading ? 'Killing...' : 'Kill Running Job'}
|
||||
Kill Running Job
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -593,41 +413,32 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-text-prominent mb-4">
|
||||
Recent Sessions for this Schedule
|
||||
</h2>
|
||||
<h2 className="text-xl font-semibold text-text-prominent mb-4">Recent Sessions</h2>
|
||||
{isLoadingSessions && <p className="text-text-subtle">Loading sessions...</p>}
|
||||
{sessionsError && (
|
||||
<p className="text-text-error text-sm p-3 bg-background-error border border-border-error rounded-md">
|
||||
Error: {sessionsError}
|
||||
</p>
|
||||
)}
|
||||
{!isLoadingSessions && !sessionsError && sessions.length === 0 && (
|
||||
{!isLoadingSessions && sessions.length === 0 && (
|
||||
<p className="text-text-subtle text-center py-4">
|
||||
No sessions found for this schedule.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isLoadingSessions && sessions.length > 0 && (
|
||||
{sessions.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{sessions.map((session) => (
|
||||
<Card
|
||||
key={session.id}
|
||||
className="p-4 bg-background-card shadow cursor-pointer hover:shadow-lg transition-shadow duration-200"
|
||||
onClick={() => handleSessionCardClick(session.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
handleSessionCardClick(session.id);
|
||||
}
|
||||
}}
|
||||
onClick={() => loadSession(session.id)}
|
||||
>
|
||||
<h3
|
||||
className="text-sm font-semibold text-text-prominent truncate"
|
||||
title={session.name || session.id}
|
||||
>
|
||||
{session.name || `Session ID: ${session.id}`}{' '}
|
||||
{session.name || `Session ID: ${session.id}`}
|
||||
</h3>
|
||||
<p className="text-xs text-text-subtle mt-1">
|
||||
Created:{' '}
|
||||
@@ -662,13 +473,15 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
||||
</section>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<EditScheduleModal
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={handleCloseEditModal}
|
||||
onSubmit={handleEditScheduleSubmit}
|
||||
|
||||
<ScheduleModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSubmit={handleModalSubmit}
|
||||
schedule={scheduleDetails}
|
||||
isLoadingExternally={isEditSubmitting}
|
||||
apiErrorExternally={editApiError}
|
||||
isLoadingExternally={isActionLoading}
|
||||
apiErrorExternally={null}
|
||||
initialDeepLink={null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
import React, { useState, useEffect, FormEvent, useCallback } from 'react';
|
||||
import { Card } from '../ui/card';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { ScheduledJob } from '../../schedule';
|
||||
import { CronPicker } from './CronPicker';
|
||||
import { Recipe, decodeRecipe } from '../../recipe';
|
||||
import { getStorageDirectory } from '../../recipe/recipe_management';
|
||||
import ClockIcon from '../../assets/clock-icon.svg';
|
||||
import * as yaml from 'yaml';
|
||||
|
||||
export interface NewSchedulePayload {
|
||||
id: string;
|
||||
recipe_source: string;
|
||||
cron: string;
|
||||
execution_mode?: string;
|
||||
}
|
||||
|
||||
interface ScheduleModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (payload: NewSchedulePayload | string) => Promise<void>;
|
||||
schedule: ScheduledJob | null;
|
||||
isLoadingExternally: boolean;
|
||||
apiErrorExternally: string | null;
|
||||
initialDeepLink: string | null;
|
||||
}
|
||||
|
||||
type SourceType = 'file' | 'deeplink';
|
||||
|
||||
interface CleanExtension {
|
||||
name: string;
|
||||
type: 'stdio' | 'sse' | 'builtin' | 'frontend' | 'streamable_http';
|
||||
cmd?: string;
|
||||
args?: string[];
|
||||
uri?: string;
|
||||
display_name?: string;
|
||||
tools?: unknown[];
|
||||
instructions?: string;
|
||||
env_keys?: string[];
|
||||
timeout?: number;
|
||||
description?: string;
|
||||
bundled?: boolean;
|
||||
}
|
||||
|
||||
interface CleanRecipe {
|
||||
title: string;
|
||||
description: string;
|
||||
instructions?: string;
|
||||
prompt?: string;
|
||||
activities?: string[];
|
||||
extensions?: CleanExtension[];
|
||||
author?: {
|
||||
contact?: string;
|
||||
metadata?: string;
|
||||
};
|
||||
schedule?: {
|
||||
window_title?: string;
|
||||
working_directory?: string;
|
||||
};
|
||||
}
|
||||
|
||||
async function parseDeepLink(deepLink: string): Promise<Recipe | null> {
|
||||
try {
|
||||
const url = new URL(deepLink);
|
||||
if (url.protocol !== 'goose:' || (url.hostname !== 'bot' && url.hostname !== 'recipe')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const recipeParam = url.searchParams.get('config');
|
||||
if (!recipeParam) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await decodeRecipe(recipeParam);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse deep link:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function recipeToYaml(recipe: Recipe): string {
|
||||
const cleanRecipe: CleanRecipe = {
|
||||
title: recipe.title,
|
||||
description: recipe.description,
|
||||
};
|
||||
|
||||
if (recipe.instructions) {
|
||||
cleanRecipe.instructions = recipe.instructions;
|
||||
}
|
||||
|
||||
if (recipe.prompt) {
|
||||
cleanRecipe.prompt = recipe.prompt;
|
||||
}
|
||||
|
||||
if (recipe.activities && recipe.activities.length > 0) {
|
||||
cleanRecipe.activities = recipe.activities;
|
||||
}
|
||||
|
||||
if (recipe.extensions && recipe.extensions.length > 0) {
|
||||
cleanRecipe.extensions = recipe.extensions.map((ext) => {
|
||||
const cleanExt: CleanExtension = {
|
||||
name: ext.name,
|
||||
type: 'builtin',
|
||||
};
|
||||
|
||||
if ('type' in ext && ext.type) {
|
||||
cleanExt.type = ext.type as CleanExtension['type'];
|
||||
|
||||
const extAny = ext as Record<string, unknown>;
|
||||
|
||||
if (ext.type === 'sse' && extAny.uri) {
|
||||
cleanExt.uri = extAny.uri as string;
|
||||
} else if (ext.type === 'streamable_http' && extAny.uri) {
|
||||
cleanExt.uri = extAny.uri as string;
|
||||
} else if (ext.type === 'stdio') {
|
||||
if (extAny.cmd) {
|
||||
cleanExt.cmd = extAny.cmd as string;
|
||||
}
|
||||
if (extAny.args) {
|
||||
cleanExt.args = extAny.args as string[];
|
||||
}
|
||||
} else if (ext.type === 'builtin' && extAny.display_name) {
|
||||
cleanExt.display_name = extAny.display_name as string;
|
||||
}
|
||||
|
||||
if ((ext.type as string) === 'frontend') {
|
||||
if (extAny.tools) {
|
||||
cleanExt.tools = extAny.tools as unknown[];
|
||||
}
|
||||
if (extAny.instructions) {
|
||||
cleanExt.instructions = extAny.instructions as string;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const extAny = ext as Record<string, unknown>;
|
||||
|
||||
if (extAny.cmd) {
|
||||
cleanExt.type = 'stdio';
|
||||
cleanExt.cmd = extAny.cmd as string;
|
||||
if (extAny.args) {
|
||||
cleanExt.args = extAny.args as string[];
|
||||
}
|
||||
} else if (extAny.command) {
|
||||
cleanExt.type = 'stdio';
|
||||
cleanExt.cmd = extAny.command as string;
|
||||
} else if (extAny.uri) {
|
||||
cleanExt.type = 'streamable_http';
|
||||
cleanExt.uri = extAny.uri as string;
|
||||
} else if (extAny.tools) {
|
||||
cleanExt.type = 'frontend';
|
||||
cleanExt.tools = extAny.tools as unknown[];
|
||||
if (extAny.instructions) {
|
||||
cleanExt.instructions = extAny.instructions as string;
|
||||
}
|
||||
} else {
|
||||
cleanExt.type = 'builtin';
|
||||
}
|
||||
}
|
||||
|
||||
if ('env_keys' in ext && ext.env_keys && ext.env_keys.length > 0) {
|
||||
cleanExt.env_keys = ext.env_keys;
|
||||
}
|
||||
|
||||
if ('timeout' in ext && ext.timeout) {
|
||||
cleanExt.timeout = ext.timeout as number;
|
||||
}
|
||||
|
||||
if ('description' in ext && ext.description) {
|
||||
cleanExt.description = ext.description as string;
|
||||
}
|
||||
|
||||
if ('bundled' in ext && ext.bundled !== undefined) {
|
||||
cleanExt.bundled = ext.bundled as boolean;
|
||||
}
|
||||
|
||||
return cleanExt;
|
||||
});
|
||||
}
|
||||
|
||||
if (recipe.author) {
|
||||
cleanRecipe.author = {
|
||||
contact: recipe.author.contact || undefined,
|
||||
metadata: recipe.author.metadata || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
cleanRecipe.schedule = {
|
||||
window_title: `${recipe.title} - Scheduled`,
|
||||
};
|
||||
|
||||
return yaml.stringify(cleanRecipe);
|
||||
}
|
||||
|
||||
const modalLabelClassName = 'block text-sm font-medium text-text-prominent mb-1';
|
||||
|
||||
export const ScheduleModal: React.FC<ScheduleModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
schedule,
|
||||
isLoadingExternally,
|
||||
apiErrorExternally,
|
||||
initialDeepLink,
|
||||
}) => {
|
||||
const isEditMode = !!schedule;
|
||||
|
||||
const [scheduleId, setScheduleId] = useState<string>('');
|
||||
const [sourceType, setSourceType] = useState<SourceType>('file');
|
||||
const [recipeSourcePath, setRecipeSourcePath] = useState<string>('');
|
||||
const [deepLinkInput, setDeepLinkInput] = useState<string>('');
|
||||
const [parsedRecipe, setParsedRecipe] = useState<Recipe | null>(null);
|
||||
const [cronExpression, setCronExpression] = useState<string>('0 0 14 * * *');
|
||||
const [internalValidationError, setInternalValidationError] = useState<string | null>(null);
|
||||
|
||||
const handleDeepLinkChange = useCallback(async (value: string) => {
|
||||
setDeepLinkInput(value);
|
||||
setInternalValidationError(null);
|
||||
|
||||
if (value.trim()) {
|
||||
try {
|
||||
const recipe = await parseDeepLink(value.trim());
|
||||
if (recipe) {
|
||||
setParsedRecipe(recipe);
|
||||
if (recipe.title) {
|
||||
const cleanId = recipe.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, '-')
|
||||
.replace(/-+/g, '-');
|
||||
setScheduleId(cleanId);
|
||||
}
|
||||
} else {
|
||||
setParsedRecipe(null);
|
||||
setInternalValidationError(
|
||||
'Invalid deep link format. Please use a goose://bot or goose://recipe link.'
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
setParsedRecipe(null);
|
||||
setInternalValidationError(
|
||||
'Failed to parse deep link. Please ensure using a goose://bot or goose://recipe link and try again.'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setParsedRecipe(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (schedule) {
|
||||
setScheduleId(schedule.id);
|
||||
setCronExpression(schedule.cron);
|
||||
} else {
|
||||
setScheduleId('');
|
||||
setSourceType('file');
|
||||
setRecipeSourcePath('');
|
||||
setDeepLinkInput('');
|
||||
setParsedRecipe(null);
|
||||
setCronExpression('0 0 14 * * *');
|
||||
setInternalValidationError(null);
|
||||
if (initialDeepLink) {
|
||||
setSourceType('deeplink');
|
||||
handleDeepLinkChange(initialDeepLink);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isOpen, schedule, initialDeepLink, handleDeepLinkChange]);
|
||||
|
||||
const handleBrowseFile = async () => {
|
||||
const defaultPath = getStorageDirectory(true);
|
||||
const filePath = await window.electron.selectFileOrDirectory(defaultPath);
|
||||
if (filePath) {
|
||||
if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) {
|
||||
setRecipeSourcePath(filePath);
|
||||
setInternalValidationError(null);
|
||||
} else {
|
||||
setInternalValidationError('Invalid file type: Please select a YAML file (.yaml or .yml)');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleLocalSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setInternalValidationError(null);
|
||||
|
||||
if (isEditMode) {
|
||||
await onSubmit(cronExpression);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!scheduleId.trim()) {
|
||||
setInternalValidationError('Schedule ID is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
let finalRecipeSource = '';
|
||||
|
||||
if (sourceType === 'file') {
|
||||
if (!recipeSourcePath) {
|
||||
setInternalValidationError('Recipe source file is required.');
|
||||
return;
|
||||
}
|
||||
finalRecipeSource = recipeSourcePath;
|
||||
} else if (sourceType === 'deeplink') {
|
||||
if (!deepLinkInput.trim()) {
|
||||
setInternalValidationError('Deep link is required.');
|
||||
return;
|
||||
}
|
||||
if (!parsedRecipe) {
|
||||
setInternalValidationError('Invalid deep link. Please check the format.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const yamlContent = recipeToYaml(parsedRecipe);
|
||||
const tempFileName = `schedule-${scheduleId}-${Date.now()}.yaml`;
|
||||
const tempDir = window.electron.getConfig().GOOSE_WORKING_DIR || '.';
|
||||
const tempFilePath = `${tempDir}/${tempFileName}`;
|
||||
|
||||
const writeSuccess = await window.electron.writeFile(tempFilePath, yamlContent);
|
||||
if (!writeSuccess) {
|
||||
setInternalValidationError('Failed to create temporary recipe file.');
|
||||
return;
|
||||
}
|
||||
|
||||
finalRecipeSource = tempFilePath;
|
||||
} catch (error) {
|
||||
console.error('Failed to convert recipe to YAML:', error);
|
||||
setInternalValidationError('Failed to process the recipe from deep link.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const newSchedulePayload: NewSchedulePayload = {
|
||||
id: scheduleId.trim(),
|
||||
recipe_source: finalRecipeSource,
|
||||
cron: cronExpression,
|
||||
};
|
||||
|
||||
await onSubmit(newSchedulePayload);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-40 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md bg-background-default shadow-xl rounded-3xl z-50 flex flex-col max-h-[90vh] overflow-hidden">
|
||||
<div className="px-8 pt-6 pb-4 flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src={ClockIcon} alt="Clock" className="w-8 h-8" />
|
||||
<div className="flex-1">
|
||||
<h2 className="text-base font-semibold text-text-prominent">
|
||||
{isEditMode ? 'Edit Schedule' : 'Create New Schedule'}
|
||||
</h2>
|
||||
{isEditMode && <p className="text-sm text-text-subtle">{schedule.id}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
id="schedule-form"
|
||||
onSubmit={handleLocalSubmit}
|
||||
className="px-8 py-4 space-y-4 flex-grow overflow-y-auto"
|
||||
>
|
||||
{apiErrorExternally && (
|
||||
<p className="text-text-error text-sm mb-3 p-2 bg-background-error border border-border-error rounded-md">
|
||||
{apiErrorExternally}
|
||||
</p>
|
||||
)}
|
||||
{internalValidationError && (
|
||||
<p className="text-text-error text-sm mb-3 p-2 bg-background-error border border-border-error rounded-md">
|
||||
{internalValidationError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isEditMode && (
|
||||
<>
|
||||
<div>
|
||||
<label htmlFor="scheduleId-modal" className={modalLabelClassName}>
|
||||
Name:
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
id="scheduleId-modal"
|
||||
value={scheduleId}
|
||||
onChange={(e) => setScheduleId(e.target.value)}
|
||||
placeholder="e.g., daily-summary-job"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={modalLabelClassName}>Source:</label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex bg-gray-100 dark:bg-gray-700 rounded-full p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSourceType('file')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-full transition-all ${
|
||||
sourceType === 'file'
|
||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
YAML
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSourceType('deeplink')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-full transition-all ${
|
||||
sourceType === 'deeplink'
|
||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Deep link
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{sourceType === 'file' && (
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleBrowseFile}
|
||||
className="w-full justify-center rounded-full"
|
||||
>
|
||||
Browse for YAML file...
|
||||
</Button>
|
||||
{recipeSourcePath && (
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400 italic">
|
||||
Selected: {recipeSourcePath}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sourceType === 'deeplink' && (
|
||||
<div>
|
||||
<Input
|
||||
type="text"
|
||||
value={deepLinkInput}
|
||||
onChange={(e) => handleDeepLinkChange(e.target.value)}
|
||||
placeholder="Paste goose://bot or goose://recipe link here..."
|
||||
className="rounded-full"
|
||||
/>
|
||||
{parsedRecipe && (
|
||||
<div className="mt-2 p-2 bg-green-100 dark:bg-green-900/30 rounded-md border border-green-500/50">
|
||||
<p className="text-xs text-green-700 dark:text-green-300 font-medium">
|
||||
✓ Recipe parsed successfully
|
||||
</p>
|
||||
<p className="text-xs text-green-600 dark:text-green-400">
|
||||
Title: {parsedRecipe.title}
|
||||
</p>
|
||||
<p className="text-xs text-green-600 dark:text-green-400">
|
||||
Description: {parsedRecipe.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className={modalLabelClassName}>Schedule:</label>
|
||||
<CronPicker schedule={schedule} onChange={setCronExpression} />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="flex gap-2 px-8 py-4 border-t border-border-subtle">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onClose}
|
||||
disabled={isLoadingExternally}
|
||||
className="flex-1 text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="schedule-form"
|
||||
disabled={isLoadingExternally}
|
||||
className="flex-1"
|
||||
>
|
||||
{isLoadingExternally
|
||||
? isEditMode
|
||||
? 'Updating...'
|
||||
: 'Creating...'
|
||||
: isEditMode
|
||||
? 'Update Schedule'
|
||||
: 'Create Schedule'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import {
|
||||
listSchedules,
|
||||
@@ -16,8 +16,7 @@ import { Card } from '../ui/card';
|
||||
import { Button } from '../ui/button';
|
||||
import { TrashIcon } from '../icons/TrashIcon';
|
||||
import { Plus, RefreshCw, Pause, Play, Edit, Square, Eye, CircleDotDashed } from 'lucide-react';
|
||||
import { CreateScheduleModal, NewSchedulePayload } from './CreateScheduleModal';
|
||||
import { EditScheduleModal } from './EditScheduleModal';
|
||||
import { NewSchedulePayload, ScheduleModal } from './ScheduleModal';
|
||||
import ScheduleDetailView from './ScheduleDetailView';
|
||||
import { toastError, toastSuccess } from '../../toasts';
|
||||
import cronstrue from 'cronstrue';
|
||||
@@ -29,8 +28,7 @@ interface SchedulesViewProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
// Memoized ScheduleCard component to prevent unnecessary re-renders
|
||||
const ScheduleCard = React.memo<{
|
||||
const ScheduleCard: React.FC<{
|
||||
job: ScheduledJob;
|
||||
onNavigateToDetail: (id: string) => void;
|
||||
onEdit: (job: ScheduledJob) => void;
|
||||
@@ -39,177 +37,150 @@ const ScheduleCard = React.memo<{
|
||||
onKill: (id: string) => void;
|
||||
onInspect: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
isPausing: boolean;
|
||||
isDeleting: boolean;
|
||||
isKilling: boolean;
|
||||
isInspecting: boolean;
|
||||
isSubmitting: boolean;
|
||||
}>(
|
||||
({
|
||||
job,
|
||||
onNavigateToDetail,
|
||||
onEdit,
|
||||
onPause,
|
||||
onUnpause,
|
||||
onKill,
|
||||
onInspect,
|
||||
onDelete,
|
||||
isPausing,
|
||||
isDeleting,
|
||||
isKilling,
|
||||
isInspecting,
|
||||
isSubmitting,
|
||||
}) => {
|
||||
const readableCron = useMemo(() => {
|
||||
try {
|
||||
return cronstrue.toString(job.cron);
|
||||
} catch (e) {
|
||||
console.warn(`Could not parse cron string "${job.cron}":`, e);
|
||||
return job.cron;
|
||||
}
|
||||
}, [job.cron]);
|
||||
actionInProgress: boolean;
|
||||
}> = ({
|
||||
job,
|
||||
onNavigateToDetail,
|
||||
onEdit,
|
||||
onPause,
|
||||
onUnpause,
|
||||
onKill,
|
||||
onInspect,
|
||||
onDelete,
|
||||
actionInProgress,
|
||||
}) => {
|
||||
let readableCron: string;
|
||||
try {
|
||||
readableCron = cronstrue.toString(job.cron);
|
||||
} catch {
|
||||
readableCron = job.cron;
|
||||
}
|
||||
|
||||
const formattedLastRun = useMemo(() => {
|
||||
return formatToLocalDateWithTimezone(job.last_run);
|
||||
}, [job.last_run]);
|
||||
const formattedLastRun = formatToLocalDateWithTimezone(job.last_run);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="py-2 px-4 mb-2 bg-background-default border-none hover:bg-background-muted cursor-pointer transition-all duration-150"
|
||||
onClick={() => onNavigateToDetail(job.id)}
|
||||
>
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-base truncate max-w-[50vw]" title={job.id}>
|
||||
{job.id}
|
||||
</h3>
|
||||
{job.execution_mode && (
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
job.execution_mode === 'foreground'
|
||||
? 'bg-background-accent text-text-on-accent'
|
||||
: 'bg-background-medium text-text-default'
|
||||
}`}
|
||||
>
|
||||
{job.execution_mode === 'foreground' ? '🖥️' : '⚡'}
|
||||
</span>
|
||||
)}
|
||||
{job.currently_running && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||
<span className="inline-block w-2 h-2 bg-green-500 rounded-full mr-1 animate-pulse"></span>
|
||||
Running
|
||||
</span>
|
||||
)}
|
||||
{job.paused && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300">
|
||||
<Pause className="w-3 h-3 mr-1" />
|
||||
Paused
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-text-muted text-sm mb-2 line-clamp-2" title={readableCron}>
|
||||
{readableCron}
|
||||
</p>
|
||||
<div className="flex items-center text-xs text-text-muted">
|
||||
<span>Last run: {formattedLastRun}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{!job.currently_running && (
|
||||
<>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(job);
|
||||
}}
|
||||
disabled={isPausing || isDeleting || isSubmitting}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (job.paused) {
|
||||
onUnpause(job.id);
|
||||
} else {
|
||||
onPause(job.id);
|
||||
}
|
||||
}}
|
||||
disabled={isPausing || isDeleting}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
>
|
||||
{job.paused ? (
|
||||
<>
|
||||
<Play className="w-4 h-4 mr-1" />
|
||||
Resume
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pause className="w-4 h-4 mr-1" />
|
||||
Pause
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
return (
|
||||
<Card
|
||||
className="py-2 px-4 mb-2 bg-background-default border-none hover:bg-background-muted cursor-pointer transition-all duration-150"
|
||||
onClick={() => onNavigateToDetail(job.id)}
|
||||
>
|
||||
<div className="flex justify-between items-start gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-base truncate max-w-[50vw]" title={job.id}>
|
||||
{job.id}
|
||||
</h3>
|
||||
{job.currently_running && (
|
||||
<>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onInspect(job.id);
|
||||
}}
|
||||
disabled={isInspecting || isKilling}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-1" />
|
||||
Inspect
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onKill(job.id);
|
||||
}}
|
||||
disabled={isKilling || isInspecting}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
>
|
||||
<Square className="w-4 h-4 mr-1" />
|
||||
Kill
|
||||
</Button>
|
||||
</>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||
<span className="inline-block w-2 h-2 bg-green-500 rounded-full mr-1 animate-pulse"></span>
|
||||
Running
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(job.id);
|
||||
}}
|
||||
disabled={isPausing || isDeleting || isKilling || isInspecting}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
{job.paused && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300">
|
||||
<Pause className="w-3 h-3 mr-1" />
|
||||
Paused
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-text-muted text-sm mb-2 line-clamp-2" title={readableCron}>
|
||||
{readableCron}
|
||||
</p>
|
||||
<div className="flex items-center text-xs text-text-muted">
|
||||
<span>Last run: {formattedLastRun}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ScheduleCard.displayName = 'ScheduleCard';
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{!job.currently_running && (
|
||||
<>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(job);
|
||||
}}
|
||||
disabled={actionInProgress}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (job.paused) {
|
||||
onUnpause(job.id);
|
||||
} else {
|
||||
onPause(job.id);
|
||||
}
|
||||
}}
|
||||
disabled={actionInProgress}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
>
|
||||
{job.paused ? (
|
||||
<>
|
||||
<Play className="w-4 h-4 mr-1" />
|
||||
Resume
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pause className="w-4 h-4 mr-1" />
|
||||
Pause
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{job.currently_running && (
|
||||
<>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onInspect(job.id);
|
||||
}}
|
||||
disabled={actionInProgress}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-1" />
|
||||
Inspect
|
||||
</Button>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onKill(job.id);
|
||||
}}
|
||||
disabled={actionInProgress}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
>
|
||||
<Square className="w-4 h-4 mr-1" />
|
||||
Kill
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(job.id);
|
||||
}}
|
||||
disabled={actionInProgress}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose: _onClose }) => {
|
||||
const location = useLocation();
|
||||
@@ -218,33 +189,19 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose: _onClose }) => {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
const [submitApiError, setSubmitApiError] = useState<string | null>(null);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingSchedule, setEditingSchedule] = useState<ScheduledJob | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [pendingDeepLink, setPendingDeepLink] = useState<string | null>(null);
|
||||
|
||||
// Individual loading states for each action to prevent double-clicks
|
||||
const [pausingScheduleIds, setPausingScheduleIds] = useState<Set<string>>(new Set());
|
||||
const [deletingScheduleIds, setDeletingScheduleIds] = useState<Set<string>>(new Set());
|
||||
const [killingScheduleIds, setKillingScheduleIds] = useState<Set<string>>(new Set());
|
||||
const [inspectingScheduleIds, setInspectingScheduleIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const [actionsInProgress, setActionsInProgress] = useState<Set<string>>(new Set());
|
||||
const [viewingScheduleId, setViewingScheduleId] = useState<string | null>(null);
|
||||
|
||||
// Memoized fetch function to prevent unnecessary re-creation
|
||||
const fetchSchedules = useCallback(async (isRefresh = false) => {
|
||||
if (!isRefresh) setIsLoading(true);
|
||||
const fetchSchedules = async () => {
|
||||
setIsLoading(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
const fetchedSchedules = await listSchedules();
|
||||
setSchedules((prevSchedules) => {
|
||||
// Only update if schedules actually changed to prevent unnecessary re-renders
|
||||
if (JSON.stringify(prevSchedules) !== JSON.stringify(fetchedSchedules)) {
|
||||
return fetchedSchedules;
|
||||
}
|
||||
return prevSchedules;
|
||||
});
|
||||
setSchedules(fetchedSchedules);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch schedules:', error);
|
||||
setApiError(
|
||||
@@ -253,266 +210,181 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose: _onClose }) => {
|
||||
: 'An unknown error occurred while fetching schedules.'
|
||||
);
|
||||
} finally {
|
||||
if (!isRefresh) setIsLoading(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (viewingScheduleId === null) {
|
||||
fetchSchedules();
|
||||
|
||||
// Check for pending deep link from navigation state
|
||||
const locationState = location.state as ViewOptions | null;
|
||||
if (locationState?.pendingScheduleDeepLink) {
|
||||
setPendingDeepLink(locationState.pendingScheduleDeepLink);
|
||||
setIsCreateModalOpen(true);
|
||||
// Clear the state after reading it
|
||||
setIsModalOpen(true);
|
||||
window.history.replaceState({}, document.title);
|
||||
}
|
||||
}
|
||||
}, [viewingScheduleId, fetchSchedules, location.state]);
|
||||
}, [viewingScheduleId, location.state]);
|
||||
|
||||
// Optimized periodic refresh - only refresh if not actively doing something
|
||||
useEffect(() => {
|
||||
if (viewingScheduleId !== null) return;
|
||||
if (viewingScheduleId !== null || actionsInProgress.size > 0) return;
|
||||
|
||||
// Set up periodic refresh every 15 seconds (increased from 8 to reduce flashing)
|
||||
const intervalId = setInterval(() => {
|
||||
if (
|
||||
viewingScheduleId === null &&
|
||||
!isRefreshing &&
|
||||
!isLoading &&
|
||||
!isSubmitting &&
|
||||
pausingScheduleIds.size === 0 &&
|
||||
deletingScheduleIds.size === 0 &&
|
||||
killingScheduleIds.size === 0 &&
|
||||
inspectingScheduleIds.size === 0
|
||||
) {
|
||||
fetchSchedules(true); // Pass true to indicate this is a refresh
|
||||
if (viewingScheduleId === null && !isRefreshing && !isLoading && !isSubmitting) {
|
||||
fetchSchedules();
|
||||
}
|
||||
}, 15000); // Increased from 8000 to 15000 (15 seconds)
|
||||
}, 15000);
|
||||
|
||||
// Clean up on unmount
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [
|
||||
viewingScheduleId,
|
||||
isRefreshing,
|
||||
isLoading,
|
||||
isSubmitting,
|
||||
pausingScheduleIds.size,
|
||||
deletingScheduleIds.size,
|
||||
killingScheduleIds.size,
|
||||
inspectingScheduleIds.size,
|
||||
fetchSchedules,
|
||||
]);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [viewingScheduleId, isRefreshing, isLoading, isSubmitting, actionsInProgress.size]);
|
||||
|
||||
const handleOpenCreateModal = () => {
|
||||
setSubmitApiError(null);
|
||||
setIsCreateModalOpen(true);
|
||||
};
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await fetchSchedules();
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, [fetchSchedules]);
|
||||
|
||||
const handleCloseCreateModal = () => {
|
||||
setIsCreateModalOpen(false);
|
||||
setSubmitApiError(null);
|
||||
setPendingDeepLink(null);
|
||||
};
|
||||
|
||||
const handleOpenEditModal = (schedule: ScheduledJob) => {
|
||||
setEditingSchedule(schedule);
|
||||
setSubmitApiError(null);
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseEditModal = () => {
|
||||
setIsEditModalOpen(false);
|
||||
setEditingSchedule(null);
|
||||
setSubmitApiError(null);
|
||||
};
|
||||
|
||||
const handleCreateScheduleSubmit = async (payload: NewSchedulePayload) => {
|
||||
const handleModalSubmit = async (payload: NewSchedulePayload | string) => {
|
||||
setIsSubmitting(true);
|
||||
setSubmitApiError(null);
|
||||
try {
|
||||
await createSchedule(payload);
|
||||
if (editingSchedule) {
|
||||
await updateSchedule(editingSchedule.id, payload as string);
|
||||
toastSuccess({
|
||||
title: 'Schedule Updated',
|
||||
msg: `Successfully updated schedule "${editingSchedule.id}"`,
|
||||
});
|
||||
} else {
|
||||
await createSchedule(payload as NewSchedulePayload);
|
||||
}
|
||||
await fetchSchedules();
|
||||
setIsCreateModalOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to create schedule:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error creating schedule.';
|
||||
setSubmitApiError(errorMessage);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditScheduleSubmit = async (cron: string) => {
|
||||
if (!editingSchedule) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setSubmitApiError(null);
|
||||
try {
|
||||
await updateSchedule(editingSchedule.id, cron);
|
||||
toastSuccess({
|
||||
title: 'Schedule Updated',
|
||||
msg: `Successfully updated schedule "${editingSchedule.id}"`,
|
||||
});
|
||||
await fetchSchedules();
|
||||
setIsEditModalOpen(false);
|
||||
setIsModalOpen(false);
|
||||
setEditingSchedule(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to update schedule:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error updating schedule.';
|
||||
setSubmitApiError(errorMessage);
|
||||
toastError({
|
||||
title: 'Update Schedule Error',
|
||||
msg: errorMessage,
|
||||
});
|
||||
console.error('Failed to save schedule:', error);
|
||||
setSubmitApiError(error instanceof Error ? error.message : 'Unknown error saving schedule.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSchedule = async (idToDelete: string) => {
|
||||
if (!window.confirm(`Are you sure you want to delete schedule "${idToDelete}"?`)) return;
|
||||
const handleDeleteSchedule = async (id: string) => {
|
||||
if (!window.confirm(`Are you sure you want to delete schedule "${id}"?`)) return;
|
||||
|
||||
// Immediately add to deleting set to disable button
|
||||
setDeletingScheduleIds((prev) => new Set(prev).add(idToDelete));
|
||||
|
||||
if (viewingScheduleId === idToDelete) {
|
||||
setViewingScheduleId(null);
|
||||
}
|
||||
setActionsInProgress((prev) => new Set(prev).add(id));
|
||||
if (viewingScheduleId === id) setViewingScheduleId(null);
|
||||
setApiError(null);
|
||||
|
||||
try {
|
||||
await deleteSchedule(idToDelete);
|
||||
await deleteSchedule(id);
|
||||
await fetchSchedules();
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete schedule "${idToDelete}":`, error);
|
||||
setApiError(
|
||||
error instanceof Error ? error.message : `Unknown error deleting "${idToDelete}".`
|
||||
);
|
||||
console.error(`Failed to delete schedule "${id}":`, error);
|
||||
setApiError(error instanceof Error ? error.message : `Unknown error deleting "${id}".`);
|
||||
} finally {
|
||||
// Remove from deleting set
|
||||
setDeletingScheduleIds((prev) => {
|
||||
setActionsInProgress((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(idToDelete);
|
||||
newSet.delete(id);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handlePauseSchedule = async (idToPause: string) => {
|
||||
// Immediately add to pausing set to disable button
|
||||
setPausingScheduleIds((prev) => new Set(prev).add(idToPause));
|
||||
|
||||
const handlePauseSchedule = async (id: string) => {
|
||||
setActionsInProgress((prev) => new Set(prev).add(id));
|
||||
setApiError(null);
|
||||
|
||||
try {
|
||||
await pauseSchedule(idToPause);
|
||||
await pauseSchedule(id);
|
||||
toastSuccess({
|
||||
title: 'Schedule Paused',
|
||||
msg: `Successfully paused schedule "${idToPause}"`,
|
||||
msg: `Successfully paused schedule "${id}"`,
|
||||
});
|
||||
await fetchSchedules();
|
||||
} catch (error) {
|
||||
console.error(`Failed to pause schedule "${idToPause}":`, error);
|
||||
const errorMsg =
|
||||
error instanceof Error ? error.message : `Unknown error pausing "${idToPause}".`;
|
||||
console.error(`Failed to pause schedule "${id}":`, error);
|
||||
const errorMsg = error instanceof Error ? error.message : `Unknown error pausing "${id}".`;
|
||||
setApiError(errorMsg);
|
||||
toastError({
|
||||
title: 'Pause Schedule Error',
|
||||
msg: errorMsg,
|
||||
});
|
||||
} finally {
|
||||
// Remove from pausing set
|
||||
setPausingScheduleIds((prev) => {
|
||||
setActionsInProgress((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(idToPause);
|
||||
newSet.delete(id);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnpauseSchedule = async (idToUnpause: string) => {
|
||||
// Immediately add to pausing set to disable button
|
||||
setPausingScheduleIds((prev) => new Set(prev).add(idToUnpause));
|
||||
|
||||
const handleUnpauseSchedule = async (id: string) => {
|
||||
setActionsInProgress((prev) => new Set(prev).add(id));
|
||||
setApiError(null);
|
||||
|
||||
try {
|
||||
await unpauseSchedule(idToUnpause);
|
||||
await unpauseSchedule(id);
|
||||
toastSuccess({
|
||||
title: 'Schedule Unpaused',
|
||||
msg: `Successfully unpaused schedule "${idToUnpause}"`,
|
||||
msg: `Successfully unpaused schedule "${id}"`,
|
||||
});
|
||||
await fetchSchedules();
|
||||
} catch (error) {
|
||||
console.error(`Failed to unpause schedule "${idToUnpause}":`, error);
|
||||
const errorMsg =
|
||||
error instanceof Error ? error.message : `Unknown error unpausing "${idToUnpause}".`;
|
||||
console.error(`Failed to unpause schedule "${id}":`, error);
|
||||
const errorMsg = error instanceof Error ? error.message : `Unknown error unpausing "${id}".`;
|
||||
setApiError(errorMsg);
|
||||
toastError({
|
||||
title: 'Unpause Schedule Error',
|
||||
msg: errorMsg,
|
||||
});
|
||||
} finally {
|
||||
// Remove from pausing set
|
||||
setPausingScheduleIds((prev) => {
|
||||
setActionsInProgress((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(idToUnpause);
|
||||
newSet.delete(id);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleKillRunningJob = async (scheduleId: string) => {
|
||||
// Immediately add to killing set to disable button
|
||||
setKillingScheduleIds((prev) => new Set(prev).add(scheduleId));
|
||||
|
||||
const handleKillRunningJob = async (id: string) => {
|
||||
setActionsInProgress((prev) => new Set(prev).add(id));
|
||||
setApiError(null);
|
||||
|
||||
try {
|
||||
const result = await killRunningJob(scheduleId);
|
||||
const result = await killRunningJob(id);
|
||||
toastSuccess({
|
||||
title: 'Job Killed',
|
||||
msg: result.message,
|
||||
});
|
||||
await fetchSchedules();
|
||||
} catch (error) {
|
||||
console.error(`Failed to kill running job "${scheduleId}":`, error);
|
||||
console.error(`Failed to kill running job "${id}":`, error);
|
||||
const errorMsg =
|
||||
error instanceof Error ? error.message : `Unknown error killing job "${scheduleId}".`;
|
||||
error instanceof Error ? error.message : `Unknown error killing job "${id}".`;
|
||||
setApiError(errorMsg);
|
||||
toastError({
|
||||
title: 'Kill Job Error',
|
||||
msg: errorMsg,
|
||||
});
|
||||
} finally {
|
||||
// Remove from killing set
|
||||
setKillingScheduleIds((prev) => {
|
||||
setActionsInProgress((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(scheduleId);
|
||||
newSet.delete(id);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleInspectRunningJob = async (scheduleId: string) => {
|
||||
// Immediately add to inspecting set to disable button
|
||||
setInspectingScheduleIds((prev) => new Set(prev).add(scheduleId));
|
||||
|
||||
const handleInspectRunningJob = async (id: string) => {
|
||||
setActionsInProgress((prev) => new Set(prev).add(id));
|
||||
setApiError(null);
|
||||
|
||||
try {
|
||||
const result = await inspectRunningJob(scheduleId);
|
||||
const result = await inspectRunningJob(id);
|
||||
if (result.sessionId) {
|
||||
const duration = result.runningDurationSeconds
|
||||
? `${Math.floor(result.runningDurationSeconds / 60)}m ${result.runningDurationSeconds % 60}s`
|
||||
@@ -528,37 +400,28 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose: _onClose }) => {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to inspect running job "${scheduleId}":`, error);
|
||||
console.error(`Failed to inspect running job "${id}":`, error);
|
||||
const errorMsg =
|
||||
error instanceof Error ? error.message : `Unknown error inspecting job "${scheduleId}".`;
|
||||
error instanceof Error ? error.message : `Unknown error inspecting job "${id}".`;
|
||||
setApiError(errorMsg);
|
||||
toastError({
|
||||
title: 'Inspect Job Error',
|
||||
msg: errorMsg,
|
||||
});
|
||||
} finally {
|
||||
// Remove from inspecting set
|
||||
setInspectingScheduleIds((prev) => {
|
||||
setActionsInProgress((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(scheduleId);
|
||||
newSet.delete(id);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleNavigateToScheduleDetail = (scheduleId: string) => {
|
||||
setViewingScheduleId(scheduleId);
|
||||
};
|
||||
|
||||
const handleNavigateBackFromDetail = () => {
|
||||
setViewingScheduleId(null);
|
||||
};
|
||||
|
||||
if (viewingScheduleId) {
|
||||
return (
|
||||
<ScheduleDetailView
|
||||
scheduleId={viewingScheduleId}
|
||||
onNavigateBack={handleNavigateBackFromDetail}
|
||||
onNavigateBack={() => setViewingScheduleId(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -583,7 +446,10 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose: _onClose }) => {
|
||||
{isRefreshing ? 'Refreshing...' : 'Refresh'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleOpenCreateModal}
|
||||
onClick={() => {
|
||||
setSubmitApiError(null);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
@@ -626,18 +492,18 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose: _onClose }) => {
|
||||
<ScheduleCard
|
||||
key={job.id}
|
||||
job={job}
|
||||
onNavigateToDetail={handleNavigateToScheduleDetail}
|
||||
onEdit={handleOpenEditModal}
|
||||
onNavigateToDetail={setViewingScheduleId}
|
||||
onEdit={(schedule) => {
|
||||
setEditingSchedule(schedule);
|
||||
setSubmitApiError(null);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
onPause={handlePauseSchedule}
|
||||
onUnpause={handleUnpauseSchedule}
|
||||
onKill={handleKillRunningJob}
|
||||
onInspect={handleInspectRunningJob}
|
||||
onDelete={handleDeleteSchedule}
|
||||
isPausing={pausingScheduleIds.has(job.id)}
|
||||
isDeleting={deletingScheduleIds.has(job.id)}
|
||||
isKilling={killingScheduleIds.has(job.id)}
|
||||
isInspecting={inspectingScheduleIds.has(job.id)}
|
||||
isSubmitting={isSubmitting}
|
||||
actionInProgress={actionsInProgress.has(job.id) || isSubmitting}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -648,21 +514,19 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose: _onClose }) => {
|
||||
</div>
|
||||
</MainPanelLayout>
|
||||
|
||||
<CreateScheduleModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={handleCloseCreateModal}
|
||||
onSubmit={handleCreateScheduleSubmit}
|
||||
isLoadingExternally={isSubmitting}
|
||||
apiErrorExternally={submitApiError}
|
||||
initialDeepLink={pendingDeepLink}
|
||||
/>
|
||||
<EditScheduleModal
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={handleCloseEditModal}
|
||||
onSubmit={handleEditScheduleSubmit}
|
||||
<ScheduleModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => {
|
||||
setIsModalOpen(false);
|
||||
setEditingSchedule(null);
|
||||
setSubmitApiError(null);
|
||||
setPendingDeepLink(null);
|
||||
}}
|
||||
onSubmit={handleModalSubmit}
|
||||
schedule={editingSchedule}
|
||||
isLoadingExternally={isSubmitting}
|
||||
apiErrorExternally={submitApiError}
|
||||
initialDeepLink={pendingDeepLink}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
runNowHandler as apiRunScheduleNow,
|
||||
killRunningJob as apiKillRunningJob,
|
||||
inspectRunningJob as apiInspectRunningJob,
|
||||
SessionDisplayInfo,
|
||||
} from './api';
|
||||
|
||||
export interface ScheduledJob {
|
||||
@@ -20,7 +21,6 @@ export interface ScheduledJob {
|
||||
paused?: boolean;
|
||||
current_session_id?: string | null;
|
||||
process_start_time?: string | null;
|
||||
execution_mode?: string | null; // "foreground" or "background"
|
||||
}
|
||||
|
||||
export interface ScheduleSession {
|
||||
@@ -82,23 +82,15 @@ export async function deleteSchedule(id: string): Promise<void> {
|
||||
|
||||
export async function getScheduleSessions(
|
||||
scheduleId: string,
|
||||
limit?: number
|
||||
): Promise<ScheduleSession[]> {
|
||||
try {
|
||||
const response = await apiGetScheduleSessions<true>({
|
||||
path: { id: scheduleId },
|
||||
query: { limit },
|
||||
});
|
||||
limit: number
|
||||
): Promise<Array<SessionDisplayInfo>> {
|
||||
const response = await apiGetScheduleSessions<true>({
|
||||
path: { id: scheduleId },
|
||||
query: { limit },
|
||||
throwOnError: true,
|
||||
});
|
||||
|
||||
if (response && response.data) {
|
||||
return response.data as ScheduleSession[];
|
||||
}
|
||||
console.error('Unexpected response format from apiGetScheduleSessions', response);
|
||||
throw new Error('Failed to get schedule sessions: Unexpected response format');
|
||||
} catch (error) {
|
||||
console.error(`Error fetching sessions for schedule ${scheduleId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function runScheduleNow(scheduleId: string): Promise<string> {
|
||||
|
||||
Reference in New Issue
Block a user