Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2926ee7e08 | ||
|
|
9506d1a9db | ||
|
|
feaa06c132 | ||
|
|
ad46500d4e | ||
|
|
3c209a8f97 | ||
|
|
398259ff20 | ||
|
|
cf030bfa39 | ||
|
|
5dc976c7e3 | ||
|
|
05f1bc61c9 | ||
|
|
6e8a35a836 | ||
|
|
87771655a8 |
@@ -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
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 512 KiB After Width: | Height: | Size: 4.7 MiB |
|
Before Width: | Height: | Size: 372 KiB After Width: | Height: | Size: 402 KiB |
@@ -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
|
||||||
|
|||||||
63
index.js
@@ -4,21 +4,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
|
||||||
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 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();
|
||||||
@@ -37,12 +33,10 @@ 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 rawDir = settings.sqlitepath || '/db';
|
const { dir: sqliteDir } = await computeDbPath();
|
||||||
const relDir = rawDir.startsWith('/') ? rawDir.slice(1) : rawDir;
|
if (!fs.existsSync(sqliteDir)) {
|
||||||
const absDir = path.isAbsolute(relDir) ? relDir : path.join(process.cwd(), relDir);
|
fs.mkdirSync(sqliteDir, { recursive: true });
|
||||||
if (!fs.existsSync(absDir)) {
|
|
||||||
fs.mkdirSync(absDir, { recursive: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load provider modules once at startup
|
// Load provider modules once at startup
|
||||||
@@ -62,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;
|
||||||
@@ -6,7 +6,6 @@
|
|||||||
import { notificationAdapterRouter } from './routes/notificationAdapterRouter.js';
|
import { notificationAdapterRouter } from './routes/notificationAdapterRouter.js';
|
||||||
import { authInterceptor, cookieSession, adminInterceptor } from './security.js';
|
import { authInterceptor, cookieSession, adminInterceptor } from './security.js';
|
||||||
import { generalSettingsRouter } from './routes/generalSettingsRoute.js';
|
import { generalSettingsRouter } from './routes/generalSettingsRoute.js';
|
||||||
import { analyticsRouter } from './routes/analyticsRouter.js';
|
|
||||||
import { providerRouter } from './routes/providerRouter.js';
|
import { providerRouter } from './routes/providerRouter.js';
|
||||||
import { versionRouter } from './routes/versionRouter.js';
|
import { versionRouter } from './routes/versionRouter.js';
|
||||||
import { loginRouter } from './routes/loginRoute.js';
|
import { loginRouter } from './routes/loginRoute.js';
|
||||||
@@ -22,6 +21,8 @@ import logger from '../services/logger.js';
|
|||||||
import { listingsRouter } from './routes/listingsRouter.js';
|
import { listingsRouter } from './routes/listingsRouter.js';
|
||||||
import { getSettings } from '../services/storage/settingsStorage.js';
|
import { getSettings } from '../services/storage/settingsStorage.js';
|
||||||
import { featureRouter } from './routes/featureRouter.js';
|
import { featureRouter } from './routes/featureRouter.js';
|
||||||
|
import { dashboardRouter } from './routes/dashboardRouter.js';
|
||||||
|
import { backupRouter } from './routes/backupRouter.js';
|
||||||
const service = restana();
|
const service = restana();
|
||||||
const staticService = files(path.join(getDirName(), '../ui/public'));
|
const staticService = files(path.join(getDirName(), '../ui/public'));
|
||||||
const PORT = (await getSettings()).port || 9998;
|
const PORT = (await getSettings()).port || 9998;
|
||||||
@@ -33,19 +34,22 @@ service.use('/api/admin', authInterceptor());
|
|||||||
service.use('/api/jobs', authInterceptor());
|
service.use('/api/jobs', authInterceptor());
|
||||||
service.use('/api/version', authInterceptor());
|
service.use('/api/version', authInterceptor());
|
||||||
service.use('/api/listings', authInterceptor());
|
service.use('/api/listings', authInterceptor());
|
||||||
|
service.use('/api/dashboard', authInterceptor());
|
||||||
|
service.use('/api/features', authInterceptor());
|
||||||
|
|
||||||
// /admin can only be accessed when user is having admin permissions
|
// /admin can only be accessed when user is having admin permissions
|
||||||
service.use('/api/admin', adminInterceptor());
|
service.use('/api/admin', adminInterceptor());
|
||||||
service.use('/api/jobs/notificationAdapter', notificationAdapterRouter);
|
service.use('/api/jobs/notificationAdapter', notificationAdapterRouter);
|
||||||
service.use('/api/admin/generalSettings', generalSettingsRouter);
|
service.use('/api/admin/generalSettings', generalSettingsRouter);
|
||||||
|
service.use('/api/admin/backup', backupRouter);
|
||||||
service.use('/api/jobs/provider', providerRouter);
|
service.use('/api/jobs/provider', providerRouter);
|
||||||
service.use('/api/jobs/insights', analyticsRouter);
|
|
||||||
service.use('/api/admin/users', userRouter);
|
service.use('/api/admin/users', userRouter);
|
||||||
service.use('/api/version', versionRouter);
|
service.use('/api/version', versionRouter);
|
||||||
service.use('/api/jobs', jobRouter);
|
service.use('/api/jobs', jobRouter);
|
||||||
service.use('/api/login', loginRouter);
|
service.use('/api/login', loginRouter);
|
||||||
service.use('/api/listings', listingsRouter);
|
service.use('/api/listings', listingsRouter);
|
||||||
service.use('/api/features', featureRouter);
|
service.use('/api/features', featureRouter);
|
||||||
|
service.use('/api/dashboard', dashboardRouter);
|
||||||
//this route is unsecured intentionally as it is being queried from the login page
|
//this route is unsecured intentionally as it is being queried from the login page
|
||||||
service.use('/api/demo', demoRouter);
|
service.use('/api/demo', demoRouter);
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2025 by Christian Kellner.
|
|
||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
|
||||||
*/
|
|
||||||
|
|
||||||
import restana from 'restana';
|
|
||||||
import * as listingStorage from '../../services/storage/listingsStorage.js';
|
|
||||||
const service = restana();
|
|
||||||
const analyticsRouter = service.newRouter();
|
|
||||||
analyticsRouter.get('/:jobId', async (req, res) => {
|
|
||||||
const { jobId } = req.params;
|
|
||||||
res.body = listingStorage.getListingProviderDataForAnalytics(jobId) || {};
|
|
||||||
res.send();
|
|
||||||
});
|
|
||||||
export { analyticsRouter };
|
|
||||||
75
lib/api/routes/backupRouter.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import restana from 'restana';
|
||||||
|
import {
|
||||||
|
buildBackupFileName,
|
||||||
|
createBackupZip,
|
||||||
|
precheckRestore,
|
||||||
|
restoreFromZip,
|
||||||
|
} from '../../services/storage/backupRestoreService.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backup & Restore Admin Router
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* - GET /api/admin/backup
|
||||||
|
* Returns the current database as a zip download. Content-Type: application/zip
|
||||||
|
* - POST /api/admin/backup/restore?dryRun=true
|
||||||
|
* Accepts a zip file (raw body). Returns a compatibility report, does not restore.
|
||||||
|
* - POST /api/admin/backup/restore?force=true|false
|
||||||
|
* Accepts a zip file (raw body). Restores the database; when incompatible and force=false, returns 400.
|
||||||
|
*/
|
||||||
|
const service = restana();
|
||||||
|
const backupRouter = service.newRouter();
|
||||||
|
|
||||||
|
backupRouter.get('/', async (req, res) => {
|
||||||
|
const zipBuffer = await createBackupZip();
|
||||||
|
const fileName = await buildBackupFileName();
|
||||||
|
res.setHeader('Content-Type', 'application/zip');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
|
||||||
|
res.send(zipBuffer);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the full request body as a Buffer. Used for raw zip uploads.
|
||||||
|
* @param {import('http').IncomingMessage} req
|
||||||
|
* @returns {Promise<Buffer>}
|
||||||
|
*/
|
||||||
|
function readBody(req) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks = [];
|
||||||
|
req.on('data', (c) => chunks.push(c));
|
||||||
|
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
req.on('error', (e) => reject(e));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload endpoint. Accepts raw zip (Content-Type: application/zip or application/octet-stream)
|
||||||
|
// Query parameters:
|
||||||
|
// - dryRun=true => only validate and return compatibility info
|
||||||
|
// - force=true => proceed even if incompatible
|
||||||
|
backupRouter.post('/restore', async (req, res) => {
|
||||||
|
const { dryRun = 'false', force = 'false' } = req.query || {};
|
||||||
|
const doDryRun = String(dryRun) === 'true';
|
||||||
|
const doForce = String(force) === 'true';
|
||||||
|
const body = await readBody(req);
|
||||||
|
|
||||||
|
if (doDryRun) {
|
||||||
|
res.body = await precheckRestore(body);
|
||||||
|
return res.send();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
res.body = await restoreFromZip(body, { force: doForce });
|
||||||
|
return res.send();
|
||||||
|
} catch (e) {
|
||||||
|
res.statusCode = 400;
|
||||||
|
res.body = { message: e?.message || 'Restore failed', details: e?.payload || null };
|
||||||
|
return res.send();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export { backupRouter };
|
||||||
71
lib/api/routes/dashboardRouter.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import restana from 'restana';
|
||||||
|
import * as jobStorage from '../../services/storage/jobStorage.js';
|
||||||
|
import * as userStorage from '../../services/storage/userStorage.js';
|
||||||
|
import { getListingsKpisForJobIds, getProviderDistributionForJobIds } from '../../services/storage/listingsStorage.js';
|
||||||
|
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||||
|
|
||||||
|
const service = restana();
|
||||||
|
export const dashboardRouter = service.newRouter();
|
||||||
|
|
||||||
|
function isAdmin(req) {
|
||||||
|
const user = req.session?.currentUser ? userStorage.getUser(req.session.currentUser) : null;
|
||||||
|
return !!user?.isAdmin;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAccessibleJobs(req) {
|
||||||
|
const currentUser = req.session.currentUser;
|
||||||
|
const admin = isAdmin(req);
|
||||||
|
return jobStorage
|
||||||
|
.getJobs()
|
||||||
|
.filter((job) => admin || job.userId === currentUser || job.shared_with_user.includes(currentUser));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cap(val) {
|
||||||
|
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
dashboardRouter.get('/', async (req, res) => {
|
||||||
|
const jobs = getAccessibleJobs(req);
|
||||||
|
const settings = await getSettings();
|
||||||
|
|
||||||
|
// KPIs
|
||||||
|
const totalJobs = jobs.length;
|
||||||
|
const totalListings = jobs.reduce((sum, j) => sum + (j.numberOfFoundListings || 0), 0);
|
||||||
|
const jobIds = jobs.map((j) => j.id);
|
||||||
|
const { numberOfActiveListings, avgPriceOfListings } = getListingsKpisForJobIds(jobIds);
|
||||||
|
// Build Pie data in a simple shape the frontend can consume directly
|
||||||
|
// Shape: { labels: string[], values: number[] } with values as percentages
|
||||||
|
const providerPieRaw = getProviderDistributionForJobIds(jobIds);
|
||||||
|
const providerPie = Array.isArray(providerPieRaw)
|
||||||
|
? {
|
||||||
|
labels: providerPieRaw.map((p) => cap(p.type)),
|
||||||
|
values: providerPieRaw.map((p) => Number(p.value) || 0),
|
||||||
|
}
|
||||||
|
: providerPieRaw && typeof providerPieRaw === 'object'
|
||||||
|
? {
|
||||||
|
labels: Array.isArray(providerPieRaw.labels) ? providerPieRaw.labels : [],
|
||||||
|
values: Array.isArray(providerPieRaw.values) ? providerPieRaw.values : [],
|
||||||
|
}
|
||||||
|
: { labels: [], values: [] };
|
||||||
|
|
||||||
|
res.body = {
|
||||||
|
general: {
|
||||||
|
interval: settings.interval,
|
||||||
|
lastRun: settings.lastRun || null,
|
||||||
|
nextRun: settings.lastRun == null ? 0 : settings.lastRun + settings.interval * 60000,
|
||||||
|
},
|
||||||
|
kpis: {
|
||||||
|
totalJobs,
|
||||||
|
totalListings,
|
||||||
|
numberOfActiveListings,
|
||||||
|
avgPriceOfListings,
|
||||||
|
},
|
||||||
|
pie: providerPie,
|
||||||
|
};
|
||||||
|
res.send();
|
||||||
|
});
|
||||||
@@ -9,7 +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 { getSettings } from '../../services/storage/settingsStorage.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();
|
||||||
@@ -38,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 &&
|
||||||
@@ -48,18 +50,113 @@ jobRouter.get('/', async (req, res) => {
|
|||||||
res.send();
|
res.send();
|
||||||
});
|
});
|
||||||
|
|
||||||
jobRouter.get('/processingTimes', async (req, res) => {
|
jobRouter.get('/data', async (req, res) => {
|
||||||
const settings = await getSettings();
|
const { page, pageSize = 50, activityFilter, sortfield = null, sortdir = 'asc', freeTextFilter } = req.query || {};
|
||||||
res.body = {
|
|
||||||
interval: settings.interval,
|
// normalize booleans
|
||||||
lastRun: settings.lastRun || null,
|
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) => {
|
jobRouter.post('/startAll', async (req, res) => {
|
||||||
bus.emit('jobs:runAll');
|
try {
|
||||||
res.send();
|
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) => {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
@@ -155,3 +155,21 @@ class SqliteConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default SqliteConnection;
|
export default SqliteConnection;
|
||||||
|
|
||||||
|
// Centralized DB path computation to avoid duplication across modules
|
||||||
|
// Returns: { dir, dbPath }
|
||||||
|
/**
|
||||||
|
* Compute the absolute SQLite database directory and file path based on configuration.
|
||||||
|
* Ensures the directory exists on disk.
|
||||||
|
* @returns {Promise<{dir:string, dbPath:string}>} Absolute directory and database file path.
|
||||||
|
*/
|
||||||
|
export async function computeDbPath() {
|
||||||
|
const cfg = await readConfigFromStorage();
|
||||||
|
const rawDir = cfg?.sqlitepath && cfg.sqlitepath.length > 0 ? cfg.sqlitepath : '/db';
|
||||||
|
const relDir = rawDir.startsWith('/') ? rawDir.slice(1) : rawDir;
|
||||||
|
const absDir = path.isAbsolute(relDir) ? relDir : path.join(process.cwd(), relDir);
|
||||||
|
const dbPath = path.join(absDir, 'listings.db');
|
||||||
|
const dir = path.dirname(dbPath);
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
|
return { dir: absDir, dbPath };
|
||||||
|
}
|
||||||
|
|||||||
320
lib/services/storage/backupRestoreService.js
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
import SqliteConnection, { computeDbPath } from './SqliteConnection.js';
|
||||||
|
import logger from '../../services/logger.js';
|
||||||
|
import { getPackageVersion } from '../../utils.js';
|
||||||
|
import { runMigrations, listMigrationFiles } from './migrations/migrate.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily resolve and cache the AdmZip constructor via dynamic import.
|
||||||
|
* This keeps startup costs low and avoids ESM/CJS interop pitfalls.
|
||||||
|
* @returns {Promise<any>} AdmZip constructor (class)
|
||||||
|
*/
|
||||||
|
let _AdmZipSingleton = null;
|
||||||
|
async function getAdmZip() {
|
||||||
|
if (_AdmZipSingleton) return _AdmZipSingleton;
|
||||||
|
// Allow tests to provide a mock constructor without ESM loader intricacies
|
||||||
|
if (globalThis && globalThis.__TEST_ADM_ZIP__) {
|
||||||
|
_AdmZipSingleton = globalThis.__TEST_ADM_ZIP__;
|
||||||
|
return _AdmZipSingleton;
|
||||||
|
}
|
||||||
|
const mod = await import('adm-zip');
|
||||||
|
_AdmZipSingleton = (mod && mod.default) || mod;
|
||||||
|
return _AdmZipSingleton;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract numeric migration id from a migration file name like "12.add-users.js".
|
||||||
|
* @param {string} name
|
||||||
|
* @returns {number} Parsed id or 0 when not parsable
|
||||||
|
*/
|
||||||
|
function parseMigrationIdFromName(name) {
|
||||||
|
if (typeof name !== 'string') return 0;
|
||||||
|
const m = name.match(/^(\d+)\./);
|
||||||
|
return m ? parseInt(m[1], 10) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the highest migration id from available migration files.
|
||||||
|
* @returns {number} Highest migration id from files, or 0 when none.
|
||||||
|
*/
|
||||||
|
function getLatestMigrationIdFromFiles() {
|
||||||
|
try {
|
||||||
|
const files = listMigrationFiles();
|
||||||
|
const ids = files.map((f) => f.id);
|
||||||
|
return ids.length > 0 ? Math.max(...ids) : 0;
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('Failed to scan migrations directory:', e.message);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inspect the current database and return the highest applied migration id.
|
||||||
|
* @returns {number} Max id from schema_migrations, or 0 when table/rows are missing.
|
||||||
|
*/
|
||||||
|
function getCurrentDbMigration() {
|
||||||
|
try {
|
||||||
|
const exists = SqliteConnection.tableExists('schema_migrations');
|
||||||
|
if (!exists) return 0;
|
||||||
|
const rows = SqliteConnection.query('SELECT name FROM schema_migrations');
|
||||||
|
if (!rows || rows.length === 0) return 0;
|
||||||
|
return rows.reduce((max, r) => Math.max(max, parseMigrationIdFromName(r.name)), 0);
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('Failed to read current DB migration:', e.message);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a consistent SQLite snapshot using the native backup API into a temp folder.
|
||||||
|
* @returns {Promise<{tempDir:string, backupPath:string}>}
|
||||||
|
*/
|
||||||
|
async function createTempBackupFile() {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fredy-db-'));
|
||||||
|
const backupPath = path.join(tempDir, 'listings.db');
|
||||||
|
// Ensure connection is open and create a consistent snapshot
|
||||||
|
const db = SqliteConnection.getConnection();
|
||||||
|
await db.backup(backupPath);
|
||||||
|
return { tempDir, backupPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a zip buffer that contains the DB snapshot and metadata marker.
|
||||||
|
* Files:
|
||||||
|
* - listings.db
|
||||||
|
* - fredy-backup.json { formatVersion, createdAt, dbMigration, fredyVersion }
|
||||||
|
* @returns {Promise<Buffer>}
|
||||||
|
*/
|
||||||
|
async function buildBackupZipBuffer() {
|
||||||
|
const { backupPath, tempDir } = await createTempBackupFile();
|
||||||
|
try {
|
||||||
|
const AdmZip = await getAdmZip();
|
||||||
|
const zip = new AdmZip();
|
||||||
|
const meta = {
|
||||||
|
formatVersion: 1,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
dbMigration: getCurrentDbMigration(),
|
||||||
|
fredyVersion: await getPackageVersion(),
|
||||||
|
};
|
||||||
|
// add files
|
||||||
|
zip.addLocalFile(backupPath, '', 'listings.db');
|
||||||
|
zip.addFile('fredy-backup.json', Buffer.from(JSON.stringify(meta, null, 2), 'utf-8'));
|
||||||
|
return zip.toBuffer();
|
||||||
|
} finally {
|
||||||
|
// cleanup temp
|
||||||
|
try {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
} catch (e) {
|
||||||
|
logger.debug('Failed to cleanup temp backup dir:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read and parse the metadata file from a backup zip buffer.
|
||||||
|
* @param {Buffer} zipBuffer
|
||||||
|
* @returns {Promise<any|null>} Parsed JSON or null when missing/invalid.
|
||||||
|
*/
|
||||||
|
async function readMetadataFromZip(zipBuffer) {
|
||||||
|
const AdmZip = await getAdmZip();
|
||||||
|
const zip = new AdmZip(zipBuffer);
|
||||||
|
const entry = zip.getEntry('fredy-backup.json');
|
||||||
|
if (!entry) return null;
|
||||||
|
try {
|
||||||
|
const txt = entry.getData().toString('utf-8');
|
||||||
|
return JSON.parse(txt);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a backup zip contains a listings.db entry.
|
||||||
|
* @param {Buffer} zipBuffer
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async function hasListingsDbInZip(zipBuffer) {
|
||||||
|
const AdmZip = await getAdmZip();
|
||||||
|
const zip = new AdmZip(zipBuffer);
|
||||||
|
return zip.getEntry('listings.db') != null || zip.getEntries().some((e) => /listings\.db$/i.test(e.entryName));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the listings.db from a backup zip buffer to a temp directory.
|
||||||
|
* @param {Buffer} zipBuffer
|
||||||
|
* @returns {Promise<{tempDir:string, dbPath:string}>}
|
||||||
|
*/
|
||||||
|
async function extractListingsDbToTemp(zipBuffer) {
|
||||||
|
const AdmZip = await getAdmZip();
|
||||||
|
const zip = new AdmZip(zipBuffer);
|
||||||
|
const entry = zip.getEntry('listings.db') || zip.getEntries().find((e) => /listings\.db$/i.test(e.entryName));
|
||||||
|
if (!entry) throw new Error('Backup zip does not contain listings.db');
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fredy-restore-'));
|
||||||
|
const outPath = path.join(tempDir, 'listings.db');
|
||||||
|
fs.writeFileSync(outPath, entry.getData());
|
||||||
|
return { tempDir, dbPath: outPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public: Create a backup zip buffer ready for download.
|
||||||
|
* @returns {Promise<Buffer>}
|
||||||
|
*/
|
||||||
|
export async function createBackupZip() {
|
||||||
|
return buildBackupZipBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze a backup zip for compatibility with the current codebase.
|
||||||
|
* - Missing DB yields danger.
|
||||||
|
* - Newer backup migration than required yields danger.
|
||||||
|
* - Older backup yields warning but is considered compatible (auto-migrate).
|
||||||
|
* - Equal version yields info.
|
||||||
|
* @param {Buffer} zipBuffer
|
||||||
|
* @returns {Promise<{compatible:boolean,severity:'danger'|'warning'|'info',message:string,backupMigration:number|null,requiredMigration:number,fredyVersion?:string|null}>>}
|
||||||
|
*/
|
||||||
|
export async function precheckRestore(zipBuffer) {
|
||||||
|
if (!zipBuffer || zipBuffer.length === 0) {
|
||||||
|
return {
|
||||||
|
compatible: false,
|
||||||
|
severity: 'danger',
|
||||||
|
message: 'Empty upload',
|
||||||
|
backupMigration: null,
|
||||||
|
requiredMigration: getLatestMigrationIdFromFiles(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!(await hasListingsDbInZip(zipBuffer))) {
|
||||||
|
return {
|
||||||
|
compatible: false,
|
||||||
|
severity: 'danger',
|
||||||
|
message: 'Zip file is missing the database file (listings.db).',
|
||||||
|
backupMigration: null,
|
||||||
|
requiredMigration: getLatestMigrationIdFromFiles(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const meta = await readMetadataFromZip(zipBuffer);
|
||||||
|
const requiredMigration = getLatestMigrationIdFromFiles();
|
||||||
|
const backupMigration = meta?.dbMigration ?? null;
|
||||||
|
const fredyVersion = meta?.fredyVersion ?? null;
|
||||||
|
|
||||||
|
if (backupMigration == null) {
|
||||||
|
return {
|
||||||
|
compatible: false,
|
||||||
|
severity: 'danger',
|
||||||
|
message:
|
||||||
|
'Backup metadata is missing the migration marker. Cannot validate compatibility. It is NOT advised to continue!',
|
||||||
|
backupMigration,
|
||||||
|
requiredMigration,
|
||||||
|
fredyVersion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backupMigration > requiredMigration) {
|
||||||
|
return {
|
||||||
|
compatible: false,
|
||||||
|
severity: 'danger',
|
||||||
|
message:
|
||||||
|
'Backup schema is newer than this Fredy version. Please upgrade Fredy to a version that supports this backup or proceed at your own risk.',
|
||||||
|
backupMigration,
|
||||||
|
requiredMigration,
|
||||||
|
fredyVersion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backupMigration < requiredMigration) {
|
||||||
|
return {
|
||||||
|
compatible: true,
|
||||||
|
severity: 'warning',
|
||||||
|
message:
|
||||||
|
'Backup contains an older database schema than this Fredy version requires. We will apply automatic migrations right after the restore to upgrade the database.',
|
||||||
|
backupMigration,
|
||||||
|
requiredMigration,
|
||||||
|
fredyVersion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
compatible: true,
|
||||||
|
severity: 'info',
|
||||||
|
message: 'Backup is compatible with the current Fredy version.',
|
||||||
|
backupMigration,
|
||||||
|
requiredMigration,
|
||||||
|
fredyVersion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform a restore from a validated backup zip.
|
||||||
|
* - Optionally forces restore when incompatible.
|
||||||
|
* - Replaces the on-disk DB and runs migrations when needed.
|
||||||
|
* @param {Buffer} zipBuffer
|
||||||
|
* @param {{force?:boolean}} [opts]
|
||||||
|
* @returns {Promise<{restored:true,warning:string|null,details:any}>}
|
||||||
|
* @throws Error with code 'INCOMPATIBLE' when not forced and incompatible
|
||||||
|
*/
|
||||||
|
export async function restoreFromZip(zipBuffer, { force = false } = {}) {
|
||||||
|
const check = await precheckRestore(zipBuffer);
|
||||||
|
if (!check.compatible && !force) {
|
||||||
|
const err = new Error(check.message || 'Backup is incompatible');
|
||||||
|
err.code = 'INCOMPATIBLE';
|
||||||
|
err.payload = check;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { dbPath } = await computeDbPath();
|
||||||
|
const { tempDir, dbPath: tempDbPath } = await extractListingsDbToTemp(zipBuffer);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Close existing connection to allow file replacement
|
||||||
|
SqliteConnection.close();
|
||||||
|
|
||||||
|
// Backup existing DB file
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(dbPath)) {
|
||||||
|
const backupName = `${dbPath}.bak-${Date.now()}`;
|
||||||
|
fs.copyFileSync(dbPath, backupName);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('Failed to create on-disk backup copy of current DB:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace DB with the one from the zip
|
||||||
|
fs.copyFileSync(tempDbPath, dbPath);
|
||||||
|
|
||||||
|
// Re-run migrations when needed
|
||||||
|
if (check.backupMigration < check.requiredMigration) {
|
||||||
|
await runMigrations();
|
||||||
|
} else {
|
||||||
|
// Ensure we can re-open the DB
|
||||||
|
SqliteConnection.getConnection();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
} catch (e) {
|
||||||
|
logger.debug('Failed to cleanup temp restore dir:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { restored: true, warning: check.severity !== 'info' ? check.message : null, details: check };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the backup file name with current date and Fredy version.
|
||||||
|
* Pattern: YYYY-MM-DD-FredyBackup-{version}.zip
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
export async function buildBackupFileName() {
|
||||||
|
const dt = new Date();
|
||||||
|
const yyyy = dt.getFullYear();
|
||||||
|
const mm = String(dt.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(dt.getDate()).padStart(2, '0');
|
||||||
|
const version = await getPackageVersion();
|
||||||
|
return `${yyyy}-${mm}-${dd}-FredyBackup-${version}.zip`.replaceAll(' ', '');
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
};
|
||||||
|
|||||||
@@ -7,40 +7,6 @@ import { nullOrEmpty } from '../../utils.js';
|
|||||||
import SqliteConnection from './SqliteConnection.js';
|
import SqliteConnection from './SqliteConnection.js';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
/**
|
|
||||||
* Build analytics data for a given job by grouping all listings by provider and
|
|
||||||
* mapping each listing hash to its creation timestamp.
|
|
||||||
*
|
|
||||||
* SQL shape:
|
|
||||||
* SELECT json_group_object(provider, json_object(hash, created_at)) AS result
|
|
||||||
* FROM listings WHERE job_id = @jobId;
|
|
||||||
*
|
|
||||||
* The resulting object has the shape:
|
|
||||||
* {
|
|
||||||
* providerA: { "<hash1>": <created_at_ms>, "<hash2>": <created_at_ms>, ... },
|
|
||||||
* providerB: { ... }
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* @param {string} jobId - ID of the job whose listings should be aggregated.
|
|
||||||
* @returns {Record<string, Record<string, number>>} Object grouped by provider mapping listing-hash -> created_at epoch ms.
|
|
||||||
*/
|
|
||||||
export const getListingProviderDataForAnalytics = (jobId) => {
|
|
||||||
const row = SqliteConnection.query(
|
|
||||||
`SELECT COALESCE(
|
|
||||||
json_group_object(provider, json(provider_map)),
|
|
||||||
json('{}')
|
|
||||||
) AS result
|
|
||||||
FROM (SELECT provider,
|
|
||||||
json_group_object(hash, created_at) AS provider_map
|
|
||||||
FROM listings
|
|
||||||
WHERE job_id = @jobId
|
|
||||||
GROUP BY provider);`,
|
|
||||||
{ jobId },
|
|
||||||
);
|
|
||||||
|
|
||||||
return row?.length > 0 ? JSON.parse(row[0].result) : {};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return a list of known listing hashes for a given job and provider.
|
* Return a list of known listing hashes for a given job and provider.
|
||||||
* Useful to de-duplicate before inserting new listings.
|
* Useful to de-duplicate before inserting new listings.
|
||||||
@@ -59,6 +25,89 @@ export const getKnownListingHashesForJobAndProvider = (jobId, providerId) => {
|
|||||||
).map((r) => r.hash);
|
).map((r) => r.hash);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute KPI aggregates for a given set of job IDs from the listings table.
|
||||||
|
*
|
||||||
|
* - numberOfActiveListings: count of listings where is_active = 1
|
||||||
|
* - avgPriceOfListings: average of numeric price, rounded to nearest integer
|
||||||
|
*
|
||||||
|
* When no jobIds are provided, returns zeros.
|
||||||
|
*
|
||||||
|
* @param {string[]} jobIds
|
||||||
|
* @returns {{ numberOfActiveListings: number, avgPriceOfListings: number }}
|
||||||
|
*/
|
||||||
|
export const getListingsKpisForJobIds = (jobIds = []) => {
|
||||||
|
if (!Array.isArray(jobIds) || jobIds.length === 0) {
|
||||||
|
return { numberOfActiveListings: 0, avgPriceOfListings: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholders = jobIds.map(() => '?').join(',');
|
||||||
|
const row =
|
||||||
|
SqliteConnection.query(
|
||||||
|
`SELECT
|
||||||
|
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) AS activeCount,
|
||||||
|
AVG(price) AS avgPrice
|
||||||
|
FROM listings
|
||||||
|
WHERE job_id IN (${placeholders})`,
|
||||||
|
jobIds,
|
||||||
|
)[0] || {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
numberOfActiveListings: Number(row.activeCount || 0),
|
||||||
|
avgPriceOfListings: row?.avgPrice == null ? 0 : Math.round(Number(row.avgPrice)),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute distribution of listings by provider for the given set of job IDs.
|
||||||
|
* Returns data ready for the pie chart component with fields `type` and `value` (percentage).
|
||||||
|
*
|
||||||
|
* Example return:
|
||||||
|
* [ { type: 'immoscout', value: 62 }, { type: 'immowelt', value: 38 } ]
|
||||||
|
*
|
||||||
|
* When no jobIds are provided or no listings exist, returns empty array.
|
||||||
|
*
|
||||||
|
* @param {string[]} jobIds
|
||||||
|
* @returns {{ type: string, value: number }[]}
|
||||||
|
*/
|
||||||
|
export const getProviderDistributionForJobIds = (jobIds = []) => {
|
||||||
|
if (!Array.isArray(jobIds) || jobIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholders = jobIds.map(() => '?').join(',');
|
||||||
|
const rows = SqliteConnection.query(
|
||||||
|
`SELECT provider, COUNT(*) AS cnt
|
||||||
|
FROM listings
|
||||||
|
WHERE job_id IN (${placeholders})
|
||||||
|
GROUP BY provider
|
||||||
|
ORDER BY cnt DESC`,
|
||||||
|
jobIds,
|
||||||
|
);
|
||||||
|
|
||||||
|
const total = rows.reduce((acc, r) => acc + Number(r.cnt || 0), 0);
|
||||||
|
if (total === 0) return [];
|
||||||
|
|
||||||
|
// Map counts to integer percentage values (0-100). Ensure sum is ~100 by rounding.
|
||||||
|
const percentages = rows.map((r) => ({
|
||||||
|
type: r.provider,
|
||||||
|
value: Math.round((Number(r.cnt) / total) * 100),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Adjust rounding drift to keep sum at 100 (optional minor correction)
|
||||||
|
const drift = 100 - percentages.reduce((s, p) => s + p.value, 0);
|
||||||
|
if (drift !== 0 && percentages.length > 0) {
|
||||||
|
// apply drift to the largest slice to keep UX simple
|
||||||
|
let maxIdx = 0;
|
||||||
|
for (let i = 1; i < percentages.length; i++) {
|
||||||
|
if (percentages[i].value > percentages[maxIdx].value) maxIdx = i;
|
||||||
|
}
|
||||||
|
percentages[maxIdx].value = Math.max(0, percentages[maxIdx].value + drift);
|
||||||
|
}
|
||||||
|
|
||||||
|
return percentages;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return a list of listing that either are active or have an unknown status
|
* Return a list of listing that either are active or have an unknown status
|
||||||
* to constantly check if they are still online
|
* to constantly check if they are still online
|
||||||
@@ -228,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) {
|
||||||
@@ -246,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 ')}` : '';
|
||||||
|
|||||||
@@ -35,7 +35,11 @@ import SqliteConnection from '../SqliteConnection.js';
|
|||||||
import logger from '../../logger.js';
|
import logger from '../../logger.js';
|
||||||
|
|
||||||
const ROOT = path.resolve('.');
|
const ROOT = path.resolve('.');
|
||||||
const MIGRATIONS_DIR = path.join(ROOT, 'lib', 'services', 'storage', 'migrations', 'sql');
|
/**
|
||||||
|
* Absolute path to the migrations directory (lib/services/storage/migrations/sql).
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
export const MIGRATIONS_DIR = path.join(ROOT, 'lib', 'services', 'storage', 'migrations', 'sql');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensures that the given directory exists, creating it recursively if needed.
|
* Ensures that the given directory exists, creating it recursively if needed.
|
||||||
@@ -50,7 +54,7 @@ function ensureDir(p) {
|
|||||||
* Migration files must follow the format: <number>.<label>.js
|
* Migration files must follow the format: <number>.<label>.js
|
||||||
* @returns {Array<{id:number, name:string, label:string, path:string}>}
|
* @returns {Array<{id:number, name:string, label:string, path:string}>}
|
||||||
*/
|
*/
|
||||||
function listMigrationFiles() {
|
export function listMigrationFiles() {
|
||||||
ensureDir(MIGRATIONS_DIR);
|
ensureDir(MIGRATIONS_DIR);
|
||||||
return fs
|
return fs
|
||||||
.readdirSync(MIGRATIONS_DIR)
|
.readdirSync(MIGRATIONS_DIR)
|
||||||
|
|||||||
30
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fredy",
|
"name": "fredy",
|
||||||
"version": "16.0.1",
|
"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",
|
||||||
@@ -59,15 +59,14 @@
|
|||||||
"Firefox ESR"
|
"Firefox ESR"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@douyinfe/semi-icons": "^2.89.0",
|
"adm-zip": "^0.5.16",
|
||||||
"@douyinfe/semi-ui": "2.89.0",
|
"@douyinfe/semi-icons": "^2.90.3",
|
||||||
|
"@douyinfe/semi-ui": "2.90.3",
|
||||||
"@sendgrid/mail": "8.1.6",
|
"@sendgrid/mail": "8.1.6",
|
||||||
"@visactor/react-vchart": "^2.0.10",
|
|
||||||
"@visactor/vchart": "^2.0.10",
|
|
||||||
"@visactor/vchart-semi-theme": "^1.12.2",
|
|
||||||
"@vitejs/plugin-react": "5.1.2",
|
"@vitejs/plugin-react": "5.1.2",
|
||||||
"better-sqlite3": "^12.5.0",
|
"better-sqlite3": "^12.5.0",
|
||||||
"body-parser": "2.2.1",
|
"body-parser": "2.2.1",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"cheerio": "^1.1.2",
|
"cheerio": "^1.1.2",
|
||||||
"cookie-session": "2.1.1",
|
"cookie-session": "2.1.1",
|
||||||
"handlebars": "4.7.8",
|
"handlebars": "4.7.8",
|
||||||
@@ -78,19 +77,20 @@
|
|||||||
"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.32.1",
|
"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-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.0",
|
"serve-static": "2.2.1",
|
||||||
"slack": "11.0.2",
|
"slack": "11.0.2",
|
||||||
"vite": "7.2.7",
|
"vite": "7.3.0",
|
||||||
"x-var": "^3.0.1",
|
"x-var": "^3.0.1",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
},
|
},
|
||||||
@@ -99,14 +99,14 @@
|
|||||||
"@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.1",
|
"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",
|
||||||
"esmock": "2.7.3",
|
"esmock": "2.7.3",
|
||||||
"history": "5.3.0",
|
"history": "5.3.0",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"less": "4.4.2",
|
"less": "4.5.1",
|
||||||
"lint-staged": "16.2.7",
|
"lint-staged": "16.2.7",
|
||||||
"mocha": "11.7.5",
|
"mocha": "11.7.5",
|
||||||
"nodemon": "^3.1.11",
|
"nodemon": "^3.1.11",
|
||||||
|
|||||||
143
test/backup/backupRestoreService.test.js
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
/*
|
||||||
|
* 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';
|
||||||
|
|
||||||
|
describe('services/storage/backupRestoreService.js - precheck & filename', () => {
|
||||||
|
let svc;
|
||||||
|
let setZipState;
|
||||||
|
let calls;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
calls = { logger: { info: [], warn: [], error: [] } };
|
||||||
|
|
||||||
|
// Mock AdmZip with configurable state via globalThis (avoid esmock export name pitfalls)
|
||||||
|
globalThis.__ADM_ZIP_STATE__ = { hasDb: false, meta: null };
|
||||||
|
setZipState = (s) => {
|
||||||
|
globalThis.__ADM_ZIP_STATE__ = { ...globalThis.__ADM_ZIP_STATE__, ...s };
|
||||||
|
};
|
||||||
|
class MockAdmZip {
|
||||||
|
constructor() {}
|
||||||
|
getEntry(name) {
|
||||||
|
const state = globalThis.__ADM_ZIP_STATE__ || {};
|
||||||
|
if (name === 'listings.db') {
|
||||||
|
if (state.hasDb) return { getData: () => Buffer.from('db') };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (name === 'fredy-backup.json') {
|
||||||
|
if (state.meta) return { getData: () => Buffer.from(JSON.stringify(state.meta)) };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
getEntries() {
|
||||||
|
const state = globalThis.__ADM_ZIP_STATE__ || {};
|
||||||
|
const arr = [];
|
||||||
|
if (state.hasDb) arr.push({ entryName: 'listings.db', getData: () => Buffer.from('db') });
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const admZipMock = { default: MockAdmZip };
|
||||||
|
// Also expose for service via globalThis escape hatch
|
||||||
|
globalThis.__TEST_ADM_ZIP__ = MockAdmZip;
|
||||||
|
|
||||||
|
const path = await import('node:path');
|
||||||
|
const ROOT = path.resolve('.');
|
||||||
|
|
||||||
|
// Mocks for dependencies
|
||||||
|
const migratePath = path.join(ROOT, 'lib', 'services', 'storage', 'migrations', 'migrate.js');
|
||||||
|
const sqlitePath = path.join(ROOT, 'lib', 'services', 'storage', 'SqliteConnection.js');
|
||||||
|
const loggerPath = path.join(ROOT, 'lib', 'services', 'logger.js');
|
||||||
|
const utilsPath = path.join(ROOT, 'lib', 'utils.js');
|
||||||
|
|
||||||
|
const migrateMock = {
|
||||||
|
listMigrationFiles: () => [{ id: 10 }],
|
||||||
|
runMigrations: async () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const sqliteMock = {
|
||||||
|
default: {
|
||||||
|
getConnection: () => ({ backup: async () => {} }),
|
||||||
|
close: () => {},
|
||||||
|
tableExists: () => false,
|
||||||
|
query: () => [],
|
||||||
|
withTransaction: (cb) => cb({ prepare: () => ({ run: () => {} }) }),
|
||||||
|
},
|
||||||
|
computeDbPath: async () => ({ dir: '/tmp', dbPath: '/tmp/listings.db' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const loggerMock = {
|
||||||
|
info: (...a) => calls.logger.info.push(a),
|
||||||
|
warn: (...a) => calls.logger.warn.push(a),
|
||||||
|
error: (...a) => calls.logger.error.push(a),
|
||||||
|
};
|
||||||
|
|
||||||
|
const utilsMock = { getPackageVersion: async () => '16.2.0' };
|
||||||
|
|
||||||
|
const admZipPath = path.join(ROOT, 'node_modules', 'adm-zip', 'adm-zip.js');
|
||||||
|
const mod = await esmock(
|
||||||
|
path.join(ROOT, 'lib', 'services', 'storage', 'backupRestoreService.js'),
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
'adm-zip': admZipMock,
|
||||||
|
[admZipPath]: admZipMock,
|
||||||
|
[migratePath]: migrateMock,
|
||||||
|
[sqlitePath]: sqliteMock,
|
||||||
|
[loggerPath]: loggerMock,
|
||||||
|
[utilsPath]: utilsMock,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
svc = mod;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('precheck: empty upload yields danger', async () => {
|
||||||
|
const res = await svc.precheckRestore(Buffer.alloc(0));
|
||||||
|
expect(res.compatible).to.equal(false);
|
||||||
|
expect(res.severity).to.equal('danger');
|
||||||
|
expect(res.message).to.contain('Empty upload');
|
||||||
|
expect(res.requiredMigration).to.equal(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('precheck: missing listings.db yields danger', async () => {
|
||||||
|
setZipState({ hasDb: false, meta: { dbMigration: 9 } });
|
||||||
|
const res = await svc.precheckRestore(Buffer.from('dummy'));
|
||||||
|
expect(res.compatible).to.equal(false);
|
||||||
|
expect(res.severity).to.equal('danger');
|
||||||
|
expect(res.message).to.match(/missing the database file/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('precheck: older backup is compatible with warning', async () => {
|
||||||
|
setZipState({ hasDb: true, meta: { dbMigration: 5, fredyVersion: '16.0.0' } });
|
||||||
|
const res = await svc.precheckRestore(Buffer.from('zip'));
|
||||||
|
expect(res.compatible).to.equal(true);
|
||||||
|
expect(res.severity).to.equal('warning');
|
||||||
|
expect(res.message).to.match(/automatic migrations/i);
|
||||||
|
expect(res.backupMigration).to.equal(5);
|
||||||
|
expect(res.requiredMigration).to.equal(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('precheck: equal backup is compatible with info', async () => {
|
||||||
|
setZipState({ hasDb: true, meta: { dbMigration: 10 } });
|
||||||
|
const res = await svc.precheckRestore(Buffer.from('zip'));
|
||||||
|
expect(res.compatible).to.equal(true);
|
||||||
|
expect(res.severity).to.equal('info');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('precheck: newer backup yields danger', async () => {
|
||||||
|
setZipState({ hasDb: true, meta: { dbMigration: 11 } });
|
||||||
|
const res = await svc.precheckRestore(Buffer.from('zip'));
|
||||||
|
expect(res.compatible).to.equal(false);
|
||||||
|
expect(res.severity).to.equal('danger');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buildBackupFileName: matches pattern and includes version', async () => {
|
||||||
|
const name = await svc.buildBackupFileName();
|
||||||
|
expect(name).to.match(/^\d{4}-\d{2}-\d{2}-FredyBackup-/);
|
||||||
|
expect(name).to.include('16.2.0');
|
||||||
|
expect(name).to.match(/\.zip$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import PermissionAwareRoute from './components/permission/PermissionAwareRoute';
|
|||||||
import GeneralSettings from './views/generalSettings/GeneralSettings';
|
import GeneralSettings from './views/generalSettings/GeneralSettings';
|
||||||
import JobMutation from './views/jobs/mutation/JobMutation';
|
import JobMutation from './views/jobs/mutation/JobMutation';
|
||||||
import UserMutator from './views/user/mutation/UserMutator';
|
import UserMutator from './views/user/mutation/UserMutator';
|
||||||
import JobInsight from './views/jobs/insights/JobInsight.jsx';
|
|
||||||
import { useActions, useSelector } from './services/state/store';
|
import { useActions, useSelector } from './services/state/store';
|
||||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import Login from './views/login/Login';
|
import Login from './views/login/Login';
|
||||||
@@ -25,8 +24,8 @@ import Listings from './views/listings/Listings.jsx';
|
|||||||
import Navigation from './components/navigation/Navigation.jsx';
|
import Navigation from './components/navigation/Navigation.jsx';
|
||||||
import { Layout } from '@douyinfe/semi-ui';
|
import { Layout } from '@douyinfe/semi-ui';
|
||||||
import FredyFooter from './components/footer/FredyFooter.jsx';
|
import FredyFooter from './components/footer/FredyFooter.jsx';
|
||||||
import ProcessingTimes from './views/jobs/ProcessingTimes.jsx';
|
|
||||||
import WatchlistManagement from './views/listings/management/WatchlistManagement.jsx';
|
import WatchlistManagement from './views/listings/management/WatchlistManagement.jsx';
|
||||||
|
import Dashboard from './views/dashboard/Dashboard.jsx';
|
||||||
|
|
||||||
export default function FredyApp() {
|
export default function FredyApp() {
|
||||||
const actions = useActions();
|
const actions = useActions();
|
||||||
@@ -34,7 +33,6 @@ export default function FredyApp() {
|
|||||||
const currentUser = useSelector((state) => state.user.currentUser);
|
const currentUser = useSelector((state) => state.user.currentUser);
|
||||||
const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
|
const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
|
||||||
const settings = useSelector((state) => state.generalSettings.settings);
|
const settings = useSelector((state) => state.generalSettings.settings);
|
||||||
const processingTimes = useSelector((state) => state.jobs.processingTimes);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function init() {
|
async function init() {
|
||||||
@@ -42,9 +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.getProcessingTimes();
|
await actions.jobsData.getSharableUserList();
|
||||||
await actions.jobs.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();
|
||||||
@@ -88,14 +85,13 @@ export default function FredyApp() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{settings.analyticsEnabled === null && !settings.demoMode && <TrackingModal />}
|
{settings.analyticsEnabled === null && !settings.demoMode && <TrackingModal />}
|
||||||
{processingTimes != null && <ProcessingTimes processingTimes={processingTimes} />}
|
|
||||||
<Divider />
|
<Divider />
|
||||||
<div className="app__content">
|
<div className="app__content">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/403" element={<InsufficientPermission />} />
|
<Route path="/403" element={<InsufficientPermission />} />
|
||||||
<Route path="/jobs/new" element={<JobMutation />} />
|
<Route path="/jobs/new" element={<JobMutation />} />
|
||||||
<Route path="/jobs/edit/:jobId" element={<JobMutation />} />
|
<Route path="/jobs/edit/:jobId" element={<JobMutation />} />
|
||||||
<Route path="/jobs/insights/:jobId" element={<JobInsight />} />
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
<Route path="/jobs" element={<Jobs />} />
|
<Route path="/jobs" element={<Jobs />} />
|
||||||
<Route path="/listings" element={<Listings />} />
|
<Route path="/listings" element={<Listings />} />
|
||||||
<Route path="/watchlistManagement" element={<WatchlistManagement />} />
|
<Route path="/watchlistManagement" element={<WatchlistManagement />} />
|
||||||
@@ -134,7 +130,7 @@ export default function FredyApp() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route path="/" element={<Navigate to="/jobs" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</Content>
|
</Content>
|
||||||
|
|||||||
@@ -9,17 +9,12 @@ import { HashRouter } from 'react-router-dom';
|
|||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import en_US from '@douyinfe/semi-ui/lib/es/locale/source/en_US';
|
import en_US from '@douyinfe/semi-ui/lib/es/locale/source/en_US';
|
||||||
import { LocaleProvider } from '@douyinfe/semi-ui';
|
import { LocaleProvider } from '@douyinfe/semi-ui';
|
||||||
import { initVChartSemiTheme } from '@visactor/vchart-semi-theme';
|
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import './Index.less';
|
import './Index.less';
|
||||||
|
|
||||||
const container = document.getElementById('fredy');
|
const container = document.getElementById('fredy');
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
|
|
||||||
initVChartSemiTheme({
|
|
||||||
defaultMode: 'dark',
|
|
||||||
});
|
|
||||||
|
|
||||||
root.render(
|
root.render(
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
<LocaleProvider locale={en_US}>
|
<LocaleProvider locale={en_US}>
|
||||||
|
|||||||
BIN
ui/src/assets/heart.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
23
ui/src/components/cards/ChartCard.less
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
.chartCard {
|
||||||
|
/* Use provided background with slight transparency and a brighter mix */
|
||||||
|
background: color-mix(in oklab, var(--card-bg, rgb(70 72 78)) 20%, white 80%);
|
||||||
|
border-radius: .6rem;
|
||||||
|
border: 1px solid color-mix(in oklab, var(--card-bg, rgb(70 72 78)) 35%, white 65%);
|
||||||
|
box-shadow: 0 6px 20px rgba(0,0,0,0.08);
|
||||||
|
/* Ensure base text has strong contrast */
|
||||||
|
color: var(--semi-color-text-0);
|
||||||
|
|
||||||
|
/* Semi Card header/title styling */
|
||||||
|
.semi-card-header .semi-card-header-title {
|
||||||
|
/* Derive a tinted title color with stronger contrast towards black */
|
||||||
|
color: color-mix(in oklab, var(--card-bg, rgb(70 72 78)) 60%, black 40%);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__no__data {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
height: 14rem;
|
||||||
|
opacity: .7;
|
||||||
|
}
|
||||||
|
}
|
||||||
92
ui/src/components/cards/DashboardCard.less
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
@import "DashboardCardColors.less";
|
||||||
|
|
||||||
|
.color-variant(@bg, @border, @text) {
|
||||||
|
background-color: @bg;
|
||||||
|
border: 1px solid @border;
|
||||||
|
color: @text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: .8rem;
|
||||||
|
border-radius: .5rem;
|
||||||
|
border-width: 1px;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 6px 20px rgba(0,0,0,0.08);
|
||||||
|
/* Make all KPI boxes the same size regardless of content/font */
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
height: 10rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
&.blue {
|
||||||
|
.color-variant(@color-blue-bg, @color-blue-border, @color-blue-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.orange {
|
||||||
|
.color-variant(@color-orange-bg, @color-orange-border, @color-orange-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.green {
|
||||||
|
.color-variant(@color-green-bg, @color-green-border, @color-green-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.purple {
|
||||||
|
.color-variant(@color-purple-bg, @color-purple-border, @color-purple-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.gray {
|
||||||
|
.color-variant(@color-gray-bg, @color-gray-border, @color-gray-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .6rem;
|
||||||
|
/* Keep header from growing content height */
|
||||||
|
min-height: 2rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
border-radius: .6rem;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
margin-top: .4rem;
|
||||||
|
font-size: .7rem;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__desc {
|
||||||
|
opacity: .8;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
19
ui/src/components/cards/DashboardCardColors.less
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
@color-blue-bg: rgba(0, 123, 255, 0.24);
|
||||||
|
@color-blue-border: #1E40AFFF;
|
||||||
|
@color-blue-text: #60a5fa;
|
||||||
|
|
||||||
|
@color-orange-bg: rgba(250, 91, 5, 0.12);
|
||||||
|
@color-orange-border: #992f0c;
|
||||||
|
@color-orange-text: #FB923CFF;
|
||||||
|
|
||||||
|
@color-green-bg: rgba(38, 250, 5, 0.12);
|
||||||
|
@color-green-border: #278832;
|
||||||
|
@color-green-text: #33f308;
|
||||||
|
|
||||||
|
@color-purple-bg: rgba(91, 3, 218, 0.38);
|
||||||
|
@color-purple-border: #7500c3;
|
||||||
|
@color-purple-text: #b15fff;
|
||||||
|
|
||||||
|
@color-gray-bg: rgba(110, 110, 110, 0.38);
|
||||||
|
@color-gray-border: #807f7f;
|
||||||
|
@color-gray-text: #bab9b9;
|
||||||
40
ui/src/components/cards/KpiCard.jsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import './DashboardCard.less';
|
||||||
|
|
||||||
|
export default function KpiCard({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
value,
|
||||||
|
valueFontSize = '1.5rem',
|
||||||
|
description,
|
||||||
|
color = 'gray',
|
||||||
|
children,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`dashboard-card ${color}`}>
|
||||||
|
<div className="dashboard-card__header">
|
||||||
|
<div className="dashboard-card__icon">{icon}</div>
|
||||||
|
<div className="dashboard-card__title">
|
||||||
|
<span>{title}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="dashboard-card__content">
|
||||||
|
<p className="dashboard-card__value" style={{ fontSize: valueFontSize }}>
|
||||||
|
{value}
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
{description && <span className="dashboard-card__desc">{description}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
ui/src/components/cards/PieChartCard.jsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Pie } from 'react-chartjs-2';
|
||||||
|
import { Chart as ChartJS, ArcElement, Tooltip, Legend, Title as ChartTitle } from 'chart.js';
|
||||||
|
|
||||||
|
import './ChartCard.less';
|
||||||
|
|
||||||
|
ChartJS.register(ArcElement, Tooltip, Legend, ChartTitle);
|
||||||
|
|
||||||
|
export default function PieChartCard({ data = [] }) {
|
||||||
|
const { labels, values } = React.useMemo(() => {
|
||||||
|
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
||||||
|
const lbls = Array.isArray(data.labels) ? data.labels : [];
|
||||||
|
const vals = Array.isArray(data.values)
|
||||||
|
? data.values.map((v) => (Number.isFinite(Number(v)) ? Number(v) : 0))
|
||||||
|
: [];
|
||||||
|
return { labels: lbls, values: vals };
|
||||||
|
}
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
const lbls = data.map((d) => d?.type ?? 'Unknown');
|
||||||
|
const vals = data.map((d) => {
|
||||||
|
const v = Number(d?.value);
|
||||||
|
return Number.isFinite(v) ? v : 0;
|
||||||
|
});
|
||||||
|
return { labels: lbls, values: vals };
|
||||||
|
}
|
||||||
|
return { labels: [], values: [] };
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const palette = React.useMemo(
|
||||||
|
() => [
|
||||||
|
'#4e79a7',
|
||||||
|
'#f28e2b',
|
||||||
|
'#e15759',
|
||||||
|
'#76b7b2',
|
||||||
|
'#59a14f',
|
||||||
|
'#edc948',
|
||||||
|
'#b07aa1',
|
||||||
|
'#ff9da7',
|
||||||
|
'#9c755f',
|
||||||
|
'#bab0ab',
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const chartData = React.useMemo(
|
||||||
|
() => ({
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: values,
|
||||||
|
backgroundColor: labels.map((_, i) => palette[i % palette.length]),
|
||||||
|
borderColor: labels.map((_, i) => palette[i % palette.length]),
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
[labels, values, palette],
|
||||||
|
);
|
||||||
|
|
||||||
|
const options = React.useMemo(
|
||||||
|
() => ({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: true,
|
||||||
|
position: 'right',
|
||||||
|
labels: {
|
||||||
|
color: () => '#fff',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
title: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx) => {
|
||||||
|
const label = ctx.label || '';
|
||||||
|
const val = ctx.parsed !== undefined ? ctx.parsed : ctx.raw;
|
||||||
|
return `${label}: ${val}%`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isEmpty = !labels || labels.length === 0 || !values || values.length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>{isEmpty ? <div className="chartCard__no__data">No Data</div> : <Pie data={chartData} options={options} />}</>
|
||||||
|
);
|
||||||
|
}
|
||||||
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
@@ -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
@@ -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
@@ -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,9 +1,10 @@
|
|||||||
.navigate {
|
.navigate {
|
||||||
&__logout_Button {
|
&__footer {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,26 +3,34 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Nav } from '@douyinfe/semi-ui';
|
import { Button, Nav } from '@douyinfe/semi-ui';
|
||||||
import { IconStar, IconSetting, IconTerminal } from '@douyinfe/semi-icons';
|
import { IconStar, IconSetting, IconTerminal, IconHistogram, IconSidebar } from '@douyinfe/semi-icons';
|
||||||
import logoWhite from '../../assets/logo_white.png';
|
import logoWhite from '../../assets/logo_white.png';
|
||||||
|
import heart from '../../assets/heart.png';
|
||||||
import Logout from '../logout/Logout.jsx';
|
import Logout from '../logout/Logout.jsx';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import './Navigate.less';
|
import './Navigate.less';
|
||||||
import { useScreenWidth } from '../../hooks/screenWidth.js';
|
|
||||||
import { useFeature } from '../../hooks/featureHook.js';
|
import { useFeature } from '../../hooks/featureHook.js';
|
||||||
|
import { useScreenWidth } from '../../hooks/screenWidth.js';
|
||||||
|
|
||||||
export default function Navigation({ isAdmin }) {
|
export default function Navigation({ isAdmin }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const width = useScreenWidth();
|
const width = useScreenWidth();
|
||||||
const collapsed = width <= 850;
|
const [collapsed, setCollapsed] = useState(width <= 850);
|
||||||
const watchlistFeature = useFeature('WATCHLIST_MANAGEMENT') || false;
|
const watchlistFeature = useFeature('WATCHLIST_MANAGEMENT') || false;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (width <= 850) {
|
||||||
|
setCollapsed(true);
|
||||||
|
}
|
||||||
|
}, [width]);
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
|
{ itemKey: '/dashboard', text: 'Dashboard', icon: <IconHistogram /> },
|
||||||
{ itemKey: '/jobs', text: 'Jobs', icon: <IconTerminal /> },
|
{ itemKey: '/jobs', text: 'Jobs', icon: <IconTerminal /> },
|
||||||
{ itemKey: '/listings', text: 'Listings', icon: <IconStar /> },
|
{ itemKey: '/listings', text: 'Listings', icon: <IconStar /> },
|
||||||
];
|
];
|
||||||
@@ -51,18 +59,21 @@ export default function Navigation({ isAdmin }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Nav
|
<Nav
|
||||||
style={{ height: '100%', width: collapsed ? '' : '13.2rem' }}
|
style={{ height: '100%' }}
|
||||||
items={items}
|
items={items}
|
||||||
isCollapsed={collapsed}
|
isCollapsed={collapsed}
|
||||||
selectedKeys={[parsePathName(location.pathname)]}
|
selectedKeys={[parsePathName(location.pathname)]}
|
||||||
onSelect={(key) => {
|
onSelect={(key) => {
|
||||||
navigate(key.itemKey);
|
navigate(key.itemKey);
|
||||||
}}
|
}}
|
||||||
header={<img src={logoWhite} width="180" alt="Fredy Logo" />}
|
header={<img src={collapsed ? heart : logoWhite} width={collapsed ? '80' : '160'} alt="Fredy Logo" />}
|
||||||
footer={
|
footer={
|
||||||
<div className="navigate__logout_Button">
|
<Nav.Footer className="navigate__footer">
|
||||||
<Logout text={!collapsed} />
|
<Logout text={!collapsed} />
|
||||||
</div>
|
<Button icon={<IconSidebar />} onClick={() => setCollapsed(!collapsed)}>
|
||||||
|
{!collapsed && 'Collapse'}
|
||||||
|
</Button>
|
||||||
|
</Nav.Footer>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,14 +8,16 @@ import { Card } from '@douyinfe/semi-ui';
|
|||||||
|
|
||||||
import './SegmentParts.less';
|
import './SegmentParts.less';
|
||||||
|
|
||||||
export const SegmentPart = ({ name, Icon = null, children, helpText }) => {
|
export const SegmentPart = ({ name, Icon = null, children, helpText = null }) => {
|
||||||
const { Meta } = Card;
|
const { Meta } = Card;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className="segmentParts"
|
className="segmentParts"
|
||||||
title={
|
title={
|
||||||
<Meta title={name} description={helpText} avatar={Icon == null ? null : <Icon size="extra-extra-small" />} />
|
(helpText || name) && (
|
||||||
|
<Meta title={name} description={helpText} avatar={Icon == null ? null : <Icon size="extra-extra-small" />} />
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.segmentParts {
|
.segmentParts {
|
||||||
border: 1px solid #323232 !important;
|
border: 1px solid #323232 !important;
|
||||||
border-radius: 5px !important;
|
border-radius: .9rem !important;
|
||||||
color: rgba(var(--semi-grey-8), 1);
|
color: rgba(var(--semi-grey-8), 1);
|
||||||
background: rgb(53, 54, 60);
|
background: rgb(53, 54, 60);
|
||||||
margin: 2rem;
|
margin: 2rem;
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (c) 2025 by Christian Kellner.
|
|
||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import { Button, Empty, Table, Switch, Popover } from '@douyinfe/semi-ui';
|
|
||||||
import { IconAlertTriangle, IconDelete, IconDescend2, IconEdit, IconHistogram } 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,
|
|
||||||
onJobInsight,
|
|
||||||
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('Job Insights')}>
|
|
||||||
<Button
|
|
||||||
type="primary"
|
|
||||||
icon={<IconHistogram />}
|
|
||||||
disabled={job.isOnlyShared}
|
|
||||||
onClick={() => onJobInsight(job.id)}
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
<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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
85
ui/src/services/backupRestoreClient.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight client for Backup & Restore interactions with the backend.
|
||||||
|
*
|
||||||
|
* Usage (in React components):
|
||||||
|
* ```js
|
||||||
|
* import { downloadBackup, precheckRestore, restore } from '../../services/backupRestoreClient';
|
||||||
|
* await downloadBackup();
|
||||||
|
* const info = await precheckRestore(file);
|
||||||
|
* await restore(file, false);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
function extractFileNameFromDisposition(disposition) {
|
||||||
|
const dispo = disposition || '';
|
||||||
|
const match = dispo.match(/filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/);
|
||||||
|
return decodeURIComponent(match?.[1] || match?.[2] || 'FredyBackup.zip');
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BackupRestoreClient {
|
||||||
|
/**
|
||||||
|
* Trigger a backup download and save it using the filename provided by the server.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
static async downloadBackup() {
|
||||||
|
const resp = await fetch('/api/admin/backup', { credentials: 'include' });
|
||||||
|
if (!resp.ok) throw new Error('Failed to create backup');
|
||||||
|
const blob = await resp.blob();
|
||||||
|
const fileName = extractFileNameFromDisposition(resp.headers.get('Content-Disposition'));
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = fileName;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a backup zip for analysis without restoring.
|
||||||
|
* @param {Blob|ArrayBuffer|Buffer} file - Backup zip content.
|
||||||
|
* @returns {Promise<{compatible:boolean,severity:string,message:string,backupMigration:number|null,requiredMigration:number,fredyVersion?:string|null}>>}
|
||||||
|
*/
|
||||||
|
static async precheckRestore(file) {
|
||||||
|
const resp = await fetch('/api/admin/backup/restore?dryRun=true', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/zip' },
|
||||||
|
body: file,
|
||||||
|
});
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform a database restore from a backup zip.
|
||||||
|
* @param {Blob|ArrayBuffer|Buffer} file - Backup zip content.
|
||||||
|
* @param {boolean} force - When true, proceed even if reported incompatible.
|
||||||
|
* @returns {Promise<{restored:true,warning:string|null,details:any}>}
|
||||||
|
*/
|
||||||
|
static async restore(file, force) {
|
||||||
|
const resp = await fetch(`/api/admin/backup/restore?force=${force ? 'true' : 'false'}`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/zip' },
|
||||||
|
body: file,
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = new Error(data?.message || 'Restore failed');
|
||||||
|
err.payload = data;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience named exports
|
||||||
|
export const downloadBackup = (...args) => BackupRestoreClient.downloadBackup(...args);
|
||||||
|
export const precheckRestore = (...args) => BackupRestoreClient.precheckRestore(...args);
|
||||||
|
export const restore = (...args) => BackupRestoreClient.restore(...args);
|
||||||
@@ -33,6 +33,16 @@ export const useFredyState = create(
|
|||||||
(set) => {
|
(set) => {
|
||||||
// Async actions that directly set state (no separate reducer concept)
|
// Async actions that directly set state (no separate reducer concept)
|
||||||
const effects = {
|
const effects = {
|
||||||
|
dashboard: {
|
||||||
|
async getDashboard() {
|
||||||
|
try {
|
||||||
|
const response = await xhrGet('/api/dashboard');
|
||||||
|
set((state) => ({ dashboard: { ...state.dashboard, data: response.json } }));
|
||||||
|
} catch (Exception) {
|
||||||
|
console.error('Error while trying to get resource for /api/dashboard. Error:', Exception);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
notificationAdapter: {
|
notificationAdapter: {
|
||||||
async getAdapter() {
|
async getAdapter() {
|
||||||
try {
|
try {
|
||||||
@@ -73,43 +83,58 @@ 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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async getProcessingTimes() {
|
setJobRunning(jobId, running) {
|
||||||
try {
|
if (!jobId) return;
|
||||||
const response = await xhrGet('/api/jobs/processingTimes');
|
set((state) => {
|
||||||
set((state) => ({ jobs: { ...state.jobs, processingTimes: Object.freeze(response.json) } }));
|
const list = state.jobsData.jobs || [];
|
||||||
} catch (Exception) {
|
const updated = list.map((j) => (j.id === jobId ? { ...j, running: !!running } : j));
|
||||||
console.error(`Error while trying to get resource for api/processingTimes. Error:`, Exception);
|
const result = (state.jobsData.result || []).map((j) =>
|
||||||
}
|
j.id === jobId ? { ...j, running: !!running } : j,
|
||||||
},
|
);
|
||||||
async getInsightDataForJob(jobId) {
|
return { jobsData: { ...state.jobsData, jobs: Object.freeze(updated), result: Object.freeze(result) } };
|
||||||
try {
|
});
|
||||||
const response = await xhrGet(`/api/jobs/insights/${jobId}`);
|
|
||||||
set((state) => ({
|
|
||||||
jobs: {
|
|
||||||
...state.jobs,
|
|
||||||
insights: { ...state.jobs.insights, [jobId]: Object.freeze(response.json) },
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
} catch (Exception) {
|
|
||||||
console.error(`Error while trying to get resource for api/jobs/insights. Error:`, Exception);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
@@ -154,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,
|
||||||
@@ -174,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);
|
||||||
@@ -185,8 +210,9 @@ export const useFredyState = create(
|
|||||||
|
|
||||||
// Initial state
|
// Initial state
|
||||||
const initial = {
|
const initial = {
|
||||||
|
dashboard: { data: null },
|
||||||
notificationAdapter: [],
|
notificationAdapter: [],
|
||||||
listingsTable: {
|
listingsData: {
|
||||||
totalNumber: 0,
|
totalNumber: 0,
|
||||||
page: 1,
|
page: 1,
|
||||||
result: [],
|
result: [],
|
||||||
@@ -196,20 +222,27 @@ export const useFredyState = create(
|
|||||||
demoMode: { demoMode: false },
|
demoMode: { demoMode: false },
|
||||||
versionUpdate: {},
|
versionUpdate: {},
|
||||||
provider: [],
|
provider: [],
|
||||||
jobs: { jobs: [], insights: {}, processingTimes: {}, shareableUserList: [] },
|
jobsData: {
|
||||||
|
jobs: [],
|
||||||
|
shareableUserList: [],
|
||||||
|
totalNumber: 0,
|
||||||
|
page: 1,
|
||||||
|
result: [],
|
||||||
|
},
|
||||||
user: { users: [], currentUser: null },
|
user: { users: [], currentUser: null },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Expose actions by grouping them per slice
|
// Expose actions by grouping them per slice
|
||||||
const actions = {
|
const actions = {
|
||||||
|
dashboard: { ...effects.dashboard },
|
||||||
notificationAdapter: { ...effects.notificationAdapter },
|
notificationAdapter: { ...effects.notificationAdapter },
|
||||||
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 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
156
ui/src/views/dashboard/Dashboard.jsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Button, Col, Row, Toast } from '@douyinfe/semi-ui';
|
||||||
|
import {
|
||||||
|
IconTerminal,
|
||||||
|
IconStar,
|
||||||
|
IconClock,
|
||||||
|
IconDoubleChevronLeft,
|
||||||
|
IconDoubleChevronRight,
|
||||||
|
IconStarStroked,
|
||||||
|
IconNoteMoney,
|
||||||
|
IconSearch,
|
||||||
|
IconPlayCircle,
|
||||||
|
} from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
|
import { useSelector, useActions } from '../../services/state/store';
|
||||||
|
import KpiCard from '../../components/cards/KpiCard.jsx';
|
||||||
|
import PieChartCard from '../../components/cards/PieChartCard.jsx';
|
||||||
|
import Headline from '../../components/headline/Headline.jsx';
|
||||||
|
|
||||||
|
import './Dashboard.less';
|
||||||
|
import { SegmentPart } from '../../components/segment/SegmentPart.jsx';
|
||||||
|
import { xhrPost } from '../../services/xhr.js';
|
||||||
|
import { format } from '../../services/time/timeService.js';
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const actions = useActions();
|
||||||
|
const dashboard = useSelector((state) => state.dashboard.data);
|
||||||
|
React.useEffect(() => {
|
||||||
|
actions.dashboard.getDashboard();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const kpis = dashboard?.kpis || { totalJobs: 0, totalListings: 0, providersUsed: 0 };
|
||||||
|
const pieData = dashboard?.pie || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dashboard">
|
||||||
|
<Headline text="Dashboard" size={3} />
|
||||||
|
|
||||||
|
<Row gutter={16} className="dashboard__row">
|
||||||
|
<Col span={12} xs={24} sm={24} md={24} lg={24} xl={12}>
|
||||||
|
<SegmentPart name="General" Icon={IconTerminal}>
|
||||||
|
<Row gutter={16} className="dashboard__row">
|
||||||
|
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||||
|
<KpiCard
|
||||||
|
title="Search Interval"
|
||||||
|
value={`${dashboard?.general?.interval} min`}
|
||||||
|
icon={<IconClock />}
|
||||||
|
description="Time interval for job execution"
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||||
|
<KpiCard
|
||||||
|
title="Last Search"
|
||||||
|
valueFontSize="14px"
|
||||||
|
value={
|
||||||
|
dashboard?.general?.lastRun == null || dashboard?.general?.lastRun === 0
|
||||||
|
? '---'
|
||||||
|
: format(dashboard?.general?.lastRun)
|
||||||
|
}
|
||||||
|
icon={<IconDoubleChevronLeft />}
|
||||||
|
description="Last execution timestamp"
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||||
|
<KpiCard
|
||||||
|
title="Next Search"
|
||||||
|
value={
|
||||||
|
dashboard?.general?.nextRun == null || dashboard?.general?.nextRun === 0
|
||||||
|
? '---'
|
||||||
|
: format(dashboard?.general?.nextRun)
|
||||||
|
}
|
||||||
|
valueFontSize="14px"
|
||||||
|
icon={<IconDoubleChevronRight />}
|
||||||
|
description="Next execution timestamp"
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||||
|
<KpiCard title="Search Now" icon={<IconSearch />} description="Run a search now">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
style={{ marginTop: '.2rem' }}
|
||||||
|
icon={<IconPlayCircle />}
|
||||||
|
aria-label="Start now"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await xhrPost('/api/jobs/startAll', null);
|
||||||
|
Toast.success('Successfully triggered Fredy search.');
|
||||||
|
} catch {
|
||||||
|
Toast.error('Failed to trigger search');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Search now
|
||||||
|
</Button>
|
||||||
|
</KpiCard>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</SegmentPart>
|
||||||
|
</Col>
|
||||||
|
<Col span={12} xs={24} sm={24} md={24} lg={24} xl={12}>
|
||||||
|
<SegmentPart name="Overview" Icon={IconStar}>
|
||||||
|
<Row gutter={16} className="dashboard__row">
|
||||||
|
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||||
|
<KpiCard
|
||||||
|
title="Jobs"
|
||||||
|
color="blue"
|
||||||
|
value={!kpis.totalJobs ? '---' : kpis.totalJobs}
|
||||||
|
icon={<IconTerminal />}
|
||||||
|
description="Total number of jobs"
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||||
|
<KpiCard
|
||||||
|
title="Listings"
|
||||||
|
color="orange"
|
||||||
|
value={!kpis.totalListings ? '---' : kpis.totalListings}
|
||||||
|
icon={<IconStarStroked />}
|
||||||
|
description="Total listings found"
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||||
|
<KpiCard
|
||||||
|
title="Active Listings"
|
||||||
|
color="green"
|
||||||
|
value={!kpis.numberOfActiveListings ? '---' : kpis.numberOfActiveListings}
|
||||||
|
icon={<IconStar />}
|
||||||
|
description="Total active listings"
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||||
|
<KpiCard
|
||||||
|
title="Avg. Price"
|
||||||
|
color="purple"
|
||||||
|
value={`${!kpis.avgPriceOfListings ? '---' : kpis.avgPriceOfListings} EUR`}
|
||||||
|
icon={<IconNoteMoney />}
|
||||||
|
description="Avg. Price of listings"
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</SegmentPart>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<SegmentPart name="Provider Insights" Icon={IconStar} helpText="Percentage of found listings over all providers">
|
||||||
|
<PieChartCard title="Jobs per Provider" data={pieData} isLoading={false} />
|
||||||
|
</SegmentPart>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Dashboard.displayName = 'Dashboard';
|
||||||
11
ui/src/views/dashboard/Dashboard.less
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.dashboard {
|
||||||
|
&__row {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
/* Ensure grid items wrap to next line on narrow screens */
|
||||||
|
flex-wrap: wrap;
|
||||||
|
/* Vertical gap of 1rem between wrapped grid items (no px) */
|
||||||
|
.semi-col {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,11 +7,16 @@ import React from 'react';
|
|||||||
|
|
||||||
import { useActions, useSelector } from '../../services/state/store';
|
import { useActions, useSelector } from '../../services/state/store';
|
||||||
|
|
||||||
import { Divider, TimePicker, Button, Checkbox, Input } from '@douyinfe/semi-ui';
|
import { Divider, TimePicker, Button, Checkbox, Input, Modal } from '@douyinfe/semi-ui';
|
||||||
import { InputNumber } from '@douyinfe/semi-ui';
|
import { InputNumber } from '@douyinfe/semi-ui';
|
||||||
import { xhrPost } from '../../services/xhr';
|
import { xhrPost } from '../../services/xhr';
|
||||||
import { SegmentPart } from '../../components/segment/SegmentPart';
|
import { SegmentPart } from '../../components/segment/SegmentPart';
|
||||||
import { Banner, Toast } from '@douyinfe/semi-ui';
|
import { Banner, Toast } from '@douyinfe/semi-ui';
|
||||||
|
import {
|
||||||
|
downloadBackup as downloadBackupZip,
|
||||||
|
precheckRestore as clientPrecheckRestore,
|
||||||
|
restore as clientRestore,
|
||||||
|
} from '../../services/backupRestoreClient';
|
||||||
import {
|
import {
|
||||||
IconSave,
|
IconSave,
|
||||||
IconCalendar,
|
IconCalendar,
|
||||||
@@ -52,6 +57,11 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
const [demoMode, setDemoMode] = React.useState(null);
|
const [demoMode, setDemoMode] = React.useState(null);
|
||||||
const [analyticsEnabled, setAnalyticsEnabled] = React.useState(null);
|
const [analyticsEnabled, setAnalyticsEnabled] = React.useState(null);
|
||||||
const [sqlitePath, setSqlitePath] = React.useState(null);
|
const [sqlitePath, setSqlitePath] = React.useState(null);
|
||||||
|
const fileInputRef = React.useRef(null);
|
||||||
|
const [restoreModalVisible, setRestoreModalVisible] = React.useState(false);
|
||||||
|
const [precheckInfo, setPrecheckInfo] = React.useState(null);
|
||||||
|
const [restoreBusy, setRestoreBusy] = React.useState(false);
|
||||||
|
const [selectedRestoreFile, setSelectedRestoreFile] = React.useState(null);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
async function init() {
|
async function init() {
|
||||||
@@ -78,7 +88,7 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
|
|
||||||
const nullOrEmpty = (val) => val == null || val.length === 0;
|
const nullOrEmpty = (val) => val == null || val.length === 0;
|
||||||
|
|
||||||
const onStore = async () => {
|
const handleStore = async () => {
|
||||||
if (nullOrEmpty(interval)) {
|
if (nullOrEmpty(interval)) {
|
||||||
Toast.error('Interval may not be empty.');
|
Toast.error('Interval may not be empty.');
|
||||||
return;
|
return;
|
||||||
@@ -125,6 +135,60 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
}, 3000);
|
}, 3000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDownloadBackup = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await downloadBackupZip();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
Toast.error('Unexpected error while downloading backup.');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const precheckRestore = React.useCallback(async (file) => {
|
||||||
|
try {
|
||||||
|
const data = await clientPrecheckRestore(file);
|
||||||
|
setPrecheckInfo(data);
|
||||||
|
setRestoreModalVisible(true);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
Toast.error('Failed to analyze backup.');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const performRestore = React.useCallback(
|
||||||
|
async (force) => {
|
||||||
|
try {
|
||||||
|
setRestoreBusy(true);
|
||||||
|
await clientRestore(selectedRestoreFile, force);
|
||||||
|
Toast.success('Restore completed. Please restart the Fredy backend now!');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
Toast.error(e?.message || 'Unexpected error while restoring backup.');
|
||||||
|
} finally {
|
||||||
|
setRestoreBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedRestoreFile],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelectRestoreFile = React.useCallback(
|
||||||
|
async (ev) => {
|
||||||
|
const file = ev?.target?.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setSelectedRestoreFile(file);
|
||||||
|
await precheckRestore(file);
|
||||||
|
// reset the input to allow same file re-select
|
||||||
|
ev.target.value = '';
|
||||||
|
},
|
||||||
|
[precheckRestore],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenFilePicker = React.useCallback(() => {
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.click();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{!loading && (
|
{!loading && (
|
||||||
@@ -146,6 +210,28 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
/>
|
/>
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
<Divider margin="1rem" />
|
<Divider margin="1rem" />
|
||||||
|
<SegmentPart
|
||||||
|
name="Backup & Restore"
|
||||||
|
helpText="Download a zipped backup of your database or restore it from a backup zip."
|
||||||
|
Icon={IconSave}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<Button theme="solid" icon={<IconSave />} onClick={handleDownloadBackup}>
|
||||||
|
Download backup
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".zip,application/zip"
|
||||||
|
ref={fileInputRef}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleSelectRestoreFile}
|
||||||
|
/>
|
||||||
|
<Button onClick={handleOpenFilePicker} theme="light" icon={<IconFolder />}>
|
||||||
|
Restore from zip
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</SegmentPart>
|
||||||
|
<Divider margin="1rem" />
|
||||||
<SegmentPart name="Port" helpText="Port on which Fredy is running." Icon={IconSignal}>
|
<SegmentPart name="Port" helpText="Port on which Fredy is running." Icon={IconSignal}>
|
||||||
<InputNumber
|
<InputNumber
|
||||||
min={0}
|
min={0}
|
||||||
@@ -271,12 +357,55 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
|
|
||||||
<Divider margin="1rem" />
|
<Divider margin="1rem" />
|
||||||
<Button type="primary" theme="solid" onClick={onStore} icon={<IconSave />}>
|
<Button type="primary" theme="solid" onClick={handleStore} icon={<IconSave />}>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)}
|
)}
|
||||||
|
{restoreModalVisible && (
|
||||||
|
<Modal
|
||||||
|
title="Restore database"
|
||||||
|
visible={restoreModalVisible}
|
||||||
|
onCancel={() => setRestoreModalVisible(false)}
|
||||||
|
onOk={() => performRestore(!precheckInfo?.compatible)}
|
||||||
|
okText={precheckInfo?.compatible ? 'Restore now' : 'Restore anyway'}
|
||||||
|
okType={precheckInfo?.compatible ? 'primary' : 'danger'}
|
||||||
|
confirmLoading={restoreBusy}
|
||||||
|
>
|
||||||
|
{precheckInfo?.severity === 'danger' && (
|
||||||
|
<Banner
|
||||||
|
type="danger"
|
||||||
|
fullMode={false}
|
||||||
|
closeIcon={null}
|
||||||
|
title={<div style={{ fontWeight: 600, fontSize: '14px' }}>Problem detected</div>}
|
||||||
|
description={<div>{precheckInfo?.message}</div>}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{precheckInfo?.severity === 'warning' && (
|
||||||
|
<Banner
|
||||||
|
type="warning"
|
||||||
|
fullMode={false}
|
||||||
|
closeIcon={null}
|
||||||
|
title={<div style={{ fontWeight: 600, fontSize: '14px' }}>Automatic migrations will be applied</div>}
|
||||||
|
description={<div>{precheckInfo?.message}</div>}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{precheckInfo?.severity === 'info' && (
|
||||||
|
<Banner
|
||||||
|
type="success"
|
||||||
|
fullMode={false}
|
||||||
|
closeIcon={null}
|
||||||
|
title={<div style={{ fontWeight: 600, fontSize: '14px' }}>Backup is compatible</div>}
|
||||||
|
description={<div>{precheckInfo?.message}</div>}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div style={{ marginTop: '0.5rem', fontSize: '12px', color: 'var(--semi-color-text-2)' }}>
|
||||||
|
Backup migration: {precheckInfo?.backupMigration ?? 'unknown'} | Required migration:{' '}
|
||||||
|
{precheckInfo?.requiredMigration ?? 'unknown'}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,70 +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}
|
|
||||||
onJobInsight={(jobId) => navigate(`/jobs/insights/${jobId}`)}
|
|
||||||
onJobEdit={(jobId) => navigate(`/jobs/edit/${jobId}`)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,102 +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 { format } from '../../services/time/timeService';
|
|
||||||
import { Button, Card, Col, Row, Toast } from '@douyinfe/semi-ui';
|
|
||||||
import {
|
|
||||||
IconClock,
|
|
||||||
IconDoubleChevronLeft,
|
|
||||||
IconDoubleChevronRight,
|
|
||||||
IconPlayCircle,
|
|
||||||
IconSearch,
|
|
||||||
} from '@douyinfe/semi-icons';
|
|
||||||
import { xhrPost } from '../../services/xhr.js';
|
|
||||||
|
|
||||||
import './ProsessingTimes.less';
|
|
||||||
import { useScreenWidth } from '../../hooks/screenWidth.js';
|
|
||||||
|
|
||||||
function InfoCard({ title, value, icon }) {
|
|
||||||
const { Meta } = Card;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
margin: '1rem',
|
|
||||||
background: 'rgb(53, 54, 60)',
|
|
||||||
borderRadius: '.3rem',
|
|
||||||
padding: '1rem',
|
|
||||||
minHeight: '3rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Meta title={title} description={value} avatar={icon} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProcessingTimes({ processingTimes = {} }) {
|
|
||||||
if (Object.keys(processingTimes).length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const width = useScreenWidth();
|
|
||||||
const invisible = width <= 1180;
|
|
||||||
|
|
||||||
if (invisible) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Row>
|
|
||||||
<Col span={6}>
|
|
||||||
<InfoCard
|
|
||||||
title="Search Interval"
|
|
||||||
value={`${processingTimes.interval} min`}
|
|
||||||
icon={<IconClock style={{ color: 'rgba(var(--semi-grey-4), 1)' }} />}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
{processingTimes.lastRun && (
|
|
||||||
<>
|
|
||||||
<Col span={6}>
|
|
||||||
<InfoCard
|
|
||||||
title="Last search"
|
|
||||||
icon={<IconDoubleChevronLeft style={{ color: 'rgba(var(--semi-grey-4), 1)' }} />}
|
|
||||||
value={format(processingTimes.lastRun)}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
<Col span={6}>
|
|
||||||
<InfoCard
|
|
||||||
title="Next search"
|
|
||||||
icon={<IconDoubleChevronRight style={{ color: 'rgba(var(--semi-grey-4), 1)' }} />}
|
|
||||||
value={format(processingTimes.lastRun + processingTimes.interval * 60000)}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Col span={6}>
|
|
||||||
<InfoCard
|
|
||||||
title="Search Now"
|
|
||||||
icon={<IconSearch style={{ color: 'rgba(var(--semi-grey-4), 1)' }} />}
|
|
||||||
value={
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
style={{ marginTop: '.2rem' }}
|
|
||||||
icon={<IconPlayCircle />}
|
|
||||||
aria-label="Start now"
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
await xhrPost('/api/jobs/startAll', null);
|
|
||||||
Toast.success('Successfully triggered Fredy search.');
|
|
||||||
} catch {
|
|
||||||
Toast.error('Failed to trigger search');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Search now
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
.processingTimes {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
@@ -1,91 +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 { roundToHour } from '../../../services/time/timeService';
|
|
||||||
import Headline from '../../../components/headline/Headline';
|
|
||||||
import { useActions, useSelector } from '../../../services/state/store';
|
|
||||||
import { useParams } from 'react-router-dom';
|
|
||||||
import Linechart from './Linechart';
|
|
||||||
|
|
||||||
const JobInsight = function JobInsight() {
|
|
||||||
const actions = useActions();
|
|
||||||
|
|
||||||
const insights = useSelector((state) => state.jobs.insights);
|
|
||||||
const jobs = useSelector((state) => state.jobs.jobs);
|
|
||||||
const params = useParams();
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
actions.jobs.getInsightDataForJob(params.jobId);
|
|
||||||
actions.jobs.getJobs();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getData = () => {
|
|
||||||
const data = insights[params.jobId] || {};
|
|
||||||
const providers = Object.keys(data);
|
|
||||||
|
|
||||||
const countsByProvider = {};
|
|
||||||
const allTimes = new Set();
|
|
||||||
|
|
||||||
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : 'Unknown');
|
|
||||||
|
|
||||||
providers.forEach((key) => {
|
|
||||||
const providerName = cap(key);
|
|
||||||
const tmpTimeObj = {};
|
|
||||||
|
|
||||||
Object.values(data[key] || {}).forEach((listingTs) => {
|
|
||||||
const time = roundToHour(listingTs);
|
|
||||||
tmpTimeObj[time] = tmpTimeObj[time] == null ? 1 : tmpTimeObj[time] + 1;
|
|
||||||
allTimes.add(time);
|
|
||||||
});
|
|
||||||
|
|
||||||
countsByProvider[providerName] = tmpTimeObj;
|
|
||||||
});
|
|
||||||
|
|
||||||
const sortedTimes = Array.from(allTimes).sort((a, b) => a - b);
|
|
||||||
|
|
||||||
const result = [];
|
|
||||||
providers.forEach((key) => {
|
|
||||||
const providerName = cap(key);
|
|
||||||
const bucket = countsByProvider[providerName] || {};
|
|
||||||
|
|
||||||
sortedTimes.forEach((t) => {
|
|
||||||
result.push({
|
|
||||||
listings: new Intl.DateTimeFormat('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: false,
|
|
||||||
}).format(new Date(parseInt(t))),
|
|
||||||
listingsNumber: bucket[t] || 0, // y value
|
|
||||||
provider: providerName, // series key
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getJobName = () => {
|
|
||||||
const job = jobs.find((job) => job.id === params.jobId);
|
|
||||||
if (job == null) {
|
|
||||||
return 'unknown';
|
|
||||||
} else {
|
|
||||||
return job.name;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Headline text={`Insights into Job: ${getJobName()}`} />
|
|
||||||
<Linechart isLoading={false} series={getData()} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default JobInsight;
|
|
||||||
@@ -1,57 +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 Placeholder from '../../../components/placeholder/Placeholder';
|
|
||||||
import { VChart } from '@visactor/react-vchart';
|
|
||||||
|
|
||||||
import './Linechart.less';
|
|
||||||
|
|
||||||
const commonSpec = {
|
|
||||||
type: 'line',
|
|
||||||
xField: 'listings',
|
|
||||||
yField: 'listingsNumber',
|
|
||||||
seriesField: 'provider',
|
|
||||||
legends: { visible: true },
|
|
||||||
line: {
|
|
||||||
style: {
|
|
||||||
lineWidth: 2,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
point: {
|
|
||||||
visible: false,
|
|
||||||
},
|
|
||||||
axes: [
|
|
||||||
{
|
|
||||||
orient: 'bottom',
|
|
||||||
field: 'listings',
|
|
||||||
zero: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const Linechart = function Linechart({ title, series, isLoading = false }) {
|
|
||||||
return (
|
|
||||||
<Placeholder ready={!isLoading} rows={6}>
|
|
||||||
{series == null || series.length === 0 ? (
|
|
||||||
<div className="linechart__no__data">No Data for selected timeframe :-/</div>
|
|
||||||
) : (
|
|
||||||
<VChart
|
|
||||||
spec={{
|
|
||||||
...commonSpec,
|
|
||||||
title: {
|
|
||||||
visible: true,
|
|
||||||
text: title,
|
|
||||||
},
|
|
||||||
data: { values: series },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Placeholder>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Linechart;
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
.linechart {
|
|
||||||
&__no__data {
|
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: #06dcfff2;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
&__height {
|
|
||||||
height: 30.7rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export default function Login() {
|
|||||||
Toast.success('Login successful!');
|
Toast.success('Login successful!');
|
||||||
|
|
||||||
await actions.user.getCurrentUser();
|
await actions.user.getCurrentUser();
|
||||||
navigate('/jobs');
|
navigate('/dashboard');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||