diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index aa59e60a32..24e2d5153f 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -41,6 +41,7 @@ use utoipa::OpenApi; super::routes::schedule::create_schedule, super::routes::schedule::list_schedules, super::routes::schedule::delete_schedule, + super::routes::schedule::update_schedule, super::routes::schedule::run_now_handler, super::routes::schedule::pause_schedule, super::routes::schedule::unpause_schedule, @@ -93,6 +94,7 @@ use utoipa::OpenApi; SessionInfo, SessionMetadata, super::routes::schedule::CreateScheduleRequest, + super::routes::schedule::UpdateScheduleRequest, goose::scheduler::ScheduledJob, super::routes::schedule::RunNowResponse, super::routes::schedule::ListSchedulesResponse, diff --git a/crates/goose-server/src/routes/schedule.rs b/crates/goose-server/src/routes/schedule.rs index 69ac8c2efa..fb7d852aba 100644 --- a/crates/goose-server/src/routes/schedule.rs +++ b/crates/goose-server/src/routes/schedule.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use axum::{ extract::{Path, Query, State}, http::{HeaderMap, StatusCode}, - routing::{delete, get, post}, + routing::{delete, get, post, put}, Json, Router, }; use serde::{Deserialize, Serialize}; @@ -21,6 +21,11 @@ pub struct CreateScheduleRequest { cron: String, } +#[derive(Deserialize, Serialize, utoipa::ToSchema)] +pub struct UpdateScheduleRequest { + cron: String, +} + #[derive(Serialize, utoipa::ToSchema)] pub struct ListSchedulesResponse { jobs: Vec, @@ -333,11 +338,63 @@ async fn unpause_schedule( Ok(StatusCode::NO_CONTENT) } +#[utoipa::path( + put, + path = "/schedule/{id}", + params( + ("id" = String, Path, description = "ID of the schedule to update") + ), + request_body = UpdateScheduleRequest, + responses( + (status = 200, description = "Scheduled job updated successfully", body = ScheduledJob), + (status = 404, description = "Scheduled job not found"), + (status = 400, description = "Cannot update a currently running job or invalid request"), + (status = 500, description = "Internal server error") + ), + tag = "schedule" +)] +#[axum::debug_handler] +async fn update_schedule( + State(state): State>, + headers: HeaderMap, + Path(id): Path, + Json(req): Json, +) -> Result, StatusCode> { + verify_secret_key(&headers, &state)?; + let scheduler = state + .scheduler() + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + scheduler + .update_schedule(&id, req.cron) + .await + .map_err(|e| { + eprintln!("Error updating schedule '{}': {:?}", id, e); + match e { + goose::scheduler::SchedulerError::JobNotFound(_) => StatusCode::NOT_FOUND, + goose::scheduler::SchedulerError::AnyhowError(_) => StatusCode::BAD_REQUEST, + goose::scheduler::SchedulerError::CronParseError(_) => StatusCode::BAD_REQUEST, + _ => StatusCode::INTERNAL_SERVER_ERROR, + } + })?; + + // Return the updated schedule + let jobs = scheduler.list_scheduled_jobs().await; + let updated_job = jobs + .into_iter() + .find(|job| job.id == id) + .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(updated_job)) +} + pub fn routes(state: Arc) -> Router { Router::new() .route("/schedule/create", post(create_schedule)) .route("/schedule/list", get(list_schedules)) .route("/schedule/delete/{id}", delete(delete_schedule)) // Corrected + .route("/schedule/{id}", put(update_schedule)) .route("/schedule/{id}/run_now", post(run_now_handler)) // Corrected .route("/schedule/{id}/pause", post(pause_schedule)) .route("/schedule/{id}/unpause", post(unpause_schedule)) diff --git a/crates/goose/src/scheduler.rs b/crates/goose/src/scheduler.rs index 6d1112269d..32a1aea977 100644 --- a/crates/goose/src/scheduler.rs +++ b/crates/goose/src/scheduler.rs @@ -577,6 +577,138 @@ impl Scheduler { None => Err(SchedulerError::JobNotFound(sched_id.to_string())), } } + + pub async fn update_schedule( + &self, + sched_id: &str, + new_cron: String, + ) -> Result<(), SchedulerError> { + let mut jobs_guard = self.jobs.lock().await; + match jobs_guard.get_mut(sched_id) { + Some((job_uuid, job_def)) => { + if job_def.currently_running { + return Err(SchedulerError::AnyhowError(anyhow!( + "Cannot edit schedule '{}' while it's currently running", + sched_id + ))); + } + + if new_cron == job_def.cron { + // No change needed + return Ok(()); + } + + // Remove the old job from the scheduler + self.internal_scheduler + .remove(job_uuid) + .await + .map_err(|e| SchedulerError::SchedulerInternalError(e.to_string()))?; + + // Create new job with updated cron + let job_for_task = job_def.clone(); + let jobs_arc_for_task = self.jobs.clone(); + let storage_path_for_task = self.storage_path.clone(); + + let cron_task = Job::new_async(&new_cron, move |_uuid, _l| { + let task_job_id = job_for_task.id.clone(); + let current_jobs_arc = jobs_arc_for_task.clone(); + let local_storage_path = storage_path_for_task.clone(); + let job_to_execute = job_for_task.clone(); + + Box::pin(async move { + // Check if the job is paused before executing + let should_execute = { + let jobs_map_guard = current_jobs_arc.lock().await; + if let Some((_, current_job_in_map)) = jobs_map_guard.get(&task_job_id) + { + !current_job_in_map.paused + } else { + false + } + }; + + if !should_execute { + tracing::info!("Skipping execution of paused job '{}'", &task_job_id); + return; + } + + let current_time = Utc::now(); + let mut needs_persist = false; + { + let mut jobs_map_guard = current_jobs_arc.lock().await; + if let Some((_, current_job_in_map)) = + jobs_map_guard.get_mut(&task_job_id) + { + current_job_in_map.last_run = Some(current_time); + current_job_in_map.currently_running = true; + needs_persist = true; + } + } + + if needs_persist { + if let Err(e) = + persist_jobs_from_arc(&local_storage_path, ¤t_jobs_arc).await + { + tracing::error!( + "Failed to persist last_run update for job {}: {}", + &task_job_id, + e + ); + } + } + + let result = run_scheduled_job_internal(job_to_execute, None).await; + + // Update the job status after execution + { + let mut jobs_map_guard = current_jobs_arc.lock().await; + if let Some((_, current_job_in_map)) = + jobs_map_guard.get_mut(&task_job_id) + { + current_job_in_map.currently_running = false; + needs_persist = true; + } + } + + if needs_persist { + if let Err(e) = + persist_jobs_from_arc(&local_storage_path, ¤t_jobs_arc).await + { + tracing::error!( + "Failed to persist running status update for job {}: {}", + &task_job_id, + e + ); + } + } + + if let Err(e) = result { + tracing::error!( + "Scheduled job '{}' execution failed: {}", + &e.job_id, + e.error + ); + } + }) + }) + .map_err(|e| SchedulerError::CronParseError(e.to_string()))?; + + let new_job_uuid = self + .internal_scheduler + .add(cron_task) + .await + .map_err(|e| SchedulerError::SchedulerInternalError(e.to_string()))?; + + // Update the job UUID and cron expression + *job_uuid = new_job_uuid; + job_def.cron = new_cron; + + self.persist_jobs_to_storage_with_guard(&jobs_guard).await?; + Ok(()) + } + None => Err(SchedulerError::JobNotFound(sched_id.to_string())), + } + } } #[derive(Debug)] diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 79005c9074..1f4c197c6d 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -539,6 +539,56 @@ } } }, + "/schedule/{id}": { + "put": { + "tags": [ + "schedule" + ], + "operationId": "update_schedule", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of the schedule to update", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateScheduleRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Scheduled job updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScheduledJob" + } + } + } + }, + "400": { + "description": "Cannot update a currently running job or invalid request" + }, + "404": { + "description": "Scheduled job not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/schedule/{id}/pause": { "post": { "tags": [ @@ -2198,6 +2248,17 @@ "success": true } }, + "UpdateScheduleRequest": { + "type": "object", + "required": [ + "cron" + ], + "properties": { + "cron": { + "type": "string" + } + } + }, "UpsertConfigQuery": { "type": "object", "required": [ diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index 75b7e9e6b8..7947294530 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts import type { Options as ClientOptions, TDataShape, Client } from '@hey-api/client-fetch'; -import type { GetToolsData, GetToolsResponse, ReadAllConfigData, ReadAllConfigResponse, BackupConfigData, BackupConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, InitConfigData, InitConfigResponse, UpsertPermissionsData, UpsertPermissionsResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse, ConfirmPermissionData, ManageContextData, ManageContextResponse, CreateScheduleData, CreateScheduleResponse, DeleteScheduleData, DeleteScheduleResponse, ListSchedulesData, ListSchedulesResponse2, PauseScheduleData, PauseScheduleResponse, RunNowHandlerData, RunNowHandlerResponse, SessionsHandlerData, SessionsHandlerResponse, UnpauseScheduleData, UnpauseScheduleResponse, ListSessionsData, ListSessionsResponse, GetSessionHistoryData, GetSessionHistoryResponse } from './types.gen'; +import type { GetToolsData, GetToolsResponse, ReadAllConfigData, ReadAllConfigResponse, BackupConfigData, BackupConfigResponse, GetExtensionsData, GetExtensionsResponse, AddExtensionData, AddExtensionResponse, RemoveExtensionData, RemoveExtensionResponse, InitConfigData, InitConfigResponse, UpsertPermissionsData, UpsertPermissionsResponse, ProvidersData, ProvidersResponse2, ReadConfigData, RemoveConfigData, RemoveConfigResponse, UpsertConfigData, UpsertConfigResponse, ConfirmPermissionData, ManageContextData, ManageContextResponse, CreateScheduleData, CreateScheduleResponse, DeleteScheduleData, DeleteScheduleResponse, ListSchedulesData, ListSchedulesResponse2, UpdateScheduleData, UpdateScheduleResponse, PauseScheduleData, PauseScheduleResponse, RunNowHandlerData, RunNowHandlerResponse, SessionsHandlerData, SessionsHandlerResponse, UnpauseScheduleData, UnpauseScheduleResponse, ListSessionsData, ListSessionsResponse, GetSessionHistoryData, GetSessionHistoryResponse } from './types.gen'; import { client as _heyApiClient } from './client.gen'; export type Options = ClientOptions & { @@ -169,6 +169,17 @@ export const listSchedules = (options?: Op }); }; +export const updateSchedule = (options: Options) => { + return (options.client ?? _heyApiClient).put({ + url: '/schedule/{id}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers + } + }); +}; + export const pauseSchedule = (options: Options) => { return (options.client ?? _heyApiClient).post({ url: '/schedule/{id}/pause', diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 0916830fb4..a032086c01 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -529,6 +529,10 @@ export type ToolResultSchema = { success: boolean; }; +export type UpdateScheduleRequest = { + cron: string; +}; + export type UpsertConfigQuery = { is_secret: boolean; key: string; @@ -964,6 +968,42 @@ export type ListSchedulesResponses = { export type ListSchedulesResponse2 = ListSchedulesResponses[keyof ListSchedulesResponses]; +export type UpdateScheduleData = { + body: UpdateScheduleRequest; + path: { + /** + * ID of the schedule to update + */ + id: string; + }; + query?: never; + url: '/schedule/{id}'; +}; + +export type UpdateScheduleErrors = { + /** + * Cannot update a currently running job or invalid request + */ + 400: unknown; + /** + * Scheduled job not found + */ + 404: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type UpdateScheduleResponses = { + /** + * Scheduled job updated successfully + */ + 200: ScheduledJob; +}; + +export type UpdateScheduleResponse = UpdateScheduleResponses[keyof UpdateScheduleResponses]; + export type PauseScheduleData = { body?: never; path: { diff --git a/ui/desktop/src/components/schedule/EditScheduleModal.tsx b/ui/desktop/src/components/schedule/EditScheduleModal.tsx new file mode 100644 index 0000000000..4859180b02 --- /dev/null +++ b/ui/desktop/src/components/schedule/EditScheduleModal.tsx @@ -0,0 +1,434 @@ +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' | 'hourly' | 'daily' | 'weekly' | 'monthly'; + +interface FrequencyOption { + value: FrequencyValue; + label: string; +} + +interface EditScheduleModalProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (cron: string) => Promise; + schedule: ScheduledJob | null; + isLoadingExternally?: boolean; + apiErrorExternally?: string | null; +} + +const frequencies: FrequencyOption[] = [ + { value: 'once', label: 'Once' }, + { value: 'hourly', label: 'Hourly' }, + { value: 'daily', label: 'Daily' }, + { value: 'weekly', label: 'Weekly' }, + { value: 'monthly', label: 'Monthly' }, +]; + +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 !== 6) return null; + + const [_seconds, minutes, hours, dayOfMonth, month, dayOfWeek] = parts; + + // 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: 'hourly' as FrequencyValue, minutes }; + } + 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 = ({ + isOpen, + onClose, + onSubmit, + schedule, + isLoadingExternally = false, + apiErrorExternally = null, +}) => { + const [frequency, setFrequency] = useState('daily'); + const [selectedDate, setSelectedDate] = useState( + () => new Date().toISOString().split('T')[0] + ); + const [selectedTime, setSelectedTime] = useState('09:00'); + const [selectedMinute, setSelectedMinute] = useState('0'); + const [selectedDaysOfWeek, setSelectedDaysOfWeek] = useState>(new Set(['1'])); + const [selectedDayOfMonth, setSelectedDayOfMonth] = useState('1'); + const [derivedCronExpression, setDerivedCronExpression] = useState(''); + const [readableCronExpression, setReadableCronExpression] = useState(''); + const [internalValidationError, setInternalValidationError] = useState(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 'hourly': + setSelectedMinute(parsed.minutes || '0'); + 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.'; + } + const secondsPart = '0'; + 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 `${secondsPart} ${dateObj.getMinutes()} ${dateObj.getHours()} ${dateObj.getDate()} ${ + dateObj.getMonth() + 1 + } *`; + } catch (e) { + return "Error parsing date/time for 'once'."; + } + } + return 'Date and Time are required for "Once" frequency.'; + case 'hourly': { + const sMinute = parseInt(selectedMinute, 10); + if (isNaN(sMinute) || sMinute < 0 || sMinute > 59) { + return 'Invalid minute (0-59) for hourly frequency.'; + } + return `${secondsPart} ${sMinute} * * * *`; + } + case 'daily': + return `${secondsPart} ${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 `${secondsPart} ${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 `${secondsPart} ${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 (e) { + setReadableCronExpression('Could not parse cron string.'); + } + }, [ + frequency, + 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 ( +
+ +
+

