feat: add pause/unpause functionality for scheduled jobs (#2698)

This commit is contained in:
Max Novich
2025-05-28 12:20:03 -07:00
committed by GitHub
parent feb7b15c76
commit ea6a7a7847
10 changed files with 484 additions and 20 deletions
+66
View File
@@ -539,6 +539,39 @@
}
}
},
"/schedule/{id}/pause": {
"post": {
"tags": [
"schedule"
],
"operationId": "pause_schedule",
"parameters": [
{
"name": "id",
"in": "path",
"description": "ID of the schedule to pause",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "Scheduled job paused successfully"
},
"400": {
"description": "Cannot pause a currently running job"
},
"404": {
"description": "Scheduled job not found"
},
"500": {
"description": "Internal server error"
}
}
}
},
"/schedule/{id}/run_now": {
"post": {
"tags": [
@@ -623,6 +656,36 @@
}
}
},
"/schedule/{id}/unpause": {
"post": {
"tags": [
"schedule"
],
"operationId": "unpause_schedule",
"parameters": [
{
"name": "id",
"in": "path",
"description": "ID of the schedule to unpause",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "Scheduled job unpaused successfully"
},
"404": {
"description": "Scheduled job not found"
},
"500": {
"description": "Internal server error"
}
}
}
},
"/sessions": {
"get": {
"tags": [
@@ -1703,6 +1766,9 @@
"format": "date-time",
"nullable": true
},
"paused": {
"type": "boolean"
},
"source": {
"type": "string"
}
+15 -1
View File
@@ -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, RunNowHandlerData, RunNowHandlerResponse, SessionsHandlerData, SessionsHandlerResponse, 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, 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,13 @@ export const listSchedules = <ThrowOnError extends boolean = false>(options?: Op
});
};
export const pauseSchedule = <ThrowOnError extends boolean = false>(options: Options<PauseScheduleData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<PauseScheduleResponse, unknown, ThrowOnError>({
url: '/schedule/{id}/pause',
...options
});
};
export const runNowHandler = <ThrowOnError extends boolean = false>(options: Options<RunNowHandlerData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<RunNowHandlerResponse, unknown, ThrowOnError>({
url: '/schedule/{id}/run_now',
@@ -183,6 +190,13 @@ export const sessionsHandler = <ThrowOnError extends boolean = false>(options: O
});
};
export const unpauseSchedule = <ThrowOnError extends boolean = false>(options: Options<UnpauseScheduleData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<UnpauseScheduleResponse, unknown, ThrowOnError>({
url: '/schedule/{id}/unpause',
...options
});
};
export const listSessions = <ThrowOnError extends boolean = false>(options?: Options<ListSessionsData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).get<ListSessionsResponse, unknown, ThrowOnError>({
url: '/sessions',
+69
View File
@@ -307,6 +307,7 @@ export type ScheduledJob = {
currently_running?: boolean;
id: string;
last_run?: string | null;
paused?: boolean;
source: string;
};
@@ -963,6 +964,42 @@ export type ListSchedulesResponses = {
export type ListSchedulesResponse2 = ListSchedulesResponses[keyof ListSchedulesResponses];
export type PauseScheduleData = {
body?: never;
path: {
/**
* ID of the schedule to pause
*/
id: string;
};
query?: never;
url: '/schedule/{id}/pause';
};
export type PauseScheduleErrors = {
/**
* Cannot pause a currently running job
*/
400: unknown;
/**
* Scheduled job not found
*/
404: unknown;
/**
* Internal server error
*/
500: unknown;
};
export type PauseScheduleResponses = {
/**
* Scheduled job paused successfully
*/
204: void;
};
export type PauseScheduleResponse = PauseScheduleResponses[keyof PauseScheduleResponses];
export type RunNowHandlerData = {
body?: never;
path: {
@@ -1025,6 +1062,38 @@ export type SessionsHandlerResponses = {
export type SessionsHandlerResponse = SessionsHandlerResponses[keyof SessionsHandlerResponses];
export type UnpauseScheduleData = {
body?: never;
path: {
/**
* ID of the schedule to unpause
*/
id: string;
};
query?: never;
url: '/schedule/{id}/unpause';
};
export type UnpauseScheduleErrors = {
/**
* Scheduled job not found
*/
404: unknown;
/**
* Internal server error
*/
500: unknown;
};
export type UnpauseScheduleResponses = {
/**
* Scheduled job unpaused successfully
*/
204: void;
};
export type UnpauseScheduleResponse = UnpauseScheduleResponses[keyof UnpauseScheduleResponses];
export type ListSessionsData = {
body?: never;
path?: never;
@@ -5,10 +5,10 @@ import BackButton from '../ui/BackButton';
import { Card } from '../ui/card';
import MoreMenuLayout from '../more_menu/MoreMenuLayout';
import { fetchSessionDetails, SessionDetails } from '../../sessions';
import { getScheduleSessions, runScheduleNow, listSchedules, ScheduledJob } from '../../schedule';
import { getScheduleSessions, runScheduleNow, pauseSchedule, unpauseSchedule, listSchedules, ScheduledJob } from '../../schedule';
import SessionHistoryView from '../sessions/SessionHistoryView';
import { toastError, toastSuccess } from '../../toasts';
import { Loader2 } from 'lucide-react';
import { Loader2, Pause, Play } from 'lucide-react';
import cronstrue from 'cronstrue';
interface ScheduleSessionMeta {
@@ -128,6 +128,38 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
}
};
const handlePauseSchedule = async () => {
if (!scheduleId) return;
try {
await pauseSchedule(scheduleId);
toastSuccess({
title: 'Schedule Paused',
msg: `Successfully paused schedule "${scheduleId}"`,
});
fetchScheduleDetails(scheduleId);
} catch (err) {
console.error('Failed to pause schedule:', err);
const errorMsg = err instanceof Error ? err.message : 'Failed to pause schedule';
toastError({ title: 'Pause Schedule Error', msg: errorMsg });
}
};
const handleUnpauseSchedule = async () => {
if (!scheduleId) return;
try {
await unpauseSchedule(scheduleId);
toastSuccess({
title: 'Schedule Unpaused',
msg: `Successfully unpaused schedule "${scheduleId}"`,
});
fetchScheduleDetails(scheduleId);
} catch (err) {
console.error('Failed to unpause schedule:', err);
const errorMsg = err instanceof Error ? err.message : 'Failed to unpause schedule';
toastError({ title: 'Unpause Schedule Error', msg: errorMsg });
}
};
// Add a periodic refresh for schedule details to keep the running status up to date
useEffect(() => {
if (!scheduleId) return;
@@ -255,12 +287,20 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
<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 className="mt-2 md:mt-0 flex items-center gap-2">
{scheduleDetails.currently_running && (
<div className="text-sm text-green-500 dark:text-green-400 font-semibold flex items-center">
<span className="inline-block w-2 h-2 bg-green-500 dark:bg-green-400 rounded-full mr-1 animate-pulse"></span>
Currently Running
</div>
)}
{scheduleDetails.paused && (
<div className="text-sm text-orange-500 dark:text-orange-400 font-semibold flex items-center">
<Pause className="w-3 h-3 mr-1" />
Paused
</div>
)}
</div>
</div>
<p className="text-sm text-gray-600 dark:text-gray-300">
<span className="font-semibold">Schedule:</span>{' '}
@@ -285,16 +325,49 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
<section>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-3">Actions</h2>
<Button
onClick={handleRunNow}
disabled={runNowLoading || scheduleDetails?.currently_running === true}
className="w-full md:w-auto"
>
{runNowLoading ? 'Triggering...' : 'Run Schedule Now'}
</Button>
<div className="flex flex-col md:flex-row gap-2">
<Button
onClick={handleRunNow}
disabled={runNowLoading || scheduleDetails?.currently_running === true}
className="w-full md:w-auto"
>
{runNowLoading ? 'Triggering...' : 'Run Schedule Now'}
</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>
)}
</div>
{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.
Cannot trigger or modify a schedule while it's already running.
</p>
)}
{scheduleDetails?.paused && (
<p className="text-sm text-orange-600 dark:text-orange-400 mt-2">
This schedule is paused and will not run automatically. Use "Run Schedule Now" to trigger it manually or unpause to resume automatic execution.
</p>
)}
</section>
@@ -1,14 +1,15 @@
import React, { useState, useEffect } from 'react';
import { listSchedules, createSchedule, deleteSchedule, ScheduledJob } from '../../schedule';
import { listSchedules, createSchedule, deleteSchedule, pauseSchedule, unpauseSchedule, 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 } from 'lucide-react';
import { Plus, RefreshCw, Pause, Play } from 'lucide-react';
import { CreateScheduleModal, NewSchedulePayload } from './CreateScheduleModal';
import ScheduleDetailView from './ScheduleDetailView';
import { toastError, toastSuccess } from '../../toasts';
import cronstrue from 'cronstrue';
interface SchedulesViewProps {
@@ -123,6 +124,52 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
}
};
const handlePauseSchedule = async (idToPause: string) => {
setIsLoading(true);
setApiError(null);
try {
await pauseSchedule(idToPause);
toastSuccess({
title: 'Schedule Paused',
msg: `Successfully paused schedule "${idToPause}"`,
});
await fetchSchedules();
} catch (error) {
console.error(`Failed to pause schedule "${idToPause}":`, error);
const errorMsg = error instanceof Error ? error.message : `Unknown error pausing "${idToPause}".`;
setApiError(errorMsg);
toastError({
title: 'Pause Schedule Error',
msg: errorMsg,
});
} finally {
setIsLoading(false);
}
};
const handleUnpauseSchedule = async (idToUnpause: string) => {
setIsLoading(true);
setApiError(null);
try {
await unpauseSchedule(idToUnpause);
toastSuccess({
title: 'Schedule Unpaused',
msg: `Successfully unpaused schedule "${idToUnpause}"`,
});
await fetchSchedules();
} catch (error) {
console.error(`Failed to unpause schedule "${idToUnpause}":`, error);
const errorMsg = error instanceof Error ? error.message : `Unknown error unpausing "${idToUnpause}".`;
setApiError(errorMsg);
toastError({
title: 'Unpause Schedule Error',
msg: errorMsg,
});
} finally {
setIsLoading(false);
}
};
const handleNavigateToScheduleDetail = (scheduleId: string) => {
setViewingScheduleId(scheduleId);
};
@@ -237,8 +284,37 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
Currently Running
</p>
)}
{job.paused && (
<p className="text-xs text-orange-500 dark:text-orange-400 mt-1 font-semibold flex items-center">
<Pause className="w-3 h-3 mr-1" />
Paused
</p>
)}
</div>
<div className="flex-shrink-0">
<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"
+25
View File
@@ -2,6 +2,8 @@ import {
listSchedules as apiListSchedules,
createSchedule as apiCreateSchedule,
deleteSchedule as apiDeleteSchedule,
pauseSchedule as apiPauseSchedule,
unpauseSchedule as apiUnpauseSchedule,
sessionsHandler as apiGetScheduleSessions,
runNowHandler as apiRunScheduleNow,
} from './api';
@@ -12,6 +14,7 @@ export interface ScheduledJob {
cron: string;
last_run?: string | null;
currently_running?: boolean;
paused?: boolean;
}
export interface ScheduleSession {
@@ -107,3 +110,25 @@ export async function runScheduleNow(scheduleId: string): Promise<string> {
throw error;
}
}
export async function pauseSchedule(scheduleId: string): Promise<void> {
try {
await apiPauseSchedule<true>({
path: { id: scheduleId },
});
} catch (error) {
console.error(`Error pausing schedule ${scheduleId}:`, error);
throw error;
}
}
export async function unpauseSchedule(scheduleId: string): Promise<void> {
try {
await apiUnpauseSchedule<true>({
path: { id: scheduleId },
});
} catch (error) {
console.error(`Error unpausing schedule ${scheduleId}:`, error);
throw error;
}
}