2025-12-11 10:40:55 +01:00
|
|
|
/*
|
|
|
|
|
* Copyright (c) 2025 by Christian Kellner.
|
|
|
|
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
|
|
|
|
*/
|
|
|
|
|
|
2021-01-21 16:09:23 +01:00
|
|
|
import React from 'react';
|
|
|
|
|
|
|
|
|
|
import JobTable from '../../components/table/JobTable';
|
2025-09-18 20:09:11 +02:00
|
|
|
import { useSelector, useActions } from '../../services/state/store';
|
2025-12-18 19:16:28 +01:00
|
|
|
import { xhrDelete, xhrPut, xhrPost } from '../../services/xhr';
|
2025-09-02 20:18:37 +02:00
|
|
|
import { useNavigate } from 'react-router-dom';
|
2023-03-20 08:52:13 +01:00
|
|
|
import { Button, Toast } from '@douyinfe/semi-ui';
|
|
|
|
|
import { IconPlusCircle } from '@douyinfe/semi-icons';
|
2021-01-21 16:09:23 +01:00
|
|
|
import './Jobs.less';
|
|
|
|
|
|
|
|
|
|
export default function Jobs() {
|
|
|
|
|
const jobs = useSelector((state) => state.jobs.jobs);
|
2025-09-02 20:18:37 +02:00
|
|
|
const navigate = useNavigate();
|
2025-09-18 20:09:11 +02:00
|
|
|
const actions = useActions();
|
2025-12-18 19:16:28 +01:00
|
|
|
const pendingJobIdRef = React.useRef(null);
|
|
|
|
|
const evtSourceRef = React.useRef(null);
|
|
|
|
|
|
|
|
|
|
// SSE connection for live job status updates
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
// establish SSE connection
|
|
|
|
|
const src = new EventSource('/api/jobs/events');
|
|
|
|
|
evtSourceRef.current = src;
|
|
|
|
|
|
|
|
|
|
const onJobStatus = (e) => {
|
|
|
|
|
try {
|
|
|
|
|
const data = JSON.parse(e.data || '{}');
|
|
|
|
|
if (data && data.jobId) {
|
|
|
|
|
actions.jobs.setJobRunning(data.jobId, !!data.running);
|
|
|
|
|
// notify finish if it was triggered by this view
|
|
|
|
|
if (pendingJobIdRef.current === data.jobId && data.running === false) {
|
|
|
|
|
Toast.success('Job finished');
|
|
|
|
|
pendingJobIdRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore malformed events
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
src.addEventListener('jobStatus', onJobStatus);
|
|
|
|
|
src.onerror = () => {
|
|
|
|
|
// Let browser auto-reconnect; optionally log
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
try {
|
|
|
|
|
src.removeEventListener('jobStatus', onJobStatus);
|
|
|
|
|
src.close();
|
|
|
|
|
} catch {
|
|
|
|
|
//noop
|
|
|
|
|
}
|
|
|
|
|
evtSourceRef.current = null;
|
|
|
|
|
pendingJobIdRef.current = null;
|
|
|
|
|
};
|
|
|
|
|
}, [actions.jobs]);
|
2021-01-21 16:09:23 +01:00
|
|
|
|
|
|
|
|
const onJobRemoval = async (jobId) => {
|
|
|
|
|
try {
|
|
|
|
|
await xhrDelete('/api/jobs', { jobId });
|
2025-10-03 13:04:35 +02:00
|
|
|
Toast.success('Job successfully removed');
|
|
|
|
|
await actions.jobs.getJobs();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
Toast.error(error);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onListingRemoval = async (jobId) => {
|
|
|
|
|
try {
|
2025-10-03 13:27:44 +02:00
|
|
|
await xhrDelete('/api/listings/job', { jobId });
|
2025-10-03 13:04:35 +02:00
|
|
|
Toast.success('Listings successfully removed');
|
2025-09-18 20:09:11 +02:00
|
|
|
await actions.jobs.getJobs();
|
2021-01-21 16:09:23 +01:00
|
|
|
} catch (error) {
|
2023-03-20 08:52:13 +01:00
|
|
|
Toast.error(error);
|
2021-01-21 16:09:23 +01:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onJobStatusChanged = async (jobId, status) => {
|
|
|
|
|
try {
|
|
|
|
|
await xhrPut(`/api/jobs/${jobId}/status`, { status });
|
2023-03-20 08:52:13 +01:00
|
|
|
Toast.success('Job status successfully changed');
|
2025-09-18 20:09:11 +02:00
|
|
|
await actions.jobs.getJobs();
|
2021-01-21 16:09:23 +01:00
|
|
|
} catch (error) {
|
2023-03-20 08:52:13 +01:00
|
|
|
Toast.error(error);
|
2021-01-21 16:09:23 +01:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-18 19:16:28 +01:00
|
|
|
const onJobRun = async (jobId) => {
|
|
|
|
|
try {
|
|
|
|
|
const response = await xhrPost(`/api/jobs/${jobId}/run`);
|
|
|
|
|
if (response.status === 202) {
|
|
|
|
|
Toast.success('Job run started');
|
|
|
|
|
} else {
|
|
|
|
|
Toast.info('Job run requested');
|
|
|
|
|
}
|
|
|
|
|
// remember so we can show a finish toast when SSE says it's done
|
|
|
|
|
pendingJobIdRef.current = jobId;
|
|
|
|
|
// optional: one initial refresh in case SSE arrives late
|
|
|
|
|
await actions.jobs.getJobs();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (error?.status === 409) {
|
|
|
|
|
Toast.warning(error?.json?.message || 'Job is already running');
|
|
|
|
|
} else if (error?.status === 403) {
|
|
|
|
|
Toast.error('You are not allowed to run this job');
|
|
|
|
|
} else if (error?.status === 404) {
|
|
|
|
|
Toast.error('Job not found');
|
|
|
|
|
} else {
|
|
|
|
|
Toast.error('Failed to trigger job');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2021-01-21 16:09:23 +01:00
|
|
|
return (
|
|
|
|
|
<div>
|
2021-05-13 20:27:42 +02:00
|
|
|
<div>
|
2023-03-20 08:52:13 +01:00
|
|
|
<Button
|
|
|
|
|
type="primary"
|
|
|
|
|
icon={<IconPlusCircle />}
|
|
|
|
|
className="jobs__newButton"
|
2025-09-02 20:18:37 +02:00
|
|
|
onClick={() => navigate('/jobs/new')}
|
2023-03-20 08:52:13 +01:00
|
|
|
>
|
2021-05-13 20:27:42 +02:00
|
|
|
New Job
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
2021-01-21 16:09:23 +01:00
|
|
|
|
|
|
|
|
<JobTable
|
|
|
|
|
jobs={jobs || []}
|
|
|
|
|
onJobRemoval={onJobRemoval}
|
2025-10-03 13:04:35 +02:00
|
|
|
onListingRemoval={onListingRemoval}
|
2021-01-21 16:09:23 +01:00
|
|
|
onJobStatusChanged={onJobStatusChanged}
|
2025-12-18 19:16:28 +01:00
|
|
|
onJobRun={onJobRun}
|
2025-09-02 20:18:37 +02:00
|
|
|
onJobEdit={(jobId) => navigate(`/jobs/edit/${jobId}`)}
|
2021-01-21 16:09:23 +01:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|