diff --git a/README.md b/README.md index f3db19a..461a133 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,17 @@ docker-compose up --build **Metrics:** Utilization, temperature, memory, power draw, fan speed, clock speeds, PCIe info, P-State, throttle status, encoder/decoder sessions +### Export Features + +**Snapshot exports:** +- **JSON Export** - Machine-readable metrics + 60s history +- **HTML Report** - Professional, printable, self-contained report +- **Chart Images** - High-resolution PNG screenshots + +**Use cases:** Bug reports, documentation, capacity planning + +**How to use:** Click "📊 Export" button → Select format → Downloads automatically + --- ## Configuration diff --git a/static/css/styles.css b/static/css/styles.css index 0da5542..e8fb851 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -1185,6 +1185,119 @@ body { box-shadow: 0 0 0 1px rgba(79, 172, 254, 0.3); } +/* Export Controls */ +.export-controls { + position: relative; + margin-bottom: 2rem; + display: flex; + justify-content: flex-end; +} + +.export-btn { + padding: 0.75rem 1.5rem; + background: linear-gradient(135deg, rgba(79, 172, 254, 0.15) 0%, rgba(30, 58, 138, 0.15) 100%); + border: 1px solid rgba(79, 172, 254, 0.3); + border-radius: 12px; + color: var(--text-primary); + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + gap: 0.5rem; + letter-spacing: 0.3px; +} + +.export-btn:hover { + background: linear-gradient(135deg, rgba(79, 172, 254, 0.25) 0%, rgba(30, 58, 138, 0.25) 100%); + border-color: rgba(79, 172, 254, 0.5); + box-shadow: 0 4px 16px rgba(79, 172, 254, 0.2); + transform: translateY(-2px); +} + +.export-btn:active { + transform: translateY(0); +} + +.export-chevron { + font-size: 0.75rem; + transition: transform 0.3s ease; +} + +.export-btn.active .export-chevron { + transform: rotate(180deg); +} + +.export-menu { + position: absolute; + top: calc(100% + 0.5rem); + right: 0; + min-width: 280px; + background: linear-gradient(135deg, rgba(15, 15, 35, 0.98) 0%, rgba(20, 20, 50, 0.98) 100%); + backdrop-filter: blur(30px); + border: 1px solid rgba(79, 172, 254, 0.3); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + padding: 0.5rem; + z-index: 1000; + opacity: 1; + transform: translateY(0); + transition: all 0.3s ease; +} + +.export-menu.hidden { + opacity: 0; + transform: translateY(-10px); + pointer-events: none; +} + +.export-option { + width: 100%; + padding: 0.875rem 1rem; + background: transparent; + border: none; + border-radius: 8px; + color: var(--text-secondary); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + text-align: left; + display: flex; + align-items: flex-start; + gap: 0.75rem; + line-height: 1.4; +} + +.export-option:hover { + background: rgba(79, 172, 254, 0.1); + color: var(--text-primary); + transform: translateX(4px); +} + +.export-option:active { + background: rgba(79, 172, 254, 0.15); +} + +/* Mobile responsive */ +@media (max-width: 768px) { + .export-controls { + justify-content: center; + } + + .export-btn { + width: 100%; + justify-content: center; + } + + .export-menu { + left: 0; + right: 0; + min-width: 100%; + } +} + /* Tab Content with smooth transitions */ .tab-content { display: none; diff --git a/static/js/export.js b/static/js/export.js new file mode 100644 index 0000000..929fdbd --- /dev/null +++ b/static/js/export.js @@ -0,0 +1,563 @@ +/** + * GPU Hot - Export Functionality + * Client-side export of GPU metrics to JSON, HTML, and Chart Images + */ + +// Toggle export menu visibility +function toggleExportMenu() { + const menu = document.getElementById('export-menu'); + const btn = document.getElementById('export-btn'); + + menu.classList.toggle('hidden'); + btn.classList.toggle('active'); + + // Close menu when clicking outside + if (!menu.classList.contains('hidden')) { + setTimeout(() => { + document.addEventListener('click', closeExportMenuOnOutsideClick); + }, 100); + } else { + document.removeEventListener('click', closeExportMenuOnOutsideClick); + } +} + +function closeExportMenuOnOutsideClick(event) { + const menu = document.getElementById('export-menu'); + const btn = document.getElementById('export-btn'); + const controls = document.querySelector('.export-controls'); + + if (!controls.contains(event.target)) { + menu.classList.add('hidden'); + btn.classList.remove('active'); + document.removeEventListener('click', closeExportMenuOnOutsideClick); + } +} + +// Export as JSON +function exportAsJSON() { + try { + const exportData = { + metadata: { + export_timestamp: new Date().toISOString(), + export_format: 'json', + application: 'GPU Hot', + version: document.getElementById('version-current')?.textContent || 'unknown' + }, + gpus: collectGPUData(), + processes: collectProcessData(), + system: collectSystemData(), + historical_data: collectHistoricalData() + }; + + const jsonString = JSON.stringify(exportData, null, 2); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5); + const filename = `gpu-hot-export-${timestamp}.json`; + + downloadFile(jsonString, filename, 'application/json'); + closeExportMenu(); + + console.log('JSON export completed:', filename); + } catch (error) { + console.error('Error exporting JSON:', error); + alert('Error exporting JSON data. Check console for details.'); + } +} + +// Export as HTML Report +async function exportAsHTML() { + try { + const exportData = { + metadata: { + export_timestamp: new Date().toISOString(), + export_format: 'html', + application: 'GPU Hot', + version: document.getElementById('version-current')?.textContent || 'unknown' + }, + gpus: collectGPUData(), + processes: collectProcessData(), + system: collectSystemData(), + charts: await generateChartImages() + }; + + const htmlContent = generateHTMLReport(exportData); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5); + const filename = `gpu-hot-report-${timestamp}.html`; + + downloadFile(htmlContent, filename, 'text/html'); + closeExportMenu(); + + console.log('HTML export completed:', filename); + } catch (error) { + console.error('Error exporting HTML:', error); + alert('Error exporting HTML report. Check console for details.'); + } +} + +// Export Chart Images +async function exportChartImages() { + try { + const chartImages = await generateChartImages(); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5); + + if (Object.keys(chartImages).length === 0) { + alert('No charts available to export. Please wait for data to load.'); + return; + } + + // Download each chart as a separate PNG file + for (const [chartName, base64Image] of Object.entries(chartImages)) { + // Convert base64 to blob + const base64Data = base64Image.split(',')[1]; + const byteCharacters = atob(base64Data); + const byteNumbers = new Array(byteCharacters.length); + for (let i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.charCodeAt(i); + } + const byteArray = new Uint8Array(byteNumbers); + const blob = new Blob([byteArray], { type: 'image/png' }); + + // Create download link + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `gpu-hot-${chartName}-${timestamp}.png`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + // Small delay between downloads to prevent browser blocking + await new Promise(resolve => setTimeout(resolve, 100)); + } + + closeExportMenu(); + console.log(`Chart export completed: ${Object.keys(chartImages).length} images downloaded`); + } catch (error) { + console.error('Error exporting charts:', error); + alert('Error exporting chart images. Check console for details.'); + } +} + +// Helper: Close export menu +function closeExportMenu() { + const menu = document.getElementById('export-menu'); + const btn = document.getElementById('export-btn'); + menu.classList.add('hidden'); + btn.classList.remove('active'); + document.removeEventListener('click', closeExportMenuOnOutsideClick); +} + +// Collect GPU data from DOM +function collectGPUData() { + const gpus = {}; + + // Get all GPU cards + const gpuCards = document.querySelectorAll('.overview-gpu-card, .gpu-card'); + + gpuCards.forEach((card, index) => { + const gpuId = card.dataset.gpuId || `gpu-${index}`; + const nodeName = card.dataset.nodeName || 'default'; + + // Extract GPU name + const nameElement = card.querySelector('.gpu-name, .gpu-model'); + const gpuName = nameElement?.textContent.trim() || 'Unknown GPU'; + + // Extract metrics + const metrics = {}; + + // Utilization + const utilizationElement = card.querySelector('[data-metric="utilization"]'); + if (utilizationElement) { + metrics.utilization = parseFloat(utilizationElement.textContent) || 0; + } + + // Memory + const memoryElement = card.querySelector('[data-metric="memory"]'); + if (memoryElement) { + metrics.memory_used = memoryElement.textContent.trim(); + } + + // Temperature + const temperatureElement = card.querySelector('[data-metric="temperature"]'); + if (temperatureElement) { + metrics.temperature = parseFloat(temperatureElement.textContent) || 0; + } + + // Power + const powerElement = card.querySelector('[data-metric="power"]'); + if (powerElement) { + metrics.power_draw = powerElement.textContent.trim(); + } + + // Fan speed + const fanElement = card.querySelector('[data-metric="fan"]'); + if (fanElement) { + metrics.fan_speed = parseFloat(fanElement.textContent) || 0; + } + + // Clocks + const clockElement = card.querySelector('[data-metric="clock"]'); + if (clockElement) { + metrics.gpu_clock = clockElement.textContent.trim(); + } + + gpus[gpuId] = { + node_name: nodeName, + name: gpuName, + metrics: metrics + }; + }); + + return gpus; +} + +// Collect process data from DOM +function collectProcessData() { + const processes = []; + + const processRows = document.querySelectorAll('.process-row, .process-item'); + + processRows.forEach((row) => { + const process = { + pid: row.querySelector('.process-pid')?.textContent.trim() || '', + name: row.querySelector('.process-name')?.textContent.trim() || '', + gpu_id: row.dataset.gpuId || '', + memory: row.querySelector('.process-memory')?.textContent.trim() || '', + gpu_utilization: row.querySelector('.process-utilization')?.textContent.trim() || '' + }; + + if (process.pid || process.name) { + processes.push(process); + } + }); + + return processes; +} + +// Collect system data from DOM +function collectSystemData() { + const system = {}; + + // CPU usage + const cpuElement = document.getElementById('cpu-usage'); + if (cpuElement) { + system.cpu_usage = cpuElement.textContent.trim(); + } + + // Memory usage + const memoryElement = document.getElementById('memory-usage'); + if (memoryElement) { + system.memory_usage = memoryElement.textContent.trim(); + } + + // Connection status + const connectionElement = document.getElementById('connection-status'); + if (connectionElement) { + system.connection_status = connectionElement.textContent.trim(); + } + + // Process count + const processCountElement = document.getElementById('process-count'); + if (processCountElement) { + system.active_processes = processCountElement.textContent.trim(); + } + + return system; +} + +// Collect historical chart data (60 second window) +function collectHistoricalData() { + const historical = {}; + + // Check if chartData is available globally + if (typeof chartData !== 'undefined' && chartData) { + // Copy the chart data structure + Object.keys(chartData).forEach(key => { + if (chartData[key] && Array.isArray(chartData[key])) { + historical[key] = [...chartData[key]]; + } + }); + } + + return historical; +} + +// Generate chart images from Chart.js instances +async function generateChartImages() { + const chartImages = {}; + + // Check if charts object is available globally + if (typeof charts === 'undefined' || !charts) { + console.warn('Charts object not available for export'); + return chartImages; + } + + // Export each chart as base64 PNG + for (const [chartName, chartInstance] of Object.entries(charts)) { + if (chartInstance && typeof chartInstance.toBase64Image === 'function') { + try { + const base64Image = chartInstance.toBase64Image('image/png', 1.0); + chartImages[chartName] = base64Image; + } catch (error) { + console.warn(`Failed to export chart ${chartName}:`, error); + } + } + } + + return chartImages; +} + +// Generate HTML report +function generateHTMLReport(data) { + const timestamp = new Date(data.metadata.export_timestamp).toLocaleString(); + + let chartHTML = ''; + if (data.charts && Object.keys(data.charts).length > 0) { + chartHTML = '

