feat: consolidate update system and improve version comparison UI

- Consolidate dual update mechanisms to use proven install script
- Fix critical version parameter extraction bug
- Add comprehensive commit differences display with GitHub links
- Improve update UI with expandable details and compact summary
- Remove redundant channel recommendation warnings
- Add proper error handling and cleanup for update process
This commit is contained in:
courtmanr@gmail.com
2025-06-04 14:20:28 +01:00
parent b827a90bd9
commit 67d79e20ed
6 changed files with 1107 additions and 1392 deletions
+15 -3
View File
@@ -212,7 +212,7 @@ async function fetchDataForPveEndpoint(endpointId, apiClientInstance, config) {
endpointType = 'standalone';
}
} else if (clusterStatusResult.status === 'rejected') {
console.error(`[DataFetcher - ${endpointName}] Error fetching /cluster/status: ${clusterStatusResult.reason?.message || clusterStatusResult.reason}`);
console.error(`[DataFetcher - ${endpointName}] Error fetching /cluster/status: ${clusterStatusResult.reason?.message || clusterStatusResult.reason}`, clusterStatusResult.reason);
endpointType = 'standalone'; // Fallback
}
@@ -230,7 +230,12 @@ async function fetchDataForPveEndpoint(endpointId, apiClientInstance, config) {
actualClusterName = standaloneNodeName;
}
} else if (nodesResult.status === 'rejected') {
console.error(`[DataFetcher - ${endpointName}] Failed to fetch nodes: ${nodesResult.reason?.message || nodesResult.reason}`);
if (clusterStatusResult.status === 'rejected') {
// Both cluster status and nodes failed
console.error(`[DataFetcher - ${endpointName}] Also failed to fetch /nodes after /cluster/status error: ${nodesResult.reason?.message || nodesResult.reason}`);
} else {
console.error(`[DataFetcher - ${endpointName}] Failed to fetch nodes: ${nodesResult.reason?.message || nodesResult.reason}`);
}
return { nodes: [], vms: [], containers: [] };
}
@@ -1402,7 +1407,7 @@ async function fetchDiscoveryData(currentApiClients, currentPbsApiClients, _fetc
: { backupTasks: [], storageBackups: [], guestSnapshots: [] };
if (backupResults[0].status === 'rejected') {
console.error("[DataFetcher] Error fetching PBS data:", backupResults[0].reason);
console.error("[DataFetcher] Error during parallel backup data fetch:", backupResults[0].reason);
}
if (backupResults[1].status === 'rejected') {
console.error("[DataFetcher] Error fetching PVE backup data:", backupResults[1].reason);
@@ -1657,10 +1662,17 @@ async function fetchPbsVersionInfo({ client, config }) {
}
}
// Function to clear caches for testing
function clearCaches() {
nodeConnectionCache.clear();
nodeStateCache.clear();
}
module.exports = {
fetchDiscoveryData,
fetchPbsData, // Keep exporting the real one
fetchMetricsData,
clearCaches, // Export for testing
// Potentially export PBS helpers if needed elsewhere, but keep internal if not
// fetchPbsNodeName,
// fetchPbsDatastoreData,
+5 -3
View File
@@ -198,7 +198,9 @@ app.get('/api/updates/check', async (req, res) => {
return res.json(mockUpdateInfo);
}
const updateInfo = await updateManager.checkForUpdates();
// Allow override of update channel via query parameter for preview
const channelOverride = req.query.channel;
const updateInfo = await updateManager.checkForUpdates(channelOverride);
res.json(updateInfo);
} catch (error) {
console.error('Error checking for updates:', error);
@@ -229,10 +231,10 @@ app.post('/api/updates/apply', async (req, res) => {
io.emit('updateProgress', progress);
});
// Apply update
// Apply update (pass download URL for version extraction)
await updateManager.applyUpdate(updateFile, (progress) => {
io.emit('updateProgress', progress);
});
}, downloadUrl);
io.emit('updateComplete', { success: true });
} catch (error) {
File diff suppressed because it is too large Load Diff
+161 -147
View File
@@ -2,7 +2,7 @@ const axios = require('axios');
const semver = require('semver');
const fs = require('fs').promises;
const path = require('path');
const { exec } = require('child_process');
const { exec, spawn } = require('child_process');
const { promisify } = require('util');
const { getUpdateChannelPreference } = require('./configLoader');
const execAsync = promisify(exec);
@@ -24,7 +24,8 @@ class UpdateManager {
if (!versionToCheck || typeof versionToCheck !== 'string') {
return false;
}
return versionToCheck.includes('-rc') || versionToCheck.includes('-alpha') || versionToCheck.includes('-beta');
const versionLower = versionToCheck.toLowerCase();
return versionLower.includes('-rc') || versionLower.includes('-alpha') || versionLower.includes('-beta');
}
/**
@@ -57,12 +58,21 @@ class UpdateManager {
/**
* Check for available updates
* @param {string} channelOverride - Optional channel override ('stable' or 'rc')
*/
async checkForUpdates() {
async checkForUpdates(channelOverride = null) {
try {
console.log('[UpdateManager] Checking for updates...');
const updateChannel = getUpdateChannelPreference();
// Use override channel if provided and valid, otherwise use config
const configChannel = getUpdateChannelPreference();
const updateChannel = (channelOverride && ['stable', 'rc'].includes(channelOverride))
? channelOverride
: configChannel;
if (channelOverride && channelOverride !== configChannel) {
console.log(`[UpdateManager] Using channel override: ${channelOverride} (config: ${configChannel})`);
}
let response;
let channelDescription = '';
@@ -130,7 +140,20 @@ class UpdateManager {
}
const latestVersion = response.data.tag_name.replace('v', '');
const updateAvailable = semver.gt(latestVersion, this.currentVersion);
// For stable channel, also consider "downgrade" from RC as an update
const isCurrentRC = this.isReleaseCandidate();
const isStableChannel = updateChannel === 'stable';
const isDifferentVersion = latestVersion !== this.currentVersion;
let updateAvailable;
if (isStableChannel && isCurrentRC && isDifferentVersion) {
// Offer stable version even if it's older than current RC
updateAvailable = true;
} else {
// Normal case: only newer versions
updateAvailable = semver.gt(latestVersion, this.currentVersion);
}
const updateInfo = {
currentVersion: this.currentVersion,
@@ -280,9 +303,9 @@ class UpdateManager {
}
/**
* Apply update
* Apply update using the install script (reliable method)
*/
async applyUpdate(updateFile, progressCallback) {
async applyUpdate(updateFile, progressCallback, downloadUrl = null) {
if (this.updateInProgress) {
throw new Error('Update already in progress');
}
@@ -300,156 +323,138 @@ class UpdateManager {
this.updateInProgress = true;
try {
console.log('[UpdateManager] Applying update...');
console.log('[UpdateManager] Applying update using install script...');
// 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 });
progressCallback({ phase: 'preparing', progress: 10 });
}
// 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);
}
// Extract version from the download URL (more reliable than temp file path)
let targetVersion = 'latest';
if (downloadUrl && typeof downloadUrl === 'string') {
// Extract from download URL like: https://github.com/user/repo/releases/download/v3.21.0/pulse-v3.21.0.tar.gz
const urlMatch = downloadUrl.match(/\/releases\/download\/(v[\d\.\-\w]+)\//);
if (urlMatch) {
targetVersion = urlMatch[1];
console.log(`[UpdateManager] Extracted version from URL: ${targetVersion}`);
}
}
// Fallback: try to extract from updateFile path if URL parsing failed
if (targetVersion === 'latest' && typeof updateFile === 'string' && updateFile.includes('pulse-v')) {
const fileMatch = updateFile.match(/pulse-v([\d\.\-\w]+)\.tar\.gz/);
if (fileMatch) {
targetVersion = 'v' + fileMatch[1];
console.log(`[UpdateManager] Extracted version from file: ${targetVersion}`);
}
}
if (progressCallback) {
progressCallback({ phase: 'updating', progress: 20 });
}
// Use the proven install script for updates
const installScriptPath = path.join(__dirname, '..', 'scripts', 'install-pulse.sh');
// Check if install script exists
try {
await fs.access(installScriptPath);
} catch (error) {
throw new Error('Install script not found. Please update manually using the install script.');
}
console.log(`[UpdateManager] Running install script update to ${targetVersion}...`);
// Validate version parameter to prevent injection attacks
if (targetVersion !== 'latest' && !/^v[\d\.\-\w]+$/.test(targetVersion)) {
throw new Error(`Invalid version format: ${targetVersion}. Expected format like v3.21.0`);
}
if (progressCallback) {
progressCallback({ phase: 'downloading', progress: 30 });
}
// Execute the install script with real-time output parsing
const updateProcess = spawn('sudo', ['bash', installScriptPath, '--update', ...(targetVersion !== 'latest' ? ['--version', targetVersion] : [])], {
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, DEBIAN_FRONTEND: 'noninteractive' }
});
let output = '';
let hasError = false;
// Parse output for progress updates
updateProcess.stdout.on('data', (data) => {
const text = data.toString();
output += text;
console.log('[UpdateManager] Install script:', text.trim());
// Parse progress from install script output
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 });
console.log('[UpdateManager] Extracting update tarball...');
await execAsync(`tar -xzf ${updateFile} -C ${tempExtractDir}`);
// Find the extracted directory (should be pulse-vX.Y.Z)
const extractedFiles = await fs.readdir(tempExtractDir);
const extractedDirName = extractedFiles.find(file => file.startsWith('pulse-v'));
if (!extractedDirName) {
throw new Error('Invalid update package: pulse directory not found');
}
const extractedPulsePath = path.join(tempExtractDir, extractedDirName);
// Verify essential files exist
const requiredFiles = ['package.json', 'server/index.js', 'node_modules'];
for (const file of requiredFiles) {
const filePath = path.join(extractedPulsePath, file);
try {
await fs.access(filePath);
} catch (error) {
throw new Error(`Invalid update package: missing ${file}`);
}
}
console.log('[UpdateManager] Update package validated successfully');
if (progressCallback) {
progressCallback({ phase: 'extract', progress: 100 });
}
const currentPulseDir = path.join(__dirname, '..');
if (progressCallback) {
progressCallback({ phase: 'apply', progress: 50 });
}
// Apply update files
console.log('[UpdateManager] Applying update files...');
// List files to update from the extracted pulse directory (exclude config and data)
const updateFiles = await fs.readdir(extractedPulsePath);
for (const file of updateFiles) {
if (file === '.env' || file === 'data') continue;
const sourcePath = path.join(extractedPulsePath, file);
const destPath = path.join(currentPulseDir, file);
// Remove existing file/directory
try {
await fs.rm(destPath, { recursive: true, force: true });
} catch (e) {
// Ignore errors
}
// Copy new file/directory using Node.js fs
const stat = await fs.stat(sourcePath);
if (stat.isDirectory()) {
await this.copyDirectory(sourcePath, destPath);
} else {
await fs.copyFile(sourcePath, destPath);
}
}
// No need to install dependencies - the release tarball already includes node_modules
console.log('[UpdateManager] Release tarball already includes dependencies, skipping npm install...');
if (progressCallback) {
progressCallback({ phase: 'apply', progress: 100 });
}
// Schedule restart
console.log('[UpdateManager] Scheduling restart...');
setTimeout(async () => {
console.log('[UpdateManager] Attempting restart...');
// In test mode or development, just exit (user needs to restart manually)
if (process.env.UPDATE_TEST_MODE === 'true' || process.env.NODE_ENV === 'development') {
console.log('[UpdateManager] Test/Dev mode: Please restart the server manually');
console.log('[UpdateManager] Exiting in 3 seconds...');
setTimeout(() => {
process.exit(0);
}, 3000);
return;
}
// For production deployments, try various restart methods
try {
// Try systemctl first (Linux with systemd)
await execAsync('sudo systemctl restart pulse');
console.log('[UpdateManager] Restarted via systemctl');
} catch (error) {
try {
// Try pm2 restart
await execAsync('pm2 restart pulse');
console.log('[UpdateManager] Restarted via pm2');
} catch (error) {
// Last resort: exit and hope something restarts us
console.log('[UpdateManager] No restart mechanism found, exiting...');
process.exit(0);
if (text.includes('Downloading')) {
progressCallback({ phase: 'downloading', progress: 40 });
} else if (text.includes('Extracting')) {
progressCallback({ phase: 'extracting', progress: 60 });
} else if (text.includes('Backing up')) {
progressCallback({ phase: 'backup', progress: 70 });
} else if (text.includes('dependencies')) {
progressCallback({ phase: 'dependencies', progress: 80 });
} else if (text.includes('Service started') || text.includes('complete')) {
progressCallback({ phase: 'finishing', progress: 95 });
}
}
}, 2000);
});
// Cleanup
await fs.rm(path.join(__dirname, '..', 'temp'), { recursive: true, force: true });
updateProcess.stderr.on('data', (data) => {
const text = data.toString();
console.error('[UpdateManager] Install script error:', text.trim());
if (!text.includes('Warning:') && !text.includes('WARN:')) {
hasError = true;
}
});
// Wait for install script to complete with timeout
const exitCode = await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
updateProcess.kill('SIGTERM');
reject(new Error('Install script timeout after 10 minutes'));
}, 600000); // 10 minutes timeout
updateProcess.on('close', (code) => {
clearTimeout(timeout);
resolve(code);
});
updateProcess.on('error', (error) => {
clearTimeout(timeout);
reject(error);
});
});
if (exitCode !== 0 || hasError) {
throw new Error(`Install script failed with exit code ${exitCode}. Output: ${output}`);
}
if (progressCallback) {
progressCallback({ phase: 'complete', progress: 100 });
}
console.log('[UpdateManager] Update completed successfully via install script');
// The install script handles restart automatically
console.log('[UpdateManager] Install script will handle service restart');
// Cleanup temp file before process terminates
try {
await fs.unlink(updateFile);
console.log('[UpdateManager] Cleaned up temporary update file');
} catch (cleanupError) {
console.warn('[UpdateManager] Could not cleanup temp file:', cleanupError.message);
}
// Note: The install script will restart the service, so this process will be terminated
// Reset flag before process terminates (good practice)
this.updateInProgress = false;
return {
success: true,
@@ -458,6 +463,15 @@ class UpdateManager {
} catch (error) {
console.error('[UpdateManager] Error applying update:', error.message);
// Cleanup temp file on failure
try {
await fs.unlink(updateFile);
console.log('[UpdateManager] Cleaned up temporary update file after failure');
} catch (cleanupError) {
console.warn('[UpdateManager] Could not cleanup temp file after failure:', cleanupError.message);
}
this.updateInProgress = false;
throw new Error(`Failed to apply update: ${error.message}`);
}
+538 -34
View File
@@ -5,6 +5,8 @@ PulseApp.ui.settings = (() => {
let isInitialized = false;
let activeTab = 'proxmox';
let latestReleaseData = null; // Store the latest release data
let updateCache = new Map(); // Cache update check results to reduce API calls
let updateCheckTimeout = null; // Debounce rapid channel changes
function init() {
if (isInitialized) return;
@@ -740,10 +742,7 @@ PulseApp.ui.settings = (() => {
</option>
</select>
<div id="update-channel-description" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<div class="space-y-1">
<div><strong>Stable:</strong> Thoroughly tested releases for production use</div>
<div><strong>RC:</strong> Pre-release versions for testing - may contain bugs</div>
</div>
<strong>Stable:</strong> Thoroughly tested releases for production use
</div>
<div id="rc-warning" class="mt-3 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg hidden">
<div class="flex items-start gap-2">
@@ -808,9 +807,28 @@ PulseApp.ui.settings = (() => {
Current Version: <span id="current-version" class="font-mono font-semibold">${currentConfig.version || 'Unknown'}</span>
</p>
<p class="text-sm text-gray-700 dark:text-gray-300 mt-1">
Latest Version: <span id="latest-version" class="font-mono font-semibold text-gray-500 dark:text-gray-400">Checking...</span>
<span id="latest-version-label">Latest Version</span>: <span id="latest-version" class="font-mono font-semibold text-gray-500 dark:text-gray-400">Checking...</span>
</p>
<p id="update-channel-info" class="text-sm text-gray-500 dark:text-gray-400 mt-1"></p>
<div id="channel-mismatch-warning" class="hidden mt-3 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div class="flex items-start gap-2">
<svg class="w-4 h-4 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
</svg>
<div>
<h5 class="text-sm font-semibold text-blue-800 dark:text-blue-200">Channel Recommendation</h5>
<p id="channel-mismatch-message" class="text-sm text-blue-700 dark:text-blue-300 mt-1"></p>
<div class="mt-2 space-x-3">
<button type="button" id="switch-to-stable-btn" class="text-sm text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-200 underline font-medium" onclick="PulseApp.ui.settings.proceedWithStableSwitch()">
Switch to stable release
</button>
<button type="button" id="cancel-switch-btn" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 underline" onclick="PulseApp.ui.settings.switchToChannel('rc')">
Cancel (stay on RC)
</button>
</div>
</div>
</div>
</div>
<p id="version-status" class="text-sm mt-1"></p>
</div>
<button type="button" onclick="PulseApp.ui.settings.checkForUpdates()"
@@ -828,16 +846,50 @@ PulseApp.ui.settings = (() => {
<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 class="flex items-center justify-between mb-3">
<h4 class="text-sm font-semibold text-blue-800 dark:text-blue-200">
Update Available: <span id="update-version"></span>
</h4>
<span id="update-published-badge" class="text-xs text-blue-600 dark:text-blue-400 bg-blue-100 dark:bg-blue-800 px-2 py-1 rounded"></span>
</div>
<!-- Compact Changes Summary -->
<div id="changes-summary" class="mb-3">
<div id="changes-loading" class="flex items-center gap-2 text-gray-500 dark:text-gray-400 text-sm">
<svg class="w-4 h-4 animate-spin" fill="none" 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>
Loading changes...
</div>
<div id="changes-summary-text" class="hidden text-sm text-gray-700 dark:text-gray-300"></div>
<div id="changes-error" class="hidden text-red-600 dark:text-red-400 text-sm"></div>
</div>
<!-- Expandable Details -->
<div class="border-t border-blue-200 dark:border-blue-700 pt-3">
<button type="button" id="toggle-details-btn" class="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200 flex items-center gap-1">
<span>Show details</span>
<svg id="details-chevron" class="w-4 h-4 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div id="update-details-expanded" class="hidden mt-3 space-y-3">
<div id="detailed-changes" class="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded p-3">
<h5 class="text-sm font-medium text-gray-800 dark:text-gray-200 mb-2">Commit Details</h5>
<div id="changes-list" class="space-y-2 max-h-64 overflow-y-auto"></div>
</div>
<div id="release-notes-section" class="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded p-3">
<h5 class="text-sm font-medium text-gray-800 dark:text-gray-200 mb-2">Release Notes</h5>
<div id="update-release-notes" class="text-sm text-gray-700 dark:text-gray-300 prose prose-sm max-w-none max-h-48 overflow-y-auto"></div>
</div>
</div>
</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>
<div class="flex justify-end">
<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">
@@ -1380,10 +1432,13 @@ PulseApp.ui.settings = (() => {
}
// Check for latest version from GitHub releases
async function checkLatestVersion() {
// @param {string} channelOverride - Optional channel to check instead of saved config
async function checkLatestVersion(channelOverride = null) {
const latestVersionElement = document.getElementById('latest-version');
const latestVersionLabelElement = document.getElementById('latest-version-label');
const versionStatusElement = document.getElementById('version-status');
const updateChannelInfoElement = document.getElementById('update-channel-info');
const channelMismatchWarning = document.getElementById('channel-mismatch-warning');
if (!latestVersionElement) return;
@@ -1395,13 +1450,47 @@ PulseApp.ui.settings = (() => {
updateChannelInfoElement.textContent = '';
}
// Use the server's update check API to get proper update info
const response = await fetch('/api/updates/check');
const data = await response.json();
if (channelMismatchWarning) {
channelMismatchWarning.classList.add('hidden');
}
if (response.ok && data.latestVersion) {
// Check cache first to reduce API calls
const cacheKey = channelOverride || 'default';
const cacheExpiry = 5 * 60 * 1000; // 5 minutes
const cachedResult = updateCache.get(cacheKey);
let data;
if (cachedResult && (Date.now() - cachedResult.timestamp) < cacheExpiry) {
console.log(`[Settings] Using cached result for channel: ${cacheKey}`);
data = cachedResult.data;
} else {
// Use the server's update check API with optional channel override
const url = channelOverride ? `/api/updates/check?channel=${channelOverride}` : '/api/updates/check';
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Server error: ${response.status} ${response.statusText}`);
}
data = await response.json();
// Cache the result
updateCache.set(cacheKey, {
data: data,
timestamp: Date.now()
});
}
// Add preview indicator if using channel override
const isPreview = !!channelOverride;
if (data && data.latestVersion) {
const latestVersion = data.latestVersion;
const currentVersion = data.currentVersion || currentConfig.version || 'Unknown';
// Parse channel from descriptive text (e.g., "RC releases only" -> "rc")
const rawChannel = data.updateChannel || 'stable';
const rawChannelLower = rawChannel.toLowerCase();
const updateChannel = (rawChannelLower.includes('rc') || rawChannelLower.includes('release candidate') || rawChannelLower.includes('alpha') || rawChannelLower.includes('beta')) ? 'rc' : 'stable';
latestVersionElement.textContent = latestVersion;
@@ -1410,15 +1499,60 @@ PulseApp.ui.settings = (() => {
currentConfig.version = data.currentVersion;
}
// Display update channel information
if (updateChannelInfoElement && data.updateChannel) {
updateChannelInfoElement.textContent = `Update channel: ${data.updateChannel}`;
// Update the label to be channel-specific
if (latestVersionLabelElement) {
if (updateChannel === 'rc') {
latestVersionLabelElement.textContent = 'Latest RC Version';
} else {
latestVersionLabelElement.textContent = 'Latest Stable Version';
}
}
if (data.updateAvailable) {
// Update available
// Display update channel information with more context
if (updateChannelInfoElement) {
const channelName = updateChannel === 'rc' ? 'Release Candidate (RC)' : 'Stable';
const previewText = isPreview ? ' (Preview)' : '';
updateChannelInfoElement.textContent = `Update channel: ${channelName}${previewText}`;
// Add preview styling
if (isPreview) {
updateChannelInfoElement.classList.add('text-blue-600', 'dark:text-blue-400', 'font-medium');
} else {
updateChannelInfoElement.classList.remove('text-blue-600', 'dark:text-blue-400', 'font-medium');
}
}
// Check for channel mismatch and show recommendations
const currentVersionLower = currentVersion.toLowerCase();
const isCurrentRC = currentVersionLower.includes('-rc') || currentVersionLower.includes('-alpha') || currentVersionLower.includes('-beta');
const shouldShowRecommendation = (!isCurrentRC && updateChannel === 'rc');
console.log('Channel debug:', { currentVersion, updateChannel, isCurrentRC, shouldShowRecommendation, serverResponse: data });
if (shouldShowRecommendation && channelMismatchWarning) {
const messageElement = document.getElementById('channel-mismatch-message');
if (messageElement) {
messageElement.textContent = `You're running a stable version (${currentVersion}) but checking for RC releases. Consider switching to the stable channel for production use.`;
}
channelMismatchWarning.classList.remove('hidden');
}
// Check if this is a "downgrade" scenario (RC to stable)
const isDowngradeToStable = isCurrentRC && updateChannel === 'stable' &&
currentVersion !== latestVersion;
if (data.updateAvailable || isDowngradeToStable) {
// Update available (or downgrade to stable)
latestVersionElement.className = 'font-mono font-semibold text-green-600 dark:text-green-400';
versionStatusElement.innerHTML = '<span class="text-green-600 dark:text-green-400">📦 Update available!</span>';
let updateText;
if (isDowngradeToStable) {
updateText = '📦 Switch to stable release available';
} else {
updateText = updateChannel === 'rc' ? '📦 RC Update available!' : '📦 Update available!';
}
versionStatusElement.innerHTML = `<span class="text-green-600 dark:text-green-400">${updateText}</span>`;
// Convert server response to match GitHub API format for showUpdateDetails
const releaseData = {
@@ -1438,18 +1572,65 @@ PulseApp.ui.settings = (() => {
versionStatusElement.innerHTML += '<br><span class="text-amber-600 dark:text-amber-400 text-xs">Note: Docker deployments require manual update</span>';
}
} else {
// Up to date
// Up to date - hide any update details
hideUpdateDetails();
latestVersionElement.className = 'font-mono font-semibold text-gray-700 dark:text-gray-300';
versionStatusElement.innerHTML = '<span class="text-green-600 dark:text-green-400">✅ Up to date</span>';
// Check if we should show "up to date" or "no updates" for RC on stable
if (isCurrentRC && updateChannel === 'stable' && currentVersion === latestVersion) {
// Same version on both channels
const upToDateText = '✅ Up to date (same as stable)';
versionStatusElement.innerHTML = `<span class="text-green-600 dark:text-green-400">${upToDateText}</span>`;
} else if (isCurrentRC && updateChannel === 'stable') {
// RC version is different from stable - should have been caught above as "downgrade"
const upToDateText = '⚠️ No newer stable (running RC)';
versionStatusElement.innerHTML = `<span class="text-amber-600 dark:text-amber-400">${upToDateText}</span>`;
} else {
const upToDateText = updateChannel === 'rc' ? '✅ Up to date (RC channel)' : '✅ Up to date';
versionStatusElement.innerHTML = `<span class="text-green-600 dark:text-green-400">${upToDateText}</span>`;
}
}
} else {
throw new Error('Failed to fetch release data');
throw new Error('Invalid response data - missing version information');
}
} catch (error) {
console.error('Error checking for updates:', error);
// Hide update details on error
hideUpdateDetails();
latestVersionElement.textContent = 'Error';
latestVersionElement.className = 'font-mono font-semibold text-red-500';
versionStatusElement.innerHTML = '<span class="text-red-500">Failed to check for updates</span>';
// Provide more specific error messages
let errorMessage = 'Failed to check for updates';
if (error.message.includes('500')) {
errorMessage = 'Server error - please try again later';
} else if (error.message.includes('403') || error.message.includes('429')) {
errorMessage = 'Rate limited - please wait a moment';
} else if (error.message.includes('Failed to fetch')) {
errorMessage = 'Network error - check connection';
}
versionStatusElement.innerHTML = `<span class="text-red-500">${errorMessage}</span>`;
// If it's a rate limit issue, suggest using cached data if available
if (error.message.includes('403') || error.message.includes('429')) {
const cacheKey = channelOverride || 'default';
const staleCache = updateCache.get(cacheKey);
if (staleCache) {
console.log('[Settings] Using stale cache due to rate limiting');
// Recursively call with cached data
setTimeout(() => {
const cachedData = staleCache.data;
// Process cached data (simplified version)
latestVersionElement.textContent = cachedData.latestVersion || 'Unknown';
latestVersionElement.className = 'font-mono font-semibold text-gray-700 dark:text-gray-300';
versionStatusElement.innerHTML = '<span class="text-amber-600 dark:text-amber-400">⚠️ Cached data (rate limited)</span>';
}, 100);
}
}
}
}
@@ -1478,13 +1659,18 @@ PulseApp.ui.settings = (() => {
latestReleaseData = releaseData;
const updateVersion = document.getElementById('update-version');
const updatePublishedBadge = document.getElementById('update-published-badge');
const updateReleaseNotes = document.getElementById('update-release-notes');
const updatePublished = document.getElementById('update-published');
if (updateVersion) {
updateVersion.textContent = releaseData.tag_name;
}
if (updatePublishedBadge && releaseData.published_at) {
const publishedDate = new Date(releaseData.published_at).toLocaleDateString();
updatePublishedBadge.textContent = publishedDate;
}
if (updateReleaseNotes && releaseData.body) {
// Convert markdown to basic HTML (simple implementation)
const htmlContent = releaseData.body
@@ -1498,12 +1684,252 @@ PulseApp.ui.settings = (() => {
updateReleaseNotes.innerHTML = htmlContent;
}
if (updatePublished && releaseData.published_at) {
const publishedDate = new Date(releaseData.published_at).toLocaleDateString();
updatePublished.textContent = publishedDate;
}
// Set up toggle functionality
setupDetailsToggle();
updateDetails.classList.remove('hidden');
// Load commit differences for summary
loadVersionChanges(releaseData.tag_name);
}
// Set up the toggle functionality for details expansion
function setupDetailsToggle() {
const toggleBtn = document.getElementById('toggle-details-btn');
const detailsExpanded = document.getElementById('update-details-expanded');
const chevron = document.getElementById('details-chevron');
if (toggleBtn && detailsExpanded && chevron) {
toggleBtn.onclick = () => {
const isExpanded = !detailsExpanded.classList.contains('hidden');
if (isExpanded) {
detailsExpanded.classList.add('hidden');
chevron.style.transform = 'rotate(0deg)';
toggleBtn.querySelector('span').textContent = 'Show details';
} else {
detailsExpanded.classList.remove('hidden');
chevron.style.transform = 'rotate(180deg)';
toggleBtn.querySelector('span').textContent = 'Hide details';
}
};
}
}
// Load and display commit differences between versions
async function loadVersionChanges(targetVersion) {
const currentVersion = currentConfig.version || 'Unknown';
const changesLoading = document.getElementById('changes-loading');
const changesSummaryText = document.getElementById('changes-summary-text');
const changesList = document.getElementById('changes-list');
const changesError = document.getElementById('changes-error');
if (currentVersion === 'Unknown') return;
// Determine if we're showing changes between RC and stable or between versions
const currentVersionLower = currentVersion.toLowerCase();
const isCurrentRC = currentVersionLower.includes('-rc') || currentVersionLower.includes('-alpha') || currentVersionLower.includes('-beta');
const isTargetRC = targetVersion.toLowerCase().includes('-rc') || targetVersion.toLowerCase().includes('-alpha') || targetVersion.toLowerCase().includes('-beta');
// Show loading state
changesLoading.classList.remove('hidden');
changesSummaryText.classList.add('hidden');
changesError.classList.add('hidden');
try {
// GitHub API to compare between versions
const baseVersion = isCurrentRC && !isTargetRC ? targetVersion : currentVersion;
const headVersion = isCurrentRC && !isTargetRC ? currentVersion : targetVersion;
// Strip any existing 'v' prefix to avoid double prefixes
const cleanBaseVersion = baseVersion.replace(/^v/, '');
const cleanHeadVersion = headVersion.replace(/^v/, '');
const compareUrl = `https://api.github.com/repos/rcourtman/Pulse/compare/v${cleanBaseVersion}...v${cleanHeadVersion}`;
console.log(`[Settings] Fetching version comparison: ${compareUrl}`);
const response = await fetch(compareUrl);
if (!response.ok) {
if (response.status === 404) {
throw new Error(`Version comparison not found. One of the versions (${cleanBaseVersion} or ${cleanHeadVersion}) may not exist.`);
} else if (response.status === 403) {
throw new Error('GitHub API rate limit exceeded. Please try again later.');
} else {
throw new Error(`Failed to fetch version comparison: ${response.status} ${response.statusText}`);
}
}
const compareData = await response.json();
// Hide loading and show results
changesLoading.classList.add('hidden');
if (compareData.commits && compareData.commits.length > 0) {
// Create compact summary
createChangesSummary(compareData.commits, isCurrentRC && !isTargetRC);
changesSummaryText.classList.remove('hidden');
// Populate detailed view for expandable section
displayCommitChanges(compareData.commits, isCurrentRC && !isTargetRC);
} else {
changesError.textContent = 'No commits found between these versions.';
changesError.classList.remove('hidden');
}
} catch (error) {
console.error('[Settings] Error fetching version changes:', error);
changesLoading.classList.add('hidden');
changesError.textContent = `Could not load changes: ${error.message}`;
changesError.classList.remove('hidden');
}
}
// Create a compact summary of changes for the main view
function createChangesSummary(commits, isRollback = false) {
const changesSummaryText = document.getElementById('changes-summary-text');
if (!changesSummaryText) return;
// Categorize commits
const categories = {
features: commits.filter(c => c.commit.message.toLowerCase().startsWith('feat')),
fixes: commits.filter(c => c.commit.message.toLowerCase().startsWith('fix')),
chores: commits.filter(c => c.commit.message.toLowerCase().startsWith('chore')),
docs: commits.filter(c => c.commit.message.toLowerCase().startsWith('docs'))
};
const totalCommits = commits.length;
const summaryParts = [];
if (categories.features.length > 0) {
summaryParts.push(`<span class="text-green-600 dark:text-green-400">✨ ${categories.features.length} new feature${categories.features.length === 1 ? '' : 's'}</span>`);
}
if (categories.fixes.length > 0) {
summaryParts.push(`<span class="text-red-600 dark:text-red-400">🐛 ${categories.fixes.length} bug fix${categories.fixes.length === 1 ? '' : 'es'}</span>`);
}
if (categories.chores.length > 0) {
summaryParts.push(`<span class="text-gray-600 dark:text-gray-400">🔧 ${categories.chores.length} maintenance</span>`);
}
if (categories.docs.length > 0) {
summaryParts.push(`<span class="text-purple-600 dark:text-purple-400">📚 ${categories.docs.length} documentation</span>`);
}
let summaryHtml = '';
if (isRollback) {
summaryHtml = `<span class="text-amber-600 dark:text-amber-400">⚠️ Switching to stable will remove ${totalCommits} changes:</span>`;
} else {
summaryHtml = `<span class="text-green-600 dark:text-green-400">✅ This update includes ${totalCommits} changes:</span>`;
}
if (summaryParts.length > 0) {
summaryHtml += ` ${summaryParts.join(', ')}`;
}
changesSummaryText.innerHTML = summaryHtml;
}
// Display commit changes in a nice format
function displayCommitChanges(commits, isRollback = false) {
const changesList = document.getElementById('changes-list');
if (!changesList) return;
// Limit to most recent 15 commits to avoid overwhelming the UI
const limitedCommits = commits.slice(0, 15);
const hasMore = commits.length > 15;
const commitsHtml = limitedCommits.map(commit => {
const shortSha = commit.sha.substring(0, 7);
const commitUrl = commit.html_url;
const message = commit.commit.message.split('\n')[0]; // First line only
const author = commit.commit.author.name;
const date = new Date(commit.commit.author.date).toLocaleDateString();
// Simple commit type detection
let icon = '📝';
let iconClass = 'text-blue-600 dark:text-blue-400';
if (message.toLowerCase().startsWith('feat')) {
icon = '✨';
iconClass = 'text-green-600 dark:text-green-400';
} else if (message.toLowerCase().startsWith('fix')) {
icon = '🐛';
iconClass = 'text-red-600 dark:text-red-400';
} else if (message.toLowerCase().startsWith('chore')) {
icon = '🔧';
iconClass = 'text-gray-600 dark:text-gray-400';
} else if (message.toLowerCase().startsWith('docs')) {
icon = '📚';
iconClass = 'text-purple-600 dark:text-purple-400';
}
return `
<div class="flex items-start gap-3 p-2 rounded border border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700">
<span class="${iconClass} text-lg">${icon}</span>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">${message}</p>
<div class="flex items-center gap-2 mt-1 text-xs text-gray-500 dark:text-gray-400">
<a href="${commitUrl}" target="_blank" class="font-mono hover:text-blue-600 dark:hover:text-blue-400 hover:underline">${shortSha}</a>
<span>•</span>
<span>${author}</span>
<span>•</span>
<span>${date}</span>
</div>
</div>
</div>
`;
}).join('');
let summaryText = '';
if (isRollback) {
summaryText = `<p class="text-sm text-amber-600 dark:text-amber-400 mb-3">⚠️ Switching to stable will remove these ${limitedCommits.length} changes${hasMore ? ` (showing ${limitedCommits.length} of ${commits.length})` : ''}:</p>`;
} else {
summaryText = `<p class="text-sm text-green-600 dark:text-green-400 mb-3">✅ This update includes ${limitedCommits.length} new changes${hasMore ? ` (showing ${limitedCommits.length} of ${commits.length})` : ''}:</p>`;
}
changesList.innerHTML = summaryText + commitsHtml;
if (hasMore) {
changesList.innerHTML += `
<div class="text-center mt-3">
<a href="https://github.com/rcourtman/Pulse/compare/v${currentConfig.version}...v${latestReleaseData?.tag_name || 'latest'}"
target="_blank"
class="text-sm text-blue-600 dark:text-blue-400 hover:underline">
View all ${commits.length} changes on GitHub →
</a>
</div>
`;
}
}
// Hide update details card
function hideUpdateDetails() {
const updateDetails = document.getElementById('update-details');
if (updateDetails) {
updateDetails.classList.add('hidden');
}
// Reset expanded details state
const detailsExpanded = document.getElementById('update-details-expanded');
const chevron = document.getElementById('details-chevron');
const toggleBtn = document.getElementById('toggle-details-btn');
if (detailsExpanded) {
detailsExpanded.classList.add('hidden');
}
if (chevron) {
chevron.style.transform = 'rotate(0deg)';
}
if (toggleBtn) {
const span = toggleBtn.querySelector('span');
if (span) span.textContent = 'Show details';
}
// Clear the stored release data
latestReleaseData = null;
}
// Update management functions
@@ -2704,8 +3130,81 @@ PulseApp.ui.settings = (() => {
rcWarning.classList.add('hidden');
}
}
// Re-check for updates with the selected channel (preview mode)
// Debounce rapid changes to prevent API spam
if (updateCheckTimeout) {
clearTimeout(updateCheckTimeout);
}
updateCheckTimeout = setTimeout(() => {
checkLatestVersion(value);
}, 300);
}
// Switch to a specific update channel
function switchToChannel(targetChannel) {
const channelSelect = document.querySelector('select[name="UPDATE_CHANNEL"]');
if (channelSelect) {
channelSelect.value = targetChannel;
onUpdateChannelChange(targetChannel);
// Hide the warning
const warningElement = document.getElementById('channel-mismatch-warning');
if (warningElement) {
warningElement.classList.add('hidden');
}
// Show success message
const button = document.getElementById('switch-to-rc-btn');
if (button) {
const originalText = button.textContent;
button.textContent = 'Switched! ✓';
button.classList.add('text-green-600', 'dark:text-green-400');
button.classList.remove('text-blue-600', 'dark:text-blue-400');
setTimeout(() => {
if (warningElement) {
warningElement.classList.add('hidden');
}
}, 2000);
}
}
}
// Proceed with switching to stable release (downgrade)
function proceedWithStableSwitch() {
const warningElement = document.getElementById('channel-mismatch-warning');
if (warningElement) {
warningElement.classList.add('hidden');
}
// Show confirmation that they're choosing stable
const versionStatusElement = document.getElementById('version-status');
if (versionStatusElement) {
versionStatusElement.innerHTML = '<span class="text-blue-600 dark:text-blue-400">✓ Stable channel selected - save settings to apply</span>';
}
}
// Acknowledge the user wants to stay on stable channel (legacy)
function acknowledgeStableChoice() {
proceedWithStableSwitch();
}
// Legacy function for backward compatibility
function switchToRecommendedChannel() {
const currentVersion = currentConfig.version || 'Unknown';
const currentVersionLower = currentVersion.toLowerCase();
const isCurrentRC = currentVersionLower.includes('-rc') || currentVersionLower.includes('-alpha') || currentVersionLower.includes('-beta');
const recommendedChannel = isCurrentRC ? 'rc' : 'stable';
switchToChannel(recommendedChannel);
}
// Clear update cache (useful after saving settings)
function clearUpdateCache() {
updateCache.clear();
console.log('[Settings] Update cache cleared');
}
return {
init,
openModal,
@@ -2721,7 +3220,12 @@ PulseApp.ui.settings = (() => {
runDiagnostics,
copyDiagnosticReport,
downloadDiagnosticReport,
onUpdateChannelChange
onUpdateChannelChange,
switchToRecommendedChannel,
switchToChannel,
acknowledgeStableChoice,
proceedWithStableSwitch,
clearUpdateCache
};
})();
-19
View File
@@ -1,19 +0,0 @@
const express = require('express');
const app = express();
const port = 3001;
app.use(express.json());
app.post('/webhook', (req, res) => {
console.log('=== WEBHOOK RECEIVED ===');
console.log('Headers:', req.headers);
console.log('Body:', JSON.stringify(req.body, null, 2));
console.log('========================');
res.status(200).json({ received: true });
});
app.listen(port, () => {
console.log(`Test webhook server running at http://localhost:${port}/webhook`);
console.log('Configure Pulse to send webhooks to: http://localhost:3001/webhook');
});