diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index fa336f6..f3cf6eb 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -2,7 +2,7 @@ stages: - deploy variables: - BASE_DIRS: "api config data debug modules partials public src tools" + BASE_DIRS: "api data debug modules partials public src tools" CONFIG_BASE_DIR: "config" LOCAL_ROOT: "/mnt/nexusserver" # SITE_DOMAIN_DIR wurde entfernt @@ -96,4 +96,4 @@ deploy:production: name: production only: - main - # when: manual \ No newline at end of file + # when: manual diff --git a/modules/pi_control/assets/console.js b/modules/pi_control/assets/console.js index a02ef7e..22e1169 100644 --- a/modules/pi_control/assets/console.js +++ b/modules/pi_control/assets/console.js @@ -165,6 +165,10 @@ const queueBody = document.querySelector('[data-queue-body]'); const countdownEl = document.querySelector('[data-queue-countdown]'); const refreshBtn = document.querySelector('[data-queue-refresh]'); + const queueBtn = document.querySelector('[data-queue-button]'); + const queueCount = document.querySelector('[data-queue-count]'); + const queueModal = document.querySelector('[data-queue-modal]'); + const queueClose = document.querySelector('[data-queue-close]'); if (!queueBody || !countdownEl) return; let remaining = 10; @@ -179,6 +183,10 @@ if (data && data.html) { queueBody.innerHTML = data.html; } + if (queueCount && typeof data.count === 'number') { + queueCount.textContent = String(data.count); + queueCount.style.display = data.count > 0 ? 'inline-flex' : 'none'; + } remaining = data && data.next ? data.next : 10; } catch (e) { // ignore @@ -195,6 +203,7 @@ }; setInterval(tick, 1000); + fetchQueue(); if (refreshBtn) { refreshBtn.addEventListener('click', () => { fetchQueue(); @@ -203,6 +212,59 @@ }); } + if (queueBtn && queueModal) { + queueBtn.addEventListener('click', () => { + queueModal.classList.add('is-open'); + queueModal.setAttribute('aria-hidden', 'false'); + fetchQueue(); + }); + } + if (queueClose && queueModal) { + queueClose.addEventListener('click', () => { + queueModal.classList.remove('is-open'); + queueModal.setAttribute('aria-hidden', 'true'); + }); + } + if (queueModal) { + queueModal.addEventListener('click', (e) => { + if (e.target === queueModal) { + queueModal.classList.remove('is-open'); + queueModal.setAttribute('aria-hidden', 'true'); + } + }); + } + + queueBody.addEventListener('click', async (e) => { + const btn = e.target.closest('[data-queue-action]'); + if (!btn) return; + const runId = btn.getAttribute('data-run-id'); + const action = btn.getAttribute('data-queue-action'); + if (!runId || !action) return; + const url = new URL(window.location.href); + url.searchParams.set('queue_action_json', '1'); + const formData = new FormData(); + formData.set('run_id', runId); + formData.set('action', action); + try { + const res = await fetch(url.toString(), { method: 'POST', body: formData, cache: 'no-store' }); + const data = await res.json(); + if (!data.ok) { + if (consoleNotice) { + consoleNotice.textContent = data.error || 'Aktion fehlgeschlagen.'; + consoleNotice.style.display = 'block'; + } + } + } catch (err) { + if (consoleNotice) { + consoleNotice.textContent = 'Aktion fehlgeschlagen.'; + consoleNotice.style.display = 'block'; + } + } + fetchQueue(); + remaining = 10; + countdownEl.textContent = String(remaining); + }); + const consoleForm = document.querySelector('[data-console-form]'); const consoleError = document.querySelector('[data-console-error]'); const consoleNotice = document.querySelector('[data-console-notice]'); diff --git a/modules/pi_control/assets/pi_control.css b/modules/pi_control/assets/pi_control.css index 6032d47..db34cd5 100644 --- a/modules/pi_control/assets/pi_control.css +++ b/modules/pi_control/assets/pi_control.css @@ -72,3 +72,55 @@ border: 0; background: #0b0f17; } + +.queue-button { + display: inline-flex; + align-items: center; + gap: 8px; +} +.queue-badge { + display: inline-flex; + min-width: 22px; + height: 22px; + align-items: center; + justify-content: center; + border-radius: 999px; + background: #ff5a3c; + color: #fff; + font-weight: 700; + font-size: 0.75rem; + padding: 0 6px; +} + +.modal { + position: fixed; + inset: 0; + background: rgba(10, 14, 24, 0.55); + display: none; + align-items: center; + justify-content: center; + padding: 24px; + z-index: 40; +} +.modal.is-open { display: flex; } +.modal-card { + width: min(1100px, 96vw); + max-height: 90vh; + overflow: auto; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 16px; + box-shadow: var(--shadow); + padding: 16px; +} +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.modal-actions { + display: inline-flex; + align-items: center; + gap: 8px; +} diff --git a/modules/pi_control/pages/console.php b/modules/pi_control/pages/console.php index 5fdf440..e0991e9 100644 --- a/modules/pi_control/pages/console.php +++ b/modules/pi_control/pages/console.php @@ -31,7 +31,7 @@ $commands = $pdo->query('SELECT * FROM ' . $table('commands') . ' ORDER BY label $renderRuns = function (array $runs): string { ob_start(); if (!$runs) { - echo 'Noch keine Runs.'; + echo 'Noch keine Runs.'; return ob_get_clean(); } foreach ($runs as $r) { @@ -49,6 +49,17 @@ $renderRuns = function (array $runs): string { echo '' . e((string)($r['created_by'] ?? '')) . ''; echo '' . e($snippet) . ''; echo '' . (!empty($r['timeout_sec']) ? e((string)$r['timeout_sec']) . 's' : 'default') . ''; + $status = (string)($r['status'] ?? ''); + echo ''; + if ($status === 'queued') { + echo ''; + echo ''; + } elseif ($status === 'running') { + echo 'läuft'; + } else { + echo ''; + } + echo ''; echo ''; } return ob_get_clean(); @@ -61,15 +72,75 @@ if (isset($_GET['queue_json'])) { LEFT JOIN ' . $table('hosts') . ' h ON h.id = r.host_id ORDER BY r.id DESC LIMIT 20' )->fetchAll(PDO::FETCH_ASSOC); + $count = (int)$pdo->query( + "SELECT COUNT(*) FROM " . $table('runs') . " WHERE status IN ('queued','running','cancel_requested')" + )->fetchColumn(); header('Content-Type: application/json; charset=utf-8'); echo json_encode([ 'ok' => true, 'next' => 10, + 'count' => $count, 'html' => $renderRuns($runs), ]); exit; } +if (isset($_GET['queue_action_json'])) { + $runId = (int)($_POST['run_id'] ?? 0); + $action = (string)($_POST['action'] ?? ''); + $error = null; + + if ($runId <= 0 || !in_array($action, ['cancel', 'delete'], true)) { + $error = 'Ungültige Anfrage.'; + } else { + $stmt = $pdo->prepare('SELECT * FROM ' . $table('runs') . ' WHERE id = :id LIMIT 1'); + $stmt->execute(['id' => $runId]); + $run = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$run) { + $error = 'Run nicht gefunden.'; + } else { + $status = (string)($run['status'] ?? ''); + $driver = (string)$pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + $nowExpr = $driver === 'pgsql' ? 'NOW()' : "DATETIME('now')"; + + if ($action === 'cancel') { + if ($status !== 'queued') { + $error = 'Nur queued Runs können gestoppt werden.'; + } else { + $pdo->exec('UPDATE ' . $table('runs') . ' SET status = \'canceled\', finished_at = ' . $nowExpr . ' WHERE id = ' . (int)$runId); + try { + $redis = module_fn('pi_control', 'redis'); + $payload = json_encode(['run_id' => $runId]); + $redis->command(['LREM', $queueName, '0', $payload]); + } catch (\Throwable $e) { + // ignore redis cleanup errors + } + } + } elseif ($action === 'delete') { + if ($status === 'running') { + $error = 'Laufende Runs können nicht gelöscht werden.'; + } else { + $pdo->prepare('DELETE FROM ' . $table('runs') . ' WHERE id = :id')->execute(['id' => $runId]); + try { + $redis = module_fn('pi_control', 'redis'); + $payload = json_encode(['run_id' => $runId]); + $redis->command(['LREM', $queueName, '0', $payload]); + } catch (\Throwable $e) { + // ignore redis cleanup errors + } + } + } + } + } + + header('Content-Type: application/json; charset=utf-8'); + echo json_encode([ + 'ok' => $error === null, + 'error' => $error, + ]); + exit; +} + if (isset($_GET['open_console_json'])) { $hostId = (int)($_POST['terminal_host_id'] ?? 0); $presetId = (int)($_POST['terminal_preset_id'] ?? 0); @@ -309,28 +380,42 @@ $runs = $pdo->query(
Queue -
- Update in 10s - -
+
-
- - - - - - - - - - - - - - - -
IDStatusHostCommandVonOutputTimeout
+
+ +