diff --git a/partials/landingpages/modules/setup.php b/partials/landingpages/modules/setup.php
index 15854f7..baf8583 100644
--- a/partials/landingpages/modules/setup.php
+++ b/partials/landingpages/modules/setup.php
@@ -414,9 +414,10 @@ $renderField = function (array $field) use (&$current, $getNested, $driverOption
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$isSchedulerAutosave = isset($_POST['scheduler_autosave']) && (string) $_POST['scheduler_autosave'] === '1';
+ $isSchedulerTest = isset($_POST['scheduler_test']) && (string) $_POST['scheduler_test'] === '1';
$payload = [];
- if ($isSchedulerAutosave) {
+ if ($isSchedulerAutosave || $isSchedulerTest) {
if ($cronTaskDefinitions !== []) {
$postedSchedulerJobs = is_array($_POST['scheduler_jobs'] ?? null) ? $_POST['scheduler_jobs'] : [];
$current['scheduler_jobs'] = $extractSchedulerJobs($postedSchedulerJobs, $cronTaskDefinitions, $current);
@@ -425,15 +426,26 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$current = modules()->settings($moduleName);
$refreshSchedulerState();
+ $testResult = null;
+ if ($isSchedulerTest) {
+ $jobName = trim((string) ($_POST['scheduler_job_name'] ?? ''));
+ $entryIndex = max(0, (int) ($_POST['scheduler_entry_index'] ?? 0));
+ $testResult = modules()->runCronTaskNow($moduleName, $jobName, $entryIndex);
+ $refreshSchedulerState();
+ }
+
while (ob_get_level() > 0) {
ob_end_clean();
}
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => true,
- 'message' => 'Scheduler gespeichert.',
+ 'message' => $isSchedulerTest
+ ? (string) ($testResult['message'] ?? 'Cron-Test ausgefuehrt.')
+ : 'Scheduler gespeichert.',
'scheduler_jobs' => $current['scheduler_jobs'] ?? [],
'statuses' => $cronTaskStatuses,
+ 'test_result' => $testResult,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
@@ -906,6 +918,7 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
Letzter Erfolg: = e($formatRunTimestamp((string) ($task['state']['last_success_at'] ?? ''), (string) ($task['timezone'] ?? 'UTC'))) ?>
Naechster Lauf lokal: = e(!empty($task['enabled']) ? (string) (($task['next_due_at_local'] ?? '') !== '' ? $task['next_due_at_local'] : '-') : '-') ?>
Status: = e((string) (($task['state']['last_status'] ?? '') !== '' ? $task['state']['last_status'] : '-')) ?>
+ Meldung: = e((string) (($task['state']['last_message'] ?? '') !== '' ? $task['state']['last_message'] : '-')) ?>
Cron-Fehler: = e((string) $task['parse_error']) ?>
@@ -1281,6 +1294,7 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
Letzter Erfolg: = e($formatRunTimestamp((string) ($task['state']['last_success_at'] ?? ''), (string) ($task['timezone'] ?? 'UTC'))) ?>
Naechster Lauf lokal: = e(!empty($task['enabled']) ? (string) (($task['next_due_at_local'] ?? '') !== '' ? $task['next_due_at_local'] : '-') : '-') ?>
Status: = e((string) (($task['state']['last_status'] ?? '') !== '' ? $task['state']['last_status'] : '-')) ?>
+ Meldung: = e((string) (($task['state']['last_message'] ?? '') !== '' ? $task['state']['last_message'] : '-')) ?>
Cron-Fehler: = e((string) $task['parse_error']) ?>
@@ -1754,6 +1768,15 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
return payload;
};
+ const collectSchedulerTestPayload = (jobName, entryIndex) => {
+ const payload = collectSchedulerPayload();
+ payload.delete('scheduler_autosave');
+ payload.append('scheduler_test', '1');
+ payload.append('scheduler_job_name', jobName);
+ payload.append('scheduler_entry_index', String(entryIndex));
+ return payload;
+ };
+
const persistScheduler = async () => {
if (!form) return true;
const url = form.action || window.location.href;
@@ -1799,6 +1822,35 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
return true;
};
+ const testScheduler = async (jobName, entryIndex) => {
+ if (!form) return null;
+ const url = form.action || window.location.href;
+ console.log('[scheduler] test start', { jobName, entryIndex, url });
+ const response = await fetch(url, {
+ method: 'POST',
+ body: collectSchedulerTestPayload(jobName, entryIndex),
+ credentials: 'same-origin',
+ headers: {
+ 'Accept': 'application/json',
+ 'X-Requested-With': 'XMLHttpRequest',
+ },
+ });
+ const raw = await response.text();
+ console.log('[scheduler] test raw response', raw);
+ let data = null;
+ try {
+ data = JSON.parse(raw);
+ } catch (error) {
+ console.error('[scheduler] test json parse failed', error);
+ throw new Error(`Cron-Test Antwort war kein JSON: ${raw.slice(0, 160)}`);
+ }
+ if (!response.ok || !data || data.ok !== true) {
+ throw new Error(data?.message || `Cron-Test fehlgeschlagen (${response.status}).`);
+ }
+ applyStatusUpdates(data.statuses || []);
+ return data;
+ };
+
const updateEntrySummary = (entry) => {
const enabled = getEntryField(entry, '[data-enabled]')?.checked ?? false;
const expression = getEntryField(entry, '[data-cron-expression]')?.value || '';
@@ -1855,6 +1907,7 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
${lastSuccess ? lastSuccess.textContent : 'Letzter Erfolg: -'}
${nextLocal ? nextLocal.textContent : 'Naechster Lauf lokal: -'}
${status ? status.textContent : 'Status: -'}
+ ${findStatusNode(entry, 'Meldung') ? findStatusNode(entry, 'Meldung').textContent : 'Meldung: -'}
${parseError ? `${parseError.textContent}` : ''}
`;
};
@@ -2012,6 +2065,7 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
Letzter Erfolg: -
Naechster Lauf lokal: -
Status: -
+ Meldung: -
`;
getEntryField(entry, '[data-enabled]').checked = Boolean(values.enabled);
@@ -2061,12 +2115,34 @@ $activeDbGroup = $testGroup !== null && array_key_exists($testGroup, $dbGroups)
actions.dataset.entryActions = '';
actions.innerHTML = `
+
${job.dataset.jobMode === 'multi' ? '' : ''}
`;
entry.appendChild(actions);
}
actions.querySelector('[data-entry-edit]')?.addEventListener('click', () => openModal(entry));
+ actions.querySelector('[data-entry-test]')?.addEventListener('click', async () => {
+ const testButton = actions.querySelector('[data-entry-test]');
+ const originalText = testButton?.textContent || 'Cron testen';
+ const jobName = job.dataset.jobName || '';
+ const entryIndex = Number(entry.dataset.entryIndex || '0');
+ if (testButton) {
+ testButton.disabled = true;
+ testButton.textContent = 'Teste...';
+ }
+ try {
+ const result = await testScheduler(jobName, entryIndex);
+ alert(result?.message || 'Cron-Test erfolgreich.');
+ } catch (error) {
+ alert(error instanceof Error ? error.message : 'Cron-Test fehlgeschlagen.');
+ } finally {
+ if (testButton) {
+ testButton.disabled = false;
+ testButton.textContent = originalText;
+ }
+ }
+ });
actions.querySelector('[data-remove-scheduler-entry]')?.addEventListener('click', async () => {
entry.remove();
reindexJob(job);
diff --git a/src/App/ModuleCronScheduler.php b/src/App/ModuleCronScheduler.php
index 3d679d8..b319048 100644
--- a/src/App/ModuleCronScheduler.php
+++ b/src/App/ModuleCronScheduler.php
@@ -202,6 +202,101 @@ final class ModuleCronScheduler
return $results;
}
+ public function runNow(string $moduleName, string $jobName, int $entryIndex): array
+ {
+ $task = null;
+ foreach ($this->statuses($moduleName) as $status) {
+ if ((string) ($status['job_name'] ?? '') === $jobName && (int) ($status['entry_index'] ?? -1) === $entryIndex) {
+ $task = $status;
+ break;
+ }
+ }
+
+ if (!is_array($task)) {
+ return [
+ 'task' => $jobName,
+ 'entry_index' => $entryIndex,
+ 'ok' => false,
+ 'message' => 'Cron-Eintrag nicht gefunden.',
+ ];
+ }
+
+ if (!$this->modules->hasFunction($moduleName, (string) $task['callback'])) {
+ return [
+ 'task' => $jobName,
+ 'entry_index' => $entryIndex,
+ 'ok' => false,
+ 'message' => 'Callback nicht registriert.',
+ ];
+ }
+
+ if (!$this->acquireLock($moduleName, (string) $task['state_key'], (int) $task['lock_minutes'])) {
+ return [
+ 'task' => $jobName,
+ 'entry_index' => $entryIndex,
+ 'ok' => false,
+ 'message' => 'Cron-Eintrag ist aktuell gesperrt.',
+ ];
+ }
+
+ $startedAt = gmdate('Y-m-d H:i:s');
+ $timezone = $this->safeTimezone((string) ($task['timezone'] ?? 'UTC'));
+ $this->persistState($moduleName, (string) $task['state_key'], [
+ 'last_started_at' => $startedAt,
+ 'last_status' => 'running',
+ 'last_message' => 'Manueller Testlauf gestartet.',
+ ]);
+
+ try {
+ $result = $this->modules->call($moduleName, (string) $task['callback'], [
+ 'task' => $task,
+ 'trigger' => 'manual_test',
+ 'started_at' => $startedAt,
+ 'scheduled_for_utc' => null,
+ 'scheduled_for_local' => null,
+ 'timezone' => $timezone->getName(),
+ ]);
+
+ $ok = !is_array($result) || !array_key_exists('ok', $result) || !empty($result['ok']);
+ $skipped = is_array($result) && !empty($result['skipped']);
+ $message = is_array($result) ? trim((string) ($result['message'] ?? '')) : '';
+ $finishedAt = gmdate('Y-m-d H:i:s');
+
+ $payload = [
+ 'last_finished_at' => $finishedAt,
+ 'last_status' => $skipped ? 'skipped' : ($ok ? 'success' : 'error'),
+ 'last_message' => $message !== '' ? $message : ($ok ? 'Manueller Testlauf erfolgreich.' : 'Manueller Testlauf fehlgeschlagen.'),
+ 'lock_until' => null,
+ ];
+ if ($ok && !$skipped) {
+ $payload['last_success_at'] = $finishedAt;
+ }
+ $this->persistState($moduleName, (string) $task['state_key'], $payload);
+
+ return [
+ 'task' => $jobName,
+ 'entry_index' => $entryIndex,
+ 'ok' => $ok,
+ 'message' => (string) $payload['last_message'],
+ ];
+ } catch (\Throwable $e) {
+ $finishedAt = gmdate('Y-m-d H:i:s');
+ $this->persistState($moduleName, (string) $task['state_key'], [
+ 'last_finished_at' => $finishedAt,
+ 'last_status' => 'error',
+ 'last_message' => $e->getMessage(),
+ 'lock_until' => null,
+ ]);
+
+ return [
+ 'task' => $jobName,
+ 'entry_index' => $entryIndex,
+ 'ok' => false,
+ 'message' => $e->getMessage(),
+ ];
+ }
+ }
+
private function jobConfigs(array $definition, array $settings): array
{
$jobs = is_array($settings['scheduler_jobs'] ?? null)
diff --git a/src/App/ModuleManager.php b/src/App/ModuleManager.php
index 2b76127..dfeab61 100644
--- a/src/App/ModuleManager.php
+++ b/src/App/ModuleManager.php
@@ -311,6 +311,11 @@ final class ModuleManager
return $this->cronScheduler()->runDue($name);
}
+ public function runCronTaskNow(string $name, string $jobName, int $entryIndex): array
+ {
+ return $this->cronScheduler()->runNow($name, $jobName, $entryIndex);
+ }
+
private function scanModules(): void
{
$this->modules = [];