diff --git a/modules/pi_control/pages/console.php b/modules/pi_control/pages/console.php index 8c8054b..91e5698 100644 --- a/modules/pi_control/pages/console.php +++ b/modules/pi_control/pages/console.php @@ -9,6 +9,7 @@ $terminalNotice = null; $terminalError = null; $terminalUrl = null; $terminalToken = null; +$terminalHostLabel = null; $settings = modules()->settings('pi_control'); $ttydUrl = trim((string)($settings['ttyd_url'] ?? '/ttyd')); @@ -22,18 +23,76 @@ $tokenTtl = $tokenTtl > 0 ? $tokenTtl : 10; $hosts = $pdo->query('SELECT * FROM ' . $table('hosts') . ' ORDER BY name ASC')->fetchAll(PDO::FETCH_ASSOC); $commands = $pdo->query('SELECT * FROM ' . $table('commands') . ' ORDER BY label ASC')->fetchAll(PDO::FETCH_ASSOC); -if ($_SERVER['REQUEST_METHOD'] === 'POST') { - $action = (string)($_POST['action'] ?? ''); +$renderRuns = function (array $runs): string { + ob_start(); + if (!$runs) { + echo 'Noch keine Runs.'; + return ob_get_clean(); + } + foreach ($runs as $r) { + $out = (string)($r['output'] ?? ''); + $err = (string)($r['error'] ?? ''); + $snippet = $out !== '' ? $out : $err; + if (strlen($snippet) > 140) { + $snippet = substr($snippet, 0, 140) . '…'; + } + echo ''; + echo '' . e((string)$r['id']) . ''; + echo '' . e((string)($r['status'] ?? '')) . ''; + echo '' . e((string)($r['host_name'] ?? $r['host_addr'] ?? '')) . ''; + echo '' . e((string)($r['command_text'] ?? '')) . ''; + echo '' . e((string)($r['created_by'] ?? '')) . ''; + echo '' . e($snippet) . ''; + echo '' . (!empty($r['timeout_sec']) ? e((string)$r['timeout_sec']) . 's' : 'default') . ''; + echo ''; + } + return ob_get_clean(); +}; - if ($action === 'open_console') { - $hostId = (int)($_POST['terminal_host_id'] ?? 0); - $provider = 'ttyd'; +if (isset($_GET['queue_json'])) { + $runs = $pdo->query( + 'SELECT r.*, h.name AS host_name, h.host AS host_addr + FROM ' . $table('runs') . ' r + LEFT JOIN ' . $table('hosts') . ' h ON h.id = r.host_id + ORDER BY r.id DESC LIMIT 20' + )->fetchAll(PDO::FETCH_ASSOC); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode([ + 'ok' => true, + 'next' => 10, + 'html' => $renderRuns($runs), + ]); + exit; +} - if ($hostId <= 0) { - $terminalError = 'Bitte einen Host wählen.'; - } elseif ($ttydUrl === '') { - $terminalError = 'ttyd URL fehlt. Bitte im Setup setzen.'; - } else { +if (isset($_GET['open_console_json'])) { + $hostId = (int)($_POST['terminal_host_id'] ?? 0); + $presetId = (int)($_POST['terminal_preset_id'] ?? 0); + $rawCommand = trim((string)($_POST['terminal_command_text'] ?? '')); + $terminalError = null; + $terminalUrl = null; + $terminalToken = null; + $terminalHostLabel = null; + + if ($hostId <= 0) { + $terminalError = 'Bitte einen Host wählen.'; + } elseif ($ttydUrl === '') { + $terminalError = 'ttyd URL fehlt. Bitte im Setup setzen.'; + } else { + $presetCommand = ''; + if ($presetId > 0) { + foreach ($commands as $c) { + if ((int)$c['id'] === $presetId) { + if (!auth_is_admin() && !empty($c['admin_only'])) { + $terminalError = 'Diese Vorlage ist nur für Admins.'; + } else { + $presetCommand = (string)$c['command']; + } + break; + } + } + } + if (!$terminalError) { $driver = (string)$pdo->getAttribute(PDO::ATTR_DRIVER_NAME); $expiresSql = $driver === 'pgsql' ? "NOW() + INTERVAL '{$tokenTtl} minutes'" @@ -48,88 +107,122 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $stmt->execute([ 'token' => $token, 'host_id' => $hostId, - 'provider' => $provider, + 'provider' => 'ttyd', 'created_by' => auth_display_name() ?: null, ]); $sep = str_contains($ttydUrl, '?') ? '&' : '?'; $terminalUrl = rtrim($ttydUrl, '/') . '/' . $sep . 'arg=' . rawurlencode($token); - } - } else { - $hostId = (int)($_POST['host_id'] ?? 0); - $commandId = (int)($_POST['command_id'] ?? 0); - $rawCommand = trim((string)($_POST['command_text'] ?? '')); - $timeoutSec = $defaultTimeout; - - if ($hostId <= 0) { - $error = 'Bitte einen Host wählen.'; - } elseif ($commandId <= 0 && $rawCommand === '') { - $error = 'Bitte einen Befehl wählen oder einen Befehl eingeben.'; - } else { - $selectedCommand = ''; - if ($commandId > 0) { - foreach ($commands as $c) { - if ((int)$c['id'] === $commandId) { - if (!auth_is_admin() && !empty($c['admin_only'])) { - $error = 'Dieser Befehl ist nur für Admins.'; - } else { - $selectedCommand = (string)$c['command']; - if (!empty($c['timeout_sec'])) { - $timeoutSec = (int)$c['timeout_sec']; - } - } - break; - } - } + $commandToRun = $presetCommand !== '' ? $presetCommand : $rawCommand; + if ($commandToRun !== '') { + $terminalUrl .= '&arg=' . rawurlencode(base64_encode($commandToRun)); } - - if (!$error) { - $commandText = $selectedCommand !== '' ? $selectedCommand : $rawCommand; - $driver = (string)$pdo->getAttribute(PDO::ATTR_DRIVER_NAME); - if ($driver === 'pgsql') { - $stmt = $pdo->prepare( - 'INSERT INTO ' . $table('runs') . ' (host_id, command_id, command_text, status, timeout_sec, created_by) - VALUES (:host_id, :command_id, :command_text, :status, :timeout_sec, :created_by) - RETURNING id' - ); - $stmt->execute([ - 'host_id' => $hostId, - 'command_id' => $commandId > 0 ? $commandId : null, - 'command_text' => $commandText, - 'status' => 'queued', - 'timeout_sec' => $timeoutSec, - 'created_by' => auth_display_name() ?: null, - ]); - $runId = (int)$stmt->fetchColumn(); - } else { - $stmt = $pdo->prepare( - 'INSERT INTO ' . $table('runs') . ' (host_id, command_id, command_text, status, timeout_sec, created_by) - VALUES (:host_id, :command_id, :command_text, :status, :timeout_sec, :created_by)' - ); - $stmt->execute([ - 'host_id' => $hostId, - 'command_id' => $commandId > 0 ? $commandId : null, - 'command_text' => $commandText, - 'status' => 'queued', - 'timeout_sec' => $timeoutSec, - 'created_by' => auth_display_name() ?: null, - ]); - $runId = (int)$pdo->lastInsertId(); - } - try { - $redis = module_fn('pi_control', 'redis'); - $payload = json_encode(['run_id' => $runId], JSON_THROW_ON_ERROR); - $redis->command(['RPUSH', $queueName, $payload]); - $notice = 'Befehl wurde in die Queue gestellt.'; - } catch (\Throwable $e) { - $pdo->exec('UPDATE ' . $table('runs') . ' SET status = \'queue_error\' WHERE id = ' . (int)$runId); - $notice = 'Befehl gespeichert, aber Queue nicht erreichbar.'; + foreach ($hosts as $h) { + if ((int)$h['id'] === $hostId) { + $terminalHostLabel = (string)($h['name'] ?? $h['host']); + break; } } } } + + header('Content-Type: application/json; charset=utf-8'); + echo json_encode([ + 'ok' => $terminalError === null, + 'error' => $terminalError, + 'url' => $terminalUrl, + 'token' => $terminalToken, + 'host' => $terminalHostLabel, + ]); + exit; } +if (isset($_GET['run_command_json'])) { + $hostId = (int)($_POST['terminal_host_id'] ?? 0); + $commandId = (int)($_POST['terminal_preset_id'] ?? 0); + $rawCommand = trim((string)($_POST['terminal_command_text'] ?? '')); + $timeoutSec = $defaultTimeout; + $error = null; + $notice = null; + + if ($hostId <= 0) { + $error = 'Bitte einen Host wählen.'; + } elseif ($commandId <= 0 && $rawCommand === '') { + $error = 'Bitte einen Befehl wählen oder einen Befehl eingeben.'; + } else { + $selectedCommand = ''; + if ($commandId > 0) { + foreach ($commands as $c) { + if ((int)$c['id'] === $commandId) { + if (!auth_is_admin() && !empty($c['admin_only'])) { + $error = 'Dieser Befehl ist nur für Admins.'; + } else { + $selectedCommand = (string)$c['command']; + if (!empty($c['timeout_sec'])) { + $timeoutSec = (int)$c['timeout_sec']; + } + } + break; + } + } + } + + if (!$error) { + $commandText = $selectedCommand !== '' ? $selectedCommand : $rawCommand; + $driver = (string)$pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + if ($driver === 'pgsql') { + $stmt = $pdo->prepare( + 'INSERT INTO ' . $table('runs') . ' (host_id, command_id, command_text, status, timeout_sec, created_by) + VALUES (:host_id, :command_id, :command_text, :status, :timeout_sec, :created_by) + RETURNING id' + ); + $stmt->execute([ + 'host_id' => $hostId, + 'command_id' => $commandId > 0 ? $commandId : null, + 'command_text' => $commandText, + 'status' => 'queued', + 'timeout_sec' => $timeoutSec, + 'created_by' => auth_display_name() ?: null, + ]); + $runId = (int)$stmt->fetchColumn(); + } else { + $stmt = $pdo->prepare( + 'INSERT INTO ' . $table('runs') . ' (host_id, command_id, command_text, status, timeout_sec, created_by) + VALUES (:host_id, :command_id, :command_text, :status, :timeout_sec, :created_by)' + ); + $stmt->execute([ + 'host_id' => $hostId, + 'command_id' => $commandId > 0 ? $commandId : null, + 'command_text' => $commandText, + 'status' => 'queued', + 'timeout_sec' => $timeoutSec, + 'created_by' => auth_display_name() ?: null, + ]); + $runId = (int)$pdo->lastInsertId(); + } + try { + $redis = module_fn('pi_control', 'redis'); + $payload = json_encode(['run_id' => $runId], JSON_THROW_ON_ERROR); + $redis->command(['RPUSH', $queueName, $payload]); + $notice = 'Befehl wurde in die Queue gestellt.'; + } catch (\Throwable $e) { + $pdo->exec('UPDATE ' . $table('runs') . ' SET status = \'queue_error\' WHERE id = ' . (int)$runId); + $notice = 'Befehl gespeichert, aber Queue nicht erreichbar.'; + } + } + } + + header('Content-Type: application/json; charset=utf-8'); + echo json_encode([ + 'ok' => $error === null, + 'error' => $error, + 'notice' => $notice, + ]); + exit; +} + +// Form submits are handled via AJAX to avoid reloads. + $runs = $pdo->query( 'SELECT r.*, h.name AS host_name, h.host AS host_addr FROM ' . $table('runs') . ' r @@ -155,17 +248,9 @@ $runs = $pdo->query(
Live-Konsole - -
- -
- -
- -
- -
- + + + - -
- - -
-
- Aktive Konsole - In neuem Tab öffnen -
- -
- Token: -
- -
- -
-
- -
- -
- Befehl ausführen -
- - - - +
+ + +
-

Befehle werden über die Queue ausgeführt.

+
+ Token: +
+ + +
+
- Letzte Runs + Konsole Tabs +
+
+
+
+

Mehrere Konsolen bleiben hier parallel offen.

+
+ +
+
+ Queue +
+ Update in 10s + +
+
@@ -247,29 +321,8 @@ $runs = $pdo->query( - - - - - - 140) { - $snippet = substr($snippet, 0, 140) . '…'; - } - ?> - - - - - - - - - - + +
Timeout
Noch keine Runs.
diff --git a/public/assets/css/app.css b/public/assets/css/app.css index adeeb4e..90c5ccb 100644 --- a/public/assets/css/app.css +++ b/public/assets/css/app.css @@ -154,6 +154,74 @@ body { .form-field select, .form-field textarea { width: 100%; } +.icon-button { + border: 1px solid var(--line); + background: var(--panel-2); + border-radius: 999px; + padding: 4px 10px; + cursor: pointer; + font-weight: 700; +} +.icon-button:hover { background: var(--panel); } + +.console-tabs { + border: 1px solid var(--line); + border-radius: 14px; + overflow: hidden; + background: #0b0f17; +} +.console-tab-bar { + display: flex; + gap: 6px; + flex-wrap: wrap; + padding: 8px; + background: #0f1624; + border-bottom: 1px solid rgba(255,255,255,0.08); +} +.console-tab { + background: transparent; + color: #c9d3e3; + border: 1px solid transparent; + border-radius: 10px; + padding: 6px 10px; + cursor: pointer; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 8px; +} +.console-tab.is-active { + border-color: rgba(255,255,255,0.2); + background: rgba(255,255,255,0.08); + color: #ffffff; +} +.console-tab-panels { + min-height: 420px; +} +.console-tab-close { + display: inline-flex; + width: 18px; + height: 18px; + align-items: center; + justify-content: center; + border-radius: 999px; + background: rgba(255,255,255,0.12); + color: #ffffff; + font-size: 0.8rem; + line-height: 1; +} +.console-tab-close:hover { + background: rgba(255,255,255,0.25); +} +.console-panel { display: none; } +.console-panel.is-active { display: block; } +.console-panel iframe { + width: 100%; + height: 520px; + border: 0; + background: #0b0f17; +} + .layout-body { display: grid; grid-template-columns: 1fr; diff --git a/public/assets/js/app.js b/public/assets/js/app.js index 13562d0..10f2134 100755 --- a/public/assets/js/app.js +++ b/public/assets/js/app.js @@ -125,3 +125,202 @@ startRefresh(); } })(); + +(() => { + const tabBar = document.querySelector('[data-console-tab-bar]'); + const tabPanels = document.querySelector('[data-console-tab-panels]'); + if (!tabBar || !tabPanels) return; + + let tabCount = 0; + const idleMs = 5 * 60 * 1000; + const idleTimers = new Map(); + + const activateTab = (id) => { + tabBar.querySelectorAll('.console-tab').forEach((btn) => { + btn.classList.toggle('is-active', btn.dataset.tabId === id); + }); + tabPanels.querySelectorAll('.console-panel').forEach((panel) => { + panel.classList.toggle('is-active', panel.dataset.tabId === id); + }); + }; + + const openTab = (label, url) => { + const id = `tab-${++tabCount}`; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'console-tab'; + btn.textContent = label || 'Konsole'; + btn.dataset.tabId = id; + btn.addEventListener('click', () => activateTab(id)); + + const closeBtn = document.createElement('span'); + closeBtn.className = 'console-tab-close'; + closeBtn.textContent = '×'; + closeBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const panel = tabPanels.querySelector(`.console-panel[data-tab-id="${id}"]`); + if (panel) panel.remove(); + btn.remove(); + idleTimers.delete(id); + const next = tabBar.querySelector('.console-tab'); + if (next) activateTab(next.dataset.tabId); + }); + btn.appendChild(closeBtn); + + const panel = document.createElement('div'); + panel.className = 'console-panel'; + panel.dataset.tabId = id; + panel.innerHTML = ``; + + tabBar.appendChild(btn); + tabPanels.appendChild(panel); + activateTab(id); + + const iframe = panel.querySelector('iframe'); + const markActive = () => { + idleTimers.set(id, Date.now()); + }; + idleTimers.set(id, Date.now()); + + iframe.addEventListener('load', () => { + try { + const doc = iframe.contentWindow.document; + ['keydown', 'mousedown', 'wheel', 'touchstart'].forEach((evt) => { + doc.addEventListener(evt, markActive, { passive: true }); + }); + const observer = new MutationObserver(markActive); + observer.observe(doc.body, { childList: true, subtree: true, characterData: true }); + } catch (e) { + // cross-origin or blocked; rely on timer only + } + }); + + const idleCheck = setInterval(() => { + const last = idleTimers.get(id) || 0; + if (Date.now() - last > idleMs) { + const panelEl = tabPanels.querySelector(`.console-panel[data-tab-id="${id}"]`); + const btnEl = tabBar.querySelector(`.console-tab[data-tab-id="${id}"]`); + if (panelEl) panelEl.remove(); + if (btnEl) btnEl.remove(); + idleTimers.delete(id); + clearInterval(idleCheck); + const next = tabBar.querySelector('.console-tab'); + if (next) activateTab(next.dataset.tabId); + } + }, 10000); + }; + + document.querySelectorAll('.console-launch').forEach((el) => { + const url = el.dataset.url; + const host = el.dataset.host || 'Konsole'; + if (url) { + openTab(host, url); + } + el.remove(); + }); + + const queueBody = document.querySelector('[data-queue-body]'); + const countdownEl = document.querySelector('[data-queue-countdown]'); + const refreshBtn = document.querySelector('[data-queue-refresh]'); + if (!queueBody || !countdownEl) return; + + let remaining = 10; + let timer = null; + + const fetchQueue = async () => { + const url = new URL(window.location.href); + url.searchParams.set('queue_json', '1'); + try { + const res = await fetch(url.toString(), { cache: 'no-store' }); + if (!res.ok) return; + const data = await res.json(); + if (data && data.html) { + queueBody.innerHTML = data.html; + } + remaining = data && data.next ? data.next : 10; + } catch (e) { + // ignore + } + }; + + const tick = () => { + remaining -= 1; + if (remaining <= 0) { + fetchQueue(); + remaining = 10; + } + countdownEl.textContent = String(remaining); + }; + + timer = setInterval(tick, 1000); + if (refreshBtn) { + refreshBtn.addEventListener('click', () => { + 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]'); + const tokenEl = document.querySelector('[data-console-token]'); + if (consoleForm) { + const submitOpen = async () => { + const formData = new FormData(consoleForm); + const url = new URL(window.location.href); + url.searchParams.set('open_console_json', '1'); + const res = await fetch(url.toString(), { method: 'POST', body: formData, cache: 'no-store' }); + const data = await res.json(); + if (!data.ok) { + if (consoleError) { + consoleError.textContent = data.error || 'Fehler beim Öffnen der Konsole.'; + consoleError.style.display = 'block'; + } + return; + } + if (consoleError) consoleError.style.display = 'none'; + if (consoleNotice) consoleNotice.style.display = 'none'; + if (tokenEl) tokenEl.textContent = data.token || ''; + if (data.url) openTab(data.host || 'Konsole', data.url); + }; + + const submitRun = async () => { + const formData = new FormData(consoleForm); + const url = new URL(window.location.href); + url.searchParams.set('run_command_json', '1'); + const res = await fetch(url.toString(), { method: 'POST', body: formData, cache: 'no-store' }); + const data = await res.json(); + if (!data.ok) { + if (consoleError) { + consoleError.textContent = data.error || 'Fehler beim Ausführen.'; + consoleError.style.display = 'block'; + } + return; + } + if (consoleError) consoleError.style.display = 'none'; + if (consoleNotice) { + consoleNotice.textContent = data.notice || ''; + consoleNotice.style.display = 'block'; + } + fetchQueue(); + remaining = 10; + countdownEl.textContent = String(remaining); + }; + + const openBtn = consoleForm.querySelector('[data-open-console]'); + const runBtn = consoleForm.querySelector('[data-run-command]'); + if (openBtn) { + openBtn.addEventListener('click', (e) => { + e.preventDefault(); + submitOpen(); + }); + } + if (runBtn) { + runBtn.addEventListener('click', (e) => { + e.preventDefault(); + submitRun(); + }); + } + } +})(); diff --git a/tools/pi_control/terminal_entry.sh b/tools/pi_control/terminal_entry.sh index 15b615c..ac36984 100644 --- a/tools/pi_control/terminal_entry.sh +++ b/tools/pi_control/terminal_entry.sh @@ -2,6 +2,7 @@ set -euo pipefail TOKEN="${1:-}" +ENC_COMMAND="${2:-}" if [[ -z "${TOKEN}" ]]; then echo "Missing token." exit 1 @@ -34,6 +35,11 @@ AUTH_TYPE="$(echo "${JSON}" | jq -r '.host.auth_type')" KEY_PATH="$(echo "${JSON}" | jq -r '.host.key_path')" PASSWORD="$(echo "${JSON}" | jq -r '.host.password')" +COMMAND="" +if [[ -n "${ENC_COMMAND}" ]]; then + COMMAND="$(printf '%s' "${ENC_COMMAND}" | base64 -d 2>/dev/null || true)" +fi + if [[ -z "${HOST}" || -z "${USER}" ]]; then echo "Host data incomplete." exit 1 @@ -46,10 +52,24 @@ else SSH_OPTS=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null) fi -if [[ "${AUTH_TYPE}" == "key" && -n "${KEY_PATH}" ]]; then - exec ssh "${SSH_OPTS[@]}" -i "${KEY_PATH}" -p "${PORT:-22}" "${USER}@${HOST}" -elif [[ "${AUTH_TYPE}" == "pass" && -n "${PASSWORD}" ]]; then - exec sshpass -p "${PASSWORD}" ssh "${SSH_OPTS[@]}" -p "${PORT:-22}" "${USER}@${HOST}" +SSH_TARGET="${USER}@${HOST}" +if [[ -n "${COMMAND}" ]]; then + REMOTE_CMD="${COMMAND}; exec \$SHELL" + REMOTE_CMD="${REMOTE_CMD//\\/\\\\}" + REMOTE_CMD="${REMOTE_CMD//\"/\\\"}" + if [[ "${AUTH_TYPE}" == "key" && -n "${KEY_PATH}" ]]; then + exec ssh "${SSH_OPTS[@]}" -i "${KEY_PATH}" -p "${PORT:-22}" -t "${SSH_TARGET}" -- sh -lc "${REMOTE_CMD}" + elif [[ "${AUTH_TYPE}" == "pass" && -n "${PASSWORD}" ]]; then + exec sshpass -p "${PASSWORD}" ssh "${SSH_OPTS[@]}" -p "${PORT:-22}" -t "${SSH_TARGET}" -- sh -lc "${REMOTE_CMD}" + else + exec ssh "${SSH_OPTS[@]}" -p "${PORT:-22}" -t "${SSH_TARGET}" -- sh -lc "${REMOTE_CMD}" + fi else - exec ssh "${SSH_OPTS[@]}" -p "${PORT:-22}" "${USER}@${HOST}" + if [[ "${AUTH_TYPE}" == "key" && -n "${KEY_PATH}" ]]; then + exec ssh "${SSH_OPTS[@]}" -i "${KEY_PATH}" -p "${PORT:-22}" "${SSH_TARGET}" + elif [[ "${AUTH_TYPE}" == "pass" && -n "${PASSWORD}" ]]; then + exec sshpass -p "${PASSWORD}" ssh "${SSH_OPTS[@]}" -p "${PORT:-22}" "${SSH_TARGET}" + else + exec ssh "${SSH_OPTS[@]}" -p "${PORT:-22}" "${SSH_TARGET}" + fi fi