/* * 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 (
} showClear placeholder="Search" onChange={handleFilterChange} />
{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}
))} {(jobsData?.result || []).length > 0 && jobsData?.totalNumber > 12 && (
)} ); }; export default JobGrid;