mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 03:33:53 +00:00
feat: integrate diagnostics into settings menu
- Move diagnostics from standalone page to settings modal tab - Add Diagnostics tab to settings navigation - Port all diagnostic functionality to settings UI module - Update diagnostics button to open settings modal directly - Remove standalone diagnostics.html page - Preserve all features: report generation, sanitization, copy/download 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -98,6 +98,14 @@ class DiagnosticTool {
|
||||
console.error('Error generating recommendations:', e);
|
||||
}
|
||||
|
||||
// Add summary for UI
|
||||
report.summary = {
|
||||
hasIssues: report.recommendations.some(r => r.severity === 'critical' || r.severity === 'warning'),
|
||||
criticalIssues: report.recommendations.filter(r => r.severity === 'critical').length,
|
||||
warnings: report.recommendations.filter(r => r.severity === 'warning').length,
|
||||
isTimingIssue: report.state.loadTimeout || (report.state.serverUptime < 60 && (!report.state.guests || report.state.guests.total === 0))
|
||||
};
|
||||
|
||||
// Return unsanitized report - sanitization will be done client-side for copy/download
|
||||
return report;
|
||||
}
|
||||
|
||||
@@ -1,769 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Pulse Diagnostics</title>
|
||||
<link rel="stylesheet" href="/output.css">
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
line-height: 1.6;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
h1 {
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.subtitle {
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.status {
|
||||
padding: 10px 15px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status.loading {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
.status.success {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
.status.warning {
|
||||
background: #fff3e0;
|
||||
color: #f57c00;
|
||||
}
|
||||
.status.error {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
.section {
|
||||
margin-bottom: 30px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.section-header {
|
||||
background: #f5f5f5;
|
||||
padding: 15px 20px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.section-header:hover {
|
||||
background: #eeeeee;
|
||||
}
|
||||
.section-content {
|
||||
padding: 20px;
|
||||
display: none;
|
||||
}
|
||||
.section.expanded .section-content {
|
||||
display: block;
|
||||
}
|
||||
.indicator {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
.recommendation {
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 4px;
|
||||
border-left: 4px solid;
|
||||
}
|
||||
.recommendation.critical {
|
||||
background: #ffebee;
|
||||
border-color: #f44336;
|
||||
}
|
||||
.recommendation.warning {
|
||||
background: #fff3e0;
|
||||
border-color: #ff9800;
|
||||
}
|
||||
.recommendation.info {
|
||||
background: #e3f2fd;
|
||||
border-color: #2196f3;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
th {
|
||||
background: #f5f5f5;
|
||||
font-weight: 600;
|
||||
}
|
||||
.success-icon {
|
||||
color: #4caf50;
|
||||
}
|
||||
.error-icon {
|
||||
color: #f44336;
|
||||
}
|
||||
.warning-icon {
|
||||
color: #ff9800;
|
||||
}
|
||||
pre {
|
||||
background: #f5f5f5;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
font-size: 14px;
|
||||
}
|
||||
button {
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
button:hover {
|
||||
background: #1565c0;
|
||||
}
|
||||
button:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.actions {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.copy-button {
|
||||
background: #666;
|
||||
font-size: 14px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Pulse Diagnostics</h1>
|
||||
<p class="subtitle">Generate a comprehensive diagnostic report to help troubleshoot issues</p>
|
||||
|
||||
<div class="actions">
|
||||
<button id="runDiagnostics" onclick="runDiagnostics()">Run Diagnostics</button>
|
||||
<button id="copyReport" onclick="copyReport()" style="display: none;" title="Safe to share - all sensitive data has been sanitized">📋 Copy Safe Report</button>
|
||||
<button id="downloadReport" onclick="downloadReport()" style="display: none;" title="Safe to share - all sensitive data has been sanitized">💾 Download Safe Report</button>
|
||||
</div>
|
||||
|
||||
<div id="status" class="status" style="display: none;"></div>
|
||||
|
||||
<div id="results" style="display: none;"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let diagnosticData = null;
|
||||
|
||||
async function runDiagnostics() {
|
||||
const statusEl = document.getElementById('status');
|
||||
const resultsEl = document.getElementById('results');
|
||||
const runButton = document.getElementById('runDiagnostics');
|
||||
|
||||
// Reset UI
|
||||
statusEl.className = 'status loading';
|
||||
statusEl.textContent = 'Running diagnostics...';
|
||||
statusEl.style.display = 'block';
|
||||
resultsEl.style.display = 'none';
|
||||
runButton.disabled = true;
|
||||
|
||||
// Add a loading indicator after 2 seconds
|
||||
let secondsWaiting = 0;
|
||||
const progressInterval = setInterval(() => {
|
||||
if (statusEl.className === 'status loading') {
|
||||
secondsWaiting++;
|
||||
if (secondsWaiting > 2) {
|
||||
statusEl.innerHTML = `
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<div class="spinner" style="width: 20px; height: 20px; border: 3px solid #f3f3f3; border-top: 3px solid #1976d2; border-radius: 50%; animation: spin 1s linear infinite;"></div>
|
||||
<span>Waiting for data to load... ${secondsWaiting}s</span>
|
||||
</div>
|
||||
${secondsWaiting > 10 ? '<div style="font-size: 12px; margin-top: 5px; color: #666;">First run may take longer as initial data is being collected.</div>' : ''}
|
||||
`;
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Add spinner animation
|
||||
const style = document.createElement('style');
|
||||
style.textContent = '@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }';
|
||||
document.head.appendChild(style);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/diagnostics');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
diagnosticData = await response.json();
|
||||
|
||||
// Clear progress indicator
|
||||
clearInterval(progressInterval);
|
||||
|
||||
// Show success status
|
||||
statusEl.className = 'status success';
|
||||
if (diagnosticData.summary.isTimingIssue) {
|
||||
if (diagnosticData.state.dataAge === null) {
|
||||
statusEl.textContent = `Warning: No data loaded yet (server uptime: ${Math.floor(diagnosticData.state.serverUptime)}s). Waiting for first discovery cycle to complete...`;
|
||||
} else {
|
||||
statusEl.textContent = `Warning: Server recently started (${Math.floor(diagnosticData.state.serverUptime)}s ago). Data may still be loading.`;
|
||||
}
|
||||
statusEl.className = 'status warning';
|
||||
} else if (diagnosticData.summary.hasIssues) {
|
||||
statusEl.textContent = `Diagnostics complete. Found ${diagnosticData.summary.criticalIssues} critical issues and ${diagnosticData.summary.warnings} warnings.`;
|
||||
} else {
|
||||
statusEl.textContent = 'Diagnostics complete. No critical issues found!';
|
||||
}
|
||||
|
||||
// Display results
|
||||
displayResults(diagnosticData);
|
||||
resultsEl.style.display = 'block';
|
||||
|
||||
// Show action buttons
|
||||
document.getElementById('copyReport').style.display = 'inline-block';
|
||||
document.getElementById('downloadReport').style.display = 'inline-block';
|
||||
|
||||
} catch (error) {
|
||||
clearInterval(progressInterval);
|
||||
statusEl.className = 'status error';
|
||||
statusEl.textContent = `Error running diagnostics: ${error.message}`;
|
||||
} finally {
|
||||
runButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function displayResults(data) {
|
||||
const resultsEl = document.getElementById('results');
|
||||
|
||||
let html = '';
|
||||
|
||||
// Show notice about sensitive data
|
||||
html += `
|
||||
<div style="background: #fff3e0; border: 1px solid #ff9800; border-radius: 4px; padding: 15px; margin-bottom: 20px;">
|
||||
<h3 style="margin: 0 0 10px 0; color: #f57c00;">⚠️ Sensitive Data Visible</h3>
|
||||
<p style="margin: 0; font-size: 14px; color: #e65100;">
|
||||
This page displays real hostnames, IPs, and other potentially sensitive information for diagnostic purposes.
|
||||
</p>
|
||||
<p style="margin: 10px 0 0 0; font-size: 12px; color: #e65100; font-weight: bold;">
|
||||
📋 When you copy or download this report, all sensitive data will be automatically sanitized for safe sharing.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Errors section (if any)
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
html += createSection('Diagnostic Errors', renderErrors(data.errors), true);
|
||||
}
|
||||
|
||||
// Recommendations section
|
||||
if (data.recommendations && data.recommendations.length > 0) {
|
||||
html += createSection('Recommendations', renderRecommendations(data.recommendations), true);
|
||||
}
|
||||
|
||||
// Configuration section
|
||||
if (data.configuration) {
|
||||
html += createSection('Configuration', renderConfiguration(data.configuration));
|
||||
}
|
||||
|
||||
// Permissions section (only if available)
|
||||
if (data.permissions) {
|
||||
html += createSection('API Token Permissions', renderPermissions(data.permissions));
|
||||
}
|
||||
|
||||
// Connectivity section (only if available)
|
||||
if (data.connectivity) {
|
||||
html += createSection('Connectivity', renderConnectivity(data.connectivity));
|
||||
}
|
||||
|
||||
// Data Flow section (only if available)
|
||||
if (data.dataFlow) {
|
||||
html += createSection('Data Flow', renderDataFlow(data.dataFlow));
|
||||
}
|
||||
|
||||
// System Information section
|
||||
if (data.state || data.version) {
|
||||
html += createSection('System Information', renderSystemInfo(data));
|
||||
}
|
||||
|
||||
// Raw Data section
|
||||
html += createSection('Raw Diagnostic Data', `<pre>${JSON.stringify(data, null, 2)}</pre>`);
|
||||
|
||||
resultsEl.innerHTML = html;
|
||||
|
||||
// Add click handlers for sections
|
||||
document.querySelectorAll('.section-header').forEach(header => {
|
||||
header.addEventListener('click', () => {
|
||||
header.parentElement.classList.toggle('expanded');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createSection(title, content, expanded = false) {
|
||||
return `
|
||||
<div class="section ${expanded ? 'expanded' : ''}">
|
||||
<div class="section-header">
|
||||
<span>${title}</span>
|
||||
<span class="indicator">${expanded ? '▼' : '▶'}</span>
|
||||
</div>
|
||||
<div class="section-content">
|
||||
${content}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderErrors(errors) {
|
||||
return errors.map(err => `
|
||||
<div class="recommendation critical">
|
||||
<strong>Error in ${err.step}:</strong> ${err.error}
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderRecommendations(recommendations) {
|
||||
if (recommendations.length === 0) {
|
||||
return '<p style="color: green;">✓ No issues found - everything looks good!</p>';
|
||||
}
|
||||
|
||||
return recommendations.map(rec => `
|
||||
<div class="recommendation ${rec.severity}">
|
||||
<strong>[${rec.severity.toUpperCase()}] ${rec.category}:</strong> ${rec.message}
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderConfiguration(config) {
|
||||
let html = '<h3>Proxmox VE Instances</h3>';
|
||||
if (!config.proxmox || config.proxmox.length === 0) {
|
||||
html += '<p>No Proxmox instances configured</p>';
|
||||
} else {
|
||||
html += '<table><tr><th>ID</th><th>Host</th><th>Name</th><th>Token Configured</th><th>Self-Signed</th></tr>';
|
||||
config.proxmox.forEach(pve => {
|
||||
html += `<tr>
|
||||
<td>${pve.id}</td>
|
||||
<td>${pve.host}</td>
|
||||
<td>${pve.name}</td>
|
||||
<td>${pve.tokenConfigured ? 'Yes' : 'No'}</td>
|
||||
<td>${pve.selfSignedCerts ? 'Yes' : 'No'}</td>
|
||||
</tr>`;
|
||||
});
|
||||
html += '</table>';
|
||||
}
|
||||
|
||||
html += '<h3>PBS Instances</h3>';
|
||||
if (!config.pbs || config.pbs.length === 0) {
|
||||
html += '<p>No PBS instances configured</p>';
|
||||
} else {
|
||||
html += '<table><tr><th>ID</th><th>Host</th><th>Name</th><th>Node Name</th><th>Token Configured</th><th>Self-Signed</th></tr>';
|
||||
config.pbs.forEach(pbs => {
|
||||
html += `<tr>
|
||||
<td>${pbs.id}</td>
|
||||
<td>${pbs.host}</td>
|
||||
<td>${pbs.name}</td>
|
||||
<td style="${pbs.node_name === 'NOT SET' ? 'color: red; font-weight: bold;' : ''}">${pbs.node_name}</td>
|
||||
<td>${pbs.tokenConfigured ? 'Yes' : 'No'}</td>
|
||||
<td>${pbs.selfSignedCerts ? 'Yes' : 'No'}</td>
|
||||
</tr>`;
|
||||
});
|
||||
html += '</table>';
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderPermissions(permissions) {
|
||||
let html = '<h3>Proxmox VE Token Permissions</h3>';
|
||||
if (!permissions.proxmox || permissions.proxmox.length === 0) {
|
||||
html += '<p>No Proxmox permission checks available</p>';
|
||||
} else {
|
||||
html += '<table><tr><th>Instance</th><th>Connect</th><th>List Nodes</th><th>List VMs</th><th>List Containers</th><th>Node Stats</th><th>Errors</th></tr>';
|
||||
permissions.proxmox.forEach(perm => {
|
||||
const checkIcon = (canDo) => canDo ? '<span class="success-icon">✓</span>' : '<span class="error-icon">✗</span>';
|
||||
html += `<tr>
|
||||
<td>${perm.name}<br><small>${perm.host}</small></td>
|
||||
<td>${checkIcon(perm.canConnect)}</td>
|
||||
<td>${checkIcon(perm.canListNodes)} ${perm.nodeCount ? `(${perm.nodeCount})` : ''}</td>
|
||||
<td>${checkIcon(perm.canListVMs)} ${perm.vmCount !== undefined ? `(${perm.vmCount})` : ''}</td>
|
||||
<td>${checkIcon(perm.canListContainers)} ${perm.containerCount !== undefined ? `(${perm.containerCount})` : ''}</td>
|
||||
<td>${checkIcon(perm.canGetNodeStats)}</td>
|
||||
<td><small>${perm.errors.length > 0 ? perm.errors.join('<br>') : 'None'}</small></td>
|
||||
</tr>`;
|
||||
});
|
||||
html += '</table>';
|
||||
}
|
||||
|
||||
html += '<h3>PBS Token Permissions</h3>';
|
||||
if (!permissions.pbs || permissions.pbs.length === 0) {
|
||||
html += '<p>No PBS permission checks available</p>';
|
||||
} else {
|
||||
html += '<table><tr><th>Instance</th><th>Connect</th><th>List Datastores</th><th>List Backups</th><th>Node Name</th><th>Errors</th></tr>';
|
||||
permissions.pbs.forEach(perm => {
|
||||
const checkIcon = (canDo) => canDo ? '<span class="success-icon">✓</span>' : '<span class="error-icon">✗</span>';
|
||||
html += `<tr>
|
||||
<td>${perm.name}<br><small>${perm.host}</small></td>
|
||||
<td>${checkIcon(perm.canConnect)}</td>
|
||||
<td>${checkIcon(perm.canListDatastores)} ${perm.datastoreCount !== undefined ? `(${perm.datastoreCount})` : ''}</td>
|
||||
<td>${checkIcon(perm.canListBackups)} ${perm.backupCount !== undefined ? `(${perm.backupCount})` : ''}</td>
|
||||
<td style="${perm.node_name === 'NOT SET' ? 'color: red; font-weight: bold;' : ''}">${perm.node_name}</td>
|
||||
<td><small>${perm.errors.length > 0 ? perm.errors.join('<br>') : 'None'}</small></td>
|
||||
</tr>`;
|
||||
});
|
||||
html += '</table>';
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderConnectivity(connectivity) {
|
||||
let html = '<h3>Proxmox VE Connectivity</h3>';
|
||||
html += '<table><tr><th>Instance</th><th>Host</th><th>Status</th><th>Auth</th><th>Response Time</th><th>Error</th></tr>';
|
||||
connectivity.proxmox.forEach(conn => {
|
||||
html += `<tr>
|
||||
<td>${conn.index}</td>
|
||||
<td>${conn.host}</td>
|
||||
<td>${conn.reachable ? '<span class="success-icon">✓</span> Reachable' : '<span class="error-icon">✗</span> Unreachable'}</td>
|
||||
<td>${conn.authValid ? '<span class="success-icon">✓</span> Valid' : '<span class="error-icon">✗</span> Invalid'}</td>
|
||||
<td>${conn.responseTime ? conn.responseTime + 'ms' : '-'}</td>
|
||||
<td>${conn.error || '-'}</td>
|
||||
</tr>`;
|
||||
});
|
||||
html += '</table>';
|
||||
|
||||
html += '<h3>PBS Connectivity</h3>';
|
||||
html += '<table><tr><th>Instance</th><th>Host</th><th>Status</th><th>Auth</th><th>Response Time</th><th>Error</th></tr>';
|
||||
connectivity.pbs.forEach(conn => {
|
||||
html += `<tr>
|
||||
<td>${conn.index}</td>
|
||||
<td>${conn.host}</td>
|
||||
<td>${conn.reachable ? '<span class="success-icon">✓</span> Reachable' : '<span class="error-icon">✗</span> Unreachable'}</td>
|
||||
<td>${conn.authValid ? '<span class="success-icon">✓</span> Valid' : '<span class="error-icon">✗</span> Invalid'}</td>
|
||||
<td>${conn.responseTime ? conn.responseTime + 'ms' : '-'}</td>
|
||||
<td>${conn.error || '-'}</td>
|
||||
</tr>`;
|
||||
});
|
||||
html += '</table>';
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderDataFlow(dataFlow) {
|
||||
let html = '<h3>Proxmox VE Data</h3>';
|
||||
html += `<p>
|
||||
Nodes: ${dataFlow.pve.nodes_count}<br>
|
||||
Total Guests: ${dataFlow.pve.guests_count} (${dataFlow.pve.vms_count} VMs, ${dataFlow.pve.containers_count} Containers)
|
||||
</p>`;
|
||||
|
||||
html += '<h3>PBS Data</h3>';
|
||||
html += `<p>
|
||||
PBS Instances: ${dataFlow.pbs.instances_count}<br>
|
||||
Total Datastores: ${dataFlow.pbs.datastores_total}<br>
|
||||
Total Backups: ${dataFlow.pbs.backups_total}<br>
|
||||
Total Tasks: ${dataFlow.pbs.tasks_total}
|
||||
</p>`;
|
||||
|
||||
if (dataFlow.pbs.backup_matching.length > 0) {
|
||||
html += '<h3>Backup Matching Details</h3>';
|
||||
html += '<table><tr><th>Instance</th><th>Node Name</th><th>Datastores</th><th>Backups</th><th>Matching</th><th>Sample Backups</th></tr>';
|
||||
dataFlow.pbs.backup_matching.forEach(instance => {
|
||||
const samples = instance.sample_backups.slice(0, 3).map(b => b.backup_id).join('<br>');
|
||||
html += `<tr>
|
||||
<td>${instance.index}</td>
|
||||
<td>${instance.node_name}</td>
|
||||
<td>${instance.datastores_count}</td>
|
||||
<td>${instance.backups_count}</td>
|
||||
<td>${instance.matching_backups}</td>
|
||||
<td><small>${samples || '-'}</small></td>
|
||||
</tr>`;
|
||||
});
|
||||
html += '</table>';
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderSystemInfo(data) {
|
||||
let html = '';
|
||||
|
||||
if (data.version) {
|
||||
html += '<h3>Version</h3>';
|
||||
html += `<p>Pulse Version: ${data.version}</p>`;
|
||||
}
|
||||
|
||||
if (data.state) {
|
||||
html += '<h3>Current State</h3>';
|
||||
html += '<table>';
|
||||
|
||||
if (data.state.lastUpdate) {
|
||||
html += `<tr><td>Last Update:</td><td>${new Date(data.state.lastUpdate).toLocaleString()}</td></tr>`;
|
||||
}
|
||||
|
||||
if (data.state.serverUptime) {
|
||||
html += `<tr><td>Server Uptime:</td><td>${Math.floor(data.state.serverUptime)} seconds</td></tr>`;
|
||||
|
||||
// Show warning if server just started and no guests found
|
||||
if (data.state.serverUptime < 60 && data.state.guests && data.state.guests.total === 0) {
|
||||
html += `<tr><td colspan="2" style="color: orange; font-weight: bold;">⚠️ Server just started ${Math.floor(data.state.serverUptime)}s ago. Data may still be loading. Wait 30s and try again.</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.state.dataAge !== null) {
|
||||
html += `<tr><td>Data Age:</td><td>${data.state.dataAge} seconds</td></tr>`;
|
||||
}
|
||||
|
||||
if (data.state.nodes) {
|
||||
html += `<tr><td>Nodes:</td><td>${data.state.nodes.count} (${data.state.nodes.names.join(', ') || 'none'})</td></tr>`;
|
||||
}
|
||||
|
||||
if (data.state.guests) {
|
||||
html += `<tr><td>Total Guests:</td><td>${data.state.guests.total} (${data.state.guests.vms} VMs, ${data.state.guests.containers} Containers)</td></tr>`;
|
||||
html += `<tr><td>Guest Status:</td><td>${data.state.guests.running} running, ${data.state.guests.stopped} stopped</td></tr>`;
|
||||
}
|
||||
|
||||
if (data.state.pbs) {
|
||||
html += `<tr><td>PBS Instances:</td><td>${data.state.pbs.instances}</td></tr>`;
|
||||
html += `<tr><td>Total Backups:</td><td>${data.state.pbs.totalBackups}</td></tr>`;
|
||||
html += `<tr><td>Datastores:</td><td>${data.state.pbs.datastores}</td></tr>`;
|
||||
if (data.state.pbs.sampleBackupIds && data.state.pbs.sampleBackupIds.length > 0) {
|
||||
html += `<tr><td>Sample Backup IDs:</td><td>${data.state.pbs.sampleBackupIds.join(', ')}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.state.alerts) {
|
||||
html += `<tr><td>Active Alerts:</td><td>${data.state.alerts.active}</td></tr>`;
|
||||
}
|
||||
|
||||
html += '</table>';
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// 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;
|
||||
button.textContent = '✅ Copied! Safe to paste';
|
||||
button.style.background = '#4caf50';
|
||||
setTimeout(() => {
|
||||
button.textContent = originalText;
|
||||
button.style.background = '';
|
||||
}, 3000);
|
||||
});
|
||||
}
|
||||
|
||||
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(sanitizedData, null, 2);
|
||||
|
||||
const blob = new Blob([text], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// Run diagnostics automatically on page load
|
||||
window.addEventListener('load', () => {
|
||||
runDiagnostics();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -455,12 +455,12 @@
|
||||
<span class="text-lg font-medium text-gray-800 dark:text-gray-200">Pulse</span>
|
||||
</div>
|
||||
<div class="header-controls flex justify-end items-center gap-4 md:flex-1">
|
||||
<a id="diagnostics-icon" href="/diagnostics.html" target="_blank" class="hidden p-1 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 focus:outline-none relative" title="Run Diagnostics" aria-label="Run diagnostics">
|
||||
<button id="diagnostics-icon" type="button" class="hidden p-1 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 focus:outline-none relative" title="Run Diagnostics" aria-label="Run diagnostics">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span id="diagnostics-badge" class="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full hidden animate-pulse"></span>
|
||||
</a>
|
||||
</button>
|
||||
<button id="settings-button" type="button" class="p-1 h-11 w-11 sm:h-auto sm:w-auto rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 focus:outline-none" title="Settings" aria-label="Open settings">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"></path><circle cx="12" cy="12" r="3"></circle>
|
||||
@@ -887,6 +887,9 @@
|
||||
<button class="settings-tab py-3 px-1 border-b-2 border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 font-medium text-sm" data-tab="system">
|
||||
System
|
||||
</button>
|
||||
<button class="settings-tab py-3 px-1 border-b-2 border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 font-medium text-sm" data-tab="diagnostics">
|
||||
Diagnostics
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -20,6 +20,12 @@ PulseApp.ui.settings = (() => {
|
||||
if (settingsButton) {
|
||||
settingsButton.addEventListener('click', openModal);
|
||||
}
|
||||
|
||||
// Set up diagnostics button
|
||||
const diagnosticsButton = document.getElementById('diagnostics-icon');
|
||||
if (diagnosticsButton) {
|
||||
diagnosticsButton.addEventListener('click', () => openModalWithTab('diagnostics'));
|
||||
}
|
||||
|
||||
if (closeButton) {
|
||||
closeButton.addEventListener('click', closeModal);
|
||||
@@ -91,7 +97,11 @@ PulseApp.ui.settings = (() => {
|
||||
}
|
||||
|
||||
async function openModal() {
|
||||
console.log('[Settings] Opening modal...');
|
||||
await openModalWithTab('proxmox');
|
||||
}
|
||||
|
||||
async function openModalWithTab(tabName) {
|
||||
console.log('[Settings] Opening modal with tab:', tabName);
|
||||
|
||||
const modal = document.getElementById('settings-modal');
|
||||
if (!modal) return;
|
||||
@@ -103,8 +113,8 @@ PulseApp.ui.settings = (() => {
|
||||
// Load current configuration
|
||||
await loadConfiguration();
|
||||
|
||||
// Reset to first tab
|
||||
switchTab('proxmox');
|
||||
// Switch to requested tab
|
||||
switchTab(tabName);
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
@@ -162,6 +172,9 @@ PulseApp.ui.settings = (() => {
|
||||
case 'system':
|
||||
content = renderSystemTab(advanced, safeConfig);
|
||||
break;
|
||||
case 'diagnostics':
|
||||
content = renderDiagnosticsTab();
|
||||
break;
|
||||
}
|
||||
|
||||
container.innerHTML = `<form id="settings-form" class="space-y-6">${content}</form>`;
|
||||
@@ -661,6 +674,65 @@ PulseApp.ui.settings = (() => {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDiagnosticsTab() {
|
||||
return `
|
||||
<div class="space-y-6">
|
||||
<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-2">System Diagnostics</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Generate a comprehensive diagnostic report to help troubleshoot issues with your Pulse configuration.
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<button type="button" id="runDiagnostics" onclick="PulseApp.ui.settings.runDiagnostics()"
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md transition-colors">
|
||||
Run Diagnostics
|
||||
</button>
|
||||
<button type="button" id="copyReport" onclick="PulseApp.ui.settings.copyDiagnosticReport()"
|
||||
style="display: none;" title="Safe to share - all sensitive data has been sanitized"
|
||||
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white text-sm font-medium rounded-md transition-colors 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="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"></path>
|
||||
</svg>
|
||||
Copy Safe Report
|
||||
</button>
|
||||
<button type="button" id="downloadReport" onclick="PulseApp.ui.settings.downloadDiagnosticReport()"
|
||||
style="display: none;" title="Safe to share - all sensitive data has been sanitized"
|
||||
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white text-sm font-medium rounded-md transition-colors 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="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
Download Safe Report
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="diagnostics-status" class="hidden mb-4 p-3 rounded-lg text-sm font-medium"></div>
|
||||
|
||||
<div id="diagnostics-results" style="display: none;" class="space-y-4 mt-6">
|
||||
<!-- Results will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-yellow-400" 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"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">Privacy Notice</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
|
||||
<p>The diagnostic report displays real hostnames, IPs, and other potentially sensitive information for troubleshooting purposes.</p>
|
||||
<p class="mt-1 font-semibold">When you copy or download the report, all sensitive data is automatically sanitized for safe sharing.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderThresholdsTab() {
|
||||
return `
|
||||
<div class="space-y-6">
|
||||
@@ -1638,10 +1710,463 @@ PulseApp.ui.settings = (() => {
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostics functions
|
||||
let diagnosticData = null;
|
||||
|
||||
async function runDiagnostics() {
|
||||
const statusEl = document.getElementById('diagnostics-status');
|
||||
const resultsEl = document.getElementById('diagnostics-results');
|
||||
const runButton = document.getElementById('runDiagnostics');
|
||||
const copyButton = document.getElementById('copyReport');
|
||||
const downloadButton = document.getElementById('downloadReport');
|
||||
|
||||
// Reset UI
|
||||
statusEl.className = 'block mb-4 p-3 rounded-lg text-sm font-medium';
|
||||
statusEl.classList.add('bg-blue-50', 'dark:bg-blue-900/20', 'text-blue-700', 'dark:text-blue-300');
|
||||
statusEl.textContent = 'Running diagnostics...';
|
||||
statusEl.style.display = 'block';
|
||||
resultsEl.style.display = 'none';
|
||||
runButton.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/diagnostics');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
diagnosticData = await response.json();
|
||||
|
||||
// Show success status
|
||||
statusEl.className = 'block mb-4 p-3 rounded-lg text-sm font-medium';
|
||||
if (diagnosticData.summary?.hasIssues) {
|
||||
statusEl.classList.add('bg-yellow-50', 'dark:bg-yellow-900/20', 'text-yellow-700', 'dark:text-yellow-300');
|
||||
statusEl.textContent = `Diagnostics complete. Found ${diagnosticData.summary.criticalIssues} critical issues and ${diagnosticData.summary.warnings} warnings.`;
|
||||
} else {
|
||||
statusEl.classList.add('bg-green-50', 'dark:bg-green-900/20', 'text-green-700', 'dark:text-green-300');
|
||||
statusEl.textContent = 'Diagnostics complete. No critical issues found!';
|
||||
}
|
||||
|
||||
// Display results
|
||||
displayDiagnosticResults(diagnosticData);
|
||||
resultsEl.style.display = 'block';
|
||||
|
||||
// Show action buttons
|
||||
copyButton.style.display = 'inline-flex';
|
||||
downloadButton.style.display = 'inline-flex';
|
||||
|
||||
} catch (error) {
|
||||
statusEl.className = 'block mb-4 p-3 rounded-lg text-sm font-medium';
|
||||
statusEl.classList.add('bg-red-50', 'dark:bg-red-900/20', 'text-red-700', 'dark:text-red-300');
|
||||
statusEl.textContent = `Error running diagnostics: ${error.message}`;
|
||||
} finally {
|
||||
runButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function displayDiagnosticResults(data) {
|
||||
const resultsEl = document.getElementById('diagnostics-results');
|
||||
|
||||
let html = '';
|
||||
|
||||
// Recommendations section
|
||||
if (data.recommendations && data.recommendations.length > 0) {
|
||||
html += createDiagnosticSection('Recommendations', renderRecommendations(data.recommendations), true);
|
||||
}
|
||||
|
||||
// Configuration section
|
||||
if (data.configuration) {
|
||||
html += createDiagnosticSection('Configuration', renderConfiguration(data.configuration));
|
||||
}
|
||||
|
||||
// Permissions section
|
||||
if (data.permissions) {
|
||||
html += createDiagnosticSection('API Token Permissions', renderPermissions(data.permissions));
|
||||
}
|
||||
|
||||
// System Information section
|
||||
if (data.state || data.version) {
|
||||
html += createDiagnosticSection('System Information', renderSystemInfo(data));
|
||||
}
|
||||
|
||||
resultsEl.innerHTML = html;
|
||||
|
||||
// Add click handlers for collapsible sections
|
||||
resultsEl.querySelectorAll('.diagnostic-section-header').forEach(header => {
|
||||
header.addEventListener('click', () => {
|
||||
const section = header.parentElement;
|
||||
const content = section.querySelector('.diagnostic-section-content');
|
||||
const indicator = header.querySelector('.diagnostic-indicator');
|
||||
|
||||
if (content.style.display === 'none') {
|
||||
content.style.display = 'block';
|
||||
indicator.textContent = '▼';
|
||||
} else {
|
||||
content.style.display = 'none';
|
||||
indicator.textContent = '▶';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createDiagnosticSection(title, content, expanded = false) {
|
||||
return `
|
||||
<div class="diagnostic-section bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div class="diagnostic-section-header px-4 py-3 bg-gray-50 dark:bg-gray-700 cursor-pointer flex justify-between items-center hover:bg-gray-100 dark:hover:bg-gray-600">
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">${title}</span>
|
||||
<span class="diagnostic-indicator text-gray-500 dark:text-gray-400">${expanded ? '▼' : '▶'}</span>
|
||||
</div>
|
||||
<div class="diagnostic-section-content px-4 py-3" style="display: ${expanded ? 'block' : 'none'};">
|
||||
${content}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRecommendations(recommendations) {
|
||||
if (recommendations.length === 0) {
|
||||
return '<p class="text-green-600 dark:text-green-400">✓ No issues found - everything looks good!</p>';
|
||||
}
|
||||
|
||||
return recommendations.map(rec => {
|
||||
let bgColor, textColor, borderColor;
|
||||
switch(rec.severity) {
|
||||
case 'critical':
|
||||
bgColor = 'bg-red-50 dark:bg-red-900/20';
|
||||
textColor = 'text-red-800 dark:text-red-200';
|
||||
borderColor = 'border-red-500';
|
||||
break;
|
||||
case 'warning':
|
||||
bgColor = 'bg-yellow-50 dark:bg-yellow-900/20';
|
||||
textColor = 'text-yellow-800 dark:text-yellow-200';
|
||||
borderColor = 'border-yellow-500';
|
||||
break;
|
||||
default:
|
||||
bgColor = 'bg-blue-50 dark:bg-blue-900/20';
|
||||
textColor = 'text-blue-800 dark:text-blue-200';
|
||||
borderColor = 'border-blue-500';
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="${bgColor} ${textColor} p-3 rounded-lg border-l-4 ${borderColor} mb-3">
|
||||
<strong>[${rec.severity.toUpperCase()}] ${rec.category}:</strong> ${rec.message}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderConfiguration(config) {
|
||||
let html = '<div class="space-y-4">';
|
||||
|
||||
html += '<h4 class="font-medium text-gray-900 dark:text-gray-100">Proxmox VE Instances</h4>';
|
||||
if (!config.proxmox || config.proxmox.length === 0) {
|
||||
html += '<p class="text-gray-600 dark:text-gray-400">No Proxmox instances configured</p>';
|
||||
} else {
|
||||
html += '<div class="overflow-x-auto"><table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">';
|
||||
html += '<thead class="bg-gray-50 dark:bg-gray-700"><tr>';
|
||||
html += '<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Host</th>';
|
||||
html += '<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Name</th>';
|
||||
html += '<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Token</th>';
|
||||
html += '</tr></thead><tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">';
|
||||
|
||||
config.proxmox.forEach(pve => {
|
||||
html += `<tr>
|
||||
<td class="px-4 py-2 text-sm text-gray-900 dark:text-gray-100">${pve.host}</td>
|
||||
<td class="px-4 py-2 text-sm text-gray-900 dark:text-gray-100">${pve.name}</td>
|
||||
<td class="px-4 py-2 text-sm text-gray-900 dark:text-gray-100">${pve.tokenConfigured ? '✓' : '✗'}</td>
|
||||
</tr>`;
|
||||
});
|
||||
html += '</tbody></table></div>';
|
||||
}
|
||||
|
||||
html += '<h4 class="font-medium text-gray-900 dark:text-gray-100 mt-4">PBS Instances</h4>';
|
||||
if (!config.pbs || config.pbs.length === 0) {
|
||||
html += '<p class="text-gray-600 dark:text-gray-400">No PBS instances configured</p>';
|
||||
} else {
|
||||
html += '<div class="overflow-x-auto"><table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">';
|
||||
html += '<thead class="bg-gray-50 dark:bg-gray-700"><tr>';
|
||||
html += '<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Host</th>';
|
||||
html += '<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Name</th>';
|
||||
html += '<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Node Name</th>';
|
||||
html += '<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Token</th>';
|
||||
html += '</tr></thead><tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">';
|
||||
|
||||
config.pbs.forEach(pbs => {
|
||||
const nodeNameStyle = pbs.node_name === 'NOT SET' ? 'text-red-600 dark:text-red-400 font-bold' : '';
|
||||
html += `<tr>
|
||||
<td class="px-4 py-2 text-sm text-gray-900 dark:text-gray-100">${pbs.host}</td>
|
||||
<td class="px-4 py-2 text-sm text-gray-900 dark:text-gray-100">${pbs.name}</td>
|
||||
<td class="px-4 py-2 text-sm ${nodeNameStyle}">${pbs.node_name}</td>
|
||||
<td class="px-4 py-2 text-sm text-gray-900 dark:text-gray-100">${pbs.tokenConfigured ? '✓' : '✗'}</td>
|
||||
</tr>`;
|
||||
});
|
||||
html += '</tbody></table></div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderPermissions(permissions) {
|
||||
let html = '<div class="space-y-4">';
|
||||
|
||||
if (permissions.proxmox && permissions.proxmox.length > 0) {
|
||||
html += '<h4 class="font-medium text-gray-900 dark:text-gray-100">Proxmox VE Token Permissions</h4>';
|
||||
html += '<div class="overflow-x-auto"><table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">';
|
||||
html += '<thead class="bg-gray-50 dark:bg-gray-700"><tr>';
|
||||
html += '<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Instance</th>';
|
||||
html += '<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Connect</th>';
|
||||
html += '<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Nodes</th>';
|
||||
html += '<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">VMs</th>';
|
||||
html += '<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Containers</th>';
|
||||
html += '</tr></thead><tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">';
|
||||
|
||||
permissions.proxmox.forEach(perm => {
|
||||
const checkIcon = (canDo) => canDo ?
|
||||
'<span class="text-green-600 dark:text-green-400">✓</span>' :
|
||||
'<span class="text-red-600 dark:text-red-400">✗</span>';
|
||||
html += `<tr>
|
||||
<td class="px-4 py-2 text-sm text-gray-900 dark:text-gray-100">${perm.name}</td>
|
||||
<td class="px-4 py-2 text-sm">${checkIcon(perm.canConnect)}</td>
|
||||
<td class="px-4 py-2 text-sm">${checkIcon(perm.canListNodes)} ${perm.nodeCount ? `(${perm.nodeCount})` : ''}</td>
|
||||
<td class="px-4 py-2 text-sm">${checkIcon(perm.canListVMs)} ${perm.vmCount !== undefined ? `(${perm.vmCount})` : ''}</td>
|
||||
<td class="px-4 py-2 text-sm">${checkIcon(perm.canListContainers)} ${perm.containerCount !== undefined ? `(${perm.containerCount})` : ''}</td>
|
||||
</tr>`;
|
||||
});
|
||||
html += '</tbody></table></div>';
|
||||
}
|
||||
|
||||
if (permissions.pbs && permissions.pbs.length > 0) {
|
||||
html += '<h4 class="font-medium text-gray-900 dark:text-gray-100 mt-4">PBS Token Permissions</h4>';
|
||||
html += '<div class="overflow-x-auto"><table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">';
|
||||
html += '<thead class="bg-gray-50 dark:bg-gray-700"><tr>';
|
||||
html += '<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Instance</th>';
|
||||
html += '<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Connect</th>';
|
||||
html += '<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Datastores</th>';
|
||||
html += '<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Backups</th>';
|
||||
html += '</tr></thead><tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">';
|
||||
|
||||
permissions.pbs.forEach(perm => {
|
||||
const checkIcon = (canDo) => canDo ?
|
||||
'<span class="text-green-600 dark:text-green-400">✓</span>' :
|
||||
'<span class="text-red-600 dark:text-red-400">✗</span>';
|
||||
html += `<tr>
|
||||
<td class="px-4 py-2 text-sm text-gray-900 dark:text-gray-100">${perm.name}</td>
|
||||
<td class="px-4 py-2 text-sm">${checkIcon(perm.canConnect)}</td>
|
||||
<td class="px-4 py-2 text-sm">${checkIcon(perm.canListDatastores)} ${perm.datastoreCount !== undefined ? `(${perm.datastoreCount})` : ''}</td>
|
||||
<td class="px-4 py-2 text-sm">${checkIcon(perm.canListBackups)} ${perm.backupCount !== undefined ? `(${perm.backupCount})` : ''}</td>
|
||||
</tr>`;
|
||||
});
|
||||
html += '</tbody></table></div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderSystemInfo(data) {
|
||||
let html = '<div class="space-y-2">';
|
||||
|
||||
if (data.version) {
|
||||
html += `<p class="text-sm"><span class="font-medium text-gray-700 dark:text-gray-300">Pulse Version:</span> <span class="text-gray-900 dark:text-gray-100">${data.version}</span></p>`;
|
||||
}
|
||||
|
||||
if (data.state) {
|
||||
if (data.state.lastUpdate) {
|
||||
html += `<p class="text-sm"><span class="font-medium text-gray-700 dark:text-gray-300">Last Update:</span> <span class="text-gray-900 dark:text-gray-100">${new Date(data.state.lastUpdate).toLocaleString()}</span></p>`;
|
||||
}
|
||||
|
||||
if (data.state.serverUptime) {
|
||||
html += `<p class="text-sm"><span class="font-medium text-gray-700 dark:text-gray-300">Server Uptime:</span> <span class="text-gray-900 dark:text-gray-100">${Math.floor(data.state.serverUptime)} seconds</span></p>`;
|
||||
}
|
||||
|
||||
if (data.state.nodes) {
|
||||
html += `<p class="text-sm"><span class="font-medium text-gray-700 dark:text-gray-300">Nodes:</span> <span class="text-gray-900 dark:text-gray-100">${data.state.nodes.count} (${data.state.nodes.names.join(', ') || 'none'})</span></p>`;
|
||||
}
|
||||
|
||||
if (data.state.guests) {
|
||||
html += `<p class="text-sm"><span class="font-medium text-gray-700 dark:text-gray-300">Total Guests:</span> <span class="text-gray-900 dark:text-gray-100">${data.state.guests.total} (${data.state.guests.vms} VMs, ${data.state.guests.containers} Containers)</span></p>`;
|
||||
html += `<p class="text-sm"><span class="font-medium text-gray-700 dark:text-gray-300">Guest Status:</span> <span class="text-gray-900 dark:text-gray-100">${data.state.guests.running} running, ${data.state.guests.stopped} stopped</span></p>`;
|
||||
}
|
||||
|
||||
if (data.state.pbs) {
|
||||
html += `<p class="text-sm"><span class="font-medium text-gray-700 dark:text-gray-300">PBS Instances:</span> <span class="text-gray-900 dark:text-gray-100">${data.state.pbs.instances}</span></p>`;
|
||||
html += `<p class="text-sm"><span class="font-medium text-gray-700 dark:text-gray-300">Total Backups:</span> <span class="text-gray-900 dark:text-gray-100">${data.state.pbs.totalBackups}</span></p>`;
|
||||
}
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
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),
|
||||
tokenConfigured: pve.tokenConfigured,
|
||||
selfSignedCerts: pve.selfSignedCerts
|
||||
}));
|
||||
}
|
||||
|
||||
if (sanitized.configuration.pbs) {
|
||||
sanitized.configuration.pbs = sanitized.configuration.pbs.map(pbs => ({
|
||||
...pbs,
|
||||
host: sanitizeUrl(pbs.host),
|
||||
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),
|
||||
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),
|
||||
errors: perm.errors ? perm.errors.map(err => sanitizeErrorMessage(err)) : []
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize state section
|
||||
if (sanitized.state) {
|
||||
if (sanitized.state.nodes && sanitized.state.nodes.names) {
|
||||
sanitized.state.nodes.names = sanitized.state.nodes.names.map((name, index) => `node-${index + 1}`);
|
||||
}
|
||||
|
||||
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)
|
||||
}));
|
||||
}
|
||||
|
||||
// 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 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 copyDiagnosticReport() {
|
||||
if (!diagnosticData) return;
|
||||
|
||||
// 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.innerHTML;
|
||||
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="M5 13l4 4L19 7"></path></svg> Copied!';
|
||||
button.classList.remove('bg-gray-600', 'hover:bg-gray-700');
|
||||
button.classList.add('bg-green-600', 'hover:bg-green-700');
|
||||
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalText;
|
||||
button.classList.remove('bg-green-600', 'hover:bg-green-700');
|
||||
button.classList.add('bg-gray-600', 'hover:bg-gray-700');
|
||||
}, 3000);
|
||||
});
|
||||
}
|
||||
|
||||
function downloadDiagnosticReport() {
|
||||
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(sanitizedData, null, 2);
|
||||
|
||||
const blob = new Blob([text], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init,
|
||||
openModal,
|
||||
openModalWithTab,
|
||||
closeModal,
|
||||
addPveEndpoint,
|
||||
addPbsEndpoint,
|
||||
@@ -1649,7 +2174,10 @@ PulseApp.ui.settings = (() => {
|
||||
testConnections,
|
||||
checkForUpdates,
|
||||
applyUpdate,
|
||||
changeTheme
|
||||
changeTheme,
|
||||
runDiagnostics,
|
||||
copyDiagnosticReport,
|
||||
downloadDiagnosticReport
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user