feat: implement proper task cancellation for scheduled jobs (#2731)
This commit is contained in:
@@ -589,6 +589,66 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/schedule/{id}/inspect": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"schedule"
|
||||
],
|
||||
"operationId": "inspect_running_job",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"description": "ID of the schedule to inspect",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Running job information",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InspectJobResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Scheduled job not found"
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/schedule/{id}/kill": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"schedule"
|
||||
],
|
||||
"operationId": "kill_running_job",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Running job killed successfully"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/schedule/{id}/pause": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -1332,6 +1392,35 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"InspectJobResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"processStartTime": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"runningDurationSeconds": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"nullable": true
|
||||
},
|
||||
"sessionId": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"KillJobResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ListSchedulesResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -1805,6 +1894,10 @@
|
||||
"cron": {
|
||||
"type": "string"
|
||||
},
|
||||
"current_session_id": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"currently_running": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -1819,6 +1912,11 @@
|
||||
"paused": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"process_start_time": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"nullable": true
|
||||
},
|
||||
"source": {
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
@@ -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, UpdateScheduleData, UpdateScheduleResponse, 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, InspectRunningJobData, InspectRunningJobResponse, KillRunningJobData, 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> & {
|
||||
@@ -180,6 +180,20 @@ export const updateSchedule = <ThrowOnError extends boolean = false>(options: Op
|
||||
});
|
||||
};
|
||||
|
||||
export const inspectRunningJob = <ThrowOnError extends boolean = false>(options: Options<InspectRunningJobData, ThrowOnError>) => {
|
||||
return (options.client ?? _heyApiClient).get<InspectRunningJobResponse, unknown, ThrowOnError>({
|
||||
url: '/schedule/{id}/inspect',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
export const killRunningJob = <ThrowOnError extends boolean = false>(options: Options<KillRunningJobData, ThrowOnError>) => {
|
||||
return (options.client ?? _heyApiClient).post<unknown, unknown, ThrowOnError>({
|
||||
url: '/schedule/{id}/kill',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
export const pauseSchedule = <ThrowOnError extends boolean = false>(options: Options<PauseScheduleData, ThrowOnError>) => {
|
||||
return (options.client ?? _heyApiClient).post<PauseScheduleResponse, unknown, ThrowOnError>({
|
||||
url: '/schedule/{id}/pause',
|
||||
|
||||
@@ -172,6 +172,16 @@ export type ImageContent = {
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
export type InspectJobResponse = {
|
||||
processStartTime?: string | null;
|
||||
runningDurationSeconds?: number | null;
|
||||
sessionId?: string | null;
|
||||
};
|
||||
|
||||
export type KillJobResponse = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ListSchedulesResponse = {
|
||||
jobs: Array<ScheduledJob>;
|
||||
};
|
||||
@@ -304,10 +314,12 @@ export type RunNowResponse = {
|
||||
|
||||
export type ScheduledJob = {
|
||||
cron: string;
|
||||
current_session_id?: string | null;
|
||||
currently_running?: boolean;
|
||||
id: string;
|
||||
last_run?: string | null;
|
||||
paused?: boolean;
|
||||
process_start_time?: string | null;
|
||||
source: string;
|
||||
};
|
||||
|
||||
@@ -1004,6 +1016,54 @@ export type UpdateScheduleResponses = {
|
||||
|
||||
export type UpdateScheduleResponse = UpdateScheduleResponses[keyof UpdateScheduleResponses];
|
||||
|
||||
export type InspectRunningJobData = {
|
||||
body?: never;
|
||||
path: {
|
||||
/**
|
||||
* ID of the schedule to inspect
|
||||
*/
|
||||
id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/schedule/{id}/inspect';
|
||||
};
|
||||
|
||||
export type InspectRunningJobErrors = {
|
||||
/**
|
||||
* Scheduled job not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Internal server error
|
||||
*/
|
||||
500: unknown;
|
||||
};
|
||||
|
||||
export type InspectRunningJobResponses = {
|
||||
/**
|
||||
* Running job information
|
||||
*/
|
||||
200: InspectJobResponse;
|
||||
};
|
||||
|
||||
export type InspectRunningJobResponse = InspectRunningJobResponses[keyof InspectRunningJobResponses];
|
||||
|
||||
export type KillRunningJobData = {
|
||||
body?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/schedule/{id}/kill';
|
||||
};
|
||||
|
||||
export type KillRunningJobResponses = {
|
||||
/**
|
||||
* Running job killed successfully
|
||||
*/
|
||||
200: unknown;
|
||||
};
|
||||
|
||||
export type PauseScheduleData = {
|
||||
body?: never;
|
||||
path: {
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { Select } from '../ui/Select';
|
||||
import cronstrue from 'cronstrue';
|
||||
|
||||
type FrequencyValue = 'once' | 'hourly' | 'daily' | 'weekly' | 'monthly';
|
||||
|
||||
@@ -5,11 +5,21 @@ 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, updateSchedule, listSchedules, ScheduledJob } from '../../schedule';
|
||||
import {
|
||||
getScheduleSessions,
|
||||
runScheduleNow,
|
||||
pauseSchedule,
|
||||
unpauseSchedule,
|
||||
updateSchedule,
|
||||
listSchedules,
|
||||
killRunningJob,
|
||||
inspectRunningJob,
|
||||
ScheduledJob,
|
||||
} from '../../schedule';
|
||||
import SessionHistoryView from '../sessions/SessionHistoryView';
|
||||
import { EditScheduleModal } from './EditScheduleModal';
|
||||
import { toastError, toastSuccess } from '../../toasts';
|
||||
import { Loader2, Pause, Play, Edit } from 'lucide-react';
|
||||
import { Loader2, Pause, Play, Edit, Square, Eye } from 'lucide-react';
|
||||
import cronstrue from 'cronstrue';
|
||||
|
||||
interface ScheduleSessionMeta {
|
||||
@@ -40,9 +50,14 @@ 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 [killJobLoading, setKillJobLoading] = useState(false);
|
||||
const [inspectJobLoading, setInspectJobLoading] = useState(false);
|
||||
|
||||
// Track if we explicitly killed a job to distinguish from natural completion
|
||||
const [jobWasKilled, setJobWasKilled] = useState(false);
|
||||
|
||||
const [selectedSessionDetails, setSelectedSessionDetails] = useState<SessionDetails | null>(null);
|
||||
const [isLoadingSessionDetails, setIsLoadingSessionDetails] = useState(false);
|
||||
@@ -68,25 +83,34 @@ 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');
|
||||
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) {
|
||||
// Only reset runNowLoading if we explicitly killed the job
|
||||
// This prevents interfering with natural job completion
|
||||
if (!schedule.currently_running && runNowLoading && jobWasKilled) {
|
||||
setRunNowLoading(false);
|
||||
setJobWasKilled(false); // Reset the flag
|
||||
}
|
||||
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);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch schedule details:', err);
|
||||
setScheduleError(err instanceof Error ? err.message : 'Failed to fetch schedule details');
|
||||
} finally {
|
||||
setIsLoadingSchedule(false);
|
||||
}
|
||||
}, []);
|
||||
},
|
||||
[runNowLoading, jobWasKilled]
|
||||
);
|
||||
|
||||
const getReadableCron = (cronString: string) => {
|
||||
try {
|
||||
@@ -108,6 +132,7 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
||||
setSelectedSessionDetails(null);
|
||||
setScheduleDetails(null);
|
||||
setScheduleError(null);
|
||||
setJobWasKilled(false); // Reset kill flag when changing schedules
|
||||
}
|
||||
}, [scheduleId, fetchScheduleSessions, fetchScheduleDetails, selectedSessionDetails]);
|
||||
|
||||
@@ -115,11 +140,18 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
||||
if (!scheduleId) return;
|
||||
setRunNowLoading(true);
|
||||
try {
|
||||
const newSessionId = await runScheduleNow(scheduleId); // MODIFIED
|
||||
toastSuccess({
|
||||
title: 'Schedule Triggered',
|
||||
msg: `Successfully triggered schedule. New session ID: ${newSessionId}`,
|
||||
});
|
||||
const newSessionId = await runScheduleNow(scheduleId);
|
||||
if (newSessionId === 'CANCELLED') {
|
||||
toastSuccess({
|
||||
title: 'Job Cancelled',
|
||||
msg: 'The job was cancelled while starting up.',
|
||||
});
|
||||
} else {
|
||||
toastSuccess({
|
||||
title: 'Schedule Triggered',
|
||||
msg: `Successfully triggered schedule. New session ID: ${newSessionId}`,
|
||||
});
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (scheduleId) {
|
||||
fetchScheduleSessions(scheduleId);
|
||||
@@ -183,9 +215,60 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
||||
setEditApiError(null);
|
||||
};
|
||||
|
||||
const handleKillRunningJob = async () => {
|
||||
if (!scheduleId) return;
|
||||
setKillJobLoading(true);
|
||||
try {
|
||||
const result = await killRunningJob(scheduleId);
|
||||
toastSuccess({
|
||||
title: 'Job Killed',
|
||||
msg: result.message,
|
||||
});
|
||||
// Mark that we explicitly killed this job
|
||||
setJobWasKilled(true);
|
||||
// Clear the runNowLoading state immediately when job is killed
|
||||
setRunNowLoading(false);
|
||||
fetchScheduleDetails(scheduleId);
|
||||
} catch (err) {
|
||||
console.error('Failed to kill running job:', err);
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to kill running job';
|
||||
toastError({ title: 'Kill Job Error', msg: errorMsg });
|
||||
} finally {
|
||||
setKillJobLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInspectRunningJob = async () => {
|
||||
if (!scheduleId) return;
|
||||
setInspectJobLoading(true);
|
||||
try {
|
||||
const result = await inspectRunningJob(scheduleId);
|
||||
if (result.sessionId) {
|
||||
const duration = result.runningDurationSeconds
|
||||
? `${Math.floor(result.runningDurationSeconds / 60)}m ${result.runningDurationSeconds % 60}s`
|
||||
: 'Unknown';
|
||||
toastSuccess({
|
||||
title: 'Job Inspection',
|
||||
msg: `Session: ${result.sessionId}\nRunning for: ${duration}`,
|
||||
});
|
||||
} else {
|
||||
toastSuccess({
|
||||
title: 'Job Inspection',
|
||||
msg: 'No detailed information available for this job',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to inspect running job:', err);
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to inspect running job';
|
||||
toastError({ title: 'Inspect Job Error', msg: errorMsg });
|
||||
} finally {
|
||||
setInspectJobLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditScheduleSubmit = async (cron: string) => {
|
||||
if (!scheduleId) return;
|
||||
|
||||
|
||||
setIsEditSubmitting(true);
|
||||
setEditApiError(null);
|
||||
try {
|
||||
@@ -226,6 +309,18 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
||||
};
|
||||
}, [scheduleId, fetchScheduleDetails]);
|
||||
|
||||
// Monitor schedule state changes and reset loading states appropriately
|
||||
useEffect(() => {
|
||||
if (scheduleDetails) {
|
||||
// Only reset runNowLoading if we explicitly killed the job
|
||||
// This prevents interfering with natural job completion
|
||||
if (!scheduleDetails.currently_running && runNowLoading && jobWasKilled) {
|
||||
setRunNowLoading(false);
|
||||
setJobWasKilled(false); // Reset the flag
|
||||
}
|
||||
}
|
||||
}, [scheduleDetails, runNowLoading, jobWasKilled]);
|
||||
|
||||
const loadAndShowSessionDetails = async (sessionId: string) => {
|
||||
setIsLoadingSessionDetails(true);
|
||||
setSessionDetailsError(null);
|
||||
@@ -364,6 +459,18 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
||||
? new Date(scheduleDetails.last_run).toLocaleString()
|
||||
: 'Never'}
|
||||
</p>
|
||||
{scheduleDetails.currently_running && scheduleDetails.current_session_id && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="font-semibold">Current Session:</span>{' '}
|
||||
{scheduleDetails.current_session_id}
|
||||
</p>
|
||||
)}
|
||||
{scheduleDetails.currently_running && scheduleDetails.process_start_time && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="font-semibold">Process Started:</span>{' '}
|
||||
{new Date(scheduleDetails.process_start_time).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
@@ -379,7 +486,7 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
||||
>
|
||||
{runNowLoading ? 'Triggering...' : 'Run Schedule Now'}
|
||||
</Button>
|
||||
|
||||
|
||||
{scheduleDetails && !scheduleDetails.currently_running && (
|
||||
<>
|
||||
<Button
|
||||
@@ -415,17 +522,41 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{scheduleDetails && scheduleDetails.currently_running && (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleInspectRunningJob}
|
||||
variant="outline"
|
||||
className="w-full md:w-auto flex items-center gap-2 text-blue-600 dark:text-blue-400 border-blue-300 dark:border-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20"
|
||||
disabled={inspectJobLoading}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
{inspectJobLoading ? 'Inspecting...' : 'Inspect Running Job'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleKillRunningJob}
|
||||
variant="outline"
|
||||
className="w-full md:w-auto flex items-center gap-2 text-red-600 dark:text-red-400 border-red-300 dark:border-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
disabled={killJobLoading}
|
||||
>
|
||||
<Square className="w-4 h-4" />
|
||||
{killJobLoading ? 'Killing...' : 'Kill Running Job'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{scheduleDetails?.currently_running && (
|
||||
<p className="text-sm text-amber-600 dark:text-amber-400 mt-2">
|
||||
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.
|
||||
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,12 +1,22 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { listSchedules, createSchedule, deleteSchedule, pauseSchedule, unpauseSchedule, updateSchedule, ScheduledJob } from '../../schedule';
|
||||
import {
|
||||
listSchedules,
|
||||
createSchedule,
|
||||
deleteSchedule,
|
||||
pauseSchedule,
|
||||
unpauseSchedule,
|
||||
updateSchedule,
|
||||
killRunningJob,
|
||||
inspectRunningJob,
|
||||
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, Edit } from 'lucide-react';
|
||||
import { Plus, RefreshCw, Pause, Play, Edit, Square, Eye } from 'lucide-react';
|
||||
import { CreateScheduleModal, NewSchedulePayload } from './CreateScheduleModal';
|
||||
import { EditScheduleModal } from './EditScheduleModal';
|
||||
import ScheduleDetailView from './ScheduleDetailView';
|
||||
@@ -27,10 +37,12 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
|
||||
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 [killingScheduleIds, setKillingScheduleIds] = useState<Set<string>>(new Set());
|
||||
const [inspectingScheduleIds, setInspectingScheduleIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const [viewingScheduleId, setViewingScheduleId] = useState<string | null>(null);
|
||||
|
||||
@@ -125,7 +137,7 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
|
||||
|
||||
const handleEditScheduleSubmit = async (cron: string) => {
|
||||
if (!editingSchedule) return;
|
||||
|
||||
|
||||
setIsSubmitting(true);
|
||||
setSubmitApiError(null);
|
||||
try {
|
||||
@@ -153,10 +165,10 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
|
||||
|
||||
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));
|
||||
|
||||
setDeletingScheduleIds((prev) => new Set(prev).add(idToDelete));
|
||||
|
||||
if (viewingScheduleId === idToDelete) {
|
||||
setViewingScheduleId(null);
|
||||
}
|
||||
@@ -171,7 +183,7 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
|
||||
);
|
||||
} finally {
|
||||
// Remove from deleting set
|
||||
setDeletingScheduleIds(prev => {
|
||||
setDeletingScheduleIds((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(idToDelete);
|
||||
return newSet;
|
||||
@@ -181,8 +193,8 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
|
||||
|
||||
const handlePauseSchedule = async (idToPause: string) => {
|
||||
// Immediately add to pausing set to disable button
|
||||
setPausingScheduleIds(prev => new Set(prev).add(idToPause));
|
||||
|
||||
setPausingScheduleIds((prev) => new Set(prev).add(idToPause));
|
||||
|
||||
setApiError(null);
|
||||
try {
|
||||
await pauseSchedule(idToPause);
|
||||
@@ -193,7 +205,8 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
|
||||
await fetchSchedules();
|
||||
} catch (error) {
|
||||
console.error(`Failed to pause schedule "${idToPause}":`, error);
|
||||
const errorMsg = error instanceof Error ? error.message : `Unknown error pausing "${idToPause}".`;
|
||||
const errorMsg =
|
||||
error instanceof Error ? error.message : `Unknown error pausing "${idToPause}".`;
|
||||
setApiError(errorMsg);
|
||||
toastError({
|
||||
title: 'Pause Schedule Error',
|
||||
@@ -201,7 +214,7 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
|
||||
});
|
||||
} finally {
|
||||
// Remove from pausing set
|
||||
setPausingScheduleIds(prev => {
|
||||
setPausingScheduleIds((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(idToPause);
|
||||
return newSet;
|
||||
@@ -211,8 +224,8 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
|
||||
|
||||
const handleUnpauseSchedule = async (idToUnpause: string) => {
|
||||
// Immediately add to pausing set to disable button
|
||||
setPausingScheduleIds(prev => new Set(prev).add(idToUnpause));
|
||||
|
||||
setPausingScheduleIds((prev) => new Set(prev).add(idToUnpause));
|
||||
|
||||
setApiError(null);
|
||||
try {
|
||||
await unpauseSchedule(idToUnpause);
|
||||
@@ -223,7 +236,8 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
|
||||
await fetchSchedules();
|
||||
} catch (error) {
|
||||
console.error(`Failed to unpause schedule "${idToUnpause}":`, error);
|
||||
const errorMsg = error instanceof Error ? error.message : `Unknown error unpausing "${idToUnpause}".`;
|
||||
const errorMsg =
|
||||
error instanceof Error ? error.message : `Unknown error unpausing "${idToUnpause}".`;
|
||||
setApiError(errorMsg);
|
||||
toastError({
|
||||
title: 'Unpause Schedule Error',
|
||||
@@ -231,7 +245,7 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
|
||||
});
|
||||
} finally {
|
||||
// Remove from pausing set
|
||||
setPausingScheduleIds(prev => {
|
||||
setPausingScheduleIds((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(idToUnpause);
|
||||
return newSet;
|
||||
@@ -239,6 +253,77 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleKillRunningJob = async (scheduleId: string) => {
|
||||
// Immediately add to killing set to disable button
|
||||
setKillingScheduleIds((prev) => new Set(prev).add(scheduleId));
|
||||
|
||||
setApiError(null);
|
||||
try {
|
||||
const result = await killRunningJob(scheduleId);
|
||||
toastSuccess({
|
||||
title: 'Job Killed',
|
||||
msg: result.message,
|
||||
});
|
||||
await fetchSchedules();
|
||||
} catch (error) {
|
||||
console.error(`Failed to kill running job "${scheduleId}":`, error);
|
||||
const errorMsg =
|
||||
error instanceof Error ? error.message : `Unknown error killing job "${scheduleId}".`;
|
||||
setApiError(errorMsg);
|
||||
toastError({
|
||||
title: 'Kill Job Error',
|
||||
msg: errorMsg,
|
||||
});
|
||||
} finally {
|
||||
// Remove from killing set
|
||||
setKillingScheduleIds((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(scheduleId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleInspectRunningJob = async (scheduleId: string) => {
|
||||
// Immediately add to inspecting set to disable button
|
||||
setInspectingScheduleIds((prev) => new Set(prev).add(scheduleId));
|
||||
|
||||
setApiError(null);
|
||||
try {
|
||||
const result = await inspectRunningJob(scheduleId);
|
||||
if (result.sessionId) {
|
||||
const duration = result.runningDurationSeconds
|
||||
? `${Math.floor(result.runningDurationSeconds / 60)}m ${result.runningDurationSeconds % 60}s`
|
||||
: 'Unknown';
|
||||
toastSuccess({
|
||||
title: 'Job Inspection',
|
||||
msg: `Session: ${result.sessionId}\nRunning for: ${duration}`,
|
||||
});
|
||||
} else {
|
||||
toastSuccess({
|
||||
title: 'Job Inspection',
|
||||
msg: 'No detailed information available for this job',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to inspect running job "${scheduleId}":`, error);
|
||||
const errorMsg =
|
||||
error instanceof Error ? error.message : `Unknown error inspecting job "${scheduleId}".`;
|
||||
setApiError(errorMsg);
|
||||
toastError({
|
||||
title: 'Inspect Job Error',
|
||||
msg: errorMsg,
|
||||
});
|
||||
} finally {
|
||||
// Remove from inspecting set
|
||||
setInspectingScheduleIds((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(scheduleId);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleNavigateToScheduleDetail = (scheduleId: string) => {
|
||||
setViewingScheduleId(scheduleId);
|
||||
};
|
||||
@@ -372,7 +457,11 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
|
||||
}}
|
||||
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}
|
||||
disabled={
|
||||
pausingScheduleIds.has(job.id) ||
|
||||
deletingScheduleIds.has(job.id) ||
|
||||
isSubmitting
|
||||
}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -392,10 +481,54 @@ const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
|
||||
? '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)}
|
||||
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" />}
|
||||
{job.paused ? (
|
||||
<Play className="w-4 h-4" />
|
||||
) : (
|
||||
<Pause className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{job.currently_running && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleInspectRunningJob(job.id);
|
||||
}}
|
||||
className="text-blue-500 dark:text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 hover:bg-blue-100/50 dark:hover:bg-blue-900/30"
|
||||
title={`Inspect running job ${job.id}`}
|
||||
disabled={
|
||||
inspectingScheduleIds.has(job.id) || killingScheduleIds.has(job.id)
|
||||
}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleKillRunningJob(job.id);
|
||||
}}
|
||||
className="text-red-500 dark:text-red-400 hover:text-red-600 dark:hover:text-red-300 hover:bg-red-100/50 dark:hover:bg-red-900/30"
|
||||
title={`Kill running job ${job.id}`}
|
||||
disabled={
|
||||
killingScheduleIds.has(job.id) || inspectingScheduleIds.has(job.id)
|
||||
}
|
||||
>
|
||||
<Square className="w-4 h-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -408,7 +541,12 @@ 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={pausingScheduleIds.has(job.id) || deletingScheduleIds.has(job.id)}
|
||||
disabled={
|
||||
pausingScheduleIds.has(job.id) ||
|
||||
deletingScheduleIds.has(job.id) ||
|
||||
killingScheduleIds.has(job.id) ||
|
||||
inspectingScheduleIds.has(job.id)
|
||||
}
|
||||
>
|
||||
<TrashIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
updateSchedule as apiUpdateSchedule,
|
||||
sessionsHandler as apiGetScheduleSessions,
|
||||
runNowHandler as apiRunScheduleNow,
|
||||
killRunningJob as apiKillRunningJob,
|
||||
inspectRunningJob as apiInspectRunningJob,
|
||||
} from './api';
|
||||
|
||||
export interface ScheduledJob {
|
||||
@@ -16,6 +18,8 @@ export interface ScheduledJob {
|
||||
last_run?: string | null;
|
||||
currently_running?: boolean;
|
||||
paused?: boolean;
|
||||
current_session_id?: string | null;
|
||||
process_start_time?: string | null;
|
||||
}
|
||||
|
||||
export interface ScheduleSession {
|
||||
@@ -151,3 +155,47 @@ export async function updateSchedule(scheduleId: string, cron: string): Promise<
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export interface KillJobResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface InspectJobResponse {
|
||||
sessionId?: string | null;
|
||||
processStartTime?: string | null;
|
||||
runningDurationSeconds?: number | null;
|
||||
}
|
||||
|
||||
export async function killRunningJob(scheduleId: string): Promise<KillJobResponse> {
|
||||
try {
|
||||
const response = await apiKillRunningJob<true>({
|
||||
path: { id: scheduleId },
|
||||
});
|
||||
|
||||
if (response && response.data) {
|
||||
return response.data as KillJobResponse;
|
||||
}
|
||||
console.error('Unexpected response format from apiKillRunningJob', response);
|
||||
throw new Error('Failed to kill running job: Unexpected response format');
|
||||
} catch (error) {
|
||||
console.error(`Error killing running job ${scheduleId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function inspectRunningJob(scheduleId: string): Promise<InspectJobResponse> {
|
||||
try {
|
||||
const response = await apiInspectRunningJob<true>({
|
||||
path: { id: scheduleId },
|
||||
});
|
||||
|
||||
if (response && response.data) {
|
||||
return response.data as InspectJobResponse;
|
||||
}
|
||||
console.error('Unexpected response format from apiInspectRunningJob', response);
|
||||
throw new Error('Failed to inspect running job: Unexpected response format');
|
||||
} catch (error) {
|
||||
console.error(`Error inspecting running job ${scheduleId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user