/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React, { useState, useEffect, useMemo, useRef } from 'react';
import {
Card,
Col,
Row,
Button,
Space,
Typography,
Divider,
Switch,
Popover,
Tag,
Input,
Select,
Pagination,
Toast,
Empty,
} from '@douyinfe/semi-ui-19';
import {
IconAlertTriangle,
IconDelete,
IconDescend2,
IconEdit,
IconCopy,
IconPlayCircle,
IconBriefcase,
IconBell,
IconSearch,
IconFilter,
IconPlusCircle,
} from '@douyinfe/semi-icons';
import { useNavigate } from 'react-router-dom';
import { useActions, useSelector } from '../../../services/state/store.js';
import { xhrDelete, xhrPut, xhrPost } from '../../../services/xhr.js';
import debounce from 'lodash/debounce';
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
import './JobGrid.less';
const { Text, Title } = Typography;
const getPopoverContent = (text) => {text};
const JobGrid = () => {
const jobsData = useSelector((state) => state.jobsData);
const actions = useActions();
const navigate = useNavigate();
const [page, setPage] = useState(1);
const pageSize = 12;
const [sortField, setSortField] = useState('name');
const [sortDir, setSortDir] = useState('asc');
const [freeTextFilter, setFreeTextFilter] = useState(null);
const [activityFilter, setActivityFilter] = useState(null);
const [showFilterBar, setShowFilterBar] = useState(false);
const pendingJobIdRef = useRef(null);
const evtSourceRef = useRef(null);
const loadData = () => {
actions.jobsData.getJobsData({
page,
pageSize,
sortfield: sortField,
sortdir: sortDir,
freeTextFilter,
filter: { activityFilter },
});
};
useEffect(() => {
loadData();
}, [page, sortField, sortDir, freeTextFilter, activityFilter]);
// SSE connection for live job status updates
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.jobsData.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
};
return () => {
try {
src.removeEventListener('jobStatus', onJobStatus);
src.close();
} catch {
//noop
}
evtSourceRef.current = null;
pendingJobIdRef.current = null;
};
}, [actions.jobsData]);
const handleFilterChange = useMemo(() => debounce((value) => setFreeTextFilter(value), 500), []);
useEffect(() => {
return () => {
handleFilterChange.cancel && handleFilterChange.cancel();
};
}, [handleFilterChange]);
const onJobRemoval = async (jobId) => {
try {
await xhrDelete('/api/jobs', { jobId });
Toast.success('Job successfully removed');
loadData();
actions.jobsData.getJobs(); // refresh select list too
} catch (error) {
Toast.error(error);
}
};
const onListingRemoval = async (jobId) => {
try {
await xhrDelete('/api/listings/job', { jobId });
Toast.success('Listings successfully removed');
loadData();
} catch (error) {
Toast.error(error);
}
};
const onJobStatusChanged = async (jobId, status) => {
try {
await xhrPut(`/api/jobs/${jobId}/status`, { status });
Toast.success('Job status successfully changed');
loadData();
} catch (error) {
Toast.error(error);
}
};
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');
}
pendingJobIdRef.current = jobId;
loadData();
} 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');
}
}
};
const handlePageChange = (_page) => {
setPage(_page);
};
return (
} onClick={() => navigate('/jobs/new')}>
New Job
} showClear placeholder="Search" onChange={handleFilterChange} />
}
style={{ marginLeft: '8px' }}
onClick={() => {
setShowFilterBar(!showFilterBar);
}}
/>
{showFilterBar && (
Filter by:
Sort by:
)}
{(jobsData?.result || []).length === 0 && (
}
darkModeImage={
}
description="No jobs available yet..."
/>
)}
{(jobsData?.result || []).map((job) => (
{job.name}
{job.isOnlyShared && (
)}
{job.running && (
RUNNING
)}
}
>
} size="small">
Is active:
onJobStatusChanged(job.id, checked)}
style={{ marginLeft: 'auto' }}
checked={job.enabled}
disabled={job.isOnlyShared}
size="small"
/>
} size="small">
Listings:
{job.numberOfFoundListings || 0}
} size="small">
Providers:
{job.provider.length || 0}
} size="small">
Adapters:
{job.notificationAdapter.length || 0}
}
disabled={job.isOnlyShared || job.running}
onClick={() => onJobRun(job.id)}
/>
}
disabled={job.isOnlyShared}
onClick={() => navigate(`/jobs/edit/${job.id}`)}
/>
}
disabled={job.isOnlyShared}
onClick={() => navigate('/jobs/new', { state: { cloneFrom: job.id } })}
/>
}
disabled={job.isOnlyShared}
onClick={() => onListingRemoval(job.id)}
/>
}
disabled={job.isOnlyShared}
onClick={() => onJobRemoval(job.id)}
/>
))}
{(jobsData?.result || []).length > 0 && jobsData?.totalNumber > 12 && (
)}
);
};
export default JobGrid;