+
+
Queue
+
+ Update in 10s
+
+
+
@@ -247,29 +321,8 @@ $runs = $pdo->query(
| Timeout |
-
-
- | Noch keine Runs. |
-
-
- 140) {
- $snippet = substr($snippet, 0, 140) . '…';
- }
- ?>
-
- | = e((string)$r['id']) ?> |
- = e($r['status'] ?? '') ?> |
- = e($r['host_name'] ?? $r['host_addr'] ?? '') ?> |
- = e($r['command_text'] ?? '') ?> |
- = e($r['created_by'] ?? '') ?> |
- = e($snippet) ?> |
- = !empty($r['timeout_sec']) ? e((string)$r['timeout_sec']) . 's' : 'default' ?> |
-
-
+
+ = $renderRuns($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