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
This commit is contained in:
courtmanr@gmail.com
2025-05-30 16:31:14 +01:00
parent 468172c5bf
commit afa1aef98f
5 changed files with 562 additions and 2 deletions
+3
View File
@@ -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
+3 -1
View File
@@ -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: {} };
}
}
+67
View File
@@ -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');
+252
View File
@@ -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;
+237 -1
View File
@@ -361,6 +361,71 @@ PulseApp.ui.settings = (() => {
</div>
</div>
<!-- Update Management -->
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">Software Updates</h3>
<div id="update-status" class="mb-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-700 dark:text-gray-300">
Current Version: <span id="current-version" class="font-mono font-semibold">${currentConfig.version || 'Unknown'}</span>
</p>
<p id="latest-version-info" class="text-sm text-gray-600 dark:text-gray-400 mt-1"></p>
</div>
<button type="button" onclick="PulseApp.ui.settings.checkForUpdates()"
id="check-updates-button"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-md flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Check for Updates
</button>
</div>
</div>
<!-- Update Details (hidden by default) -->
<div id="update-details" class="hidden">
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-4">
<h4 class="text-sm font-semibold text-blue-800 dark:text-blue-200 mb-2">
Update Available: <span id="update-version"></span>
</h4>
<div id="update-release-notes" class="text-sm text-gray-700 dark:text-gray-300 prose prose-sm max-w-none"></div>
</div>
<div class="flex items-center justify-between">
<p class="text-sm text-gray-600 dark:text-gray-400">
Published: <span id="update-published"></span>
</p>
<button type="button" onclick="PulseApp.ui.settings.applyUpdate()"
id="apply-update-button"
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm rounded-md flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
</svg>
Apply Update
</button>
</div>
</div>
</div>
<!-- Update Progress (hidden by default) -->
<div id="update-progress" class="hidden">
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
<div class="mb-2">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300" id="update-progress-text">Preparing update...</p>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2.5">
<div id="update-progress-bar" class="bg-blue-600 h-2.5 rounded-full transition-all duration-300" style="width: 0%"></div>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
Do not close this window or refresh the page during the update process.
</p>
</div>
</div>
</div>
<!-- Status Messages -->
<div id="settings-messages"></div>
</form>
@@ -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 = `
<svg class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
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, '<h3 class="font-semibold mt-3 mb-1">$1</h3>')
.replace(/### (.*?)$/gm, '<h4 class="font-medium mt-2 mb-1">$1</h4>')
.replace(/- (.*?)$/gm, '<li class="ml-4">$1</li>')
.replace(/(\n\n)/g, '</p><p class="mb-2">')
.replace(/^/, '<p class="mb-2">')
.replace(/$/, '</p>');
latestVersionInfo.innerHTML = `<span class="text-green-600 dark:text-green-400">Update available!</span>`;
} else {
updateDetails.classList.add('hidden');
latestVersionInfo.innerHTML = `<span class="text-gray-600 dark:text-gray-400">You are running the latest version</span>`;
}
} catch (error) {
console.error('[Settings] Error checking for updates:', error);
showMessage('Failed to check for updates: ' + error.message, 'error');
latestVersionInfo.innerHTML = `<span class="text-red-600 dark:text-red-400">Error checking for updates</span>`;
} finally {
// Re-enable button
button.disabled = false;
button.innerHTML = `
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
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
};
})();