Performance Charts

'; + for (const [chartName, base64Image] of Object.entries(data.charts)) { + chartHTML += ` +
+

${formatChartName(chartName)}

+ ${chartName} +
+ `; + } + chartHTML += '
'; + } + + let gpuHTML = ''; + if (data.gpus && Object.keys(data.gpus).length > 0) { + gpuHTML = '

GPU Status

'; + + for (const [gpuId, gpu] of Object.entries(data.gpus)) { + gpuHTML += ` + + + + + + + + + + `; + } + + gpuHTML += '
GPUNodeUtilizationMemoryTemperaturePowerFan
${gpu.name}${gpu.node_name}${gpu.metrics.utilization !== undefined ? gpu.metrics.utilization + '%' : 'N/A'}${gpu.metrics.memory_used || 'N/A'}${gpu.metrics.temperature !== undefined ? gpu.metrics.temperature + '°C' : 'N/A'}${gpu.metrics.power_draw || 'N/A'}${gpu.metrics.fan_speed !== undefined ? gpu.metrics.fan_speed + '%' : 'N/A'}
'; + } + + let processHTML = ''; + if (data.processes && data.processes.length > 0) { + processHTML = '

Active Processes

'; + + data.processes.forEach(process => { + processHTML += ` + + + + + + + + `; + }); + + processHTML += '
PIDNameGPUMemoryUtilization
${process.pid}${process.name}${process.gpu_id}${process.memory}${process.gpu_utilization}
'; + } + + const systemHTML = ` +
+

