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 ( +
+
+ + +
+ } 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; 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} /> + +
+ {showFilterBar && ( +
+ +
+
+ Filter by: +
+
+ + + + + + + +
+
+ + +
+
+ Sort by: +
+
+ + + +
+
+
+
+ )} + + {(listingsData?.result || []).length === 0 && ( + } + darkModeImage={} + description="No listings available yet..." + /> + )} + + {(listingsData?.result || []).map((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 ( - -
-
- -
- {name} -
-
- ); - } 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 ( -
- -
- ); - }, - }, - ]} - 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 ( -
- -
- ); - }, - }, - { - 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 && ( - - )} -
{ - 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 ( -
-
- -
- - 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: