mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
(feat): add livetracking of running schedules (#2683)
This commit is contained in:
@@ -32,6 +32,7 @@ pub async fn handle_schedule_add(
|
||||
source: recipe_source_arg.clone(), // Pass the original user-provided path
|
||||
cron,
|
||||
last_run: None,
|
||||
currently_running: false,
|
||||
};
|
||||
|
||||
let scheduler_storage_path =
|
||||
|
||||
@@ -93,6 +93,7 @@ async fn create_schedule(
|
||||
source: req.recipe_source,
|
||||
cron: req.cron,
|
||||
last_run: None,
|
||||
currently_running: false,
|
||||
};
|
||||
scheduler
|
||||
.add_scheduled_job(job.clone())
|
||||
|
||||
@@ -107,6 +107,8 @@ pub struct ScheduledJob {
|
||||
pub source: String,
|
||||
pub cron: String,
|
||||
pub last_run: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub currently_running: bool,
|
||||
}
|
||||
|
||||
async fn persist_jobs_from_arc(
|
||||
@@ -223,6 +225,7 @@ impl Scheduler {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -239,7 +242,30 @@ impl Scheduler {
|
||||
}
|
||||
}
|
||||
// Pass None for provider_override in normal execution
|
||||
if let Err(e) = run_scheduled_job_internal(job_to_execute, None).await {
|
||||
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,
|
||||
@@ -299,6 +325,7 @@ impl Scheduler {
|
||||
let mut jobs_map_guard = current_jobs_arc.lock().await;
|
||||
if let Some((_, stored_job)) = jobs_map_guard.get_mut(&task_job_id) {
|
||||
stored_job.last_run = Some(current_time);
|
||||
stored_job.currently_running = true;
|
||||
needs_persist = true;
|
||||
}
|
||||
}
|
||||
@@ -315,7 +342,30 @@ impl Scheduler {
|
||||
}
|
||||
}
|
||||
// Pass None for provider_override in normal execution
|
||||
if let Err(e) = run_scheduled_job_internal(job_to_execute, None).await {
|
||||
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((_, stored_job)) = jobs_map_guard.get_mut(&task_job_id) {
|
||||
stored_job.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,
|
||||
@@ -424,33 +474,46 @@ impl Scheduler {
|
||||
|
||||
pub async fn run_now(&self, sched_id: &str) -> Result<String, SchedulerError> {
|
||||
let job_to_run: ScheduledJob = {
|
||||
let jobs_guard = self.jobs.lock().await;
|
||||
match jobs_guard.get(sched_id) {
|
||||
Some((_, job_def)) => job_def.clone(),
|
||||
let mut jobs_guard = self.jobs.lock().await;
|
||||
match jobs_guard.get_mut(sched_id) {
|
||||
Some((_, job_def)) => {
|
||||
// Set the currently_running flag before executing
|
||||
job_def.currently_running = true;
|
||||
let job_clone = job_def.clone();
|
||||
// Drop the guard before persisting to avoid borrow issues
|
||||
drop(jobs_guard);
|
||||
|
||||
// Persist the change immediately
|
||||
self.persist_jobs().await?;
|
||||
job_clone
|
||||
}
|
||||
None => return Err(SchedulerError::JobNotFound(sched_id.to_string())),
|
||||
}
|
||||
};
|
||||
// Pass None for provider_override in normal execution
|
||||
let session_id = run_scheduled_job_internal(job_to_run.clone(), None)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
SchedulerError::AnyhowError(anyhow!(
|
||||
"Failed to execute job '{}' immediately: {}",
|
||||
sched_id,
|
||||
e.error
|
||||
))
|
||||
})?;
|
||||
|
||||
// Pass None for provider_override in normal execution
|
||||
let run_result = run_scheduled_job_internal(job_to_run.clone(), None).await;
|
||||
|
||||
// Clear the currently_running flag after execution
|
||||
{
|
||||
let mut jobs_guard = self.jobs.lock().await;
|
||||
if let Some((_tokio_job_id, job_in_map)) = jobs_guard.get_mut(sched_id) {
|
||||
job_in_map.currently_running = false;
|
||||
job_in_map.last_run = Some(Utc::now());
|
||||
} // MutexGuard is dropped here
|
||||
}
|
||||
|
||||
// Persist after the lock is released and update is made.
|
||||
self.persist_jobs().await?;
|
||||
|
||||
Ok(session_id)
|
||||
match run_result {
|
||||
Ok(session_id) => Ok(session_id),
|
||||
Err(e) => Err(SchedulerError::AnyhowError(anyhow!(
|
||||
"Failed to execute job '{}' immediately: {}",
|
||||
sched_id,
|
||||
e.error
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -794,6 +857,7 @@ mod tests {
|
||||
source: recipe_filename.to_string_lossy().into_owned(),
|
||||
cron: "* * * * * * ".to_string(), // Runs every second for quick testing
|
||||
last_run: None,
|
||||
currently_running: false,
|
||||
};
|
||||
|
||||
// Create the mock provider instance for the test
|
||||
|
||||
@@ -1692,6 +1692,9 @@
|
||||
"cron": {
|
||||
"type": "string"
|
||||
},
|
||||
"currently_running": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -304,6 +304,7 @@ export type RunNowResponse = {
|
||||
|
||||
export type ScheduledJob = {
|
||||
cron: string;
|
||||
currently_running?: boolean;
|
||||
id: string;
|
||||
last_run?: string | null;
|
||||
source: string;
|
||||
|
||||
@@ -5,9 +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 } from '../../schedule';
|
||||
import { getScheduleSessions, runScheduleNow, listSchedules, ScheduledJob } from '../../schedule';
|
||||
import SessionHistoryView from '../sessions/SessionHistoryView';
|
||||
import { toastError, toastSuccess } from '../../toasts';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import cronstrue from 'cronstrue';
|
||||
|
||||
interface ScheduleSessionMeta {
|
||||
id: string;
|
||||
@@ -34,6 +36,9 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
||||
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);
|
||||
|
||||
const [selectedSessionDetails, setSelectedSessionDetails] = useState<SessionDetails | null>(null);
|
||||
const [isLoadingSessionDetails, setIsLoadingSessionDetails] = useState(false);
|
||||
@@ -56,16 +61,48 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchScheduleDetails = useCallback(async (sId: string) => {
|
||||
if (!sId) return;
|
||||
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');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch schedule details:', err);
|
||||
setScheduleError(err instanceof Error ? err.message : 'Failed to fetch schedule details');
|
||||
} finally {
|
||||
setIsLoadingSchedule(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getReadableCron = (cronString: string) => {
|
||||
try {
|
||||
return cronstrue.toString(cronString);
|
||||
} catch (e) {
|
||||
console.warn(`Could not parse cron string "${cronString}":`, e);
|
||||
return cronString;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (scheduleId && !selectedSessionDetails) {
|
||||
fetchScheduleSessions(scheduleId);
|
||||
fetchScheduleDetails(scheduleId);
|
||||
} else if (!scheduleId) {
|
||||
setSessions([]);
|
||||
setSessionsError(null);
|
||||
setRunNowLoading(false);
|
||||
setSelectedSessionDetails(null);
|
||||
setScheduleDetails(null);
|
||||
setScheduleError(null);
|
||||
}
|
||||
}, [scheduleId, fetchScheduleSessions, selectedSessionDetails]);
|
||||
}, [scheduleId, fetchScheduleSessions, fetchScheduleDetails, selectedSessionDetails]);
|
||||
|
||||
const handleRunNow = async () => {
|
||||
if (!scheduleId) return;
|
||||
@@ -77,7 +114,10 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
||||
msg: `Successfully triggered schedule. New session ID: ${newSessionId}`,
|
||||
});
|
||||
setTimeout(() => {
|
||||
if (scheduleId) fetchScheduleSessions(scheduleId);
|
||||
if (scheduleId) {
|
||||
fetchScheduleSessions(scheduleId);
|
||||
fetchScheduleDetails(scheduleId);
|
||||
}
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
console.error('Failed to run schedule now:', err);
|
||||
@@ -88,6 +128,26 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
||||
}
|
||||
};
|
||||
|
||||
// Add a periodic refresh for schedule details to keep the running status up to date
|
||||
useEffect(() => {
|
||||
if (!scheduleId) return;
|
||||
|
||||
// Initial fetch
|
||||
fetchScheduleDetails(scheduleId);
|
||||
|
||||
// Set up periodic refresh every 5 seconds
|
||||
const intervalId = setInterval(() => {
|
||||
if (scheduleId) {
|
||||
fetchScheduleDetails(scheduleId);
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
// Clean up on unmount or when scheduleId changes
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [scheduleId, fetchScheduleDetails]);
|
||||
|
||||
const loadAndShowSessionDetails = async (sessionId: string) => {
|
||||
setIsLoadingSessionDetails(true);
|
||||
setSessionDetailsError(null);
|
||||
@@ -174,11 +234,69 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
||||
|
||||
<ScrollArea className="flex-grow">
|
||||
<div className="p-8 space-y-6">
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Schedule Information
|
||||
</h2>
|
||||
{isLoadingSchedule && (
|
||||
<div className="flex items-center text-gray-500 dark:text-gray-400">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading schedule details...
|
||||
</div>
|
||||
)}
|
||||
{scheduleError && (
|
||||
<p className="text-red-500 dark:text-red-400 text-sm p-3 bg-red-100 dark:bg-red-900/30 border border-red-500 dark:border-red-700 rounded-md">
|
||||
Error: {scheduleError}
|
||||
</p>
|
||||
)}
|
||||
{!isLoadingSchedule && !scheduleError && scheduleDetails && (
|
||||
<Card className="p-4 bg-white dark:bg-gray-800 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-gray-900 dark:text-white">
|
||||
{scheduleDetails.id}
|
||||
</h3>
|
||||
{scheduleDetails.currently_running && (
|
||||
<div className="mt-2 md:mt-0 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>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="font-semibold">Schedule:</span>{' '}
|
||||
{getReadableCron(scheduleDetails.cron)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="font-semibold">Cron Expression:</span> {scheduleDetails.cron}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="font-semibold">Recipe Source:</span> {scheduleDetails.source}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="font-semibold">Last Run:</span>{' '}
|
||||
{scheduleDetails.last_run
|
||||
? new Date(scheduleDetails.last_run).toLocaleString()
|
||||
: 'Never'}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-3">Actions</h2>
|
||||
<Button onClick={handleRunNow} disabled={runNowLoading} className="w-full md:w-auto">
|
||||
<Button
|
||||
onClick={handleRunNow}
|
||||
disabled={runNowLoading || scheduleDetails?.currently_running === true}
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
{runNowLoading ? 'Triggering...' : 'Run Schedule Now'}
|
||||
</Button>
|
||||
{scheduleDetails?.currently_running && (
|
||||
<p className="text-sm text-amber-600 dark:text-amber-400 mt-2">
|
||||
Cannot trigger a schedule while it's already running.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
|
||||
@@ -6,7 +6,7 @@ import MoreMenuLayout from '../more_menu/MoreMenuLayout';
|
||||
import { Card } from '../ui/card';
|
||||
import { Button } from '../ui/button';
|
||||
import { TrashIcon } from '../icons/TrashIcon';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { Plus, RefreshCw } from 'lucide-react';
|
||||
import { CreateScheduleModal, NewSchedulePayload } from './CreateScheduleModal';
|
||||
import ScheduleDetailView from './ScheduleDetailView';
|
||||
import cronstrue from 'cronstrue';
|
||||
@@ -22,6 +22,7 @@ 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 [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const [viewingScheduleId, setViewingScheduleId] = useState<string | null>(null);
|
||||
|
||||
@@ -49,11 +50,37 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
|
||||
}
|
||||
}, [viewingScheduleId]);
|
||||
|
||||
// Add a periodic refresh for schedules list to keep the running status up to date
|
||||
useEffect(() => {
|
||||
if (viewingScheduleId !== null) return;
|
||||
|
||||
// Set up periodic refresh every 10 seconds
|
||||
const intervalId = setInterval(() => {
|
||||
if (viewingScheduleId === null && !isRefreshing && !isLoading) {
|
||||
fetchSchedules();
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
// Clean up on unmount
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [viewingScheduleId, isRefreshing, isLoading]);
|
||||
|
||||
const handleOpenCreateModal = () => {
|
||||
setSubmitApiError(null);
|
||||
setIsCreateModalOpen(true);
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await fetchSchedules();
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseCreateModal = () => {
|
||||
setIsCreateModalOpen(false);
|
||||
setSubmitApiError(null);
|
||||
@@ -134,12 +161,24 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
|
||||
|
||||
<ScrollArea className="flex-grow">
|
||||
<div className="p-8">
|
||||
<Button
|
||||
onClick={handleOpenCreateModal}
|
||||
className="w-full md:w-auto flex items-center gap-2 justify-center text-white dark:text-black bg-bgAppInverse hover:bg-bgStandardInverse [&>svg]:!size-4 mb-8"
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Create New Schedule
|
||||
</Button>
|
||||
<div className="flex flex-col md:flex-row gap-2 mb-8">
|
||||
<Button
|
||||
onClick={handleOpenCreateModal}
|
||||
className="w-full md:w-auto flex items-center gap-2 justify-center text-white dark:text-black bg-bgAppInverse hover:bg-bgStandardInverse [&>svg]:!size-4"
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Create New Schedule
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing || isLoading}
|
||||
variant="outline"
|
||||
className="w-full md:w-auto flex items-center gap-2 justify-center"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
{isRefreshing ? 'Refreshing...' : 'Refresh'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{apiError && (
|
||||
<p className="text-red-500 dark:text-red-400 text-sm p-4 bg-red-100 dark:bg-red-900/30 border border-red-500 dark:border-red-700 rounded-md">
|
||||
@@ -192,6 +231,12 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
|
||||
Last Run:{' '}
|
||||
{job.last_run ? new Date(job.last_run).toLocaleString() : 'Never'}
|
||||
</p>
|
||||
{job.currently_running && (
|
||||
<p className="text-xs text-green-500 dark:text-green-400 mt-1 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
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<Button
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface ScheduledJob {
|
||||
source: string;
|
||||
cron: string;
|
||||
last_run?: string | null;
|
||||
currently_running?: boolean;
|
||||
}
|
||||
|
||||
export interface ScheduleSession {
|
||||
|
||||
Reference in New Issue
Block a user