mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2926ee7e08 | ||
|
|
9506d1a9db | ||
|
|
feaa06c132 | ||
|
|
ad46500d4e | ||
|
|
3c209a8f97 | ||
|
|
398259ff20 | ||
|
|
cf030bfa39 | ||
|
|
5dc976c7e3 | ||
|
|
05f1bc61c9 |
@@ -34,7 +34,8 @@ WORKDIR /fredy
|
|||||||
# Using Alpine's chromium package which is much smaller
|
# Using Alpine's chromium package which is much smaller
|
||||||
RUN apk add --no-cache chromium curl
|
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
|
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
||||||
|
|
||||||
# Install build dependencies for native modules, then remove them after yarn install
|
# Install build dependencies for native modules, then remove them after yarn install
|
||||||
|
|||||||
2
LICENSE
2
LICENSE
@@ -210,5 +210,5 @@ different name or branding without the explicit written permission of the
|
|||||||
original copyright holder.
|
original copyright holder.
|
||||||
|
|
||||||
|
|
||||||
Copyright (c) 2025 Christian Kellner
|
Copyright (c) 2026 Christian Kellner
|
||||||
Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ flowchart TD
|
|||||||
F2["Adapter 2"]
|
F2["Adapter 2"]
|
||||||
end
|
end
|
||||||
|
|
||||||
A1 --> B["FredyPipeline"]
|
A1 --> B["FredyPipelineExecutioner"]
|
||||||
A2 --> B
|
A2 --> B
|
||||||
A3 --> B
|
A3 --> B
|
||||||
B --> C1 & C2 & C3
|
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 |
@@ -5,6 +5,8 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: ghcr.io/orangecoding/fredy
|
image: ghcr.io/orangecoding/fredy
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
volumes:
|
volumes:
|
||||||
- ./conf:/conf
|
- ./conf:/conf
|
||||||
- ./db:/db
|
- ./db:/db
|
||||||
|
|||||||
52
index.js
52
index.js
@@ -6,18 +6,15 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { checkIfConfigIsAccessible, getProviders, refreshConfig } from './lib/utils.js';
|
import { checkIfConfigIsAccessible, getProviders, refreshConfig } from './lib/utils.js';
|
||||||
import * as similarityCache from './lib/services/similarity-check/similarityCache.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 { runMigrations } from './lib/services/storage/migrations/migrate.js';
|
||||||
import { ensureDemoUserExists, ensureAdminUserExists } from './lib/services/storage/userStorage.js';
|
import { ensureDemoUserExists, ensureAdminUserExists } from './lib/services/storage/userStorage.js';
|
||||||
import { cleanupDemoAtMidnight } from './lib/services/crons/demoCleanup-cron.js';
|
import { cleanupDemoAtMidnight } from './lib/services/crons/demoCleanup-cron.js';
|
||||||
import { initTrackerCron } from './lib/services/crons/tracker-cron.js';
|
import { initTrackerCron } from './lib/services/crons/tracker-cron.js';
|
||||||
import logger from './lib/services/logger.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 { initActiveCheckerCron } from './lib/services/crons/listing-alive-cron.js';
|
||||||
import { getSettings } from './lib/services/storage/settingsStorage.js';
|
import { getSettings } from './lib/services/storage/settingsStorage.js';
|
||||||
import SqliteConnection, { computeDbPath } from './lib/services/storage/SqliteConnection.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
|
//in the config, we store the path of the sqlite file, thus we must check if it is available
|
||||||
const isConfigAccessible = await checkIfConfigIsAccessible();
|
const isConfigAccessible = await checkIfConfigIsAccessible();
|
||||||
@@ -36,7 +33,7 @@ await runMigrations();
|
|||||||
|
|
||||||
const settings = await getSettings();
|
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();
|
const { dir: sqliteDir } = await computeDbPath();
|
||||||
if (!fs.existsSync(sqliteDir)) {
|
if (!fs.existsSync(sqliteDir)) {
|
||||||
fs.mkdirSync(sqliteDir, { recursive: true });
|
fs.mkdirSync(sqliteDir, { recursive: true });
|
||||||
@@ -59,52 +56,13 @@ if (settings.demoMode) {
|
|||||||
cleanupDemoAtMidnight();
|
cleanupDemoAtMidnight();
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Started Fredy successfully. Ui can be accessed via http://localhost:${settings.port}`);
|
|
||||||
|
|
||||||
ensureAdminUserExists();
|
ensureAdminUserExists();
|
||||||
ensureDemoUserExists();
|
ensureDemoUserExists();
|
||||||
await initTrackerCron();
|
await initTrackerCron();
|
||||||
//do not wait for this to finish, let it run in the background
|
//do not wait for this to finish, let it run in the background
|
||||||
initActiveCheckerCron();
|
initActiveCheckerCron();
|
||||||
|
|
||||||
bus.on('jobs:runAll', () => {
|
logger.info(`Started Fredy successfully. Ui can be accessed via http://localhost:${settings.port}`);
|
||||||
logger.debug('Running Fredy Job manually');
|
|
||||||
execute();
|
|
||||||
});
|
|
||||||
|
|
||||||
const execute = () => {
|
// Initialize the lean Job Execution Service (schedules and bus listeners)
|
||||||
const isDuringWorkingHoursOrNotSet = duringWorkingHoursOrNotSet(settings, Date.now());
|
initJobExecutionService({ providers, settings, intervalMs: INTERVAL });
|
||||||
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();
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ import logger from './services/logger.js';
|
|||||||
* 7) Filter out entries similar to already seen ones
|
* 7) Filter out entries similar to already seen ones
|
||||||
* 8) Dispatch notifications
|
* 8) Dispatch notifications
|
||||||
*/
|
*/
|
||||||
class FredyPipeline {
|
class FredyPipelineExecutioner {
|
||||||
/**
|
/**
|
||||||
* Create a new runtime instance for a single provider/job execution.
|
* Create a new runtime instance for a single provider/job execution.
|
||||||
*
|
*
|
||||||
@@ -218,4 +218,4 @@ class FredyPipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default FredyPipeline;
|
export default FredyPipelineExecutioner;
|
||||||
@@ -9,6 +9,8 @@ import * as userStorage from '../../services/storage/userStorage.js';
|
|||||||
import { isAdmin } from '../security.js';
|
import { isAdmin } from '../security.js';
|
||||||
import logger from '../../services/logger.js';
|
import logger from '../../services/logger.js';
|
||||||
import { bus } from '../../services/events/event-bus.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 service = restana();
|
||||||
const jobRouter = service.newRouter();
|
const jobRouter = service.newRouter();
|
||||||
@@ -37,6 +39,7 @@ jobRouter.get('/', async (req, res) => {
|
|||||||
.map((job) => {
|
.map((job) => {
|
||||||
return {
|
return {
|
||||||
...job,
|
...job,
|
||||||
|
running: isJobRunning(job.id),
|
||||||
isOnlyShared:
|
isOnlyShared:
|
||||||
!isUserAdmin &&
|
!isUserAdmin &&
|
||||||
job.userId !== req.session.currentUser &&
|
job.userId !== req.session.currentUser &&
|
||||||
@@ -47,11 +50,115 @@ jobRouter.get('/', async (req, res) => {
|
|||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
|
|
||||||
jobRouter.post('/startAll', async (req, res) => {
|
jobRouter.get('/data', async (req, res) => {
|
||||||
bus.emit('jobs:runAll');
|
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();
|
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) => {
|
jobRouter.post('/', async (req, res) => {
|
||||||
const { provider, notificationAdapter, name, blacklist = [], jobId, enabled, shareWithUsers = [] } = req.body;
|
const { provider, notificationAdapter, name, blacklist = [], jobId, enabled, shareWithUsers = [] } = req.body;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -28,10 +28,14 @@ listingsRouter.get('/table', async (req, res) => {
|
|||||||
freeTextFilter,
|
freeTextFilter,
|
||||||
} = req.query || {};
|
} = req.query || {};
|
||||||
|
|
||||||
// normalize booleans (accept true, 'true', 1, '1')
|
// normalize booleans (accept true, 'true', 1, '1' for true; false, 'false', 0, '0' for false)
|
||||||
const toBool = (v) => v === true || v === 'true' || v === 1 || v === '1';
|
const toBool = (v) => {
|
||||||
const normalizedActivity = toBool(activityFilter) ? true : null;
|
if (v === true || v === 'true' || v === 1 || v === '1') return true;
|
||||||
const normalizedWatch = toBool(watchListFilter) ? true : null;
|
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 jobFilter = null;
|
||||||
let jobIdFilter = null;
|
let jobIdFilter = null;
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export const init = (sourceConfig, blacklist) => {
|
|||||||
|
|
||||||
export const metaInformation = {
|
export const metaInformation = {
|
||||||
name: 'OhneMakler',
|
name: 'OhneMakler',
|
||||||
baseUrl: 'https://www.ohne-makler.net/immobilien',
|
baseUrl: 'https://www.ohne-makler.net',
|
||||||
id: 'ohneMakler',
|
id: 'ohneMakler',
|
||||||
};
|
};
|
||||||
export { config };
|
export { config };
|
||||||
|
|||||||
@@ -104,7 +104,11 @@ export default async function execute(url, waitForSelector, options) {
|
|||||||
result = pageSource || (await page.content());
|
result = pageSource || (await page.content());
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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;
|
result = null;
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
|
|||||||
187
lib/services/jobs/jobExecutionService.js
Normal file
187
lib/services/jobs/jobExecutionService.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
42
lib/services/jobs/run-state.js
Normal file
42
lib/services/jobs/run-state.js
Normal 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);
|
||||||
|
}
|
||||||
108
lib/services/sse/sse-broker.js
Normal file
108
lib/services/sse/sse-broker.js
Normal 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);
|
||||||
@@ -19,8 +19,13 @@ import { runMigrations, listMigrationFiles } from './migrations/migrate.js';
|
|||||||
let _AdmZipSingleton = null;
|
let _AdmZipSingleton = null;
|
||||||
async function getAdmZip() {
|
async function getAdmZip() {
|
||||||
if (_AdmZipSingleton) return _AdmZipSingleton;
|
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');
|
const mod = await import('adm-zip');
|
||||||
_AdmZipSingleton = mod.default || mod;
|
_AdmZipSingleton = (mod && mod.default) || mod;
|
||||||
return _AdmZipSingleton;
|
return _AdmZipSingleton;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export const getJob = (jobId) => {
|
|||||||
j.name,
|
j.name,
|
||||||
j.blacklist,
|
j.blacklist,
|
||||||
j.provider,
|
j.provider,
|
||||||
|
j.shared_with_user,
|
||||||
j.notification_adapter AS notificationAdapter,
|
j.notification_adapter AS notificationAdapter,
|
||||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings
|
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id) AS numberOfFoundListings
|
||||||
FROM jobs j
|
FROM jobs j
|
||||||
@@ -98,6 +99,7 @@ export const getJob = (jobId) => {
|
|||||||
enabled: !!row.enabled,
|
enabled: !!row.enabled,
|
||||||
blacklist: fromJson(row.blacklist, []),
|
blacklist: fromJson(row.blacklist, []),
|
||||||
provider: fromJson(row.provider, []),
|
provider: fromJson(row.provider, []),
|
||||||
|
shared_with_user: fromJson(row.shared_with_user, []),
|
||||||
notificationAdapter: fromJson(row.notificationAdapter, []),
|
notificationAdapter: fromJson(row.notificationAdapter, []),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -161,3 +163,109 @@ export const getJobs = () => {
|
|||||||
notificationAdapter: fromJson(row.notificationAdapter, []),
|
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 };
|
||||||
|
};
|
||||||
|
|||||||
@@ -277,9 +277,11 @@ export const queryListings = ({
|
|||||||
params.filter = `%${String(freeTextFilter).trim()}%`;
|
params.filter = `%${String(freeTextFilter).trim()}%`;
|
||||||
whereParts.push(`(title LIKE @filter OR address LIKE @filter OR provider LIKE @filter OR link LIKE @filter)`);
|
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) {
|
if (activityFilter === true) {
|
||||||
whereParts.push('(is_active = 1)');
|
whereParts.push('(is_active = 1)');
|
||||||
|
} else if (activityFilter === false) {
|
||||||
|
whereParts.push('(is_active = 0)');
|
||||||
}
|
}
|
||||||
// Prefer filtering by job id when provided (unambiguous and robust)
|
// Prefer filtering by job id when provided (unambiguous and robust)
|
||||||
if (jobIdFilter && String(jobIdFilter).trim().length > 0) {
|
if (jobIdFilter && String(jobIdFilter).trim().length > 0) {
|
||||||
@@ -295,9 +297,11 @@ export const queryListings = ({
|
|||||||
params.providerName = String(providerFilter).trim();
|
params.providerName = String(providerFilter).trim();
|
||||||
whereParts.push('(provider = @providerName)');
|
whereParts.push('(provider = @providerName)');
|
||||||
}
|
}
|
||||||
// watchListFilter: when true -> only watched listings
|
// watchListFilter: when true -> only watched listings, false -> only unwatched
|
||||||
if (watchListFilter === true) {
|
if (watchListFilter === true) {
|
||||||
whereParts.push('(wl.id IS NOT NULL)');
|
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 ')}` : '';
|
const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
|
||||||
|
|||||||
16
package.json
16
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fredy",
|
"name": "fredy",
|
||||||
"version": "16.2.0",
|
"version": "17.0.2",
|
||||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
"format": "prettier --write \"**/*.js\"",
|
"format": "prettier --write \"**/*.js\"",
|
||||||
"format:check": "prettier --check \"**/*.js\"",
|
"format:check": "prettier --check \"**/*.js\"",
|
||||||
"test": "node --import ./test/esmock-loader.mjs ./node_modules/mocha/bin/mocha.js --timeout 60000 test/**/*.test.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": "eslint .",
|
||||||
"lint:fix": "yarn lint --fix",
|
"lint:fix": "yarn lint --fix",
|
||||||
"migratedb": "node lib/services/storage/migrations/migrate.js",
|
"migratedb": "node lib/services/storage/migrations/migrate.js",
|
||||||
@@ -60,8 +60,8 @@
|
|||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"@douyinfe/semi-icons": "^2.89.0",
|
"@douyinfe/semi-icons": "^2.90.3",
|
||||||
"@douyinfe/semi-ui": "2.89.0",
|
"@douyinfe/semi-ui": "2.90.3",
|
||||||
"@sendgrid/mail": "8.1.6",
|
"@sendgrid/mail": "8.1.6",
|
||||||
"@vitejs/plugin-react": "5.1.2",
|
"@vitejs/plugin-react": "5.1.2",
|
||||||
"better-sqlite3": "^12.5.0",
|
"better-sqlite3": "^12.5.0",
|
||||||
@@ -77,15 +77,15 @@
|
|||||||
"node-mailjet": "6.0.11",
|
"node-mailjet": "6.0.11",
|
||||||
"p-throttle": "^8.1.0",
|
"p-throttle": "^8.1.0",
|
||||||
"package-up": "^5.0.0",
|
"package-up": "^5.0.0",
|
||||||
"puppeteer": "^24.33.0",
|
"puppeteer": "^24.34.0",
|
||||||
"puppeteer-extra": "^3.3.6",
|
"puppeteer-extra": "^3.3.6",
|
||||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||||
"query-string": "9.3.1",
|
"query-string": "9.3.1",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-chartjs-2": "^5.3.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-router": "7.10.1",
|
"react-router": "7.11.0",
|
||||||
"react-router-dom": "7.10.1",
|
"react-router-dom": "7.11.0",
|
||||||
"restana": "5.1.0",
|
"restana": "5.1.0",
|
||||||
"semver": "^7.7.3",
|
"semver": "^7.7.3",
|
||||||
"serve-static": "2.2.1",
|
"serve-static": "2.2.1",
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
"@babel/eslint-parser": "7.28.5",
|
"@babel/eslint-parser": "7.28.5",
|
||||||
"@babel/preset-env": "7.28.5",
|
"@babel/preset-env": "7.28.5",
|
||||||
"@babel/preset-react": "7.28.5",
|
"@babel/preset-react": "7.28.5",
|
||||||
"chai": "6.2.1",
|
"chai": "6.2.2",
|
||||||
"eslint": "9.39.2",
|
"eslint": "9.39.2",
|
||||||
"eslint-config-prettier": "10.1.8",
|
"eslint-config-prettier": "10.1.8",
|
||||||
"eslint-plugin-react": "7.37.5",
|
"eslint-plugin-react": "7.37.5",
|
||||||
|
|||||||
@@ -8,17 +8,21 @@ import esmock from 'esmock';
|
|||||||
|
|
||||||
describe('services/storage/backupRestoreService.js - precheck & filename', () => {
|
describe('services/storage/backupRestoreService.js - precheck & filename', () => {
|
||||||
let svc;
|
let svc;
|
||||||
let admZipMock;
|
let setZipState;
|
||||||
let calls;
|
let calls;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
calls = { logger: { info: [], warn: [], error: [] } };
|
calls = { logger: { info: [], warn: [], error: [] } };
|
||||||
|
|
||||||
// Mock AdmZip with configurable state
|
// Mock AdmZip with configurable state via globalThis (avoid esmock export name pitfalls)
|
||||||
let state = { hasDb: false, meta: null };
|
globalThis.__ADM_ZIP_STATE__ = { hasDb: false, meta: null };
|
||||||
|
setZipState = (s) => {
|
||||||
|
globalThis.__ADM_ZIP_STATE__ = { ...globalThis.__ADM_ZIP_STATE__, ...s };
|
||||||
|
};
|
||||||
class MockAdmZip {
|
class MockAdmZip {
|
||||||
constructor() {}
|
constructor() {}
|
||||||
getEntry(name) {
|
getEntry(name) {
|
||||||
|
const state = globalThis.__ADM_ZIP_STATE__ || {};
|
||||||
if (name === 'listings.db') {
|
if (name === 'listings.db') {
|
||||||
if (state.hasDb) return { getData: () => Buffer.from('db') };
|
if (state.hasDb) return { getData: () => Buffer.from('db') };
|
||||||
return null;
|
return null;
|
||||||
@@ -30,12 +34,15 @@ describe('services/storage/backupRestoreService.js - precheck & filename', () =>
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
getEntries() {
|
getEntries() {
|
||||||
|
const state = globalThis.__ADM_ZIP_STATE__ || {};
|
||||||
const arr = [];
|
const arr = [];
|
||||||
if (state.hasDb) arr.push({ entryName: 'listings.db', getData: () => Buffer.from('db') });
|
if (state.hasDb) arr.push({ entryName: 'listings.db', getData: () => Buffer.from('db') });
|
||||||
return arr;
|
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 path = await import('node:path');
|
||||||
const ROOT = path.resolve('.');
|
const ROOT = path.resolve('.');
|
||||||
@@ -70,11 +77,13 @@ describe('services/storage/backupRestoreService.js - precheck & filename', () =>
|
|||||||
|
|
||||||
const utilsMock = { getPackageVersion: async () => '16.2.0' };
|
const utilsMock = { getPackageVersion: async () => '16.2.0' };
|
||||||
|
|
||||||
|
const admZipPath = path.join(ROOT, 'node_modules', 'adm-zip', 'adm-zip.js');
|
||||||
const mod = await esmock(
|
const mod = await esmock(
|
||||||
path.join(ROOT, 'lib', 'services', 'storage', 'backupRestoreService.js'),
|
path.join(ROOT, 'lib', 'services', 'storage', 'backupRestoreService.js'),
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
'adm-zip': admZipMock,
|
'adm-zip': admZipMock,
|
||||||
|
[admZipPath]: admZipMock,
|
||||||
[migratePath]: migrateMock,
|
[migratePath]: migrateMock,
|
||||||
[sqlitePath]: sqliteMock,
|
[sqlitePath]: sqliteMock,
|
||||||
[loggerPath]: loggerMock,
|
[loggerPath]: loggerMock,
|
||||||
@@ -94,7 +103,7 @@ describe('services/storage/backupRestoreService.js - precheck & filename', () =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('precheck: missing listings.db yields danger', async () => {
|
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'));
|
const res = await svc.precheckRestore(Buffer.from('dummy'));
|
||||||
expect(res.compatible).to.equal(false);
|
expect(res.compatible).to.equal(false);
|
||||||
expect(res.severity).to.equal('danger');
|
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 () => {
|
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'));
|
const res = await svc.precheckRestore(Buffer.from('zip'));
|
||||||
expect(res.compatible).to.equal(true);
|
expect(res.compatible).to.equal(true);
|
||||||
expect(res.severity).to.equal('warning');
|
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 () => {
|
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'));
|
const res = await svc.precheckRestore(Buffer.from('zip'));
|
||||||
expect(res.compatible).to.equal(true);
|
expect(res.compatible).to.equal(true);
|
||||||
expect(res.severity).to.equal('info');
|
expect(res.severity).to.equal('info');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('precheck: newer backup yields danger', async () => {
|
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'));
|
const res = await svc.precheckRestore(Buffer.from('zip'));
|
||||||
expect(res.compatible).to.equal(false);
|
expect(res.compatible).to.equal(false);
|
||||||
expect(res.severity).to.equal('danger');
|
expect(res.severity).to.equal('danger');
|
||||||
|
|||||||
124
test/services/jobs/jobExecutionService.test.js
Normal file
124
test/services/jobs/jobExecutionService.test.js
Normal 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']));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 providerConfig = JSON.parse(await readFile(new URL('./provider/testProvider.json', import.meta.url)));
|
||||||
|
|
||||||
export const mockFredy = async () => {
|
export const mockFredy = async () => {
|
||||||
return await esmock('../lib/FredyPipeline', {
|
return await esmock('../lib/FredyPipelineExecutioner', {
|
||||||
'../lib/services/storage/listingsStorage.js': {
|
'../lib/services/storage/listingsStorage.js': {
|
||||||
...mockStore,
|
...mockStore,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ export default function FredyApp() {
|
|||||||
if (!needsLogin()) {
|
if (!needsLogin()) {
|
||||||
await actions.features.getFeatures();
|
await actions.features.getFeatures();
|
||||||
await actions.provider.getProvider();
|
await actions.provider.getProvider();
|
||||||
await actions.jobs.getJobs();
|
await actions.jobsData.getJobs();
|
||||||
await actions.jobs.getSharableUserList();
|
await actions.jobsData.getSharableUserList();
|
||||||
await actions.notificationAdapter.getAdapter();
|
await actions.notificationAdapter.getAdapter();
|
||||||
await actions.generalSettings.getGeneralSettings();
|
await actions.generalSettings.getGeneralSettings();
|
||||||
await actions.versionUpdate.getVersionUpdate();
|
await actions.versionUpdate.getVersionUpdate();
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
@color-blue-text: #60a5fa;
|
@color-blue-text: #60a5fa;
|
||||||
|
|
||||||
@color-orange-bg: rgba(250, 91, 5, 0.12);
|
@color-orange-bg: rgba(250, 91, 5, 0.12);
|
||||||
@color-orange-border: #d33601;
|
@color-orange-border: #992f0c;
|
||||||
@color-orange-text: #FB923CFF;
|
@color-orange-text: #FB923CFF;
|
||||||
|
|
||||||
@color-green-bg: rgba(38, 250, 5, 0.12);
|
@color-green-bg: rgba(38, 250, 5, 0.12);
|
||||||
@color-green-border: #00c316;
|
@color-green-border: #278832;
|
||||||
@color-green-text: #33f308;
|
@color-green-text: #33f308;
|
||||||
|
|
||||||
@color-purple-bg: rgba(91, 3, 218, 0.38);
|
@color-purple-bg: rgba(91, 3, 218, 0.38);
|
||||||
|
|||||||
402
ui/src/components/grid/jobs/JobGrid.jsx
Normal file
402
ui/src/components/grid/jobs/JobGrid.jsx
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
Row,
|
||||||
|
Button,
|
||||||
|
Space,
|
||||||
|
Typography,
|
||||||
|
Divider,
|
||||||
|
Switch,
|
||||||
|
Popover,
|
||||||
|
Tag,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Pagination,
|
||||||
|
Toast,
|
||||||
|
Empty,
|
||||||
|
} from '@douyinfe/semi-ui';
|
||||||
|
import {
|
||||||
|
IconAlertTriangle,
|
||||||
|
IconDelete,
|
||||||
|
IconDescend2,
|
||||||
|
IconEdit,
|
||||||
|
IconPlayCircle,
|
||||||
|
IconBriefcase,
|
||||||
|
IconBell,
|
||||||
|
IconSearch,
|
||||||
|
IconFilter,
|
||||||
|
IconPlusCircle,
|
||||||
|
} from '@douyinfe/semi-icons';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useActions, useSelector } from '../../../services/state/store.js';
|
||||||
|
import { xhrDelete, xhrPut, xhrPost } from '../../../services/xhr.js';
|
||||||
|
import debounce from 'lodash/debounce';
|
||||||
|
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||||
|
|
||||||
|
import './JobGrid.less';
|
||||||
|
|
||||||
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
|
const getPopoverContent = (text) => <article className="jobPopoverContent">{text}</article>;
|
||||||
|
|
||||||
|
const JobGrid = () => {
|
||||||
|
const jobsData = useSelector((state) => state.jobsData);
|
||||||
|
const actions = useActions();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const pageSize = 12;
|
||||||
|
|
||||||
|
const [sortField, setSortField] = useState('name');
|
||||||
|
const [sortDir, setSortDir] = useState('asc');
|
||||||
|
const [freeTextFilter, setFreeTextFilter] = useState(null);
|
||||||
|
const [activityFilter, setActivityFilter] = useState(null);
|
||||||
|
const [showFilterBar, setShowFilterBar] = useState(false);
|
||||||
|
|
||||||
|
const pendingJobIdRef = useRef(null);
|
||||||
|
const evtSourceRef = useRef(null);
|
||||||
|
|
||||||
|
const loadData = () => {
|
||||||
|
actions.jobsData.getJobsData({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
sortfield: sortField,
|
||||||
|
sortdir: sortDir,
|
||||||
|
freeTextFilter,
|
||||||
|
filter: { activityFilter },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [page, sortField, sortDir, freeTextFilter, activityFilter]);
|
||||||
|
|
||||||
|
// SSE connection for live job status updates
|
||||||
|
useEffect(() => {
|
||||||
|
// establish SSE connection
|
||||||
|
const src = new EventSource('/api/jobs/events');
|
||||||
|
evtSourceRef.current = src;
|
||||||
|
|
||||||
|
const onJobStatus = (e) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data || '{}');
|
||||||
|
if (data && data.jobId) {
|
||||||
|
actions.jobsData.setJobRunning(data.jobId, !!data.running);
|
||||||
|
// notify finish if it was triggered by this view
|
||||||
|
if (pendingJobIdRef.current === data.jobId && data.running === false) {
|
||||||
|
Toast.success('Job finished');
|
||||||
|
pendingJobIdRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore malformed events
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
src.addEventListener('jobStatus', onJobStatus);
|
||||||
|
src.onerror = () => {
|
||||||
|
// Let browser auto-reconnect
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
try {
|
||||||
|
src.removeEventListener('jobStatus', onJobStatus);
|
||||||
|
src.close();
|
||||||
|
} catch {
|
||||||
|
//noop
|
||||||
|
}
|
||||||
|
evtSourceRef.current = null;
|
||||||
|
pendingJobIdRef.current = null;
|
||||||
|
};
|
||||||
|
}, [actions.jobsData]);
|
||||||
|
|
||||||
|
const handleFilterChange = useMemo(() => debounce((value) => setFreeTextFilter(value), 500), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
handleFilterChange.cancel && handleFilterChange.cancel();
|
||||||
|
};
|
||||||
|
}, [handleFilterChange]);
|
||||||
|
|
||||||
|
const onJobRemoval = async (jobId) => {
|
||||||
|
try {
|
||||||
|
await xhrDelete('/api/jobs', { jobId });
|
||||||
|
Toast.success('Job successfully removed');
|
||||||
|
loadData();
|
||||||
|
actions.jobsData.getJobs(); // refresh select list too
|
||||||
|
} catch (error) {
|
||||||
|
Toast.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onListingRemoval = async (jobId) => {
|
||||||
|
try {
|
||||||
|
await xhrDelete('/api/listings/job', { jobId });
|
||||||
|
Toast.success('Listings successfully removed');
|
||||||
|
loadData();
|
||||||
|
} catch (error) {
|
||||||
|
Toast.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onJobStatusChanged = async (jobId, status) => {
|
||||||
|
try {
|
||||||
|
await xhrPut(`/api/jobs/${jobId}/status`, { status });
|
||||||
|
Toast.success('Job status successfully changed');
|
||||||
|
loadData();
|
||||||
|
} catch (error) {
|
||||||
|
Toast.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onJobRun = async (jobId) => {
|
||||||
|
try {
|
||||||
|
const response = await xhrPost(`/api/jobs/${jobId}/run`);
|
||||||
|
if (response.status === 202) {
|
||||||
|
Toast.success('Job run started');
|
||||||
|
} else {
|
||||||
|
Toast.info('Job run requested');
|
||||||
|
}
|
||||||
|
pendingJobIdRef.current = jobId;
|
||||||
|
loadData();
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.status === 409) {
|
||||||
|
Toast.warning(error?.json?.message || 'Job is already running');
|
||||||
|
} else if (error?.status === 403) {
|
||||||
|
Toast.error('You are not allowed to run this job');
|
||||||
|
} else if (error?.status === 404) {
|
||||||
|
Toast.error('Job not found');
|
||||||
|
} else {
|
||||||
|
Toast.error('Failed to trigger job');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageChange = (_page) => {
|
||||||
|
setPage(_page);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="jobGrid">
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Button
|
||||||
|
style={{ width: '7rem', margin: 0 }}
|
||||||
|
type="primary"
|
||||||
|
icon={<IconPlusCircle />}
|
||||||
|
className="jobs__newButton"
|
||||||
|
onClick={() => navigate('/jobs/new')}
|
||||||
|
>
|
||||||
|
New Job
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="jobGrid__searchbar">
|
||||||
|
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} />
|
||||||
|
<Popover content="Filter / Sort Results" style={{ color: 'white', padding: '.5rem' }}>
|
||||||
|
<Button
|
||||||
|
icon={<IconFilter />}
|
||||||
|
onClick={() => {
|
||||||
|
setShowFilterBar(!showFilterBar);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showFilterBar && (
|
||||||
|
<div className="jobGrid__toolbar">
|
||||||
|
<Space wrap style={{ marginBottom: '1rem' }}>
|
||||||
|
<div className="jobGrid__toolbar__card">
|
||||||
|
<div>
|
||||||
|
<Text strong>Filter by:</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '.3rem' }}>
|
||||||
|
<Select
|
||||||
|
placeholder="Status"
|
||||||
|
showClear
|
||||||
|
onChange={(val) => setActivityFilter(val)}
|
||||||
|
value={activityFilter}
|
||||||
|
style={{ width: 140 }}
|
||||||
|
>
|
||||||
|
<Select.Option value={true}>Active</Select.Option>
|
||||||
|
<Select.Option value={false}>Not Active</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Divider layout="vertical" />
|
||||||
|
<div className="jobGrid__toolbar__card">
|
||||||
|
<div>
|
||||||
|
<Text strong>Sort by:</Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '.3rem' }}>
|
||||||
|
<Select
|
||||||
|
placeholder="Sort By"
|
||||||
|
style={{ width: 160 }}
|
||||||
|
value={sortField}
|
||||||
|
onChange={(val) => setSortField(val)}
|
||||||
|
>
|
||||||
|
<Select.Option value="name">Name</Select.Option>
|
||||||
|
<Select.Option value="numberOfFoundListings">Number of Listings</Select.Option>
|
||||||
|
<Select.Option value="enabled">Status</Select.Option>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
placeholder="Direction"
|
||||||
|
style={{ width: 120 }}
|
||||||
|
value={sortDir}
|
||||||
|
onChange={(val) => setSortDir(val)}
|
||||||
|
>
|
||||||
|
<Select.Option value="asc">Ascending</Select.Option>
|
||||||
|
<Select.Option value="desc">Descending</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(jobsData?.result || []).length === 0 && (
|
||||||
|
<Empty
|
||||||
|
image={<IllustrationNoResult />}
|
||||||
|
darkModeImage={<IllustrationNoResultDark />}
|
||||||
|
description="No jobs available yet..."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
{(jobsData?.result || []).map((job) => (
|
||||||
|
<Col key={job.id} xs={24} sm={12} md={8} lg={6} xl={4} xxl={6}>
|
||||||
|
<Card
|
||||||
|
className="jobGrid__card"
|
||||||
|
bodyStyle={{ padding: '16px' }}
|
||||||
|
headerLine={true}
|
||||||
|
title={
|
||||||
|
<div className="jobGrid__header">
|
||||||
|
<Title heading={5} ellipsis={{ showTooltip: true }} className="jobGrid__title">
|
||||||
|
{job.name}
|
||||||
|
</Title>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
{job.isOnlyShared && (
|
||||||
|
<Popover
|
||||||
|
content={getPopoverContent(
|
||||||
|
'This job has been shared with you by another user, therefor it is read-only.',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)', marginLeft: '8px' }} />
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{job.running && (
|
||||||
|
<Tag color="green" variant="light" size="small">
|
||||||
|
RUNNING
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="jobGrid__content">
|
||||||
|
<Space vertical align="start" spacing={4} style={{ width: '100%', marginTop: 12 }}>
|
||||||
|
<div className="jobGrid__infoItem">
|
||||||
|
<Text type="secondary" icon={<IconSearch />} size="small">
|
||||||
|
Is active:
|
||||||
|
</Text>
|
||||||
|
<Switch
|
||||||
|
onChange={(checked) => onJobStatusChanged(job.id, checked)}
|
||||||
|
style={{ marginLeft: 'auto' }}
|
||||||
|
checked={job.enabled}
|
||||||
|
disabled={job.isOnlyShared}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="jobGrid__infoItem">
|
||||||
|
<Text type="secondary" icon={<IconSearch />} size="small">
|
||||||
|
Listings:
|
||||||
|
</Text>
|
||||||
|
<Tag color="blue" size="small" style={{ marginLeft: 'auto' }}>
|
||||||
|
{job.numberOfFoundListings || 0}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
<div className="jobGrid__infoItem">
|
||||||
|
<Text type="secondary" icon={<IconBriefcase />} size="small">
|
||||||
|
Providers:
|
||||||
|
</Text>
|
||||||
|
<Tag color="cyan" size="small" style={{ marginLeft: 'auto' }}>
|
||||||
|
{job.provider.length || 0}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
<div className="jobGrid__infoItem">
|
||||||
|
<Text type="secondary" icon={<IconBell />} size="small">
|
||||||
|
Adapters:
|
||||||
|
</Text>
|
||||||
|
<Tag color="purple" size="small" style={{ marginLeft: 'auto' }}>
|
||||||
|
{job.notificationAdapter.length || 0}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Divider margin="12px" />
|
||||||
|
|
||||||
|
<div className="jobGrid__actions">
|
||||||
|
<Popover content={getPopoverContent('Run Job')}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
theme="solid"
|
||||||
|
icon={<IconPlayCircle />}
|
||||||
|
disabled={job.isOnlyShared || job.running}
|
||||||
|
onClick={() => onJobRun(job.id)}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
<Popover content={getPopoverContent('Edit a Job')}>
|
||||||
|
<Button
|
||||||
|
type="secondary"
|
||||||
|
theme="solid"
|
||||||
|
icon={<IconEdit />}
|
||||||
|
disabled={job.isOnlyShared}
|
||||||
|
onClick={() => navigate(`/jobs/edit/${job.id}`)}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
<Popover content={getPopoverContent('Delete all found Listings of this Job')}>
|
||||||
|
<Button
|
||||||
|
type="danger"
|
||||||
|
theme="solid"
|
||||||
|
icon={<IconDescend2 />}
|
||||||
|
disabled={job.isOnlyShared}
|
||||||
|
onClick={() => onListingRemoval(job.id)}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
<Popover content={getPopoverContent('Delete Job')}>
|
||||||
|
<Button
|
||||||
|
type="danger"
|
||||||
|
theme="solid"
|
||||||
|
icon={<IconDelete />}
|
||||||
|
disabled={job.isOnlyShared}
|
||||||
|
onClick={() => onJobRemoval(job.id)}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
{(jobsData?.result || []).length > 0 && jobsData?.totalNumber > 12 && (
|
||||||
|
<div className="jobGrid__pagination">
|
||||||
|
<Pagination
|
||||||
|
currentPage={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={jobsData?.totalNumber || 0}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
showSizeChanger={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default JobGrid;
|
||||||
69
ui/src/components/grid/jobs/JobGrid.less
Normal file
69
ui/src/components/grid/jobs/JobGrid.less
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
.jobGrid {
|
||||||
|
&__card {
|
||||||
|
height: 100%;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: var(--semi-shadow-elevated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__searchbar {
|
||||||
|
display: flex;
|
||||||
|
gap: .5rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__toolbar {
|
||||||
|
&__card {
|
||||||
|
border-radius: 5px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .3rem;
|
||||||
|
background: #232429;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__infoItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.semi-typography {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__pagination {
|
||||||
|
margin-top: 2rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.jobPopoverContent {
|
||||||
|
padding: .4rem;
|
||||||
|
color: var(--semi-color-white);
|
||||||
|
}
|
||||||
324
ui/src/components/grid/listings/ListingsGrid.jsx
Normal file
324
ui/src/components/grid/listings/ListingsGrid.jsx
Normal 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;
|
||||||
106
ui/src/components/grid/listings/ListingsGrid.less
Normal file
106
ui/src/components/grid/listings/ListingsGrid.less
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
.listingsTable {
|
|
||||||
&__search {
|
|
||||||
margin-bottom: 1rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__expanded {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__toolbar {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__setupButton {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -83,23 +83,59 @@ export const useFredyState = create(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
jobs: {
|
jobsData: {
|
||||||
async getJobs() {
|
async getJobs() {
|
||||||
try {
|
try {
|
||||||
const response = await xhrGet('/api/jobs');
|
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) {
|
} catch (Exception) {
|
||||||
console.error(`Error while trying to get resource for api/jobs. Error:`, 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() {
|
async getSharableUserList() {
|
||||||
try {
|
try {
|
||||||
const response = await xhrGet('/api/jobs/shareableUserList');
|
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) {
|
} catch (Exception) {
|
||||||
console.error(`Error while trying to get resource for api/jobs. Error:`, 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: {
|
user: {
|
||||||
async getUsers() {
|
async getUsers() {
|
||||||
@@ -143,8 +179,8 @@ export const useFredyState = create(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
listingsTable: {
|
listingsData: {
|
||||||
async getListingsTable({
|
async getListingsData({
|
||||||
page = 1,
|
page = 1,
|
||||||
pageSize = 20,
|
pageSize = 20,
|
||||||
freeTextFilter = null,
|
freeTextFilter = null,
|
||||||
@@ -163,7 +199,7 @@ export const useFredyState = create(
|
|||||||
});
|
});
|
||||||
const response = await xhrGet(`/api/listings/table?${qryString}`);
|
const response = await xhrGet(`/api/listings/table?${qryString}`);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
listingsTable: { ...state.listingsTable, ...response.json },
|
listingsData: { ...state.listingsData, ...response.json },
|
||||||
}));
|
}));
|
||||||
} catch (Exception) {
|
} catch (Exception) {
|
||||||
console.error('Error while trying to get resource for api/listings. Error:', Exception);
|
console.error('Error while trying to get resource for api/listings. Error:', Exception);
|
||||||
@@ -176,7 +212,7 @@ export const useFredyState = create(
|
|||||||
const initial = {
|
const initial = {
|
||||||
dashboard: { data: null },
|
dashboard: { data: null },
|
||||||
notificationAdapter: [],
|
notificationAdapter: [],
|
||||||
listingsTable: {
|
listingsData: {
|
||||||
totalNumber: 0,
|
totalNumber: 0,
|
||||||
page: 1,
|
page: 1,
|
||||||
result: [],
|
result: [],
|
||||||
@@ -186,7 +222,13 @@ export const useFredyState = create(
|
|||||||
demoMode: { demoMode: false },
|
demoMode: { demoMode: false },
|
||||||
versionUpdate: {},
|
versionUpdate: {},
|
||||||
provider: [],
|
provider: [],
|
||||||
jobs: { jobs: [], shareableUserList: [] },
|
jobsData: {
|
||||||
|
jobs: [],
|
||||||
|
shareableUserList: [],
|
||||||
|
totalNumber: 0,
|
||||||
|
page: 1,
|
||||||
|
result: [],
|
||||||
|
},
|
||||||
user: { users: [], currentUser: null },
|
user: { users: [], currentUser: null },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -197,10 +239,10 @@ export const useFredyState = create(
|
|||||||
generalSettings: { ...effects.generalSettings },
|
generalSettings: { ...effects.generalSettings },
|
||||||
demoMode: { ...effects.demoMode },
|
demoMode: { ...effects.demoMode },
|
||||||
versionUpdate: { ...effects.versionUpdate },
|
versionUpdate: { ...effects.versionUpdate },
|
||||||
listingsTable: { ...effects.listingsTable },
|
listingsData: { ...effects.listingsData },
|
||||||
provider: { ...effects.provider },
|
provider: { ...effects.provider },
|
||||||
features: { ...effects.features },
|
features: { ...effects.features },
|
||||||
jobs: { ...effects.jobs },
|
jobsData: { ...effects.jobsData },
|
||||||
user: { ...effects.user },
|
user: { ...effects.user },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,69 +5,13 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import JobTable from '../../components/table/JobTable';
|
import JobGrid from '../../components/grid/jobs/JobGrid.jsx';
|
||||||
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 './Jobs.less';
|
import './Jobs.less';
|
||||||
|
|
||||||
export default function Jobs() {
|
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 (
|
return (
|
||||||
<div>
|
<div className="jobs">
|
||||||
<div>
|
<JobGrid />
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ import {
|
|||||||
} from '@douyinfe/semi-icons';
|
} from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
export default function JobMutator() {
|
export default function JobMutator() {
|
||||||
const jobs = useSelector((state) => state.jobs.jobs);
|
const jobs = useSelector((state) => state.jobsData.jobs);
|
||||||
const shareableUserList = useSelector((state) => state.jobs.shareableUserList);
|
const shareableUserList = useSelector((state) => state.jobsData.shareableUserList);
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|
||||||
const jobToBeEdit = params.jobId == null ? null : jobs.find((job) => job.id === params.jobId);
|
const jobToBeEdit = params.jobId == null ? null : jobs.find((job) => job.id === params.jobId);
|
||||||
@@ -73,7 +73,7 @@ export default function JobMutator() {
|
|||||||
enabled,
|
enabled,
|
||||||
jobId: jobToBeEdit?.id || null,
|
jobId: jobToBeEdit?.id || null,
|
||||||
});
|
});
|
||||||
await actions.jobs.getJobs();
|
await actions.jobsData.getJobs();
|
||||||
Toast.success('Job successfully saved...');
|
Toast.success('Job successfully saved...');
|
||||||
navigate('/jobs');
|
navigate('/jobs');
|
||||||
} catch (Exception) {
|
} catch (Exception) {
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export default function NotificationAdapterMutator({
|
|||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
field={uiElement.label}
|
field={uiElement.label}
|
||||||
type={uiElement.type}
|
type={uiElement.type}
|
||||||
value={uiElement.value || ''}
|
initValue={uiElement.value ?? ''}
|
||||||
placeholder={uiElement.label}
|
placeholder={uiElement.label}
|
||||||
label={uiElement.label}
|
label={uiElement.label}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import ListingsTable from '../../components/table/listings/ListingsTable.jsx';
|
import ListingsGrid from '../../components/grid/listings/ListingsGrid.jsx';
|
||||||
|
|
||||||
export default function Listings() {
|
export default function Listings() {
|
||||||
return <ListingsTable />;
|
return <ListingsGrid />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const Users = function Users() {
|
|||||||
await xhrDelete('/api/admin/users', { userId: userIdToBeRemoved });
|
await xhrDelete('/api/admin/users', { userId: userIdToBeRemoved });
|
||||||
Toast.success('User successfully remove');
|
Toast.success('User successfully remove');
|
||||||
setUserIdToBeRemoved(null);
|
setUserIdToBeRemoved(null);
|
||||||
await actions.jobs.getJobs();
|
await actions.jobsData.getJobs();
|
||||||
await actions.user.getUsers();
|
await actions.user.getUsers();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Toast.error(error);
|
Toast.error(error);
|
||||||
|
|||||||
158
yarn.lock
158
yarn.lock
@@ -997,34 +997,34 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
tslib "^2.0.0"
|
tslib "^2.0.0"
|
||||||
|
|
||||||
"@douyinfe/semi-animation-react@2.89.0":
|
"@douyinfe/semi-animation-react@2.90.3":
|
||||||
version "2.89.0"
|
version "2.90.3"
|
||||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-react/-/semi-animation-react-2.89.0.tgz#be95b42a928ffe60b54d688dcf1d0f65e81b5bcc"
|
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-react/-/semi-animation-react-2.90.3.tgz#dc55e082febfdae38c42caefb9928b1bb853647a"
|
||||||
integrity sha512-6GSQMF2bIoWN2Bua4wYGCe//ltfE1/iNQRMF7+TybVMz9kBJU0gelFsvxxVnqpka994RuTvhe73CSWWdpLwjng==
|
integrity sha512-iivpAVysJqKUmpRp4gqXb/+x7A4DOWXNv8lNM841CC2EVpCnFh4yOgXvxYCELD1V70RcXcXMTOGwAukCO1HkCA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@douyinfe/semi-animation" "2.89.0"
|
"@douyinfe/semi-animation" "2.90.3"
|
||||||
"@douyinfe/semi-animation-styled" "2.89.0"
|
"@douyinfe/semi-animation-styled" "2.90.3"
|
||||||
classnames "^2.2.6"
|
classnames "^2.2.6"
|
||||||
|
|
||||||
"@douyinfe/semi-animation-styled@2.89.0":
|
"@douyinfe/semi-animation-styled@2.90.3":
|
||||||
version "2.89.0"
|
version "2.90.3"
|
||||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-styled/-/semi-animation-styled-2.89.0.tgz#72cd09f73abf5198bcfb47f6254c0f2799c146b2"
|
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-styled/-/semi-animation-styled-2.90.3.tgz#6a5758af4540b482fd9a5db4960cac0511fb1f27"
|
||||||
integrity sha512-y1wXswseGbJpPh3hJQ9aNjnMzecLh9eUERmSpQaWbDSdrzk65hBa91MMC2rk/wlIN0/Q6OAKU8FcMoSiBiuI0Q==
|
integrity sha512-PQfNfOWOKiKHcViJOQipPRYYLFQNhzdFn9O84Rky9KzhrkH5/6NsNPMKIPfIUNN15qNI1gQMPP2mQxRAXJQTNg==
|
||||||
|
|
||||||
"@douyinfe/semi-animation@2.89.0":
|
"@douyinfe/semi-animation@2.90.3":
|
||||||
version "2.89.0"
|
version "2.90.3"
|
||||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation/-/semi-animation-2.89.0.tgz#a30de59827f6b8452100a5dd2a828aebe8cb86ec"
|
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation/-/semi-animation-2.90.3.tgz#197a12e1b100e8d15dbf7c7e858ce828938837cc"
|
||||||
integrity sha512-y6an913b841V0BAdR5qSLYvoK5C2OAbNKImzM+FzWmbRQjzbOEYcF3bqi5AZhY4mYk7v05k2W7U6fmaXYNOS1Q==
|
integrity sha512-NcLXV2KDpgxHbqS+/d1KAkYJVmSlV0n1gRwB3diM6EgOaGLZHGNXMCVBuN7mss9f2sF6GTTJRwcjH3VdcV5E9g==
|
||||||
dependencies:
|
dependencies:
|
||||||
bezier-easing "^2.1.0"
|
bezier-easing "^2.1.0"
|
||||||
|
|
||||||
"@douyinfe/semi-foundation@2.89.0":
|
"@douyinfe/semi-foundation@2.90.3":
|
||||||
version "2.89.0"
|
version "2.90.3"
|
||||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-foundation/-/semi-foundation-2.89.0.tgz#299b10ecb92289bd4158471d91a08dfc461b7ef2"
|
resolved "https://registry.yarnpkg.com/@douyinfe/semi-foundation/-/semi-foundation-2.90.3.tgz#f75b51bd6f7bf3d8da3d9a3ed99aa0e44a2d3626"
|
||||||
integrity sha512-Ryc2XywB3BVoUHETp5e7cY9x/ccweeKyCjqw/dcM16txeSpGxW7p1ykexGHRl3+dz1QcVrU4vp/ELD6GutC0Sg==
|
integrity sha512-p9dtZbvkLtMKQturVwxtlJqfIOzHHN8ujaVgR/T9bS1ojzW+3Nua22yTkl7baLKUk/fvS/841BP3aHNXUmk71A==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@douyinfe/semi-animation" "2.89.0"
|
"@douyinfe/semi-animation" "2.90.3"
|
||||||
"@douyinfe/semi-json-viewer-core" "2.89.0"
|
"@douyinfe/semi-json-viewer-core" "2.90.3"
|
||||||
"@mdx-js/mdx" "^3.0.1"
|
"@mdx-js/mdx" "^3.0.1"
|
||||||
async-validator "^3.5.0"
|
async-validator "^3.5.0"
|
||||||
classnames "^2.2.6"
|
classnames "^2.2.6"
|
||||||
@@ -1038,44 +1038,44 @@
|
|||||||
remark-gfm "^4.0.0"
|
remark-gfm "^4.0.0"
|
||||||
scroll-into-view-if-needed "^2.2.24"
|
scroll-into-view-if-needed "^2.2.24"
|
||||||
|
|
||||||
"@douyinfe/semi-icons@2.89.0", "@douyinfe/semi-icons@^2.89.0":
|
"@douyinfe/semi-icons@2.90.3", "@douyinfe/semi-icons@^2.90.3":
|
||||||
version "2.89.0"
|
version "2.90.3"
|
||||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-icons/-/semi-icons-2.89.0.tgz#c9252981fde29668e3a88862948d09f71360a4fa"
|
resolved "https://registry.yarnpkg.com/@douyinfe/semi-icons/-/semi-icons-2.90.3.tgz#9dc40a743951aed7ea5a709adc248f89a97ec3d9"
|
||||||
integrity sha512-LfUhh/S0+3bOdD7jy1xg5F1y6mXrYtDiIsA1Hmuhy3zhNSpSKSwfqPiV3IxwRRmGXFWjgiSefKd99h5OmKMPHg==
|
integrity sha512-1zeBiBtpnRTblJo1OWTRv1W0ULYdAb4XAF9K4AF7YSnYw+jvHn0zulaQyBqGsuCKeJlfOaCKSui/5HjDt1RgJg==
|
||||||
dependencies:
|
dependencies:
|
||||||
classnames "^2.2.6"
|
classnames "^2.2.6"
|
||||||
|
|
||||||
"@douyinfe/semi-illustrations@2.89.0":
|
"@douyinfe/semi-illustrations@2.90.3":
|
||||||
version "2.89.0"
|
version "2.90.3"
|
||||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-illustrations/-/semi-illustrations-2.89.0.tgz#93611cfa572d79eb4bd50a1e13ee416161e6f41f"
|
resolved "https://registry.yarnpkg.com/@douyinfe/semi-illustrations/-/semi-illustrations-2.90.3.tgz#03cb5f2edb909fe0f11ef907f4061597053ca7d3"
|
||||||
integrity sha512-yAU4sSHr236E7ygTlwxupQkeF/W7EtfrUfRx3NUdGWuswMPAICz7d6Upa0XAZoCJ4skBZ5ItcQq9FfM+pw4wKg==
|
integrity sha512-RXTVyAwRNiFEa9jXSGANCpm8aN2K4iU1knffmVNaRirGmMc5h6b8Uq7ZkVezizegSW6ETtucpFa2NmJX53MpPg==
|
||||||
|
|
||||||
"@douyinfe/semi-json-viewer-core@2.89.0":
|
"@douyinfe/semi-json-viewer-core@2.90.3":
|
||||||
version "2.89.0"
|
version "2.90.3"
|
||||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-json-viewer-core/-/semi-json-viewer-core-2.89.0.tgz#4254ce7d36c24f70267980f8e8a42faf6757f502"
|
resolved "https://registry.yarnpkg.com/@douyinfe/semi-json-viewer-core/-/semi-json-viewer-core-2.90.3.tgz#eb38c59377dd2954194f8474938bcea106e6e267"
|
||||||
integrity sha512-BGMJgg+tBFcwg3/7aJmtIXaHW+tSA6Tae3UfyhLYjUxcl6cFtYjtN0DAGwoia9KzUdNHoSAhl3GJVtGCBsmApQ==
|
integrity sha512-18d2TuNdwXPt08b+ODiBSehyuKrvdZuF+2hVYnL3R7KRDCdd/BMLbhqPmr9IA92aP3u/TQKjRjIKhgpqw6XCQw==
|
||||||
dependencies:
|
dependencies:
|
||||||
jsonc-parser "^3.3.1"
|
jsonc-parser "^3.3.1"
|
||||||
|
|
||||||
"@douyinfe/semi-theme-default@2.89.0":
|
"@douyinfe/semi-theme-default@2.90.3":
|
||||||
version "2.89.0"
|
version "2.90.3"
|
||||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-theme-default/-/semi-theme-default-2.89.0.tgz#eb1fed1939a16fb903f6845867007020d76a503b"
|
resolved "https://registry.yarnpkg.com/@douyinfe/semi-theme-default/-/semi-theme-default-2.90.3.tgz#a2ebde9d37bccb8ceec3ed9fdde0d18b573515f7"
|
||||||
integrity sha512-mdL6Ui1XMGW9L5tYl9uG3MnmyHaIXnRVa7b4PB0Y8kfFGAVzls5XBjZT5ACVtwxlVZrU5BrJBFOXGER5p1FVDg==
|
integrity sha512-DfHJ/3jHaTMJfcuer8LTcrh84Vxixberpk0DyvzgwVD1T+zAOppElwWq0tGDc5lnDHMgCY4R9W4b0aUcLCB2qA==
|
||||||
|
|
||||||
"@douyinfe/semi-ui@2.89.0":
|
"@douyinfe/semi-ui@2.90.3":
|
||||||
version "2.89.0"
|
version "2.90.3"
|
||||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-ui/-/semi-ui-2.89.0.tgz#563adb7f33b9d888a882573024df2296be3c8bf4"
|
resolved "https://registry.yarnpkg.com/@douyinfe/semi-ui/-/semi-ui-2.90.3.tgz#465b0d94ae5316a57c8dd12a0c8d4c47061e2329"
|
||||||
integrity sha512-XZ2yo2TgGWk8ubukJq7zbpKePpswQRq3nxeBlmL39SEben8AUfEq92vus0Hcmua5Y2wgi6TY2qWPgo+WEZCrkQ==
|
integrity sha512-Ojrj2y6G/79A85khqP8IinZaPFIIC3kWO263ppYQm/3Vb+yvg1ih1XIFNkPrhqha4Hybs1g+1LxKhjdvkhURRg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@dnd-kit/core" "^6.0.8"
|
"@dnd-kit/core" "^6.0.8"
|
||||||
"@dnd-kit/sortable" "^7.0.2"
|
"@dnd-kit/sortable" "^7.0.2"
|
||||||
"@dnd-kit/utilities" "^3.2.1"
|
"@dnd-kit/utilities" "^3.2.1"
|
||||||
"@douyinfe/semi-animation" "2.89.0"
|
"@douyinfe/semi-animation" "2.90.3"
|
||||||
"@douyinfe/semi-animation-react" "2.89.0"
|
"@douyinfe/semi-animation-react" "2.90.3"
|
||||||
"@douyinfe/semi-foundation" "2.89.0"
|
"@douyinfe/semi-foundation" "2.90.3"
|
||||||
"@douyinfe/semi-icons" "2.89.0"
|
"@douyinfe/semi-icons" "2.90.3"
|
||||||
"@douyinfe/semi-illustrations" "2.89.0"
|
"@douyinfe/semi-illustrations" "2.90.3"
|
||||||
"@douyinfe/semi-theme-default" "2.89.0"
|
"@douyinfe/semi-theme-default" "2.90.3"
|
||||||
"@tiptap/core" "^3.10.7"
|
"@tiptap/core" "^3.10.7"
|
||||||
"@tiptap/extension-document" "^3.10.7"
|
"@tiptap/extension-document" "^3.10.7"
|
||||||
"@tiptap/extension-hard-break" "^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"
|
resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5"
|
||||||
integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==
|
integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==
|
||||||
|
|
||||||
chai@6.2.1:
|
chai@6.2.2:
|
||||||
version "6.2.1"
|
version "6.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/chai/-/chai-6.2.1.tgz#d1e64bc42433fbee6175ad5346799682060b5b6a"
|
resolved "https://registry.yarnpkg.com/chai/-/chai-6.2.2.tgz#ae41b52c9aca87734505362717f3255facda360e"
|
||||||
integrity sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==
|
integrity sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==
|
||||||
|
|
||||||
chalk@^4.0.0, chalk@^4.1.0:
|
chalk@^4.0.0, chalk@^4.1.0:
|
||||||
version "4.1.2"
|
version "4.1.2"
|
||||||
@@ -2363,10 +2363,10 @@ chownr@^1.1.1:
|
|||||||
resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b"
|
resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b"
|
||||||
integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==
|
integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==
|
||||||
|
|
||||||
chromium-bidi@11.0.0:
|
chromium-bidi@12.0.1:
|
||||||
version "11.0.0"
|
version "12.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/chromium-bidi/-/chromium-bidi-11.0.0.tgz#193433d0722095abca0cada2fa0c5111b447bea3"
|
resolved "https://registry.yarnpkg.com/chromium-bidi/-/chromium-bidi-12.0.1.tgz#3edb1420ab5a52004c10c223b928622c128b4f27"
|
||||||
integrity sha512-cM3DI+OOb89T3wO8cpPSro80Q9eKYJ7hGVXoGS3GkDPxnYSqiv+6xwpIf6XERyJ9Tdsl09hmNmY94BkgZdVekw==
|
integrity sha512-fGg+6jr0xjQhzpy5N4ErZxQ4wF7KLEvhGZXD6EgvZKDhu7iOhZXnZhcDxPJDcwTcrD48NPzOCo84RP2lv3Z+Cg==
|
||||||
dependencies:
|
dependencies:
|
||||||
mitt "^3.0.1"
|
mitt "^3.0.1"
|
||||||
zod "^3.24.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"
|
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
|
||||||
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
||||||
|
|
||||||
puppeteer-core@24.33.0:
|
puppeteer-core@24.34.0:
|
||||||
version "24.33.0"
|
version "24.34.0"
|
||||||
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.33.0.tgz#c3e6adee5fd5dd77895d4cc14e0d17461048917a"
|
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.34.0.tgz#00c7f63b4a83d4ca2ec5ea3a234588fb2ce7c994"
|
||||||
integrity sha512-tPTxVg+Qdj/8av4cy6szv3GlhxeOoNhiiMZ955fjxQyvPQE/6DjCa6ZyF/x0WJrlgBZtaLSP8TQgJb7FdLDXXA==
|
integrity sha512-24evawO+mUGW4mvS2a2ivwLdX3gk8zRLZr9HP+7+VT2vBQnm0oh9jJEZmUE3ePJhRkYlZ93i7OMpdcoi2qNCLg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@puppeteer/browsers" "2.11.0"
|
"@puppeteer/browsers" "2.11.0"
|
||||||
chromium-bidi "11.0.0"
|
chromium-bidi "12.0.1"
|
||||||
debug "^4.4.3"
|
debug "^4.4.3"
|
||||||
devtools-protocol "0.0.1534754"
|
devtools-protocol "0.0.1534754"
|
||||||
typed-query-selector "^2.12.0"
|
typed-query-selector "^2.12.0"
|
||||||
webdriver-bidi-protocol "0.3.9"
|
webdriver-bidi-protocol "0.3.10"
|
||||||
ws "^8.18.3"
|
ws "^8.18.3"
|
||||||
|
|
||||||
puppeteer-extra-plugin-stealth@^2.11.2:
|
puppeteer-extra-plugin-stealth@^2.11.2:
|
||||||
@@ -5934,16 +5934,16 @@ puppeteer-extra@^3.3.6:
|
|||||||
debug "^4.1.1"
|
debug "^4.1.1"
|
||||||
deepmerge "^4.2.2"
|
deepmerge "^4.2.2"
|
||||||
|
|
||||||
puppeteer@^24.33.0:
|
puppeteer@^24.34.0:
|
||||||
version "24.33.0"
|
version "24.34.0"
|
||||||
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.33.0.tgz#5d0aba25f0038db2a3a996deb8c157d1842e8a21"
|
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.34.0.tgz#061f6e97ce9511863ec83cd6f17a27253c68b5e9"
|
||||||
integrity sha512-nl3wsAztq5F8zybn4Tk41OCnYIzFIzGC6AN0WcF2KCUnWenajvRRPgBmS6LvNUV2HEeIzT2zRZHH0TgVxLDKew==
|
integrity sha512-Sdpl/zsYOsagZ4ICoZJPGZw8d9gZmK5DcxVal11dXi/1/t2eIXHjCf5NfmhDg5XnG9Nye+yo/LqMzIxie2rHTw==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@puppeteer/browsers" "2.11.0"
|
"@puppeteer/browsers" "2.11.0"
|
||||||
chromium-bidi "11.0.0"
|
chromium-bidi "12.0.1"
|
||||||
cosmiconfig "^9.0.0"
|
cosmiconfig "^9.0.0"
|
||||||
devtools-protocol "0.0.1534754"
|
devtools-protocol "0.0.1534754"
|
||||||
puppeteer-core "24.33.0"
|
puppeteer-core "24.34.0"
|
||||||
typed-query-selector "^2.12.0"
|
typed-query-selector "^2.12.0"
|
||||||
|
|
||||||
qs@^6.14.0:
|
qs@^6.14.0:
|
||||||
@@ -6033,17 +6033,17 @@ react-resizable@^3.0.5:
|
|||||||
prop-types "15.x"
|
prop-types "15.x"
|
||||||
react-draggable "^4.0.3"
|
react-draggable "^4.0.3"
|
||||||
|
|
||||||
react-router-dom@7.10.1:
|
react-router-dom@7.11.0:
|
||||||
version "7.10.1"
|
version "7.11.0"
|
||||||
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.10.1.tgz#fddea814d30a3630c11d9ea539932482ff6f744c"
|
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.11.0.tgz#2165f63e52798bd0eb138480c098ad058cdf3413"
|
||||||
integrity sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==
|
integrity sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==
|
||||||
dependencies:
|
dependencies:
|
||||||
react-router "7.10.1"
|
react-router "7.11.0"
|
||||||
|
|
||||||
react-router@7.10.1:
|
react-router@7.11.0:
|
||||||
version "7.10.1"
|
version "7.11.0"
|
||||||
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.10.1.tgz#e973146ed5f10a80783fdb3f27dbe37679557a7c"
|
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.11.0.tgz#d3b91567fdbe910caf9064ea69b7b4d9264f2945"
|
||||||
integrity sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==
|
integrity sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
cookie "^1.0.1"
|
cookie "^1.0.1"
|
||||||
set-cookie-parser "^2.6.0"
|
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"
|
resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b"
|
||||||
integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==
|
integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==
|
||||||
|
|
||||||
webdriver-bidi-protocol@0.3.9:
|
webdriver-bidi-protocol@0.3.10:
|
||||||
version "0.3.9"
|
version "0.3.10"
|
||||||
resolved "https://registry.yarnpkg.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.9.tgz#89abf021f2a557a2dd81772f9ce7172b01f8a0f0"
|
resolved "https://registry.yarnpkg.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.10.tgz#437405564ff7e200371468f4f1eba1ff5537e119"
|
||||||
integrity sha512-uIYvlRQ0PwtZR1EzHlTMol1G0lAlmOe6wPykF9a77AK3bkpvZHzIVxRE2ThOx5vjy2zISe0zhwf5rzuUfbo1PQ==
|
integrity sha512-5LAE43jAVLOhB/QqX4bwSiv0Hg1HBfMmOuwBSXHdvg4GMGu9Y0lIq7p4R/yySu6w74WmaR4GM4H9t2IwLW7hgw==
|
||||||
|
|
||||||
whatwg-encoding@^3.1.1:
|
whatwg-encoding@^3.1.1:
|
||||||
version "3.1.1"
|
version "3.1.1"
|
||||||
|
|||||||
Reference in New Issue
Block a user