diff --git a/modules/pi_control/assets/hosts.js b/modules/pi_control/assets/hosts.js index bcc5602..abddbe2 100644 --- a/modules/pi_control/assets/hosts.js +++ b/modules/pi_control/assets/hosts.js @@ -17,6 +17,7 @@ const modalTitle = document.querySelector('[data-host-modal-title]'); const closeBtn = document.querySelector('[data-host-close]'); const newBtn = document.querySelector('[data-host-new]'); + const checkAllBtn = document.querySelector('[data-host-check-all]'); const unsavedBar = document.querySelector('[data-host-unsaved]'); const discardBtn = document.querySelector('[data-host-discard]'); @@ -148,4 +149,128 @@ document.querySelectorAll('.host-card').forEach((card) => { fetchStatus(card); }); + + const setUpdateUi = (card, data) => { + const upd = card.querySelector('[data-update-badge]'); + const upg = card.querySelector('[data-upgrade-badge]'); + const time = card.querySelector('[data-update-time]'); + if (upd) { + if (data.updates && typeof data.updates.count === 'number') { + upd.textContent = `Updates: ${data.updates.count}`; + upd.classList.toggle('badge-warn', data.updates.count > 0); + upd.classList.toggle('badge-ok', data.updates.count === 0); + if (data.updates.preview) { + upd.setAttribute('title', data.updates.preview); + } + } else { + upd.textContent = 'Updates: Fehler'; + upd.classList.add('badge-error'); + if (data.updates && data.updates.error) { + upd.setAttribute('title', data.updates.error); + } + } + } + if (upg) { + if (data.os && typeof data.os.available === 'boolean') { + upg.textContent = data.os.available ? 'OS: Upgrade verfügbar' : 'OS: OK'; + upg.classList.toggle('badge-warn', data.os.available); + upg.classList.toggle('badge-ok', !data.os.available); + if (data.os.raw) upg.setAttribute('title', data.os.raw); + } else { + upg.textContent = 'OS: Fehler'; + upg.classList.add('badge-error'); + if (data.os && data.os.error) { + upg.setAttribute('title', data.os.error); + } + } + } + if (time && data.checked_at) { + const dt = new Date(data.checked_at); + time.textContent = isNaN(dt.getTime()) ? data.checked_at : dt.toLocaleString(); + } + }; + + const checkHostUpdates = (card) => { + const id = card.dataset.hostId; + if (!id) return Promise.resolve(); + const btn = card.querySelector('[data-host-check]'); + if (btn) { + btn.disabled = true; + btn.textContent = 'Prüfe...'; + } + return fetch(`${window.location.pathname}?update_json=1&id=${encodeURIComponent(id)}`, { cache: 'no-store' }) + .then((res) => res.json()) + .then((data) => { + if (data && data.ok) { + setUpdateUi(card, data); + } + }) + .finally(() => { + if (btn) { + btn.disabled = false; + btn.textContent = 'Updates prüfen'; + } + }); + }; + + document.querySelectorAll('[data-host-check]').forEach((btn) => { + btn.addEventListener('click', () => { + const card = btn.closest('.host-card'); + if (card) checkHostUpdates(card); + }); + }); + + if (checkAllBtn) { + checkAllBtn.addEventListener('click', async () => { + const cards = Array.from(document.querySelectorAll('.host-card')); + for (const card of cards) { + await checkHostUpdates(card); + } + }); + } + + const applyStoredUpdate = (card) => { + const checkedAt = card.dataset.updateChecked || ''; + const updateCount = card.dataset.updateCount; + const updateError = card.dataset.updateError || ''; + const upgradeAvailable = card.dataset.upgradeAvailable; + const upgradeRaw = card.dataset.upgradeRaw || ''; + const upgradeError = card.dataset.upgradeError || ''; + + const payload = { + updates: {}, + os: {}, + checked_at: checkedAt || '', + }; + if (updateError) { + payload.updates.error = updateError; + } else if (updateCount !== undefined && updateCount !== '') { + payload.updates.count = Number(updateCount); + payload.updates.preview = ''; + } + + if (upgradeError) { + payload.os.error = upgradeError; + } else if (upgradeAvailable !== undefined && upgradeAvailable !== '') { + payload.os.available = upgradeAvailable === '1' || upgradeAvailable === 'true'; + payload.os.raw = upgradeRaw; + } + + setUpdateUi(card, payload); + }; + + const isStale = (checkedAt) => { + if (!checkedAt) return true; + const dt = new Date(checkedAt); + if (isNaN(dt.getTime())) return true; + const ageMs = Date.now() - dt.getTime(); + return ageMs > 24 * 60 * 60 * 1000; + }; + + document.querySelectorAll('.host-card').forEach((card) => { + applyStoredUpdate(card); + if (isStale(card.dataset.updateChecked || '')) { + checkHostUpdates(card); + } + }); })(); diff --git a/modules/pi_control/assets/pi_control.css b/modules/pi_control/assets/pi_control.css index 82bd75c..91121fd 100644 --- a/modules/pi_control/assets/pi_control.css +++ b/modules/pi_control/assets/pi_control.css @@ -126,6 +126,35 @@ .status-auth { background: #fbbf24; } .status-down { background: #ef4444; } +.host-update-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.update-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid var(--line); + background: var(--panel-2); + font-size: 0.75rem; + font-weight: 600; +} +.badge-ok { + border-color: rgba(49,196,141,0.5); + background: rgba(49,196,141,0.15); +} +.badge-warn { + border-color: rgba(251,191,36,0.6); + background: rgba(251,191,36,0.2); +} +.badge-error { + border-color: rgba(239,68,68,0.6); + background: rgba(239,68,68,0.15); +} + .action-menu { position: relative; } diff --git a/modules/pi_control/bootstrap.php b/modules/pi_control/bootstrap.php index 1bafcdd..b00aca8 100644 --- a/modules/pi_control/bootstrap.php +++ b/modules/pi_control/bootstrap.php @@ -65,6 +65,13 @@ $mm->registerFunction($moduleName, 'ensure_schema', function () use ($moduleName key_path TEXT NULL, password TEXT NULL, image_url TEXT NULL, + update_checked_at TIMESTAMP NULL, + update_count INTEGER NULL, + update_preview TEXT NULL, + update_error TEXT NULL, + upgrade_available BOOLEAN NULL, + upgrade_raw TEXT NULL, + upgrade_error TEXT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP )"); $pdo->exec("CREATE TABLE IF NOT EXISTS {$cmdTable} ( @@ -113,6 +120,13 @@ $mm->registerFunction($moduleName, 'ensure_schema', function () use ($moduleName key_path TEXT NULL, password TEXT NULL, image_url TEXT NULL, + update_checked_at DATETIME NULL, + update_count INTEGER NULL, + update_preview TEXT NULL, + update_error TEXT NULL, + upgrade_available INTEGER NULL, + upgrade_raw TEXT NULL, + upgrade_error TEXT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )"); $pdo->exec("CREATE TABLE IF NOT EXISTS {$cmdTable} ( @@ -155,6 +169,13 @@ $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 {$hostTable} ADD COLUMN IF NOT EXISTS update_checked_at TIMESTAMP NULL"); + $pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN IF NOT EXISTS update_count INTEGER NULL"); + $pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN IF NOT EXISTS update_preview TEXT NULL"); + $pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN IF NOT EXISTS update_error TEXT NULL"); + $pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN IF NOT EXISTS upgrade_available BOOLEAN NULL"); + $pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN IF NOT EXISTS upgrade_raw TEXT NULL"); + $pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN IF NOT EXISTS upgrade_error 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"); @@ -172,6 +193,27 @@ $mm->registerFunction($moduleName, 'ensure_schema', function () use ($moduleName if (empty($columns['image_url'])) { $pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN image_url TEXT NULL"); } + if (empty($columns['update_checked_at'])) { + $pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN update_checked_at DATETIME NULL"); + } + if (empty($columns['update_count'])) { + $pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN update_count INTEGER NULL"); + } + if (empty($columns['update_preview'])) { + $pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN update_preview TEXT NULL"); + } + if (empty($columns['update_error'])) { + $pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN update_error TEXT NULL"); + } + if (empty($columns['upgrade_available'])) { + $pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN upgrade_available INTEGER NULL"); + } + if (empty($columns['upgrade_raw'])) { + $pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN upgrade_raw TEXT NULL"); + } + if (empty($columns['upgrade_error'])) { + $pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN upgrade_error TEXT NULL"); + } $columns = []; $stmt = $pdo->query('PRAGMA table_info(' . $cmdTable . ')'); diff --git a/modules/pi_control/pages/hosts.php b/modules/pi_control/pages/hosts.php index 663b280..3bf9305 100644 --- a/modules/pi_control/pages/hosts.php +++ b/modules/pi_control/pages/hosts.php @@ -32,6 +32,78 @@ if (isset($_GET['status_json'])) { exit; } +if (isset($_GET['update_json'])) { + require_admin(); + $id = (int)($_GET['id'] ?? 0); + $stmt = $pdo->prepare('SELECT * FROM ' . $table('hosts') . ' WHERE id = :id LIMIT 1'); + $stmt->execute(['id' => $id]); + $host = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$host) { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['ok' => false, 'error' => 'not_found']); + exit; + } + $settings = modules()->settings('pi_control'); + $strictHostKey = !empty($settings['terminal_strict_hostkey']); + + $updateCmd = "apt-get -s upgrade | grep '^Inst'"; + $upgradeCmd = 'current="$(. /etc/os-release && echo "$VERSION_CODENAME")"; latest="$(curl -fsSL https://deb.debian.org/debian/dists/stable/Release | awk -F\': \' \'/^Codename:/{print $2}\')"; echo "Installed: $current"; echo "Latest stable: $latest"; [ "$current" != "$latest" ] && echo "OS UPGRADE AVAILABLE" || echo "NO OS UPGRADE AVAILABLE"'; + + [$updExit, $updOut, $updErr] = runSshCommandCapture($host, $updateCmd, $strictHostKey, 20); + $lines = array_values(array_filter(preg_split('/\r?\n/', (string)$updOut))); + $updateCount = $updExit === 0 ? count($lines) : null; + $updatePreview = $updateCount ? implode("\n", array_slice($lines, 0, 6)) : ''; + + [$upgExit, $upgOut, $upgErr] = runSshCommandCapture($host, $upgradeCmd, $strictHostKey, 25); + $upgradeAvailable = null; + if ($upgExit === 0) { + $upgradeAvailable = str_contains($upgOut, 'OS UPGRADE AVAILABLE'); + } + + $driver = (string)$pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + $nowExpr = $driver === 'pgsql' ? 'NOW()' : "DATETIME('now')"; + $updCountVal = $updateCount; + $updErrVal = $updExit === 0 ? null : trim($updErr ?: $updOut); + $upgAvailVal = $upgradeAvailable; + $upgErrVal = $upgExit === 0 ? null : trim($upgErr ?: $upgOut); + $stmt = $pdo->prepare( + 'UPDATE ' . $table('hosts') . ' SET update_checked_at = ' . $nowExpr . ', + update_count = :update_count, + update_preview = :update_preview, + update_error = :update_error, + upgrade_available = :upgrade_available, + upgrade_raw = :upgrade_raw, + upgrade_error = :upgrade_error + WHERE id = :id' + ); + $stmt->execute([ + 'update_count' => $updCountVal, + 'update_preview' => $updatePreview !== '' ? $updatePreview : null, + 'update_error' => $updErrVal, + 'upgrade_available' => $upgAvailVal === null ? null : ($upgAvailVal ? 1 : 0), + 'upgrade_raw' => $upgExit === 0 ? trim($upgOut) : null, + 'upgrade_error' => $upgErrVal, + 'id' => $id, + ]); + + header('Content-Type: application/json; charset=utf-8'); + echo json_encode([ + 'ok' => true, + 'updates' => [ + 'count' => $updateCount, + 'preview' => $updatePreview, + 'error' => $updExit === 0 ? '' : trim($updErr ?: $updOut), + ], + 'os' => [ + 'available' => $upgradeAvailable, + 'raw' => $upgExit === 0 ? trim($upgOut) : '', + 'error' => $upgExit === 0 ? '' : trim($upgErr ?: $upgOut), + ], + 'checked_at' => date('c'), + ]); + exit; +} + if ($_SERVER['REQUEST_METHOD'] === 'POST') { require_admin(); $deleteId = (int)($_POST['delete_id'] ?? 0); @@ -138,6 +210,64 @@ function runSshCommand(string $cmd, int $timeoutSec): int } } +function runSshCommandCapture(array $host, string $command, bool $strictHostKey, int $timeoutSec): array +{ + $hostAddr = (string)($host['host'] ?? ''); + $user = (string)($host['username'] ?? ''); + $port = (int)($host['port'] ?? 22); + $authType = (string)($host['auth_type'] ?? 'key'); + $keyPath = (string)($host['key_path'] ?? ''); + $password = (string)($host['password'] ?? ''); + + $opts = $strictHostKey + ? '-o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/root/.ssh/known_hosts' + : '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'; + $opts .= ' -o ConnectTimeout=6 -o NumberOfPasswordPrompts=1'; + + $target = escapeshellarg($user . '@' . $hostAddr); + $remote = '/bin/bash -lc ' . escapeshellarg($command); + $cmd = 'ssh ' . $opts . ' -p ' . (int)$port . ' '; + if ($authType === 'key' && $keyPath !== '') { + $cmd .= '-i ' . escapeshellarg($keyPath) . ' -o BatchMode=yes '; + } elseif ($authType === 'key') { + $cmd .= '-o BatchMode=yes '; + } + $cmd .= $target . ' -- ' . $remote; + if ($authType === 'pass' && $password !== '') { + $cmd = 'sshpass -p ' . escapeshellarg($password) . ' ' . $cmd; + } + + $descriptors = [ + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $process = proc_open($cmd, $descriptors, $pipes); + if (!is_resource($process)) { + return [255, '', 'proc_open failed']; + } + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + $out = ''; + $err = ''; + $start = time(); + while (true) { + $status = proc_get_status($process); + $out .= stream_get_contents($pipes[1]); + $err .= stream_get_contents($pipes[2]); + if (!$status['running']) { + $exit = (int)$status['exitcode']; + proc_close($process); + return [$exit, $out, $err]; + } + if (time() - $start > $timeoutSec) { + proc_terminate($process, 9); + proc_close($process); + return [124, $out, $err]; + } + usleep(100000); + } +} + function hostAuthOk(array $host, bool $strictHostKey): bool { $hostAddr = (string)($host['host'] ?? ''); @@ -179,7 +309,10 @@ function hostAuthOk(array $host, bool $strictHostKey): bool
Verwalte die Raspberry Pis, die du steuern möchtest.
@@ -209,7 +342,13 @@ function hostAuthOk(array $host, bool $strictHostKey): bool data-username="= e((string)($h['username'] ?? '')) ?>" data-auth="= e((string)($h['auth_type'] ?? 'key')) ?>" data-key-path="= e((string)($h['key_path'] ?? '')) ?>" - data-image-url="= e((string)($h['image_url'] ?? '')) ?>"> + data-image-url="= e((string)($h['image_url'] ?? '')) ?>" + data-update-checked="= e((string)($h['update_checked_at'] ?? '')) ?>" + data-update-count="= e((string)($h['update_count'] ?? '')) ?>" + data-update-error="= e((string)($h['update_error'] ?? '')) ?>" + data-upgrade-available="= e((string)($h['upgrade_available'] ?? '')) ?>" + data-upgrade-raw="= e((string)($h['upgrade_raw'] ?? '')) ?>" + data-upgrade-error="= e((string)($h['upgrade_error'] ?? '')) ?>">