mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
398259ff20 | ||
|
|
cf030bfa39 | ||
|
|
5dc976c7e3 | ||
|
|
05f1bc61c9 | ||
|
|
6e8a35a836 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
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;
|
||||||
@@ -22,6 +22,7 @@ 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 { 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;
|
||||||
@@ -40,6 +41,7 @@ service.use('/api/features', authInterceptor());
|
|||||||
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/admin/users', userRouter);
|
service.use('/api/admin/users', userRouter);
|
||||||
service.use('/api/version', versionRouter);
|
service.use('/api/version', versionRouter);
|
||||||
|
|||||||
75
lib/api/routes/backupRouter.js
Normal file
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 };
|
||||||
@@ -9,6 +9,8 @@ import * as userStorage from '../../services/storage/userStorage.js';
|
|||||||
import { isAdmin } from '../security.js';
|
import { isAdmin } from '../security.js';
|
||||||
import logger from '../../services/logger.js';
|
import logger from '../../services/logger.js';
|
||||||
import { bus } from '../../services/events/event-bus.js';
|
import { bus } from '../../services/events/event-bus.js';
|
||||||
|
import { isRunning as isJobRunning } from '../../services/jobs/run-state.js';
|
||||||
|
import { addClient as addSseClient, removeClient } from '../../services/sse/sse-broker.js';
|
||||||
|
|
||||||
const service = restana();
|
const service = restana();
|
||||||
const jobRouter = service.newRouter();
|
const jobRouter = service.newRouter();
|
||||||
@@ -37,6 +39,7 @@ jobRouter.get('/', async (req, res) => {
|
|||||||
.map((job) => {
|
.map((job) => {
|
||||||
return {
|
return {
|
||||||
...job,
|
...job,
|
||||||
|
running: isJobRunning(job.id),
|
||||||
isOnlyShared:
|
isOnlyShared:
|
||||||
!isUserAdmin &&
|
!isUserAdmin &&
|
||||||
job.userId !== req.session.currentUser &&
|
job.userId !== req.session.currentUser &&
|
||||||
@@ -47,9 +50,73 @@ jobRouter.get('/', async (req, res) => {
|
|||||||
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) => {
|
||||||
|
|||||||
187
lib/services/jobs/jobExecutionService.js
Normal file
187
lib/services/jobs/jobExecutionService.js
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import logger from '../logger.js';
|
||||||
|
import { bus } from '../events/event-bus.js';
|
||||||
|
import * as jobStorage from '../storage/jobStorage.js';
|
||||||
|
import * as userStorage from '../storage/userStorage.js';
|
||||||
|
import { getUser } from '../storage/userStorage.js';
|
||||||
|
import { duringWorkingHoursOrNotSet } from '../../utils.js';
|
||||||
|
import FredyPipelineExecutioner from '../../FredyPipelineExecutioner.js';
|
||||||
|
import * as similarityCache from '../similarity-check/similarityCache.js';
|
||||||
|
import { isRunning, markFinished, markRunning } from './run-state.js';
|
||||||
|
import { sendToUsers } from '../sse/sse-broker.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the job execution service.
|
||||||
|
* - Registers event-bus listeners for `jobs:runAll`, `jobs:runOne`, and `jobs:status`.
|
||||||
|
* - Starts the periodic scheduler (if `intervalMs` > 0) and performs an initial run respecting working hours.
|
||||||
|
* - Forwards job status updates to affected users via Server-Sent Events (SSE).
|
||||||
|
*
|
||||||
|
* This function is intentionally side-effectful and exposes no external API.
|
||||||
|
*
|
||||||
|
* @param {Object} deps - Dependencies required to initialize the service.
|
||||||
|
* @param {Array<Object>} deps.providers - Loaded provider modules. Each module must expose `metaInformation.id`, `config`, and `init(config, blacklist)`.
|
||||||
|
* @param {Object} deps.settings - Global settings object (read/write). Must include `demoMode`, `interval`, and working-hours attributes used by `duringWorkingHoursOrNotSet`.
|
||||||
|
* @param {number} deps.intervalMs - Scheduler interval in milliseconds. If not finite or <= 0, the scheduler is not started.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function initJobExecutionService({ providers, settings, intervalMs }) {
|
||||||
|
// Forward job status via SSE to relevant recipients
|
||||||
|
bus.on('jobs:status', ({ jobId, running }) => {
|
||||||
|
try {
|
||||||
|
const recipients = resolveRecipients(jobId);
|
||||||
|
if (recipients.length > 0) {
|
||||||
|
sendToUsers(recipients, 'jobStatus', { jobId, running });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to forward job status', jobId, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for "run all" requests (admin = all, user = own)
|
||||||
|
bus.on('jobs:runAll', (payload) => {
|
||||||
|
const userId = payload?.userId ?? null;
|
||||||
|
const user = userId ? getUser(userId) : null;
|
||||||
|
const isAdmin = !!user?.isAdmin;
|
||||||
|
if (isAdmin) {
|
||||||
|
logger.debug('Running all jobs manually (admin request)');
|
||||||
|
} else if (userId) {
|
||||||
|
logger.debug(`Running all jobs manually for user ${userId}`);
|
||||||
|
} else {
|
||||||
|
logger.debug('Running all jobs manually (no user provided)');
|
||||||
|
}
|
||||||
|
runAll(false, { userId, isAdmin });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for single job run requests
|
||||||
|
bus.on('jobs:runOne', ({ jobId }) => {
|
||||||
|
logger.debug(`Running single job manually: ${jobId}`);
|
||||||
|
// fire and forget, do not block the bus
|
||||||
|
runSingle(jobId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start scheduler and initial run
|
||||||
|
if (Number.isFinite(intervalMs) && intervalMs > 0) {
|
||||||
|
setInterval(() => runAll(true), intervalMs);
|
||||||
|
}
|
||||||
|
// start once at startup, respecting working hours
|
||||||
|
runAll(true);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve all recipients who should receive SSE updates for a job.
|
||||||
|
* Includes job owner, users with whom the job is shared, and all admins.
|
||||||
|
*
|
||||||
|
* @param {string} jobId
|
||||||
|
* @returns {string[]} unique userIds
|
||||||
|
*/
|
||||||
|
function resolveRecipients(jobId) {
|
||||||
|
const job = jobStorage.getJob(jobId);
|
||||||
|
if (!job) return [];
|
||||||
|
const admins = (userStorage.getUsers && userStorage.getUsers(false)) || [];
|
||||||
|
const adminIds = admins.filter((u) => u.isAdmin).map((u) => u.id);
|
||||||
|
const shared = Array.isArray(job.shared_with_user) ? job.shared_with_user : [];
|
||||||
|
const recipients = [job.userId, ...shared, ...adminIds].filter(Boolean);
|
||||||
|
return Array.from(new Set(recipients));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute all enabled jobs, optionally filtering by context (admin/owner) and respecting working hours.
|
||||||
|
*
|
||||||
|
* @param {boolean} [respectWorkingHours=true] - If true, skip execution when outside configured working hours.
|
||||||
|
* @param {{userId?: string, isAdmin?: boolean}} [context] - Who requested the run; determines job filtering.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function runAll(respectWorkingHours = true, context = undefined) {
|
||||||
|
if (settings.demoMode) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const withinHours = duringWorkingHoursOrNotSet(settings, now);
|
||||||
|
if (respectWorkingHours && !withinHours) {
|
||||||
|
logger.debug('Working hours set. Skipping as outside of working hours.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settings.lastRun = now;
|
||||||
|
jobStorage
|
||||||
|
.getJobs()
|
||||||
|
.filter((job) => job.enabled)
|
||||||
|
.filter((job) => {
|
||||||
|
if (!context) return true; // startup/cron → all
|
||||||
|
if (context.isAdmin) return true; // admin → all
|
||||||
|
return context.userId ? job.userId === context.userId : false; // user → own
|
||||||
|
})
|
||||||
|
.forEach((job) => executeJob(job));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a single job by id.
|
||||||
|
* Manual runs are allowed even if the job is disabled, but never duplicated when already running.
|
||||||
|
*
|
||||||
|
* @param {string} jobId
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function runSingle(jobId) {
|
||||||
|
if (settings.demoMode) return;
|
||||||
|
const job = jobStorage.getJob(jobId);
|
||||||
|
if (!job) return;
|
||||||
|
// allow manual run even if disabled; keep guard to avoid duplicates
|
||||||
|
await executeJob(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes one job across all of its configured providers.
|
||||||
|
* Emits SSE start/finish events via the bus and ensures the run-state guard is always cleared.
|
||||||
|
* Provider errors are surfaced via logging but do not abort other providers.
|
||||||
|
*
|
||||||
|
* @param {Object} job
|
||||||
|
* @param {string} job.id
|
||||||
|
* @param {Array<{id:string}>} job.provider
|
||||||
|
* @param {Array<string>} [job.blacklist]
|
||||||
|
* @param {*} job.notificationAdapter
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function executeJob(job) {
|
||||||
|
if (isRunning(job.id)) {
|
||||||
|
logger.debug(`Job ${job.id} is already running. Skipping.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const acquired = markRunning(job.id);
|
||||||
|
if (!acquired) return;
|
||||||
|
// notify listeners (SSE) that the job started
|
||||||
|
try {
|
||||||
|
bus.emit('jobs:status', { jobId: job.id, running: true });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to emit start status for job', job.id, err);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const jobProviders = job.provider.filter(
|
||||||
|
(p) => providers.find((loaded) => loaded.metaInformation.id === p.id) != null,
|
||||||
|
);
|
||||||
|
const executions = jobProviders.map(async (prov) => {
|
||||||
|
const matchedProvider = providers.find((loaded) => loaded.metaInformation.id === prov.id);
|
||||||
|
matchedProvider.init(prov, job.blacklist);
|
||||||
|
await new FredyPipelineExecutioner(
|
||||||
|
matchedProvider.config,
|
||||||
|
job.notificationAdapter,
|
||||||
|
prov.id,
|
||||||
|
job.id,
|
||||||
|
similarityCache,
|
||||||
|
).execute();
|
||||||
|
});
|
||||||
|
const results = await Promise.allSettled(executions);
|
||||||
|
for (const r of results) {
|
||||||
|
if (r.status === 'rejected') {
|
||||||
|
logger.error(r.reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
markFinished(job.id);
|
||||||
|
try {
|
||||||
|
bus.emit('jobs:status', { jobId: job.id, running: false });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to emit finish status for job', job.id, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
lib/services/jobs/run-state.js
Normal file
50
lib/services/jobs/run-state.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve all currently running job IDs.
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
export function getRunningJobIds() {
|
||||||
|
return Array.from(running);
|
||||||
|
}
|
||||||
108
lib/services/sse/sse-broker.js
Normal file
108
lib/services/sse/sse-broker.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory SSE client registry.
|
||||||
|
* Maps a userId to a Set of Node.js ServerResponse objects representing open streams.
|
||||||
|
* @type {Map<string, Set<import('http').ServerResponse>>}
|
||||||
|
*/
|
||||||
|
const clients = new Map(); // Map<userId, Set<ServerResponse>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a single SSE event frame to a response.
|
||||||
|
*
|
||||||
|
* @param {import('http').ServerResponse} res - The open SSE HTTP response.
|
||||||
|
* @param {string} [event] - Optional event name (sent as `event:`). If omitted, a generic message is sent.
|
||||||
|
* @param {any} [data] - Optional payload. Objects are JSON.stringified.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function writeEvent(res, event, data) {
|
||||||
|
try {
|
||||||
|
if (event) {
|
||||||
|
res.write(`event: ${event}\n`);
|
||||||
|
}
|
||||||
|
if (data !== undefined) {
|
||||||
|
const payload = typeof data === 'string' ? data : JSON.stringify(data);
|
||||||
|
res.write(`data: ${payload}\n`);
|
||||||
|
}
|
||||||
|
res.write('\n');
|
||||||
|
} catch {
|
||||||
|
// ignore write errors here; cleanup happens on close
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new SSE client for the given user.
|
||||||
|
*
|
||||||
|
* @param {string} userId
|
||||||
|
* @param {import('http').ServerResponse} res
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function addClient(userId, res) {
|
||||||
|
let set = clients.get(userId);
|
||||||
|
if (!set) {
|
||||||
|
set = new Set();
|
||||||
|
clients.set(userId, set);
|
||||||
|
}
|
||||||
|
set.add(res);
|
||||||
|
// send a hello event
|
||||||
|
writeEvent(res, 'hello', { ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister a specific SSE client for a user. Removes the user entry when empty.
|
||||||
|
*
|
||||||
|
* @param {string} userId
|
||||||
|
* @param {import('http').ServerResponse} res
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function removeClient(userId, res) {
|
||||||
|
const set = clients.get(userId);
|
||||||
|
if (!set) return;
|
||||||
|
set.delete(res);
|
||||||
|
if (set.size === 0) clients.delete(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an SSE event to all open connections of a user.
|
||||||
|
*
|
||||||
|
* @param {string} userId
|
||||||
|
* @param {string} event
|
||||||
|
* @param {any} data
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function sendToUser(userId, event, data) {
|
||||||
|
const set = clients.get(userId);
|
||||||
|
if (!set) return;
|
||||||
|
for (const res of set) {
|
||||||
|
writeEvent(res, event, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcast an SSE event to multiple users (unique by id).
|
||||||
|
*
|
||||||
|
* @param {string[]} userIds
|
||||||
|
* @param {string} event
|
||||||
|
* @param {any} data
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function sendToUsers(userIds, event, data) {
|
||||||
|
const unique = Array.from(new Set(userIds));
|
||||||
|
unique.forEach((id) => sendToUser(id, event, data));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heartbeat to keep connections alive on proxies (every 25s)
|
||||||
|
setInterval(() => {
|
||||||
|
for (const set of clients.values()) {
|
||||||
|
for (const res of set) {
|
||||||
|
try {
|
||||||
|
res.write(`: ping ${Date.now()}\n\n`);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 25000);
|
||||||
@@ -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
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, []),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
19
package.json
19
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fredy",
|
"name": "fredy",
|
||||||
"version": "16.1.0",
|
"version": "16.3.0",
|
||||||
"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",
|
||||||
@@ -59,8 +59,9 @@
|
|||||||
"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.89.1",
|
||||||
|
"@douyinfe/semi-ui": "2.89.1",
|
||||||
"@sendgrid/mail": "8.1.6",
|
"@sendgrid/mail": "8.1.6",
|
||||||
"@vitejs/plugin-react": "5.1.2",
|
"@vitejs/plugin-react": "5.1.2",
|
||||||
"better-sqlite3": "^12.5.0",
|
"better-sqlite3": "^12.5.0",
|
||||||
@@ -76,20 +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.33.0",
|
"puppeteer": "^24.33.1",
|
||||||
"puppeteer-extra": "^3.3.6",
|
"puppeteer-extra": "^3.3.6",
|
||||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||||
"query-string": "9.3.1",
|
"query-string": "9.3.1",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-chartjs-2": "^5.3.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-router": "7.10.1",
|
"react-router": "7.11.0",
|
||||||
"react-router-dom": "7.10.1",
|
"react-router-dom": "7.11.0",
|
||||||
"restana": "5.1.0",
|
"restana": "5.1.0",
|
||||||
"semver": "^7.7.3",
|
"semver": "^7.7.3",
|
||||||
"serve-static": "2.2.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"
|
||||||
},
|
},
|
||||||
@@ -105,7 +106,7 @@
|
|||||||
"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
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
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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { Button, Empty, Table, Switch, Popover } from '@douyinfe/semi-ui';
|
import { Button, Empty, Table, Switch, Popover } from '@douyinfe/semi-ui';
|
||||||
import { IconAlertTriangle, IconDelete, IconDescend2, IconEdit } from '@douyinfe/semi-icons';
|
import { IconAlertTriangle, IconDelete, IconDescend2, IconEdit, IconPlayCircle } from '@douyinfe/semi-icons';
|
||||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||||
|
|
||||||
import './JobTable.less';
|
import './JobTable.less';
|
||||||
@@ -21,7 +21,14 @@ const empty = (
|
|||||||
|
|
||||||
const getPopoverContent = (text) => <article className="jobPopoverContent">{text}</article>;
|
const getPopoverContent = (text) => <article className="jobPopoverContent">{text}</article>;
|
||||||
|
|
||||||
export default function JobTable({ jobs = {}, onJobRemoval, onJobStatusChanged, onJobEdit, onListingRemoval } = {}) {
|
export default function JobTable({
|
||||||
|
jobs = {},
|
||||||
|
onJobRemoval,
|
||||||
|
onJobStatusChanged,
|
||||||
|
onJobEdit,
|
||||||
|
onListingRemoval,
|
||||||
|
onJobRun,
|
||||||
|
} = {}) {
|
||||||
return (
|
return (
|
||||||
<Table
|
<Table
|
||||||
pagination={false}
|
pagination={false}
|
||||||
@@ -91,6 +98,14 @@ export default function JobTable({ jobs = {}, onJobRemoval, onJobStatusChanged,
|
|||||||
render: (_, job) => {
|
render: (_, job) => {
|
||||||
return (
|
return (
|
||||||
<div className="interactions">
|
<div className="interactions">
|
||||||
|
<Popover content={getPopoverContent('Run Job')}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<IconPlayCircle />}
|
||||||
|
disabled={job.isOnlyShared || job.running}
|
||||||
|
onClick={() => onJobRun && onJobRun(job.id)}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
<Popover content={getPopoverContent('Edit a Job')}>
|
<Popover content={getPopoverContent('Edit a Job')}>
|
||||||
<Button
|
<Button
|
||||||
type="secondary"
|
type="secondary"
|
||||||
|
|||||||
85
ui/src/services/backupRestoreClient.js
Normal file
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);
|
||||||
@@ -100,6 +100,14 @@ export const useFredyState = create(
|
|||||||
console.error(`Error while trying to get resource for api/jobs. Error:`, Exception);
|
console.error(`Error while trying to get resource for api/jobs. Error:`, Exception);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
setJobRunning(jobId, running) {
|
||||||
|
if (!jobId) return;
|
||||||
|
set((state) => {
|
||||||
|
const list = state.jobs.jobs || [];
|
||||||
|
const updated = list.map((j) => (j.id === jobId ? { ...j, running: !!running } : j));
|
||||||
|
return { jobs: { ...state.jobs, jobs: Object.freeze(updated) } };
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
async getUsers() {
|
async getUsers() {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import React from 'react';
|
|||||||
|
|
||||||
import JobTable from '../../components/table/JobTable';
|
import JobTable from '../../components/table/JobTable';
|
||||||
import { useSelector, useActions } from '../../services/state/store';
|
import { useSelector, useActions } from '../../services/state/store';
|
||||||
import { xhrDelete, xhrPut } from '../../services/xhr';
|
import { xhrDelete, xhrPut, xhrPost } from '../../services/xhr';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Button, Toast } from '@douyinfe/semi-ui';
|
import { Button, Toast } from '@douyinfe/semi-ui';
|
||||||
import { IconPlusCircle } from '@douyinfe/semi-icons';
|
import { IconPlusCircle } from '@douyinfe/semi-icons';
|
||||||
@@ -17,6 +17,47 @@ export default function Jobs() {
|
|||||||
const jobs = useSelector((state) => state.jobs.jobs);
|
const jobs = useSelector((state) => state.jobs.jobs);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const actions = useActions();
|
const actions = useActions();
|
||||||
|
const pendingJobIdRef = React.useRef(null);
|
||||||
|
const evtSourceRef = React.useRef(null);
|
||||||
|
|
||||||
|
// SSE connection for live job status updates
|
||||||
|
React.useEffect(() => {
|
||||||
|
// establish SSE connection
|
||||||
|
const src = new EventSource('/api/jobs/events');
|
||||||
|
evtSourceRef.current = src;
|
||||||
|
|
||||||
|
const onJobStatus = (e) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data || '{}');
|
||||||
|
if (data && data.jobId) {
|
||||||
|
actions.jobs.setJobRunning(data.jobId, !!data.running);
|
||||||
|
// notify finish if it was triggered by this view
|
||||||
|
if (pendingJobIdRef.current === data.jobId && data.running === false) {
|
||||||
|
Toast.success('Job finished');
|
||||||
|
pendingJobIdRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore malformed events
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
src.addEventListener('jobStatus', onJobStatus);
|
||||||
|
src.onerror = () => {
|
||||||
|
// Let browser auto-reconnect; optionally log
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
try {
|
||||||
|
src.removeEventListener('jobStatus', onJobStatus);
|
||||||
|
src.close();
|
||||||
|
} catch {
|
||||||
|
//noop
|
||||||
|
}
|
||||||
|
evtSourceRef.current = null;
|
||||||
|
pendingJobIdRef.current = null;
|
||||||
|
};
|
||||||
|
}, [actions.jobs]);
|
||||||
|
|
||||||
const onJobRemoval = async (jobId) => {
|
const onJobRemoval = async (jobId) => {
|
||||||
try {
|
try {
|
||||||
@@ -48,6 +89,31 @@ export default function Jobs() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onJobRun = async (jobId) => {
|
||||||
|
try {
|
||||||
|
const response = await xhrPost(`/api/jobs/${jobId}/run`);
|
||||||
|
if (response.status === 202) {
|
||||||
|
Toast.success('Job run started');
|
||||||
|
} else {
|
||||||
|
Toast.info('Job run requested');
|
||||||
|
}
|
||||||
|
// remember so we can show a finish toast when SSE says it's done
|
||||||
|
pendingJobIdRef.current = jobId;
|
||||||
|
// optional: one initial refresh in case SSE arrives late
|
||||||
|
await actions.jobs.getJobs();
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.status === 409) {
|
||||||
|
Toast.warning(error?.json?.message || 'Job is already running');
|
||||||
|
} else if (error?.status === 403) {
|
||||||
|
Toast.error('You are not allowed to run this job');
|
||||||
|
} else if (error?.status === 404) {
|
||||||
|
Toast.error('Job not found');
|
||||||
|
} else {
|
||||||
|
Toast.error('Failed to trigger job');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
@@ -66,6 +132,7 @@ export default function Jobs() {
|
|||||||
onJobRemoval={onJobRemoval}
|
onJobRemoval={onJobRemoval}
|
||||||
onListingRemoval={onListingRemoval}
|
onListingRemoval={onListingRemoval}
|
||||||
onJobStatusChanged={onJobStatusChanged}
|
onJobStatusChanged={onJobStatusChanged}
|
||||||
|
onJobRun={onJobRun}
|
||||||
onJobEdit={(jobId) => navigate(`/jobs/edit/${jobId}`)}
|
onJobEdit={(jobId) => navigate(`/jobs/edit/${jobId}`)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
449
yarn.lock
449
yarn.lock
@@ -997,34 +997,34 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
tslib "^2.0.0"
|
tslib "^2.0.0"
|
||||||
|
|
||||||
"@douyinfe/semi-animation-react@2.89.0":
|
"@douyinfe/semi-animation-react@2.89.1":
|
||||||
version "2.89.0"
|
version "2.89.1"
|
||||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-react/-/semi-animation-react-2.89.0.tgz#be95b42a928ffe60b54d688dcf1d0f65e81b5bcc"
|
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-react/-/semi-animation-react-2.89.1.tgz#cbbf6f5cfdfb870c20418441ab5f0ce6a5844146"
|
||||||
integrity sha512-6GSQMF2bIoWN2Bua4wYGCe//ltfE1/iNQRMF7+TybVMz9kBJU0gelFsvxxVnqpka994RuTvhe73CSWWdpLwjng==
|
integrity sha512-SNEy7kujmLp7jUNzxsjEurkYmnd54OQRxedWJ8Taq3R/AhOGq6x1mP6E8EXHcBl3DgWMDdLv7YF9or/6Pom4XQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@douyinfe/semi-animation" "2.89.0"
|
"@douyinfe/semi-animation" "2.89.1"
|
||||||
"@douyinfe/semi-animation-styled" "2.89.0"
|
"@douyinfe/semi-animation-styled" "2.89.1"
|
||||||
classnames "^2.2.6"
|
classnames "^2.2.6"
|
||||||
|
|
||||||
"@douyinfe/semi-animation-styled@2.89.0":
|
"@douyinfe/semi-animation-styled@2.89.1":
|
||||||
version "2.89.0"
|
version "2.89.1"
|
||||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-styled/-/semi-animation-styled-2.89.0.tgz#72cd09f73abf5198bcfb47f6254c0f2799c146b2"
|
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation-styled/-/semi-animation-styled-2.89.1.tgz#9a8b5b66a0065f1b15573b85c911aaaa1e05dcf3"
|
||||||
integrity sha512-y1wXswseGbJpPh3hJQ9aNjnMzecLh9eUERmSpQaWbDSdrzk65hBa91MMC2rk/wlIN0/Q6OAKU8FcMoSiBiuI0Q==
|
integrity sha512-Zwl4EHn8IXWiqTAl3IIfbAWtmxH82B/BH2YqX88ASXqja8omJqMQ5ho1yYxzVFiurswz45RtoVm5tdt6M0d+MA==
|
||||||
|
|
||||||
"@douyinfe/semi-animation@2.89.0":
|
"@douyinfe/semi-animation@2.89.1":
|
||||||
version "2.89.0"
|
version "2.89.1"
|
||||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation/-/semi-animation-2.89.0.tgz#a30de59827f6b8452100a5dd2a828aebe8cb86ec"
|
resolved "https://registry.yarnpkg.com/@douyinfe/semi-animation/-/semi-animation-2.89.1.tgz#bb8c341895a7439838f654a9b43a65ba256e2a04"
|
||||||
integrity sha512-y6an913b841V0BAdR5qSLYvoK5C2OAbNKImzM+FzWmbRQjzbOEYcF3bqi5AZhY4mYk7v05k2W7U6fmaXYNOS1Q==
|
integrity sha512-7hKxLO//Ggxr7lYxIwJSQx+WQt9uYfuUnybn7Td/80/Jk3eIs+OJF9rN/8zmECANcxzZxHIuWjX5lfcQDDGhoA==
|
||||||
dependencies:
|
dependencies:
|
||||||
bezier-easing "^2.1.0"
|
bezier-easing "^2.1.0"
|
||||||
|
|
||||||
"@douyinfe/semi-foundation@2.89.0":
|
"@douyinfe/semi-foundation@2.89.1":
|
||||||
version "2.89.0"
|
version "2.89.1"
|
||||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-foundation/-/semi-foundation-2.89.0.tgz#299b10ecb92289bd4158471d91a08dfc461b7ef2"
|
resolved "https://registry.yarnpkg.com/@douyinfe/semi-foundation/-/semi-foundation-2.89.1.tgz#9bce2d137974da975c7e44cf3620924efd10bee4"
|
||||||
integrity sha512-Ryc2XywB3BVoUHETp5e7cY9x/ccweeKyCjqw/dcM16txeSpGxW7p1ykexGHRl3+dz1QcVrU4vp/ELD6GutC0Sg==
|
integrity sha512-LP8vLQ/IyKGkzUoUX5/15A2SjTmCEWl/kiouvBeRpPvVyy/worTZe5XVd6ojlbFl0VIYWv4Jwy1w3kJHq4Ltvg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@douyinfe/semi-animation" "2.89.0"
|
"@douyinfe/semi-animation" "2.89.1"
|
||||||
"@douyinfe/semi-json-viewer-core" "2.89.0"
|
"@douyinfe/semi-json-viewer-core" "2.89.1"
|
||||||
"@mdx-js/mdx" "^3.0.1"
|
"@mdx-js/mdx" "^3.0.1"
|
||||||
async-validator "^3.5.0"
|
async-validator "^3.5.0"
|
||||||
classnames "^2.2.6"
|
classnames "^2.2.6"
|
||||||
@@ -1038,44 +1038,44 @@
|
|||||||
remark-gfm "^4.0.0"
|
remark-gfm "^4.0.0"
|
||||||
scroll-into-view-if-needed "^2.2.24"
|
scroll-into-view-if-needed "^2.2.24"
|
||||||
|
|
||||||
"@douyinfe/semi-icons@2.89.0", "@douyinfe/semi-icons@^2.89.0":
|
"@douyinfe/semi-icons@2.89.1", "@douyinfe/semi-icons@^2.89.1":
|
||||||
version "2.89.0"
|
version "2.89.1"
|
||||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-icons/-/semi-icons-2.89.0.tgz#c9252981fde29668e3a88862948d09f71360a4fa"
|
resolved "https://registry.yarnpkg.com/@douyinfe/semi-icons/-/semi-icons-2.89.1.tgz#34635b5d515750ea281ec78190de71522847e2a4"
|
||||||
integrity sha512-LfUhh/S0+3bOdD7jy1xg5F1y6mXrYtDiIsA1Hmuhy3zhNSpSKSwfqPiV3IxwRRmGXFWjgiSefKd99h5OmKMPHg==
|
integrity sha512-K1xNbscHkGdx91xP5I6M/JKks/Xd2UzKGhLxBmyQ2D1Oboj95mJ+Dt15A8OGCcx2/n8shaJa2Ldw9JBPiXbNJg==
|
||||||
dependencies:
|
dependencies:
|
||||||
classnames "^2.2.6"
|
classnames "^2.2.6"
|
||||||
|
|
||||||
"@douyinfe/semi-illustrations@2.89.0":
|
"@douyinfe/semi-illustrations@2.89.1":
|
||||||
version "2.89.0"
|
version "2.89.1"
|
||||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-illustrations/-/semi-illustrations-2.89.0.tgz#93611cfa572d79eb4bd50a1e13ee416161e6f41f"
|
resolved "https://registry.yarnpkg.com/@douyinfe/semi-illustrations/-/semi-illustrations-2.89.1.tgz#9ca1508af325d5362dbf802035022cb54e424c9c"
|
||||||
integrity sha512-yAU4sSHr236E7ygTlwxupQkeF/W7EtfrUfRx3NUdGWuswMPAICz7d6Upa0XAZoCJ4skBZ5ItcQq9FfM+pw4wKg==
|
integrity sha512-3XEm3JIlCLZCKl/RmysYtIlqe6itbWf6u9nxaE6TWpSKJtSfA+wbyO2F3W2fvXkCJZopjk65BFLIFx/CAEqc5g==
|
||||||
|
|
||||||
"@douyinfe/semi-json-viewer-core@2.89.0":
|
"@douyinfe/semi-json-viewer-core@2.89.1":
|
||||||
version "2.89.0"
|
version "2.89.1"
|
||||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-json-viewer-core/-/semi-json-viewer-core-2.89.0.tgz#4254ce7d36c24f70267980f8e8a42faf6757f502"
|
resolved "https://registry.yarnpkg.com/@douyinfe/semi-json-viewer-core/-/semi-json-viewer-core-2.89.1.tgz#582a3c49a06913c287be531670e87120a2220af2"
|
||||||
integrity sha512-BGMJgg+tBFcwg3/7aJmtIXaHW+tSA6Tae3UfyhLYjUxcl6cFtYjtN0DAGwoia9KzUdNHoSAhl3GJVtGCBsmApQ==
|
integrity sha512-MSkbeoADyD1d4mERdmMNeqL0SBI2d8b9j/em7LDQp6tLzmfQEIHgPjjwvPthefHBPjUiikPSiSc9Iv/gPayhIQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
jsonc-parser "^3.3.1"
|
jsonc-parser "^3.3.1"
|
||||||
|
|
||||||
"@douyinfe/semi-theme-default@2.89.0":
|
"@douyinfe/semi-theme-default@2.89.1":
|
||||||
version "2.89.0"
|
version "2.89.1"
|
||||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-theme-default/-/semi-theme-default-2.89.0.tgz#eb1fed1939a16fb903f6845867007020d76a503b"
|
resolved "https://registry.yarnpkg.com/@douyinfe/semi-theme-default/-/semi-theme-default-2.89.1.tgz#57f596e610269d31e021fe6472440970305f9750"
|
||||||
integrity sha512-mdL6Ui1XMGW9L5tYl9uG3MnmyHaIXnRVa7b4PB0Y8kfFGAVzls5XBjZT5ACVtwxlVZrU5BrJBFOXGER5p1FVDg==
|
integrity sha512-8GGPU4mfhUg9dwyVS+xEhXp7iNEaqEslyRwbaiUImjOwLZmpEbYST/gvYpXkpRltaWGZ7n82P3uVPyQZkIBLAg==
|
||||||
|
|
||||||
"@douyinfe/semi-ui@2.89.0":
|
"@douyinfe/semi-ui@2.89.1":
|
||||||
version "2.89.0"
|
version "2.89.1"
|
||||||
resolved "https://registry.yarnpkg.com/@douyinfe/semi-ui/-/semi-ui-2.89.0.tgz#563adb7f33b9d888a882573024df2296be3c8bf4"
|
resolved "https://registry.yarnpkg.com/@douyinfe/semi-ui/-/semi-ui-2.89.1.tgz#c0573f2531fe24d4830db9331e99295d75af2207"
|
||||||
integrity sha512-XZ2yo2TgGWk8ubukJq7zbpKePpswQRq3nxeBlmL39SEben8AUfEq92vus0Hcmua5Y2wgi6TY2qWPgo+WEZCrkQ==
|
integrity sha512-1jh9c1NwPEHYIPt+vCSF/TeXrhJ6g6/5745h8x2VZMA6+Epexv61eucTiMEz/HZzi9GC/u+K0GuwSS3CjJdEFA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@dnd-kit/core" "^6.0.8"
|
"@dnd-kit/core" "^6.0.8"
|
||||||
"@dnd-kit/sortable" "^7.0.2"
|
"@dnd-kit/sortable" "^7.0.2"
|
||||||
"@dnd-kit/utilities" "^3.2.1"
|
"@dnd-kit/utilities" "^3.2.1"
|
||||||
"@douyinfe/semi-animation" "2.89.0"
|
"@douyinfe/semi-animation" "2.89.1"
|
||||||
"@douyinfe/semi-animation-react" "2.89.0"
|
"@douyinfe/semi-animation-react" "2.89.1"
|
||||||
"@douyinfe/semi-foundation" "2.89.0"
|
"@douyinfe/semi-foundation" "2.89.1"
|
||||||
"@douyinfe/semi-icons" "2.89.0"
|
"@douyinfe/semi-icons" "2.89.1"
|
||||||
"@douyinfe/semi-illustrations" "2.89.0"
|
"@douyinfe/semi-illustrations" "2.89.1"
|
||||||
"@douyinfe/semi-theme-default" "2.89.0"
|
"@douyinfe/semi-theme-default" "2.89.1"
|
||||||
"@tiptap/core" "^3.10.7"
|
"@tiptap/core" "^3.10.7"
|
||||||
"@tiptap/extension-document" "^3.10.7"
|
"@tiptap/extension-document" "^3.10.7"
|
||||||
"@tiptap/extension-hard-break" "^3.10.7"
|
"@tiptap/extension-hard-break" "^3.10.7"
|
||||||
@@ -1100,135 +1100,135 @@
|
|||||||
scroll-into-view-if-needed "^2.2.24"
|
scroll-into-view-if-needed "^2.2.24"
|
||||||
utility-types "^3.10.0"
|
utility-types "^3.10.0"
|
||||||
|
|
||||||
"@esbuild/aix-ppc64@0.25.9":
|
"@esbuild/aix-ppc64@0.27.1":
|
||||||
version "0.25.9"
|
version "0.27.1"
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz#bef96351f16520055c947aba28802eede3c9e9a9"
|
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.1.tgz#116edcd62c639ed8ab551e57b38251bb28384de4"
|
||||||
integrity sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==
|
integrity sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA==
|
||||||
|
|
||||||
"@esbuild/android-arm64@0.25.9":
|
"@esbuild/android-arm64@0.27.1":
|
||||||
version "0.25.9"
|
version "0.27.1"
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz#d2e70be7d51a529425422091e0dcb90374c1546c"
|
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.1.tgz#31c00d864c80f6de1900a11de8a506dbfbb27349"
|
||||||
integrity sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==
|
integrity sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ==
|
||||||
|
|
||||||
"@esbuild/android-arm@0.25.9":
|
"@esbuild/android-arm@0.27.1":
|
||||||
version "0.25.9"
|
version "0.27.1"
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.9.tgz#d2a753fe2a4c73b79437d0ba1480e2d760097419"
|
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.1.tgz#d2b73ab0ba894923a1d1378fd4b15cc20985f436"
|
||||||
integrity sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==
|
integrity sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg==
|
||||||
|
|
||||||
"@esbuild/android-x64@0.25.9":
|
"@esbuild/android-x64@0.27.1":
|
||||||
version "0.25.9"
|
version "0.27.1"
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.9.tgz#5278836e3c7ae75761626962f902a0d55352e683"
|
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.1.tgz#d9f74d8278191317250cfe0c15a13f410540b122"
|
||||||
integrity sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==
|
integrity sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ==
|
||||||
|
|
||||||
"@esbuild/darwin-arm64@0.25.9":
|
"@esbuild/darwin-arm64@0.27.1":
|
||||||
version "0.25.9"
|
version "0.27.1"
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz#f1513eaf9ec8fa15dcaf4c341b0f005d3e8b47ae"
|
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.1.tgz#baf6914b8c57ed9d41f9de54023aa3ff9b084680"
|
||||||
integrity sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==
|
integrity sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ==
|
||||||
|
|
||||||
"@esbuild/darwin-x64@0.25.9":
|
"@esbuild/darwin-x64@0.27.1":
|
||||||
version "0.25.9"
|
version "0.27.1"
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz#e27dbc3b507b3a1cea3b9280a04b8b6b725f82be"
|
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.1.tgz#64e37400795f780a76c858a118ff19681a64b4e0"
|
||||||
integrity sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==
|
integrity sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ==
|
||||||
|
|
||||||
"@esbuild/freebsd-arm64@0.25.9":
|
"@esbuild/freebsd-arm64@0.27.1":
|
||||||
version "0.25.9"
|
version "0.27.1"
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz#364e3e5b7a1fd45d92be08c6cc5d890ca75908ca"
|
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.1.tgz#6572f2f235933eee906e070dfaae54488ee60acd"
|
||||||
integrity sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==
|
integrity sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg==
|
||||||
|
|
||||||
"@esbuild/freebsd-x64@0.25.9":
|
"@esbuild/freebsd-x64@0.27.1":
|
||||||
version "0.25.9"
|
version "0.27.1"
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz#7c869b45faeb3df668e19ace07335a0711ec56ab"
|
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.1.tgz#83105dba9cf6ac4f44336799446d7f75c8c3a1e1"
|
||||||
integrity sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==
|
integrity sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ==
|
||||||
|
|
||||||
"@esbuild/linux-arm64@0.25.9":
|
"@esbuild/linux-arm64@0.27.1":
|
||||||
version "0.25.9"
|
version "0.27.1"
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz#48d42861758c940b61abea43ba9a29b186d6cb8b"
|
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.1.tgz#035ff647d4498bdf16eb2d82801f73b366477dfa"
|
||||||
integrity sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==
|
integrity sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q==
|
||||||
|
|
||||||
"@esbuild/linux-arm@0.25.9":
|
"@esbuild/linux-arm@0.27.1":
|
||||||
version "0.25.9"
|
version "0.27.1"
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz#6ce4b9cabf148274101701d112b89dc67cc52f37"
|
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.1.tgz#3516c74d2afbe305582dbb546d60f7978a8ece7f"
|
||||||
integrity sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==
|
integrity sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA==
|
||||||
|
|
||||||
"@esbuild/linux-ia32@0.25.9":
|
"@esbuild/linux-ia32@0.27.1":
|
||||||
version "0.25.9"
|
version "0.27.1"
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz#207e54899b79cac9c26c323fc1caa32e3143f1c4"
|
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.1.tgz#788db5db8ecd3d75dd41c42de0fe8f1fd967a4a7"
|
||||||
integrity sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==
|
integrity sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw==
|
||||||
|
|
||||||
"@esbuild/linux-loong64@0.25.9":
|
"@esbuild/linux-loong64@0.27.1":
|
||||||
version "0.25.9"
|
version "0.27.1"
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz#0ba48a127159a8f6abb5827f21198b999ffd1fc0"
|
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.1.tgz#8211f08b146916a6302ec2b8f87ec0cc4b62c49e"
|
||||||
integrity sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==
|
integrity sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg==
|
||||||
|
|
||||||
"@esbuild/linux-mips64el@0.25.9":
|
"@esbuild/linux-mips64el@0.27.1":
|
||||||
version "0.25.9"
|
version "0.27.1"
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz#a4d4cc693d185f66a6afde94f772b38ce5d64eb5"
|
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.1.tgz#cc58586ea83b3f171e727a624e7883a1c3eb4c04"
|
||||||
integrity sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==
|
integrity sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA==
|
||||||
|
|
||||||
"@esbuild/linux-ppc64@0.25.9":
|
"@esbuild/linux-ppc64@0.27.1":
|
||||||
version "0.25.9"
|
version "0.27.1"
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz#0f5805c1c6d6435a1dafdc043cb07a19050357db"
|
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.1.tgz#632477bbd98175cf8e53a7c9952d17fb2d6d4115"
|
||||||
integrity sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==
|
integrity sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ==
|
||||||
|
|
||||||
"@esbuild/linux-riscv64@0.25.9":
|
"@esbuild/linux-riscv64@0.27.1":
|
||||||
version "0.25.9"
|
version "0.27.1"
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz#6776edece0f8fca79f3386398b5183ff2a827547"
|
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.1.tgz#35435a82435a8a750edf433b83ac0d10239ac3fe"
|
||||||
integrity sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==
|
integrity sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ==
|
||||||
|
|
||||||
"@esbuild/linux-s390x@0.25.9":
|
"@esbuild/linux-s390x@0.27.1":
|
||||||
version "0.25.9"
|
version "0.27.1"
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz#3f6f29ef036938447c2218d309dc875225861830"
|
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.1.tgz#172edd7086438edacd86c0e2ea25ac9dbb62aac5"
|
||||||
integrity sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==
|
integrity sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw==
|
||||||
|
|
||||||
"@esbuild/linux-x64@0.25.9":
|
"@esbuild/linux-x64@0.27.1":
|
||||||
version "0.25.9"
|
version "0.27.1"
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz#831fe0b0e1a80a8b8391224ea2377d5520e1527f"
|
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.1.tgz#09c771de9e2d8169d5969adf298ae21581f08c7f"
|
||||||
integrity sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==
|
integrity sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA==
|
||||||
|
|
||||||
"@esbuild/netbsd-arm64@0.25.9":
|
"@esbuild/netbsd-arm64@0.27.1":
|
||||||
version "0.25.9"
|
version "0.27.1"
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz#06f99d7eebe035fbbe43de01c9d7e98d2a0aa548"
|
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.1.tgz#475ac0ce7edf109a358b1669f67759de4bcbb7c4"
|
||||||
integrity sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==
|
integrity sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ==
|
||||||
|
|
||||||
"@esbuild/netbsd-x64@0.25.9":
|
"@esbuild/netbsd-x64@0.27.1":
|
||||||
version "0.25.9"
|
version "0.27.1"
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz#db99858e6bed6e73911f92a88e4edd3a8c429a52"
|
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.1.tgz#3c31603d592477dc43b63df1ae100000f7fb59d7"
|
||||||
integrity sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==
|
integrity sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg==
|
||||||
|
|
||||||
"@esbuild/openbsd-arm64@0.25.9":
|
"@esbuild/openbsd-arm64@0.27.1":
|
||||||
version "0.25.9"
|
version "0.27.1"
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz#afb886c867e36f9d86bb21e878e1185f5d5a0935"
|
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.1.tgz#482067c847665b10d66431e936d4bc5fa8025abf"
|
||||||
integrity sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==
|
integrity sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g==
|
||||||
|
|
||||||
"@esbuild/openbsd-x64@0.25.9":
|
"@esbuild/openbsd-x64@0.27.1":
|
||||||
version "0.25.9"
|
version "0.27.1"
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz#30855c9f8381fac6a0ef5b5f31ac6e7108a66ecf"
|
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.1.tgz#687a188c2b184e5b671c5f74a6cd6247c0718c52"
|
||||||
integrity sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==
|
integrity sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg==
|
||||||
|
|
||||||
"@esbuild/openharmony-arm64@0.25.9":
|
"@esbuild/openharmony-arm64@0.27.1":
|
||||||
version "0.25.9"
|
version "0.27.1"
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz#2f2144af31e67adc2a8e3705c20c2bd97bd88314"
|
resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.1.tgz#9929ee7fa8c1db2f33ef4d86198018dac9c1744f"
|
||||||
integrity sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==
|
integrity sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg==
|
||||||
|
|
||||||
"@esbuild/sunos-x64@0.25.9":
|
"@esbuild/sunos-x64@0.27.1":
|
||||||
version "0.25.9"
|
version "0.27.1"
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz#69b99a9b5bd226c9eb9c6a73f990fddd497d732e"
|
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.1.tgz#94071a146f313e7394c6424af07b2b564f1f994d"
|
||||||
integrity sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==
|
integrity sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA==
|
||||||
|
|
||||||
"@esbuild/win32-arm64@0.25.9":
|
"@esbuild/win32-arm64@0.27.1":
|
||||||
version "0.25.9"
|
version "0.27.1"
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz#d789330a712af916c88325f4ffe465f885719c6b"
|
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.1.tgz#869fde72a3576fdf48824085d05493fceebe395d"
|
||||||
integrity sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==
|
integrity sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg==
|
||||||
|
|
||||||
"@esbuild/win32-ia32@0.25.9":
|
"@esbuild/win32-ia32@0.27.1":
|
||||||
version "0.25.9"
|
version "0.27.1"
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz#52fc735406bd49688253e74e4e837ac2ba0789e3"
|
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.1.tgz#31d7585893ed7b54483d0b8d87a4bfeba0ecfff5"
|
||||||
integrity sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==
|
integrity sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ==
|
||||||
|
|
||||||
"@esbuild/win32-x64@0.25.9":
|
"@esbuild/win32-x64@0.27.1":
|
||||||
version "0.25.9"
|
version "0.27.1"
|
||||||
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz#585624dc829cfb6e7c0aa6c3ca7d7e6daa87e34f"
|
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.1.tgz#5efe5a112938b1180e98c76685ff9185cfa4f16e"
|
||||||
integrity sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==
|
integrity sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw==
|
||||||
|
|
||||||
"@eslint-community/eslint-utils@^4.8.0":
|
"@eslint-community/eslint-utils@^4.8.0":
|
||||||
version "4.9.0"
|
version "4.9.0"
|
||||||
@@ -1828,6 +1828,11 @@ acorn@^8.0.0, acorn@^8.15.0:
|
|||||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816"
|
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816"
|
||||||
integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==
|
integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==
|
||||||
|
|
||||||
|
adm-zip@^0.5.16:
|
||||||
|
version "0.5.16"
|
||||||
|
resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.5.16.tgz#0b5e4c779f07dedea5805cdccb1147071d94a909"
|
||||||
|
integrity sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==
|
||||||
|
|
||||||
agent-base@^7.1.0, agent-base@^7.1.2:
|
agent-base@^7.1.0, agent-base@^7.1.2:
|
||||||
version "7.1.4"
|
version "7.1.4"
|
||||||
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.4.tgz#e3cd76d4c548ee895d3c3fd8dc1f6c5b9032e7a8"
|
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.4.tgz#e3cd76d4c548ee895d3c3fd8dc1f6c5b9032e7a8"
|
||||||
@@ -2358,10 +2363,10 @@ chownr@^1.1.1:
|
|||||||
resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b"
|
resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b"
|
||||||
integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==
|
integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==
|
||||||
|
|
||||||
chromium-bidi@11.0.0:
|
chromium-bidi@12.0.1:
|
||||||
version "11.0.0"
|
version "12.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/chromium-bidi/-/chromium-bidi-11.0.0.tgz#193433d0722095abca0cada2fa0c5111b447bea3"
|
resolved "https://registry.yarnpkg.com/chromium-bidi/-/chromium-bidi-12.0.1.tgz#3edb1420ab5a52004c10c223b928622c128b4f27"
|
||||||
integrity sha512-cM3DI+OOb89T3wO8cpPSro80Q9eKYJ7hGVXoGS3GkDPxnYSqiv+6xwpIf6XERyJ9Tdsl09hmNmY94BkgZdVekw==
|
integrity sha512-fGg+6jr0xjQhzpy5N4ErZxQ4wF7KLEvhGZXD6EgvZKDhu7iOhZXnZhcDxPJDcwTcrD48NPzOCo84RP2lv3Z+Cg==
|
||||||
dependencies:
|
dependencies:
|
||||||
mitt "^3.0.1"
|
mitt "^3.0.1"
|
||||||
zod "^3.24.1"
|
zod "^3.24.1"
|
||||||
@@ -3005,37 +3010,37 @@ esast-util-from-js@^2.0.0:
|
|||||||
esast-util-from-estree "^2.0.0"
|
esast-util-from-estree "^2.0.0"
|
||||||
vfile-message "^4.0.0"
|
vfile-message "^4.0.0"
|
||||||
|
|
||||||
esbuild@^0.25.0:
|
esbuild@^0.27.0:
|
||||||
version "0.25.9"
|
version "0.27.1"
|
||||||
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.9.tgz#15ab8e39ae6cdc64c24ff8a2c0aef5b3fd9fa976"
|
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.1.tgz#56bf43e6a4b4d2004642ec7c091b78de02b0831a"
|
||||||
integrity sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==
|
integrity sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
"@esbuild/aix-ppc64" "0.25.9"
|
"@esbuild/aix-ppc64" "0.27.1"
|
||||||
"@esbuild/android-arm" "0.25.9"
|
"@esbuild/android-arm" "0.27.1"
|
||||||
"@esbuild/android-arm64" "0.25.9"
|
"@esbuild/android-arm64" "0.27.1"
|
||||||
"@esbuild/android-x64" "0.25.9"
|
"@esbuild/android-x64" "0.27.1"
|
||||||
"@esbuild/darwin-arm64" "0.25.9"
|
"@esbuild/darwin-arm64" "0.27.1"
|
||||||
"@esbuild/darwin-x64" "0.25.9"
|
"@esbuild/darwin-x64" "0.27.1"
|
||||||
"@esbuild/freebsd-arm64" "0.25.9"
|
"@esbuild/freebsd-arm64" "0.27.1"
|
||||||
"@esbuild/freebsd-x64" "0.25.9"
|
"@esbuild/freebsd-x64" "0.27.1"
|
||||||
"@esbuild/linux-arm" "0.25.9"
|
"@esbuild/linux-arm" "0.27.1"
|
||||||
"@esbuild/linux-arm64" "0.25.9"
|
"@esbuild/linux-arm64" "0.27.1"
|
||||||
"@esbuild/linux-ia32" "0.25.9"
|
"@esbuild/linux-ia32" "0.27.1"
|
||||||
"@esbuild/linux-loong64" "0.25.9"
|
"@esbuild/linux-loong64" "0.27.1"
|
||||||
"@esbuild/linux-mips64el" "0.25.9"
|
"@esbuild/linux-mips64el" "0.27.1"
|
||||||
"@esbuild/linux-ppc64" "0.25.9"
|
"@esbuild/linux-ppc64" "0.27.1"
|
||||||
"@esbuild/linux-riscv64" "0.25.9"
|
"@esbuild/linux-riscv64" "0.27.1"
|
||||||
"@esbuild/linux-s390x" "0.25.9"
|
"@esbuild/linux-s390x" "0.27.1"
|
||||||
"@esbuild/linux-x64" "0.25.9"
|
"@esbuild/linux-x64" "0.27.1"
|
||||||
"@esbuild/netbsd-arm64" "0.25.9"
|
"@esbuild/netbsd-arm64" "0.27.1"
|
||||||
"@esbuild/netbsd-x64" "0.25.9"
|
"@esbuild/netbsd-x64" "0.27.1"
|
||||||
"@esbuild/openbsd-arm64" "0.25.9"
|
"@esbuild/openbsd-arm64" "0.27.1"
|
||||||
"@esbuild/openbsd-x64" "0.25.9"
|
"@esbuild/openbsd-x64" "0.27.1"
|
||||||
"@esbuild/openharmony-arm64" "0.25.9"
|
"@esbuild/openharmony-arm64" "0.27.1"
|
||||||
"@esbuild/sunos-x64" "0.25.9"
|
"@esbuild/sunos-x64" "0.27.1"
|
||||||
"@esbuild/win32-arm64" "0.25.9"
|
"@esbuild/win32-arm64" "0.27.1"
|
||||||
"@esbuild/win32-ia32" "0.25.9"
|
"@esbuild/win32-ia32" "0.27.1"
|
||||||
"@esbuild/win32-x64" "0.25.9"
|
"@esbuild/win32-x64" "0.27.1"
|
||||||
|
|
||||||
escalade@^3.1.1, escalade@^3.2.0:
|
escalade@^3.1.1, escalade@^3.2.0:
|
||||||
version "3.2.0"
|
version "3.2.0"
|
||||||
@@ -4329,10 +4334,10 @@ lazy-cache@^1.0.3:
|
|||||||
resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e"
|
resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e"
|
||||||
integrity sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ==
|
integrity sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ==
|
||||||
|
|
||||||
less@4.4.2:
|
less@4.5.1:
|
||||||
version "4.4.2"
|
version "4.5.1"
|
||||||
resolved "https://registry.yarnpkg.com/less/-/less-4.4.2.tgz#fa4291fdb0334de91163622cc038f4bd3eb6b8d7"
|
resolved "https://registry.yarnpkg.com/less/-/less-4.5.1.tgz#739266532249a3de232e8b60ffb1b27ad5ec6ad8"
|
||||||
integrity sha512-j1n1IuTX1VQjIy3tT7cyGbX7nvQOsFLoIqobZv4ttI5axP923gA44zUj6miiA6R5Aoms4sEGVIIcucXUbRI14g==
|
integrity sha512-UKgI3/KON4u6ngSsnDADsUERqhZknsVZbnuzlRZXLQCmfC/MDld42fTydUE9B+Mla1AL6SJ/Pp6SlEFi/AVGfw==
|
||||||
dependencies:
|
dependencies:
|
||||||
copy-anything "^2.0.1"
|
copy-anything "^2.0.1"
|
||||||
parse-node-version "^1.0.1"
|
parse-node-version "^1.0.1"
|
||||||
@@ -5869,17 +5874,17 @@ punycode@^2.1.0:
|
|||||||
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
|
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
|
||||||
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
||||||
|
|
||||||
puppeteer-core@24.33.0:
|
puppeteer-core@24.33.1:
|
||||||
version "24.33.0"
|
version "24.33.1"
|
||||||
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.33.0.tgz#c3e6adee5fd5dd77895d4cc14e0d17461048917a"
|
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.33.1.tgz#80b9d79dd0597fd2b1de265aae4dd0d95df81c66"
|
||||||
integrity sha512-tPTxVg+Qdj/8av4cy6szv3GlhxeOoNhiiMZ955fjxQyvPQE/6DjCa6ZyF/x0WJrlgBZtaLSP8TQgJb7FdLDXXA==
|
integrity sha512-MZjFLeGMBFbSkc1xKfcv6hjFlfNi1bmQly++HyqxGPYzLIMY0mSYyjqkAzT1PtomTYHq7SEonciIKkeyHExA1g==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@puppeteer/browsers" "2.11.0"
|
"@puppeteer/browsers" "2.11.0"
|
||||||
chromium-bidi "11.0.0"
|
chromium-bidi "12.0.1"
|
||||||
debug "^4.4.3"
|
debug "^4.4.3"
|
||||||
devtools-protocol "0.0.1534754"
|
devtools-protocol "0.0.1534754"
|
||||||
typed-query-selector "^2.12.0"
|
typed-query-selector "^2.12.0"
|
||||||
webdriver-bidi-protocol "0.3.9"
|
webdriver-bidi-protocol "0.3.10"
|
||||||
ws "^8.18.3"
|
ws "^8.18.3"
|
||||||
|
|
||||||
puppeteer-extra-plugin-stealth@^2.11.2:
|
puppeteer-extra-plugin-stealth@^2.11.2:
|
||||||
@@ -5929,16 +5934,16 @@ puppeteer-extra@^3.3.6:
|
|||||||
debug "^4.1.1"
|
debug "^4.1.1"
|
||||||
deepmerge "^4.2.2"
|
deepmerge "^4.2.2"
|
||||||
|
|
||||||
puppeteer@^24.33.0:
|
puppeteer@^24.33.1:
|
||||||
version "24.33.0"
|
version "24.33.1"
|
||||||
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.33.0.tgz#5d0aba25f0038db2a3a996deb8c157d1842e8a21"
|
resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.33.1.tgz#c84c9545633bc731b92caead463e77928dcf183e"
|
||||||
integrity sha512-nl3wsAztq5F8zybn4Tk41OCnYIzFIzGC6AN0WcF2KCUnWenajvRRPgBmS6LvNUV2HEeIzT2zRZHH0TgVxLDKew==
|
integrity sha512-2KiSIXk+zFzmYsScv+hx/I3TODFGPcNpyJsWMQk1EQ2y8KZ2X6225/NingyqYxekzceSUnq5qX39dUezVDZ9EQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@puppeteer/browsers" "2.11.0"
|
"@puppeteer/browsers" "2.11.0"
|
||||||
chromium-bidi "11.0.0"
|
chromium-bidi "12.0.1"
|
||||||
cosmiconfig "^9.0.0"
|
cosmiconfig "^9.0.0"
|
||||||
devtools-protocol "0.0.1534754"
|
devtools-protocol "0.0.1534754"
|
||||||
puppeteer-core "24.33.0"
|
puppeteer-core "24.33.1"
|
||||||
typed-query-selector "^2.12.0"
|
typed-query-selector "^2.12.0"
|
||||||
|
|
||||||
qs@^6.14.0:
|
qs@^6.14.0:
|
||||||
@@ -6028,17 +6033,17 @@ react-resizable@^3.0.5:
|
|||||||
prop-types "15.x"
|
prop-types "15.x"
|
||||||
react-draggable "^4.0.3"
|
react-draggable "^4.0.3"
|
||||||
|
|
||||||
react-router-dom@7.10.1:
|
react-router-dom@7.11.0:
|
||||||
version "7.10.1"
|
version "7.11.0"
|
||||||
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.10.1.tgz#fddea814d30a3630c11d9ea539932482ff6f744c"
|
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.11.0.tgz#2165f63e52798bd0eb138480c098ad058cdf3413"
|
||||||
integrity sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==
|
integrity sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==
|
||||||
dependencies:
|
dependencies:
|
||||||
react-router "7.10.1"
|
react-router "7.11.0"
|
||||||
|
|
||||||
react-router@7.10.1:
|
react-router@7.11.0:
|
||||||
version "7.10.1"
|
version "7.11.0"
|
||||||
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.10.1.tgz#e973146ed5f10a80783fdb3f27dbe37679557a7c"
|
resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.11.0.tgz#d3b91567fdbe910caf9064ea69b7b4d9264f2945"
|
||||||
integrity sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==
|
integrity sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
cookie "^1.0.1"
|
cookie "^1.0.1"
|
||||||
set-cookie-parser "^2.6.0"
|
set-cookie-parser "^2.6.0"
|
||||||
@@ -6442,10 +6447,10 @@ serialize-javascript@^6.0.2:
|
|||||||
dependencies:
|
dependencies:
|
||||||
randombytes "^2.1.0"
|
randombytes "^2.1.0"
|
||||||
|
|
||||||
serve-static@2.2.0:
|
serve-static@2.2.1:
|
||||||
version "2.2.0"
|
version "2.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-2.2.0.tgz#9c02564ee259bdd2251b82d659a2e7e1938d66f9"
|
resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-2.2.1.tgz#7f186a4a4e5f5b663ad7a4294ff1bf37cf0e98a9"
|
||||||
integrity sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==
|
integrity sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==
|
||||||
dependencies:
|
dependencies:
|
||||||
encodeurl "^2.0.0"
|
encodeurl "^2.0.0"
|
||||||
escape-html "^1.0.3"
|
escape-html "^1.0.3"
|
||||||
@@ -7229,12 +7234,12 @@ vfile@^6.0.0:
|
|||||||
"@types/unist" "^3.0.0"
|
"@types/unist" "^3.0.0"
|
||||||
vfile-message "^4.0.0"
|
vfile-message "^4.0.0"
|
||||||
|
|
||||||
vite@7.2.7:
|
vite@7.3.0:
|
||||||
version "7.2.7"
|
version "7.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/vite/-/vite-7.2.7.tgz#0789a4c3206081699f34a9ecca2dda594a07478e"
|
resolved "https://registry.yarnpkg.com/vite/-/vite-7.3.0.tgz#066c7a835993a66e82004eac3e185d0d157fd658"
|
||||||
integrity sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==
|
integrity sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild "^0.25.0"
|
esbuild "^0.27.0"
|
||||||
fdir "^6.5.0"
|
fdir "^6.5.0"
|
||||||
picomatch "^4.0.3"
|
picomatch "^4.0.3"
|
||||||
postcss "^8.5.6"
|
postcss "^8.5.6"
|
||||||
@@ -7253,10 +7258,10 @@ web-streams-polyfill@^3.0.3:
|
|||||||
resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b"
|
resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b"
|
||||||
integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==
|
integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==
|
||||||
|
|
||||||
webdriver-bidi-protocol@0.3.9:
|
webdriver-bidi-protocol@0.3.10:
|
||||||
version "0.3.9"
|
version "0.3.10"
|
||||||
resolved "https://registry.yarnpkg.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.9.tgz#89abf021f2a557a2dd81772f9ce7172b01f8a0f0"
|
resolved "https://registry.yarnpkg.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.10.tgz#437405564ff7e200371468f4f1eba1ff5537e119"
|
||||||
integrity sha512-uIYvlRQ0PwtZR1EzHlTMol1G0lAlmOe6wPykF9a77AK3bkpvZHzIVxRE2ThOx5vjy2zISe0zhwf5rzuUfbo1PQ==
|
integrity sha512-5LAE43jAVLOhB/QqX4bwSiv0Hg1HBfMmOuwBSXHdvg4GMGu9Y0lIq7p4R/yySu6w74WmaR4GM4H9t2IwLW7hgw==
|
||||||
|
|
||||||
whatwg-encoding@^3.1.1:
|
whatwg-encoding@^3.1.1:
|
||||||
version "3.1.1"
|
version "3.1.1"
|
||||||
|
|||||||
Reference in New Issue
Block a user