+ Edit Schedule: {schedule?.id || ''} +

+
+ +
+ {apiErrorExternally && ( +

+ {apiErrorExternally} +

+ )} + {internalValidationError && ( +

+ {internalValidationError} +

+ )} + +
+ + setSelectedDate(e.target.value)} + required + /> +
+
+ + setSelectedTime(e.target.value)} + required + /> +
+ + )} + {frequency === 'hourly' && ( +
+ + setSelectedMinute(e.target.value)} + required + /> +
+ )} + {(frequency === 'daily' || frequency === 'weekly' || frequency === 'monthly') && ( +
+ + setSelectedTime(e.target.value)} + required + /> +
+ )} + {frequency === 'weekly' && ( +
+ +
+ {daysOfWeekOptions.map((day) => ( + + ))} +
+
+ )} + {frequency === 'monthly' && ( +
+ + setSelectedDayOfMonth(e.target.value)} + required + /> +
+ )} +
+

+ Generated Cron:{' '} + + {derivedCronExpression} + +

+

+ Human Readable: {readableCronExpression} +

+

Syntax: S M H D M DoW. (S=0, DoW: 0/7=Sun)

+ {frequency === 'once' && ( +

+ Note: "Once" schedules recur annually. True one-time tasks may need backend deletion + after execution. +

+ )} +
+
+ + {/* Actions */} +
+ + +
+
+
+ ); +}; \ No newline at end of file diff --git a/ui/desktop/src/components/schedule/ScheduleDetailView.tsx b/ui/desktop/src/components/schedule/ScheduleDetailView.tsx index 764a3a09df..3b4bf70c2a 100644 --- a/ui/desktop/src/components/schedule/ScheduleDetailView.tsx +++ b/ui/desktop/src/components/schedule/ScheduleDetailView.tsx @@ -5,10 +5,11 @@ import BackButton from '../ui/BackButton'; import { Card } from '../ui/card'; import MoreMenuLayout from '../more_menu/MoreMenuLayout'; import { fetchSessionDetails, SessionDetails } from '../../sessions'; -import { getScheduleSessions, runScheduleNow, pauseSchedule, unpauseSchedule, listSchedules, ScheduledJob } from '../../schedule'; +import { getScheduleSessions, runScheduleNow, pauseSchedule, unpauseSchedule, updateSchedule, listSchedules, ScheduledJob } from '../../schedule'; import SessionHistoryView from '../sessions/SessionHistoryView'; +import { EditScheduleModal } from './EditScheduleModal'; import { toastError, toastSuccess } from '../../toasts'; -import { Loader2, Pause, Play } from 'lucide-react'; +import { Loader2, Pause, Play, Edit } from 'lucide-react'; import cronstrue from 'cronstrue'; interface ScheduleSessionMeta { @@ -39,10 +40,16 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN const [scheduleDetails, setScheduleDetails] = useState(null); const [isLoadingSchedule, setIsLoadingSchedule] = useState(false); const [scheduleError, setScheduleError] = useState(null); + + // Individual loading states for each action to prevent double-clicks + const [pauseUnpauseLoading, setPauseUnpauseLoading] = useState(false); const [selectedSessionDetails, setSelectedSessionDetails] = useState(null); const [isLoadingSessionDetails, setIsLoadingSessionDetails] = useState(false); const [sessionDetailsError, setSessionDetailsError] = useState(null); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [editApiError, setEditApiError] = useState(null); + const [isEditSubmitting, setIsEditSubmitting] = useState(false); const fetchScheduleSessions = useCallback(async (sId: string) => { if (!sId) return; @@ -130,6 +137,7 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN const handlePauseSchedule = async () => { if (!scheduleId) return; + setPauseUnpauseLoading(true); try { await pauseSchedule(scheduleId); toastSuccess({ @@ -141,11 +149,14 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN 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); } }; const handleUnpauseSchedule = async () => { if (!scheduleId) return; + setPauseUnpauseLoading(true); try { await unpauseSchedule(scheduleId); toastSuccess({ @@ -157,6 +168,41 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN 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 }); + } finally { + setPauseUnpauseLoading(false); + } + }; + + const handleOpenEditModal = () => { + setEditApiError(null); + setIsEditModalOpen(true); + }; + + const handleCloseEditModal = () => { + setIsEditModalOpen(false); + setEditApiError(null); + }; + + 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}"`, + }); + 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); } }; @@ -335,27 +381,39 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN {scheduleDetails && !scheduleDetails.currently_running && ( - + <> + + + )} @@ -444,6 +502,14 @@ const ScheduleDetailView: React.FC = ({ scheduleId, onN + ); }; diff --git a/ui/desktop/src/components/schedule/SchedulesView.tsx b/ui/desktop/src/components/schedule/SchedulesView.tsx index 10f3a7f6c5..18dd5fe66b 100644 --- a/ui/desktop/src/components/schedule/SchedulesView.tsx +++ b/ui/desktop/src/components/schedule/SchedulesView.tsx @@ -1,13 +1,14 @@ import React, { useState, useEffect } from 'react'; -import { listSchedules, createSchedule, deleteSchedule, pauseSchedule, unpauseSchedule, ScheduledJob } from '../../schedule'; +import { listSchedules, createSchedule, deleteSchedule, pauseSchedule, unpauseSchedule, updateSchedule, ScheduledJob } from '../../schedule'; import BackButton from '../ui/BackButton'; import { ScrollArea } from '../ui/scroll-area'; import MoreMenuLayout from '../more_menu/MoreMenuLayout'; import { Card } from '../ui/card'; import { Button } from '../ui/button'; import { TrashIcon } from '../icons/TrashIcon'; -import { Plus, RefreshCw, Pause, Play } from 'lucide-react'; +import { Plus, RefreshCw, Pause, Play, Edit } from 'lucide-react'; import { CreateScheduleModal, NewSchedulePayload } from './CreateScheduleModal'; +import { EditScheduleModal } from './EditScheduleModal'; import ScheduleDetailView from './ScheduleDetailView'; import { toastError, toastSuccess } from '../../toasts'; import cronstrue from 'cronstrue'; @@ -23,7 +24,13 @@ const SchedulesView: React.FC = ({ onClose }) => { const [apiError, setApiError] = useState(null); const [submitApiError, setSubmitApiError] = useState(null); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [editingSchedule, setEditingSchedule] = useState(null); const [isRefreshing, setIsRefreshing] = useState(false); + + // Individual loading states for each action to prevent double-clicks + const [pausingScheduleIds, setPausingScheduleIds] = useState>(new Set()); + const [deletingScheduleIds, setDeletingScheduleIds] = useState>(new Set()); const [viewingScheduleId, setViewingScheduleId] = useState(null); @@ -87,6 +94,18 @@ const SchedulesView: React.FC = ({ onClose }) => { setSubmitApiError(null); }; + const handleOpenEditModal = (schedule: ScheduledJob) => { + setEditingSchedule(schedule); + setSubmitApiError(null); + setIsEditModalOpen(true); + }; + + const handleCloseEditModal = () => { + setIsEditModalOpen(false); + setEditingSchedule(null); + setSubmitApiError(null); + }; + const handleCreateScheduleSubmit = async (payload: NewSchedulePayload) => { setIsSubmitting(true); setSubmitApiError(null); @@ -104,12 +123,43 @@ const SchedulesView: React.FC = ({ onClose }) => { } }; + 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); + 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, + }); + } finally { + setIsSubmitting(false); + } + }; + const handleDeleteSchedule = async (idToDelete: string) => { if (!window.confirm(`Are you sure you want to delete schedule "${idToDelete}"?`)) return; + + // Immediately add to deleting set to disable button + setDeletingScheduleIds(prev => new Set(prev).add(idToDelete)); + if (viewingScheduleId === idToDelete) { setViewingScheduleId(null); } - setIsLoading(true); setApiError(null); try { await deleteSchedule(idToDelete); @@ -120,12 +170,19 @@ const SchedulesView: React.FC = ({ onClose }) => { error instanceof Error ? error.message : `Unknown error deleting "${idToDelete}".` ); } finally { - setIsLoading(false); + // Remove from deleting set + setDeletingScheduleIds(prev => { + const newSet = new Set(prev); + newSet.delete(idToDelete); + return newSet; + }); } }; const handlePauseSchedule = async (idToPause: string) => { - setIsLoading(true); + // Immediately add to pausing set to disable button + setPausingScheduleIds(prev => new Set(prev).add(idToPause)); + setApiError(null); try { await pauseSchedule(idToPause); @@ -143,12 +200,19 @@ const SchedulesView: React.FC = ({ onClose }) => { msg: errorMsg, }); } finally { - setIsLoading(false); + // Remove from pausing set + setPausingScheduleIds(prev => { + const newSet = new Set(prev); + newSet.delete(idToPause); + return newSet; + }); } }; const handleUnpauseSchedule = async (idToUnpause: string) => { - setIsLoading(true); + // Immediately add to pausing set to disable button + setPausingScheduleIds(prev => new Set(prev).add(idToUnpause)); + setApiError(null); try { await unpauseSchedule(idToUnpause); @@ -166,7 +230,12 @@ const SchedulesView: React.FC = ({ onClose }) => { msg: errorMsg, }); } finally { - setIsLoading(false); + // Remove from pausing set + setPausingScheduleIds(prev => { + const newSet = new Set(prev); + newSet.delete(idToUnpause); + return newSet; + }); } }; @@ -293,27 +362,42 @@ const SchedulesView: React.FC = ({ onClose }) => {
{!job.currently_running && ( - + <> + + + )} @@ -344,6 +428,14 @@ const SchedulesView: React.FC = ({ onClose }) => { isLoadingExternally={isSubmitting} apiErrorExternally={submitApiError} /> +
); }; diff --git a/ui/desktop/src/schedule.ts b/ui/desktop/src/schedule.ts index 1efc0cf64a..4c300166ef 100644 --- a/ui/desktop/src/schedule.ts +++ b/ui/desktop/src/schedule.ts @@ -4,6 +4,7 @@ import { deleteSchedule as apiDeleteSchedule, pauseSchedule as apiPauseSchedule, unpauseSchedule as apiUnpauseSchedule, + updateSchedule as apiUpdateSchedule, sessionsHandler as apiGetScheduleSessions, runNowHandler as apiRunScheduleNow, } from './api'; @@ -132,3 +133,21 @@ export async function unpauseSchedule(scheduleId: string): Promise { throw error; } } + +export async function updateSchedule(scheduleId: string, cron: string): Promise { + try { + const response = await apiUpdateSchedule({ + path: { id: scheduleId }, + body: { cron }, + }); + + if (response && response.data) { + return response.data as ScheduledJob; + } + console.error('Unexpected response format from apiUpdateSchedule', response); + throw new Error('Failed to update schedule: Unexpected response format'); + } catch (error) { + console.error(`Error updating schedule ${scheduleId}:`, error); + throw error; + } +}