mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-22 03:04:03 +00:00
feat: implement user-controlled update channels (stable/RC)
Add comprehensive update channel system allowing users to choose between: - Stable: Production releases only (default, safe) - RC: Release candidate versions for testing fixes and new features Key Features: - Safe defaults: All users start on stable channel unless explicitly changed - Clear UI warnings: RC selection shows warning about potential bugs - Robust validation: Invalid values auto-correct to stable with warnings - Security: URL validation prevents malicious downloads - Bulletproof error handling: Handles all edge cases gracefully Technical Implementation: - UPDATE_CHANNEL environment variable (stable/rc) - Enhanced UpdateManager with channel-specific GitHub API calls - Frontend UI with clear channel selection and warnings - Configuration persistence and validation - Comprehensive edge case handling 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+17
-8
@@ -48,6 +48,7 @@ class ConfigApi {
|
||||
advanced: {
|
||||
metricInterval: config.PULSE_METRIC_INTERVAL_MS,
|
||||
discoveryInterval: config.PULSE_DISCOVERY_INTERVAL_MS,
|
||||
updateChannel: config.UPDATE_CHANNEL || 'stable',
|
||||
alerts: {
|
||||
cpu: {
|
||||
enabled: config.ALERT_CPU_ENABLED !== 'false',
|
||||
@@ -271,6 +272,14 @@ class ConfigApi {
|
||||
// Update existing config with new values
|
||||
Object.entries(config).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== '') {
|
||||
// Special validation for UPDATE_CHANNEL
|
||||
if (key === 'UPDATE_CHANNEL') {
|
||||
const validChannels = ['stable', 'rc'];
|
||||
if (!validChannels.includes(value)) {
|
||||
console.warn(`WARN: Invalid UPDATE_CHANNEL value "${value}" in config. Skipping.`);
|
||||
return; // Skip this invalid value
|
||||
}
|
||||
}
|
||||
existingConfig[key] = value;
|
||||
}
|
||||
});
|
||||
@@ -595,8 +604,8 @@ class ConfigApi {
|
||||
if (keys.length > 0 && keys.some(key => config[key])) {
|
||||
lines.push(`# ${groupName}`);
|
||||
keys.forEach(key => {
|
||||
if (config[key] !== undefined && config[key] !== '') {
|
||||
const value = config[key];
|
||||
if (config[key] !== undefined && config[key] !== '' && config[key] !== null) {
|
||||
const value = String(config[key]); // Ensure value is a string
|
||||
const needsQuotes = value.includes(' ') || value.includes('#') || value.includes('=');
|
||||
lines.push(`${key}=${needsQuotes ? `"${value}"` : value}`);
|
||||
}
|
||||
@@ -621,8 +630,8 @@ class ConfigApi {
|
||||
`PROXMOX_ALLOW_SELF_SIGNED_CERTS_${index}`
|
||||
];
|
||||
orderedKeys.forEach(key => {
|
||||
if (config[key] !== undefined && config[key] !== '') {
|
||||
const value = config[key];
|
||||
if (config[key] !== undefined && config[key] !== '' && config[key] !== null) {
|
||||
const value = String(config[key]); // Ensure value is a string
|
||||
const needsQuotes = value.includes(' ') || value.includes('#') || value.includes('=');
|
||||
lines.push(`${key}=${needsQuotes ? `"${value}"` : value}`);
|
||||
}
|
||||
@@ -646,8 +655,8 @@ class ConfigApi {
|
||||
`PBS_ALLOW_SELF_SIGNED_CERTS_${index}`
|
||||
];
|
||||
orderedKeys.forEach(key => {
|
||||
if (config[key] !== undefined && config[key] !== '') {
|
||||
const value = config[key];
|
||||
if (config[key] !== undefined && config[key] !== '' && config[key] !== null) {
|
||||
const value = String(config[key]); // Ensure value is a string
|
||||
const needsQuotes = value.includes(' ') || value.includes('#') || value.includes('=');
|
||||
lines.push(`${key}=${needsQuotes ? `"${value}"` : value}`);
|
||||
}
|
||||
@@ -662,8 +671,8 @@ class ConfigApi {
|
||||
if (keys.length > 0 && keys.some(key => config[key])) {
|
||||
lines.push(`# ${groupName}`);
|
||||
keys.forEach(key => {
|
||||
if (config[key] !== undefined && config[key] !== '') {
|
||||
const value = config[key];
|
||||
if (config[key] !== undefined && config[key] !== '' && config[key] !== null) {
|
||||
const value = String(config[key]); // Ensure value is a string
|
||||
const needsQuotes = value.includes(' ') || value.includes('#') || value.includes('=');
|
||||
lines.push(`${key}=${needsQuotes ? `"${value}"` : value}`);
|
||||
}
|
||||
|
||||
+19
-2
@@ -32,6 +32,19 @@ class ConfigurationError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Function to get update channel preference
|
||||
function getUpdateChannelPreference() {
|
||||
const updateChannel = process.env.UPDATE_CHANNEL || 'stable';
|
||||
const validChannels = ['stable', 'rc'];
|
||||
|
||||
if (!validChannels.includes(updateChannel)) {
|
||||
console.warn(`WARN: Invalid UPDATE_CHANNEL value "${updateChannel}". Using default "stable".`);
|
||||
return 'stable';
|
||||
}
|
||||
|
||||
return updateChannel;
|
||||
}
|
||||
|
||||
// Function to load PBS configuration
|
||||
function loadPbsConfig(index = null) {
|
||||
const suffix = index ? `_${index}` : '';
|
||||
@@ -278,8 +291,12 @@ function loadConfiguration() {
|
||||
}
|
||||
|
||||
// console.log('INFO: Configuration loaded successfully.');
|
||||
|
||||
// Load update channel preference
|
||||
const updateChannel = getUpdateChannelPreference();
|
||||
|
||||
// Return the flag along with endpoints and pbsConfigs
|
||||
return { endpoints, pbsConfigs, isConfigPlaceholder };
|
||||
return { endpoints, pbsConfigs, isConfigPlaceholder, updateChannel };
|
||||
}
|
||||
|
||||
module.exports = { loadConfiguration, ConfigurationError }; // Export the function and error class
|
||||
module.exports = { loadConfiguration, getUpdateChannelPreference, ConfigurationError }; // Export the function and error class
|
||||
|
||||
+114
-11
@@ -4,6 +4,7 @@ const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const { exec } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const { getUpdateChannelPreference } = require('./configLoader');
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
class UpdateManager {
|
||||
@@ -13,6 +14,47 @@ class UpdateManager {
|
||||
this.updateInProgress = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current version is a release candidate
|
||||
*/
|
||||
isReleaseCandidate(version) {
|
||||
// Only use currentVersion as default if no argument is passed at all
|
||||
const versionToCheck = (arguments.length === 0) ? this.currentVersion : version;
|
||||
|
||||
if (!versionToCheck || typeof versionToCheck !== 'string') {
|
||||
return false;
|
||||
}
|
||||
return versionToCheck.includes('-rc') || versionToCheck.includes('-alpha') || versionToCheck.includes('-beta');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate download URL for security
|
||||
*/
|
||||
isValidDownloadUrl(downloadUrl) {
|
||||
if (!downloadUrl || typeof downloadUrl !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(downloadUrl);
|
||||
|
||||
// Allow test mode URLs
|
||||
if (process.env.UPDATE_TEST_MODE === 'true' &&
|
||||
url.hostname === 'localhost' &&
|
||||
url.pathname.includes('/api/test/mock-update.tar.gz')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Only allow HTTPS GitHub release asset URLs
|
||||
return url.protocol === 'https:' &&
|
||||
url.hostname === 'github.com' &&
|
||||
url.pathname.includes('/releases/download/') &&
|
||||
url.pathname.includes(`/${this.githubRepo}/`);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for available updates
|
||||
*/
|
||||
@@ -20,17 +62,72 @@ class UpdateManager {
|
||||
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 updateChannel = getUpdateChannelPreference();
|
||||
let response;
|
||||
let channelDescription = '';
|
||||
|
||||
if (updateChannel === 'stable') {
|
||||
// Stable channel: only check latest stable release
|
||||
channelDescription = 'stable releases only';
|
||||
console.log('[UpdateManager] Checking for stable releases...');
|
||||
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
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// RC channel: check all releases for RC versions
|
||||
channelDescription = 'RC releases only';
|
||||
console.log('[UpdateManager] Checking for RC releases...');
|
||||
response = await axios.get(
|
||||
`https://api.github.com/repos/${this.githubRepo}/releases?per_page=10`,
|
||||
{
|
||||
headers: {
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'Pulse-Update-Checker'
|
||||
},
|
||||
timeout: 10000
|
||||
}
|
||||
);
|
||||
|
||||
// Find the latest RC release that's newer than current
|
||||
let latestRelease = null;
|
||||
const releases = response.data;
|
||||
|
||||
for (const release of releases) {
|
||||
const releaseVersion = release.tag_name.replace('v', '');
|
||||
const releaseIsRC = this.isReleaseCandidate(releaseVersion);
|
||||
|
||||
if (releaseIsRC && semver.gt(releaseVersion, this.currentVersion)) {
|
||||
latestRelease = release;
|
||||
break;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!latestRelease) {
|
||||
// No newer RC version found
|
||||
const updateInfo = {
|
||||
currentVersion: this.currentVersion,
|
||||
latestVersion: this.currentVersion,
|
||||
updateAvailable: false,
|
||||
isDocker: this.isDockerEnvironment(),
|
||||
releaseNotes: 'No newer RC version available',
|
||||
releaseUrl: null,
|
||||
publishedAt: null,
|
||||
assets: [],
|
||||
updateChannel: channelDescription
|
||||
};
|
||||
console.log(`[UpdateManager] No RC updates available: ${this.currentVersion}`);
|
||||
return updateInfo;
|
||||
}
|
||||
|
||||
response.data = latestRelease;
|
||||
}
|
||||
|
||||
const latestVersion = response.data.tag_name.replace('v', '');
|
||||
const updateAvailable = semver.gt(latestVersion, this.currentVersion);
|
||||
@@ -43,6 +140,7 @@ class UpdateManager {
|
||||
releaseNotes: response.data.body || 'No release notes available',
|
||||
releaseUrl: response.data.html_url,
|
||||
publishedAt: response.data.published_at,
|
||||
updateChannel: channelDescription,
|
||||
assets: response.data.assets.map(asset => ({
|
||||
name: asset.name,
|
||||
size: asset.size,
|
||||
@@ -50,7 +148,7 @@ class UpdateManager {
|
||||
}))
|
||||
};
|
||||
|
||||
console.log(`[UpdateManager] Current version: ${this.currentVersion}, Latest version: ${latestVersion}, Docker: ${updateInfo.isDocker}`);
|
||||
console.log(`[UpdateManager] Current version: ${this.currentVersion}, Latest version: ${latestVersion}, Channel: ${channelDescription}, Docker: ${updateInfo.isDocker}`);
|
||||
return updateInfo;
|
||||
|
||||
} catch (error) {
|
||||
@@ -64,6 +162,11 @@ class UpdateManager {
|
||||
*/
|
||||
async downloadUpdate(downloadUrl, progressCallback) {
|
||||
try {
|
||||
// Validate download URL for security
|
||||
if (!this.isValidDownloadUrl(downloadUrl)) {
|
||||
throw new Error('Invalid download URL. Only GitHub release assets are allowed.');
|
||||
}
|
||||
|
||||
console.log('[UpdateManager] Downloading update from:', downloadUrl);
|
||||
|
||||
const tempDir = path.join(__dirname, '..', 'temp');
|
||||
|
||||
@@ -183,6 +183,13 @@ PulseApp.ui.settings = (() => {
|
||||
} else if (activeTab === 'system') {
|
||||
// Auto-check for latest version when system tab is opened
|
||||
checkLatestVersion();
|
||||
// Initialize update channel warning visibility
|
||||
setTimeout(() => {
|
||||
const channelSelect = document.querySelector('select[name="UPDATE_CHANNEL"]');
|
||||
if (channelSelect) {
|
||||
onUpdateChannelChange(channelSelect.value);
|
||||
}
|
||||
}, 0);
|
||||
} else if (activeTab === 'alerts') {
|
||||
// Load threshold configurations when alerts tab is opened
|
||||
loadThresholdConfigurations();
|
||||
@@ -716,6 +723,45 @@ PulseApp.ui.settings = (() => {
|
||||
<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>
|
||||
|
||||
<!-- Update Channel Preference -->
|
||||
<div class="mb-6 p-4 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<div class="mb-4">
|
||||
<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-2">Update Channel</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
Choose which types of updates to receive
|
||||
</p>
|
||||
<select name="UPDATE_CHANNEL" onchange="PulseApp.ui.settings.onUpdateChannelChange(this.value)"
|
||||
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="stable" ${(advanced.updateChannel || 'stable') === 'stable' ? 'selected' : ''}>
|
||||
Stable - Production releases (recommended)
|
||||
</option>
|
||||
<option value="rc" ${(advanced.updateChannel || 'stable') === 'rc' ? 'selected' : ''}>
|
||||
Release Candidate - Test fixes and new features
|
||||
</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>
|
||||
</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">
|
||||
<svg class="w-4 h-4 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<div>
|
||||
<h5 class="text-sm font-semibold text-amber-800 dark:text-amber-200">Release Candidate Warning</h5>
|
||||
<p class="text-sm text-amber-700 dark:text-amber-300 mt-1">
|
||||
RC versions are pre-release software for testing fixes and new features.
|
||||
They may contain bugs. Only select this if you want to help test and provide feedback.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto-update Setting -->
|
||||
<div class="mb-6 p-4 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -764,6 +810,7 @@ PulseApp.ui.settings = (() => {
|
||||
<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>
|
||||
</p>
|
||||
<p id="update-channel-info" class="text-sm text-gray-500 dark:text-gray-400 mt-1"></p>
|
||||
<p id="version-status" class="text-sm mt-1"></p>
|
||||
</div>
|
||||
<button type="button" onclick="PulseApp.ui.settings.checkForUpdates()"
|
||||
@@ -1336,6 +1383,7 @@ PulseApp.ui.settings = (() => {
|
||||
async function checkLatestVersion() {
|
||||
const latestVersionElement = document.getElementById('latest-version');
|
||||
const versionStatusElement = document.getElementById('version-status');
|
||||
const updateChannelInfoElement = document.getElementById('update-channel-info');
|
||||
|
||||
if (!latestVersionElement) return;
|
||||
|
||||
@@ -1343,6 +1391,10 @@ PulseApp.ui.settings = (() => {
|
||||
latestVersionElement.textContent = 'Checking...';
|
||||
latestVersionElement.className = 'font-mono font-semibold text-gray-500 dark:text-gray-400';
|
||||
|
||||
if (updateChannelInfoElement) {
|
||||
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();
|
||||
@@ -1358,6 +1410,11 @@ PulseApp.ui.settings = (() => {
|
||||
currentConfig.version = data.currentVersion;
|
||||
}
|
||||
|
||||
// Display update channel information
|
||||
if (updateChannelInfoElement && data.updateChannel) {
|
||||
updateChannelInfoElement.textContent = `Update channel: ${data.updateChannel}`;
|
||||
}
|
||||
|
||||
if (data.updateAvailable) {
|
||||
// Update available
|
||||
latestVersionElement.className = 'font-mono font-semibold text-green-600 dark:text-green-400';
|
||||
@@ -2637,6 +2694,18 @@ PulseApp.ui.settings = (() => {
|
||||
}
|
||||
|
||||
// Public API
|
||||
// Handle update channel selection change
|
||||
function onUpdateChannelChange(value) {
|
||||
const rcWarning = document.getElementById('rc-warning');
|
||||
if (rcWarning) {
|
||||
if (value === 'rc') {
|
||||
rcWarning.classList.remove('hidden');
|
||||
} else {
|
||||
rcWarning.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
init,
|
||||
openModal,
|
||||
@@ -2651,7 +2720,8 @@ PulseApp.ui.settings = (() => {
|
||||
changeTheme,
|
||||
runDiagnostics,
|
||||
copyDiagnosticReport,
|
||||
downloadDiagnosticReport
|
||||
downloadDiagnosticReport,
|
||||
onUpdateChannelChange
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user