638 lines
22 KiB
JavaScript
638 lines
22 KiB
JavaScript
/**
|
||
* cronService.js
|
||
* Verwaltet und führt Cronjobs aus (Skripte oder Skriptketten).
|
||
* Kein externer Package nötig – eigener Cron-Expression-Parser.
|
||
*/
|
||
|
||
const { getDb } = require('../db/database');
|
||
const logger = require('./logger').child('CRON');
|
||
const notificationService = require('./notificationService');
|
||
const settingsService = require('./settingsService');
|
||
const wsService = require('./websocketService');
|
||
const runtimeActivityService = require('./runtimeActivityService');
|
||
const { errorToMeta } = require('../utils/errorMeta');
|
||
|
||
// Maximale Zeilen pro Log-Eintrag (Output-Truncation)
|
||
const MAX_OUTPUT_CHARS = 100000;
|
||
// Maximale Log-Einträge pro Cron-Job (ältere werden gelöscht)
|
||
const MAX_LOGS_PER_JOB = 50;
|
||
|
||
// ─── Cron-Expression-Parser ────────────────────────────────────────────────
|
||
|
||
// Parst ein einzelnes Cron-Feld (z.B. "* /5", "1,3,5", "1-5", "*") und gibt
|
||
// alle erlaubten Werte als Set zurück.
|
||
function parseCronField(field, min, max) {
|
||
const values = new Set();
|
||
|
||
for (const part of field.split(',')) {
|
||
const trimmed = part.trim();
|
||
if (trimmed === '*') {
|
||
for (let i = min; i <= max; i++) values.add(i);
|
||
} else if (trimmed.startsWith('*/')) {
|
||
const step = parseInt(trimmed.slice(2), 10);
|
||
if (!Number.isFinite(step) || step < 1) throw new Error(`Ungültiges Step: ${trimmed}`);
|
||
for (let i = min; i <= max; i += step) values.add(i);
|
||
} else if (trimmed.includes('-')) {
|
||
const [startStr, endStr] = trimmed.split('-');
|
||
const start = parseInt(startStr, 10);
|
||
const end = parseInt(endStr, 10);
|
||
if (!Number.isFinite(start) || !Number.isFinite(end)) throw new Error(`Ungültiger Bereich: ${trimmed}`);
|
||
for (let i = Math.max(min, start); i <= Math.min(max, end); i++) values.add(i);
|
||
} else {
|
||
const num = parseInt(trimmed, 10);
|
||
if (!Number.isFinite(num) || num < min || num > max) throw new Error(`Ungültiger Wert: ${trimmed}`);
|
||
values.add(num);
|
||
}
|
||
}
|
||
|
||
return values;
|
||
}
|
||
|
||
/**
|
||
* Validiert eine Cron-Expression (5 Felder: minute hour day month weekday).
|
||
* Gibt { valid: true } oder { valid: false, error: string } zurück.
|
||
*/
|
||
function validateCronExpression(expr) {
|
||
try {
|
||
const parts = String(expr || '').trim().split(/\s+/);
|
||
if (parts.length !== 5) {
|
||
return { valid: false, error: 'Cron-Ausdruck muss genau 5 Felder haben (Minute Stunde Tag Monat Wochentag).' };
|
||
}
|
||
parseCronField(parts[0], 0, 59); // minute
|
||
parseCronField(parts[1], 0, 23); // hour
|
||
parseCronField(parts[2], 1, 31); // day of month
|
||
parseCronField(parts[3], 1, 12); // month
|
||
parseCronField(parts[4], 0, 7); // weekday (0 und 7 = Sonntag)
|
||
return { valid: true };
|
||
} catch (error) {
|
||
return { valid: false, error: error.message };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Berechnet den nächsten Ausführungszeitpunkt nach einem Datum.
|
||
* Gibt ein Date-Objekt zurück oder null bei Fehler.
|
||
*/
|
||
function getNextRunTime(expr, fromDate = new Date()) {
|
||
try {
|
||
const parts = String(expr || '').trim().split(/\s+/);
|
||
if (parts.length !== 5) return null;
|
||
|
||
const minutes = parseCronField(parts[0], 0, 59);
|
||
const hours = parseCronField(parts[1], 0, 23);
|
||
const days = parseCronField(parts[2], 1, 31);
|
||
const months = parseCronField(parts[3], 1, 12);
|
||
const weekdays = parseCronField(parts[4], 0, 7);
|
||
|
||
// Normalisiere Wochentag: 7 → 0 (beide = Sonntag)
|
||
if (weekdays.has(7)) weekdays.add(0);
|
||
|
||
// Suche ab der nächsten Minute
|
||
const candidate = new Date(fromDate);
|
||
candidate.setSeconds(0, 0);
|
||
candidate.setMinutes(candidate.getMinutes() + 1);
|
||
|
||
// Maximal 2 Jahre in die Zukunft suchen
|
||
const limit = new Date(fromDate);
|
||
limit.setFullYear(limit.getFullYear() + 2);
|
||
|
||
while (candidate < limit) {
|
||
const month = candidate.getMonth() + 1; // 1-12
|
||
const day = candidate.getDate();
|
||
const hour = candidate.getHours();
|
||
const minute = candidate.getMinutes();
|
||
const weekday = candidate.getDay(); // 0 = Sonntag
|
||
|
||
if (!months.has(month)) {
|
||
candidate.setMonth(candidate.getMonth() + 1, 1);
|
||
candidate.setHours(0, 0, 0, 0);
|
||
continue;
|
||
}
|
||
if (!days.has(day) || !weekdays.has(weekday)) {
|
||
candidate.setDate(candidate.getDate() + 1);
|
||
candidate.setHours(0, 0, 0, 0);
|
||
continue;
|
||
}
|
||
if (!hours.has(hour)) {
|
||
candidate.setHours(candidate.getHours() + 1, 0, 0, 0);
|
||
continue;
|
||
}
|
||
if (!minutes.has(minute)) {
|
||
candidate.setMinutes(candidate.getMinutes() + 1, 0, 0);
|
||
continue;
|
||
}
|
||
|
||
return candidate;
|
||
}
|
||
|
||
return null;
|
||
} catch (_error) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ─── DB-Helpers ────────────────────────────────────────────────────────────
|
||
|
||
function mapJobRow(row) {
|
||
if (!row) return null;
|
||
return {
|
||
id: Number(row.id),
|
||
name: String(row.name || ''),
|
||
cronExpression: String(row.cron_expression || ''),
|
||
sourceType: String(row.source_type || ''),
|
||
sourceId: Number(row.source_id),
|
||
sourceName: row.source_name != null ? String(row.source_name) : null,
|
||
enabled: Boolean(row.enabled),
|
||
pushoverEnabled: Boolean(row.pushover_enabled),
|
||
lastRunAt: row.last_run_at || null,
|
||
lastRunStatus: row.last_run_status || null,
|
||
nextRunAt: row.next_run_at || null,
|
||
createdAt: row.created_at,
|
||
updatedAt: row.updated_at
|
||
};
|
||
}
|
||
|
||
function mapLogRow(row) {
|
||
if (!row) return null;
|
||
return {
|
||
id: Number(row.id),
|
||
cronJobId: Number(row.cron_job_id),
|
||
startedAt: row.started_at,
|
||
finishedAt: row.finished_at || null,
|
||
status: String(row.status || ''),
|
||
output: row.output || null,
|
||
errorMessage: row.error_message || null
|
||
};
|
||
}
|
||
|
||
async function fetchJobWithSource(db, id) {
|
||
return db.get(
|
||
`
|
||
SELECT
|
||
c.*,
|
||
CASE c.source_type
|
||
WHEN 'script' THEN (SELECT name FROM scripts WHERE id = c.source_id)
|
||
WHEN 'chain' THEN (SELECT name FROM script_chains WHERE id = c.source_id)
|
||
ELSE NULL
|
||
END AS source_name
|
||
FROM cron_jobs c
|
||
WHERE c.id = ?
|
||
LIMIT 1
|
||
`,
|
||
[id]
|
||
);
|
||
}
|
||
|
||
async function fetchAllJobsWithSource(db) {
|
||
return db.all(
|
||
`
|
||
SELECT
|
||
c.*,
|
||
CASE c.source_type
|
||
WHEN 'script' THEN (SELECT name FROM scripts WHERE id = c.source_id)
|
||
WHEN 'chain' THEN (SELECT name FROM script_chains WHERE id = c.source_id)
|
||
ELSE NULL
|
||
END AS source_name
|
||
FROM cron_jobs c
|
||
ORDER BY c.id ASC
|
||
`
|
||
);
|
||
}
|
||
|
||
// ─── Ausführungslogik ──────────────────────────────────────────────────────
|
||
|
||
async function runCronJob(job) {
|
||
const db = await getDb();
|
||
const startedAt = new Date().toISOString();
|
||
const cronActivityId = runtimeActivityService.startActivity('cron', {
|
||
name: job?.name || `Cron #${job?.id || '?'}`,
|
||
source: 'cron',
|
||
cronJobId: job?.id || null,
|
||
currentStep: 'Starte Cronjob'
|
||
});
|
||
|
||
logger.info('cron:run:start', { cronJobId: job.id, name: job.name, sourceType: job.sourceType, sourceId: job.sourceId });
|
||
|
||
// Log-Eintrag anlegen (status = 'running')
|
||
const insertResult = await db.run(
|
||
`INSERT INTO cron_run_logs (cron_job_id, started_at, status) VALUES (?, ?, 'running')`,
|
||
[job.id, startedAt]
|
||
);
|
||
const logId = insertResult.lastID;
|
||
|
||
// Job als laufend markieren
|
||
await db.run(
|
||
`UPDATE cron_jobs SET last_run_at = ?, last_run_status = 'running', updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||
[startedAt, job.id]
|
||
);
|
||
wsService.broadcast('CRON_JOB_UPDATED', { id: job.id, lastRunStatus: 'running', lastRunAt: startedAt });
|
||
|
||
let output = '';
|
||
let errorMessage = null;
|
||
let success = false;
|
||
|
||
try {
|
||
if (job.sourceType === 'script') {
|
||
const scriptService = require('./scriptService');
|
||
const script = await scriptService.getScriptById(job.sourceId);
|
||
runtimeActivityService.updateActivity(cronActivityId, {
|
||
currentStepType: 'script',
|
||
currentStep: `Skript: ${script.name}`,
|
||
currentScriptName: script.name,
|
||
scriptId: script.id
|
||
});
|
||
const scriptActivityId = runtimeActivityService.startActivity('script', {
|
||
name: script.name,
|
||
source: 'cron',
|
||
scriptId: script.id,
|
||
cronJobId: job.id,
|
||
parentActivityId: cronActivityId,
|
||
currentStep: `Cronjob: ${job.name}`
|
||
});
|
||
let prepared = null;
|
||
try {
|
||
prepared = await scriptService.createExecutableScriptFile(script, { source: 'cron', cronJobId: job.id });
|
||
const result = await new Promise((resolve, reject) => {
|
||
const { spawn } = require('child_process');
|
||
const child = spawn(prepared.cmd, prepared.args, {
|
||
env: process.env,
|
||
stdio: ['ignore', 'pipe', 'pipe']
|
||
});
|
||
let stdout = '';
|
||
let stderr = '';
|
||
child.stdout?.on('data', (chunk) => { stdout += String(chunk); });
|
||
child.stderr?.on('data', (chunk) => { stderr += String(chunk); });
|
||
child.on('error', reject);
|
||
child.on('close', (code) => resolve({ code, stdout, stderr }));
|
||
});
|
||
|
||
output = [result.stdout, result.stderr].filter(Boolean).join('\n');
|
||
if (output.length > MAX_OUTPUT_CHARS) output = output.slice(0, MAX_OUTPUT_CHARS) + '\n...[truncated]';
|
||
success = result.code === 0;
|
||
if (!success) errorMessage = `Exit-Code ${result.code}`;
|
||
runtimeActivityService.completeActivity(scriptActivityId, {
|
||
status: success ? 'success' : 'error',
|
||
success,
|
||
outcome: success ? 'success' : 'error',
|
||
exitCode: result.code,
|
||
message: success ? null : errorMessage,
|
||
output: output || null,
|
||
stdout: result.stdout || null,
|
||
stderr: result.stderr || null,
|
||
errorMessage: success ? null : (errorMessage || null)
|
||
});
|
||
} catch (error) {
|
||
runtimeActivityService.completeActivity(scriptActivityId, {
|
||
status: 'error',
|
||
success: false,
|
||
outcome: 'error',
|
||
message: error?.message || 'Skriptfehler',
|
||
errorMessage: error?.message || 'Skriptfehler'
|
||
});
|
||
throw error;
|
||
} finally {
|
||
if (prepared?.cleanup) {
|
||
await prepared.cleanup();
|
||
}
|
||
}
|
||
} else if (job.sourceType === 'chain') {
|
||
const scriptChainService = require('./scriptChainService');
|
||
const logLines = [];
|
||
runtimeActivityService.updateActivity(cronActivityId, {
|
||
currentStepType: 'chain',
|
||
currentStep: `Kette: ${job.sourceName || `#${job.sourceId}`}`,
|
||
currentScriptName: null,
|
||
chainId: job.sourceId
|
||
});
|
||
const result = await scriptChainService.executeChain(
|
||
job.sourceId,
|
||
{
|
||
source: 'cron',
|
||
cronJobId: job.id,
|
||
runtimeParentActivityId: cronActivityId,
|
||
onRuntimeStep: (payload = {}) => {
|
||
const currentScriptName = payload?.stepType === 'script'
|
||
? (payload?.scriptName || payload?.currentScriptName || null)
|
||
: null;
|
||
runtimeActivityService.updateActivity(cronActivityId, {
|
||
currentStepType: payload?.stepType || 'chain',
|
||
currentStep: payload?.currentStep || null,
|
||
currentScriptName,
|
||
scriptId: payload?.scriptId || null
|
||
});
|
||
}
|
||
},
|
||
{
|
||
appendLog: async (_source, line) => {
|
||
logLines.push(line);
|
||
}
|
||
}
|
||
);
|
||
|
||
output = logLines.join('\n');
|
||
if (output.length > MAX_OUTPUT_CHARS) output = output.slice(0, MAX_OUTPUT_CHARS) + '\n...[truncated]';
|
||
success = result && typeof result === 'object'
|
||
? !(Boolean(result.aborted) || Number(result.failed || 0) > 0)
|
||
: Boolean(result);
|
||
if (!success) errorMessage = 'Kette enthielt fehlgeschlagene Schritte.';
|
||
} else {
|
||
throw new Error(`Unbekannter source_type: ${job.sourceType}`);
|
||
}
|
||
} catch (error) {
|
||
success = false;
|
||
errorMessage = error.message || String(error);
|
||
logger.error('cron:run:error', { cronJobId: job.id, error: errorToMeta(error) });
|
||
}
|
||
|
||
const finishedAt = new Date().toISOString();
|
||
const status = success ? 'success' : 'error';
|
||
const nextRunAt = getNextRunTime(job.cronExpression)?.toISOString() || null;
|
||
|
||
// Log-Eintrag abschließen
|
||
await db.run(
|
||
`UPDATE cron_run_logs SET finished_at = ?, status = ?, output = ?, error_message = ? WHERE id = ?`,
|
||
[finishedAt, status, output || null, errorMessage, logId]
|
||
);
|
||
|
||
// Job-Status aktualisieren
|
||
await db.run(
|
||
`UPDATE cron_jobs SET last_run_status = ?, next_run_at = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||
[status, nextRunAt, job.id]
|
||
);
|
||
|
||
// Alte Logs trimmen
|
||
await db.run(
|
||
`
|
||
DELETE FROM cron_run_logs
|
||
WHERE cron_job_id = ?
|
||
AND id NOT IN (
|
||
SELECT id FROM cron_run_logs WHERE cron_job_id = ? ORDER BY id DESC LIMIT ?
|
||
)
|
||
`,
|
||
[job.id, job.id, MAX_LOGS_PER_JOB]
|
||
);
|
||
|
||
logger.info('cron:run:done', { cronJobId: job.id, status, durationMs: new Date(finishedAt) - new Date(startedAt) });
|
||
runtimeActivityService.completeActivity(cronActivityId, {
|
||
status,
|
||
success,
|
||
outcome: success ? 'success' : 'error',
|
||
finishedAt,
|
||
currentStep: null,
|
||
currentScriptName: null,
|
||
message: success ? 'Cronjob abgeschlossen' : (errorMessage || 'Cronjob fehlgeschlagen'),
|
||
output: output || null,
|
||
errorMessage: success ? null : (errorMessage || null)
|
||
});
|
||
|
||
wsService.broadcast('CRON_JOB_UPDATED', { id: job.id, lastRunStatus: status, lastRunAt: finishedAt, nextRunAt });
|
||
|
||
// Pushover-Benachrichtigung (nur wenn am Cron aktiviert UND global aktiviert)
|
||
if (job.pushoverEnabled) {
|
||
try {
|
||
const settings = await settingsService.getSettingsMap();
|
||
const eventKey = success ? 'cron_success' : 'cron_error';
|
||
const title = `Ripster Cron: ${job.name}`;
|
||
const message = success
|
||
? `Cronjob "${job.name}" erfolgreich ausgeführt.`
|
||
: `Cronjob "${job.name}" fehlgeschlagen: ${errorMessage || 'Unbekannter Fehler'}`;
|
||
|
||
await notificationService.notifyWithSettings(settings, eventKey, { title, message });
|
||
} catch (notifyError) {
|
||
logger.warn('cron:run:notify-failed', { cronJobId: job.id, error: errorToMeta(notifyError) });
|
||
}
|
||
}
|
||
|
||
return { success, status, output, errorMessage, finishedAt, nextRunAt };
|
||
}
|
||
|
||
// ─── Scheduler ─────────────────────────────────────────────────────────────
|
||
|
||
class CronService {
|
||
constructor() {
|
||
this._timer = null;
|
||
this._running = new Set(); // IDs aktuell laufender Jobs
|
||
}
|
||
|
||
async init() {
|
||
logger.info('cron:scheduler:init');
|
||
// Beim Start next_run_at für alle enabled Jobs neu berechnen
|
||
await this._recalcNextRuns();
|
||
this._scheduleNextTick();
|
||
}
|
||
|
||
stop() {
|
||
if (this._timer) {
|
||
clearTimeout(this._timer);
|
||
this._timer = null;
|
||
}
|
||
logger.info('cron:scheduler:stopped');
|
||
}
|
||
|
||
_scheduleNextTick() {
|
||
// Auf den Beginn der nächsten vollen Minute warten
|
||
const now = new Date();
|
||
const msUntilNextMinute = (60 - now.getSeconds()) * 1000 - now.getMilliseconds() + 500;
|
||
this._timer = setTimeout(() => this._tick(), msUntilNextMinute);
|
||
}
|
||
|
||
async _tick() {
|
||
try {
|
||
await this._checkAndRunDueJobs();
|
||
} catch (error) {
|
||
logger.error('cron:scheduler:tick-error', { error: errorToMeta(error) });
|
||
}
|
||
this._scheduleNextTick();
|
||
}
|
||
|
||
async _recalcNextRuns() {
|
||
const db = await getDb();
|
||
const jobs = await db.all(`SELECT id, cron_expression FROM cron_jobs WHERE enabled = 1`);
|
||
for (const job of jobs) {
|
||
const nextRunAt = getNextRunTime(job.cron_expression)?.toISOString() || null;
|
||
await db.run(`UPDATE cron_jobs SET next_run_at = ? WHERE id = ?`, [nextRunAt, job.id]);
|
||
}
|
||
}
|
||
|
||
async _checkAndRunDueJobs() {
|
||
const db = await getDb();
|
||
const now = new Date();
|
||
const nowIso = now.toISOString();
|
||
|
||
// Jobs, deren next_run_at <= jetzt ist und die nicht gerade laufen
|
||
const dueJobs = await db.all(
|
||
`SELECT * FROM cron_jobs WHERE enabled = 1 AND next_run_at IS NOT NULL AND next_run_at <= ?`,
|
||
[nowIso]
|
||
);
|
||
|
||
for (const jobRow of dueJobs) {
|
||
const id = Number(jobRow.id);
|
||
if (this._running.has(id)) {
|
||
logger.warn('cron:scheduler:skip-still-running', { cronJobId: id });
|
||
continue;
|
||
}
|
||
|
||
const job = mapJobRow(jobRow);
|
||
this._running.add(id);
|
||
|
||
// Asynchron ausführen, damit der Scheduler nicht blockiert
|
||
runCronJob(job)
|
||
.catch((error) => {
|
||
logger.error('cron:run:unhandled-error', { cronJobId: id, error: errorToMeta(error) });
|
||
})
|
||
.finally(() => {
|
||
this._running.delete(id);
|
||
});
|
||
}
|
||
}
|
||
|
||
// ─── Public API ──────────────────────────────────────────────────────────
|
||
|
||
async listJobs() {
|
||
const db = await getDb();
|
||
const rows = await fetchAllJobsWithSource(db);
|
||
return rows.map(mapJobRow);
|
||
}
|
||
|
||
async getJobById(id) {
|
||
const db = await getDb();
|
||
const row = await fetchJobWithSource(db, id);
|
||
if (!row) {
|
||
const error = new Error(`Cronjob #${id} nicht gefunden.`);
|
||
error.statusCode = 404;
|
||
throw error;
|
||
}
|
||
return mapJobRow(row);
|
||
}
|
||
|
||
async createJob(payload) {
|
||
const { name, cronExpression, sourceType, sourceId, enabled = true, pushoverEnabled = true } = payload || {};
|
||
|
||
const trimmedName = String(name || '').trim();
|
||
const trimmedExpr = String(cronExpression || '').trim();
|
||
|
||
if (!trimmedName) throw Object.assign(new Error('Name fehlt.'), { statusCode: 400 });
|
||
if (!trimmedExpr) throw Object.assign(new Error('Cron-Ausdruck fehlt.'), { statusCode: 400 });
|
||
|
||
const validation = validateCronExpression(trimmedExpr);
|
||
if (!validation.valid) throw Object.assign(new Error(validation.error), { statusCode: 400 });
|
||
|
||
if (!['script', 'chain'].includes(sourceType)) {
|
||
throw Object.assign(new Error('sourceType muss "script" oder "chain" sein.'), { statusCode: 400 });
|
||
}
|
||
|
||
const normalizedSourceId = Number(sourceId);
|
||
if (!Number.isFinite(normalizedSourceId) || normalizedSourceId <= 0) {
|
||
throw Object.assign(new Error('sourceId fehlt oder ist ungültig.'), { statusCode: 400 });
|
||
}
|
||
|
||
const nextRunAt = getNextRunTime(trimmedExpr)?.toISOString() || null;
|
||
const db = await getDb();
|
||
|
||
const result = await db.run(
|
||
`
|
||
INSERT INTO cron_jobs (name, cron_expression, source_type, source_id, enabled, pushover_enabled, next_run_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||
`,
|
||
[trimmedName, trimmedExpr, sourceType, normalizedSourceId, enabled ? 1 : 0, pushoverEnabled ? 1 : 0, nextRunAt]
|
||
);
|
||
|
||
logger.info('cron:create', { cronJobId: result.lastID, name: trimmedName, cronExpression: trimmedExpr });
|
||
return this.getJobById(result.lastID);
|
||
}
|
||
|
||
async updateJob(id, payload) {
|
||
const db = await getDb();
|
||
const existing = await this.getJobById(id);
|
||
|
||
const trimmedName = Object.prototype.hasOwnProperty.call(payload, 'name')
|
||
? String(payload.name || '').trim()
|
||
: existing.name;
|
||
const trimmedExpr = Object.prototype.hasOwnProperty.call(payload, 'cronExpression')
|
||
? String(payload.cronExpression || '').trim()
|
||
: existing.cronExpression;
|
||
|
||
if (!trimmedName) throw Object.assign(new Error('Name fehlt.'), { statusCode: 400 });
|
||
if (!trimmedExpr) throw Object.assign(new Error('Cron-Ausdruck fehlt.'), { statusCode: 400 });
|
||
|
||
const validation = validateCronExpression(trimmedExpr);
|
||
if (!validation.valid) throw Object.assign(new Error(validation.error), { statusCode: 400 });
|
||
|
||
const sourceType = Object.prototype.hasOwnProperty.call(payload, 'sourceType') ? payload.sourceType : existing.sourceType;
|
||
const sourceId = Object.prototype.hasOwnProperty.call(payload, 'sourceId') ? Number(payload.sourceId) : existing.sourceId;
|
||
const enabled = Object.prototype.hasOwnProperty.call(payload, 'enabled') ? Boolean(payload.enabled) : existing.enabled;
|
||
const pushoverEnabled = Object.prototype.hasOwnProperty.call(payload, 'pushoverEnabled') ? Boolean(payload.pushoverEnabled) : existing.pushoverEnabled;
|
||
|
||
if (!['script', 'chain'].includes(sourceType)) {
|
||
throw Object.assign(new Error('sourceType muss "script" oder "chain" sein.'), { statusCode: 400 });
|
||
}
|
||
if (!Number.isFinite(sourceId) || sourceId <= 0) {
|
||
throw Object.assign(new Error('sourceId fehlt oder ist ungültig.'), { statusCode: 400 });
|
||
}
|
||
|
||
const nextRunAt = enabled ? (getNextRunTime(trimmedExpr)?.toISOString() || null) : null;
|
||
|
||
await db.run(
|
||
`
|
||
UPDATE cron_jobs
|
||
SET name = ?, cron_expression = ?, source_type = ?, source_id = ?,
|
||
enabled = ?, pushover_enabled = ?, next_run_at = ?, updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = ?
|
||
`,
|
||
[trimmedName, trimmedExpr, sourceType, sourceId, enabled ? 1 : 0, pushoverEnabled ? 1 : 0, nextRunAt, id]
|
||
);
|
||
|
||
logger.info('cron:update', { cronJobId: id });
|
||
return this.getJobById(id);
|
||
}
|
||
|
||
async deleteJob(id) {
|
||
const db = await getDb();
|
||
const job = await this.getJobById(id);
|
||
await db.run(`DELETE FROM cron_jobs WHERE id = ?`, [id]);
|
||
logger.info('cron:delete', { cronJobId: id });
|
||
return job;
|
||
}
|
||
|
||
async getJobLogs(id, limit = 20) {
|
||
await this.getJobById(id); // Existenz prüfen
|
||
const db = await getDb();
|
||
const rows = await db.all(
|
||
`SELECT * FROM cron_run_logs WHERE cron_job_id = ? ORDER BY id DESC LIMIT ?`,
|
||
[id, Math.min(Number(limit) || 20, 100)]
|
||
);
|
||
return rows.map(mapLogRow);
|
||
}
|
||
|
||
async triggerJobManually(id) {
|
||
const job = await this.getJobById(id);
|
||
if (this._running.has(id)) {
|
||
throw Object.assign(new Error('Cronjob läuft bereits.'), { statusCode: 409 });
|
||
}
|
||
this._running.add(id);
|
||
logger.info('cron:manual-trigger', { cronJobId: id });
|
||
|
||
// Asynchron starten
|
||
runCronJob(job)
|
||
.catch((error) => {
|
||
logger.error('cron:manual-trigger:error', { cronJobId: id, error: errorToMeta(error) });
|
||
})
|
||
.finally(() => {
|
||
this._running.delete(id);
|
||
});
|
||
|
||
return { triggered: true, cronJobId: id };
|
||
}
|
||
|
||
validateExpression(expr) {
|
||
return validateCronExpression(expr);
|
||
}
|
||
|
||
getNextRunTime(expr) {
|
||
const next = getNextRunTime(expr);
|
||
return next ? next.toISOString() : null;
|
||
}
|
||
}
|
||
|
||
module.exports = new CronService();
|