diff --git a/lib/api/api.js b/lib/api/api.js
index 14dc00e..348efd2 100644
--- a/lib/api/api.js
+++ b/lib/api/api.js
@@ -3,11 +3,11 @@ import { authInterceptor, cookieSession, adminInterceptor } from './security.js'
import { generalSettingsRouter } from './routes/generalSettingsRoute.js';
import { analyticsRouter } from './routes/analyticsRouter.js';
import { providerRouter } from './routes/providerRouter.js';
+import { versionRouter } from './routes/versionRouter.js';
import { loginRouter } from './routes/loginRoute.js';
-import { config } from '../utils.js';
import { userRouter } from './routes/userRoute.js';
import { jobRouter } from './routes/jobRouter.js';
-import { versionRouter } from './routes/versionRouter.js';
+import { config } from '../utils.js';
import bodyParser from 'body-parser';
import restana from 'restana';
import files from 'serve-static';
@@ -15,6 +15,7 @@ import path from 'path';
import { getDirName } from '../utils.js';
import { demoRouter } from './routes/demoRouter.js';
import logger from '../services/logger.js';
+import { listingsRouter } from './routes/listingsRouter.js';
const service = restana();
const staticService = files(path.join(getDirName(), '../ui/public'));
const PORT = config.port || 9998;
@@ -25,6 +26,8 @@ service.use(staticService);
service.use('/api/admin', authInterceptor());
service.use('/api/jobs', authInterceptor());
service.use('/api/version', authInterceptor());
+service.use('/api/listings', authInterceptor());
+
// /admin can only be accessed when user is having admin permissions
service.use('/api/admin', adminInterceptor());
service.use('/api/jobs/notificationAdapter', notificationAdapterRouter);
@@ -35,6 +38,7 @@ service.use('/api/admin/users', userRouter);
service.use('/api/version', versionRouter);
service.use('/api/jobs', jobRouter);
service.use('/api/login', loginRouter);
+service.use('/api/listings', listingsRouter);
//this route is unsecured intentionally as it is being queried from the login page
service.use('/api/demo', demoRouter);
diff --git a/lib/api/routes/listingsRouter.js b/lib/api/routes/listingsRouter.js
new file mode 100644
index 0000000..96c0570
--- /dev/null
+++ b/lib/api/routes/listingsRouter.js
@@ -0,0 +1,23 @@
+import restana from 'restana';
+import * as listingStorage from '../../services/storage/listingsStorage.js';
+import { isAdmin as isAdminFn } from '../security.js';
+const service = restana();
+
+const listingsRouter = service.newRouter();
+
+listingsRouter.get('/table', async (req, res) => {
+ const { page, pageSize = 50, filter, sortfield = null, sortdir = 'asc' } = req.query || {};
+
+ const result = listingStorage.queryListings({
+ page: page ? parseInt(page, 10) : 1,
+ pageSize: pageSize ? parseInt(pageSize, 10) : 50,
+ filter: filter || undefined,
+ sortField: sortfield || null,
+ sortDir: sortdir === 'desc' ? 'desc' : 'asc',
+ userId: req.session.currentUser,
+ isAdmin: isAdminFn(req),
+ });
+ res.body = result;
+ res.send();
+});
+export { listingsRouter };
diff --git a/lib/services/storage/listingsStorage.js b/lib/services/storage/listingsStorage.js
index 082767b..4bcdc78 100755
--- a/lib/services/storage/listingsStorage.js
+++ b/lib/services/storage/listingsStorage.js
@@ -166,3 +166,88 @@ export const storeListings = (jobId, providerId, listings) => {
return str.replace(/\s*\([^)]*\)/g, '');
}
};
+
+/**
+ * Query listings with pagination, filtering and sorting.
+ *
+ * @param {Object} params
+ * @param {number} [params.pageSize=50]
+ * @param {number} [params.page=1]
+ * @param {string} [params.filter]
+ * @param {string|null} [params.sortField=null] - One of: 'created_at','price','size','provider','title'.
+ * @param {('asc'|'desc')} [params.sortDir='asc']
+ * @param {string} [params.userId] - Current user id used to scope listings (ignored for admins).
+ * @param {boolean} [params.isAdmin=false] - When true, returns all listings.
+ * @returns {{ totalNumber:number, page:number, result:Object[] }}
+ */
+export const queryListings = ({
+ pageSize = 50,
+ page = 1,
+ filter,
+ 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 across common text columns
+ const whereParts = [];
+ const params = { limit: safePageSize, offset };
+ // user scoping (non-admin only): restrict to listings whose job belongs to user
+ if (!isAdmin) {
+ params.userId = userId || '__NO_USER__';
+ whereParts.push(`(j.user_id = @userId)`);
+ }
+ if (filter && String(filter).trim().length > 0) {
+ params.filter = `%${String(filter).trim()}%`;
+ whereParts.push(`(title LIKE @filter OR address LIKE @filter OR provider LIKE @filter OR link LIKE @filter)`);
+ }
+ const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
+ const whereSqlWithAlias = whereSql
+ .replace(/\btitle\b/g, 'l.title')
+ .replace(/\bdescription\b/g, 'l.description')
+ .replace(/\baddress\b/g, 'l.address')
+ .replace(/\bprovider\b/g, 'l.provider')
+ .replace(/\blink\b/g, 'l.link')
+ .replace(/\bj\.user_id\b/g, 'j.user_id');
+
+ // whitelist sortable fields to avoid SQL injection
+ const sortable = new Set(['created_at', 'price', 'size', 'provider', 'title', 'job_name', 'is_active']);
+ const safeSortField = sortField && sortable.has(sortField) ? sortField : null;
+ const safeSortDir = String(sortDir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
+ const orderSql = safeSortField ? `ORDER BY ${safeSortField} ${safeSortDir}` : 'ORDER BY created_at DESC';
+ const orderSqlWithAlias = orderSql
+ .replace(/\bcreated_at\b/g, 'l.created_at')
+ .replace(/\bprice\b/g, 'l.price')
+ .replace(/\bsize\b/g, 'l.size')
+ .replace(/\bprovider\b/g, 'l.provider')
+ .replace(/\btitle\b/g, 'l.title')
+ .replace(/\bjob_name\b/g, 'j.name');
+
+ // count total with same WHERE
+ const countRow = SqliteConnection.query(
+ `SELECT COUNT(1) as cnt
+ FROM listings l
+ LEFT JOIN jobs j ON j.id = l.job_id
+ ${whereSqlWithAlias}`,
+ params,
+ );
+ const totalNumber = countRow?.[0]?.cnt ?? 0;
+
+ // fetch page
+ const rows = SqliteConnection.query(
+ `SELECT l.*, j.name AS job_name
+ FROM listings l
+ LEFT JOIN jobs j ON j.id = l.job_id
+ ${whereSqlWithAlias}
+ ${orderSqlWithAlias}
+ LIMIT @limit OFFSET @offset`,
+ params,
+ );
+
+ return { totalNumber, page: safePage, result: rows };
+};
diff --git a/package.json b/package.json
index 3e0b61a..f2b6a9f 100755
--- a/package.json
+++ b/package.json
@@ -63,7 +63,7 @@
"@visactor/vchart": "^2.0.5",
"@visactor/vchart-semi-theme": "^1.12.2",
"@vitejs/plugin-react": "5.0.3",
- "better-sqlite3": "^12.3.0",
+ "better-sqlite3": "^12.4.1",
"body-parser": "2.2.0",
"cheerio": "^1.1.2",
"cookie-session": "2.1.1",
@@ -76,14 +76,14 @@
"node-mailjet": "6.0.9",
"p-throttle": "^8.0.0",
"package-up": "^5.0.0",
- "puppeteer": "^24.22.0",
+ "puppeteer": "^24.22.3",
"puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"query-string": "9.3.1",
"react": "18.3.1",
"react-dom": "18.3.1",
- "react-router": "7.9.1",
- "react-router-dom": "7.9.1",
+ "react-router": "7.9.2",
+ "react-router-dom": "7.9.2",
"restana": "5.1.0",
"serve-static": "2.2.0",
"slack": "11.0.2",
diff --git a/ui/src/App.jsx b/ui/src/App.jsx
index 5c6aa77..fbfab60 100644
--- a/ui/src/App.jsx
+++ b/ui/src/App.jsx
@@ -19,6 +19,7 @@ import './App.less';
import TrackingModal from './components/tracking/TrackingModal.jsx';
import { Banner } from '@douyinfe/semi-ui';
import VersionBanner from './components/version/VersionBanner.jsx';
+import Listings from './views/listings/Listings.jsx';
export default function FredyApp() {
const actions = useActions();
@@ -50,14 +51,11 @@ export default function FredyApp() {
const isAdmin = () => currentUser != null && currentUser.isAdmin;
- const login = () => (
+ return loading ? null : needsLogin() ? (
} />
} />
- );
- return loading ? null : needsLogin() ? (
- login()
) : (
@@ -84,6 +82,7 @@ export default function FredyApp() {
} />
} />
} />
+
} />
{/* Permission-aware routes */}
;
+ return

;
}
diff --git a/ui/src/components/menu/Menu.jsx b/ui/src/components/menu/Menu.jsx
index 1f4cc74..f49f9a8 100644
--- a/ui/src/components/menu/Menu.jsx
+++ b/ui/src/components/menu/Menu.jsx
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { Tabs, TabPane } from '@douyinfe/semi-ui';
import { useLocation } from 'react-router-dom';
-import { IconUser, IconTerminal, IconSetting } from '@douyinfe/semi-icons';
+import { IconUser, IconTerminal, IconSetting, IconArchive } from '@douyinfe/semi-icons';
import './Menu.less';
function parsePathName(name) {
@@ -25,6 +25,15 @@ const TopMenu = function TopMenu({ isAdmin }) {
}
/>
+
+
+ Found listings
+
+ }
+ />
{isAdmin && (
{
+ return value ? (
+
+ ) : (
+
+ );
+ },
+ },
+ {
+ title: 'Job-Name',
+ sorter: true,
+ dataIndex: 'job_name',
+ width: 170,
+ },
+ {
+ title: 'Listing date',
+ width: 130,
+ dataIndex: 'created_at',
+ sorter: true,
+ render: (text) => timeService.format(text),
+ },
+ {
+ title: 'Provider',
+ width: 130,
+ dataIndex: 'provider',
+ sorter: true,
+ render: (text) => text.charAt(0).toUpperCase() + text.slice(1),
+ },
+ {
+ title: 'Price',
+ width: 100,
+ dataIndex: 'price',
+ sorter: true,
+ render: (text) => text + ' €',
+ },
+ {
+ title: 'Address',
+ width: 150,
+ dataIndex: 'address',
+ sorter: true,
+ },
+ {
+ title: 'Title',
+ dataIndex: 'title',
+ sorter: true,
+ render: (text, row) => {
+ return (
+
+ {text}
+
+ );
+ },
+ },
+];
+
+export default function ListingsTable() {
+ const tableData = useSelector((state) => state.listingsTable);
+ const actions = useActions();
+ const [page, setPage] = useState(1);
+ const pageSize = 15;
+ const [sortData, setSortData] = useState({});
+ const [filter, setFilter] = useState(null);
+
+ const handlePageChange = (_page) => {
+ setPage(_page);
+ };
+
+ useEffect(() => {
+ 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, filter });
+ }, [page, sortData, filter]);
+
+ const handleFilterChange = useMemo(() => debounce((value) => setFilter(value), 500), []);
+
+ const expandRowRender = (record) => {
+ return (
+
+
+ {record.image_url == null ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {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}
+ />
+
{
+ 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/ListingsTable.less b/ui/src/components/table/ListingsTable.less
new file mode 100644
index 0000000..015e85e
--- /dev/null
+++ b/ui/src/components/table/ListingsTable.less
@@ -0,0 +1,10 @@
+.listingsTable {
+ &__search {
+ margin-bottom: 1rem !important;
+ }
+
+ &__expanded {
+ display: flex;
+ gap: 1rem;
+ }
+}
\ No newline at end of file
diff --git a/ui/src/services/state/store.js b/ui/src/services/state/store.js
index 6706096..60275ff 100644
--- a/ui/src/services/state/store.js
+++ b/ui/src/services/state/store.js
@@ -4,6 +4,7 @@
import { create } from 'zustand';
import { shallow } from 'zustand/shallow';
import { xhrGet } from '../xhr.js';
+import queryString from 'query-string';
const logger = (config) => (set, get, api) =>
config(
@@ -130,11 +131,35 @@ export const useFredyState = create(
}
},
},
+ listingsTable: {
+ async getListingsTable({ page = 1, pageSize = 20, filter = null, sortfield = null, sortdir = 'asc' }) {
+ try {
+ const qryString = queryString.stringify({
+ page,
+ pageSize,
+ filter,
+ sortfield,
+ sortdir,
+ });
+ const response = await xhrGet(`/api/listings/table?${qryString}`);
+ set((state) => ({
+ listingsTable: { ...state.listingsTable, ...response.json },
+ }));
+ } catch (Exception) {
+ console.error('Error while trying to get resource for api/listings. Error:', Exception);
+ }
+ },
+ },
};
// Initial state
const initial = {
notificationAdapter: [],
+ listingsTable: {
+ totalNumber: 0,
+ page: 1,
+ result: [],
+ },
generalSettings: { settings: {} },
demoMode: { demoMode: false },
versionUpdate: {},
@@ -149,6 +174,7 @@ export const useFredyState = create(
generalSettings: { ...effects.generalSettings },
demoMode: { ...effects.demoMode },
versionUpdate: { ...effects.versionUpdate },
+ listingsTable: { ...effects.listingsTable },
provider: { ...effects.provider },
jobs: { ...effects.jobs },
user: { ...effects.user },
diff --git a/ui/src/views/listings/Listings.jsx b/ui/src/views/listings/Listings.jsx
new file mode 100644
index 0000000..308484d
--- /dev/null
+++ b/ui/src/views/listings/Listings.jsx
@@ -0,0 +1,11 @@
+import React from 'react';
+
+import ListingsTable from '../../components/table/ListingsTable.jsx';
+
+export default function Listings() {
+ return (
+
+
+
+ );
+}
diff --git a/yarn.lock b/yarn.lock
index 9b7f570..631f3a8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2197,10 +2197,10 @@ basic-ftp@^5.0.2:
resolved "https://registry.yarnpkg.com/basic-ftp/-/basic-ftp-5.0.5.tgz#14a474f5fffecca1f4f406f1c26b18f800225ac0"
integrity sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==
-better-sqlite3@^12.3.0:
- version "12.3.0"
- resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-12.3.0.tgz#999817506ed9d985604ae053b5e5fe3c8a052bb1"
- integrity sha512-FFf+rsghyvXQIPV/6PDUj05EsuZA1b0drGLzNgtrELkXnJKUH6NNM2h7Ce7dkA6vvPOM4SOoUIDGRPy3yRKmqw==
+better-sqlite3@^12.4.1:
+ version "12.4.1"
+ resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-12.4.1.tgz#f78df6c80530d1a0b750b538033e6199b7d30d26"
+ integrity sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==
dependencies:
bindings "^1.5.0"
prebuild-install "^7.1.1"
@@ -2451,10 +2451,10 @@ chownr@^1.1.1:
resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b"
integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==
-chromium-bidi@8.0.0:
- version "8.0.0"
- resolved "https://registry.yarnpkg.com/chromium-bidi/-/chromium-bidi-8.0.0.tgz#d73c9beed40317adf2bcfeb9a47087003cd467ec"
- integrity sha512-d1VmE0FD7lxZQHzcDUCKZSNRtRwISXDsdg4HjdTR5+Ll5nQ/vzU12JeNmupD6VWffrPSlrnGhEWlLESKH3VO+g==
+chromium-bidi@9.1.0:
+ version "9.1.0"
+ resolved "https://registry.yarnpkg.com/chromium-bidi/-/chromium-bidi-9.1.0.tgz#356eaea018eecc7977644305ee9fd27874b2b676"
+ integrity sha512-rlUzQ4WzIAWdIbY/viPShhZU2n21CxDUgazXVbw4Hu1MwaeUSEksSeM6DqPgpRjCLXRk702AVRxJxoOz0dw4OA==
dependencies:
mitt "^3.0.1"
zod "^3.24.1"
@@ -5962,13 +5962,13 @@ 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.22.0:
- version "24.22.0"
- resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.22.0.tgz#4d576b1a2b7699c088d3f0e843c32d81df82c3a6"
- integrity sha512-oUeWlIg0pMz8YM5pu0uqakM+cCyYyXkHBxx9di9OUELu9X9+AYrNGGRLK9tNME3WfN3JGGqQIH3b4/E9LGek/w==
+puppeteer-core@24.22.3:
+ version "24.22.3"
+ resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.22.3.tgz#63285a37da6e2c44069c0b31f2171f8ab81bbe23"
+ integrity sha512-M/Jhg4PWRANSbL/C9im//Yb55wsWBS5wdp+h59iwM+EPicVQQCNs56iC5aEAO7avfDPRfxs4MM16wHjOYHNJEw==
dependencies:
"@puppeteer/browsers" "2.10.10"
- chromium-bidi "8.0.0"
+ chromium-bidi "9.1.0"
debug "^4.4.3"
devtools-protocol "0.0.1495869"
typed-query-selector "^2.12.0"
@@ -6022,16 +6022,16 @@ puppeteer-extra@^3.3.6:
debug "^4.1.1"
deepmerge "^4.2.2"
-puppeteer@^24.22.0:
- version "24.22.0"
- resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.22.0.tgz#9f6905e9c3d5c316c364adb598903a1dfbfe800f"
- integrity sha512-QabGIvu7F0hAMiKGHZCIRHMb6UoH0QAJA2OaqxEU2tL5noXPrxUcotg2l3ttOA4p1PFnVIGkr6PXRAWlM2evVQ==
+puppeteer@^24.22.3:
+ version "24.22.3"
+ resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.22.3.tgz#07dcfabdb4e924b014cb7b96bcc92f43086e637e"
+ integrity sha512-mnhXzIqSYSJ1SMv1RYH07YMzWP81xCmmQj91Q8iQMZqnf97eVzeHgsGL6kpywiGCi+nQafta/+NkwM4URMy/XQ==
dependencies:
"@puppeteer/browsers" "2.10.10"
- chromium-bidi "8.0.0"
+ chromium-bidi "9.1.0"
cosmiconfig "^9.0.0"
devtools-protocol "0.0.1495869"
- puppeteer-core "24.22.0"
+ puppeteer-core "24.22.3"
typed-query-selector "^2.12.0"
qs@^6.14.0:
@@ -6121,17 +6121,17 @@ react-resizable@^3.0.5:
prop-types "15.x"
react-draggable "^4.0.3"
-react-router-dom@7.9.1:
- version "7.9.1"
- resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.9.1.tgz#48044923701773da6362f9003ec46f308f293f15"
- integrity sha512-U9WBQssBE9B1vmRjo9qTM7YRzfZ3lUxESIZnsf4VjR/lXYz9MHjvOxHzr/aUm4efpktbVOrF09rL/y4VHa8RMw==
+react-router-dom@7.9.2:
+ version "7.9.2"
+ resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.9.2.tgz#2bb35d226ca23329f4e39c8f86d1db26ee4fdf26"
+ integrity sha512-pagqpVJnjZOfb+vIM23eTp7Sp/AAJjOgaowhP1f1TWOdk5/W8Uk8d/M/0wfleqx7SgjitjNPPsKeCZE1hTSp3w==
dependencies:
- react-router "7.9.1"
+ react-router "7.9.2"
-react-router@7.9.1:
- version "7.9.1"
- resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.9.1.tgz#b227410c31f24dd416c939ca5d0f8d5c8a1404d4"
- integrity sha512-pfAByjcTpX55mqSDGwGnY9vDCpxqBLASg0BMNAuMmpSGESo/TaOUG6BllhAtAkCGx8Rnohik/XtaqiYUJtgW2g==
+react-router@7.9.2:
+ version "7.9.2"
+ resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.9.2.tgz#f424a14f87e4d7b5b268ce3647876e9504e4fca6"
+ integrity sha512-i2TPp4dgaqrOqiRGLZmqh2WXmbdFknUyiCRmSKs0hf6fWXkTKg5h56b+9F22NbGRAMxjYfqQnpi63egzD2SuZA==
dependencies:
cookie "^1.0.1"
set-cookie-parser "^2.6.0"