Compare commits

..

5 Commits

Author SHA1 Message Date
Christian Kellner
3c209a8f97 Redesigning listing table (#248)
* redesigning listing table

* getting rid of old listing table view

* improving listing grid
2025-12-23 08:47:51 +01:00
orangecoding
398259ff20 next release version 2025-12-18 19:25:33 +01:00
orangecoding
cf030bfa39 next release version / fixing valuers not being shown when editing a notification adapter 2025-12-18 19:24:48 +01:00
orangecoding
5dc976c7e3 ability to start jobs individually 2025-12-18 19:16:28 +01:00
orangecoding
05f1bc61c9 fixing tests 2025-12-17 16:35:24 +01:00
37 changed files with 1787 additions and 815 deletions

View File

@@ -34,7 +34,8 @@ WORKDIR /fredy
# Using Alpine's chromium package which is much smaller
RUN apk add --no-cache chromium curl
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
ENV NODE_ENV=production \
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
# Install build dependencies for native modules, then remove them after yarn install

View File

@@ -206,7 +206,7 @@ flowchart TD
F2["Adapter 2"]
end
A1 --> B["FredyPipeline"]
A1 --> B["FredyPipelineExecutioner"]
A2 --> B
A3 --> B
B --> C1 & C2 & C3

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 512 KiB

After

Width:  |  Height:  |  Size: 4.7 MiB

View File

@@ -5,6 +5,8 @@ services:
context: .
dockerfile: Dockerfile
image: ghcr.io/orangecoding/fredy
environment:
- NODE_ENV=production
volumes:
- ./conf:/conf
- ./db:/db

View File

@@ -6,18 +6,15 @@
import fs from 'fs';
import { checkIfConfigIsAccessible, getProviders, refreshConfig } from './lib/utils.js';
import * as similarityCache from './lib/services/similarity-check/similarityCache.js';
import * as jobStorage from './lib/services/storage/jobStorage.js';
import FredyPipeline from './lib/FredyPipeline.js';
import { duringWorkingHoursOrNotSet } from './lib/utils.js';
import { runMigrations } from './lib/services/storage/migrations/migrate.js';
import { ensureDemoUserExists, ensureAdminUserExists } from './lib/services/storage/userStorage.js';
import { cleanupDemoAtMidnight } from './lib/services/crons/demoCleanup-cron.js';
import { initTrackerCron } from './lib/services/crons/tracker-cron.js';
import logger from './lib/services/logger.js';
import { bus } from './lib/services/events/event-bus.js';
import { initActiveCheckerCron } from './lib/services/crons/listing-alive-cron.js';
import { getSettings } from './lib/services/storage/settingsStorage.js';
import SqliteConnection, { computeDbPath } from './lib/services/storage/SqliteConnection.js';
import { initJobExecutionService } from './lib/services/jobs/jobExecutionService.js';
//in the config, we store the path of the sqlite file, thus we must check if it is available
const isConfigAccessible = await checkIfConfigIsAccessible();
@@ -36,7 +33,7 @@ await runMigrations();
const settings = await getSettings();
// Ensure sqlite directory exists before loading anything else (based on config.sqlitepath)
// Ensure the sqlite directory exists before loading anything else (based on config.sqlitepath)
const { dir: sqliteDir } = await computeDbPath();
if (!fs.existsSync(sqliteDir)) {
fs.mkdirSync(sqliteDir, { recursive: true });
@@ -59,52 +56,13 @@ if (settings.demoMode) {
cleanupDemoAtMidnight();
}
logger.info(`Started Fredy successfully. Ui can be accessed via http://localhost:${settings.port}`);
ensureAdminUserExists();
ensureDemoUserExists();
await initTrackerCron();
//do not wait for this to finish, let it run in the background
initActiveCheckerCron();
bus.on('jobs:runAll', () => {
logger.debug('Running Fredy Job manually');
execute();
});
logger.info(`Started Fredy successfully. Ui can be accessed via http://localhost:${settings.port}`);
const execute = () => {
const isDuringWorkingHoursOrNotSet = duringWorkingHoursOrNotSet(settings, Date.now());
if (!settings.demoMode) {
if (isDuringWorkingHoursOrNotSet) {
settings.lastRun = Date.now();
jobStorage
.getJobs()
.filter((job) => job.enabled)
.forEach((job) => {
job.provider
.filter((p) => providers.find((loaded) => loaded.metaInformation.id === p.id) != null)
.forEach(async (prov) => {
try {
const matchedProvider = providers.find((loaded) => loaded.metaInformation.id === prov.id);
matchedProvider.init(prov, job.blacklist);
await new FredyPipeline(
matchedProvider.config,
job.notificationAdapter,
prov.id,
job.id,
similarityCache,
).execute();
} catch (error) {
logger.error(error);
}
});
});
} else {
logger.debug('Working hours set. Skipping as outside of working hours.');
}
}
};
setInterval(execute, INTERVAL);
//start once at startup
execute();
// Initialize the lean Job Execution Service (schedules and bus listeners)
initJobExecutionService({ providers, settings, intervalMs: INTERVAL });

View File

@@ -40,7 +40,7 @@ import logger from './services/logger.js';
* 7) Filter out entries similar to already seen ones
* 8) Dispatch notifications
*/
class FredyPipeline {
class FredyPipelineExecutioner {
/**
* Create a new runtime instance for a single provider/job execution.
*
@@ -218,4 +218,4 @@ class FredyPipeline {
}
}
export default FredyPipeline;
export default FredyPipelineExecutioner;

View File

@@ -9,6 +9,8 @@ import * as userStorage from '../../services/storage/userStorage.js';
import { isAdmin } from '../security.js';
import logger from '../../services/logger.js';
import { bus } from '../../services/events/event-bus.js';
import { isRunning as isJobRunning } from '../../services/jobs/run-state.js';
import { addClient as addSseClient, removeClient } from '../../services/sse/sse-broker.js';
const service = restana();
const jobRouter = service.newRouter();
@@ -37,6 +39,7 @@ jobRouter.get('/', async (req, res) => {
.map((job) => {
return {
...job,
running: isJobRunning(job.id),
isOnlyShared:
!isUserAdmin &&
job.userId !== req.session.currentUser &&
@@ -47,11 +50,115 @@ jobRouter.get('/', async (req, res) => {
res.send();
});
jobRouter.post('/startAll', async (req, res) => {
bus.emit('jobs:runAll');
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;
if (userId == null) {
res.send({ message: 'Unauthorized' }, 401);
return;
}
// SSE headers
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
try {
// Initial comment to establish stream
res.write(': connected\n\n');
addSseClient(userId, res);
// Cleanup on close/aborted
const onClose = () => removeClient(userId, res);
// restana exposes original req/res; use both close and finish
req.on('close', onClose);
req.on('aborted', onClose);
res.on('close', onClose);
} catch (e) {
logger.error('Error establishing SSE connection', e);
try {
res.end();
} catch {
//noop
}
}
});
jobRouter.post('/startAll', async (req, res) => {
try {
const userId = req.session.currentUser;
// Emit only the userId; handler will decide based on admin/ownership
bus.emit('jobs:runAll', { userId });
res.send({ message: 'Run all accepted' }, 202);
} catch (err) {
logger.error('Failed to trigger startAll', err);
res.send({ message: 'Unexpected error' }, 500);
}
});
// Trigger a single job run
jobRouter.post('/:jobId/run', async (req, res) => {
const { jobId } = req.params;
try {
const job = jobStorage.getJob(jobId);
if (!job) {
res.send({ message: 'Job not found' }, 404);
return;
}
if (!doesJobBelongsToUser(job, req)) {
res.send({ message: 'You are trying to run a job that is not associated to your user' }, 403);
return;
}
if (isJobRunning(jobId)) {
res.send({ message: 'Job is already running' }, 409);
return;
}
// fire and forget; actual execution handled by index.js listener
bus.emit('jobs:runOne', { jobId });
res.send({ message: 'Job run accepted' }, 202);
} catch (error) {
logger.error(error);
res.send({ message: 'Unexpected error triggering job' }, 500);
}
});
jobRouter.post('/', async (req, res) => {
const { provider, notificationAdapter, name, blacklist = [], jobId, enabled, shareWithUsers = [] } = req.body;
try {

View File

@@ -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;

View File

@@ -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 {

View File

@@ -0,0 +1,187 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import logger from '../logger.js';
import { bus } from '../events/event-bus.js';
import * as jobStorage from '../storage/jobStorage.js';
import * as userStorage from '../storage/userStorage.js';
import { getUser } from '../storage/userStorage.js';
import { duringWorkingHoursOrNotSet } from '../../utils.js';
import FredyPipelineExecutioner from '../../FredyPipelineExecutioner.js';
import * as similarityCache from '../similarity-check/similarityCache.js';
import { isRunning, markFinished, markRunning } from './run-state.js';
import { sendToUsers } from '../sse/sse-broker.js';
/**
* Initializes the job execution service.
* - Registers event-bus listeners for `jobs:runAll`, `jobs:runOne`, and `jobs:status`.
* - Starts the periodic scheduler (if `intervalMs` > 0) and performs an initial run respecting working hours.
* - Forwards job status updates to affected users via Server-Sent Events (SSE).
*
* This function is intentionally side-effectful and exposes no external API.
*
* @param {Object} deps - Dependencies required to initialize the service.
* @param {Array<Object>} deps.providers - Loaded provider modules. Each module must expose `metaInformation.id`, `config`, and `init(config, blacklist)`.
* @param {Object} deps.settings - Global settings object (read/write). Must include `demoMode`, `interval`, and working-hours attributes used by `duringWorkingHoursOrNotSet`.
* @param {number} deps.intervalMs - Scheduler interval in milliseconds. If not finite or <= 0, the scheduler is not started.
* @returns {void}
*/
export function initJobExecutionService({ providers, settings, intervalMs }) {
// Forward job status via SSE to relevant recipients
bus.on('jobs:status', ({ jobId, running }) => {
try {
const recipients = resolveRecipients(jobId);
if (recipients.length > 0) {
sendToUsers(recipients, 'jobStatus', { jobId, running });
}
} catch (err) {
logger.warn('Failed to forward job status', jobId, err);
}
});
// Listen for "run all" requests (admin = all, user = own)
bus.on('jobs:runAll', (payload) => {
const userId = payload?.userId ?? null;
const user = userId ? getUser(userId) : null;
const isAdmin = !!user?.isAdmin;
if (isAdmin) {
logger.debug('Running all jobs manually (admin request)');
} else if (userId) {
logger.debug(`Running all jobs manually for user ${userId}`);
} else {
logger.debug('Running all jobs manually (no user provided)');
}
runAll(false, { userId, isAdmin });
});
// Listen for single job run requests
bus.on('jobs:runOne', ({ jobId }) => {
logger.debug(`Running single job manually: ${jobId}`);
// fire and forget, do not block the bus
runSingle(jobId);
});
// Start scheduler and initial run
if (Number.isFinite(intervalMs) && intervalMs > 0) {
setInterval(() => runAll(true), intervalMs);
}
// start once at startup, respecting working hours
runAll(true);
/**
* Resolve all recipients who should receive SSE updates for a job.
* Includes job owner, users with whom the job is shared, and all admins.
*
* @param {string} jobId
* @returns {string[]} unique userIds
*/
function resolveRecipients(jobId) {
const job = jobStorage.getJob(jobId);
if (!job) return [];
const admins = (userStorage.getUsers && userStorage.getUsers(false)) || [];
const adminIds = admins.filter((u) => u.isAdmin).map((u) => u.id);
const shared = Array.isArray(job.shared_with_user) ? job.shared_with_user : [];
const recipients = [job.userId, ...shared, ...adminIds].filter(Boolean);
return Array.from(new Set(recipients));
}
/**
* Execute all enabled jobs, optionally filtering by context (admin/owner) and respecting working hours.
*
* @param {boolean} [respectWorkingHours=true] - If true, skip execution when outside configured working hours.
* @param {{userId?: string, isAdmin?: boolean}} [context] - Who requested the run; determines job filtering.
* @returns {void}
*/
function runAll(respectWorkingHours = true, context = undefined) {
if (settings.demoMode) return;
const now = Date.now();
const withinHours = duringWorkingHoursOrNotSet(settings, now);
if (respectWorkingHours && !withinHours) {
logger.debug('Working hours set. Skipping as outside of working hours.');
return;
}
settings.lastRun = now;
jobStorage
.getJobs()
.filter((job) => job.enabled)
.filter((job) => {
if (!context) return true; // startup/cron → all
if (context.isAdmin) return true; // admin → all
return context.userId ? job.userId === context.userId : false; // user → own
})
.forEach((job) => executeJob(job));
}
/**
* Execute a single job by id.
* Manual runs are allowed even if the job is disabled, but never duplicated when already running.
*
* @param {string} jobId
* @returns {Promise<void>}
*/
async function runSingle(jobId) {
if (settings.demoMode) return;
const job = jobStorage.getJob(jobId);
if (!job) return;
// allow manual run even if disabled; keep guard to avoid duplicates
await executeJob(job);
}
/**
* Executes one job across all of its configured providers.
* Emits SSE start/finish events via the bus and ensures the run-state guard is always cleared.
* Provider errors are surfaced via logging but do not abort other providers.
*
* @param {Object} job
* @param {string} job.id
* @param {Array<{id:string}>} job.provider
* @param {Array<string>} [job.blacklist]
* @param {*} job.notificationAdapter
* @returns {Promise<void>}
*/
async function executeJob(job) {
if (isRunning(job.id)) {
logger.debug(`Job ${job.id} is already running. Skipping.`);
return;
}
const acquired = markRunning(job.id);
if (!acquired) return;
// notify listeners (SSE) that the job started
try {
bus.emit('jobs:status', { jobId: job.id, running: true });
} catch (err) {
logger.warn('Failed to emit start status for job', job.id, err);
}
try {
const jobProviders = job.provider.filter(
(p) => providers.find((loaded) => loaded.metaInformation.id === p.id) != null,
);
const executions = jobProviders.map(async (prov) => {
const matchedProvider = providers.find((loaded) => loaded.metaInformation.id === prov.id);
matchedProvider.init(prov, job.blacklist);
await new FredyPipelineExecutioner(
matchedProvider.config,
job.notificationAdapter,
prov.id,
job.id,
similarityCache,
).execute();
});
const results = await Promise.allSettled(executions);
for (const r of results) {
if (r.status === 'rejected') {
logger.error(r.reason);
}
}
} finally {
markFinished(job.id);
try {
bus.emit('jobs:status', { jobId: job.id, running: false });
} catch (err) {
logger.warn('Failed to emit finish status for job', job.id, err);
}
}
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
/**
* Simple in-memory running state registry for jobs.
* Prevents concurrent execution of the same job within a single process.
* This registry is reset on process restart.
* @type {Set<string>}
*/
const running = new Set();
/**
* Check if a job is currently marked as running.
* @param {string} jobId
* @returns {boolean}
*/
export function isRunning(jobId) {
return running.has(jobId);
}
/**
* Try to mark a job as running.
* If it was already running, returns false and does not modify the set.
* @param {string} jobId
* @returns {boolean} true if the job was successfully marked as running
*/
export function markRunning(jobId) {
if (running.has(jobId)) return false;
running.add(jobId);
return true;
}
/**
* Mark a job as finished (remove from the running registry).
* @param {string} jobId
* @returns {void}
*/
export function markFinished(jobId) {
running.delete(jobId);
}

View File

@@ -0,0 +1,108 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
/**
* In-memory SSE client registry.
* Maps a userId to a Set of Node.js ServerResponse objects representing open streams.
* @type {Map<string, Set<import('http').ServerResponse>>}
*/
const clients = new Map(); // Map<userId, Set<ServerResponse>>
/**
* Write a single SSE event frame to a response.
*
* @param {import('http').ServerResponse} res - The open SSE HTTP response.
* @param {string} [event] - Optional event name (sent as `event:`). If omitted, a generic message is sent.
* @param {any} [data] - Optional payload. Objects are JSON.stringified.
* @returns {void}
*/
function writeEvent(res, event, data) {
try {
if (event) {
res.write(`event: ${event}\n`);
}
if (data !== undefined) {
const payload = typeof data === 'string' ? data : JSON.stringify(data);
res.write(`data: ${payload}\n`);
}
res.write('\n');
} catch {
// ignore write errors here; cleanup happens on close
}
}
/**
* Register a new SSE client for the given user.
*
* @param {string} userId
* @param {import('http').ServerResponse} res
* @returns {void}
*/
export function addClient(userId, res) {
let set = clients.get(userId);
if (!set) {
set = new Set();
clients.set(userId, set);
}
set.add(res);
// send a hello event
writeEvent(res, 'hello', { ok: true });
}
/**
* Unregister a specific SSE client for a user. Removes the user entry when empty.
*
* @param {string} userId
* @param {import('http').ServerResponse} res
* @returns {void}
*/
export function removeClient(userId, res) {
const set = clients.get(userId);
if (!set) return;
set.delete(res);
if (set.size === 0) clients.delete(userId);
}
/**
* Send an SSE event to all open connections of a user.
*
* @param {string} userId
* @param {string} event
* @param {any} data
* @returns {void}
*/
export function sendToUser(userId, event, data) {
const set = clients.get(userId);
if (!set) return;
for (const res of set) {
writeEvent(res, event, data);
}
}
/**
* Broadcast an SSE event to multiple users (unique by id).
*
* @param {string[]} userIds
* @param {string} event
* @param {any} data
* @returns {void}
*/
export function sendToUsers(userIds, event, data) {
const unique = Array.from(new Set(userIds));
unique.forEach((id) => sendToUser(id, event, data));
}
// Heartbeat to keep connections alive on proxies (every 25s)
setInterval(() => {
for (const set of clients.values()) {
for (const res of set) {
try {
res.write(`: ping ${Date.now()}\n\n`);
} catch {
// ignore
}
}
}
}, 25000);

View File

@@ -19,8 +19,13 @@ import { runMigrations, listMigrationFiles } from './migrations/migrate.js';
let _AdmZipSingleton = null;
async function getAdmZip() {
if (_AdmZipSingleton) return _AdmZipSingleton;
// Allow tests to provide a mock constructor without ESM loader intricacies
if (globalThis && globalThis.__TEST_ADM_ZIP__) {
_AdmZipSingleton = globalThis.__TEST_ADM_ZIP__;
return _AdmZipSingleton;
}
const mod = await import('adm-zip');
_AdmZipSingleton = mod.default || mod;
_AdmZipSingleton = (mod && mod.default) || mod;
return _AdmZipSingleton;
}

View File

@@ -85,6 +85,7 @@ export const getJob = (jobId) => {
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
@@ -98,6 +99,7 @@ export const getJob = (jobId) => {
enabled: !!row.enabled,
blacklist: fromJson(row.blacklist, []),
provider: fromJson(row.provider, []),
shared_with_user: fromJson(row.shared_with_user, []),
notificationAdapter: fromJson(row.notificationAdapter, []),
};
};
@@ -161,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 };
};

View File

@@ -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 ')}` : '';

View File

@@ -1,6 +1,6 @@
{
"name": "fredy",
"version": "16.2.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",
@@ -60,8 +60,8 @@
],
"dependencies": {
"adm-zip": "^0.5.16",
"@douyinfe/semi-icons": "^2.89.0",
"@douyinfe/semi-ui": "2.89.0",
"@douyinfe/semi-icons": "^2.89.1",
"@douyinfe/semi-ui": "2.89.1",
"@sendgrid/mail": "8.1.6",
"@vitejs/plugin-react": "5.1.2",
"better-sqlite3": "^12.5.0",
@@ -77,15 +77,15 @@
"node-mailjet": "6.0.11",
"p-throttle": "^8.1.0",
"package-up": "^5.0.0",
"puppeteer": "^24.33.0",
"puppeteer": "^24.34.0",
"puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"query-string": "9.3.1",
"react": "18.3.1",
"react-chartjs-2": "^5.3.1",
"react-dom": "18.3.1",
"react-router": "7.10.1",
"react-router-dom": "7.10.1",
"react-router": "7.11.0",
"react-router-dom": "7.11.0",
"restana": "5.1.0",
"semver": "^7.7.3",
"serve-static": "2.2.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",

View File

@@ -8,17 +8,21 @@ import esmock from 'esmock';
describe('services/storage/backupRestoreService.js - precheck & filename', () => {
let svc;
let admZipMock;
let setZipState;
let calls;
beforeEach(async () => {
calls = { logger: { info: [], warn: [], error: [] } };
// Mock AdmZip with configurable state
let state = { hasDb: false, meta: null };
// Mock AdmZip with configurable state via globalThis (avoid esmock export name pitfalls)
globalThis.__ADM_ZIP_STATE__ = { hasDb: false, meta: null };
setZipState = (s) => {
globalThis.__ADM_ZIP_STATE__ = { ...globalThis.__ADM_ZIP_STATE__, ...s };
};
class MockAdmZip {
constructor() {}
getEntry(name) {
const state = globalThis.__ADM_ZIP_STATE__ || {};
if (name === 'listings.db') {
if (state.hasDb) return { getData: () => Buffer.from('db') };
return null;
@@ -30,12 +34,15 @@ describe('services/storage/backupRestoreService.js - precheck & filename', () =>
return null;
}
getEntries() {
const state = globalThis.__ADM_ZIP_STATE__ || {};
const arr = [];
if (state.hasDb) arr.push({ entryName: 'listings.db', getData: () => Buffer.from('db') });
return arr;
}
}
admZipMock = { default: MockAdmZip, __set: (s) => (state = { ...state, ...s }) };
const admZipMock = { default: MockAdmZip };
// Also expose for service via globalThis escape hatch
globalThis.__TEST_ADM_ZIP__ = MockAdmZip;
const path = await import('node:path');
const ROOT = path.resolve('.');
@@ -70,11 +77,13 @@ describe('services/storage/backupRestoreService.js - precheck & filename', () =>
const utilsMock = { getPackageVersion: async () => '16.2.0' };
const admZipPath = path.join(ROOT, 'node_modules', 'adm-zip', 'adm-zip.js');
const mod = await esmock(
path.join(ROOT, 'lib', 'services', 'storage', 'backupRestoreService.js'),
{},
{
'adm-zip': admZipMock,
[admZipPath]: admZipMock,
[migratePath]: migrateMock,
[sqlitePath]: sqliteMock,
[loggerPath]: loggerMock,
@@ -94,7 +103,7 @@ describe('services/storage/backupRestoreService.js - precheck & filename', () =>
});
it('precheck: missing listings.db yields danger', async () => {
admZipMock.__set({ hasDb: false, meta: { dbMigration: 9 } });
setZipState({ hasDb: false, meta: { dbMigration: 9 } });
const res = await svc.precheckRestore(Buffer.from('dummy'));
expect(res.compatible).to.equal(false);
expect(res.severity).to.equal('danger');
@@ -102,7 +111,7 @@ describe('services/storage/backupRestoreService.js - precheck & filename', () =>
});
it('precheck: older backup is compatible with warning', async () => {
admZipMock.__set({ hasDb: true, meta: { dbMigration: 5, fredyVersion: '16.0.0' } });
setZipState({ hasDb: true, meta: { dbMigration: 5, fredyVersion: '16.0.0' } });
const res = await svc.precheckRestore(Buffer.from('zip'));
expect(res.compatible).to.equal(true);
expect(res.severity).to.equal('warning');
@@ -112,14 +121,14 @@ describe('services/storage/backupRestoreService.js - precheck & filename', () =>
});
it('precheck: equal backup is compatible with info', async () => {
admZipMock.__set({ hasDb: true, meta: { dbMigration: 10 } });
setZipState({ hasDb: true, meta: { dbMigration: 10 } });
const res = await svc.precheckRestore(Buffer.from('zip'));
expect(res.compatible).to.equal(true);
expect(res.severity).to.equal('info');
});
it('precheck: newer backup yields danger', async () => {
admZipMock.__set({ hasDb: true, meta: { dbMigration: 11 } });
setZipState({ hasDb: true, meta: { dbMigration: 11 } });
const res = await svc.precheckRestore(Buffer.from('zip'));
expect(res.compatible).to.equal(false);
expect(res.severity).to.equal('danger');

View File

@@ -0,0 +1,124 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { expect } from 'chai';
import esmock from 'esmock';
import { EventEmitter } from 'node:events';
describe('services/jobs/jobExecutionService', () => {
/** @type {EventEmitter} */
let bus;
let calls;
let state;
async function initService() {
const root = (await import('node:path')).resolve('.');
const svcPath = root + '/lib/services/jobs/jobExecutionService.js';
const busPath = root + '/lib/services/events/event-bus.js';
const jobStoragePath = root + '/lib/services/storage/jobStorage.js';
const userStoragePath = root + '/lib/services/storage/userStorage.js';
const brokerPath = root + '/lib/services/sse/sse-broker.js';
const utilsPath = root + '/lib/utils.js';
const loggerPath = root + '/lib/services/logger.js';
// esmock the service with all its collaborators
const mod = await esmock(
svcPath,
{},
{
[busPath]: { bus },
[jobStoragePath]: {
getJob: (id) => state.jobsById[id] || null,
getJobs: () => state.jobsList.slice(),
},
[userStoragePath]: {
getUsers: () => state.users.slice(),
getUser: (id) => state.users.find((u) => u.id === id) || null,
},
[brokerPath]: {
sendToUsers: (...args) => calls.sent.push(args),
},
[utilsPath]: {
duringWorkingHoursOrNotSet: () => false, // avoid startup run
},
[loggerPath]: {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
},
[root + '/lib/services/jobs/run-state.js']: {
isRunning: () => false,
markRunning: (id) => {
calls.markRunning.push(id);
return true;
},
markFinished: () => {},
},
},
);
// call initializer with minimal deps
mod.initJobExecutionService({ providers: [], settings: { demoMode: false }, intervalMs: 0 });
return mod;
}
beforeEach(() => {
bus = new EventEmitter();
calls = { sent: [], markRunning: [] };
state = {
jobsById: {},
jobsList: [],
users: [],
};
});
it('forwards SSE jobStatus to owner, shared users and admins', async () => {
state.jobsById['j1'] = { id: 'j1', userId: 'owner1', shared_with_user: ['u2'] };
state.users = [
{ id: 'a1', isAdmin: true },
{ id: 'owner1', isAdmin: false },
{ id: 'u2', isAdmin: false },
];
await initService();
bus.emit('jobs:status', { jobId: 'j1', running: true });
expect(calls.sent.length).to.equal(1, 'sendToUsers should be called once');
const [recipients, event, data] = calls.sent[0];
expect(event).to.equal('jobStatus');
expect(data).to.deep.equal({ jobId: 'j1', running: true });
const got = new Set(recipients);
const expected = new Set(['owner1', 'u2', 'a1']);
expect(got).to.deep.equal(expected);
});
it('runs all jobs for admin; only own jobs for regular user', async () => {
state.jobsList = [
{ id: 'j1', enabled: true, userId: 'u1', provider: [] },
{ id: 'j2', enabled: true, userId: 'u2', provider: [] },
];
state.users = [
{ id: 'u1', isAdmin: false },
{ id: 'u2', isAdmin: false },
{ id: 'admin', isAdmin: true },
];
await initService();
// Non-admin: only own jobs
bus.emit('jobs:runAll', { userId: 'u1' });
// allow microtasks to flush
await new Promise((r) => setTimeout(r, 0));
expect(new Set(calls.markRunning)).to.deep.equal(new Set(['j1']));
// Admin: all jobs
calls.markRunning = [];
bus.emit('jobs:runAll', { userId: 'admin' });
await new Promise((r) => setTimeout(r, 0));
expect(new Set(calls.markRunning)).to.deep.equal(new Set(['j1', 'j2']));
});
});

View File

@@ -11,7 +11,7 @@ import { send } from './mocks/mockNotification.js';
export const providerConfig = JSON.parse(await readFile(new URL('./provider/testProvider.json', import.meta.url)));
export const mockFredy = async () => {
return await esmock('../lib/FredyPipeline', {
return await esmock('../lib/FredyPipelineExecutioner', {
'../lib/services/storage/listingsStorage.js': {
...mockStore,
},

View File

@@ -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();

View File

@@ -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);

View File

@@ -0,0 +1,402 @@
/*
* Copyright (c) 2025 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React, { useState, useEffect, useMemo, useRef } from 'react';
import {
Card,
Col,
Row,
Button,
Space,
Typography,
Divider,
Switch,
Popover,
Tag,
Input,
Select,
Pagination,
Toast,
Empty,
} from '@douyinfe/semi-ui';
import {
IconAlertTriangle,
IconDelete,
IconDescend2,
IconEdit,
IconPlayCircle,
IconBriefcase,
IconBell,
IconSearch,
IconFilter,
IconPlusCircle,
} from '@douyinfe/semi-icons';
import { useNavigate } from 'react-router-dom';
import { useActions, useSelector } from '../../../services/state/store.js';
import { xhrDelete, xhrPut, xhrPost } from '../../../services/xhr.js';
import debounce from 'lodash/debounce';
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
import './JobGrid.less';
const { Text, Title } = Typography;
const getPopoverContent = (text) => <article className="jobPopoverContent">{text}</article>;
const JobGrid = () => {
const jobsData = useSelector((state) => state.jobsData);
const actions = useActions();
const navigate = useNavigate();
const [page, setPage] = useState(1);
const pageSize = 12;
const [sortField, setSortField] = useState('name');
const [sortDir, setSortDir] = useState('asc');
const [freeTextFilter, setFreeTextFilter] = useState(null);
const [activityFilter, setActivityFilter] = useState(null);
const [showFilterBar, setShowFilterBar] = useState(false);
const pendingJobIdRef = useRef(null);
const evtSourceRef = useRef(null);
const loadData = () => {
actions.jobsData.getJobsData({
page,
pageSize,
sortfield: sortField,
sortdir: sortDir,
freeTextFilter,
filter: { activityFilter },
});
};
useEffect(() => {
loadData();
}, [page, sortField, sortDir, freeTextFilter, activityFilter]);
// SSE connection for live job status updates
useEffect(() => {
// establish SSE connection
const src = new EventSource('/api/jobs/events');
evtSourceRef.current = src;
const onJobStatus = (e) => {
try {
const data = JSON.parse(e.data || '{}');
if (data && data.jobId) {
actions.jobsData.setJobRunning(data.jobId, !!data.running);
// notify finish if it was triggered by this view
if (pendingJobIdRef.current === data.jobId && data.running === false) {
Toast.success('Job finished');
pendingJobIdRef.current = null;
}
}
} catch {
// ignore malformed events
}
};
src.addEventListener('jobStatus', onJobStatus);
src.onerror = () => {
// Let browser auto-reconnect
};
return () => {
try {
src.removeEventListener('jobStatus', onJobStatus);
src.close();
} catch {
//noop
}
evtSourceRef.current = null;
pendingJobIdRef.current = null;
};
}, [actions.jobsData]);
const handleFilterChange = useMemo(() => debounce((value) => setFreeTextFilter(value), 500), []);
useEffect(() => {
return () => {
handleFilterChange.cancel && handleFilterChange.cancel();
};
}, [handleFilterChange]);
const onJobRemoval = async (jobId) => {
try {
await xhrDelete('/api/jobs', { jobId });
Toast.success('Job successfully removed');
loadData();
actions.jobsData.getJobs(); // refresh select list too
} catch (error) {
Toast.error(error);
}
};
const onListingRemoval = async (jobId) => {
try {
await xhrDelete('/api/listings/job', { jobId });
Toast.success('Listings successfully removed');
loadData();
} catch (error) {
Toast.error(error);
}
};
const onJobStatusChanged = async (jobId, status) => {
try {
await xhrPut(`/api/jobs/${jobId}/status`, { status });
Toast.success('Job status successfully changed');
loadData();
} catch (error) {
Toast.error(error);
}
};
const onJobRun = async (jobId) => {
try {
const response = await xhrPost(`/api/jobs/${jobId}/run`);
if (response.status === 202) {
Toast.success('Job run started');
} else {
Toast.info('Job run requested');
}
pendingJobIdRef.current = jobId;
loadData();
} catch (error) {
if (error?.status === 409) {
Toast.warning(error?.json?.message || 'Job is already running');
} else if (error?.status === 403) {
Toast.error('You are not allowed to run this job');
} else if (error?.status === 404) {
Toast.error('Job not found');
} else {
Toast.error('Failed to trigger job');
}
}
};
const handlePageChange = (_page) => {
setPage(_page);
};
return (
<div className="jobGrid">
<div style={{ display: 'flex', flexDirection: 'column' }}>
<Button
style={{ width: '7rem', margin: 0 }}
type="primary"
icon={<IconPlusCircle />}
className="jobs__newButton"
onClick={() => navigate('/jobs/new')}
>
New Job
</Button>
<div className="jobGrid__searchbar">
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} />
<Popover content="Filter / Sort Results" style={{ color: 'white', padding: '.5rem' }}>
<Button
icon={<IconFilter />}
onClick={() => {
setShowFilterBar(!showFilterBar);
}}
/>
</Popover>
</div>
</div>
{showFilterBar && (
<div className="jobGrid__toolbar">
<Space wrap style={{ marginBottom: '1rem' }}>
<div className="jobGrid__toolbar__card">
<div>
<Text strong>Filter by:</Text>
</div>
<div style={{ display: 'flex', gap: '.3rem' }}>
<Select
placeholder="Status"
showClear
onChange={(val) => setActivityFilter(val)}
value={activityFilter}
style={{ width: 140 }}
>
<Select.Option value={true}>Active</Select.Option>
<Select.Option value={false}>Not Active</Select.Option>
</Select>
</div>
</div>
<Divider layout="vertical" />
<div className="jobGrid__toolbar__card">
<div>
<Text strong>Sort by:</Text>
</div>
<div style={{ display: 'flex', gap: '.3rem' }}>
<Select
placeholder="Sort By"
style={{ width: 160 }}
value={sortField}
onChange={(val) => setSortField(val)}
>
<Select.Option value="name">Name</Select.Option>
<Select.Option value="numberOfFoundListings">Number of Listings</Select.Option>
<Select.Option value="enabled">Status</Select.Option>
</Select>
<Select
placeholder="Direction"
style={{ width: 120 }}
value={sortDir}
onChange={(val) => setSortDir(val)}
>
<Select.Option value="asc">Ascending</Select.Option>
<Select.Option value="desc">Descending</Select.Option>
</Select>
</div>
</div>
</Space>
</div>
)}
{(jobsData?.result || []).length === 0 && (
<Empty
image={<IllustrationNoResult />}
darkModeImage={<IllustrationNoResultDark />}
description="No jobs available yet..."
/>
)}
<Row gutter={[16, 16]}>
{(jobsData?.result || []).map((job) => (
<Col key={job.id} xs={24} sm={12} md={8} lg={6} xl={4} xxl={6}>
<Card
className="jobGrid__card"
bodyStyle={{ padding: '16px' }}
headerLine={true}
title={
<div className="jobGrid__header">
<Title heading={5} ellipsis={{ showTooltip: true }} className="jobGrid__title">
{job.name}
</Title>
<div style={{ display: 'flex', alignItems: 'center' }}>
{job.isOnlyShared && (
<Popover
content={getPopoverContent(
'This job has been shared with you by another user, therefor it is read-only.',
)}
>
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)', marginLeft: '8px' }} />
</Popover>
)}
</div>
{job.running && (
<Tag color="green" variant="light" size="small">
RUNNING
</Tag>
)}
</div>
}
>
<div className="jobGrid__content">
<Space vertical align="start" spacing={4} style={{ width: '100%', marginTop: 12 }}>
<div className="jobGrid__infoItem">
<Text type="secondary" icon={<IconSearch />} size="small">
Is active:
</Text>
<Switch
onChange={(checked) => onJobStatusChanged(job.id, checked)}
style={{ marginLeft: 'auto' }}
checked={job.enabled}
disabled={job.isOnlyShared}
size="small"
/>
</div>
<div className="jobGrid__infoItem">
<Text type="secondary" icon={<IconSearch />} size="small">
Listings:
</Text>
<Tag color="blue" size="small" style={{ marginLeft: 'auto' }}>
{job.numberOfFoundListings || 0}
</Tag>
</div>
<div className="jobGrid__infoItem">
<Text type="secondary" icon={<IconBriefcase />} size="small">
Providers:
</Text>
<Tag color="cyan" size="small" style={{ marginLeft: 'auto' }}>
{job.provider.length || 0}
</Tag>
</div>
<div className="jobGrid__infoItem">
<Text type="secondary" icon={<IconBell />} size="small">
Adapters:
</Text>
<Tag color="purple" size="small" style={{ marginLeft: 'auto' }}>
{job.notificationAdapter.length || 0}
</Tag>
</div>
</Space>
<Divider margin="12px" />
<div className="jobGrid__actions">
<Popover content={getPopoverContent('Run Job')}>
<Button
type="primary"
theme="solid"
icon={<IconPlayCircle />}
disabled={job.isOnlyShared || job.running}
onClick={() => onJobRun(job.id)}
/>
</Popover>
<Popover content={getPopoverContent('Edit a Job')}>
<Button
type="secondary"
theme="solid"
icon={<IconEdit />}
disabled={job.isOnlyShared}
onClick={() => navigate(`/jobs/edit/${job.id}`)}
/>
</Popover>
<Popover content={getPopoverContent('Delete all found Listings of this Job')}>
<Button
type="danger"
theme="solid"
icon={<IconDescend2 />}
disabled={job.isOnlyShared}
onClick={() => onListingRemoval(job.id)}
/>
</Popover>
<Popover content={getPopoverContent('Delete Job')}>
<Button
type="danger"
theme="solid"
icon={<IconDelete />}
disabled={job.isOnlyShared}
onClick={() => onJobRemoval(job.id)}
/>
</Popover>
</div>
</div>
</Card>
</Col>
))}
</Row>
{(jobsData?.result || []).length > 0 && jobsData?.totalNumber > 12 && (
<div className="jobGrid__pagination">
<Pagination
currentPage={page}
pageSize={pageSize}
total={jobsData?.totalNumber || 0}
onPageChange={handlePageChange}
showSizeChanger={false}
/>
</div>
)}
</div>
);
};
export default JobGrid;

View File

@@ -0,0 +1,69 @@
.jobGrid {
&__card {
height: 100%;
transition: transform 0.2s;
&:hover {
transform: translateY(-4px);
box-shadow: var(--semi-shadow-elevated);
}
}
&__searchbar {
display: flex;
gap: .5rem;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
&__toolbar {
&__card {
border-radius: 5px;
display: flex;
flex-direction: column;
gap: .3rem;
background: #232429;
padding: 0.5rem;
}
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
}
&__title {
margin-bottom: 0 !important;
}
&__infoItem {
display: flex;
align-items: center;
width: 100%;
.semi-typography {
display: flex;
align-items: center;
gap: 4px;
}
}
&__actions {
display: flex;
justify-content: space-between;
gap: 8px;
}
&__pagination {
margin-top: 2rem;
display: flex;
justify-content: center;
}
}
.jobPopoverContent {
padding: .4rem;
color: var(--semi-color-white);
}

View File

@@ -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 (
<div className="listingsGrid">
<div className="listingsGrid__searchbar">
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} />
<Popover content="Filter / Sort Results" style={{ color: 'white', padding: '.5rem' }}>
<Button
icon={<IconFilter />}
onClick={() => {
setShowFilterBar(!showFilterBar);
}}
/>
</Popover>
</div>
{showFilterBar && (
<div className="listingsGrid__toolbar">
<Space wrap style={{ marginBottom: '1rem' }}>
<div className="listingsGrid__toolbar__card">
<div>
<Text strong>Filter by:</Text>
</div>
<div style={{ display: 'flex', gap: '.3rem' }}>
<Select
placeholder="Status"
showClear
onChange={(val) => setActivityFilter(val)}
value={activityFilter}
>
<Select.Option value={true}>Active</Select.Option>
<Select.Option value={false}>Not Active</Select.Option>
</Select>
<Select
placeholder="Watchlist"
showClear
onChange={(val) => setWatchListFilter(val)}
value={watchListFilter}
>
<Select.Option value={true}>Watched</Select.Option>
<Select.Option value={false}>Not Watched</Select.Option>
</Select>
<Select
placeholder="Provider"
showClear
onChange={(val) => setProviderFilter(val)}
value={providerFilter}
>
{providers?.map((p) => (
<Select.Option key={p.id} value={p.id}>
{p.name}
</Select.Option>
))}
</Select>
<Select
placeholder="Job Name"
showClear
onChange={(val) => setJobNameFilter(val)}
value={jobNameFilter}
>
{jobs?.map((j) => (
<Select.Option key={j.id} value={j.id}>
{j.name}
</Select.Option>
))}
</Select>
</div>
</div>
<Divider layout="vertical" />
<div className="listingsGrid__toolbar__card">
<div>
<Text strong>Sort by:</Text>
</div>
<div style={{ display: 'flex', gap: '.3rem' }}>
<Select
placeholder="Sort By"
style={{ width: 140 }}
value={sortField}
onChange={(val) => setSortField(val)}
>
<Select.Option value="job_name">Job Name</Select.Option>
<Select.Option value="created_at">Listing Date</Select.Option>
<Select.Option value="price">Price</Select.Option>
<Select.Option value="provider">Provider</Select.Option>
</Select>
<Select
placeholder="Direction"
style={{ width: 120 }}
value={sortDir}
onChange={(val) => setSortDir(val)}
>
<Select.Option value="asc">Ascending</Select.Option>
<Select.Option value="desc">Descending</Select.Option>
</Select>
</div>
</div>
</Space>
</div>
)}
{(listingsData?.result || []).length === 0 && (
<Empty
image={<IllustrationNoResult />}
darkModeImage={<IllustrationNoResultDark />}
description="No listings available yet..."
/>
)}
<Row gutter={[16, 16]}>
{(listingsData?.result || []).map((item) => (
<Col key={item.id} xs={24} sm={12} md={8} lg={6} xl={4} xxl={6}>
<Card
className={`listingsGrid__card ${!item.is_active ? 'listingsGrid__card--inactive' : ''}`}
cover={
<div style={{ position: 'relative' }}>
<div className="listingsGrid__imageContainer">
<Image
src={item.image_url || no_image}
fallback={no_image}
width="100%"
height={180}
style={{ objectFit: 'cover' }}
preview={false}
/>
<Button
icon={
item.isWatched === 1 ? (
<IconStar style={{ color: 'rgba(var(--semi-green-5), 1)' }} />
) : (
<IconStarStroked />
)
}
theme="light"
shape="circle"
size="small"
className="listingsGrid__watchButton"
onClick={(e) => handleWatch(e, item)}
/>
</div>
{!item.is_active && <div className="listingsGrid__inactiveOverlay">Inactive</div>}
</div>
}
bodyStyle={{ padding: '12px' }}
>
<div className="listingsGrid__content">
<a href={item.url} target="_blank" rel="noopener noreferrer" className="listingsGrid__titleLink">
<Text strong ellipsis={{ showTooltip: true }} className="listingsGrid__title">
{item.title}
</Text>
</a>
<Space vertical align="start" spacing={2} style={{ width: '100%', marginTop: 8 }}>
<Text type="secondary" icon={<IconCart />} size="small">
{item.price}
</Text>
<Text
type="secondary"
icon={<IconMapPin />}
size="small"
ellipsis={{ showTooltip: true }}
style={{ width: '100%' }}
>
{item.address || 'No address provided'}
</Text>
<Text type="tertiary" size="small" icon={<IconClock />}>
{timeService.format(item.created_at, false)}
</Text>
<Text type="tertiary" size="small" icon={<IconBriefcase />}>
{item.provider.charAt(0).toUpperCase() + item.provider.slice(1)}
</Text>
</Space>
<Divider margin=".6rem" />
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Button
title="Link to listing"
type="primary"
size="small"
onClick={async () => {
window.open(item.link);
}}
icon={<IconLink />}
/>
<Button
title="Remove"
type="danger"
size="small"
onClick={async () => {
try {
await xhrDelete('/api/listings/', { ids: [item.id] });
Toast.success('Listing(s) successfully removed');
loadData();
} catch (error) {
Toast.error(error);
}
}}
icon={<IconDelete />}
/>
</div>
</div>
</Card>
</Col>
))}
</Row>
{(listingsData?.result || []).length > 0 && (
<div className="listingsGrid__pagination">
<Pagination
currentPage={page}
pageSize={pageSize}
total={listingsData?.totalNumber || 0}
onPageChange={handlePageChange}
showSizeChanger={false}
/>
</div>
)}
</div>
);
};
export default ListingsGrid;

View File

@@ -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;
}
}

View File

@@ -1,126 +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 } from '@douyinfe/semi-icons';
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
import './JobTable.less';
const empty = (
<Empty
image={<IllustrationNoResult />}
darkModeImage={<IllustrationNoResultDark />}
description="No jobs available. Why don't you create one? ;)"
/>
);
const getPopoverContent = (text) => <article className="jobPopoverContent">{text}</article>;
export default function JobTable({ jobs = {}, onJobRemoval, onJobStatusChanged, onJobEdit, onListingRemoval } = {}) {
return (
<Table
pagination={false}
empty={empty}
columns={[
{
title: '',
dataIndex: '',
render: (job) => {
return (
<Switch
onChange={(checked) => onJobStatusChanged(job.id, checked)}
checked={job.enabled}
disabled={job.isOnlyShared}
/>
);
},
},
{
title: 'Name',
dataIndex: 'name',
render: (name, job) => {
if (job.isOnlyShared) {
return (
<Popover
content={getPopoverContent(
'This job has been shared with you by another user, therefor it is read-only.',
)}
>
<div style={{ display: 'flex', gap: '.3rem' }}>
<div style={{ color: 'rgba(var(--semi-yellow-7), 1)' }}>
<IconAlertTriangle />
</div>
{name}
</div>
</Popover>
);
} 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 (
<div className="interactions">
<Popover content={getPopoverContent('Edit a Job')}>
<Button
type="secondary"
icon={<IconEdit />}
disabled={job.isOnlyShared}
onClick={() => onJobEdit(job.id)}
/>
</Popover>
<Popover content={getPopoverContent('Delete all found Listings of this Job')}>
<Button
type="danger"
icon={<IconDescend2 />}
disabled={job.isOnlyShared}
onClick={() => onListingRemoval(job.id)}
/>
</Popover>
<Popover content={getPopoverContent('Delete Job')}>
<Button
type="danger"
icon={<IconDelete />}
disabled={job.isOnlyShared}
onClick={() => onJobRemoval(job.id)}
/>
</Popover>
</div>
);
},
},
]}
dataSource={jobs}
/>
);
}

View File

@@ -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;
}
}

View File

@@ -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 (
<div>
<Popover
style={{
padding: '.4rem',
color: 'var(--semi-color-white)',
}}
content={row.isWatched === 1 ? 'Unwatch Listing' : 'Watch Listing'}
>
<Button
icon={
row.isWatched === 1 ? (
<IconStar style={{ color: 'rgba(var(--semi-green-5), 1)' }} />
) : (
<IconStarStroked />
)
}
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');
}
}}
/>
</Popover>
<Divider layout="vertical" margin="4px" />
<Popover
style={{
padding: '.4rem',
color: 'var(--semi-color-white)',
}}
content="Delete Listing"
>
<Button
icon={<IconDelete />}
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);
}
}}
/>
</Popover>
</div>
);
},
},
{
title: 'Active',
dataIndex: 'is_active',
width: 110,
sorter: true,
filters: [
{
text: 'Show only active listings',
value: 'activityStatus',
},
],
render: (value) => {
return value ? (
<div style={{ color: 'rgba(var(--semi-green-6), 1)' }}>
<Popover
style={{
padding: '.4rem',
color: 'var(--semi-color-white)',
}}
content="Listing is still active"
>
<IconTick />
</Popover>
</div>
) : (
<div style={{ color: 'rgba(var(--semi-red-5), 1)' }}>
<Popover
style={{
padding: '.4rem',
color: 'var(--semi-color-white)',
}}
content="Listing is inactive"
>
<IconClose />
</Popover>
</div>
);
},
},
{
title: 'Job-Name',
sorter: true,
ellipsis: true,
dataIndex: 'job_name',
width: 150,
onFilter: () => true,
renderFilterDropdown: () => {
return (
<Space vertical style={{ padding: 8 }}>
<Select showClear placeholder="Select Job to Filter" onChange={(val) => setJobNameFilter(val)}>
{jobs != null &&
jobs.length > 0 &&
jobs.map((job) => {
return (
<Select.Option value={job.id} key={job.id}>
{job.name}
</Select.Option>
);
})}
</Select>
</Space>
);
},
},
{
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 (
<Space vertical style={{ padding: 8 }}>
<Select showClear placeholder="Select Provider to Filter" onChange={(val) => setProviderFilter(val)}>
{provider != null &&
provider.length > 0 &&
provider.map((prov) => {
return (
<Select.Option value={prov.id} key={prov.id}>
{prov.name}
</Select.Option>
);
})}
</Select>
</Space>
);
},
},
{
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 (
<a href={row.url} target="_blank" rel="noopener noreferrer">
{text}
</a>
);
},
},
];
};
const empty = (
<Empty
image={<IllustrationNoResult />}
darkModeImage={<IllustrationNoResultDark />}
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 (
<div className="listingsTable__expanded">
<div>
{record.image_url == null ? (
<Image height={200} width={180} src={no_image} />
) : (
<Image
height={200}
width={imageWidth}
src={record.image_url}
onError={() => {
setImageWidth('180px');
}}
fallback={<Image height={200} src={no_image} />}
/>
)}
</div>
<div>
<Descriptions align="justify">
<Descriptions.Item itemKey="Listing still online">
<Tag size="small" shape="circle" color={record.is_active ? 'green' : 'red'}>
{record.is_active ? 'Yes' : 'No'}
</Tag>
</Descriptions.Item>
<Descriptions.Item itemKey="Link">
<a href={record.link} target="_blank" rel="noopener noreferrer">
Link to Listing
</a>
</Descriptions.Item>
<Descriptions.Item itemKey="Listing date">{format(record.created_at)}</Descriptions.Item>
<Descriptions.Item itemKey="Price">{record.price} </Descriptions.Item>
</Descriptions>
<b>{record.title}</b>
<p>{record.description == null ? 'No description available' : record.description}</p>
</div>
</div>
);
};
return (
<div>
<Input
prefix={<IconSearch />}
showClear
className="listingsTable__search"
placeholder="Search"
onChange={handleFilterChange}
/>
{watchlistFeature && (
<Button
className="listingsTable__setupButton"
onClick={() => {
navigate('/watchlistManagement');
}}
>
Setup notifications on watchlist changes
</Button>
)}
<Table
rowKey="id"
empty={empty}
hideExpandedColumn={false}
sticky={{ top: 5 }}
columns={columns}
expandedRowRender={expandRowRender}
dataSource={(tableData?.result || []).map((row) => {
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,
}}
/>
</div>
);
}

View File

@@ -1,18 +0,0 @@
.listingsTable {
&__search {
margin-bottom: 1rem !important;
}
&__expanded {
display: flex;
gap: 1rem;
}
&__toolbar {
margin-bottom: 1rem;
}
&__setupButton {
margin-bottom: 1rem;
}
}

View File

@@ -83,23 +83,59 @@ 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);
}
},
setJobRunning(jobId, running) {
if (!jobId) return;
set((state) => {
const list = state.jobsData.jobs || [];
const updated = list.map((j) => (j.id === jobId ? { ...j, running: !!running } : j));
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) } };
});
},
},
user: {
async getUsers() {
@@ -143,8 +179,8 @@ export const useFredyState = create(
}
},
},
listingsTable: {
async getListingsTable({
listingsData: {
async getListingsData({
page = 1,
pageSize = 20,
freeTextFilter = null,
@@ -163,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);
@@ -176,7 +212,7 @@ export const useFredyState = create(
const initial = {
dashboard: { data: null },
notificationAdapter: [],
listingsTable: {
listingsData: {
totalNumber: 0,
page: 1,
result: [],
@@ -186,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 },
};
@@ -197,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 },
};

View File

@@ -5,69 +5,13 @@
import React from 'react';
import JobTable from '../../components/table/JobTable';
import { useSelector, useActions } from '../../services/state/store';
import { xhrDelete, xhrPut } 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 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);
}
};
return (
<div>
<div>
<Button
type="primary"
icon={<IconPlusCircle />}
className="jobs__newButton"
onClick={() => navigate('/jobs/new')}
>
New Job
</Button>
</div>
<JobTable
jobs={jobs || []}
onJobRemoval={onJobRemoval}
onListingRemoval={onListingRemoval}
onJobStatusChanged={onJobStatusChanged}
onJobEdit={(jobId) => navigate(`/jobs/edit/${jobId}`)}
/>
<div className="jobs">
<JobGrid />
</div>
);
}

View File

@@ -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) {

View File

@@ -164,7 +164,7 @@ export default function NotificationAdapterMutator({
style={{ width: '100%' }}
field={uiElement.label}
type={uiElement.type}
value={uiElement.value || ''}
initValue={uiElement.value ?? ''}
placeholder={uiElement.label}
label={uiElement.label}
onChange={(value) => {

View File

@@ -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 <ListingsTable />;
return <ListingsGrid />;
}

View File

@@ -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);

158
yarn.lock
View File

@@ -997,34 +997,34 @@
dependencies:
tslib "^2.0.0"
"@douyinfe/semi-animation-react@2.89.0":
version "2.89.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-react/-/semi-animation-react-2.89.0.tgz#be95b42a928ffe60b54d688dcf1d0f65e81b5bcc"
integrity sha512-6GSQMF2bIoWN2Bua4wYGCe//ltfE1/iNQRMF7+TybVMz9kBJU0gelFsvxxVnqpka994RuTvhe73CSWWdpLwjng==
"@douyinfe/semi-animation-react@2.89.1":
version "2.89.1"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-react/-/semi-animation-react-2.89.1.tgz#cbbf6f5cfdfb870c20418441ab5f0ce6a5844146"
integrity sha512-SNEy7kujmLp7jUNzxsjEurkYmnd54OQRxedWJ8Taq3R/AhOGq6x1mP6E8EXHcBl3DgWMDdLv7YF9or/6Pom4XQ==
dependencies:
"@douyinfe/semi-animation" "2.89.0"
"@douyinfe/semi-animation-styled" "2.89.0"
"@douyinfe/semi-animation" "2.89.1"
"@douyinfe/semi-animation-styled" "2.89.1"
classnames "^2.2.6"
"@douyinfe/semi-animation-styled@2.89.0":
version "2.89.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-styled/-/semi-animation-styled-2.89.0.tgz#72cd09f73abf5198bcfb47f6254c0f2799c146b2"
integrity sha512-y1wXswseGbJpPh3hJQ9aNjnMzecLh9eUERmSpQaWbDSdrzk65hBa91MMC2rk/wlIN0/Q6OAKU8FcMoSiBiuI0Q==
"@douyinfe/semi-animation-styled@2.89.1":
version "2.89.1"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-styled/-/semi-animation-styled-2.89.1.tgz#9a8b5b66a0065f1b15573b85c911aaaa1e05dcf3"
integrity sha512-Zwl4EHn8IXWiqTAl3IIfbAWtmxH82B/BH2YqX88ASXqja8omJqMQ5ho1yYxzVFiurswz45RtoVm5tdt6M0d+MA==
"@douyinfe/semi-animation@2.89.0":
version "2.89.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation/-/semi-animation-2.89.0.tgz#a30de59827f6b8452100a5dd2a828aebe8cb86ec"
integrity sha512-y6an913b841V0BAdR5qSLYvoK5C2OAbNKImzM+FzWmbRQjzbOEYcF3bqi5AZhY4mYk7v05k2W7U6fmaXYNOS1Q==
"@douyinfe/semi-animation@2.89.1":
version "2.89.1"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation/-/semi-animation-2.89.1.tgz#bb8c341895a7439838f654a9b43a65ba256e2a04"
integrity sha512-7hKxLO//Ggxr7lYxIwJSQx+WQt9uYfuUnybn7Td/80/Jk3eIs+OJF9rN/8zmECANcxzZxHIuWjX5lfcQDDGhoA==
dependencies:
bezier-easing "^2.1.0"
"@douyinfe/semi-foundation@2.89.0":
version "2.89.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-foundation/-/semi-foundation-2.89.0.tgz#299b10ecb92289bd4158471d91a08dfc461b7ef2"
integrity sha512-Ryc2XywB3BVoUHETp5e7cY9x/ccweeKyCjqw/dcM16txeSpGxW7p1ykexGHRl3+dz1QcVrU4vp/ELD6GutC0Sg==
"@douyinfe/semi-foundation@2.89.1":
version "2.89.1"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-foundation/-/semi-foundation-2.89.1.tgz#9bce2d137974da975c7e44cf3620924efd10bee4"
integrity sha512-LP8vLQ/IyKGkzUoUX5/15A2SjTmCEWl/kiouvBeRpPvVyy/worTZe5XVd6ojlbFl0VIYWv4Jwy1w3kJHq4Ltvg==
dependencies:
"@douyinfe/semi-animation" "2.89.0"
"@douyinfe/semi-json-viewer-core" "2.89.0"
"@douyinfe/semi-animation" "2.89.1"
"@douyinfe/semi-json-viewer-core" "2.89.1"
"@mdx-js/mdx" "^3.0.1"
async-validator "^3.5.0"
classnames "^2.2.6"
@@ -1038,44 +1038,44 @@
remark-gfm "^4.0.0"
scroll-into-view-if-needed "^2.2.24"
"@douyinfe/semi-icons@2.89.0", "@douyinfe/semi-icons@^2.89.0":
version "2.89.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-icons/-/semi-icons-2.89.0.tgz#c9252981fde29668e3a88862948d09f71360a4fa"
integrity sha512-LfUhh/S0+3bOdD7jy1xg5F1y6mXrYtDiIsA1Hmuhy3zhNSpSKSwfqPiV3IxwRRmGXFWjgiSefKd99h5OmKMPHg==
"@douyinfe/semi-icons@2.89.1", "@douyinfe/semi-icons@^2.89.1":
version "2.89.1"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-icons/-/semi-icons-2.89.1.tgz#34635b5d515750ea281ec78190de71522847e2a4"
integrity sha512-K1xNbscHkGdx91xP5I6M/JKks/Xd2UzKGhLxBmyQ2D1Oboj95mJ+Dt15A8OGCcx2/n8shaJa2Ldw9JBPiXbNJg==
dependencies:
classnames "^2.2.6"
"@douyinfe/semi-illustrations@2.89.0":
version "2.89.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-illustrations/-/semi-illustrations-2.89.0.tgz#93611cfa572d79eb4bd50a1e13ee416161e6f41f"
integrity sha512-yAU4sSHr236E7ygTlwxupQkeF/W7EtfrUfRx3NUdGWuswMPAICz7d6Upa0XAZoCJ4skBZ5ItcQq9FfM+pw4wKg==
"@douyinfe/semi-illustrations@2.89.1":
version "2.89.1"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-illustrations/-/semi-illustrations-2.89.1.tgz#9ca1508af325d5362dbf802035022cb54e424c9c"
integrity sha512-3XEm3JIlCLZCKl/RmysYtIlqe6itbWf6u9nxaE6TWpSKJtSfA+wbyO2F3W2fvXkCJZopjk65BFLIFx/CAEqc5g==
"@douyinfe/semi-json-viewer-core@2.89.0":
version "2.89.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-json-viewer-core/-/semi-json-viewer-core-2.89.0.tgz#4254ce7d36c24f70267980f8e8a42faf6757f502"
integrity sha512-BGMJgg+tBFcwg3/7aJmtIXaHW+tSA6Tae3UfyhLYjUxcl6cFtYjtN0DAGwoia9KzUdNHoSAhl3GJVtGCBsmApQ==
"@douyinfe/semi-json-viewer-core@2.89.1":
version "2.89.1"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-json-viewer-core/-/semi-json-viewer-core-2.89.1.tgz#582a3c49a06913c287be531670e87120a2220af2"
integrity sha512-MSkbeoADyD1d4mERdmMNeqL0SBI2d8b9j/em7LDQp6tLzmfQEIHgPjjwvPthefHBPjUiikPSiSc9Iv/gPayhIQ==
dependencies:
jsonc-parser "^3.3.1"
"@douyinfe/semi-theme-default@2.89.0":
version "2.89.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-theme-default/-/semi-theme-default-2.89.0.tgz#eb1fed1939a16fb903f6845867007020d76a503b"
integrity sha512-mdL6Ui1XMGW9L5tYl9uG3MnmyHaIXnRVa7b4PB0Y8kfFGAVzls5XBjZT5ACVtwxlVZrU5BrJBFOXGER5p1FVDg==
"@douyinfe/semi-theme-default@2.89.1":
version "2.89.1"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-theme-default/-/semi-theme-default-2.89.1.tgz#57f596e610269d31e021fe6472440970305f9750"
integrity sha512-8GGPU4mfhUg9dwyVS+xEhXp7iNEaqEslyRwbaiUImjOwLZmpEbYST/gvYpXkpRltaWGZ7n82P3uVPyQZkIBLAg==
"@douyinfe/semi-ui@2.89.0":
version "2.89.0"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-ui/-/semi-ui-2.89.0.tgz#563adb7f33b9d888a882573024df2296be3c8bf4"
integrity sha512-XZ2yo2TgGWk8ubukJq7zbpKePpswQRq3nxeBlmL39SEben8AUfEq92vus0Hcmua5Y2wgi6TY2qWPgo+WEZCrkQ==
"@douyinfe/semi-ui@2.89.1":
version "2.89.1"
resolved "https://registry.yarnpkg.com/@douyinfe/semi-ui/-/semi-ui-2.89.1.tgz#c0573f2531fe24d4830db9331e99295d75af2207"
integrity sha512-1jh9c1NwPEHYIPt+vCSF/TeXrhJ6g6/5745h8x2VZMA6+Epexv61eucTiMEz/HZzi9GC/u+K0GuwSS3CjJdEFA==
dependencies:
"@dnd-kit/core" "^6.0.8"
"@dnd-kit/sortable" "^7.0.2"
"@dnd-kit/utilities" "^3.2.1"
"@douyinfe/semi-animation" "2.89.0"
"@douyinfe/semi-animation-react" "2.89.0"
"@douyinfe/semi-foundation" "2.89.0"
"@douyinfe/semi-icons" "2.89.0"
"@douyinfe/semi-illustrations" "2.89.0"
"@douyinfe/semi-theme-default" "2.89.0"
"@douyinfe/semi-animation" "2.89.1"
"@douyinfe/semi-animation-react" "2.89.1"
"@douyinfe/semi-foundation" "2.89.1"
"@douyinfe/semi-icons" "2.89.1"
"@douyinfe/semi-illustrations" "2.89.1"
"@douyinfe/semi-theme-default" "2.89.1"
"@tiptap/core" "^3.10.7"
"@tiptap/extension-document" "^3.10.7"
"@tiptap/extension-hard-break" "^3.10.7"
@@ -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"
@@ -2363,10 +2363,10 @@ chownr@^1.1.1:
resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b"
integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==
chromium-bidi@11.0.0:
version "11.0.0"
resolved "https://registry.yarnpkg.com/chromium-bidi/-/chromium-bidi-11.0.0.tgz#193433d0722095abca0cada2fa0c5111b447bea3"
integrity sha512-cM3DI+OOb89T3wO8cpPSro80Q9eKYJ7hGVXoGS3GkDPxnYSqiv+6xwpIf6XERyJ9Tdsl09hmNmY94BkgZdVekw==
chromium-bidi@12.0.1:
version "12.0.1"
resolved "https://registry.yarnpkg.com/chromium-bidi/-/chromium-bidi-12.0.1.tgz#3edb1420ab5a52004c10c223b928622c128b4f27"
integrity sha512-fGg+6jr0xjQhzpy5N4ErZxQ4wF7KLEvhGZXD6EgvZKDhu7iOhZXnZhcDxPJDcwTcrD48NPzOCo84RP2lv3Z+Cg==
dependencies:
mitt "^3.0.1"
zod "^3.24.1"
@@ -5874,17 +5874,17 @@ 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.0:
version "24.33.0"
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.33.0.tgz#c3e6adee5fd5dd77895d4cc14e0d17461048917a"
integrity sha512-tPTxVg+Qdj/8av4cy6szv3GlhxeOoNhiiMZ955fjxQyvPQE/6DjCa6ZyF/x0WJrlgBZtaLSP8TQgJb7FdLDXXA==
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 "11.0.0"
chromium-bidi "12.0.1"
debug "^4.4.3"
devtools-protocol "0.0.1534754"
typed-query-selector "^2.12.0"
webdriver-bidi-protocol "0.3.9"
webdriver-bidi-protocol "0.3.10"
ws "^8.18.3"
puppeteer-extra-plugin-stealth@^2.11.2:
@@ -5934,16 +5934,16 @@ puppeteer-extra@^3.3.6:
debug "^4.1.1"
deepmerge "^4.2.2"
puppeteer@^24.33.0:
version "24.33.0"
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.33.0.tgz#5d0aba25f0038db2a3a996deb8c157d1842e8a21"
integrity sha512-nl3wsAztq5F8zybn4Tk41OCnYIzFIzGC6AN0WcF2KCUnWenajvRRPgBmS6LvNUV2HEeIzT2zRZHH0TgVxLDKew==
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 "11.0.0"
chromium-bidi "12.0.1"
cosmiconfig "^9.0.0"
devtools-protocol "0.0.1534754"
puppeteer-core "24.33.0"
puppeteer-core "24.34.0"
typed-query-selector "^2.12.0"
qs@^6.14.0:
@@ -6033,17 +6033,17 @@ react-resizable@^3.0.5:
prop-types "15.x"
react-draggable "^4.0.3"
react-router-dom@7.10.1:
version "7.10.1"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.10.1.tgz#fddea814d30a3630c11d9ea539932482ff6f744c"
integrity sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==
react-router-dom@7.11.0:
version "7.11.0"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.11.0.tgz#2165f63e52798bd0eb138480c098ad058cdf3413"
integrity sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==
dependencies:
react-router "7.10.1"
react-router "7.11.0"
react-router@7.10.1:
version "7.10.1"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.10.1.tgz#e973146ed5f10a80783fdb3f27dbe37679557a7c"
integrity sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==
react-router@7.11.0:
version "7.11.0"
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.11.0.tgz#d3b91567fdbe910caf9064ea69b7b4d9264f2945"
integrity sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==
dependencies:
cookie "^1.0.1"
set-cookie-parser "^2.6.0"
@@ -7258,10 +7258,10 @@ web-streams-polyfill@^3.0.3:
resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b"
integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==
webdriver-bidi-protocol@0.3.9:
version "0.3.9"
resolved "https://registry.yarnpkg.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.9.tgz#89abf021f2a557a2dd81772f9ce7172b01f8a0f0"
integrity sha512-uIYvlRQ0PwtZR1EzHlTMol1G0lAlmOe6wPykF9a77AK3bkpvZHzIVxRE2ThOx5vjy2zISe0zhwf5rzuUfbo1PQ==
webdriver-bidi-protocol@0.3.10:
version "0.3.10"
resolved "https://registry.yarnpkg.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.10.tgz#437405564ff7e200371468f4f1eba1ff5537e119"
integrity sha512-5LAE43jAVLOhB/QqX4bwSiv0Hg1HBfMmOuwBSXHdvg4GMGu9Y0lIq7p4R/yySu6w74WmaR4GM4H9t2IwLW7hgw==
whatwg-encoding@^3.1.1:
version "3.1.1"