diff --git a/doc/screenshot1.png b/doc/screenshot1.png
index 65859cb0..80831621 100644
Binary files a/doc/screenshot1.png and b/doc/screenshot1.png differ
diff --git a/doc/screenshot2.png b/doc/screenshot2.png
index 87b81307..fe22a715 100644
Binary files a/doc/screenshot2.png and b/doc/screenshot2.png differ
diff --git a/lib/api/routes/jobRouter.js b/lib/api/routes/jobRouter.js
index 9f6e244c..80a55ce2 100644
--- a/lib/api/routes/jobRouter.js
+++ b/lib/api/routes/jobRouter.js
@@ -50,6 +50,46 @@ jobRouter.get('/', async (req, res) => {
res.send();
});
+jobRouter.get('/data', async (req, res) => {
+ const { page, pageSize = 50, activityFilter, sortfield = null, sortdir = 'asc', freeTextFilter } = req.query || {};
+
+ // normalize booleans
+ const toBool = (v) => {
+ if (v === true || v === 'true' || v === 1 || v === '1') return true;
+ if (v === false || v === 'false' || v === 0 || v === '0') return false;
+ return null;
+ };
+ const normalizedActivity = toBool(activityFilter);
+
+ const queryResult = jobStorage.queryJobs({
+ page: page ? parseInt(page, 10) : 1,
+ pageSize: pageSize ? parseInt(pageSize, 10) : 50,
+ freeTextFilter: freeTextFilter || null,
+ activityFilter: normalizedActivity,
+ sortField: sortfield || null,
+ sortDir: sortdir === 'desc' ? 'desc' : 'asc',
+ userId: req.session.currentUser,
+ isAdmin: isAdmin(req),
+ });
+
+ const isUserAdmin = isAdmin(req);
+
+ // Map result to include runtime status
+ queryResult.result = queryResult.result.map((job) => {
+ return {
+ ...job,
+ running: isJobRunning(job.id),
+ isOnlyShared:
+ !isUserAdmin &&
+ job.userId !== req.session.currentUser &&
+ job.shared_with_user.includes(req.session.currentUser),
+ };
+ });
+
+ res.body = queryResult;
+ res.send();
+});
+
// Server-Sent Events for job status updates
jobRouter.get('/events', async (req, res) => {
const userId = req.session.currentUser;
diff --git a/lib/api/routes/listingsRouter.js b/lib/api/routes/listingsRouter.js
index 966be161..b597d698 100644
--- a/lib/api/routes/listingsRouter.js
+++ b/lib/api/routes/listingsRouter.js
@@ -28,10 +28,14 @@ listingsRouter.get('/table', async (req, res) => {
freeTextFilter,
} = req.query || {};
- // normalize booleans (accept true, 'true', 1, '1')
- const toBool = (v) => v === true || v === 'true' || v === 1 || v === '1';
- const normalizedActivity = toBool(activityFilter) ? true : null;
- const normalizedWatch = toBool(watchListFilter) ? true : null;
+ // normalize booleans (accept true, 'true', 1, '1' for true; false, 'false', 0, '0' for false)
+ const toBool = (v) => {
+ if (v === true || v === 'true' || v === 1 || v === '1') return true;
+ if (v === false || v === 'false' || v === 0 || v === '0') return false;
+ return null;
+ };
+ const normalizedActivity = toBool(activityFilter);
+ const normalizedWatch = toBool(watchListFilter);
let jobFilter = null;
let jobIdFilter = null;
diff --git a/lib/services/extractor/puppeteerExtractor.js b/lib/services/extractor/puppeteerExtractor.js
index 62e73ca3..684f3a77 100644
--- a/lib/services/extractor/puppeteerExtractor.js
+++ b/lib/services/extractor/puppeteerExtractor.js
@@ -104,7 +104,11 @@ export default async function execute(url, waitForSelector, options) {
result = pageSource || (await page.content());
}
} catch (error) {
- logger.warn('Error executing with puppeteer executor', error);
+ if (error?.message?.includes('Timeout')) {
+ logger.debug('Error executing with puppeteer executor', error);
+ } else {
+ logger.warn('Error executing with puppeteer executor', error);
+ }
result = null;
} finally {
try {
diff --git a/lib/services/jobs/run-state.js b/lib/services/jobs/run-state.js
index 6cffac18..c6f7131e 100644
--- a/lib/services/jobs/run-state.js
+++ b/lib/services/jobs/run-state.js
@@ -40,11 +40,3 @@ export function markRunning(jobId) {
export function markFinished(jobId) {
running.delete(jobId);
}
-
-/**
- * Retrieve all currently running job IDs.
- * @returns {string[]}
- */
-export function getRunningJobIds() {
- return Array.from(running);
-}
diff --git a/lib/services/storage/jobStorage.js b/lib/services/storage/jobStorage.js
index a593ca80..9a9586a5 100644
--- a/lib/services/storage/jobStorage.js
+++ b/lib/services/storage/jobStorage.js
@@ -163,3 +163,109 @@ export const getJobs = () => {
notificationAdapter: fromJson(row.notificationAdapter, []),
}));
};
+
+/**
+ * Query jobs with pagination, filtering and sorting.
+ *
+ * @param {Object} params
+ * @param {number} [params.pageSize=50]
+ * @param {number} [params.page=1]
+ * @param {string} [params.freeTextFilter]
+ * @param {object} [params.activityFilter]
+ * @param {string|null} [params.sortField=null]
+ * @param {('asc'|'desc')} [params.sortDir='asc']
+ * @param {string} [params.userId] - Current user id used to scope jobs (ignored for admins).
+ * @param {boolean} [params.isAdmin=false] - When true, returns all jobs.
+ * @returns {{ totalNumber:number, page:number, result:Object[] }}
+ */
+export const queryJobs = ({
+ pageSize = 50,
+ page = 1,
+ activityFilter,
+ freeTextFilter,
+ sortField = null,
+ sortDir = 'asc',
+ userId = null,
+ isAdmin = false,
+} = {}) => {
+ // sanitize inputs
+ const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.min(500, Math.floor(pageSize)) : 50;
+ const safePage = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1;
+ const offset = (safePage - 1) * safePageSize;
+
+ // build WHERE filter
+ const whereParts = [];
+ const params = { limit: safePageSize, offset };
+ params.userId = userId || '__NO_USER__';
+
+ if (!isAdmin) {
+ whereParts.push(
+ `(j.user_id = @userId OR EXISTS (SELECT 1 FROM json_each(j.shared_with_user) AS sw WHERE sw.value = @userId))`,
+ );
+ }
+
+ if (freeTextFilter && String(freeTextFilter).trim().length > 0) {
+ params.filter = `%${String(freeTextFilter).trim()}%`;
+ whereParts.push(`(j.name LIKE @filter)`);
+ }
+
+ if (activityFilter === true) {
+ whereParts.push('(j.enabled = 1)');
+ } else if (activityFilter === false) {
+ whereParts.push('(j.enabled = 0)');
+ }
+
+ const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
+
+ // whitelist sortable fields
+ const sortable = new Set(['name', 'numberOfFoundListings', 'enabled']);
+ const safeSortField = sortField && sortable.has(sortField) ? sortField : null;
+ const safeSortDir = String(sortDir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
+
+ let orderSql = 'ORDER BY j.name IS NULL, j.name ASC';
+ if (safeSortField) {
+ if (safeSortField === 'numberOfFoundListings') {
+ orderSql = `ORDER BY numberOfFoundListings ${safeSortDir}`;
+ } else {
+ orderSql = `ORDER BY j.${safeSortField} ${safeSortDir}`;
+ }
+ }
+
+ // count total
+ const countRow = SqliteConnection.query(
+ `SELECT COUNT(1) as cnt
+ FROM jobs j
+ ${whereSql}`,
+ params,
+ );
+ const totalNumber = countRow?.[0]?.cnt ?? 0;
+
+ // fetch page
+ const rows = SqliteConnection.query(
+ `SELECT j.id,
+ j.user_id AS userId,
+ j.enabled,
+ j.name,
+ j.blacklist,
+ j.provider,
+ j.shared_with_user,
+ j.notification_adapter AS notificationAdapter,
+ (SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings
+ FROM jobs j
+ ${whereSql}
+ ${orderSql}
+ LIMIT @limit OFFSET @offset`,
+ params,
+ );
+
+ const result = rows.map((row) => ({
+ ...row,
+ enabled: !!row.enabled,
+ blacklist: fromJson(row.blacklist, []),
+ provider: fromJson(row.provider, []),
+ shared_with_user: fromJson(row.shared_with_user, []),
+ notificationAdapter: fromJson(row.notificationAdapter, []),
+ }));
+
+ return { totalNumber, page: safePage, result };
+};
diff --git a/lib/services/storage/listingsStorage.js b/lib/services/storage/listingsStorage.js
index 1355525e..6b4984f7 100755
--- a/lib/services/storage/listingsStorage.js
+++ b/lib/services/storage/listingsStorage.js
@@ -277,9 +277,11 @@ export const queryListings = ({
params.filter = `%${String(freeTextFilter).trim()}%`;
whereParts.push(`(title LIKE @filter OR address LIKE @filter OR provider LIKE @filter OR link LIKE @filter)`);
}
- // activityFilter: when true -> only active listings (is_active = 1)
+ // activityFilter: when true -> only active listings (is_active = 1), false -> only inactive
if (activityFilter === true) {
whereParts.push('(is_active = 1)');
+ } else if (activityFilter === false) {
+ whereParts.push('(is_active = 0)');
}
// Prefer filtering by job id when provided (unambiguous and robust)
if (jobIdFilter && String(jobIdFilter).trim().length > 0) {
@@ -295,9 +297,11 @@ export const queryListings = ({
params.providerName = String(providerFilter).trim();
whereParts.push('(provider = @providerName)');
}
- // watchListFilter: when true -> only watched listings
+ // watchListFilter: when true -> only watched listings, false -> only unwatched
if (watchListFilter === true) {
whereParts.push('(wl.id IS NOT NULL)');
+ } else if (watchListFilter === false) {
+ whereParts.push('(wl.id IS NULL)');
}
const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
diff --git a/package.json b/package.json
index 4522d41a..e332eeb6 100755
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "fredy",
- "version": "16.3.0",
+ "version": "17.0.0",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": {
"prepare": "husky",
@@ -12,7 +12,7 @@
"format": "prettier --write \"**/*.js\"",
"format:check": "prettier --check \"**/*.js\"",
"test": "node --import ./test/esmock-loader.mjs ./node_modules/mocha/bin/mocha.js --timeout 60000 test/**/*.test.js",
- "testGH": "node --import ./test/esmock-loader.mjs ./node_modules/mocha/bin/mocha.js --timeout 60000 --exclude test/provider/immonet.test.js --exclude test/provider/immowelt.test.js test/**/*.test.js",
+ "testGH": "node --import ./test/esmock-loader.mjs ./node_modules/mocha/bin/mocha.js --timeout 60000 --exclude test/provider/immonet.test.js --exclude test/provider/immobilienDe.test.js --exclude test/provider/immowelt.test.js test/**/*.test.js",
"lint": "eslint .",
"lint:fix": "yarn lint --fix",
"migratedb": "node lib/services/storage/migrations/migrate.js",
@@ -77,7 +77,7 @@
"node-mailjet": "6.0.11",
"p-throttle": "^8.1.0",
"package-up": "^5.0.0",
- "puppeteer": "^24.33.1",
+ "puppeteer": "^24.34.0",
"puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"query-string": "9.3.1",
@@ -99,7 +99,7 @@
"@babel/eslint-parser": "7.28.5",
"@babel/preset-env": "7.28.5",
"@babel/preset-react": "7.28.5",
- "chai": "6.2.1",
+ "chai": "6.2.2",
"eslint": "9.39.2",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-react": "7.37.5",
diff --git a/ui/src/App.jsx b/ui/src/App.jsx
index 649aaf45..e2f5a34e 100644
--- a/ui/src/App.jsx
+++ b/ui/src/App.jsx
@@ -40,8 +40,8 @@ export default function FredyApp() {
if (!needsLogin()) {
await actions.features.getFeatures();
await actions.provider.getProvider();
- await actions.jobs.getJobs();
- await actions.jobs.getSharableUserList();
+ await actions.jobsData.getJobs();
+ await actions.jobsData.getSharableUserList();
await actions.notificationAdapter.getAdapter();
await actions.generalSettings.getGeneralSettings();
await actions.versionUpdate.getVersionUpdate();
diff --git a/ui/src/components/cards/DashboardCardColors.less b/ui/src/components/cards/DashboardCardColors.less
index 36d07074..36408e9e 100644
--- a/ui/src/components/cards/DashboardCardColors.less
+++ b/ui/src/components/cards/DashboardCardColors.less
@@ -3,11 +3,11 @@
@color-blue-text: #60a5fa;
@color-orange-bg: rgba(250, 91, 5, 0.12);
-@color-orange-border: #d33601;
+@color-orange-border: #992f0c;
@color-orange-text: #FB923CFF;
@color-green-bg: rgba(38, 250, 5, 0.12);
-@color-green-border: #00c316;
+@color-green-border: #278832;
@color-green-text: #33f308;
@color-purple-bg: rgba(91, 3, 218, 0.38);
diff --git a/ui/src/components/grid/jobs/JobGrid.jsx b/ui/src/components/grid/jobs/JobGrid.jsx
new file mode 100644
index 00000000..6b1d8034
--- /dev/null
+++ b/ui/src/components/grid/jobs/JobGrid.jsx
@@ -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) => {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 (
+
+
+
}
+ className="jobs__newButton"
+ onClick={() => navigate('/jobs/new')}
+ >
+ New Job
+
+
+
+
} showClear placeholder="Search" onChange={handleFilterChange} />
+
+ }
+ 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={() => onListingRemoval(job.id)}
+ />
+
+
+ }
+ disabled={job.isOnlyShared}
+ onClick={() => onJobRemoval(job.id)}
+ />
+
+
+
+
+
+ ))}
+
+ {(jobsData?.result || []).length > 0 && jobsData?.totalNumber > 12 && (
+
+ )}
+
+ );
+};
+
+export default JobGrid;
diff --git a/ui/src/components/grid/jobs/JobGrid.less b/ui/src/components/grid/jobs/JobGrid.less
new file mode 100644
index 00000000..6fb2e6dc
--- /dev/null
+++ b/ui/src/components/grid/jobs/JobGrid.less
@@ -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);
+}
diff --git a/ui/src/components/grid/listings/ListingsGrid.jsx b/ui/src/components/grid/listings/ListingsGrid.jsx
new file mode 100644
index 00000000..cd62b743
--- /dev/null
+++ b/ui/src/components/grid/listings/ListingsGrid.jsx
@@ -0,0 +1,324 @@
+/*
+ * Copyright (c) 2025 by Christian Kellner.
+ * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
+ */
+
+import React, { useState, useEffect, useMemo } from 'react';
+import {
+ Card,
+ Col,
+ Row,
+ Image,
+ Button,
+ Space,
+ Typography,
+ Pagination,
+ Toast,
+ Divider,
+ Input,
+ Select,
+ Popover,
+ Empty,
+} from '@douyinfe/semi-ui';
+import {
+ IconBriefcase,
+ IconCart,
+ IconClock,
+ IconDelete,
+ IconLink,
+ IconMapPin,
+ IconStar,
+ IconStarStroked,
+ IconSearch,
+ IconFilter,
+} from '@douyinfe/semi-icons';
+import no_image from '../../../assets/no_image.jpg';
+import * as timeService from '../../../services/time/timeService.js';
+import { xhrDelete, xhrPost } from '../../../services/xhr.js';
+import { useActions, useSelector } from '../../../services/state/store.js';
+import debounce from 'lodash/debounce';
+
+import './ListingsGrid.less';
+import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
+
+const { Text } = Typography;
+
+const ListingsGrid = () => {
+ const listingsData = useSelector((state) => state.listingsData);
+ const providers = useSelector((state) => state.provider);
+ const jobs = useSelector((state) => state.jobsData.jobs);
+ const actions = useActions();
+
+ const [page, setPage] = useState(1);
+ const pageSize = 40;
+
+ const [sortField, setSortField] = useState('created_at');
+ const [sortDir, setSortDir] = useState('desc');
+ const [freeTextFilter, setFreeTextFilter] = useState(null);
+ const [watchListFilter, setWatchListFilter] = useState(null);
+ const [jobNameFilter, setJobNameFilter] = useState(null);
+ const [activityFilter, setActivityFilter] = useState(null);
+ const [providerFilter, setProviderFilter] = useState(null);
+ const [showFilterBar, setShowFilterBar] = useState(false);
+
+ const loadData = () => {
+ actions.listingsData.getListingsData({
+ page,
+ pageSize,
+ sortfield: sortField,
+ sortdir: sortDir,
+ freeTextFilter,
+ filter: { watchListFilter, jobNameFilter, activityFilter, providerFilter },
+ });
+ };
+
+ useEffect(() => {
+ loadData();
+ }, [page, sortField, sortDir, freeTextFilter, providerFilter, activityFilter, jobNameFilter, watchListFilter]);
+
+ const handleFilterChange = useMemo(() => debounce((value) => setFreeTextFilter(value), 500), []);
+
+ useEffect(() => {
+ return () => {
+ // cleanup debounced handler to avoid memory leaks
+ handleFilterChange.cancel && handleFilterChange.cancel();
+ };
+ }, [handleFilterChange]);
+
+ const handleWatch = async (e, item) => {
+ e.preventDefault();
+ e.stopPropagation();
+ try {
+ await xhrPost('/api/listings/watch', { listingId: item.id });
+ Toast.success(item.isWatched === 1 ? 'Listing removed from Watchlist' : 'Listing added to Watchlist');
+ loadData();
+ } catch (e) {
+ console.error(e);
+ Toast.error('Failed to operate Watchlist');
+ }
+ };
+
+ const handlePageChange = (_page) => {
+ setPage(_page);
+ };
+
+ return (
+
+
+
} showClear placeholder="Search" onChange={handleFilterChange} />
+
+ }
+ onClick={() => {
+ setShowFilterBar(!showFilterBar);
+ }}
+ />
+
+
+ {showFilterBar && (
+
+
+
+
+ Filter by:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Sort by:
+
+
+
+
+
+
+
+
+
+ )}
+
+ {(listingsData?.result || []).length === 0 && (
+
}
+ darkModeImage={
}
+ description="No listings available yet..."
+ />
+ )}
+
+ {(listingsData?.result || []).map((item) => (
+
+
+
+
+
+ ) : (
+
+ )
+ }
+ theme="light"
+ shape="circle"
+ size="small"
+ className="listingsGrid__watchButton"
+ onClick={(e) => handleWatch(e, item)}
+ />
+
+ {!item.is_active && Inactive
}
+
+ }
+ bodyStyle={{ padding: '12px' }}
+ >
+
+
+
+ {item.title}
+
+
+
+ } size="small">
+ {item.price} €
+
+ }
+ size="small"
+ ellipsis={{ showTooltip: true }}
+ style={{ width: '100%' }}
+ >
+ {item.address || 'No address provided'}
+
+ }>
+ {timeService.format(item.created_at, false)}
+
+ }>
+ {item.provider.charAt(0).toUpperCase() + item.provider.slice(1)}
+
+
+
+
+
+
+
+
+ ))}
+
+ {(listingsData?.result || []).length > 0 && (
+
+ )}
+
+ );
+};
+
+export default ListingsGrid;
diff --git a/ui/src/components/grid/listings/ListingsGrid.less b/ui/src/components/grid/listings/ListingsGrid.less
new file mode 100644
index 00000000..9f6a1705
--- /dev/null
+++ b/ui/src/components/grid/listings/ListingsGrid.less
@@ -0,0 +1,106 @@
+.listingsGrid {
+ &__imageContainer {
+ position: relative;
+ height: 180px;
+ overflow: hidden;
+ }
+
+ &__searchbar {
+ display: flex;
+ gap: .5rem;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 1rem;
+ }
+
+ &__watchButton {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ background-color: white !important;
+ box-shadow: var(--semi-shadow-elevated);
+
+ &:hover {
+ background-color: var(--semi-color-fill-0) !important;
+ }
+ }
+
+ &__statusTag {
+ position: absolute;
+ bottom: 8px;
+ left: 8px;
+ }
+
+ &__card {
+ height: 100%;
+ transition: transform 0.2s;
+
+ &:hover {
+ transform: translateY(-4px);
+ box-shadow: var(--semi-shadow-elevated);
+ }
+
+ &--inactive {
+ .listingsGrid__imageContainer,
+ .listingsGrid__content {
+ opacity: 0.6;
+ }
+ }
+ }
+
+ &__inactiveOverlay {
+ position: absolute;
+ top: 70px;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ pointer-events: none;
+ z-index: 10;
+ color: var(--semi-color-danger);
+ font-weight: bold;
+ font-size: 1.3rem;
+ text-transform: uppercase;
+ transform: rotate(-30deg);
+ padding: 5px;
+ max-height: fit-content;
+ margin: auto;
+ }
+
+ &__titleLink {
+ color: inherit;
+ text-decoration: none;
+
+ &:hover {
+ color: var(--semi-color-primary);
+ }
+ }
+
+ &__title {
+ display: block;
+ height: 1.5em;
+ }
+
+ &__pagination {
+ margin-top: 2rem;
+ display: flex;
+ justify-content: center;
+ }
+
+ &__toolbar {
+
+ &__card {
+ border-radius: 5px;
+ display: flex;
+ flex-direction: column;
+ gap: .3rem;
+ background: #232429;
+ }
+ }
+
+ &__setupButton {
+ margin-bottom: 1rem;
+ }
+}
diff --git a/ui/src/components/table/JobTable.jsx b/ui/src/components/table/JobTable.jsx
deleted file mode 100644
index 15788f63..00000000
--- a/ui/src/components/table/JobTable.jsx
+++ /dev/null
@@ -1,141 +0,0 @@
-/*
- * Copyright (c) 2025 by Christian Kellner.
- * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
- */
-
-import React from 'react';
-
-import { Button, Empty, Table, Switch, Popover } from '@douyinfe/semi-ui';
-import { IconAlertTriangle, IconDelete, IconDescend2, IconEdit, IconPlayCircle } from '@douyinfe/semi-icons';
-import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
-
-import './JobTable.less';
-
-const empty = (
- }
- darkModeImage={}
- description="No jobs available. Why don't you create one? ;)"
- />
-);
-
-const getPopoverContent = (text) => {text};
-
-export default function JobTable({
- jobs = {},
- onJobRemoval,
- onJobStatusChanged,
- onJobEdit,
- onListingRemoval,
- onJobRun,
-} = {}) {
- return (
- {
- return (
- onJobStatusChanged(job.id, checked)}
- checked={job.enabled}
- disabled={job.isOnlyShared}
- />
- );
- },
- },
- {
- title: 'Name',
- dataIndex: 'name',
- render: (name, job) => {
- if (job.isOnlyShared) {
- return (
-
-
-
- );
- } else {
- return name;
- }
- },
- },
- {
- title: 'Listings',
- dataIndex: 'numberOfFoundListings',
- render: (value) => {
- return value || 0;
- },
- },
- {
- title: 'Provider',
- dataIndex: 'provider',
- render: (value) => {
- return value.length || 0;
- },
- },
- {
- title: 'Notification Adapter',
- dataIndex: 'notificationAdapter',
- render: (value) => {
- return value.length || 0;
- },
- },
- {
- title: '',
- dataIndex: 'tools',
- render: (_, job) => {
- return (
-
-
- }
- disabled={job.isOnlyShared || job.running}
- onClick={() => onJobRun && onJobRun(job.id)}
- />
-
-
- }
- disabled={job.isOnlyShared}
- onClick={() => onJobEdit(job.id)}
- />
-
-
- }
- disabled={job.isOnlyShared}
- onClick={() => onListingRemoval(job.id)}
- />
-
-
- }
- disabled={job.isOnlyShared}
- onClick={() => onJobRemoval(job.id)}
- />
-
-
- );
- },
- },
- ]}
- dataSource={jobs}
- />
- );
-}
diff --git a/ui/src/components/table/JobTable.less b/ui/src/components/table/JobTable.less
deleted file mode 100644
index 7d7d9eeb..00000000
--- a/ui/src/components/table/JobTable.less
+++ /dev/null
@@ -1,17 +0,0 @@
-.interactions {
- float: right;
- display: flex;
- flex-direction: column;
- gap: 1rem;
-}
-
-.jobPopoverContent {
- padding: 1rem;
- color: white;
-}
-
-@media (min-width: 768px) {
- .interactions {
- flex-direction: initial;
- }
-}
diff --git a/ui/src/components/table/listings/ListingsTable.jsx b/ui/src/components/table/listings/ListingsTable.jsx
deleted file mode 100644
index 5149e6b8..00000000
--- a/ui/src/components/table/listings/ListingsTable.jsx
+++ /dev/null
@@ -1,417 +0,0 @@
-/*
- * Copyright (c) 2025 by Christian Kellner.
- * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
- */
-
-import React, { useState, useEffect, useMemo } from 'react';
-import {
- Table,
- Popover,
- Input,
- Descriptions,
- Tag,
- Image,
- Empty,
- Button,
- Toast,
- Divider,
- Space,
- Select,
-} from '@douyinfe/semi-ui';
-import { useActions, useSelector } from '../../../services/state/store.js';
-import { IconClose, IconDelete, IconSearch, IconStar, IconStarStroked, IconTick } from '@douyinfe/semi-icons';
-import * as timeService from '../../../services/time/timeService.js';
-import debounce from 'lodash/debounce';
-import no_image from '../../../assets/no_image.jpg';
-
-import './ListingsTable.less';
-import { format } from '../../../services/time/timeService.js';
-import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
-import { xhrDelete, xhrPost } from '../../../services/xhr.js';
-import { useNavigate } from 'react-router-dom';
-import { useFeature } from '../../../hooks/featureHook.js';
-
-const getColumns = (provider, setProviderFilter, jobs, setJobNameFilter) => {
- return [
- {
- title: 'Watchlist',
- width: 133,
- dataIndex: 'isWatched',
- sorter: true,
- filters: [
- {
- text: 'Show only watched listings',
- value: 'watchList',
- },
- ],
- render: (id, row) => {
- return (
-
-
-
- ) : (
-
- )
- }
- theme="borderless"
- size="small"
- onClick={async () => {
- try {
- await xhrPost('/api/listings/watch', { listingId: row.id });
- Toast.success(
- row.isWatched === 1 ? 'Listing removed from Watchlist' : 'Listing added to Watchlist',
- );
- row.reloadTable();
- } catch (e) {
- console.error(e);
- Toast.error('Failed to operate Watchlist');
- }
- }}
- />
-
-
-
- }
- theme="borderless"
- size="small"
- type="danger"
- onClick={async () => {
- try {
- await xhrDelete('/api/listings/', { ids: [row.id] });
- Toast.success('Listing(s) successfully removed');
- row.reloadTable();
- } catch (error) {
- Toast.error(error);
- }
- }}
- />
-
-
- );
- },
- },
- {
- title: 'Active',
- dataIndex: 'is_active',
- width: 110,
- sorter: true,
- filters: [
- {
- text: 'Show only active listings',
- value: 'activityStatus',
- },
- ],
- render: (value) => {
- return value ? (
-
- ) : (
-
- );
- },
- },
- {
- title: 'Job-Name',
- sorter: true,
- ellipsis: true,
- dataIndex: 'job_name',
- width: 150,
- onFilter: () => true,
- renderFilterDropdown: () => {
- return (
-
-
-
- );
- },
- },
- {
- title: 'Listing date',
- width: 130,
- dataIndex: 'created_at',
- sorter: true,
- render: (text) => timeService.format(text, false),
- },
- {
- title: 'Provider',
- width: 130,
- dataIndex: 'provider',
- sorter: true,
- render: (text) => text.charAt(0).toUpperCase() + text.slice(1),
- onFilter: () => true,
- renderFilterDropdown: () => {
- return (
-
-
-
- );
- },
- },
- {
- title: 'Price',
- width: 110,
- dataIndex: 'price',
- sorter: true,
- render: (text) => text + ' €',
- },
- {
- title: 'Address',
- width: 150,
- dataIndex: 'address',
- sorter: true,
- },
- {
- title: 'Title',
- dataIndex: 'title',
- sorter: true,
- ellipsis: true,
- render: (text, row) => {
- return (
-
- {text}
-
- );
- },
- },
- ];
-};
-
-const empty = (
- }
- darkModeImage={}
- description="No listings found."
- />
-);
-
-export default function ListingsTable() {
- const tableData = useSelector((state) => state.listingsTable);
- const provider = useSelector((state) => state.provider);
- const jobs = useSelector((state) => state.jobs.jobs);
- const navigate = useNavigate();
-
- const watchlistFeature = useFeature('WATCHLIST_MANAGEMENT') || false;
- const actions = useActions();
- const [page, setPage] = useState(1);
- const pageSize = 10;
- const [sortData, setSortData] = useState({});
- const [freeTextFilter, setFreeTextFilter] = useState(null);
- const [watchListFilter, setWatchListFilter] = useState(null);
- const [jobNameFilter, setJobNameFilter] = useState(null);
- const [activityFilter, setActivityFilter] = useState(null);
- const [providerFilter, setProviderFilter] = useState(null);
- const [allFilters, setAllFilters] = useState([]);
-
- const [imageWidth, setImageWidth] = useState('100%');
- const handlePageChange = (_page) => {
- setPage(_page);
- };
-
- const columns = getColumns(provider, setProviderFilter, jobs, setJobNameFilter);
- const loadTable = () => {
- let sortfield = null;
- let sortdir = null;
-
- if (sortData != null && Object.keys(sortData).length > 0) {
- sortfield = sortData.field;
- sortdir = sortData.direction;
- }
- actions.listingsTable.getListingsTable({
- page,
- pageSize,
- sortfield,
- sortdir,
- freeTextFilter,
- filter: { watchListFilter, jobNameFilter, activityFilter, providerFilter },
- });
- };
-
- useEffect(() => {
- loadTable();
- }, [page, sortData, freeTextFilter, providerFilter, activityFilter, jobNameFilter, watchListFilter]);
-
- const handleFilterChange = useMemo(() => debounce((value) => setFreeTextFilter(value), 500), []);
-
- const diffArrays = (primary, secondary) => {
- const result = {};
-
- for (const item of secondary) {
- if (!primary.includes(item)) result[item] = true;
- }
-
- for (const item of primary) {
- if (!secondary.includes(item)) result[item] = false;
- }
-
- return [result];
- };
-
- useEffect(() => {
- return () => {
- // cleanup debounced handler to avoid memory leaks
- handleFilterChange.cancel && handleFilterChange.cancel();
- };
- }, [handleFilterChange]);
-
- const expandRowRender = (record) => {
- return (
-
-
- {record.image_url == null ? (
-
- ) : (
- {
- setImageWidth('180px');
- }}
- fallback={}
- />
- )}
-
-
-
-
-
- {record.is_active ? 'Yes' : 'No'}
-
-
-
-
- Link to Listing
-
-
- {format(record.created_at)}
- {record.price} €
-
-
{record.title}
-
{record.description == null ? 'No description available' : record.description}
-
-
- );
- };
-
- return (
-
-
}
- showClear
- className="listingsTable__search"
- placeholder="Search"
- onChange={handleFilterChange}
- />
- {watchlistFeature && (
-
{
- navigate('/watchlistManagement');
- }}
- >
- Setup notifications on watchlist changes
-
- )}
-
{
- return {
- ...row,
- reloadTable: loadTable,
- };
- })}
- onChange={(changeSet) => {
- if (changeSet?.extra?.changeType === 'filter') {
- const transformed = changeSet.filters.map((f) => f.dataIndex);
- const diff = diffArrays(allFilters, transformed);
- setAllFilters(transformed);
- diff.forEach((filter) => {
- switch (Object.keys(filter)[0]) {
- case 'isWatched':
- setWatchListFilter(Object.values(filter)[0]);
- break;
- case 'is_active':
- setActivityFilter(Object.values(filter)[0]);
- break;
- default:
- console.error('Unknown filter: ', filter.dataIndex);
- }
- });
- } else if (changeSet?.extra?.changeType === 'sorter') {
- setSortData({
- field: changeSet.sorter.dataIndex,
- direction: changeSet.sorter.sortOrder === 'ascend' ? 'asc' : 'desc',
- });
- }
- }}
- pagination={{
- currentPage: page,
- //for now fixed
- pageSize,
- total: tableData?.totalNumber || 0,
- onPageChange: handlePageChange,
- }}
- />
-
- );
-}
diff --git a/ui/src/components/table/listings/ListingsTable.less b/ui/src/components/table/listings/ListingsTable.less
deleted file mode 100644
index 464afb86..00000000
--- a/ui/src/components/table/listings/ListingsTable.less
+++ /dev/null
@@ -1,18 +0,0 @@
-.listingsTable {
- &__search {
- margin-bottom: 1rem !important;
- }
-
- &__expanded {
- display: flex;
- gap: 1rem;
- }
-
- &__toolbar {
- margin-bottom: 1rem;
- }
-
- &__setupButton {
- margin-bottom: 1rem;
- }
-}
\ No newline at end of file
diff --git a/ui/src/services/state/store.js b/ui/src/services/state/store.js
index fa5a5940..7b49ae5f 100644
--- a/ui/src/services/state/store.js
+++ b/ui/src/services/state/store.js
@@ -83,19 +83,44 @@ export const useFredyState = create(
}
},
},
- jobs: {
+ jobsData: {
async getJobs() {
try {
const response = await xhrGet('/api/jobs');
- set((state) => ({ jobs: { ...state.jobs, jobs: Object.freeze(response.json) } }));
+ set((state) => ({ jobsData: { ...state.jobsData, jobs: Object.freeze(response.json) } }));
} catch (Exception) {
console.error(`Error while trying to get resource for api/jobs. Error:`, Exception);
}
},
+ async getJobsData({
+ page = 1,
+ pageSize = 20,
+ freeTextFilter = null,
+ sortfield = null,
+ sortdir = 'asc',
+ filter,
+ } = {}) {
+ try {
+ const qryString = queryString.stringify({
+ page,
+ pageSize,
+ freeTextFilter,
+ sortfield,
+ sortdir,
+ ...filter,
+ });
+ const response = await xhrGet(`/api/jobs/data?${qryString}`);
+ set((state) => ({
+ jobsData: { ...state.jobsData, ...response.json },
+ }));
+ } catch (Exception) {
+ console.error('Error while trying to get resource for api/jobs/data. Error:', Exception);
+ }
+ },
async getSharableUserList() {
try {
const response = await xhrGet('/api/jobs/shareableUserList');
- set((state) => ({ jobs: { ...state.jobs, shareableUserList: Object.freeze(response.json) } }));
+ set((state) => ({ jobsData: { ...state.jobsData, shareableUserList: Object.freeze(response.json) } }));
} catch (Exception) {
console.error(`Error while trying to get resource for api/jobs. Error:`, Exception);
}
@@ -103,9 +128,12 @@ export const useFredyState = create(
setJobRunning(jobId, running) {
if (!jobId) return;
set((state) => {
- const list = state.jobs.jobs || [];
+ const list = state.jobsData.jobs || [];
const updated = list.map((j) => (j.id === jobId ? { ...j, running: !!running } : j));
- return { jobs: { ...state.jobs, jobs: Object.freeze(updated) } };
+ const result = (state.jobsData.result || []).map((j) =>
+ j.id === jobId ? { ...j, running: !!running } : j,
+ );
+ return { jobsData: { ...state.jobsData, jobs: Object.freeze(updated), result: Object.freeze(result) } };
});
},
},
@@ -151,8 +179,8 @@ export const useFredyState = create(
}
},
},
- listingsTable: {
- async getListingsTable({
+ listingsData: {
+ async getListingsData({
page = 1,
pageSize = 20,
freeTextFilter = null,
@@ -171,7 +199,7 @@ export const useFredyState = create(
});
const response = await xhrGet(`/api/listings/table?${qryString}`);
set((state) => ({
- listingsTable: { ...state.listingsTable, ...response.json },
+ listingsData: { ...state.listingsData, ...response.json },
}));
} catch (Exception) {
console.error('Error while trying to get resource for api/listings. Error:', Exception);
@@ -184,7 +212,7 @@ export const useFredyState = create(
const initial = {
dashboard: { data: null },
notificationAdapter: [],
- listingsTable: {
+ listingsData: {
totalNumber: 0,
page: 1,
result: [],
@@ -194,7 +222,13 @@ export const useFredyState = create(
demoMode: { demoMode: false },
versionUpdate: {},
provider: [],
- jobs: { jobs: [], shareableUserList: [] },
+ jobsData: {
+ jobs: [],
+ shareableUserList: [],
+ totalNumber: 0,
+ page: 1,
+ result: [],
+ },
user: { users: [], currentUser: null },
};
@@ -205,10 +239,10 @@ export const useFredyState = create(
generalSettings: { ...effects.generalSettings },
demoMode: { ...effects.demoMode },
versionUpdate: { ...effects.versionUpdate },
- listingsTable: { ...effects.listingsTable },
+ listingsData: { ...effects.listingsData },
provider: { ...effects.provider },
features: { ...effects.features },
- jobs: { ...effects.jobs },
+ jobsData: { ...effects.jobsData },
user: { ...effects.user },
};
diff --git a/ui/src/views/jobs/Jobs.jsx b/ui/src/views/jobs/Jobs.jsx
index f6b021f6..918724f9 100644
--- a/ui/src/views/jobs/Jobs.jsx
+++ b/ui/src/views/jobs/Jobs.jsx
@@ -5,136 +5,13 @@
import React from 'react';
-import JobTable from '../../components/table/JobTable';
-import { useSelector, useActions } from '../../services/state/store';
-import { xhrDelete, xhrPut, xhrPost } from '../../services/xhr';
-import { useNavigate } from 'react-router-dom';
-import { Button, Toast } from '@douyinfe/semi-ui';
-import { IconPlusCircle } from '@douyinfe/semi-icons';
+import JobGrid from '../../components/grid/jobs/JobGrid.jsx';
import './Jobs.less';
export default function Jobs() {
- const jobs = useSelector((state) => state.jobs.jobs);
- const navigate = useNavigate();
- const actions = useActions();
- 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]);
-
- const onJobRemoval = async (jobId) => {
- try {
- await xhrDelete('/api/jobs', { jobId });
- Toast.success('Job successfully removed');
- await actions.jobs.getJobs();
- } catch (error) {
- Toast.error(error);
- }
- };
-
- const onListingRemoval = async (jobId) => {
- try {
- await xhrDelete('/api/listings/job', { jobId });
- Toast.success('Listings successfully removed');
- await actions.jobs.getJobs();
- } catch (error) {
- Toast.error(error);
- }
- };
-
- const onJobStatusChanged = async (jobId, status) => {
- try {
- await xhrPut(`/api/jobs/${jobId}/status`, { status });
- Toast.success('Job status successfully changed');
- await actions.jobs.getJobs();
- } 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');
- }
- // 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');
- }
- }
- };
-
return (
-
-
- }
- className="jobs__newButton"
- onClick={() => navigate('/jobs/new')}
- >
- New Job
-
-
-
-
navigate(`/jobs/edit/${jobId}`)}
- />
+
+
);
}
diff --git a/ui/src/views/jobs/mutation/JobMutation.jsx b/ui/src/views/jobs/mutation/JobMutation.jsx
index 615af648..1fba7132 100644
--- a/ui/src/views/jobs/mutation/JobMutation.jsx
+++ b/ui/src/views/jobs/mutation/JobMutation.jsx
@@ -27,8 +27,8 @@ import {
} from '@douyinfe/semi-icons';
export default function JobMutator() {
- const jobs = useSelector((state) => state.jobs.jobs);
- const shareableUserList = useSelector((state) => state.jobs.shareableUserList);
+ const jobs = useSelector((state) => state.jobsData.jobs);
+ const shareableUserList = useSelector((state) => state.jobsData.shareableUserList);
const params = useParams();
const jobToBeEdit = params.jobId == null ? null : jobs.find((job) => job.id === params.jobId);
@@ -73,7 +73,7 @@ export default function JobMutator() {
enabled,
jobId: jobToBeEdit?.id || null,
});
- await actions.jobs.getJobs();
+ await actions.jobsData.getJobs();
Toast.success('Job successfully saved...');
navigate('/jobs');
} catch (Exception) {
diff --git a/ui/src/views/listings/Listings.jsx b/ui/src/views/listings/Listings.jsx
index 8ed479d2..912649f2 100644
--- a/ui/src/views/listings/Listings.jsx
+++ b/ui/src/views/listings/Listings.jsx
@@ -5,8 +5,8 @@
import React from 'react';
-import ListingsTable from '../../components/table/listings/ListingsTable.jsx';
+import ListingsGrid from '../../components/grid/listings/ListingsGrid.jsx';
export default function Listings() {
- return ;
+ return ;
}
diff --git a/ui/src/views/user/Users.jsx b/ui/src/views/user/Users.jsx
index 70304975..807da086 100644
--- a/ui/src/views/user/Users.jsx
+++ b/ui/src/views/user/Users.jsx
@@ -37,7 +37,7 @@ const Users = function Users() {
await xhrDelete('/api/admin/users', { userId: userIdToBeRemoved });
Toast.success('User successfully remove');
setUserIdToBeRemoved(null);
- await actions.jobs.getJobs();
+ await actions.jobsData.getJobs();
await actions.user.getUsers();
} catch (error) {
Toast.error(error);
diff --git a/yarn.lock b/yarn.lock
index 8636d238..eb7b4802 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2267,10 +2267,10 @@ ccount@^2.0.0:
resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5"
integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==
-chai@6.2.1:
- version "6.2.1"
- resolved "https://registry.yarnpkg.com/chai/-/chai-6.2.1.tgz#d1e64bc42433fbee6175ad5346799682060b5b6a"
- integrity sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==
+chai@6.2.2:
+ version "6.2.2"
+ resolved "https://registry.yarnpkg.com/chai/-/chai-6.2.2.tgz#ae41b52c9aca87734505362717f3255facda360e"
+ integrity sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==
chalk@^4.0.0, chalk@^4.1.0:
version "4.1.2"
@@ -5874,10 +5874,10 @@ punycode@^2.1.0:
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
-puppeteer-core@24.33.1:
- version "24.33.1"
- resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.33.1.tgz#80b9d79dd0597fd2b1de265aae4dd0d95df81c66"
- integrity sha512-MZjFLeGMBFbSkc1xKfcv6hjFlfNi1bmQly++HyqxGPYzLIMY0mSYyjqkAzT1PtomTYHq7SEonciIKkeyHExA1g==
+puppeteer-core@24.34.0:
+ version "24.34.0"
+ resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.34.0.tgz#00c7f63b4a83d4ca2ec5ea3a234588fb2ce7c994"
+ integrity sha512-24evawO+mUGW4mvS2a2ivwLdX3gk8zRLZr9HP+7+VT2vBQnm0oh9jJEZmUE3ePJhRkYlZ93i7OMpdcoi2qNCLg==
dependencies:
"@puppeteer/browsers" "2.11.0"
chromium-bidi "12.0.1"
@@ -5934,16 +5934,16 @@ puppeteer-extra@^3.3.6:
debug "^4.1.1"
deepmerge "^4.2.2"
-puppeteer@^24.33.1:
- version "24.33.1"
- resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.33.1.tgz#c84c9545633bc731b92caead463e77928dcf183e"
- integrity sha512-2KiSIXk+zFzmYsScv+hx/I3TODFGPcNpyJsWMQk1EQ2y8KZ2X6225/NingyqYxekzceSUnq5qX39dUezVDZ9EQ==
+puppeteer@^24.34.0:
+ version "24.34.0"
+ resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.34.0.tgz#061f6e97ce9511863ec83cd6f17a27253c68b5e9"
+ integrity sha512-Sdpl/zsYOsagZ4ICoZJPGZw8d9gZmK5DcxVal11dXi/1/t2eIXHjCf5NfmhDg5XnG9Nye+yo/LqMzIxie2rHTw==
dependencies:
"@puppeteer/browsers" "2.11.0"
chromium-bidi "12.0.1"
cosmiconfig "^9.0.0"
devtools-protocol "0.0.1534754"
- puppeteer-core "24.33.1"
+ puppeteer-core "24.34.0"
typed-query-selector "^2.12.0"
qs@^6.14.0: