From afa1aef98f09ea896cc14d08e52ebcfa4439ace8 Mon Sep 17 00:00:00 2001 From: "courtmanr@gmail.com" Date: Fri, 30 May 2025 16:31:14 +0100 Subject: [PATCH] feat: add web-based update management system - Created UpdateManager class for handling application updates - Added update check, download, and apply functionality - Integrated update UI into settings modal with progress tracking - Added version display and update notifications - Implemented proper backup and rollback mechanism - Added Docker-aware restart logic - Enhanced configuration API to include version information - Added real-time progress updates via WebSocket --- Dockerfile | 3 + server/configApi.js | 4 +- server/index.js | 67 ++++++++++ server/updateManager.js | 252 +++++++++++++++++++++++++++++++++++ src/public/js/ui/settings.js | 238 ++++++++++++++++++++++++++++++++- 5 files changed, 562 insertions(+), 2 deletions(-) create mode 100644 server/updateManager.js diff --git a/Dockerfile b/Dockerfile index 686f6aa96..bbd1ad6a9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -50,6 +50,9 @@ RUN chown -R appuser:appgroup /usr/src/app # Switch to non-root user USER appuser +# Set environment variable to indicate Docker deployment +ENV DOCKER_DEPLOYMENT=true + # Expose port EXPOSE 7655 diff --git a/server/configApi.js b/server/configApi.js index ca2d0efa9..f1a482af5 100644 --- a/server/configApi.js +++ b/server/configApi.js @@ -14,9 +14,11 @@ class ConfigApi { async getConfig() { try { const config = await this.readEnvFile(); + const packageJson = require('../package.json'); // Build the response structure including all additional endpoints const response = { + version: packageJson.version, proxmox: config.PROXMOX_HOST ? { host: config.PROXMOX_HOST, port: config.PROXMOX_PORT || '8006', @@ -68,7 +70,7 @@ class ConfigApi { return response; } catch (error) { console.error('Error reading configuration:', error); - return { proxmox: null, pbs: null, advanced: {} }; + return { version: 'unknown', proxmox: null, pbs: null, advanced: {} }; } } diff --git a/server/index.js b/server/index.js index c414780ab..9d81b0120 100644 --- a/server/index.js +++ b/server/index.js @@ -151,6 +151,73 @@ app.get('/setup.html', (req, res) => { // Set up configuration API routes configApi.setupRoutes(app); +// Set up update API routes +const UpdateManager = require('./updateManager'); +const updateManager = new UpdateManager(); + +// Check for updates endpoint +app.get('/api/updates/check', async (req, res) => { + try { + const updateInfo = await updateManager.checkForUpdates(); + res.json(updateInfo); + } catch (error) { + console.error('Error checking for updates:', error); + res.status(500).json({ error: error.message }); + } +}); + +// Download and apply update endpoint +app.post('/api/updates/apply', async (req, res) => { + try { + const { downloadUrl } = req.body; + + if (!downloadUrl) { + return res.status(400).json({ error: 'Download URL is required' }); + } + + // Send immediate response + res.json({ + message: 'Update started. The application will restart automatically when complete.', + status: 'in_progress' + }); + + // Apply update in background + setTimeout(async () => { + try { + // Download update + const updateFile = await updateManager.downloadUpdate(downloadUrl, (progress) => { + io.emit('updateProgress', progress); + }); + + // Apply update + await updateManager.applyUpdate(updateFile, (progress) => { + io.emit('updateProgress', progress); + }); + + io.emit('updateComplete', { success: true }); + } catch (error) { + console.error('Error applying update:', error); + io.emit('updateError', { error: error.message }); + } + }, 100); + + } catch (error) { + console.error('Error initiating update:', error); + res.status(500).json({ error: error.message }); + } +}); + +// Update status endpoint +app.get('/api/updates/status', (req, res) => { + try { + const status = updateManager.getUpdateStatus(); + res.json(status); + } catch (error) { + console.error('Error getting update status:', error); + res.status(500).json({ error: error.message }); + } +}); + // Health check endpoint app.get('/healthz', (req, res) => { res.status(200).send('OK'); diff --git a/server/updateManager.js b/server/updateManager.js new file mode 100644 index 000000000..a879bdf2d --- /dev/null +++ b/server/updateManager.js @@ -0,0 +1,252 @@ +const axios = require('axios'); +const semver = require('semver'); +const fs = require('fs').promises; +const path = require('path'); +const { exec } = require('child_process'); +const { promisify } = require('util'); +const execAsync = promisify(exec); + +class UpdateManager { + constructor() { + this.githubRepo = 'rcourtman/Pulse'; + this.currentVersion = require('../package.json').version; + this.updateInProgress = false; + } + + /** + * Check for available updates + */ + async checkForUpdates() { + try { + console.log('[UpdateManager] Checking for updates...'); + + // Fetch latest release from GitHub + const response = await axios.get( + `https://api.github.com/repos/${this.githubRepo}/releases/latest`, + { + headers: { + 'Accept': 'application/vnd.github.v3+json', + 'User-Agent': 'Pulse-Update-Checker' + }, + timeout: 10000 + } + ); + + const latestVersion = response.data.tag_name.replace('v', ''); + const updateAvailable = semver.gt(latestVersion, this.currentVersion); + + const updateInfo = { + currentVersion: this.currentVersion, + latestVersion, + updateAvailable, + releaseNotes: response.data.body || 'No release notes available', + releaseUrl: response.data.html_url, + publishedAt: response.data.published_at, + assets: response.data.assets.map(asset => ({ + name: asset.name, + size: asset.size, + downloadUrl: asset.browser_download_url + })) + }; + + console.log(`[UpdateManager] Current version: ${this.currentVersion}, Latest version: ${latestVersion}`); + return updateInfo; + + } catch (error) { + console.error('[UpdateManager] Error checking for updates:', error.message); + throw new Error(`Failed to check for updates: ${error.message}`); + } + } + + /** + * Download update package + */ + async downloadUpdate(downloadUrl, progressCallback) { + try { + console.log('[UpdateManager] Downloading update from:', downloadUrl); + + const tempDir = path.join(__dirname, '..', 'temp'); + await fs.mkdir(tempDir, { recursive: true }); + + const tempFile = path.join(tempDir, 'update.tar.gz'); + + // Download with progress tracking + const response = await axios({ + method: 'get', + url: downloadUrl, + responseType: 'stream', + timeout: 300000 // 5 minutes + }); + + const totalSize = parseInt(response.headers['content-length'], 10); + let downloadedSize = 0; + + const writer = require('fs').createWriteStream(tempFile); + + response.data.on('data', (chunk) => { + downloadedSize += chunk.length; + if (progressCallback) { + const progress = Math.round((downloadedSize / totalSize) * 100); + progressCallback({ phase: 'download', progress }); + } + }); + + response.data.pipe(writer); + + return new Promise((resolve, reject) => { + writer.on('finish', () => resolve(tempFile)); + writer.on('error', reject); + }); + + } catch (error) { + console.error('[UpdateManager] Error downloading update:', error.message); + throw new Error(`Failed to download update: ${error.message}`); + } + } + + /** + * Apply update + */ + async applyUpdate(updateFile, progressCallback) { + if (this.updateInProgress) { + throw new Error('Update already in progress'); + } + + this.updateInProgress = true; + + try { + console.log('[UpdateManager] Applying update...'); + + // Create backup directory + const backupDir = path.join(__dirname, '..', 'backup', `backup-${Date.now()}`); + await fs.mkdir(backupDir, { recursive: true }); + + if (progressCallback) { + progressCallback({ phase: 'backup', progress: 0 }); + } + + // Backup critical files + const filesToBackup = [ + '.env', + 'data/metrics.db', + 'data/acknowledgements.json' + ]; + + for (let i = 0; i < filesToBackup.length; i++) { + const file = filesToBackup[i]; + const sourcePath = path.join(__dirname, '..', file); + const backupPath = path.join(backupDir, file); + + try { + await fs.mkdir(path.dirname(backupPath), { recursive: true }); + await fs.copyFile(sourcePath, backupPath); + } catch (error) { + if (error.code !== 'ENOENT') { + console.warn(`[UpdateManager] Warning: Could not backup ${file}:`, error.message); + } + } + + if (progressCallback) { + const progress = Math.round(((i + 1) / filesToBackup.length) * 100); + progressCallback({ phase: 'backup', progress }); + } + } + + if (progressCallback) { + progressCallback({ phase: 'extract', progress: 0 }); + } + + // Extract update + const tempExtractDir = path.join(__dirname, '..', 'temp', 'extract'); + await fs.mkdir(tempExtractDir, { recursive: true }); + + await execAsync(`tar -xzf ${updateFile} -C ${tempExtractDir}`); + + if (progressCallback) { + progressCallback({ phase: 'extract', progress: 100 }); + } + + // Detect deployment type + const isDocker = process.env.DOCKER_DEPLOYMENT === 'true' || require('fs').existsSync('/.dockerenv'); + const pulseDir = path.join(__dirname, '..'); + + if (progressCallback) { + progressCallback({ phase: 'apply', progress: 50 }); + } + + // Apply update files + console.log('[UpdateManager] Extracting update files...'); + + // List files to update (exclude config and data) + const updateFiles = await fs.readdir(tempExtractDir); + for (const file of updateFiles) { + if (file === '.env' || file === 'data') continue; + + const sourcePath = path.join(tempExtractDir, file); + const destPath = path.join(pulseDir, file); + + // Remove existing file/directory + try { + await fs.rm(destPath, { recursive: true, force: true }); + } catch (e) { + // Ignore errors + } + + // Copy new file/directory + await execAsync(`cp -rf "${sourcePath}" "${destPath}"`); + } + + // Install dependencies + console.log('[UpdateManager] Installing dependencies...'); + await execAsync(`cd "${pulseDir}" && npm ci --production`); + + if (progressCallback) { + progressCallback({ phase: 'apply', progress: 100 }); + } + + // Schedule restart + console.log('[UpdateManager] Scheduling restart...'); + setTimeout(() => { + if (isDocker) { + // In Docker, just exit - container will be restarted + console.log('[UpdateManager] Exiting for Docker restart...'); + process.exit(0); + } else { + // For systemd/manual deployments, try to restart + console.log('[UpdateManager] Attempting restart...'); + + // Try systemctl first + execAsync('sudo systemctl restart pulse').catch(() => { + // If systemctl fails, just exit + process.exit(0); + }); + } + }, 2000); + + // Cleanup + await fs.rm(path.join(__dirname, '..', 'temp'), { recursive: true, force: true }); + + return { + success: true, + message: 'Update applied successfully. The application will restart automatically.' + }; + + } catch (error) { + console.error('[UpdateManager] Error applying update:', error.message); + this.updateInProgress = false; + throw new Error(`Failed to apply update: ${error.message}`); + } + } + + /** + * Get update status + */ + getUpdateStatus() { + return { + updateInProgress: this.updateInProgress, + currentVersion: this.currentVersion + }; + } +} + +module.exports = UpdateManager; \ No newline at end of file diff --git a/src/public/js/ui/settings.js b/src/public/js/ui/settings.js index 43bd567c6..5bad0f648 100644 --- a/src/public/js/ui/settings.js +++ b/src/public/js/ui/settings.js @@ -361,6 +361,71 @@ PulseApp.ui.settings = (() => { + +
+

