mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Redesigning listing table (#248)
* redesigning listing table * getting rid of old listing table view * improving listing grid
This commit is contained in:
committed by
GitHub
parent
398259ff20
commit
3c209a8f97
402
ui/src/components/grid/jobs/JobGrid.jsx
Normal file
402
ui/src/components/grid/jobs/JobGrid.jsx
Normal file
@@ -0,0 +1,402 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconDelete,
|
||||
IconDescend2,
|
||||
IconEdit,
|
||||
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">
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Button
|
||||
style={{ width: '7rem', margin: 0 }}
|
||||
type="primary"
|
||||
icon={<IconPlusCircle />}
|
||||
className="jobs__newButton"
|
||||
onClick={() => navigate('/jobs/new')}
|
||||
>
|
||||
New Job
|
||||
</Button>
|
||||
|
||||
<div className="jobGrid__searchbar">
|
||||
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} />
|
||||
<Popover content="Filter / Sort Results" style={{ color: 'white', padding: '.5rem' }}>
|
||||
<Button
|
||||
icon={<IconFilter />}
|
||||
onClick={() => {
|
||||
setShowFilterBar(!showFilterBar);
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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' }}
|
||||
headerLine={true}
|
||||
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.',
|
||||
)}
|
||||
>
|
||||
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)', marginLeft: '8px' }} />
|
||||
</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')}>
|
||||
<Button
|
||||
type="primary"
|
||||
theme="solid"
|
||||
icon={<IconPlayCircle />}
|
||||
disabled={job.isOnlyShared || job.running}
|
||||
onClick={() => onJobRun(job.id)}
|
||||
/>
|
||||
</Popover>
|
||||
<Popover content={getPopoverContent('Edit a Job')}>
|
||||
<Button
|
||||
type="secondary"
|
||||
theme="solid"
|
||||
icon={<IconEdit />}
|
||||
disabled={job.isOnlyShared}
|
||||
onClick={() => navigate(`/jobs/edit/${job.id}`)}
|
||||
/>
|
||||
</Popover>
|
||||
<Popover content={getPopoverContent('Delete all found Listings of this Job')}>
|
||||
<Button
|
||||
type="danger"
|
||||
theme="solid"
|
||||
icon={<IconDescend2 />}
|
||||
disabled={job.isOnlyShared}
|
||||
onClick={() => onListingRemoval(job.id)}
|
||||
/>
|
||||
</Popover>
|
||||
<Popover content={getPopoverContent('Delete Job')}>
|
||||
<Button
|
||||
type="danger"
|
||||
theme="solid"
|
||||
icon={<IconDelete />}
|
||||
disabled={job.isOnlyShared}
|
||||
onClick={() => onJobRemoval(job.id)}
|
||||
/>
|
||||
</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;
|
||||
69
ui/src/components/grid/jobs/JobGrid.less
Normal file
69
ui/src/components/grid/jobs/JobGrid.less
Normal file
@@ -0,0 +1,69 @@
|
||||
.jobGrid {
|
||||
&__card {
|
||||
height: 100%;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--semi-shadow-elevated);
|
||||
}
|
||||
}
|
||||
|
||||
&__searchbar {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
&__toolbar {
|
||||
&__card {
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .3rem;
|
||||
background: #232429;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
&__infoItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
.semi-typography {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__pagination {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.jobPopoverContent {
|
||||
padding: .4rem;
|
||||
color: var(--semi-color-white);
|
||||
}
|
||||
Reference in New Issue
Block a user