Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

---

## [5.3.0] – branch: `feature/detail-reload-controls`

### Added

- **Job-Detail – AJAX-Reload der Ausführungshistorie:** Statt eines vollständigen `location.reload()` ruft die Seite `GET /crons/{id}?_json=1` ab und ersetzt ausschließlich den `<tbody>` der Ausführungshistorie-Tabelle. Kein Seitenflackern, Scroll-Position und aufgeklappte Output-Bereiche bleiben erhalten. `CronController::show()` rendert im JSON-Modus die Tabellenzeilen serverseitig über ein ausgelagertes Partial (`_detail_history_rows.php`) und gibt `{"has_running": bool, "rows_html": "..."}` zurück. Die Session wird vor dem Agent-I/O geschlossen (`writeClose()`).
- **Job-Detail – Reload-Steuerung im Ausführungshistorie-Header:** Zwei Icon-Buttons in der Kopfzeile des Ausführungshistorie-Abschnitts:
- **Auto-Reload-Toggle** (Pause-/Play-Icon): schaltet das automatische 10-Sekunden-Polling ein und aus. Zustand wird per `sessionStorage` (Schlüssel `cm_autoreload_<jobId>`) über Seitenbesuche hinweg gespeichert. Standard: ON wenn beim Laden der Seite ein Job läuft, OFF sonst. Wenn ein laufender Job abschließt, stoppt Auto-Reload automatisch.
- **Manueller Reload** (Refresh-Icon): löst sofort einen AJAX-Fetch aus.
- **`_detail_history_rows.php`:** Neues Partial-Template; enthält die gesamte `<tr>`-Rendering-Logik der Ausführungshistorie. Wird sowohl vom vollen Seitenrender als auch vom JSON-Endpoint eingebunden — keine Duplikation der Badge-/Output-Logik.

### Changed

- Gelber Countdown-Banner (`Job läuft – Seite aktualisiert sich in Xs`) entfernt; die Reload-Steuerung übernimmt diese Funktion.

---

## [5.2.1] – branch: `fix/ansi-escape-in-ssh-output`

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion agent/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
5.2.1
5.3.0
2 changes: 1 addition & 1 deletion web/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
5.2.1
5.3.0
2 changes: 2 additions & 0 deletions web/lang/de.php
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,8 @@
'execute_now_already_pending' => 'Sofortausführung bereits geplant – bitte warten bis der Job gestartet wurde.',
'live_running' => 'live',
'live_auto_refresh' => 'Job läuft – Seite aktualisiert sich in',
'detail_auto_reload_toggle' => 'Automatischen Reload an/aus',
'detail_manual_reload' => 'Jetzt neu laden',

// -------------------------------------------------------------------------
// Ersteinrichtung
Expand Down
2 changes: 2 additions & 0 deletions web/lang/en.php
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,8 @@
'execute_now_already_pending' => 'Immediate execution already scheduled – please wait for the job to start.',
'live_running' => 'live',
'live_auto_refresh' => 'Job is running – page refreshes in',
'detail_auto_reload_toggle' => 'Toggle auto-reload',
'detail_manual_reload' => 'Reload now',

// -------------------------------------------------------------------------
// Initial Setup page
Expand Down
48 changes: 48 additions & 0 deletions web/src/Controller/CronController.php
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,54 @@ public function show(array $params): void
$id = (string) ($params['id'] ?? '');
$agent = $this->agentClient();

// JSON mode: AJAX history refresh from the detail page
if ($this->isJsonRequest()) {
$csrfToken = SessionManager::getCsrfToken();
$isAdmin = SessionManager::hasRole('admin');
SessionManager::writeClose();

try {
$results = $agent->getMultiple([
'job' => ['path' => '/crons/' . rawurlencode($id)],
'history' => ['path' => '/history', 'query' => ['job_id' => $id, 'limit' => 20]],
]);
$job = $results['job'];
$history = $results['history']['data'] ?? [];
} catch (\RuntimeException $e) {
$this->logger->error('CronController::show (json): agent request failed', [
'id' => $id,
'message' => $e->getMessage(),
]);
$this->jsonResponse(['error' => 'agent_unavailable'], 503);
return;
}

$hasRunning = false;
foreach ($history as $_e) {
$ec = $_e['exit_code'] ?? null;
$fa = (string) ($_e['finished_at'] ?? '');
if ($ec === null && $fa === '') {
$hasRunning = true;
break;
}
}

$translator = $this->translator();
$t = fn(string $k, array $r = []): string => $translator->t($k, $r);
$csrf_token = $csrfToken;
$jobId = $id;

ob_start();
include __DIR__ . '/../../templates/cron/_detail_history_rows.php';
$rowsHtml = (string) ob_get_clean();

$this->jsonResponse([
'has_running' => $hasRunning,
'rows_html' => $rowsHtml,
]);
return;
}

