feat: implement proper task cancellation for scheduled jobs (#2731)

This commit is contained in:
Max Novich
2025-05-29 18:33:27 -07:00
committed by GitHub
parent a05029773d
commit bd430866e8
12 changed files with 2945 additions and 237 deletions
+98
View File
@@ -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"
}
+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, 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',
+60
View File
@@ -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>
+48
View File
@@ -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;
}
}