Files
fredy/ui/src/components/grid/jobs/JobGrid.jsx
Christian Kellner 472169693f Improvements 01 28 (#264)
* improving footer

* improve ui

* upgrading dependencies

* adding glow to all boxes on dashboard

* introducing single listing view

* next release version

* improve screenshots and login page
2026-01-28 14:27:03 +01:00

418 lines
14 KiB
JavaScript

/*
* 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) => <article className="jobPopoverContent">{text}</article>;
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 (
<div className="jobGrid">
<Space vertical align="start" style={{ width: '100%', marginBottom: '16px' }} spacing="medium">
<Button type="primary" icon={<IconPlusCircle />} onClick={() => navigate('/jobs/new')}>
New Job
</Button>
<div className="jobGrid__searchbar" style={{ width: '100%' }}>
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} />
<Button
icon={<IconFilter />}
style={{ marginLeft: '8px' }}
onClick={() => {
setShowFilterBar(!showFilterBar);
}}
/>
</div>
</Space>
{showFilterBar && (
<div className="jobGrid__toolbar">
<Space wrap style={{ marginBottom: '1rem' }}>
<div className="jobGrid__toolbar__card">
<div>
<Text strong>Filter by:</Text>
</div>
<div style={{ display: 'flex', gap: '.3rem' }}>
<Select
placeholder="Status"
showClear
onChange={(val) => setActivityFilter(val)}
value={activityFilter}
style={{ width: 140 }}
>
<Select.Option value={true}>Active</Select.Option>
<Select.Option value={false}>Not Active</Select.Option>
</Select>
</div>
</div>
<Divider layout="vertical" />
<div className="jobGrid__toolbar__card">
<div>
<Text strong>Sort by:</Text>
</div>
<div style={{ display: 'flex', gap: '.3rem' }}>
<Select
placeholder="Sort By"
style={{ width: 160 }}
value={sortField}
onChange={(val) => setSortField(val)}
>
<Select.Option value="name">Name</Select.Option>
<Select.Option value="numberOfFoundListings">Number of Listings</Select.Option>
<Select.Option value="enabled">Status</Select.Option>
</Select>
<Select
placeholder="Direction"
style={{ width: 120 }}
value={sortDir}
onChange={(val) => setSortDir(val)}
>
<Select.Option value="asc">Ascending</Select.Option>
<Select.Option value="desc">Descending</Select.Option>
</Select>
</div>
</div>
</Space>
</div>
)}
{(jobsData?.result || []).length === 0 && (
<Empty
image={<IllustrationNoResult />}
darkModeImage={<IllustrationNoResultDark />}
description="No jobs available yet..."
/>
)}
<Row gutter={[16, 16]}>
{(jobsData?.result || []).map((job) => (
<Col key={job.id} xs={24} sm={12} md={8} lg={6} xl={4} xxl={6}>
<Card
className="jobGrid__card"
bodyStyle={{ padding: '16px' }}
title={
<div className="jobGrid__header">
<Title heading={5} ellipsis={{ showTooltip: true }} className="jobGrid__title">
{job.name}
</Title>
<div style={{ display: 'flex', alignItems: 'center' }}>
{job.isOnlyShared && (
<Popover
content={getPopoverContent(
'This job has been shared with you by another user, therefor it is read-only.',
)}
>
<div>
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)', marginLeft: '8px' }} />
</div>
</Popover>
)}
</div>
{job.running && (
<Tag color="green" variant="light" size="small">
RUNNING
</Tag>
)}
</div>
}
>
<div className="jobGrid__content">
<Space vertical align="start" spacing={4} style={{ width: '100%', marginTop: 12 }}>
<div className="jobGrid__infoItem">
<Text type="secondary" icon={<IconSearch />} size="small">
Is active:
</Text>
<Switch
onChange={(checked) => onJobStatusChanged(job.id, checked)}
style={{ marginLeft: 'auto' }}
checked={job.enabled}
disabled={job.isOnlyShared}
size="small"
/>
</div>
<div className="jobGrid__infoItem">
<Text type="secondary" icon={<IconSearch />} size="small">
Listings:
</Text>
<Tag color="blue" size="small" style={{ marginLeft: 'auto' }}>
{job.numberOfFoundListings || 0}
</Tag>
</div>
<div className="jobGrid__infoItem">
<Text type="secondary" icon={<IconBriefcase />} size="small">
Providers:
</Text>
<Tag color="cyan" size="small" style={{ marginLeft: 'auto' }}>
{job.provider.length || 0}
</Tag>
</div>
<div className="jobGrid__infoItem">
<Text type="secondary" icon={<IconBell />} size="small">
Adapters:
</Text>
<Tag color="purple" size="small" style={{ marginLeft: 'auto' }}>
{job.notificationAdapter.length || 0}
</Tag>
</div>
</Space>
<Divider margin="12px" />
<div className="jobGrid__actions">
<Popover content={getPopoverContent('Run Job')}>
<div>
<Button
type="primary"
style={{ background: '#21aa21b5' }}
size="small"
theme="solid"
icon={<IconPlayCircle />}
disabled={job.isOnlyShared || job.running}
onClick={() => onJobRun(job.id)}
/>
</div>
</Popover>
<Popover content={getPopoverContent('Edit a Job')}>
<div>
<Button
type="secondary"
size="small"
icon={<IconEdit />}
disabled={job.isOnlyShared}
onClick={() => navigate(`/jobs/edit/${job.id}`)}
/>
</div>
</Popover>
<Popover content={getPopoverContent('Clone Job')}>
<div>
<Button
type="tertiary"
size="small"
icon={<IconCopy />}
disabled={job.isOnlyShared}
onClick={() => navigate('/jobs/new', { state: { cloneFrom: job.id } })}
/>
</div>
</Popover>
<Popover content={getPopoverContent('Delete all found Listings of this Job')}>
<div>
<Button
type="danger"
size="small"
icon={<IconDescend2 />}
disabled={job.isOnlyShared}
onClick={() => onListingRemoval(job.id)}
/>
</div>
</Popover>
<Popover content={getPopoverContent('Delete Job')}>
<div>
<Button
type="danger"
size="small"
icon={<IconDelete />}
disabled={job.isOnlyShared}
onClick={() => onJobRemoval(job.id)}
/>
</div>
</Popover>
</div>
</div>
</Card>
</Col>
))}
</Row>
{(jobsData?.result || []).length > 0 && jobsData?.totalNumber > 12 && (
<div className="jobGrid__pagination">
<Pagination
currentPage={page}
pageSize={pageSize}
total={jobsData?.totalNumber || 0}
onPageChange={handlePageChange}
showSizeChanger={false}
/>
</div>
)}
</div>
);
};
export default JobGrid;