diff --git a/modules/pi_control/assets/commands.js b/modules/pi_control/assets/commands.js new file mode 100644 index 0000000..48c5cb0 --- /dev/null +++ b/modules/pi_control/assets/commands.js @@ -0,0 +1,97 @@ +(() => { + const form = document.querySelector('[data-command-form]'); + const list = document.querySelector('[data-command-list]'); + if (!form) return; + + const idInput = form.querySelector('input[name="id"]'); + const labelInput = form.querySelector('input[name="label"]'); + const commandInput = form.querySelector('textarea[name="command"]'); + const timeoutInput = form.querySelector('input[name="timeout_sec"]'); + const adminInput = form.querySelector('input[name="admin_only"]'); + const submitBtn = form.querySelector('[data-command-submit]'); + const cancelBtn = form.querySelector('[data-command-cancel]'); + + const resetForm = () => { + if (idInput) idInput.value = ''; + if (labelInput) labelInput.value = ''; + if (commandInput) commandInput.value = ''; + if (timeoutInput) timeoutInput.value = ''; + if (adminInput) adminInput.checked = false; + if (submitBtn) submitBtn.textContent = 'Speichern'; + }; + + document.querySelectorAll('[data-command-edit]').forEach((btn) => { + btn.addEventListener('click', () => { + const item = btn.closest('.command-item'); + if (!item) return; + if (idInput) idInput.value = item.dataset.commandId || ''; + if (labelInput) labelInput.value = item.dataset.label || ''; + if (commandInput) commandInput.value = item.dataset.command || ''; + if (timeoutInput) timeoutInput.value = item.dataset.timeout || ''; + if (adminInput) adminInput.checked = item.dataset.admin === '1'; + if (submitBtn) submitBtn.textContent = 'Aktualisieren'; + const details = btn.closest('details'); + if (details) details.removeAttribute('open'); + form.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }); + }); + + if (cancelBtn) { + cancelBtn.addEventListener('click', (e) => { + e.preventDefault(); + resetForm(); + }); + } + + if (!list) return; + + let dragging = null; + + list.querySelectorAll('.command-item').forEach((item) => { + item.addEventListener('dragstart', () => { + dragging = item; + item.classList.add('is-dragging'); + }); + item.addEventListener('dragend', () => { + item.classList.remove('is-dragging'); + dragging = null; + saveOrder(); + }); + }); + + list.addEventListener('dragover', (e) => { + e.preventDefault(); + if (!dragging) return; + const after = getDragAfterElement(list, e.clientY); + if (after == null) { + list.appendChild(dragging); + } else if (after !== dragging) { + list.insertBefore(dragging, after); + } + }); + + const getDragAfterElement = (container, y) => { + const elements = [...container.querySelectorAll('.command-item:not(.is-dragging)')]; + return elements.reduce( + (closest, child) => { + const box = child.getBoundingClientRect(); + const offset = y - box.top - box.height / 2; + if (offset < 0 && offset > closest.offset) { + return { offset, element: child }; + } + return closest; + }, + { offset: Number.NEGATIVE_INFINITY, element: null } + ).element; + }; + + const saveOrder = () => { + const order = [...list.querySelectorAll('.command-item')].map((el) => el.dataset.commandId); + fetch(window.location.pathname + '?reorder_json=1', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ order }), + cache: 'no-store', + }).catch(() => {}); + }; +})(); diff --git a/modules/pi_control/assets/hosts.js b/modules/pi_control/assets/hosts.js new file mode 100644 index 0000000..6f37319 --- /dev/null +++ b/modules/pi_control/assets/hosts.js @@ -0,0 +1,56 @@ +(() => { + const form = document.querySelector('[data-host-form]'); + if (!form) return; + + const idInput = form.querySelector('input[name="id"]'); + const nameInput = form.querySelector('input[name="name"]'); + const hostInput = form.querySelector('input[name="host"]'); + const portInput = form.querySelector('input[name="port"]'); + const userInput = form.querySelector('input[name="username"]'); + const authSelect = form.querySelector('select[name="auth_type"]'); + const keyInput = form.querySelector('input[name="key_path"]'); + const passInput = form.querySelector('input[name="password"]'); + const imageInput = form.querySelector('input[name="image_url"]'); + const submitBtn = form.querySelector('[data-host-submit]'); + const cancelBtn = form.querySelector('[data-host-cancel]'); + + const resetForm = () => { + if (idInput) idInput.value = ''; + if (nameInput) nameInput.value = ''; + if (hostInput) hostInput.value = ''; + if (portInput) portInput.value = '22'; + if (userInput) userInput.value = ''; + if (authSelect) authSelect.value = 'key'; + if (keyInput) keyInput.value = ''; + if (passInput) passInput.value = ''; + if (imageInput) imageInput.value = ''; + if (submitBtn) submitBtn.textContent = 'Speichern'; + }; + + document.querySelectorAll('[data-host-edit]').forEach((btn) => { + btn.addEventListener('click', () => { + const card = btn.closest('.host-card'); + if (!card) return; + if (idInput) idInput.value = card.dataset.hostId || ''; + if (nameInput) nameInput.value = card.dataset.name || ''; + if (hostInput) hostInput.value = card.dataset.host || ''; + if (portInput) portInput.value = card.dataset.port || '22'; + if (userInput) userInput.value = card.dataset.username || ''; + if (authSelect) authSelect.value = card.dataset.auth || 'key'; + if (keyInput) keyInput.value = card.dataset.keyPath || ''; + if (passInput) passInput.value = ''; + if (imageInput) imageInput.value = card.dataset.imageUrl || ''; + if (submitBtn) submitBtn.textContent = 'Aktualisieren'; + const details = btn.closest('details'); + if (details) details.removeAttribute('open'); + form.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }); + }); + + if (cancelBtn) { + cancelBtn.addEventListener('click', (e) => { + e.preventDefault(); + resetForm(); + }); + } +})(); diff --git a/modules/pi_control/assets/pi_control.css b/modules/pi_control/assets/pi_control.css index db34cd5..85169c8 100644 --- a/modules/pi_control/assets/pi_control.css +++ b/modules/pi_control/assets/pi_control.css @@ -73,6 +73,119 @@ background: #0b0f17; } +.host-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 16px; +} +.host-card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 16px; + overflow: hidden; + display: grid; + grid-template-rows: 120px 1fr; + box-shadow: var(--shadow); +} +.host-card-image { + background: linear-gradient(135deg, #2b3a67 0%, #3b2f5c 45%, #1c2b3f 100%); + background-size: cover; + background-position: center; + position: relative; +} +.host-card-overlay { + position: absolute; + inset: 0; + background: rgba(10, 16, 28, 0.35); +} +.host-card-body { + padding: 12px 14px 14px; + display: grid; + gap: 6px; +} +.host-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.host-card-title { + display: inline-flex; + align-items: center; + gap: 8px; +} +.status-dot { + width: 10px; + height: 10px; + border-radius: 999px; + display: inline-block; +} +.status-ok { background: #31c48d; } +.status-auth { background: #fbbf24; } +.status-down { background: #ef4444; } + +.action-menu { + position: relative; +} +.action-menu summary { + list-style: none; + cursor: pointer; + border-radius: 10px; + padding: 2px 6px; + border: 1px solid var(--line); + background: var(--panel-2); +} +.action-menu summary::-webkit-details-marker { display: none; } +.action-menu[open] summary { + background: var(--panel); +} +.action-menu-panel { + position: absolute; + right: 0; + top: calc(100% + 6px); + background: var(--panel); + border: 1px solid var(--line); + border-radius: 12px; + padding: 6px; + min-width: 160px; + display: grid; + gap: 4px; + z-index: 5; + box-shadow: var(--shadow); +} +.action-menu-panel form { margin: 0; } + +.command-list { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 10px; +} +.command-item { + display: grid; + grid-template-columns: 28px 1fr auto; + gap: 10px; + align-items: start; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel); +} +.command-item.is-dragging { + opacity: 0.6; +} +.command-drag { + cursor: grab; + color: var(--muted); + font-size: 1.1rem; + padding-top: 4px; +} +.command-body code { + display: inline-block; + word-break: break-word; +} + .queue-button { display: inline-flex; align-items: center; diff --git a/modules/pi_control/bootstrap.php b/modules/pi_control/bootstrap.php index 1a4c698..1bafcdd 100644 --- a/modules/pi_control/bootstrap.php +++ b/modules/pi_control/bootstrap.php @@ -64,6 +64,7 @@ $mm->registerFunction($moduleName, 'ensure_schema', function () use ($moduleName auth_type VARCHAR(20) NOT NULL DEFAULT 'key', key_path TEXT NULL, password TEXT NULL, + image_url TEXT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP )"); $pdo->exec("CREATE TABLE IF NOT EXISTS {$cmdTable} ( @@ -72,6 +73,7 @@ $mm->registerFunction($moduleName, 'ensure_schema', function () use ($moduleName command TEXT NOT NULL, admin_only BOOLEAN NOT NULL DEFAULT false, timeout_sec INTEGER NULL, + sort_order INTEGER NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP )"); $pdo->exec("CREATE TABLE IF NOT EXISTS {$runTable} ( @@ -110,6 +112,7 @@ $mm->registerFunction($moduleName, 'ensure_schema', function () use ($moduleName auth_type VARCHAR(20) NOT NULL DEFAULT 'key', key_path TEXT NULL, password TEXT NULL, + image_url TEXT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )"); $pdo->exec("CREATE TABLE IF NOT EXISTS {$cmdTable} ( @@ -118,6 +121,7 @@ $mm->registerFunction($moduleName, 'ensure_schema', function () use ($moduleName command TEXT NOT NULL, admin_only INTEGER NOT NULL DEFAULT 0, timeout_sec INTEGER NULL, + sort_order INTEGER NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )"); $pdo->exec("CREATE TABLE IF NOT EXISTS {$runTable} ( @@ -150,7 +154,9 @@ $mm->registerFunction($moduleName, 'ensure_schema', function () use ($moduleName // Schema migrations for existing tables if ($driver === 'pgsql') { + $pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN IF NOT EXISTS image_url TEXT NULL"); $pdo->exec("ALTER TABLE {$cmdTable} ADD COLUMN IF NOT EXISTS timeout_sec INTEGER NULL"); + $pdo->exec("ALTER TABLE {$cmdTable} ADD COLUMN IF NOT EXISTS sort_order INTEGER NULL"); $pdo->exec("ALTER TABLE {$runTable} ADD COLUMN IF NOT EXISTS error TEXT NULL"); $pdo->exec("ALTER TABLE {$runTable} ADD COLUMN IF NOT EXISTS exit_code INTEGER NULL"); $pdo->exec("ALTER TABLE {$runTable} ADD COLUMN IF NOT EXISTS timeout_sec INTEGER NULL"); @@ -158,6 +164,15 @@ $mm->registerFunction($moduleName, 'ensure_schema', function () use ($moduleName $pdo->exec("ALTER TABLE {$runTable} ADD COLUMN IF NOT EXISTS finished_at TIMESTAMP NULL"); $pdo->exec("ALTER TABLE {$sessionTable} ADD COLUMN IF NOT EXISTS command_text TEXT NULL"); } else { + $columns = []; + $stmt = $pdo->query('PRAGMA table_info(' . $hostTable . ')'); + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $col) { + $columns[$col['name']] = true; + } + if (empty($columns['image_url'])) { + $pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN image_url TEXT NULL"); + } + $columns = []; $stmt = $pdo->query('PRAGMA table_info(' . $cmdTable . ')'); foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $col) { @@ -166,6 +181,9 @@ $mm->registerFunction($moduleName, 'ensure_schema', function () use ($moduleName if (empty($columns['timeout_sec'])) { $pdo->exec("ALTER TABLE {$cmdTable} ADD COLUMN timeout_sec INTEGER NULL"); } + if (empty($columns['sort_order'])) { + $pdo->exec("ALTER TABLE {$cmdTable} ADD COLUMN sort_order INTEGER NULL"); + } $columns = []; $stmt = $pdo->query('PRAGMA table_info(' . $runTable . ')'); diff --git a/modules/pi_control/module.json b/modules/pi_control/module.json index 419ddd6..631dab8 100644 --- a/modules/pi_control/module.json +++ b/modules/pi_control/module.json @@ -4,10 +4,14 @@ "description": "Verwaltung und Steuerung von Raspberry Pis (SSH/Commands/Presets).", "menu": [ { "label": "Übersicht", "href": "/module/pi_control" }, - { "label": "Hosts", "href": "/module/pi_control/hosts" }, - { "label": "Befehle", "href": "/module/pi_control/commands" }, { "label": "Konsole", "href": "/module/pi_control/console" }, - { "label": "Setup", "href": "/modules/setup/pi_control" } + { + "label": "Settings", + "children": [ + { "label": "Hosts", "href": "/module/pi_control/hosts" }, + { "label": "Befehle", "href": "/module/pi_control/commands" } + ] + } ], "sidebar": { "enabled": true, @@ -15,10 +19,9 @@ "default": "collapsed", "items": [ { "label": "Übersicht", "href": "/module/pi_control" }, - { "label": "Hosts", "href": "/module/pi_control/hosts" }, - { "label": "Befehle", "href": "/module/pi_control/commands" }, { "label": "Konsole", "href": "/module/pi_control/console" }, - { "label": "Setup", "href": "/modules/setup/pi_control" } + { "label": "Hosts", "href": "/module/pi_control/hosts" }, + { "label": "Befehle", "href": "/module/pi_control/commands" } ] }, "setup": { diff --git a/modules/pi_control/pages/asset.php b/modules/pi_control/pages/asset.php index 34f8c68..4461c27 100644 --- a/modules/pi_control/pages/asset.php +++ b/modules/pi_control/pages/asset.php @@ -4,6 +4,8 @@ $base = realpath(__DIR__ . '/../assets'); $map = [ 'pi_control.css' => $base . '/pi_control.css', 'console.js' => $base . '/console.js', + 'hosts.js' => $base . '/hosts.js', + 'commands.js' => $base . '/commands.js', ]; if (!isset($map[$file])) { diff --git a/modules/pi_control/pages/commands.php b/modules/pi_control/pages/commands.php index 9fadc18..7400df2 100644 --- a/modules/pi_control/pages/commands.php +++ b/modules/pi_control/pages/commands.php @@ -5,35 +5,79 @@ $table = fn(string $name) => module_fn('pi_control', 'table', $name); $assets = app()->assets(); if ($assets) { $assets->addStyle('/module/pi_control/asset?file=pi_control.css'); + $assets->addScript('/module/pi_control/asset?file=commands.js', 'footer', true); } $notice = null; $error = null; +if (isset($_GET['reorder_json'])) { + require_admin(); + $payload = json_decode(file_get_contents('php://input'), true); + $order = is_array($payload['order'] ?? null) ? $payload['order'] : []; + if ($order) { + $stmt = $pdo->prepare('UPDATE ' . $table('commands') . ' SET sort_order = :sort_order WHERE id = :id'); + $pos = 1; + foreach ($order as $id) { + $stmt->execute([ + 'sort_order' => $pos++, + 'id' => (int)$id, + ]); + } + } + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['ok' => true]); + exit; +} + if ($_SERVER['REQUEST_METHOD'] === 'POST') { require_admin(); + $deleteId = (int)($_POST['delete_id'] ?? 0); + $editId = (int)($_POST['id'] ?? 0); $label = trim((string)($_POST['label'] ?? '')); $command = trim((string)($_POST['command'] ?? '')); $adminOnly = !empty($_POST['admin_only']) ? 1 : 0; $timeoutSec = (int)($_POST['timeout_sec'] ?? 0); - if ($label === '' || $command === '') { - $error = 'Bitte Label und Command angeben.'; + if ($deleteId > 0) { + $stmt = $pdo->prepare('DELETE FROM ' . $table('commands') . ' WHERE id = :id'); + $stmt->execute(['id' => $deleteId]); + $notice = 'Befehl gelöscht.'; } else { - $stmt = $pdo->prepare( - 'INSERT INTO ' . $table('commands') . ' (label, command, admin_only, timeout_sec) VALUES (:label, :command, :admin_only, :timeout_sec)' - ); - $stmt->execute([ - 'label' => $label, - 'command' => $command, - 'admin_only' => $adminOnly, - 'timeout_sec' => $timeoutSec > 0 ? $timeoutSec : null, - ]); - $notice = 'Befehl gespeichert.'; + if ($label === '' || $command === '') { + $error = 'Bitte Label und Command angeben.'; + } else { + if ($editId > 0) { + $stmt = $pdo->prepare( + 'UPDATE ' . $table('commands') . ' SET label = :label, command = :command, admin_only = :admin_only, timeout_sec = :timeout_sec WHERE id = :id' + ); + $stmt->execute([ + 'id' => $editId, + 'label' => $label, + 'command' => $command, + 'admin_only' => $adminOnly, + 'timeout_sec' => $timeoutSec > 0 ? $timeoutSec : null, + ]); + $notice = 'Befehl aktualisiert.'; + } else { + $nextSort = (int)$pdo->query('SELECT COALESCE(MAX(sort_order), 0) + 1 FROM ' . $table('commands'))->fetchColumn(); + $stmt = $pdo->prepare( + 'INSERT INTO ' . $table('commands') . ' (label, command, admin_only, timeout_sec, sort_order) VALUES (:label, :command, :admin_only, :timeout_sec, :sort_order)' + ); + $stmt->execute([ + 'label' => $label, + 'command' => $command, + 'admin_only' => $adminOnly, + 'timeout_sec' => $timeoutSec > 0 ? $timeoutSec : null, + 'sort_order' => $nextSort, + ]); + $notice = 'Befehl gespeichert.'; + } + } } } -$commands = $pdo->query('SELECT * FROM ' . $table('commands') . ' ORDER BY id DESC')->fetchAll(PDO::FETCH_ASSOC); +$commands = $pdo->query('SELECT * FROM ' . $table('commands') . ' ORDER BY COALESCE(sort_order, id) ASC, id ASC')->fetchAll(PDO::FETCH_ASSOC); ?>
| Label | -Command | -Timeout | -Admin | -
|---|---|---|---|
| Keine Befehle vorhanden. | |||
| = e($c['label'] ?? '') ?> | -
+
+ Keine Befehle vorhanden.
+
+
|
- = !empty($c['timeout_sec']) ? e((string)$c['timeout_sec']) . 's' : 'default' ?> | -= !empty($c['admin_only']) ? 'ja' : 'nein' ?> | -
Reihenfolge per Drag & Drop ändern.
+| Name | -Host | -User | -Port | -Auth | -
|---|---|---|---|---|
| Keine Hosts vorhanden. | ||||
| = e($h['name'] ?? '') ?> | -= e($h['host'] ?? '') ?> | -= e($h['username'] ?? '') ?> | -= e((string)($h['port'] ?? 22)) ?> | -= e($h['auth_type'] ?? '') ?> | -