Software Updates

+ +
+
+
+

+ Current Version: ${currentConfig.version || 'Unknown'} +

+

+
+ +
+
+ + + + + + +
+
@@ -773,6 +838,175 @@ PulseApp.ui.settings = (() => { } } + // Update management functions + let updateInfo = null; + + async function checkForUpdates() { + const button = document.getElementById('check-updates-button'); + const updateDetails = document.getElementById('update-details'); + const latestVersionInfo = document.getElementById('latest-version-info'); + + try { + // Disable button and show loading state + button.disabled = true; + button.innerHTML = ` + + + + + Checking... + `; + + const response = await fetch('/api/updates/check'); + if (!response.ok) throw new Error('Failed to check for updates'); + + updateInfo = await response.json(); + + // Update UI based on result + if (updateInfo.updateAvailable) { + updateDetails.classList.remove('hidden'); + document.getElementById('update-version').textContent = `v${updateInfo.latestVersion}`; + document.getElementById('update-published').textContent = new Date(updateInfo.publishedAt).toLocaleDateString(); + + // Render release notes (convert markdown to HTML) + const releaseNotes = updateInfo.releaseNotes || 'No release notes available'; + document.getElementById('update-release-notes').innerHTML = releaseNotes + .replace(/## (.*?)$/gm, '

