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'}
+
+
+
+
+
+
+
+
+
+
+
+
+ Update Available:
+
+
+
+
+
+
+ Published:
+
+
+
+
+
+
+
+
+
+
+
+
+ Do not close this window or refresh the page during the update process.
+
+
+
+
+
@@ -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