try {
// One parallel batch instead of two sequential roundtrips
$results = $agent->getMultiple([
Expand Down
186 changes: 186 additions & 0 deletions web/templates/cron/_detail_history_rows.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
<?php

declare(strict_types=1);

/**
* Cronmanager Web UI – Execution History Rows Partial
*
* Renders the <tr> elements for the execution-history tbody in the job-detail
* view. Included by both the full-page render and the ?_json=1 AJAX endpoint
* so that the HTML is always generated by the same PHP code.
*
* Expected variables (set by the including template or controller):
* array $history – execution records (up to 20)
* array $job – job record (used for retry_count)
* bool $isAdmin – whether the current user has admin role
* callable $t – translator callable: $t('key', ['var' => val])
* string $csrf_token – CSRF token for kill forms
* int|string $jobId – numeric job id (for kill-form return URL)
*
* @author Christian Schulz <technik@meinetechnikwelt.rocks>
* @license GNU General Public License version 3 or later
*/

foreach ($history as $idx => $entry):
$startedAt = (string) ($entry['started_at'] ?? '');
$finishedAt = (string) ($entry['finished_at'] ?? '');
$exitCode = isset($entry['exit_code']) ? $entry['exit_code'] : null;
$executionId = (string) ($entry['execution_id'] ?? '');
$isRunning = $exitCode === null && $finishedAt === '';
$duration = isset($entry['duration_seconds'])
? round((float) $entry['duration_seconds'], 1) . 's'
: '–';
$entryTarget = (string) ($entry['target'] ?? '');
$output = (string) ($entry['output'] ?? '');
$outputId = 'hist-output-' . $idx;
$outputTrunc = mb_strlen($output) > 200
? mb_substr($output, 0, 200) . '…'
: $output;
$duringMaintenance = !empty($entry['during_maintenance']);
$retryAttempt = (int) ($entry['retry_attempt'] ?? 0);
$retryTotal = (int) ($job['retry_count'] ?? 0);

// Exit code badge
if ($isRunning) {
$exitBadge = '<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">'
. htmlspecialchars($t('status_running'), ENT_QUOTES, 'UTF-8')
. '</span>';
} elseif ($exitCode !== null && (int) $exitCode === -5) {
$exitBadge = '<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400">'
. htmlspecialchars($t('cron_interrupted_badge'), ENT_QUOTES, 'UTF-8')
. '</span>';
} elseif ($exitCode !== null && (int) $exitCode === -4) {
$exitBadge = '<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">'
. htmlspecialchars($t('cron_maintenance_skipped_badge'), ENT_QUOTES, 'UTF-8')
. '</span>';
} elseif ($exitCode !== null && (int) $exitCode === 0) {
$exitBadge = '<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">0</span>';
if ($duringMaintenance) {
$exitBadge .= ' <span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">'
. htmlspecialchars($t('cron_during_maintenance_badge'), ENT_QUOTES, 'UTF-8')
. '</span>';
}
} elseif ($exitCode !== null && (int) $exitCode === -2) {
$exitBadge = '<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-orange-100 text-orange-800">'
. htmlspecialchars($t('cron_kill_running'), ENT_QUOTES, 'UTF-8')
. '</span>';
} else {
$safeCode = htmlspecialchars((string) $exitCode, ENT_QUOTES, 'UTF-8');
$exitBadge = '<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">' . $safeCode . '</span>';
if ($duringMaintenance) {
$exitBadge .= ' <span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">'
. htmlspecialchars($t('cron_during_maintenance_badge'), ENT_QUOTES, 'UTF-8')
. '</span>';
}
}
?>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 align-top">
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-300 whitespace-nowrap">
<?= htmlspecialchars($startedAt, ENT_QUOTES, 'UTF-8') ?>
</td>
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-300 whitespace-nowrap">
<?= htmlspecialchars($finishedAt !== '' ? $finishedAt : '—', ENT_QUOTES, 'UTF-8') ?>
</td>
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-300 whitespace-nowrap">
<?= htmlspecialchars($duration, ENT_QUOTES, 'UTF-8') ?>
</td>
<td class="px-4 py-3 text-sm whitespace-nowrap">
<?php if ($entryTarget === '' || $entryTarget === 'local'): ?>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300">
<?= htmlspecialchars($t('cron_local_badge'), ENT_QUOTES, 'UTF-8') ?>
</span>
<?php else: ?>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 font-mono">
<?= htmlspecialchars($entryTarget, ENT_QUOTES, 'UTF-8') ?>
</span>
<?php endif; ?>
</td>
<td class="px-4 py-3 text-sm">
<?= $exitBadge ?>
<?php if ($retryAttempt > 0): ?>
<span class="ml-1 inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300">
<?= htmlspecialchars($t('cron_retry_badge', ['attempt' => $retryAttempt, 'total' => $retryTotal]), ENT_QUOTES, 'UTF-8') ?>
</span>
<?php endif; ?>
</td>
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-300 max-w-sm">
<?php if ($isRunning): ?>
<div class="flex items-center gap-1.5 mb-1">
<span class="inline-block w-2 h-2 rounded-full bg-yellow-400 animate-pulse flex-shrink-0"></span>
<span class="text-xs text-yellow-600 dark:text-yellow-400 font-medium"><?= htmlspecialchars($t('live_running'), ENT_QUOTES, 'UTF-8') ?></span>
</div>
<?php endif; ?>
<?php if ($output !== ''): ?>
<!-- Full output stored for copy/download (hidden) -->
<span id="<?= htmlspecialchars($outputId . '-data', ENT_QUOTES, 'UTF-8') ?>"
class="hidden"><?= htmlspecialchars($output, ENT_QUOTES, 'UTF-8') ?></span>
<span id="<?= htmlspecialchars($outputId . '-short', ENT_QUOTES, 'UTF-8') ?>"
class="font-mono text-xs whitespace-pre-wrap break-words">
<?= htmlspecialchars($outputTrunc, ENT_QUOTES, 'UTF-8') ?>
</span>
<?php if (mb_strlen($output) > 200): ?>
<span id="<?= htmlspecialchars($outputId . '-full', ENT_QUOTES, 'UTF-8') ?>"
class="font-mono text-xs whitespace-pre-wrap break-words hidden">
<?= htmlspecialchars($output, ENT_QUOTES, 'UTF-8') ?>
</span>
<button type="button"
onclick="toggleOutput('<?= htmlspecialchars($outputId, ENT_QUOTES, 'UTF-8') ?>')"
class="ml-1 text-xs text-blue-600 hover:underline focus:outline-none">
show more
</button>
<?php endif; ?>
<!-- Copy / Download buttons -->
<div class="mt-1 flex items-center gap-1">
<button type="button"
onclick="copyOutput('<?= htmlspecialchars($outputId, ENT_QUOTES, 'UTF-8') ?>', this)"
class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium
bg-gray-100 hover:bg-gray-200 text-gray-600 dark:bg-gray-700
dark:hover:bg-gray-600 dark:text-gray-300 border border-gray-200
dark:border-gray-600 transition focus:outline-none focus:ring-1 focus:ring-gray-400"
title="<?= htmlspecialchars($t('output_copy_title'), ENT_QUOTES, 'UTF-8') ?>">
<svg class="w-3 h-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round"
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"/>
</svg>
<span><?= htmlspecialchars($t('output_copy'), ENT_QUOTES, 'UTF-8') ?></span>
</button>
<button type="button"
onclick="downloadOutput('<?= htmlspecialchars($outputId, ENT_QUOTES, 'UTF-8') ?>', <?= (int) $jobId ?>, '<?= htmlspecialchars(addslashes($startedAt), ENT_QUOTES, 'UTF-8') ?>')"
class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium
bg-gray-100 hover:bg-gray-200 text-gray-600 dark:bg-gray-700
dark:hover:bg-gray-600 dark:text-gray-300 border border-gray-200
dark:border-gray-600 transition focus:outline-none focus:ring-1 focus:ring-gray-400"
title="<?= htmlspecialchars($t('output_download_title'), ENT_QUOTES, 'UTF-8') ?>">
<svg class="w-3 h-3 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
<span><?= htmlspecialchars($t('output_download'), ENT_QUOTES, 'UTF-8') ?></span>
</button>
</div>
<?php else: ?>
<span class="text-gray-300 dark:text-gray-600">—</span>
<?php endif; ?>
</td>
<?php if ($isAdmin): ?>
<td class="px-4 py-3 text-sm whitespace-nowrap">
<?php if ($isRunning && $executionId !== ''): ?>
<form method="POST"
action="/execution/<?= htmlspecialchars(rawurlencode($executionId), ENT_QUOTES, 'UTF-8') ?>/kill"
onsubmit="return confirm('<?= htmlspecialchars($t('cron_kill_confirm'), ENT_QUOTES, 'UTF-8') ?>')">
<input type="hidden" name="_csrf" value="<?= htmlspecialchars($csrf_token ?? '', ENT_QUOTES, 'UTF-8') ?>">
<input type="hidden" name="_return" value="/crons/<?= htmlspecialchars(rawurlencode($jobId), ENT_QUOTES, 'UTF-8') ?>">
<button type="submit"
class="inline-flex items-center gap-1 px-3 py-1 rounded text-xs font-medium
bg-red-50 hover:bg-red-100 text-red-700 border border-red-200
transition focus:outline-none focus:ring-2 focus:ring-red-400">
<?= htmlspecialchars($t('cron_kill_running'), ENT_QUOTES, 'UTF-8') ?>
</button>
</form>
<?php else: ?>
<span class="text-gray-300 dark:text-gray-600">—</span>
<?php endif; ?>
</td>
<?php endif; ?>
</tr>
<?php endforeach; ?>
Loading
Loading