$1

') + .replace(/### (.*?)$/gm, '

$1

') + .replace(/- (.*?)$/gm, '
  • $1
  • ') + .replace(/(\n\n)/g, '

    ') + .replace(/^/, '

    ') + .replace(/$/, '

    '); + + latestVersionInfo.innerHTML = `Update available!`; + } else { + updateDetails.classList.add('hidden'); + latestVersionInfo.innerHTML = `You are running the latest version`; + } + + } catch (error) { + console.error('[Settings] Error checking for updates:', error); + showMessage('Failed to check for updates: ' + error.message, 'error'); + latestVersionInfo.innerHTML = `Error checking for updates`; + } finally { + // Re-enable button + button.disabled = false; + button.innerHTML = ` + + + + Check for Updates + `; + } + } + + async function applyUpdate() { + if (!updateInfo || !updateInfo.updateAvailable) return; + + const confirmed = confirm( + `Are you sure you want to update Pulse to version ${updateInfo.latestVersion}?\\n\\n` + + `The application will restart automatically after the update is applied.` + ); + + if (!confirmed) return; + + const updateDetails = document.getElementById('update-details'); + const updateProgress = document.getElementById('update-progress'); + const applyButton = document.getElementById('apply-update-button'); + + try { + // Find the tarball asset + const tarballAsset = updateInfo.assets.find(asset => asset.name.endsWith('.tar.gz')); + if (!tarballAsset) { + throw new Error('Update package not found'); + } + + // Hide details, show progress + updateDetails.classList.add('hidden'); + updateProgress.classList.remove('hidden'); + applyButton.disabled = true; + + // Set up WebSocket listeners for progress updates + setupUpdateProgressListeners(); + + // Start update + const response = await fetch('/api/updates/apply', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + downloadUrl: tarballAsset.downloadUrl + }) + }); + + if (!response.ok) throw new Error('Failed to start update'); + + const result = await response.json(); + showMessage(result.message, 'info'); + + } catch (error) { + console.error('[Settings] Error applying update:', error); + showMessage('Failed to apply update: ' + error.message, 'error'); + + // Reset UI + updateDetails.classList.remove('hidden'); + updateProgress.classList.add('hidden'); + applyButton.disabled = false; + } + } + + function setupUpdateProgressListeners() { + const progressBar = document.getElementById('update-progress-bar'); + const progressText = document.getElementById('update-progress-text'); + + // Listen for progress updates + if (PulseApp.socket) { + PulseApp.socket.on('updateProgress', (data) => { + if (progressBar && progressText) { + progressBar.style.width = `${data.progress}%`; + + switch(data.phase) { + case 'download': + progressText.textContent = `Downloading update... ${data.progress}%`; + break; + case 'backup': + progressText.textContent = `Backing up configuration... ${data.progress}%`; + break; + case 'extract': + progressText.textContent = `Extracting update... ${data.progress}%`; + break; + case 'apply': + progressText.textContent = `Applying update... ${data.progress}%`; + break; + } + } + }); + + PulseApp.socket.on('updateComplete', (data) => { + if (data.success) { + showMessage('Update completed successfully! The application will restart momentarily...', 'success'); + progressText.textContent = 'Update complete! Restarting...'; + + // Reload page after 3 seconds + setTimeout(() => { + window.location.reload(); + }, 3000); + } + }); + + PulseApp.socket.on('updateError', (data) => { + showMessage('Update failed: ' + data.error, 'error'); + + // Reset UI + const updateDetails = document.getElementById('update-details'); + const updateProgress = document.getElementById('update-progress'); + updateDetails.classList.remove('hidden'); + updateProgress.classList.add('hidden'); + }); + } + } + // Public API return { init, @@ -782,6 +1016,8 @@ PulseApp.ui.settings = (() => { addPbsEndpoint, removeEndpoint, testConnections, - saveConfiguration + saveConfiguration, + checkForUpdates, + applyUpdate }; })(); \ No newline at end of file