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';
+ });
+ }
+ }
+})();