System Metrics

+
+
CPU Usage: ${data.system.cpu_usage || 'N/A'}
+
RAM Usage: ${data.system.memory_usage || 'N/A'}
+
Connection: ${data.system.connection_status || 'N/A'}
+
Active Processes: ${data.system.active_processes || 'N/A'}
+
+
+ `; + + const html = ` + + + + + GPU Hot Report - ${timestamp} + + + +
+

🔥 GPU Hot Report

+
+
Generated: ${timestamp}
+
Application: ${data.metadata.application} v${data.metadata.version}
+
+ + ${systemHTML} + ${gpuHTML} + ${processHTML} + ${chartHTML} + + +
+ +`; + + return html; +} + +// Format chart name for display +function formatChartName(chartName) { + return chartName + .replace(/([A-Z])/g, ' $1') + .replace(/^./, str => str.toUpperCase()) + .replace(/-/g, ' ') + .trim(); +} + +// Download file helper +function downloadFile(content, filename, mimeType) { + const blob = new Blob([content], { type: mimeType }); + 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); +} diff --git a/templates/index.html b/templates/index.html index a4dcd84..fe636d2 100644 --- a/templates/index.html +++ b/templates/index.html @@ -47,6 +47,24 @@

🔥 GPU Hot

+ +
+ + +
+
@@ -112,12 +130,13 @@

🔥 GPU Hot

- + + \ No newline at end of file