diff --git a/server/diagnostics.js b/server/diagnostics.js
index c02ce6600..100e4f932 100644
--- a/server/diagnostics.js
+++ b/server/diagnostics.js
@@ -98,8 +98,8 @@ class DiagnosticTool {
console.error('Error generating recommendations:', e);
}
- // Sanitize sensitive data before returning
- return this.sanitizeReport(report);
+ // Return unsanitized report - sanitization will be done client-side for copy/download
+ return report;
}
sanitizeReport(report) {
@@ -232,7 +232,7 @@ class DiagnosticTool {
const permCheck = {
id: id,
name: clientObj.config?.name || id,
- host: this.sanitizeUrl(clientObj.config?.host),
+ host: clientObj.config?.host,
canConnect: false,
canListNodes: false,
canListVMs: false,
@@ -357,7 +357,7 @@ class DiagnosticTool {
const permCheck = {
id: id,
name: clientObj.config?.name || id,
- host: this.sanitizeUrl(clientObj.config?.host),
+ host: clientObj.config?.host,
node_name: clientObj.config?.nodeName || clientObj.config?.node_name || 'NOT SET',
canConnect: false,
canListDatastores: false,
@@ -445,7 +445,7 @@ class DiagnosticTool {
if (!id.startsWith('pbs_') && clientObj && clientObj.config) {
config.proxmox.push({
id: id,
- host: this.sanitizeUrl(clientObj.config.host),
+ host: clientObj.config.host,
name: clientObj.config.name || id,
port: clientObj.config.port || '8006',
tokenConfigured: !!clientObj.config.tokenId,
@@ -464,7 +464,7 @@ class DiagnosticTool {
const nodeName = clientObj.config.nodeName || clientObj.config.node_name;
config.pbs.push({
id: id,
- host: this.sanitizeUrl(clientObj.config.host),
+ host: clientObj.config.host,
name: clientObj.config.name || id,
port: clientObj.config.port || '8007',
node_name: nodeName || 'NOT SET',
diff --git a/server/index.js b/server/index.js
index 7276114e1..baa493e72 100644
--- a/server/index.js
+++ b/server/index.js
@@ -43,6 +43,7 @@ const cors = require('cors');
const compression = require('compression');
const { Server } = require('socket.io');
const { URL } = require('url'); // <--- ADD: Import URL constructor
+const axios = require('axios');
const axiosRetry = require('axios-retry').default; // Import axios-retry
// Development specific dependencies
@@ -351,17 +352,78 @@ app.delete('/api/alerts/rules/:id', (req, res) => {
}
});
-// Example API Route (Add your actual API routes here)
-app.get('/api/version', (req, res) => {
+// Version check functionality
+let latestVersionCache = null;
+let lastVersionCheck = 0;
+const VERSION_CHECK_INTERVAL = 6 * 60 * 60 * 1000; // 6 hours
+
+async function checkLatestVersion() {
+ const now = Date.now();
+
+ // Return cached version if still fresh
+ if (latestVersionCache && (now - lastVersionCheck) < VERSION_CHECK_INTERVAL) {
+ return latestVersionCache;
+ }
+
+ try {
+ const response = await axios.get('https://api.github.com/repos/rcourtman/Pulse/releases/latest', {
+ timeout: 5000,
+ headers: {
+ 'Accept': 'application/vnd.github.v3+json'
+ }
+ });
+
+ if (response.data && response.data.tag_name) {
+ // Remove 'v' prefix if present
+ const version = response.data.tag_name.replace(/^v/, '');
+ latestVersionCache = version;
+ lastVersionCheck = now;
+ return version;
+ }
+ } catch (error) {
+ console.error('Error checking latest version:', error.message);
+ }
+
+ return null;
+}
+
+// Version API endpoint
+app.get('/api/version', async (req, res) => {
try {
const packageJson = require('../package.json');
- res.json({ version: packageJson.version || 'N/A' });
+ const currentVersion = packageJson.version || 'N/A';
+
+ // Check for latest version
+ const latestVersion = await checkLatestVersion();
+
+ res.json({
+ version: currentVersion,
+ latestVersion: latestVersion,
+ updateAvailable: latestVersion && latestVersion !== currentVersion &&
+ compareVersions(latestVersion, currentVersion) > 0
+ });
} catch (error) {
- console.error("Error reading package.json for version:", error);
+ console.error("Error in version endpoint:", error);
res.status(500).json({ error: "Could not retrieve version" });
}
});
+// Simple version comparison function
+function compareVersions(v1, v2) {
+ const parts1 = v1.split('.').map(Number);
+ const parts2 = v2.split('.').map(Number);
+
+ for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
+ const part1 = parts1[i] || 0;
+ const part2 = parts2[i] || 0;
+
+ if (part1 > part2) return 1;
+ if (part1 < part2) return -1;
+ }
+
+ return 0;
+}
+
app.get('/api/storage', async (req, res) => {
try {
// Get current nodes from state manager
diff --git a/src/public/diagnostics.html b/src/public/diagnostics.html
index 1b9b55ed8..b45774b3c 100644
--- a/src/public/diagnostics.html
+++ b/src/public/diagnostics.html
@@ -254,20 +254,18 @@
let html = '';
- // Add sanitization notice if data is sanitized
- if (data._sanitized) {
- html += `
-
-
🛡️ Privacy Protected
-
- ${data._sanitized.notice}
-
-
- ✅ This report is safe to share in GitHub issues or support requests
-
-
- `;
- }
+ // Show notice about sensitive data
+ html += `
+
+
⚠️ Sensitive Data Visible
+
+ This page displays real hostnames, IPs, and other potentially sensitive information for diagnostic purposes.
+
+
+ 📋 When you copy or download this report, all sensitive data will be automatically sanitized for safe sharing.
+
+
+ `;
// Errors section (if any)
if (data.errors && data.errors.length > 0) {
@@ -557,10 +555,179 @@
return html;
}
+ function sanitizeUrl(url) {
+ if (!url) return url;
+
+ // Remove protocol if present
+ let sanitized = url.replace(/^https?:\/\//, '');
+
+ // Replace IP addresses
+ sanitized = sanitized.replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, '[IP-ADDRESS]');
+
+ // Replace hostnames (anything before port or path)
+ sanitized = sanitized.replace(/^[^:/]+/, '[HOSTNAME]');
+
+ // Replace ports
+ sanitized = sanitized.replace(/:\d+/, ':[PORT]');
+
+ return sanitized;
+ }
+
+ function sanitizeErrorMessage(errorMsg) {
+ if (!errorMsg) return errorMsg;
+
+ // Remove potential IP addresses, hostnames, and ports
+ let sanitized = errorMsg
+ .replace(/\b(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?\b/g, '[IP-ADDRESS]')
+ .replace(/https?:\/\/[^\/\s:]+(?::\d+)?/g, '[HOSTNAME]')
+ .replace(/([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}/g, '[HOSTNAME]')
+ .replace(/:\d{4,5}\b/g, ':[PORT]');
+
+ return sanitized;
+ }
+
+ function sanitizeRecommendationMessage(message) {
+ if (!message) return message;
+
+ // Replace specific hostnames and IPs in common recommendation patterns
+ let sanitized = message
+ .replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, '[IP-ADDRESS]')
+ .replace(/https?:\/\/[^\/\s:]+/g, '[HOSTNAME]')
+ .replace(/host\s*'[^']+'/g, "host '[HOSTNAME]'")
+ .replace(/host\s*"[^"]+"/g, 'host "[HOSTNAME]"')
+ .replace(/node\s+'[^']+'/g, "node '[NODE-NAME]'")
+ .replace(/node\s+"[^"]+"/g, 'node "[NODE-NAME]"')
+ .replace(/:\d{4,5}\b/g, ':[PORT]');
+
+ return sanitized;
+ }
+
+ function sanitizeReport(report) {
+ // Deep clone the report to avoid modifying the original
+ const sanitized = JSON.parse(JSON.stringify(report));
+
+ // Sanitize configuration section
+ if (sanitized.configuration) {
+ if (sanitized.configuration.proxmox) {
+ sanitized.configuration.proxmox = sanitized.configuration.proxmox.map(pve => ({
+ ...pve,
+ host: sanitizeUrl(pve.host),
+ // Remove potentially sensitive fields, keep only structure info
+ tokenConfigured: pve.tokenConfigured,
+ selfSignedCerts: pve.selfSignedCerts
+ }));
+ }
+
+ if (sanitized.configuration.pbs) {
+ sanitized.configuration.pbs = sanitized.configuration.pbs.map(pbs => ({
+ ...pbs,
+ host: sanitizeUrl(pbs.host),
+ // Remove potentially sensitive fields, keep only structure info
+ tokenConfigured: pbs.tokenConfigured,
+ selfSignedCerts: pbs.selfSignedCerts,
+ node_name: pbs.node_name
+ }));
+ }
+ }
+
+ // Sanitize permissions section
+ if (sanitized.permissions) {
+ if (sanitized.permissions.proxmox) {
+ sanitized.permissions.proxmox = sanitized.permissions.proxmox.map(perm => ({
+ ...perm,
+ host: sanitizeUrl(perm.host),
+ name: sanitizeUrl(perm.name),
+ // Keep diagnostic info but sanitize error messages
+ errors: perm.errors ? perm.errors.map(err => sanitizeErrorMessage(err)) : []
+ }));
+ }
+
+ if (sanitized.permissions.pbs) {
+ sanitized.permissions.pbs = sanitized.permissions.pbs.map(perm => ({
+ ...perm,
+ host: sanitizeUrl(perm.host),
+ name: sanitizeUrl(perm.name),
+ // Keep diagnostic info but sanitize error messages
+ errors: perm.errors ? perm.errors.map(err => sanitizeErrorMessage(err)) : []
+ }));
+ }
+ }
+
+ // Sanitize state section
+ if (sanitized.state) {
+ // Remove potentially sensitive node names, keep only counts and structure
+ if (sanitized.state.nodes && sanitized.state.nodes.names) {
+ sanitized.state.nodes.names = sanitized.state.nodes.names.map((name, index) => `node-${index + 1}`);
+ }
+
+ // Remove specific backup IDs, keep only counts
+ if (sanitized.state.pbs && sanitized.state.pbs.sampleBackupIds) {
+ sanitized.state.pbs.sampleBackupIds = sanitized.state.pbs.sampleBackupIds.map((id, index) => `backup-${index + 1}`);
+ }
+ }
+
+ // Sanitize recommendations
+ if (sanitized.recommendations) {
+ sanitized.recommendations = sanitized.recommendations.map(rec => ({
+ ...rec,
+ message: sanitizeRecommendationMessage(rec.message)
+ }));
+ }
+
+ // Sanitize connectivity section
+ if (sanitized.connectivity) {
+ if (sanitized.connectivity.proxmox) {
+ sanitized.connectivity.proxmox = sanitized.connectivity.proxmox.map(conn => ({
+ ...conn,
+ host: sanitizeUrl(conn.host),
+ error: conn.error ? sanitizeErrorMessage(conn.error) : conn.error
+ }));
+ }
+
+ if (sanitized.connectivity.pbs) {
+ sanitized.connectivity.pbs = sanitized.connectivity.pbs.map(conn => ({
+ ...conn,
+ host: sanitizeUrl(conn.host),
+ error: conn.error ? sanitizeErrorMessage(conn.error) : conn.error
+ }));
+ }
+ }
+
+ // Sanitize dataFlow section
+ if (sanitized.dataFlow && sanitized.dataFlow.pbs && sanitized.dataFlow.pbs.backup_matching) {
+ sanitized.dataFlow.pbs.backup_matching = sanitized.dataFlow.pbs.backup_matching.map(instance => ({
+ ...instance,
+ sample_backups: instance.sample_backups ? instance.sample_backups.map((backup, idx) => ({
+ ...backup,
+ backup_id: `backup-${idx + 1}`
+ })) : []
+ }));
+ }
+
+ // Sanitize errors section
+ if (sanitized.errors) {
+ sanitized.errors = sanitized.errors.map(err => ({
+ ...err,
+ error: sanitizeErrorMessage(err.error)
+ }));
+ }
+
+ // Add notice about sanitization
+ sanitized._sanitized = {
+ notice: "This diagnostic report has been sanitized for safe sharing. Hostnames, IPs, node names, and backup IDs have been anonymized while preserving structural information needed for troubleshooting.",
+ timestamp: new Date().toISOString()
+ };
+
+ return sanitized;
+ }
+
function copyReport() {
if (!diagnosticData) return;
- const text = JSON.stringify(diagnosticData, null, 2);
+ // Sanitize the data before copying
+ const sanitizedData = sanitizeReport(diagnosticData);
+ const text = JSON.stringify(sanitizedData, null, 2);
+
navigator.clipboard.writeText(text).then(() => {
const button = document.getElementById('copyReport');
const originalText = button.textContent;
@@ -576,9 +743,11 @@
function downloadReport() {
if (!diagnosticData) return;
+ // Sanitize the data before downloading
+ const sanitizedData = sanitizeReport(diagnosticData);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
const filename = `pulse_diagnostics_${timestamp}.json`;
- const text = JSON.stringify(diagnosticData, null, 2);
+ const text = JSON.stringify(sanitizedData, null, 2);
const blob = new Blob([text], { type: 'application/json' });
const url = URL.createObjectURL(blob);
diff --git a/src/public/js/main.js b/src/public/js/main.js
index 6d1cc1887..e02d4b486 100644
--- a/src/public/js/main.js
+++ b/src/public/js/main.js
@@ -112,6 +112,40 @@ document.addEventListener('DOMContentLoaded', function() {
console.log('Version data received:', data);
if (data.version) {
versionSpan.textContent = data.version;
+
+ // Check if update is available
+ if (data.updateAvailable && data.latestVersion) {
+ // Check if update indicator already exists
+ const existingIndicator = document.getElementById('update-indicator');
+ if (!existingIndicator) {
+ // Create update indicator
+ const updateIndicator = document.createElement('span');
+ updateIndicator.id = 'update-indicator';
+ updateIndicator.className = 'ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
+ updateIndicator.innerHTML = `
+
+ v${data.latestVersion} available
+ `;
+ updateIndicator.title = 'Click to view the latest release';
+ updateIndicator.style.cursor = 'pointer';
+ updateIndicator.addEventListener('click', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ window.open('https://github.com/rcourtman/Pulse/releases/latest', '_blank');
+ });
+
+ // Insert after version link
+ versionSpan.parentNode.insertBefore(updateIndicator, versionSpan.nextSibling);
+ }
+ } else {
+ // Remove update indicator if no update available
+ const existingIndicator = document.getElementById('update-indicator');
+ if (existingIndicator) {
+ existingIndicator.remove();
+ }
+ }
} else {
versionSpan.textContent = 'unknown';
}
@@ -141,4 +175,10 @@ document.addEventListener('DOMContentLoaded', function() {
fetchVersion();
}
}, 2000);
+
+ // Periodically check for updates (every 6 hours)
+ setInterval(() => {
+ console.log('[Main] Checking for version updates...');
+ fetchVersion();
+ }, 6 * 60 * 60 * 1000);
});
diff --git a/src/public/js/ui/storage.js b/src/public/js/ui/storage.js
index 7f596f28b..7670a11a7 100644
--- a/src/public/js/ui/storage.js
+++ b/src/public/js/ui/storage.js
@@ -163,7 +163,7 @@ PulseApp.ui.storage = (() => {
const thead = document.createElement('thead');
thead.innerHTML = `
- | Storage |
+ Storage |
Content |
Type |
Shared |
@@ -231,7 +231,7 @@ PulseApp.ui.storage = (() => {
const contentBadges = getContentBadgesHTML(store.content);
row.innerHTML = `
- ${store.storage || 'N/A'} |
+ ${store.storage || 'N/A'} |
${contentBadges} |
${store.type || 'N/A'} |
${sharedIcon} |