diff --git a/modules/pi_control/pages/console.php b/modules/pi_control/pages/console.php index d01c286..0971ae1 100644 --- a/modules/pi_control/pages/console.php +++ b/modules/pi_control/pages/console.php @@ -12,6 +12,10 @@ $terminalToken = null; $terminalHostLabel = null; $settings = modules()->settings('pi_control'); +$assets = app()->assets(); +if ($assets) { + $assets->addScript('/assets/js/pi_control_console.js', 'footer', true); +} $ttydUrl = trim((string)($settings['ttyd_url'] ?? '/ttyd')); $defaultProvider = 'ttyd'; $defaultTimeout = (int)($settings['exec_default_timeout'] ?? (getenv('PI_CONTROL_EXEC_DEFAULT_TIMEOUT') !== false ? (int)getenv('PI_CONTROL_EXEC_DEFAULT_TIMEOUT') : 300)); diff --git a/public/assets/js/app.js b/public/assets/js/app.js index 8bbf478..13562d0 100755 --- a/public/assets/js/app.js +++ b/public/assets/js/app.js @@ -125,313 +125,3 @@ 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 storageKey = 'pi_control_console_tabs'; - - const saveTabs = () => { - const tabs = []; - tabBar.querySelectorAll('.console-tab').forEach((btn) => { - const id = btn.dataset.tabId; - const panel = tabPanels.querySelector(`.console-panel[data-tab-id="${id}"]`); - const iframe = panel ? panel.querySelector('iframe') : null; - if (iframe && iframe.src) { - tabs.push({ - label: btn.firstChild ? btn.firstChild.textContent : btn.textContent, - url: iframe.src, - openedAt: Date.now(), - }); - } - }); - localStorage.setItem(storageKey, JSON.stringify(tabs)); - }; - - 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 getActiveIframe = () => { - const activePanel = tabPanels.querySelector('.console-panel.is-active'); - if (!activePanel) return null; - return activePanel.querySelector('iframe'); - }; - - const trySendToIframe = (iframe, command) => { - if (!iframe) return false; - try { - const win = iframe.contentWindow; - if (!win) return false; - const term = win.term || win.xterm || win.terminal; - if (term && typeof term.write === 'function') { - term.write(command + '\r'); - return true; - } - } catch (e) { - return false; - } - return false; - }; - - const openTab = (label, url, persist = true) => { - 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); - saveTabs(); - 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); - if (persist) saveTabs(); - - 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); - saveTabs(); - 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 showRestoreNotice = (text) => { - const notice = document.querySelector('[data-console-notice]'); - if (!notice) return; - notice.textContent = text; - notice.style.display = 'block'; - }; - - // Restore tabs from previous session (best-effort) - try { - const raw = localStorage.getItem(storageKey); - if (raw) { - const tabs = JSON.parse(raw); - if (Array.isArray(tabs)) { - const now = Date.now(); - tabs.forEach((t) => { - if (t && t.url) { - if (t.openedAt && now - t.openedAt > (10 * 60 * 1000)) { - showRestoreNotice('Token abgelaufen, bitte Konsole neu öffnen.'); - return; - } - openTab(t.label || 'Konsole', t.url, false); - } - }); - } - } - } catch (e) { - // ignore - } - - 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) { - consoleForm.addEventListener('submit', (e) => e.preventDefault()); - const presetSelect = consoleForm.querySelector('select[name="terminal_preset_id"]'); - const commandTextarea = consoleForm.querySelector('textarea[name="terminal_command_text"]'); - if (presetSelect && commandTextarea) { - presetSelect.addEventListener('change', () => { - const opt = presetSelect.selectedOptions[0]; - const cmd = opt && opt.dataset && opt.dataset.command ? opt.dataset.command : ''; - if (cmd) { - commandTextarea.value = cmd; - } - }); - } - - const submitOpen = async () => { - const formData = new FormData(consoleForm); - if (presetSelect) formData.set('terminal_preset_id', ''); - 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); - if (presetSelect) formData.set('terminal_preset_id', ''); - 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]'); - const sendBtn = consoleForm.querySelector('[data-send-active]'); - if (openBtn) { - openBtn.addEventListener('click', (e) => { - e.preventDefault(); - submitOpen(); - }); - } - if (runBtn) { - runBtn.addEventListener('click', (e) => { - e.preventDefault(); - submitRun(); - }); - } - if (sendBtn) { - sendBtn.addEventListener('click', (e) => { - e.preventDefault(); - if (!commandTextarea || !commandTextarea.value.trim()) { - if (consoleError) { - consoleError.textContent = 'Bitte zuerst einen Befehl eingeben.'; - consoleError.style.display = 'block'; - } - return; - } - const iframe = getActiveIframe(); - const ok = trySendToIframe(iframe, commandTextarea.value.trim()); - if (!ok) { - if (consoleNotice) { - consoleNotice.textContent = 'Aktive Konsole nicht steuerbar – Befehl wird in neuer Konsole ausgeführt.'; - consoleNotice.style.display = 'block'; - } - submitOpen(); - return; - } - if (consoleError) consoleError.style.display = 'none'; - }); - } - } -})(); diff --git a/public/assets/js/pi_control_console.js b/public/assets/js/pi_control_console.js new file mode 100644 index 0000000..9388c5a --- /dev/null +++ b/public/assets/js/pi_control_console.js @@ -0,0 +1,308 @@ +(() => { + 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 storageKey = 'pi_control_console_tabs'; + + const saveTabs = () => { + const tabs = []; + tabBar.querySelectorAll('.console-tab').forEach((btn) => { + const id = btn.dataset.tabId; + const panel = tabPanels.querySelector(`.console-panel[data-tab-id="${id}"]`); + const iframe = panel ? panel.querySelector('iframe') : null; + if (iframe && iframe.src) { + tabs.push({ + label: btn.firstChild ? btn.firstChild.textContent : btn.textContent, + url: iframe.src, + openedAt: Date.now(), + }); + } + }); + localStorage.setItem(storageKey, JSON.stringify(tabs)); + }; + + 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 getActiveIframe = () => { + const activePanel = tabPanels.querySelector('.console-panel.is-active'); + if (!activePanel) return null; + return activePanel.querySelector('iframe'); + }; + + const trySendToIframe = (iframe, command) => { + if (!iframe) return false; + try { + const win = iframe.contentWindow; + if (!win) return false; + const term = win.term || win.xterm || win.terminal; + if (term && typeof term.write === 'function') { + term.write(command + '\r'); + return true; + } + } catch (e) { + return false; + } + return false; + }; + + const openTab = (label, url, persist = true) => { + 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); + saveTabs(); + 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); + if (persist) saveTabs(); + + 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); + saveTabs(); + 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 showRestoreNotice = (text) => { + const notice = document.querySelector('[data-console-notice]'); + if (!notice) return; + notice.textContent = text; + notice.style.display = 'block'; + }; + + try { + const raw = localStorage.getItem(storageKey); + if (raw) { + const tabs = JSON.parse(raw); + if (Array.isArray(tabs)) { + const now = Date.now(); + tabs.forEach((t) => { + if (t && t.url) { + if (t.openedAt && now - t.openedAt > (10 * 60 * 1000)) { + showRestoreNotice('Token abgelaufen, bitte Konsole neu öffnen.'); + return; + } + openTab(t.label || 'Konsole', t.url, false); + } + }); + } + } + } catch (e) { + // ignore + } + + 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; + + 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); + }; + + 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) { + consoleForm.addEventListener('submit', (e) => e.preventDefault()); + + const presetSelect = consoleForm.querySelector('select[name="terminal_preset_id"]'); + const commandTextarea = consoleForm.querySelector('textarea[name="terminal_command_text"]'); + if (presetSelect && commandTextarea) { + presetSelect.addEventListener('change', () => { + const opt = presetSelect.selectedOptions[0]; + const cmd = opt && opt.dataset && opt.dataset.command ? opt.dataset.command : ''; + if (cmd) { + commandTextarea.value = cmd; + } + }); + } + + const submitOpen = async () => { + const formData = new FormData(consoleForm); + if (presetSelect) formData.set('terminal_preset_id', ''); + 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); + if (presetSelect) formData.set('terminal_preset_id', ''); + 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]'); + const sendBtn = consoleForm.querySelector('[data-send-active]'); + if (openBtn) { + openBtn.addEventListener('click', (e) => { + e.preventDefault(); + submitOpen(); + }); + } + if (runBtn) { + runBtn.addEventListener('click', (e) => { + e.preventDefault(); + submitRun(); + }); + } + if (sendBtn) { + sendBtn.addEventListener('click', (e) => { + e.preventDefault(); + if (!commandTextarea || !commandTextarea.value.trim()) { + if (consoleError) { + consoleError.textContent = 'Bitte zuerst einen Befehl eingeben.'; + consoleError.style.display = 'block'; + } + return; + } + const iframe = getActiveIframe(); + const ok = trySendToIframe(iframe, commandTextarea.value.trim()); + if (!ok) { + if (consoleNotice) { + consoleNotice.textContent = 'Aktive Konsole nicht steuerbar – Befehl wird in neuer Konsole ausgeführt.'; + consoleNotice.style.display = 'block'; + } + submitOpen(); + return; + } + if (consoleError) consoleError.style.display = 'none'; + }); + } + } +})();