mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
feat: add edit schedule functionality (#2700)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<ScheduledJob>,
|
||||
@@ -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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<UpdateScheduleRequest>,
|
||||
) -> Result<Json<ScheduledJob>, 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<AppState>) -> 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))
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & {
|
||||
@@ -169,6 +169,17 @@ export const listSchedules = <ThrowOnError extends boolean = false>(options?: Op
|
||||
});
|
||||
};
|
||||
|
||||
export const updateSchedule = <ThrowOnError extends boolean = false>(options: Options<UpdateScheduleData, ThrowOnError>) => {
|
||||
return (options.client ?? _heyApiClient).put<UpdateScheduleResponse, unknown, ThrowOnError>({
|
||||
url: '/schedule/{id}',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const pauseSchedule = <ThrowOnError extends boolean = false>(options: Options<PauseScheduleData, ThrowOnError>) => {
|
||||
return (options.client ?? _heyApiClient).post<PauseScheduleResponse, unknown, ThrowOnError>({
|
||||
url: '/schedule/{id}/pause',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<void>;
|
||||
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<EditScheduleModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
schedule,
|
||||
isLoadingExternally = false,
|
||||
apiErrorExternally = null,
|
||||
}) => {
|
||||
const [frequency, setFrequency] = useState<FrequencyValue>('daily');
|
||||
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);
|
||||
|
||||
// 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 (
|
||||
<div className="fixed inset-0 bg-black/20 backdrop-blur-sm z-40 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md bg-bgApp 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={(selectedOption: FrequencyOption | null) => {
|
||||
if (selectedOption) setFrequency(selectedOption.value);
|
||||
}}
|
||||
placeholder="Select frequency..."
|
||||
/>
|
||||
</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 === 'hourly' && (
|
||||
<div>
|
||||
<label htmlFor="hourlyMinute-modal" className={modalLabelClassName}>
|
||||
Minute of the hour (0-59):
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
id="hourlyMinute-modal"
|
||||
min="0"
|
||||
max="59"
|
||||
value={selectedMinute}
|
||||
onChange={(e) => setSelectedMinute(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: S M H D M DoW. (S=0, DoW: 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="default"
|
||||
disabled={isLoadingExternally}
|
||||
className="w-full h-[60px] rounded-none border-t dark:border-gray-600 text-lg dark:text-white dark:border-gray-600 font-regular"
|
||||
>
|
||||
{isLoadingExternally ? 'Updating...' : 'Update Schedule'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<ScheduleDetailViewProps> = ({ scheduleId, onN
|
||||
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 [selectedSessionDetails, setSelectedSessionDetails] = useState<SessionDetails | 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 fetchScheduleSessions = useCallback(async (sId: string) => {
|
||||
if (!sId) return;
|
||||
@@ -130,6 +137,7 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
||||
|
||||
const handlePauseSchedule = async () => {
|
||||
if (!scheduleId) return;
|
||||
setPauseUnpauseLoading(true);
|
||||
try {
|
||||
await pauseSchedule(scheduleId);
|
||||
toastSuccess({
|
||||
@@ -141,11 +149,14 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ 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<ScheduleDetailViewProps> = ({ 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<ScheduleDetailViewProps> = ({ scheduleId, onN
|
||||
</Button>
|
||||
|
||||
{scheduleDetails && !scheduleDetails.currently_running && (
|
||||
<Button
|
||||
onClick={scheduleDetails.paused ? handleUnpauseSchedule : handlePauseSchedule}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
{scheduleDetails.paused ? (
|
||||
<>
|
||||
<Play className="w-4 h-4" />
|
||||
Unpause Schedule
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pause className="w-4 h-4" />
|
||||
Pause Schedule
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
onClick={handleOpenEditModal}
|
||||
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}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
Edit Schedule
|
||||
</Button>
|
||||
<Button
|
||||
onClick={scheduleDetails.paused ? handleUnpauseSchedule : handlePauseSchedule}
|
||||
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}
|
||||
>
|
||||
{scheduleDetails.paused ? (
|
||||
<>
|
||||
<Play className="w-4 h-4" />
|
||||
{pauseUnpauseLoading ? 'Unpausing...' : 'Unpause Schedule'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pause className="w-4 h-4" />
|
||||
{pauseUnpauseLoading ? 'Pausing...' : 'Pause Schedule'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -444,6 +502,14 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
||||
</section>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<EditScheduleModal
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={handleCloseEditModal}
|
||||
onSubmit={handleEditScheduleSubmit}
|
||||
schedule={scheduleDetails}
|
||||
isLoadingExternally={isEditSubmitting}
|
||||
apiErrorExternally={editApiError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<SchedulesViewProps> = ({ onClose }) => {
|
||||
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 [editingSchedule, setEditingSchedule] = useState<ScheduledJob | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
// 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 [viewingScheduleId, setViewingScheduleId] = useState<string | null>(null);
|
||||
|
||||
@@ -87,6 +94,18 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ 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<SchedulesViewProps> = ({ 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<SchedulesViewProps> = ({ 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<SchedulesViewProps> = ({ 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<SchedulesViewProps> = ({ 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<SchedulesViewProps> = ({ onClose }) => {
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex items-center gap-1">
|
||||
{!job.currently_running && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (job.paused) {
|
||||
handleUnpauseSchedule(job.id);
|
||||
} else {
|
||||
handlePauseSchedule(job.id);
|
||||
}
|
||||
}}
|
||||
className={`${
|
||||
job.paused
|
||||
? 'text-green-500 dark:text-green-400 hover:text-green-600 dark:hover:text-green-300 hover:bg-green-100/50 dark:hover:bg-green-900/30'
|
||||
: 'text-orange-500 dark:text-orange-400 hover:text-orange-600 dark:hover:text-orange-300 hover:bg-orange-100/50 dark:hover:bg-orange-900/30'
|
||||
}`}
|
||||
title={job.paused ? `Unpause schedule ${job.id}` : `Pause schedule ${job.id}`}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{job.paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleOpenEditModal(job);
|
||||
}}
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 hover:bg-blue-100/50 dark:hover:bg-blue-900/30"
|
||||
title={`Edit schedule ${job.id}`}
|
||||
disabled={pausingScheduleIds.has(job.id) || deletingScheduleIds.has(job.id) || isSubmitting}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (job.paused) {
|
||||
handleUnpauseSchedule(job.id);
|
||||
} else {
|
||||
handlePauseSchedule(job.id);
|
||||
}
|
||||
}}
|
||||
className={`${
|
||||
job.paused
|
||||
? 'text-green-500 dark:text-green-400 hover:text-green-600 dark:hover:text-green-300 hover:bg-green-100/50 dark:hover:bg-green-900/30'
|
||||
: 'text-orange-500 dark:text-orange-400 hover:text-orange-600 dark:hover:text-orange-300 hover:bg-orange-100/50 dark:hover:bg-orange-900/30'
|
||||
}`}
|
||||
title={job.paused ? `Unpause schedule ${job.id}` : `Pause schedule ${job.id}`}
|
||||
disabled={pausingScheduleIds.has(job.id) || deletingScheduleIds.has(job.id)}
|
||||
>
|
||||
{job.paused ? <Play className="w-4 h-4" /> : <Pause className="w-4 h-4" />}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -324,7 +408,7 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
|
||||
}}
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-100/50 dark:hover:bg-red-900/30"
|
||||
title={`Delete schedule ${job.id}`}
|
||||
disabled={isLoading}
|
||||
disabled={pausingScheduleIds.has(job.id) || deletingScheduleIds.has(job.id)}
|
||||
>
|
||||
<TrashIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
@@ -344,6 +428,14 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
|
||||
isLoadingExternally={isSubmitting}
|
||||
apiErrorExternally={submitApiError}
|
||||
/>
|
||||
<EditScheduleModal
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={handleCloseEditModal}
|
||||
onSubmit={handleEditScheduleSubmit}
|
||||
schedule={editingSchedule}
|
||||
isLoadingExternally={isSubmitting}
|
||||
apiErrorExternally={submitApiError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<void> {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSchedule(scheduleId: string, cron: string): Promise<ScheduledJob> {
|
||||
try {
|
||||
const response = await apiUpdateSchedule<true>({
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user