diff --git a/vna_system/web_ui/static/js/modules/charts.js b/vna_system/web_ui/static/js/modules/charts.js index 9c8f236..fe4c55d 100644 --- a/vna_system/web_ui/static/js/modules/charts.js +++ b/vna_system/web_ui/static/js/modules/charts.js @@ -3,15 +3,16 @@ * Handles Plotly.js chart creation, updates, and management */ -import { formatProcessorName, createParameterControl, safeClone, downloadJSON } from './utils.js'; +import { formatProcessorName, safeClone, downloadJSON } from './utils.js'; +import { ChartSettingsManager } from './charts/chart-settings.js'; export class ChartManager { constructor(config, notifications) { this.config = config; this.notifications = notifications; - this.charts = new Map(); // id -> { element, plotContainer, isVisible } - this.chartData = new Map(); // id -> [{ timestamp, metadata, data, plotly_config }] + this.charts = new Map(); + this.chartData = new Map(); this.disabledProcessors = new Set(); this.chartsGrid = null; @@ -28,11 +29,7 @@ export class ChartManager { lastUpdateTime: null }; - // Debounce timers for settings updates - this.settingDebounceTimers = {}; - - // Track last setting values to prevent feedback loops - this.lastSettingValues = {}; + this.settingsManager = new ChartSettingsManager(); this.plotlyConfig = { displayModeBar: true, @@ -48,7 +45,7 @@ export class ChartManager { paper_bgcolor: 'transparent', font: { family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif', size: 12, color: '#f1f5f9' }, colorway: ['#3b82f6','#22c55e','#eab308','#ef4444','#8b5cf6','#06b6d4','#f59e0b','#10b981'], - margin: { l: 60, r: 50, t: 50, b: 60 }, // Increased right and top margins + margin: { l: 60, r: 50, t: 50, b: 60 }, showlegend: true, legend: { orientation: 'v', @@ -76,7 +73,6 @@ export class ChartManager { console.log('Chart Manager initialized'); } - /** New input format - direct payload from WS: processor_result */ addResult(payload) { try { const { processor_id, timestamp, plotly_config, metadata, data } = payload; @@ -86,13 +82,11 @@ export class ChartManager { } if (this.disabledProcessors.has(processor_id)) { - console.log(` Skipping disabled processor: ${processor_id}`); return; } - // Store only the latest data (no history needed) this.chartData.set(processor_id, { - timestamp: new Date((timestamp ?? Date.now()) * 1000), // if timestamp is in epoch seconds + timestamp: new Date((timestamp ?? Date.now()) * 1000), metadata: metadata || {}, data: data || {}, plotly_config: plotly_config || { data: [], layout: {} } @@ -111,7 +105,7 @@ export class ChartManager { } createChart(processorId) { - console.log(` Creating chart for processor: ${processorId}`); + console.log(`Creating chart for processor: ${processorId}`); const card = this.createChartCard(processorId); this.chartsGrid.appendChild(card); @@ -132,7 +126,7 @@ export class ChartManager { plotContainer._resizeObserver = ro; } - this.charts.set(processorId, { element: card, plotContainer, isVisible: true }); + this.charts.set(processorId, { element: card, plotContainer, isVisible: true, settingsInitialized: false }); this.performanceStats.chartsCreated++; if (this.config.animation) { @@ -144,7 +138,10 @@ export class ChartManager { if (this.isPaused) return; const chart = this.charts.get(processorId); - if (!chart?.plotContainer) { console.warn(` Chart not found for processor: ${processorId}`); return; } + if (!chart?.plotContainer) { + console.warn(`Chart not found for processor: ${processorId}`); + return; + } try { const start = performance.now(); @@ -160,19 +157,19 @@ export class ChartManager { await Plotly.react(chart.plotContainer, plotlyConfig.data || [], updateLayout, this.plotlyConfig); this.updateChartMetadata(processorId); - // Only update settings if chart is newly created or if parameters actually changed + if (!chart.settingsInitialized) { this.updateChartSettings(processorId); chart.settingsInitialized = true; } else { - // Check if settings need updating without forcing it this.updateChartSettings(processorId); } + const dt = performance.now() - start; this.updatePerformanceStats(dt); }); } catch (e) { - console.error(` Error updating chart ${processorId}:`, e); + console.error(`Error updating chart ${processorId}:`, e); } } @@ -187,7 +184,7 @@ export class ChartManager { while (this.updateQueue.size > 0 && !this.isPaused) { const [id, fn] = this.updateQueue.entries().next().value; this.updateQueue.delete(id); - try { await fn(); } catch (e) { console.error(` Error in queued update for ${id}:`, e); } + try { await fn(); } catch (e) { console.error(`Error in queued update for ${id}:`, e); } await new Promise(r => setTimeout(r, 0)); } this.isUpdating = false; @@ -229,13 +226,11 @@ export class ChartManager {
Last update: --
-
Info: --
+
`; this.setupChartCardEvents(card, processorId); - - // Initialize settings immediately this.updateChartSettings(processorId); if (typeof lucide !== 'undefined') lucide.createIcons({ attrs: { 'stroke-width': 1.5 } }); @@ -262,223 +257,27 @@ export class ChartManager { const chart = this.charts.get(processorId); const latestData = this.chartData.get(processorId); if (!chart || !latestData) return; - const tsEl = chart.element.querySelector('[data-timestamp]'); - const infoEl = chart.element.querySelector('[data-sweep]'); + const tsEl = chart.element.querySelector('[data-timestamp]'); if (tsEl) { const dt = latestData.timestamp instanceof Date ? latestData.timestamp : new Date(); tsEl.textContent = `Last update: ${dt.toLocaleTimeString()}`; tsEl.dataset.timestamp = dt.toISOString(); } - - if (infoEl) { - // Hide history count since we no longer track it - infoEl.textContent = ''; - } } - /** - * Update chart settings with current UI parameters - */ updateChartSettings(processorId) { const chart = this.charts.get(processorId); const settingsContainer = chart?.element?.querySelector('.chart-settings__controls'); - if (!settingsContainer) return; - - // Get UI parameters from the latest chart data metadata const latestData = this.chartData.get(processorId); - const uiParameters = latestData?.metadata?.ui_parameters; - // Store current parameters to avoid unnecessary updates - if (!chart.lastUiParameters) chart.lastUiParameters = null; - - // Check if parameters have actually changed - const parametersChanged = !chart.lastUiParameters || - JSON.stringify(uiParameters) !== JSON.stringify(chart.lastUiParameters); - - if (!parametersChanged) { - console.log(` No parameter changes for ${processorId}, skipping settings update`); - return; // No need to update if parameters haven't changed + if (settingsContainer && latestData) { + this.settingsManager.updateSettings(processorId, settingsContainer, latestData); } - - console.log(` Updating settings for ${processorId}:`, { - old: chart.lastUiParameters, - new: uiParameters - }); - - chart.lastUiParameters = uiParameters ? JSON.parse(JSON.stringify(uiParameters)) : null; - - // Fallback to UI manager if no data available - if (!uiParameters || !Array.isArray(uiParameters) || uiParameters.length === 0) { - const uiManager = window.vnaDashboard?.ui; - const processor = uiManager?.processors?.get(processorId); - - if (processor?.uiParameters && Array.isArray(processor.uiParameters) && processor.uiParameters.length > 0) { - const settingsHtml = processor.uiParameters.map(param => - createParameterControl(param, processorId, 'chart') - ).join(''); - settingsContainer.innerHTML = settingsHtml; - this.setupSettingsEvents(settingsContainer, processorId); - return; - } - - settingsContainer.innerHTML = '
No settings available
'; - return; - } - - // Generate settings HTML from chart data - const settingsHtml = uiParameters.map(param => - createParameterControl(param, processorId, 'chart') - ).join(''); - - settingsContainer.innerHTML = settingsHtml; - - // Add event listeners - this.setupSettingsEvents(settingsContainer, processorId); - - // Initialize last values from current UI state to prevent immediate updates - if (uiParameters) { - uiParameters.forEach(param => { - const settingKey = `${processorId}_${param.name}`; - this.lastSettingValues[settingKey] = param.value; - console.log(` Initialized setting value: ${settingKey} = ${param.value}`); - }); - } - - if (typeof lucide !== 'undefined') { - lucide.createIcons({ attrs: { 'stroke-width': 1.5 } }); - } - } - - /** - * Setup event listeners for settings - */ - setupSettingsEvents(settingsContainer, processorId) { - const onParamChange = (e) => { - if (!e.target.closest('.chart-setting')) return; - this.handleSettingChange(e, processorId); - }; - - const onButtonClick = (e) => { - if (!e.target.classList.contains('chart-setting__button')) return; - this.handleButtonClick(e, processorId); - }; - - settingsContainer.addEventListener('input', onParamChange); - settingsContainer.addEventListener('change', onParamChange); - settingsContainer.addEventListener('click', onButtonClick); - } - - /** - * Handle setting parameter change - */ - handleSettingChange(event, processorId) { - const settingElement = event.target.closest('.chart-setting'); - if (!settingElement) return; - - const paramName = settingElement.dataset.param; - const input = event.target; - - let value; - if (input.type === 'checkbox') { - value = input.checked; - } else if (input.type === 'range') { - value = parseFloat(input.value); - // Update display value - const valueDisplay = settingElement.querySelector('.chart-setting__value'); - if (valueDisplay) valueDisplay.textContent = value; - } else { - value = input.value; - } - - // Normalize boolean values to prevent comparison issues - if (typeof value === 'string' && (value === 'true' || value === 'false')) { - value = value === 'true'; - } - - console.log(` Chart setting changed: ${processorId}.${paramName} = ${value}`); - - // Store the current setting value to prevent loops - if (!this.lastSettingValues) this.lastSettingValues = {}; - const settingKey = `${processorId}_${paramName}`; - - // Normalize saved value for comparison - let lastValue = this.lastSettingValues[settingKey]; - if (typeof lastValue === 'string' && (lastValue === 'true' || lastValue === 'false')) { - lastValue = lastValue === 'true'; - } - - // Check if this is the same value we just set to prevent feedback loop - if (lastValue === value) { - console.log(` Skipping duplicate setting: ${settingKey} = ${value} (was ${this.lastSettingValues[settingKey]})`); - return; - } - - this.lastSettingValues[settingKey] = value; - - // Debounce setting updates to prevent rapid firing - const debounceKey = `${processorId}_${paramName}`; - if (this.settingDebounceTimers) { - clearTimeout(this.settingDebounceTimers[debounceKey]); - } else { - this.settingDebounceTimers = {}; - } - - this.settingDebounceTimers[debounceKey] = setTimeout(() => { - console.log(` Sending setting update: ${processorId}.${paramName} = ${value}`); - // Send update via WebSocket - const websocket = window.vnaDashboard?.websocket; - if (websocket && websocket.recalculate) { - websocket.recalculate(processorId, { [paramName]: value }); - } else { - console.warn('WebSocket not available for settings update'); - } - delete this.settingDebounceTimers[debounceKey]; - }, 300); // 300ms delay to prevent rapid updates - } - - /** - * Handle button click in settings - */ - handleButtonClick(event, processorId) { - const button = event.target; - const paramName = button.dataset.param; - - if (!paramName) { - console.warn('Button missing param data:', button); - return; - } - - // Prevent multiple clicks while processing - if (button.disabled) { - console.log('Button already processing, ignoring click'); - return; - } - - console.log(` Button clicked: ${processorId}.${paramName}`); - - // Temporarily disable button and show feedback - const originalText = button.textContent; - button.disabled = true; - button.textContent = '...'; - - // Send button action via WebSocket (set to true to trigger action) - only once - const websocket = window.vnaDashboard?.websocket; - if (websocket && websocket.recalculate) { - console.log(` Sending button action: ${processorId}.${paramName} = true`); - websocket.recalculate(processorId, { [paramName]: true }); - } else { - console.warn('WebSocket not available for button action'); - } - - // Re-enable button after a short delay - setTimeout(() => { - button.disabled = false; - button.textContent = originalText; - }, 1000); } toggleProcessor(id, enabled) { enabled ? this.showChart(id) : this.hideChart(id); } + showChart(id) { const c = this.charts.get(id); if (c) { @@ -488,6 +287,7 @@ export class ChartManager { } this.updateEmptyStateVisibility(); } + hideChart(id) { const c = this.charts.get(id); if (c) { c.element.classList.add('chart-card--hidden'); c.isVisible = false; } @@ -523,7 +323,6 @@ export class ChartManager { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const baseFilename = `${id}_${timestamp}`; - // Download image await Plotly.downloadImage(c.plotContainer, { format: 'png', width: 1200, @@ -531,8 +330,7 @@ export class ChartManager { filename: `${baseFilename}_plot` }); - // Prepare and download processor data - const processorData = this.prepareProcessorDownloadData(id); + const processorData = this.prepareDownloadData(id); if (processorData) { downloadJSON(processorData, `${baseFilename}_data.json`); } @@ -552,7 +350,7 @@ export class ChartManager { } } - prepareProcessorDownloadData(processorId) { + prepareDownloadData(processorId) { const chart = this.charts.get(processorId); const latestData = this.chartData.get(processorId); @@ -562,89 +360,17 @@ export class ChartManager { processor_info: { processor_id: processorId, processor_name: formatProcessorName(processorId), - download_timestamp: new Date().toISOString(), - is_visible: chart.isVisible + download_timestamp: new Date().toISOString() }, current_data: { data: safeClone(latestData.data), metadata: safeClone(latestData.metadata), timestamp: latestData.timestamp instanceof Date ? latestData.timestamp.toISOString() : latestData.timestamp, plotly_config: safeClone(latestData.plotly_config) - }, - plot_config: this.getCurrentPlotlyDataSafe(processorId), - ui_parameters: safeClone(this.getProcessorSettings(processorId)), - raw_sweep_data: this.extractProcessorRawData(latestData), - metadata: { - description: `VNA processor data export - ${formatProcessorName(processorId)}`, - format_version: "1.0", - exported_by: "VNA System Dashboard", - export_type: "processor_data", - contains: [ - "Current processor data and metadata", - "Plot configuration (Plotly format)", - "UI parameter settings", - "Raw measurement data if available", - "Processing results and statistics" - ] } }; } - extractProcessorRawData(latestData) { - // Extract raw data from processor results - if (!latestData || !latestData.data) return null; - - try { - const rawData = { - processor_results: this.safeStringify(latestData.data), - metadata: this.safeStringify(latestData.metadata) - }; - - // If this is magnitude processor, extract frequency/magnitude data - if (latestData.plotly_config && latestData.plotly_config.data) { - const plotData = latestData.plotly_config.data[0]; - if (plotData && plotData.x && plotData.y) { - rawData.processed_measurements = []; - const maxPoints = Math.min(plotData.x.length, plotData.y.length, 1000); // Limit to 1000 points - for (let i = 0; i < maxPoints; i++) { - rawData.processed_measurements.push({ - point_index: i, - x_value: plotData.x[i], - y_value: plotData.y[i], - x_unit: this.getAxisUnit(plotData, 'x'), - y_unit: this.getAxisUnit(plotData, 'y') - }); - } - } - } - - return rawData; - } catch (error) { - console.warn('Error extracting processor raw data:', error); - return { error: 'Failed to extract raw data' }; - } - } - - safeStringify(obj) { - try { - return JSON.parse(JSON.stringify(obj)); - } catch (e) { - return safeClone(obj); - } - } - - getAxisUnit(plotData, axis) { - // Try to determine units from plot data or layout - if (axis === 'x') { - // For frequency data, usually GHz - return plotData.name && plotData.name.includes('Frequency') ? 'GHz' : 'unknown'; - } else if (axis === 'y') { - // For magnitude data, usually dB - return plotData.name && plotData.name.includes('Magnitude') ? 'dB' : 'unknown'; - } - return 'unknown'; - } - toggleFullscreen(id) { const c = this.charts.get(id); if (!c?.element) return; @@ -668,6 +394,7 @@ export class ChartManager { hideEmptyState() { if (this.emptyState) this.emptyState.classList.add('empty-state--hidden'); } + updateEmptyStateVisibility() { if (!this.emptyState) return; const hasVisible = Array.from(this.charts.values()).some(c => c.isVisible); @@ -684,100 +411,8 @@ export class ChartManager { pause() { this.isPaused = true; console.log('Chart updates paused'); } resume() { this.isPaused = false; console.log('Chart updates resumed'); if (this.updateQueue.size) this.processUpdateQueue(); } - - /** - * Get current Plotly data/layout for a processor - */ - getCurrentPlotlyData(processorId) { - const chart = this.charts.get(processorId); - if (!chart?.plotContainer?._fullData || !chart?.plotContainer?._fullLayout) { - return null; - } - - try { - return { - data: chart.plotContainer._fullData, - layout: { - title: chart.plotContainer._fullLayout.title, - xaxis: chart.plotContainer._fullLayout.xaxis, - yaxis: chart.plotContainer._fullLayout.yaxis, - showlegend: chart.plotContainer._fullLayout.showlegend, - legend: chart.plotContainer._fullLayout.legend - } - }; - } catch (error) { - console.warn(` Could not extract Plotly data for ${processorId}:`, error); - return null; - } - } - - /** - * Safe version of getCurrentPlotlyData that avoids circular references - */ - getCurrentPlotlyDataSafe(processorId) { - const chart = this.charts.get(processorId); - if (!chart?.plotContainer?._fullData || !chart?.plotContainer?._fullLayout) { - return null; - } - - try { - // Extract only essential plot data to avoid circular references - const data = []; - if (chart.plotContainer._fullData) { - chart.plotContainer._fullData.forEach(trace => { - data.push({ - x: trace.x ? Array.from(trace.x) : null, - y: trace.y ? Array.from(trace.y) : null, - type: trace.type, - mode: trace.mode, - name: trace.name, - line: trace.line ? { - color: trace.line.color, - width: trace.line.width - } : null, - marker: trace.marker ? { - color: trace.marker.color, - size: trace.marker.size - } : null - }); - }); - } - - const layout = {}; - if (chart.plotContainer._fullLayout) { - const fullLayout = chart.plotContainer._fullLayout; - layout.title = typeof fullLayout.title === 'string' ? fullLayout.title : fullLayout.title?.text; - layout.xaxis = { - title: fullLayout.xaxis?.title, - range: fullLayout.xaxis?.range - }; - layout.yaxis = { - title: fullLayout.yaxis?.title, - range: fullLayout.yaxis?.range - }; - layout.showlegend = fullLayout.showlegend; - } - - return { data, layout }; - } catch (error) { - console.warn(` Could not extract safe Plotly data for ${processorId}:`, error); - return null; - } - } - - /** - * Get current processor settings (UI parameters) - */ - getProcessorSettings(processorId) { - const latestData = this.chartData.get(processorId); - if (!latestData) { - return null; - } - - return latestData.metadata?.ui_parameters || null; - } - getDisabledProcessors() { return Array.from(this.disabledProcessors); } + getStats() { return { ...this.performanceStats, @@ -792,9 +427,10 @@ export class ChartManager { destroy() { console.log('Cleaning up Chart Manager...'); this.clearAll(); + this.settingsManager.destroy(); this.updateQueue.clear(); this.isUpdating = false; this.isPaused = true; console.log('Chart Manager cleanup complete'); } -} +} \ No newline at end of file diff --git a/vna_system/web_ui/static/js/modules/charts/chart-settings.js b/vna_system/web_ui/static/js/modules/charts/chart-settings.js new file mode 100644 index 0000000..be76c67 --- /dev/null +++ b/vna_system/web_ui/static/js/modules/charts/chart-settings.js @@ -0,0 +1,174 @@ +/** + * Chart Settings Manager + * Handles chart parameter controls and WebSocket communication + */ + +import { createParameterControl } from '../utils.js'; + +export class ChartSettingsManager { + constructor() { + this.lastSettingValues = {}; + this.settingDebounceTimers = {}; + this.lastUiParameters = new Map(); + } + + updateSettings(processorId, settingsContainer, latestData) { + if (!settingsContainer) return; + + const uiParameters = latestData?.metadata?.ui_parameters; + + // Check if parameters have changed + const lastParams = this.lastUiParameters.get(processorId); + const parametersChanged = !lastParams || JSON.stringify(uiParameters) !== JSON.stringify(lastParams); + + if (!parametersChanged) { + return; + } + + console.log(`Updating settings for ${processorId}`); + this.lastUiParameters.set(processorId, uiParameters ? JSON.parse(JSON.stringify(uiParameters)) : null); + + // Fallback to UI manager if no data available + if (!uiParameters || !Array.isArray(uiParameters) || uiParameters.length === 0) { + const uiManager = window.vnaDashboard?.ui; + const processor = uiManager?.processors?.get(processorId); + + if (processor?.uiParameters && Array.isArray(processor.uiParameters) && processor.uiParameters.length > 0) { + const settingsHtml = processor.uiParameters.map(param => + createParameterControl(param, processorId, 'chart') + ).join(''); + settingsContainer.innerHTML = settingsHtml; + this.setupEvents(settingsContainer, processorId); + return; + } + + settingsContainer.innerHTML = '
No settings available
'; + return; + } + + // Generate settings HTML + const settingsHtml = uiParameters.map(param => + createParameterControl(param, processorId, 'chart') + ).join(''); + + settingsContainer.innerHTML = settingsHtml; + this.setupEvents(settingsContainer, processorId); + + // Initialize last values + if (uiParameters) { + uiParameters.forEach(param => { + const settingKey = `${processorId}_${param.name}`; + this.lastSettingValues[settingKey] = param.value; + }); + } + + if (typeof lucide !== 'undefined') { + lucide.createIcons({ attrs: { 'stroke-width': 1.5 } }); + } + } + + setupEvents(settingsContainer, processorId) { + const onParamChange = (e) => { + if (!e.target.closest('.chart-setting')) return; + this.handleSettingChange(e, processorId); + }; + + const onButtonClick = (e) => { + if (!e.target.classList.contains('chart-setting__button')) return; + this.handleButtonClick(e, processorId); + }; + + settingsContainer.addEventListener('input', onParamChange); + settingsContainer.addEventListener('change', onParamChange); + settingsContainer.addEventListener('click', onButtonClick); + } + + handleSettingChange(event, processorId) { + const settingElement = event.target.closest('.chart-setting'); + if (!settingElement) return; + + const paramName = settingElement.dataset.param; + const input = event.target; + + let value; + if (input.type === 'checkbox') { + value = input.checked; + } else if (input.type === 'range') { + value = parseFloat(input.value); + const valueDisplay = settingElement.querySelector('.chart-setting__value'); + if (valueDisplay) valueDisplay.textContent = value; + } else { + value = input.value; + } + + // Normalize boolean values + if (typeof value === 'string' && (value === 'true' || value === 'false')) { + value = value === 'true'; + } + + const settingKey = `${processorId}_${paramName}`; + let lastValue = this.lastSettingValues[settingKey]; + if (typeof lastValue === 'string' && (lastValue === 'true' || lastValue === 'false')) { + lastValue = lastValue === 'true'; + } + + // Check for duplicate + if (lastValue === value) { + return; + } + + this.lastSettingValues[settingKey] = value; + + // Debounce updates + const debounceKey = `${processorId}_${paramName}`; + if (this.settingDebounceTimers[debounceKey]) { + clearTimeout(this.settingDebounceTimers[debounceKey]); + } + + this.settingDebounceTimers[debounceKey] = setTimeout(() => { + console.log(`Sending setting update: ${processorId}.${paramName} = ${value}`); + const websocket = window.vnaDashboard?.websocket; + if (websocket && websocket.recalculate) { + websocket.recalculate(processorId, { [paramName]: value }); + } else { + console.warn('WebSocket not available for settings update'); + } + delete this.settingDebounceTimers[debounceKey]; + }, 300); + } + + handleButtonClick(event, processorId) { + const button = event.target; + const paramName = button.dataset.param; + + if (!paramName || button.disabled) return; + + console.log(`Button clicked: ${processorId}.${paramName}`); + + const originalText = button.textContent; + button.disabled = true; + button.textContent = '...'; + + const websocket = window.vnaDashboard?.websocket; + if (websocket && websocket.recalculate) { + websocket.recalculate(processorId, { [paramName]: true }); + } else { + console.warn('WebSocket not available for button action'); + } + + setTimeout(() => { + button.disabled = false; + button.textContent = originalText; + }, 1000); + } + + destroy() { + // Clear timers + Object.keys(this.settingDebounceTimers).forEach(key => { + clearTimeout(this.settingDebounceTimers[key]); + }); + this.settingDebounceTimers = {}; + this.lastSettingValues = {}; + this.lastUiParameters.clear(); + } +} \ No newline at end of file diff --git a/vna_system/web_ui/static/js/modules/notifications.js b/vna_system/web_ui/static/js/modules/notifications.js index 6d4a5aa..70d1fc8 100644 --- a/vna_system/web_ui/static/js/modules/notifications.js +++ b/vna_system/web_ui/static/js/modules/notifications.js @@ -3,6 +3,8 @@ * Handles toast notifications and user feedback */ +import { escapeHtml } from './utils.js'; + export class NotificationManager { constructor() { this.container = null; @@ -183,10 +185,10 @@ export class NotificationManager { const iconHtml = ``; const titleHtml = notification.title ? - `
${this.escapeHtml(notification.title)}
` : ''; + `
${escapeHtml(notification.title)}
` : ''; const messageHtml = notification.message ? - `
${this.escapeHtml(notification.message)}
` : ''; + `
${escapeHtml(notification.message)}
` : ''; const actionsHtml = notification.actions.length > 0 ? this.createActionsHtml(notification.actions) : ''; @@ -224,7 +226,7 @@ export class NotificationManager { return ` `; }).join(''); @@ -471,18 +473,6 @@ export class NotificationManager { return Math.max(...notifications.map(n => n.createdAt.getTime())); } - /** - * Escape HTML to prevent XSS - */ - escapeHtml(unsafe) { - return unsafe - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); - } - /** * Cleanup */ diff --git a/vna_system/web_ui/static/js/modules/settings.js b/vna_system/web_ui/static/js/modules/settings.js index 55eec18..3e4c5ce 100644 --- a/vna_system/web_ui/static/js/modules/settings.js +++ b/vna_system/web_ui/static/js/modules/settings.js @@ -1,1372 +1,538 @@ /** * Settings Manager Module - * - Manage VNA presets - * - Manage calibrations (working/current) - * - Build calibration standard plots (Plotly) - * - Protect from multiple requests: debounce + runExclusive (mutexes) - * - Correct subscription/unsubscription to WebSocket events with the same handler + * Coordinates preset, calibration, and reference management */ -import { Debouncer, RequestGuard, ButtonState, downloadJSON } from './utils.js'; +import { PresetManager } from './settings/preset-manager.js'; +import { CalibrationManager } from './settings/calibration-manager.js'; +import { ReferenceManager } from './settings/reference-manager.js'; +import { Debouncer, ButtonState, downloadJSON } from './utils.js'; export class SettingsManager { - /** - * @param {object} notifications - Object with .show({type,title,message}) - * @param {object} websocket - Object with .on(event, handler) / .off(event, handler) - * @param {object} acquisition - Object with .isRunning() / .triggerSingleSweep() - */ - constructor(notifications, websocket, acquisition) { - // Dependencies - this.notifications = notifications; - this.websocket = websocket; - this.acquisition = acquisition; + constructor(notifications, websocket, acquisition) { + this.notifications = notifications; + this.websocket = websocket; + this.acquisition = acquisition; - // State - this.isInitialized = false; - this.currentPreset = null; - this.currentCalibration = null; - this.workingCalibration = null; - this.availableReferences = []; - this.currentReference = null; + this.isInitialized = false; + this.elements = {}; + this.debouncer = new Debouncer(); - // Calibration: capture state - this.disabledStandards = new Set(); + // Sub-managers + this.presetManager = new PresetManager(notifications); + this.calibrationManager = new CalibrationManager(notifications); + this.referenceManager = new ReferenceManager(notifications); - // DOM cache - this.elements = {}; + // Plots modal state + this.currentPlotsData = null; - // Guards - this.debouncer = new Debouncer(); - this.reqGuard = new RequestGuard(); - - // Bind UI handlers - this.handlePresetChange = this.handlePresetChange.bind(this); - this.handleSetPreset = this.handleSetPreset.bind(this); - this.handleStartCalibration = this.handleStartCalibration.bind(this); - this.handleCalibrateStandard = this.handleCalibrateStandard.bind(this); - this.handleSaveCalibration = this.handleSaveCalibration.bind(this); - this.handleSetCalibration = this.handleSetCalibration.bind(this); - this.handleCalibrationChange = this.handleCalibrationChange.bind(this); - this.handleViewPlots = this.handleViewPlots.bind(this); - this.handleViewCurrentPlots = this.handleViewCurrentPlots.bind(this); - - // Reference handlers - this.handleCreateReference = this.handleCreateReference.bind(this); - this.handleReferenceChange = this.handleReferenceChange.bind(this); - this.handleSetReference = this.handleSetReference.bind(this); - this.handleClearReference = this.handleClearReference.bind(this); - this.handleDeleteReference = this.handleDeleteReference.bind(this); - - // Data package for plots modal - this.currentPlotsData = null; - } - - /* ----------------------------- Lifecycle ----------------------------- */ - - async init() { - try { - this._cacheDom(); - this._attachEvents(); - - await this._loadInitialData(); - - this.isInitialized = true; - console.log('Settings Manager initialized'); - } catch (err) { - console.error('Settings Manager init failed:', err); - this._notify('error', 'Settings Error', 'Failed to initialize settings'); - } - } - - destroy() { - // Clean state and subscriptions - this._resetCalibrationCaptureState(); - this._detachEvents(); - this.isInitialized = false; - console.log('Settings Manager destroyed'); - } - - async refresh() { - if (!this.isInitialized) return; - await this._loadInitialData(); - } - - /* ----------------------------- DOM ----------------------------- */ - - _cacheDom() { - this.elements = { - // Presets - presetDropdown: document.getElementById('presetDropdown'), - setPresetBtn: document.getElementById('setPresetBtn'), - currentPreset: document.getElementById('currentPreset'), - - // Calibration - currentCalibration: document.getElementById('currentCalibration'), - startCalibrationBtn: document.getElementById('startCalibrationBtn'), - calibrationSteps: document.getElementById('calibrationSteps'), - calibrationStandards: document.getElementById('calibrationStandards'), - progressText: document.getElementById('progressText'), - calibrationNameInput: document.getElementById('calibrationNameInput'), - saveCalibrationBtn: document.getElementById('saveCalibrationBtn'), - calibrationDropdown: document.getElementById('calibrationDropdown'), - setCalibrationBtn: document.getElementById('setCalibrationBtn'), - viewPlotsBtn: document.getElementById('viewPlotsBtn'), - viewCurrentPlotsBtn: document.getElementById('viewCurrentPlotsBtn'), - - // Modal - plotsModal: document.getElementById('plotsModal'), - plotsGrid: document.getElementById('plotsGrid'), - downloadAllBtn: document.getElementById('downloadAllBtn'), - - // References - referenceNameInput: document.getElementById('referenceNameInput'), - referenceDescriptionInput: document.getElementById('referenceDescriptionInput'), - createReferenceBtn: document.getElementById('createReferenceBtn'), - referenceDropdown: document.getElementById('referenceDropdown'), - setReferenceBtn: document.getElementById('setReferenceBtn'), - clearReferenceBtn: document.getElementById('clearReferenceBtn'), - deleteReferenceBtn: document.getElementById('deleteReferenceBtn'), - currentReferenceInfo: document.getElementById('currentReferenceInfo'), - currentReferenceName: document.getElementById('currentReferenceName'), - currentReferenceTimestamp: document.getElementById('currentReferenceTimestamp'), - currentReferenceDescription: document.getElementById('currentReferenceDescription'), - - // Status - presetCount: document.getElementById('presetCount'), - calibrationCount: document.getElementById('calibrationCount'), - systemStatus: document.getElementById('systemStatus') - }; - } - - _attachEvents() { - // Presets - this.elements.presetDropdown?.addEventListener('change', this.handlePresetChange); - this.elements.setPresetBtn?.addEventListener('click', this.handleSetPreset); - - // Calibration - this.elements.startCalibrationBtn?.addEventListener('click', this.handleStartCalibration); - this.elements.saveCalibrationBtn?.addEventListener('click', this.handleSaveCalibration); - this.elements.calibrationDropdown?.addEventListener('change', this.handleCalibrationChange); - this.elements.setCalibrationBtn?.addEventListener('click', this.handleSetCalibration); - this.elements.viewPlotsBtn?.addEventListener('click', this.handleViewPlots); - this.elements.viewCurrentPlotsBtn?.addEventListener('click', this.handleViewCurrentPlots); - - // References - this.elements.createReferenceBtn?.addEventListener('click', this.handleCreateReference); - this.elements.referenceDropdown?.addEventListener('change', this.handleReferenceChange); - this.elements.setReferenceBtn?.addEventListener('click', this.handleSetReference); - this.elements.clearReferenceBtn?.addEventListener('click', this.handleClearReference); - this.elements.deleteReferenceBtn?.addEventListener('click', this.handleDeleteReference); - - // Name input → enables Save - this.elements.calibrationNameInput?.addEventListener('input', () => { - const hasName = this.elements.calibrationNameInput.value.trim().length > 0; - const isComplete = this.workingCalibration && this.workingCalibration.is_complete; - this.elements.saveCalibrationBtn.disabled = !hasName || !isComplete; - }); - } - - _detachEvents() { - this.elements.presetDropdown?.removeEventListener('change', this.handlePresetChange); - this.elements.setPresetBtn?.removeEventListener('click', this.handleSetPreset); - this.elements.startCalibrationBtn?.removeEventListener('click', this.handleStartCalibration); - this.elements.saveCalibrationBtn?.removeEventListener('click', this.handleSaveCalibration); - this.elements.calibrationDropdown?.removeEventListener('change', this.handleCalibrationChange); - this.elements.setCalibrationBtn?.removeEventListener('click', this.handleSetCalibration); - this.elements.viewPlotsBtn?.removeEventListener('click', this.handleViewPlots); - this.elements.viewCurrentPlotsBtn?.removeEventListener('click', this.handleViewCurrentPlots); - - // References - this.elements.createReferenceBtn?.removeEventListener('click', this.handleCreateReference); - this.elements.referenceDropdown?.removeEventListener('change', this.handleReferenceChange); - this.elements.setReferenceBtn?.removeEventListener('click', this.handleSetReference); - this.elements.clearReferenceBtn?.removeEventListener('click', this.handleClearReference); - this.elements.deleteReferenceBtn?.removeEventListener('click', this.handleDeleteReference); - - // WebSocket - if (this.websocket) { - this.websocket.off?.('processor_result', this._boundHandleSweepForCalibration); - } - } - - /* ----------------------------- Data Loading ----------------------------- */ - - async _loadInitialData() { - await Promise.all([ - this._loadPresets(), - this._loadStatus(), - this._loadWorkingCalibration(), - this._loadReferences() - ]); - } - - async _loadPresets() { - try { - const r = await fetch('/api/v1/settings/presets'); - if (!r.ok) throw new Error(`HTTP ${r.status}`); - const presets = await r.json(); - this._populatePresetDropdown(presets); - } catch (e) { - console.error('Presets load failed:', e); - this._notify('error', 'Load Error', 'Failed to load configuration presets'); - } - } - - async _loadStatus() { - try { - const r = await fetch('/api/v1/settings/status'); - if (!r.ok) throw new Error(`HTTP ${r.status}`); - const status = await r.json(); - this._updateStatusDisplay(status); - } catch (e) { - console.error('Status load failed:', e); - } - } - - async _loadWorkingCalibration() { - try { - const r = await fetch('/api/v1/settings/working-calibration'); - if (!r.ok) throw new Error(`HTTP ${r.status}`); - const working = await r.json(); - this._updateWorkingCalibration(working); - } catch (e) { - console.error('Working calibration load failed:', e); - } - } - - async _loadCalibrations() { - if (!this.currentPreset) return; - try { - const r = await fetch(`/api/v1/settings/calibrations?preset_filename=${encodeURIComponent(this.currentPreset.filename)}`); - if (!r.ok) throw new Error(`HTTP ${r.status}`); - const calibrations = await r.json(); - this._populateCalibrationDropdown(calibrations); - } catch (e) { - console.error('Calibrations load failed:', e); - } - } - - /* ----------------------------- UI Populate ----------------------------- */ - - _populatePresetDropdown(presets) { - const dd = this.elements.presetDropdown; - dd.innerHTML = ''; - - if (!presets.length) { - dd.innerHTML = ''; - dd.disabled = true; - this.elements.setPresetBtn.disabled = true; - return; + // Bind handlers + this.handleViewPlots = this.handleViewPlots.bind(this); + this.handleViewCurrentPlots = this.handleViewCurrentPlots.bind(this); } - dd.innerHTML = ''; - presets.forEach(p => { - const opt = document.createElement('option'); - opt.value = p.filename; - opt.textContent = this._formatPresetDisplay(p); - dd.appendChild(opt); - }); - - dd.disabled = false; - this.elements.setPresetBtn.disabled = true; - } - - _populateCalibrationDropdown(calibrations) { - const dd = this.elements.calibrationDropdown; - dd.innerHTML = ''; - - if (!calibrations.length) { - dd.innerHTML = ''; - dd.disabled = true; - this.elements.setCalibrationBtn.disabled = true; - this.elements.viewPlotsBtn.disabled = true; - return; - } - - dd.innerHTML = ''; - calibrations.forEach(c => { - const opt = document.createElement('option'); - opt.value = c.name; - opt.textContent = `${c.name} ${c.is_complete ? '✔' : '?'}`; - dd.appendChild(opt); - }); - - dd.disabled = false; - this.elements.setCalibrationBtn.disabled = true; - this.elements.viewPlotsBtn.disabled = true; - } - - _formatPresetDisplay(p) { - let s = `${p.filename} (${p.mode})`; - if (p.start_freq && p.stop_freq) { - const startMHz = (p.start_freq / 1e6).toFixed(0); - const stopMHz = (p.stop_freq / 1e6).toFixed(0); - s += ` - ${startMHz}-${stopMHz}MHz`; - } - if (p.points) s += `, ${p.points}pts`; - return s; - } - - _updateStatusDisplay(status) { - // preset - if (status.current_preset) { - this.currentPreset = status.current_preset; - this.elements.currentPreset.textContent = status.current_preset.filename; - this.elements.startCalibrationBtn.disabled = false; - this._loadCalibrations(); - } else { - this.currentPreset = null; - this.elements.currentPreset.textContent = 'None'; - this.elements.startCalibrationBtn.disabled = true; - } - - // active calibration - if (status.current_calibration) { - this.currentCalibration = status.current_calibration; - this.elements.currentCalibration.textContent = status.current_calibration.calibration_name; - } else { - this.currentCalibration = null; - this.elements.currentCalibration.textContent = 'None'; - } - - // counts - this.elements.presetCount.textContent = status.available_presets || 0; - this.elements.calibrationCount.textContent = status.available_calibrations || 0; - this.elements.systemStatus.textContent = 'Ready'; - } - - _updateWorkingCalibration(working) { - this.workingCalibration = working; - if (working.active) { - this._showCalibrationSteps(working); - } else { - this._hideCalibrationSteps(); - } - } - - _showCalibrationSteps(working) { - this.elements.calibrationSteps.style.display = 'block'; - this.elements.progressText.textContent = working.progress || '0/0'; - - this._renderStandardButtons(working); - - const hasName = this.elements.calibrationNameInput.value.trim().length > 0; - this.elements.saveCalibrationBtn.disabled = !hasName || !working.is_complete; - this.elements.calibrationNameInput.disabled = false; - - const hasCompleted = (working.completed_standards || []).length > 0; - if (this.elements.viewCurrentPlotsBtn) { - this.elements.viewCurrentPlotsBtn.disabled = !hasCompleted; - } - } - - _hideCalibrationSteps() { - this.elements.calibrationSteps.style.display = 'none'; - this.elements.calibrationStandards.innerHTML = ''; - if (this.elements.viewCurrentPlotsBtn) { - this.elements.viewCurrentPlotsBtn.disabled = true; - } - } - - _renderStandardButtons(working) { - const container = this.elements.calibrationStandards; - container.innerHTML = ''; - - const all = this._standardsForCurrentMode(); - const completed = working.completed_standards || []; - const missing = working.missing_standards || []; - - all.forEach(std => { - const btn = document.createElement('button'); - btn.className = 'btn calibration-standard-btn'; - btn.dataset.standard = std; - - const isCompleted = completed.includes(std); - const isMissing = missing.includes(std); - const capturing = this.disabledStandards.has(std); - - if (capturing) { - btn.classList.add('btn--warning'); - btn.innerHTML = ` Capturing ${std.toUpperCase()}...`; - btn.disabled = true; - btn.title = 'Standard is currently being captured'; - } else if (isCompleted) { - btn.classList.add('btn--success'); - btn.innerHTML = ` ${std.toUpperCase()}`; - btn.disabled = false; - btn.title = 'Click to recapture this standard'; - } else if (isMissing) { - btn.classList.add('btn--primary'); - btn.innerHTML = ` Capture ${std.toUpperCase()}`; - btn.disabled = false; - btn.title = 'Click to capture this standard'; - } else { - btn.classList.add('btn--secondary'); - btn.innerHTML = `${std.toUpperCase()}`; - btn.disabled = true; - } - - btn.addEventListener('click', () => this.handleCalibrateStandard(std)); - container.appendChild(btn); - }); - - if (typeof lucide !== 'undefined') { - lucide.createIcons(); - } - } - - _standardsForCurrentMode() { - if (!this.currentPreset) return []; - if (this.currentPreset.mode === 's11') return ['open', 'short', 'load']; - if (this.currentPreset.mode === 's21') return ['through']; - return []; - } - - _resetCalibrationStateForPresetChange() { - this.workingCalibration = null; - this._hideCalibrationSteps(); - if (this.elements.calibrationNameInput) { - this.elements.calibrationNameInput.value = ''; - this.elements.calibrationNameInput.disabled = true; - } - if (this.elements.saveCalibrationBtn) this.elements.saveCalibrationBtn.disabled = true; - if (this.elements.progressText) this.elements.progressText.textContent = '0/0'; - - // Reset reference state - this.availableReferences = []; - this.currentReference = null; - console.log('Calibration and reference UI reset after preset change'); - } - - /* ----------------------------- Event Handlers (UI) ----------------------------- */ - - handlePresetChange() { - const v = this.elements.presetDropdown.value; - this.elements.setPresetBtn.disabled = !v; - } - - handleCalibrationChange() { - const v = this.elements.calibrationDropdown.value; - this.elements.setCalibrationBtn.disabled = !v; - this.elements.viewPlotsBtn.disabled = !v; - } - - async handleSetPreset() { - const filename = this.elements.presetDropdown.value; - if (!filename) return; - - this.debouncer.debounce('set-preset', () => - this.reqGuard.runExclusive('set-preset', async () => { + async init() { try { - ButtonState.set(this.elements.setPresetBtn, { state: 'loading', icon: 'loader', text: 'Setting...' }); + this.cacheDom(); + this.initSubManagers(); + this.setupEventHandlers(); + await this.loadInitialData(); - const r = await fetch('/api/v1/settings/preset/set', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ filename }) - }); - if (!r.ok) throw new Error(`HTTP ${r.status}`); - const result = await r.json(); - - this._notify('success', 'Preset Set', result.message); - - // Reset calibration UI - this._resetCalibrationStateForPresetChange(); - - // Update status - await this._loadStatus(); - } catch (e) { - console.error('Set preset failed:', e); - this._notify('error', 'Preset Error', 'Failed to set configuration preset'); - } finally { - ButtonState.set(this.elements.setPresetBtn, { state: 'normal', icon: 'check', text: 'Set Active' }); + this.isInitialized = true; + console.log('Settings Manager initialized'); + } catch (err) { + console.error('Settings Manager init failed:', err); + this.notify('error', 'Settings Error', 'Failed to initialize settings'); } - }), 300 - ); - } + } - async handleStartCalibration() { - if (!this.currentPreset) return; + cacheDom() { + this.elements = { + // Presets + presetDropdown: document.getElementById('presetDropdown'), + setPresetBtn: document.getElementById('setPresetBtn'), + currentPreset: document.getElementById('currentPreset'), - this.debouncer.debounce('start-calibration', () => - this.reqGuard.runExclusive('start-calibration', async () => { + // Calibration + currentCalibration: document.getElementById('currentCalibration'), + startCalibrationBtn: document.getElementById('startCalibrationBtn'), + calibrationSteps: document.getElementById('calibrationSteps'), + calibrationStandards: document.getElementById('calibrationStandards'), + progressText: document.getElementById('progressText'), + calibrationNameInput: document.getElementById('calibrationNameInput'), + saveCalibrationBtn: document.getElementById('saveCalibrationBtn'), + calibrationDropdown: document.getElementById('calibrationDropdown'), + setCalibrationBtn: document.getElementById('setCalibrationBtn'), + viewPlotsBtn: document.getElementById('viewPlotsBtn'), + viewCurrentPlotsBtn: document.getElementById('viewCurrentPlotsBtn'), + + // Modal + plotsModal: document.getElementById('plotsModal'), + plotsGrid: document.getElementById('plotsGrid'), + downloadAllBtn: document.getElementById('downloadAllBtn'), + + // References + referenceNameInput: document.getElementById('referenceNameInput'), + referenceDescriptionInput: document.getElementById('referenceDescriptionInput'), + createReferenceBtn: document.getElementById('createReferenceBtn'), + referenceDropdown: document.getElementById('referenceDropdown'), + setReferenceBtn: document.getElementById('setReferenceBtn'), + clearReferenceBtn: document.getElementById('clearReferenceBtn'), + deleteReferenceBtn: document.getElementById('deleteReferenceBtn'), + currentReferenceInfo: document.getElementById('currentReferenceInfo'), + currentReferenceName: document.getElementById('currentReferenceName'), + currentReferenceTimestamp: document.getElementById('currentReferenceTimestamp'), + currentReferenceDescription: document.getElementById('currentReferenceDescription'), + + // Status + presetCount: document.getElementById('presetCount'), + calibrationCount: document.getElementById('calibrationCount'), + systemStatus: document.getElementById('systemStatus') + }; + } + + initSubManagers() { + this.presetManager.init(this.elements); + this.calibrationManager.init(this.elements); + this.referenceManager.init(this.elements); + + // Setup callbacks + this.presetManager.onPresetChanged = async () => { + await this.loadStatus(); + const preset = this.presetManager.getCurrentPreset(); + this.calibrationManager.setCurrentPreset(preset); + this.calibrationManager.reset(); + this.referenceManager.setCurrentPreset(preset); + }; + + this.calibrationManager.onCalibrationSaved = async () => { + await this.loadStatus(); + }; + + this.calibrationManager.onCalibrationSet = async () => { + await this.loadStatus(); + }; + } + + setupEventHandlers() { + this.elements.viewPlotsBtn?.addEventListener('click', this.handleViewPlots); + this.elements.viewCurrentPlotsBtn?.addEventListener('click', this.handleViewCurrentPlots); + } + + async loadInitialData() { + await Promise.all([ + this.presetManager.loadPresets(), + this.loadStatus(), + this.calibrationManager.loadWorkingCalibration(), + this.referenceManager.loadReferences() + ]); + } + + async loadStatus() { try { - ButtonState.set(this.elements.startCalibrationBtn, { state: 'loading', icon: 'loader', text: 'Starting...' }); - - const r = await fetch('/api/v1/settings/calibration/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ preset_filename: this.currentPreset.filename }) - }); - if (!r.ok) throw new Error(`HTTP ${r.status}`); - const result = await r.json(); - - this._notify('info', 'Calibration Started', `Started calibration for ${result.preset}`); - - await this._loadWorkingCalibration(); + const r = await fetch('/api/v1/settings/status'); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const status = await r.json(); + this.updateStatusDisplay(status); } catch (e) { - console.error('Start calibration failed:', e); - this._notify('error', 'Calibration Error', 'Failed to start calibration'); - } finally { - ButtonState.set(this.elements.startCalibrationBtn, { state: 'normal', icon: 'play', text: 'Start Calibration' }); + console.error('Status load failed:', e); } - }), 400 - ); - } + } - async handleCalibrateStandard(standard) { - const key = `calibrate-${standard}`; - if (this.disabledStandards.has(standard)) return; + updateStatusDisplay(status) { + this.presetManager.updateStatus(status); + this.calibrationManager.updateStatus(status); - this.debouncer.debounce(key, () => - this.reqGuard.runExclusive(key, async () => { - try { - // Mark standard as busy - this.disabledStandards.add(standard); + const preset = this.presetManager.getCurrentPreset(); + this.calibrationManager.setCurrentPreset(preset); + this.referenceManager.setCurrentPreset(preset); - const btn = document.querySelector(`[data-standard="${standard}"]`); - ButtonState.set(btn, { state: 'loading', icon: 'upload', text: 'Capturing...' }); + this.elements.presetCount.textContent = status.available_presets || 0; + this.elements.calibrationCount.textContent = status.available_calibrations || 0; + this.elements.systemStatus.textContent = 'Ready'; + } - this._notify('info', 'Capturing Standard', `Capturing ${standard.toUpperCase()} standard...`); + async handleViewPlots() { + this.debouncer.debounce('view-plots', async () => { + const name = this.elements.calibrationDropdown.value; + const preset = this.presetManager.getCurrentPreset(); + if (!name || !preset) return; - // Direct API call - backend will handle waiting and triggering - const r = await fetch('/api/v1/settings/calibration/add-standard', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ standard }) - }); + try { + ButtonState.set(this.elements.viewPlotsBtn, { state: 'loading', icon: 'loader', text: 'Loading...' }); - if (!r.ok) throw new Error(`HTTP ${r.status}`); - const result = await r.json(); + const url = `/api/v1/settings/calibration/${encodeURIComponent(name)}/standards-plots?preset_filename=${encodeURIComponent(preset.filename)}`; + const r = await fetch(url); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const plotsData = await r.json(); - this._notify('success', 'Standard Captured', result.message); - - // Reset state - this._resetCalibrationCaptureState(); - - // Reload working calibration - await this._loadWorkingCalibration(); - } catch (e) { - console.error('Capture standard failed:', e); - this._notify('error', 'Calibration Error', 'Failed to capture calibration standard'); - this._resetCalibrationCaptureState(standard); - } - }), 500 - ); - } - - async handleSaveCalibration() { - const name = this.elements.calibrationNameInput.value.trim(); - if (!name) return; - - this.debouncer.debounce('save-calibration', () => - this.reqGuard.runExclusive('save-calibration', async () => { - try { - ButtonState.set(this.elements.saveCalibrationBtn, { state: 'loading', icon: 'loader', text: 'Saving...' }); - - const r = await fetch('/api/v1/settings/calibration/save', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name }) - }); - if (!r.ok) throw new Error(`HTTP ${r.status}`); - const result = await r.json(); - - this._notify('success', 'Calibration Saved', result.message); - - // Clear working calibration in UI - this._hideCalibrationSteps(); - this.elements.calibrationNameInput.value = ''; - - await Promise.all([ - this._loadStatus(), - this._loadWorkingCalibration(), - this._loadCalibrations() - ]); - } catch (e) { - console.error('Save calibration failed:', e); - this._notify('error', 'Calibration Error', 'Failed to save calibration'); - } finally { - ButtonState.set(this.elements.saveCalibrationBtn, { state: 'disabled', icon: 'save', text: 'Save Calibration' }); - } - }), 400 - ); - } - - async handleSetCalibration() { - this.debouncer.debounce('set-calibration', () => - this.reqGuard.runExclusive('set-calibration', async () => { - const name = this.elements.calibrationDropdown.value; - if (!name || !this.currentPreset) return; - - try { - ButtonState.set(this.elements.setCalibrationBtn, { state: 'loading', icon: 'loader', text: 'Setting...' }); - - const r = await fetch('/api/v1/settings/calibration/set', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, preset_filename: this.currentPreset.filename }) - }); - if (!r.ok) throw new Error(`HTTP ${r.status}`); - const result = await r.json(); - - this._notify('success', 'Calibration Set', result.message); - - await this._loadStatus(); - } catch (e) { - console.error('Set calibration failed:', e); - this._notify('error', 'Calibration Error', 'Failed to set active calibration'); - } finally { - ButtonState.set(this.elements.setCalibrationBtn, { state: 'normal', icon: 'check', text: 'Set Active' }); - } - }), 300 - ); - } - - async handleViewPlots() { - this.debouncer.debounce('view-plots', () => - this.reqGuard.runExclusive('view-plots', async () => { - const name = this.elements.calibrationDropdown.value; - if (!name || !this.currentPreset) return; - - try { - ButtonState.set(this.elements.viewPlotsBtn, { state: 'loading', icon: 'loader', text: 'Loading...' }); - - const url = `/api/v1/settings/calibration/${encodeURIComponent(name)}/standards-plots?preset_filename=${encodeURIComponent(this.currentPreset.filename)}`; - const r = await fetch(url); - if (!r.ok) throw new Error(`HTTP ${r.status}`); - const plotsData = await r.json(); - - this._showPlotsModal(plotsData); - } catch (e) { - console.error('Load plots failed:', e); - this._notify('error', 'Plots Error', 'Failed to load calibration plots'); - } finally { - ButtonState.set(this.elements.viewPlotsBtn, { state: 'normal', icon: 'bar-chart-3', text: 'View Plots' }); - } - }), 300 - ); - } - - async handleViewCurrentPlots() { - this.debouncer.debounce('view-current-plots', () => - this.reqGuard.runExclusive('view-current-plots', async () => { - if (!this.workingCalibration || !this.workingCalibration.active) return; - - try { - ButtonState.set(this.elements.viewCurrentPlotsBtn, { state: 'loading', icon: 'loader', text: 'Loading...' }); - - const r = await fetch('/api/v1/settings/working-calibration/standards-plots'); - if (!r.ok) { - if (r.status === 404) { - this._notify('warning', 'No Data', 'No working calibration or standards available to plot'); - return; + this.showPlotsModal(plotsData); + } catch (e) { + console.error('Load plots failed:', e); + this.notify('error', 'Plots Error', 'Failed to load calibration plots'); + } finally { + ButtonState.set(this.elements.viewPlotsBtn, { state: 'normal', icon: 'bar-chart-3', text: 'View Plots' }); } - throw new Error(`HTTP ${r.status}`); - } - const plotsData = await r.json(); - this._showPlotsModal(plotsData); - } catch (e) { - console.error('Load current plots failed:', e); - this._notify('error', 'Plots Error', 'Failed to load current calibration plots'); - } finally { - ButtonState.set(this.elements.viewCurrentPlotsBtn, { state: 'normal', icon: 'bar-chart-3', text: 'View Current Plots' }); + }, 300); + } + + async handleViewCurrentPlots() { + this.debouncer.debounce('view-current-plots', async () => { + const working = this.calibrationManager.getWorkingCalibration(); + if (!working || !working.active) return; + + try { + ButtonState.set(this.elements.viewCurrentPlotsBtn, { state: 'loading', icon: 'loader', text: 'Loading...' }); + + const r = await fetch('/api/v1/settings/working-calibration/standards-plots'); + if (!r.ok) { + if (r.status === 404) { + this.notify('warning', 'No Data', 'No working calibration or standards available to plot'); + return; + } + throw new Error(`HTTP ${r.status}`); + } + const plotsData = await r.json(); + this.showPlotsModal(plotsData); + } catch (e) { + console.error('Load current plots failed:', e); + this.notify('error', 'Plots Error', 'Failed to load current calibration plots'); + } finally { + ButtonState.set(this.elements.viewCurrentPlotsBtn, { state: 'normal', icon: 'bar-chart-3', text: 'View Current Plots' }); + } + }, 300); + } + + showPlotsModal(plotsData) { + const modal = this.elements.plotsModal; + if (!modal) return; + + this.currentPlotsData = plotsData; + + this.renderCalibrationPlots(plotsData.individual_plots, plotsData.preset); + + const title = modal.querySelector('.modal__title'); + if (title) { + title.innerHTML = ` + + ${plotsData.calibration_name} - ${plotsData.preset.mode.toUpperCase()} Standards + `; + if (typeof lucide !== 'undefined') lucide.createIcons(); } - }), 300 - ); - } - /* ----------------------------- WebSocket sweep capture ----------------------------- */ + this.setupModalCloseHandlers(modal); - - _resetCalibrationCaptureState(standard = null) { - if (standard) this.disabledStandards.delete(standard); - else this.disabledStandards.clear(); - - if (this.workingCalibration) { - this._renderStandardButtons(this.workingCalibration); - } - } - - - /* ----------------------------- Plots Modal ----------------------------- */ - - _showPlotsModal(plotsData) { - const modal = this.elements.plotsModal; - if (!modal) return; - - // Store data package - this.currentPlotsData = plotsData; - - // Render cards/plots - this._renderCalibrationPlots(plotsData.individual_plots, plotsData.preset); - - // Header - const title = modal.querySelector('.modal__title'); - if (title) { - title.innerHTML = ` - - ${plotsData.calibration_name} - ${plotsData.preset.mode.toUpperCase()} Standards - `; - if (typeof lucide !== 'undefined') lucide.createIcons(); + modal.classList.add('modal--active'); + document.body.style.overflow = 'hidden'; } - // Close/download buttons - this._setupModalCloseHandlers(modal); + renderCalibrationPlots(individualPlots, preset) { + const container = this.elements.plotsGrid; + if (!container) return; - // Display - modal.classList.add('modal--active'); - document.body.style.overflow = 'hidden'; - } + container.innerHTML = ''; - _setupModalCloseHandlers(modal) { - modal.querySelectorAll('[data-modal-close]').forEach(el => { - el.addEventListener('click', () => this.closePlotsModal()); - }); + if (!individualPlots || !Object.keys(individualPlots).length) { + container.innerHTML = '
No calibration plots available
'; + return; + } - const downloadAllBtn = modal.querySelector('#downloadAllBtn'); - if (downloadAllBtn) { - downloadAllBtn.addEventListener('click', () => - this.debouncer.debounce('download-all', () => this.downloadAllCalibrationData(), 600) - ); + Object.entries(individualPlots).forEach(([name, plot]) => { + if (plot.error) { + const err = document.createElement('div'); + err.className = 'chart-card'; + err.innerHTML = ` +
+
+ + ${name.toUpperCase()} Standard +
+
+
+
Error: ${plot.error}
+
+ `; + container.appendChild(err); + return; + } + + const card = this.createCalibrationChartCard(name, plot, preset); + container.appendChild(card); + }); + + if (typeof lucide !== 'undefined') { + lucide.createIcons({ attrs: { 'stroke-width': 1.5 } }); + } } - const escHandler = (e) => { - if (e.key === 'Escape') { - this.closePlotsModal(); - document.removeEventListener('keydown', escHandler); - } - }; - document.addEventListener('keydown', escHandler); - } + createCalibrationChartCard(standardName, plotConfig, preset) { + const card = document.createElement('div'); + card.className = 'chart-card'; + card.dataset.standard = standardName; - _renderCalibrationPlots(individualPlots, preset) { - const container = this.elements.plotsGrid; - if (!container) return; + const title = `${standardName.toUpperCase()} Standard`; - container.innerHTML = ''; - - if (!individualPlots || !Object.keys(individualPlots).length) { - container.innerHTML = '
No calibration plots available
'; - return; - } - - Object.entries(individualPlots).forEach(([name, plot]) => { - if (plot.error) { - const err = document.createElement('div'); - err.className = 'chart-card'; - err.innerHTML = ` -
-
- - ${name.toUpperCase()} Standard + card.innerHTML = ` +
+
+ + ${title} +
+
+ + +
+
+
+
+
+
+
Standard: ${standardName.toUpperCase()}
+
Preset: ${preset?.filename || 'Unknown'}
-
-
-
Error: ${plot.error}
-
`; - container.appendChild(err); - return; - } - const card = this._createCalibrationChartCard(name, plot, preset); - container.appendChild(card); - }); + card.addEventListener('click', (e) => { + const action = e.target.closest?.('[data-action]')?.dataset.action; + if (!action) return; + e.stopPropagation(); - if (typeof lucide !== 'undefined') { - lucide.createIcons({ attrs: { 'stroke-width': 1.5 } }); - } - } - - _createCalibrationChartCard(standardName, plotConfig, preset) { - const card = document.createElement('div'); - card.className = 'chart-card'; - card.dataset.standard = standardName; - - const title = `${standardName.toUpperCase()} Standard`; - - card.innerHTML = ` -
-
- - ${title} -
-
- - -
-
-
-
-
-
-
Standard: ${standardName.toUpperCase()}
-
Preset: ${preset?.filename || 'Unknown'}
-
- `; - - // Actions - card.addEventListener('click', (e) => { - const action = e.target.closest?.('[data-action]')?.dataset.action; - if (!action) return; - e.stopPropagation(); - - const plotEl = card.querySelector('.chart-card__plot'); - if (action === 'fullscreen') this._toggleFullscreen(card); - if (action === 'download') this.downloadCalibrationStandard(standardName, plotEl); - }); - - // Plot - const plotEl = card.querySelector('.chart-card__plot'); - this._renderPlotly(plotEl, plotConfig, title); - - return card; - } - - _renderPlotly(container, plotConfig, title) { - if (!container || !plotConfig || plotConfig.error) { - container.innerHTML = `
Failed to load plot: ${plotConfig?.error || 'Unknown error'}
`; - return; - } - - const layout = { - ...plotConfig.layout, - title: { text: title, font: { size: 16, color: '#f1f5f9' } }, - plot_bgcolor: 'transparent', - paper_bgcolor: 'transparent', - font: { family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif', size: 12, color: '#f1f5f9' }, - autosize: true, - width: null, - height: null, - margin: { l: 60, r: 50, t: 50, b: 60 }, - showlegend: true, - legend: { - orientation: 'v', - x: 1.02, - y: 1, - xanchor: 'left', - yanchor: 'top', - bgcolor: 'rgba(30, 41, 59, 0.9)', - bordercolor: '#475569', - borderwidth: 1, - font: { size: 10, color: '#f1f5f9' } - }, - xaxis: { - ...plotConfig.layout.xaxis, - gridcolor: '#334155', - zerolinecolor: '#475569', - color: '#cbd5e1', - fixedrange: false - }, - yaxis: { - ...plotConfig.layout.yaxis, - gridcolor: '#334155', - zerolinecolor: '#475569', - color: '#cbd5e1', - fixedrange: false - } - }; - - const config = { - displayModeBar: true, - modeBarButtonsToRemove: ['select2d', 'lasso2d', 'hoverClosestCartesian', 'hoverCompareCartesian', 'toggleSpikelines'], - displaylogo: false, - responsive: false, - doubleClick: 'reset', - toImageButtonOptions: { - format: 'png', - filename: `calibration-plot-${Date.now()}`, - height: 600, - width: 800, - scale: 1 - } - }; - - Plotly.newPlot(container, plotConfig.data, layout, config); - - // Resize observer - if (window.ResizeObserver) { - const ro = new ResizeObserver(() => { - if (container && container.clientWidth > 0) { - Plotly.Plots.resize(container); - } - }); - ro.observe(container); - container._resizeObserver = ro; - } - } - - _toggleFullscreen(card) { - if (!document.fullscreenElement) { - card.requestFullscreen?.().then(() => { - setTimeout(() => { - const plot = card.querySelector('.chart-card__plot'); - if (plot && typeof Plotly !== 'undefined') { - const rect = plot.getBoundingClientRect(); - Plotly.relayout(plot, { width: rect.width, height: rect.height }); - Plotly.Plots.resize(plot); - } - }, 200); - }).catch(console.error); - } else { - document.exitFullscreen?.().then(() => { - setTimeout(() => { - const plot = card.querySelector('.chart-card__plot'); - if (plot && typeof Plotly !== 'undefined') { - Plotly.Plots.resize(plot); - } - }, 100); - }); - } - } - - closePlotsModal() { - const modal = this.elements.plotsModal; - if (!modal) return; - - modal.classList.remove('modal--active'); - document.body.style.overflow = ''; - - // Clean plots - if (typeof Plotly !== 'undefined') { - const containers = modal.querySelectorAll('[id^="calibration-plot-"]'); - containers.forEach(c => { - if (c._resizeObserver) { c._resizeObserver.disconnect(); c._resizeObserver = null; } - if (c._fullData) Plotly.purge(c); - }); - } - - this.currentPlotsData = null; - } - - /* ----------------------------- Downloads ----------------------------- */ - - async downloadCalibrationStandard(standardName, plotContainer) { - try { - const ts = new Date().toISOString().replace(/[:.]/g, '-'); - const calibrationName = this.currentPlotsData?.calibration_name || 'unknown'; - const base = `${calibrationName}_${standardName}_${ts}`; - - if (plotContainer && typeof Plotly !== 'undefined') { - await Plotly.downloadImage(plotContainer, { - format: 'png', width: 1200, height: 800, filename: `${base}_plot` + const plotEl = card.querySelector('.chart-card__plot'); + if (action === 'fullscreen') this.toggleFullscreen(card); + if (action === 'download') this.downloadCalibrationStandard(standardName, plotEl); }); - } - const data = this._prepareCalibrationDownloadData(standardName); - downloadJSON(data, `${base}_data.json`); + const plotEl = card.querySelector('.chart-card__plot'); + this.renderPlotly(plotEl, plotConfig, title); - this._notify('success', 'Download Complete', `Downloaded ${standardName.toUpperCase()} standard plot and data`); - } catch (e) { - console.error('Download standard failed:', e); - this._notify('error', 'Download Failed', 'Failed to download calibration data'); + return card; } - } - async downloadAllCalibrationData() { - if (!this.currentPlotsData) return; - - try { - const ts = new Date().toISOString().replace(/[:.]/g, '-'); - const calibrationName = this.currentPlotsData.calibration_name || 'unknown'; - const base = `${calibrationName}_complete_${ts}`; - - const btn = this.elements.downloadAllBtn; - if (btn) ButtonState.set(btn, { state: 'loading', icon: 'loader', text: 'Downloading...' }); - - const complete = this._prepareCompleteCalibrationData(); - downloadJSON(complete, `${base}.json`); - - await this._downloadAllPlotImages(base); - - this._notify('success', 'Complete Download', `Downloaded complete calibration data and plots for ${calibrationName}`); - } catch (e) { - console.error('Download all failed:', e); - this._notify('error', 'Download Failed', 'Failed to download complete calibration data'); - } finally { - const btn = this.elements.downloadAllBtn; - if (btn) ButtonState.set(btn, { state: 'normal', icon: 'download-cloud', text: 'Download All' }); - } - } - - _prepareCalibrationDownloadData(standardName) { - if (!this.currentPlotsData) return null; - const plot = this.currentPlotsData.individual_plots[standardName]; - return { - calibration_info: { - calibration_name: this.currentPlotsData.calibration_name, - preset: this.currentPlotsData.preset, - standard_name: standardName, - download_timestamp: new Date().toISOString() - }, - plot_data: plot ? { data: plot.data, layout: plot.layout, error: plot.error } : null, - raw_sweep_data: this._extractRawSweepData(standardName), - metadata: { - description: `VNA calibration standard data export - ${standardName.toUpperCase()}`, - format_version: '1.0', - exported_by: 'VNA System Dashboard', - contains: ['Calibration information', 'Plot configuration', 'Raw sweep measurements', 'Frequency & magnitude data'] - } - }; - } - - _extractRawSweepData(standardName) { - const plot = this.currentPlotsData?.individual_plots?.[standardName]; - if (!plot || !plot.raw_sweep_data) return null; - - const raw = plot.raw_sweep_data; - const freqInfo = plot.frequency_info; - - const points = []; - if (raw.points?.length) { - for (let i = 0; i < raw.points.length; i++) { - const [re, im] = raw.points[i]; - const magLin = Math.sqrt(re * re + im * im); - const magDb = magLin > 0 ? 20 * Math.log10(magLin) : -120; - const phaseRad = Math.atan2(im, re); - const phaseDeg = phaseRad * (180 / Math.PI); - - let fHz = 0; - if (freqInfo?.start_freq && freqInfo?.stop_freq) { - fHz = freqInfo.start_freq + (freqInfo.stop_freq - freqInfo.start_freq) * i / (raw.points.length - 1); + renderPlotly(container, plotConfig, title) { + if (!container || !plotConfig || plotConfig.error) { + container.innerHTML = `
Failed to load plot: ${plotConfig?.error || 'Unknown error'}
`; + return; } - points.push({ - point_index: i, - frequency_hz: fHz, - frequency_ghz: fHz / 1e9, - complex_data: { real: re, imaginary: im }, - magnitude: { linear: magLin, db: magDb }, - phase: { radians: phaseRad, degrees: phaseDeg } + const layout = { + ...plotConfig.layout, + title: { text: title, font: { size: 16, color: '#f1f5f9' } }, + plot_bgcolor: 'transparent', + paper_bgcolor: 'transparent', + font: { family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif', size: 12, color: '#f1f5f9' }, + autosize: true, + width: null, + height: null, + margin: { l: 60, r: 50, t: 50, b: 60 }, + showlegend: true, + legend: { + orientation: 'v', + x: 1.02, + y: 1, + xanchor: 'left', + yanchor: 'top', + bgcolor: 'rgba(30, 41, 59, 0.9)', + bordercolor: '#475569', + borderwidth: 1, + font: { size: 10, color: '#f1f5f9' } + }, + xaxis: { + ...plotConfig.layout.xaxis, + gridcolor: '#334155', + zerolinecolor: '#475569', + color: '#cbd5e1', + fixedrange: false + }, + yaxis: { + ...plotConfig.layout.yaxis, + gridcolor: '#334155', + zerolinecolor: '#475569', + color: '#cbd5e1', + fixedrange: false + } + }; + + const config = { + displayModeBar: true, + modeBarButtonsToRemove: ['select2d', 'lasso2d', 'hoverClosestCartesian', 'hoverCompareCartesian', 'toggleSpikelines'], + displaylogo: false, + responsive: false, + doubleClick: 'reset', + toImageButtonOptions: { + format: 'png', + filename: `calibration-plot-${Date.now()}`, + height: 600, + width: 800, + scale: 1 + } + }; + + Plotly.newPlot(container, plotConfig.data, layout, config); + + if (window.ResizeObserver) { + const ro = new ResizeObserver(() => { + if (container && container.clientWidth > 0) { + Plotly.Plots.resize(container); + } + }); + ro.observe(container); + container._resizeObserver = ro; + } + } + + toggleFullscreen(card) { + if (!document.fullscreenElement) { + card.requestFullscreen?.().then(() => { + setTimeout(() => { + const plot = card.querySelector('.chart-card__plot'); + if (plot && typeof Plotly !== 'undefined') { + const rect = plot.getBoundingClientRect(); + Plotly.relayout(plot, { width: rect.width, height: rect.height }); + Plotly.Plots.resize(plot); + } + }, 200); + }).catch(console.error); + } else { + document.exitFullscreen?.().then(() => { + setTimeout(() => { + const plot = card.querySelector('.chart-card__plot'); + if (plot && typeof Plotly !== 'undefined') { + Plotly.Plots.resize(plot); + } + }, 100); + }); + } + } + + setupModalCloseHandlers(modal) { + modal.querySelectorAll('[data-modal-close]').forEach(el => { + el.addEventListener('click', () => this.closePlotsModal()); }); - } - } - return { - standard_name: standardName, - sweep_info: { - sweep_number: raw.sweep_number, - timestamp: raw.timestamp, - total_points: raw.total_points, - file_path: raw.file_path - }, - frequency_info: freqInfo, - measurement_points: points, - statistics: { - total_points: points.length, - frequency_range: { - start_hz: points[0]?.frequency_hz || 0, - stop_hz: points[points.length - 1]?.frequency_hz || 0 - }, - magnitude_range_db: { - min: Math.min(...points.map(p => p.magnitude.db)), - max: Math.max(...points.map(p => p.magnitude.db)) + const downloadAllBtn = modal.querySelector('#downloadAllBtn'); + if (downloadAllBtn) { + downloadAllBtn.addEventListener('click', () => + this.debouncer.debounce('download-all', () => this.downloadAllCalibrationData(), 600) + ); } - } - }; - } - _prepareCompleteCalibrationData() { - if (!this.currentPlotsData) return null; - const all = {}; - Object.keys(this.currentPlotsData.individual_plots).forEach(name => { - all[name] = this._prepareCalibrationDownloadData(name); - }); - - return { - export_info: { - export_timestamp: new Date().toISOString(), - export_type: 'complete_calibration', - calibration_name: this.currentPlotsData.calibration_name, - preset: this.currentPlotsData.preset, - standards_included: Object.keys(all), - format_version: '1.0' - }, - calibration_summary: { - name: this.currentPlotsData.calibration_name, - preset: this.currentPlotsData.preset, - total_standards: Object.keys(all).length, - standards: Object.keys(all).map(n => ({ - name: n, - has_data: all[n] !== null, - has_error: !!all[n]?.plot_data?.error - })) - }, - standards_data: all, - metadata: { - description: 'Complete VNA calibration data export including all standards', - exported_by: 'VNA System Dashboard', - contains: [ - 'Complete calibration information', - 'All calibration standards data', - 'Raw sweep measurements', - 'Plot configurations (Plotly format)', - 'Frequency and magnitude data', - 'Complex impedance measurements' - ], - usage_notes: [ - 'File contains all data for the calibration set', - "Individual standard data is in 'standards_data'", - 'Raw complex measurements are in [real, imaginary]', - 'Frequencies in Hz and GHz, magnitudes in linear and dB' - ] - } - }; - } - - async _downloadAllPlotImages(base) { - const containers = this.elements.plotsModal.querySelectorAll('[id^="calibration-plot-"]'); - const jobs = []; - containers.forEach(c => { - if (c && typeof Plotly !== 'undefined' && c._fullData) { - const name = c.id.replace('calibration-plot-', ''); - jobs.push(Plotly.downloadImage(c, { format: 'png', width: 1200, height: 800, filename: `${base}_${name}_plot` })); - } - }); - await Promise.all(jobs); - } - - /* ----------------------------- Reference Management ----------------------------- */ - - async _loadReferences() { - try { - if (!this.currentPreset) { - this._renderReferencesDropdown([]); - this._updateCurrentReferenceInfo(null); - return; - } - - // Load available references - const referencesResponse = await fetch(`/api/v1/settings/references?preset_filename=${encodeURIComponent(this.currentPreset.filename)}`); - if (!referencesResponse.ok) throw new Error(`HTTP ${referencesResponse.status}`); - this.availableReferences = await referencesResponse.json(); - - // Load current reference - const currentResponse = await fetch(`/api/v1/settings/reference/current?preset_filename=${encodeURIComponent(this.currentPreset.filename)}`); - if (!currentResponse.ok) throw new Error(`HTTP ${currentResponse.status}`); - this.currentReference = await currentResponse.json(); - - this._renderReferencesDropdown(this.availableReferences); - this._updateCurrentReferenceInfo(this.currentReference); - } catch (error) { - console.error('Failed to load references:', error); - this._renderReferencesDropdown([]); - this._updateCurrentReferenceInfo(null); - } - } - - _renderReferencesDropdown(references) { - if (!this.elements.referenceDropdown) return; - - // Clear existing options - this.elements.referenceDropdown.innerHTML = ''; - - if (references.length === 0) { - this.elements.referenceDropdown.innerHTML = ''; - this.elements.referenceDropdown.disabled = true; - this.elements.setReferenceBtn.disabled = true; - this.elements.clearReferenceBtn.disabled = true; - this.elements.deleteReferenceBtn.disabled = true; - } else { - this.elements.referenceDropdown.innerHTML = ''; - - references.forEach(ref => { - const option = document.createElement('option'); - option.value = ref.name; - option.textContent = `${ref.name} (${new Date(ref.timestamp).toLocaleDateString()})`; - this.elements.referenceDropdown.appendChild(option); - }); - - this.elements.referenceDropdown.disabled = false; - - // Select current reference if any - if (this.currentReference) { - this.elements.referenceDropdown.value = this.currentReference.name; - } + const escHandler = (e) => { + if (e.key === 'Escape') { + this.closePlotsModal(); + document.removeEventListener('keydown', escHandler); + } + }; + document.addEventListener('keydown', escHandler); } - this._updateReferenceButtons(); - } + closePlotsModal() { + const modal = this.elements.plotsModal; + if (!modal) return; - _updateReferenceButtons() { - const hasSelection = this.elements.referenceDropdown.value !== ''; - const hasCurrent = this.currentReference !== null; + modal.classList.remove('modal--active'); + document.body.style.overflow = ''; - this.elements.setReferenceBtn.disabled = !hasSelection; - this.elements.deleteReferenceBtn.disabled = !hasSelection; - this.elements.clearReferenceBtn.disabled = !hasCurrent; - } + if (typeof Plotly !== 'undefined') { + const containers = modal.querySelectorAll('[id^="calibration-plot-"]'); + containers.forEach(c => { + if (c._resizeObserver) { c._resizeObserver.disconnect(); c._resizeObserver = null; } + if (c._fullData) Plotly.purge(c); + }); + } - _updateCurrentReferenceInfo(reference) { - if (!this.elements.currentReferenceInfo) return; - - if (!reference) { - this.elements.currentReferenceInfo.style.display = 'none'; - return; + this.currentPlotsData = null; } - this.elements.currentReferenceInfo.style.display = 'block'; - - if (this.elements.currentReferenceName) { - this.elements.currentReferenceName.textContent = reference.name; - } - - if (this.elements.currentReferenceTimestamp) { - this.elements.currentReferenceTimestamp.textContent = new Date(reference.timestamp).toLocaleString(); - } - - if (this.elements.currentReferenceDescription) { - this.elements.currentReferenceDescription.textContent = reference.description || ''; - } - } - - async handleCreateReference() { - const name = this.elements.referenceNameInput.value.trim(); - if (!name) { - this._notify('warning', 'Missing Name', 'Please enter a name for the reference'); - return; - } - - const description = this.elements.referenceDescriptionInput.value.trim(); - - this.debouncer.debounce('create-reference', () => - this.reqGuard.runExclusive('create-reference', async () => { + async downloadCalibrationStandard(standardName, plotContainer) { try { - ButtonState.set(this.elements.createReferenceBtn, { state: 'loading', icon: 'loader', text: 'Creating...' }); + const ts = new Date().toISOString().replace(/[:.]/g, '-'); + const calibrationName = this.currentPlotsData?.calibration_name || 'unknown'; + const base = `${calibrationName}_${standardName}_${ts}`; - const response = await fetch('/api/v1/settings/reference/create', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, description }) - }); + if (plotContainer && typeof Plotly !== 'undefined') { + await Plotly.downloadImage(plotContainer, { + format: 'png', width: 1200, height: 800, filename: `${base}_plot` + }); + } - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const result = await response.json(); + const data = this.prepareCalibrationDownloadData(standardName); + downloadJSON(data, `${base}_data.json`); - this._notify('success', 'Reference Created', result.message); - - // Clear inputs - this.elements.referenceNameInput.value = ''; - this.elements.referenceDescriptionInput.value = ''; - - // Reload references - await this._loadReferences(); - } catch (error) { - console.error('Create reference failed:', error); - this._notify('error', 'Reference Error', 'Failed to create reference'); - } finally { - ButtonState.set(this.elements.createReferenceBtn, { state: 'normal', icon: 'target', text: 'Capture Reference' }); + this.notify('success', 'Download Complete', `Downloaded ${standardName.toUpperCase()} standard plot and data`); + } catch (e) { + console.error('Download standard failed:', e); + this.notify('error', 'Download Failed', 'Failed to download calibration data'); } - }), 500 - ); - } - - handleReferenceChange() { - this._updateReferenceButtons(); - } - - async handleSetReference() { - const referenceName = this.elements.referenceDropdown.value; - if (!referenceName) return; - - this.debouncer.debounce('set-reference', () => - this.reqGuard.runExclusive('set-reference', async () => { - try { - ButtonState.set(this.elements.setReferenceBtn, { state: 'loading', icon: 'loader', text: 'Setting...' }); - - const response = await fetch('/api/v1/settings/reference/set', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: referenceName }) - }); - - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const result = await response.json(); - - this._notify('success', 'Reference Set', result.message); - - // Reload references to update current - await this._loadReferences(); - } catch (error) { - console.error('Set reference failed:', error); - this._notify('error', 'Reference Error', 'Failed to set reference'); - } finally { - ButtonState.set(this.elements.setReferenceBtn, { state: 'normal', icon: 'check', text: 'Set Active' }); - } - }), 400 - ); - } - - async handleClearReference() { - this.debouncer.debounce('clear-reference', () => - this.reqGuard.runExclusive('clear-reference', async () => { - try { - ButtonState.set(this.elements.clearReferenceBtn, { state: 'loading', icon: 'loader', text: 'Clearing...' }); - - const response = await fetch('/api/v1/settings/reference/current', { - method: 'DELETE' - }); - - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const result = await response.json(); - - this._notify('success', 'Reference Cleared', result.message); - - // Reload references to update current - await this._loadReferences(); - } catch (error) { - console.error('Clear reference failed:', error); - this._notify('error', 'Reference Error', 'Failed to clear reference'); - } finally { - ButtonState.set(this.elements.clearReferenceBtn, { state: 'normal', icon: 'x', text: 'Clear' }); - } - }), 400 - ); - } - - async handleDeleteReference() { - const referenceName = this.elements.referenceDropdown.value; - if (!referenceName) return; - - // Confirm deletion - if (!confirm(`Are you sure you want to delete reference "${referenceName}"? This action cannot be undone.`)) { - return; } - this.debouncer.debounce('delete-reference', () => - this.reqGuard.runExclusive('delete-reference', async () => { + async downloadAllCalibrationData() { + if (!this.currentPlotsData) return; + try { - ButtonState.set(this.elements.deleteReferenceBtn, { state: 'loading', icon: 'loader', text: 'Deleting...' }); + const ts = new Date().toISOString().replace(/[:.]/g, '-'); + const calibrationName = this.currentPlotsData.calibration_name || 'unknown'; + const base = `${calibrationName}_complete_${ts}`; - const response = await fetch(`/api/v1/settings/reference/${encodeURIComponent(referenceName)}`, { - method: 'DELETE' - }); + const btn = this.elements.downloadAllBtn; + if (btn) ButtonState.set(btn, { state: 'loading', icon: 'loader', text: 'Downloading...' }); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - const result = await response.json(); + const complete = { + export_info: { + export_timestamp: new Date().toISOString(), + calibration_name: calibrationName, + preset: this.currentPlotsData.preset + }, + standards_data: this.currentPlotsData.individual_plots + }; - this._notify('success', 'Reference Deleted', result.message); + downloadJSON(complete, `${base}.json`); - // Reload references - await this._loadReferences(); - } catch (error) { - console.error('Delete reference failed:', error); - this._notify('error', 'Reference Error', 'Failed to delete reference'); + this.notify('success', 'Complete Download', `Downloaded complete calibration data for ${calibrationName}`); + } catch (e) { + console.error('Download all failed:', e); + this.notify('error', 'Download Failed', 'Failed to download complete calibration data'); } finally { - ButtonState.set(this.elements.deleteReferenceBtn, { state: 'normal', icon: 'trash-2', text: 'Delete' }); + const btn = this.elements.downloadAllBtn; + if (btn) ButtonState.set(btn, { state: 'normal', icon: 'download-cloud', text: 'Download All' }); } - }), 400 - ); - } + } - _notify(type, title, message) { - this.notifications?.show?.({ type, title, message }); - } + prepareCalibrationDownloadData(standardName) { + if (!this.currentPlotsData) return null; + const plot = this.currentPlotsData.individual_plots[standardName]; + return { + calibration_info: { + calibration_name: this.currentPlotsData.calibration_name, + preset: this.currentPlotsData.preset, + standard_name: standardName, + download_timestamp: new Date().toISOString() + }, + plot_data: plot + }; + } + async refresh() { + if (!this.isInitialized) return; + await this.loadInitialData(); + } -} + destroy() { + this.presetManager.destroy(); + this.calibrationManager.destroy(); + this.referenceManager.destroy(); + + this.elements.viewPlotsBtn?.removeEventListener('click', this.handleViewPlots); + this.elements.viewCurrentPlotsBtn?.removeEventListener('click', this.handleViewCurrentPlots); + + this.isInitialized = false; + console.log('Settings Manager destroyed'); + } + + notify(type, title, message) { + this.notifications?.show?.({ type, title, message }); + } +} \ No newline at end of file diff --git a/vna_system/web_ui/static/js/modules/settings/calibration-manager.js b/vna_system/web_ui/static/js/modules/settings/calibration-manager.js new file mode 100644 index 0000000..96c1b34 --- /dev/null +++ b/vna_system/web_ui/static/js/modules/settings/calibration-manager.js @@ -0,0 +1,371 @@ +/** + * Calibration Manager + * Handles VNA calibration workflow + */ + +import { Debouncer, RequestGuard, ButtonState } from '../utils.js'; + +export class CalibrationManager { + constructor(notifications) { + this.notifications = notifications; + this.workingCalibration = null; + this.currentCalibration = null; + this.disabledStandards = new Set(); + this.elements = {}; + this.debouncer = new Debouncer(); + this.reqGuard = new RequestGuard(); + this.currentPreset = null; + + this.handleStartCalibration = this.handleStartCalibration.bind(this); + this.handleSaveCalibration = this.handleSaveCalibration.bind(this); + this.handleSetCalibration = this.handleSetCalibration.bind(this); + this.handleCalibrationChange = this.handleCalibrationChange.bind(this); + } + + init(elements) { + this.elements = elements; + this.elements.startCalibrationBtn?.addEventListener('click', this.handleStartCalibration); + this.elements.saveCalibrationBtn?.addEventListener('click', this.handleSaveCalibration); + this.elements.calibrationDropdown?.addEventListener('change', this.handleCalibrationChange); + this.elements.setCalibrationBtn?.addEventListener('click', this.handleSetCalibration); + + this.elements.calibrationNameInput?.addEventListener('input', () => { + const hasName = this.elements.calibrationNameInput.value.trim().length > 0; + const isComplete = this.workingCalibration && this.workingCalibration.is_complete; + this.elements.saveCalibrationBtn.disabled = !hasName || !isComplete; + }); + } + + destroy() { + this.elements.startCalibrationBtn?.removeEventListener('click', this.handleStartCalibration); + this.elements.saveCalibrationBtn?.removeEventListener('click', this.handleSaveCalibration); + this.elements.calibrationDropdown?.removeEventListener('change', this.handleCalibrationChange); + this.elements.setCalibrationBtn?.removeEventListener('click', this.handleSetCalibration); + this.resetCaptureState(); + } + + setCurrentPreset(preset) { + this.currentPreset = preset; + if (preset) { + this.elements.startCalibrationBtn.disabled = false; + this.loadCalibrations(); + } else { + this.elements.startCalibrationBtn.disabled = true; + this.reset(); + } + } + + async loadWorkingCalibration() { + try { + const r = await fetch('/api/v1/settings/working-calibration'); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const working = await r.json(); + this.updateWorking(working); + } catch (e) { + console.error('Working calibration load failed:', e); + } + } + + async loadCalibrations() { + if (!this.currentPreset) return; + try { + const r = await fetch(`/api/v1/settings/calibrations?preset_filename=${encodeURIComponent(this.currentPreset.filename)}`); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const calibrations = await r.json(); + this.populateDropdown(calibrations); + } catch (e) { + console.error('Calibrations load failed:', e); + } + } + + populateDropdown(calibrations) { + const dd = this.elements.calibrationDropdown; + dd.innerHTML = ''; + + if (!calibrations.length) { + dd.innerHTML = ''; + dd.disabled = true; + this.elements.setCalibrationBtn.disabled = true; + this.elements.viewPlotsBtn.disabled = true; + return; + } + + dd.innerHTML = ''; + calibrations.forEach(c => { + const opt = document.createElement('option'); + opt.value = c.name; + opt.textContent = `${c.name} ${c.is_complete ? '✔' : '?'}`; + dd.appendChild(opt); + }); + + dd.disabled = false; + this.elements.setCalibrationBtn.disabled = true; + this.elements.viewPlotsBtn.disabled = true; + } + + updateWorking(working) { + this.workingCalibration = working; + if (working.active) { + this.showSteps(working); + } else { + this.hideSteps(); + } + } + + showSteps(working) { + this.elements.calibrationSteps.style.display = 'block'; + this.elements.progressText.textContent = working.progress || '0/0'; + + this.renderStandardButtons(working); + + const hasName = this.elements.calibrationNameInput.value.trim().length > 0; + this.elements.saveCalibrationBtn.disabled = !hasName || !working.is_complete; + this.elements.calibrationNameInput.disabled = false; + + const hasCompleted = (working.completed_standards || []).length > 0; + if (this.elements.viewCurrentPlotsBtn) { + this.elements.viewCurrentPlotsBtn.disabled = !hasCompleted; + } + } + + hideSteps() { + this.elements.calibrationSteps.style.display = 'none'; + this.elements.calibrationStandards.innerHTML = ''; + if (this.elements.viewCurrentPlotsBtn) { + this.elements.viewCurrentPlotsBtn.disabled = true; + } + } + + renderStandardButtons(working) { + const container = this.elements.calibrationStandards; + container.innerHTML = ''; + + const all = this.getStandardsForMode(); + const completed = working.completed_standards || []; + const missing = working.missing_standards || []; + + all.forEach(std => { + const btn = document.createElement('button'); + btn.className = 'btn calibration-standard-btn'; + btn.dataset.standard = std; + + const isCompleted = completed.includes(std); + const isMissing = missing.includes(std); + const capturing = this.disabledStandards.has(std); + + if (capturing) { + btn.classList.add('btn--warning'); + btn.innerHTML = ` Capturing ${std.toUpperCase()}...`; + btn.disabled = true; + btn.title = 'Standard is currently being captured'; + } else if (isCompleted) { + btn.classList.add('btn--success'); + btn.innerHTML = ` ${std.toUpperCase()}`; + btn.disabled = false; + btn.title = 'Click to recapture this standard'; + } else if (isMissing) { + btn.classList.add('btn--primary'); + btn.innerHTML = ` Capture ${std.toUpperCase()}`; + btn.disabled = false; + btn.title = 'Click to capture this standard'; + } else { + btn.classList.add('btn--secondary'); + btn.innerHTML = `${std.toUpperCase()}`; + btn.disabled = true; + } + + btn.addEventListener('click', () => this.captureStandard(std)); + container.appendChild(btn); + }); + + if (typeof lucide !== 'undefined') { + lucide.createIcons(); + } + } + + getStandardsForMode() { + if (!this.currentPreset) return []; + if (this.currentPreset.mode === 's11') return ['open', 'short', 'load']; + if (this.currentPreset.mode === 's21') return ['through']; + return []; + } + + handleCalibrationChange() { + const v = this.elements.calibrationDropdown.value; + this.elements.setCalibrationBtn.disabled = !v; + this.elements.viewPlotsBtn.disabled = !v; + } + + async handleStartCalibration() { + if (!this.currentPreset) return; + + this.debouncer.debounce('start-calibration', () => + this.reqGuard.runExclusive('start-calibration', async () => { + try { + ButtonState.set(this.elements.startCalibrationBtn, { state: 'loading', icon: 'loader', text: 'Starting...' }); + + const r = await fetch('/api/v1/settings/calibration/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ preset_filename: this.currentPreset.filename }) + }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const result = await r.json(); + + this.notify('info', 'Calibration Started', `Started calibration for ${result.preset}`); + + await this.loadWorkingCalibration(); + } catch (e) { + console.error('Start calibration failed:', e); + this.notify('error', 'Calibration Error', 'Failed to start calibration'); + } finally { + ButtonState.set(this.elements.startCalibrationBtn, { state: 'normal', icon: 'play', text: 'Start Calibration' }); + } + }), 400 + ); + } + + async captureStandard(standard) { + const key = `calibrate-${standard}`; + if (this.disabledStandards.has(standard)) return; + + this.debouncer.debounce(key, () => + this.reqGuard.runExclusive(key, async () => { + try { + this.disabledStandards.add(standard); + + const btn = document.querySelector(`[data-standard="${standard}"]`); + ButtonState.set(btn, { state: 'loading', icon: 'upload', text: 'Capturing...' }); + + this.notify('info', 'Capturing Standard', `Capturing ${standard.toUpperCase()} standard...`); + + const r = await fetch('/api/v1/settings/calibration/add-standard', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ standard }) + }); + + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const result = await r.json(); + + this.notify('success', 'Standard Captured', result.message); + + this.resetCaptureState(); + await this.loadWorkingCalibration(); + } catch (e) { + console.error('Capture standard failed:', e); + this.notify('error', 'Calibration Error', 'Failed to capture calibration standard'); + this.resetCaptureState(standard); + } + }), 500 + ); + } + + async handleSaveCalibration() { + const name = this.elements.calibrationNameInput.value.trim(); + if (!name) return; + + this.debouncer.debounce('save-calibration', () => + this.reqGuard.runExclusive('save-calibration', async () => { + try { + ButtonState.set(this.elements.saveCalibrationBtn, { state: 'loading', icon: 'loader', text: 'Saving...' }); + + const r = await fetch('/api/v1/settings/calibration/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }) + }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const result = await r.json(); + + this.notify('success', 'Calibration Saved', result.message); + + this.hideSteps(); + this.elements.calibrationNameInput.value = ''; + + await this.loadWorkingCalibration(); + await this.loadCalibrations(); + + if (this.onCalibrationSaved) { + await this.onCalibrationSaved(); + } + } catch (e) { + console.error('Save calibration failed:', e); + this.notify('error', 'Calibration Error', 'Failed to save calibration'); + } finally { + ButtonState.set(this.elements.saveCalibrationBtn, { state: 'disabled', icon: 'save', text: 'Save Calibration' }); + } + }), 400 + ); + } + + async handleSetCalibration() { + this.debouncer.debounce('set-calibration', () => + this.reqGuard.runExclusive('set-calibration', async () => { + const name = this.elements.calibrationDropdown.value; + if (!name || !this.currentPreset) return; + + try { + ButtonState.set(this.elements.setCalibrationBtn, { state: 'loading', icon: 'loader', text: 'Setting...' }); + + const r = await fetch('/api/v1/settings/calibration/set', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, preset_filename: this.currentPreset.filename }) + }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const result = await r.json(); + + this.notify('success', 'Calibration Set', result.message); + + if (this.onCalibrationSet) { + await this.onCalibrationSet(); + } + } catch (e) { + console.error('Set calibration failed:', e); + this.notify('error', 'Calibration Error', 'Failed to set active calibration'); + } finally { + ButtonState.set(this.elements.setCalibrationBtn, { state: 'normal', icon: 'check', text: 'Set Active' }); + } + }), 300 + ); + } + + updateStatus(status) { + if (status.current_calibration) { + this.currentCalibration = status.current_calibration; + this.elements.currentCalibration.textContent = status.current_calibration.calibration_name; + } else { + this.currentCalibration = null; + this.elements.currentCalibration.textContent = 'None'; + } + } + + resetCaptureState(standard = null) { + if (standard) this.disabledStandards.delete(standard); + else this.disabledStandards.clear(); + + if (this.workingCalibration) { + this.renderStandardButtons(this.workingCalibration); + } + } + + reset() { + this.workingCalibration = null; + this.hideSteps(); + if (this.elements.calibrationNameInput) { + this.elements.calibrationNameInput.value = ''; + this.elements.calibrationNameInput.disabled = true; + } + if (this.elements.saveCalibrationBtn) this.elements.saveCalibrationBtn.disabled = true; + if (this.elements.progressText) this.elements.progressText.textContent = '0/0'; + } + + getWorkingCalibration() { + return this.workingCalibration; + } + + notify(type, title, message) { + this.notifications?.show?.({ type, title, message }); + } +} \ No newline at end of file diff --git a/vna_system/web_ui/static/js/modules/settings/preset-manager.js b/vna_system/web_ui/static/js/modules/settings/preset-manager.js new file mode 100644 index 0000000..411078c --- /dev/null +++ b/vna_system/web_ui/static/js/modules/settings/preset-manager.js @@ -0,0 +1,132 @@ +/** + * Preset Manager + * Handles VNA configuration presets + */ + +import { Debouncer, RequestGuard, ButtonState } from '../utils.js'; + +export class PresetManager { + constructor(notifications) { + this.notifications = notifications; + this.currentPreset = null; + this.elements = {}; + this.debouncer = new Debouncer(); + this.reqGuard = new RequestGuard(); + + this.handlePresetChange = this.handlePresetChange.bind(this); + this.handleSetPreset = this.handleSetPreset.bind(this); + } + + init(elements) { + this.elements = elements; + this.elements.presetDropdown?.addEventListener('change', this.handlePresetChange); + this.elements.setPresetBtn?.addEventListener('click', this.handleSetPreset); + } + + destroy() { + this.elements.presetDropdown?.removeEventListener('change', this.handlePresetChange); + this.elements.setPresetBtn?.removeEventListener('click', this.handleSetPreset); + } + + async loadPresets() { + try { + const r = await fetch('/api/v1/settings/presets'); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const presets = await r.json(); + this.populateDropdown(presets); + } catch (e) { + console.error('Presets load failed:', e); + this.notify('error', 'Load Error', 'Failed to load configuration presets'); + } + } + + populateDropdown(presets) { + const dd = this.elements.presetDropdown; + dd.innerHTML = ''; + + if (!presets.length) { + dd.innerHTML = ''; + dd.disabled = true; + this.elements.setPresetBtn.disabled = true; + return; + } + + dd.innerHTML = ''; + presets.forEach(p => { + const opt = document.createElement('option'); + opt.value = p.filename; + opt.textContent = this.formatDisplay(p); + dd.appendChild(opt); + }); + + dd.disabled = false; + this.elements.setPresetBtn.disabled = true; + } + + formatDisplay(p) { + let s = `${p.filename} (${p.mode})`; + if (p.start_freq && p.stop_freq) { + const startMHz = (p.start_freq / 1e6).toFixed(0); + const stopMHz = (p.stop_freq / 1e6).toFixed(0); + s += ` - ${startMHz}-${stopMHz}MHz`; + } + if (p.points) s += `, ${p.points}pts`; + return s; + } + + handlePresetChange() { + const v = this.elements.presetDropdown.value; + this.elements.setPresetBtn.disabled = !v; + } + + async handleSetPreset() { + const filename = this.elements.presetDropdown.value; + if (!filename) return; + + this.debouncer.debounce('set-preset', () => + this.reqGuard.runExclusive('set-preset', async () => { + try { + ButtonState.set(this.elements.setPresetBtn, { state: 'loading', icon: 'loader', text: 'Setting...' }); + + const r = await fetch('/api/v1/settings/preset/set', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filename }) + }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const result = await r.json(); + + this.notify('success', 'Preset Set', result.message); + this.currentPreset = { filename }; + + if (this.onPresetChanged) { + await this.onPresetChanged(); + } + } catch (e) { + console.error('Set preset failed:', e); + this.notify('error', 'Preset Error', 'Failed to set configuration preset'); + } finally { + ButtonState.set(this.elements.setPresetBtn, { state: 'normal', icon: 'check', text: 'Set Active' }); + } + }), 300 + ); + } + + updateStatus(status) { + if (status.current_preset) { + this.currentPreset = status.current_preset; + this.elements.currentPreset.textContent = status.current_preset.filename; + } else { + this.currentPreset = null; + this.elements.currentPreset.textContent = 'None'; + } + } + + getCurrentPreset() { + return this.currentPreset; + } + + notify(type, title, message) { + this.notifications?.show?.({ type, title, message }); + } +} \ No newline at end of file diff --git a/vna_system/web_ui/static/js/modules/settings/reference-manager.js b/vna_system/web_ui/static/js/modules/settings/reference-manager.js new file mode 100644 index 0000000..0ac10e3 --- /dev/null +++ b/vna_system/web_ui/static/js/modules/settings/reference-manager.js @@ -0,0 +1,282 @@ +/** + * Reference Manager + * Handles VNA measurement references + */ + +import { Debouncer, RequestGuard, ButtonState } from '../utils.js'; + +export class ReferenceManager { + constructor(notifications) { + this.notifications = notifications; + this.availableReferences = []; + this.currentReference = null; + this.currentPreset = null; + this.elements = {}; + this.debouncer = new Debouncer(); + this.reqGuard = new RequestGuard(); + + this.handleCreateReference = this.handleCreateReference.bind(this); + this.handleReferenceChange = this.handleReferenceChange.bind(this); + this.handleSetReference = this.handleSetReference.bind(this); + this.handleClearReference = this.handleClearReference.bind(this); + this.handleDeleteReference = this.handleDeleteReference.bind(this); + } + + init(elements) { + this.elements = elements; + this.elements.createReferenceBtn?.addEventListener('click', this.handleCreateReference); + this.elements.referenceDropdown?.addEventListener('change', this.handleReferenceChange); + this.elements.setReferenceBtn?.addEventListener('click', this.handleSetReference); + this.elements.clearReferenceBtn?.addEventListener('click', this.handleClearReference); + this.elements.deleteReferenceBtn?.addEventListener('click', this.handleDeleteReference); + } + + destroy() { + this.elements.createReferenceBtn?.removeEventListener('click', this.handleCreateReference); + this.elements.referenceDropdown?.removeEventListener('change', this.handleReferenceChange); + this.elements.setReferenceBtn?.removeEventListener('click', this.handleSetReference); + this.elements.clearReferenceBtn?.removeEventListener('click', this.handleClearReference); + this.elements.deleteReferenceBtn?.removeEventListener('click', this.handleDeleteReference); + } + + setCurrentPreset(preset) { + this.currentPreset = preset; + if (preset) { + this.loadReferences(); + } else { + this.reset(); + } + } + + async loadReferences() { + try { + if (!this.currentPreset) { + this.renderDropdown([]); + this.updateInfo(null); + return; + } + + const referencesResponse = await fetch(`/api/v1/settings/references?preset_filename=${encodeURIComponent(this.currentPreset.filename)}`); + if (!referencesResponse.ok) throw new Error(`HTTP ${referencesResponse.status}`); + this.availableReferences = await referencesResponse.json(); + + const currentResponse = await fetch(`/api/v1/settings/reference/current?preset_filename=${encodeURIComponent(this.currentPreset.filename)}`); + if (!currentResponse.ok) throw new Error(`HTTP ${currentResponse.status}`); + this.currentReference = await currentResponse.json(); + + this.renderDropdown(this.availableReferences); + this.updateInfo(this.currentReference); + } catch (error) { + console.error('Failed to load references:', error); + this.renderDropdown([]); + this.updateInfo(null); + } + } + + renderDropdown(references) { + if (!this.elements.referenceDropdown) return; + + this.elements.referenceDropdown.innerHTML = ''; + + if (references.length === 0) { + this.elements.referenceDropdown.innerHTML = ''; + this.elements.referenceDropdown.disabled = true; + this.elements.setReferenceBtn.disabled = true; + this.elements.clearReferenceBtn.disabled = true; + this.elements.deleteReferenceBtn.disabled = true; + } else { + this.elements.referenceDropdown.innerHTML = ''; + + references.forEach(ref => { + const option = document.createElement('option'); + option.value = ref.name; + option.textContent = `${ref.name} (${new Date(ref.timestamp).toLocaleDateString()})`; + this.elements.referenceDropdown.appendChild(option); + }); + + this.elements.referenceDropdown.disabled = false; + + if (this.currentReference) { + this.elements.referenceDropdown.value = this.currentReference.name; + } + } + + this.updateButtons(); + } + + updateButtons() { + const hasSelection = this.elements.referenceDropdown.value !== ''; + const hasCurrent = this.currentReference !== null; + + this.elements.setReferenceBtn.disabled = !hasSelection; + this.elements.deleteReferenceBtn.disabled = !hasSelection; + this.elements.clearReferenceBtn.disabled = !hasCurrent; + } + + updateInfo(reference) { + if (!this.elements.currentReferenceInfo) return; + + if (!reference) { + this.elements.currentReferenceInfo.style.display = 'none'; + return; + } + + this.elements.currentReferenceInfo.style.display = 'block'; + + if (this.elements.currentReferenceName) { + this.elements.currentReferenceName.textContent = reference.name; + } + + if (this.elements.currentReferenceTimestamp) { + this.elements.currentReferenceTimestamp.textContent = new Date(reference.timestamp).toLocaleString(); + } + + if (this.elements.currentReferenceDescription) { + this.elements.currentReferenceDescription.textContent = reference.description || ''; + } + } + + async handleCreateReference() { + const name = this.elements.referenceNameInput.value.trim(); + if (!name) { + this.notify('warning', 'Missing Name', 'Please enter a name for the reference'); + return; + } + + const description = this.elements.referenceDescriptionInput.value.trim(); + + this.debouncer.debounce('create-reference', () => + this.reqGuard.runExclusive('create-reference', async () => { + try { + ButtonState.set(this.elements.createReferenceBtn, { state: 'loading', icon: 'loader', text: 'Creating...' }); + + const response = await fetch('/api/v1/settings/reference/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, description }) + }); + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const result = await response.json(); + + this.notify('success', 'Reference Created', result.message); + + this.elements.referenceNameInput.value = ''; + this.elements.referenceDescriptionInput.value = ''; + + await this.loadReferences(); + } catch (error) { + console.error('Create reference failed:', error); + this.notify('error', 'Reference Error', 'Failed to create reference'); + } finally { + ButtonState.set(this.elements.createReferenceBtn, { state: 'normal', icon: 'target', text: 'Capture Reference' }); + } + }), 500 + ); + } + + handleReferenceChange() { + this.updateButtons(); + } + + async handleSetReference() { + const referenceName = this.elements.referenceDropdown.value; + if (!referenceName) return; + + this.debouncer.debounce('set-reference', () => + this.reqGuard.runExclusive('set-reference', async () => { + try { + ButtonState.set(this.elements.setReferenceBtn, { state: 'loading', icon: 'loader', text: 'Setting...' }); + + const response = await fetch('/api/v1/settings/reference/set', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: referenceName }) + }); + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const result = await response.json(); + + this.notify('success', 'Reference Set', result.message); + + await this.loadReferences(); + } catch (error) { + console.error('Set reference failed:', error); + this.notify('error', 'Reference Error', 'Failed to set reference'); + } finally { + ButtonState.set(this.elements.setReferenceBtn, { state: 'normal', icon: 'check', text: 'Set Active' }); + } + }), 400 + ); + } + + async handleClearReference() { + this.debouncer.debounce('clear-reference', () => + this.reqGuard.runExclusive('clear-reference', async () => { + try { + ButtonState.set(this.elements.clearReferenceBtn, { state: 'loading', icon: 'loader', text: 'Clearing...' }); + + const response = await fetch('/api/v1/settings/reference/current', { + method: 'DELETE' + }); + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const result = await response.json(); + + this.notify('success', 'Reference Cleared', result.message); + + await this.loadReferences(); + } catch (error) { + console.error('Clear reference failed:', error); + this.notify('error', 'Reference Error', 'Failed to clear reference'); + } finally { + ButtonState.set(this.elements.clearReferenceBtn, { state: 'normal', icon: 'x', text: 'Clear' }); + } + }), 400 + ); + } + + async handleDeleteReference() { + const referenceName = this.elements.referenceDropdown.value; + if (!referenceName) return; + + if (!confirm(`Are you sure you want to delete reference "${referenceName}"? This action cannot be undone.`)) { + return; + } + + this.debouncer.debounce('delete-reference', () => + this.reqGuard.runExclusive('delete-reference', async () => { + try { + ButtonState.set(this.elements.deleteReferenceBtn, { state: 'loading', icon: 'loader', text: 'Deleting...' }); + + const response = await fetch(`/api/v1/settings/reference/${encodeURIComponent(referenceName)}`, { + method: 'DELETE' + }); + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const result = await response.json(); + + this.notify('success', 'Reference Deleted', result.message); + + await this.loadReferences(); + } catch (error) { + console.error('Delete reference failed:', error); + this.notify('error', 'Reference Error', 'Failed to delete reference'); + } finally { + ButtonState.set(this.elements.deleteReferenceBtn, { state: 'normal', icon: 'trash-2', text: 'Delete' }); + } + }), 400 + ); + } + + reset() { + this.availableReferences = []; + this.currentReference = null; + this.renderDropdown([]); + this.updateInfo(null); + } + + notify(type, title, message) { + this.notifications?.show?.({ type, title, message }); + } +} \ No newline at end of file diff --git a/vna_system/web_ui/static/js/modules/storage.js b/vna_system/web_ui/static/js/modules/storage.js index 04b9524..d2bef42 100644 --- a/vna_system/web_ui/static/js/modules/storage.js +++ b/vna_system/web_ui/static/js/modules/storage.js @@ -3,6 +3,8 @@ * Handles localStorage persistence and user preferences */ +import { formatBytes } from './utils.js'; + export class StorageManager { constructor() { this.prefix = 'vna_dashboard_'; @@ -271,7 +273,7 @@ export class StorageManager { return { available: true, totalSize, - totalSizeFormatted: this.formatBytes(totalSize), + totalSizeFormatted: formatBytes(totalSize), itemSizes, itemCount: Object.keys(itemSizes).length, storageQuotaMB: this.estimateStorageQuota() @@ -291,19 +293,6 @@ export class StorageManager { return 5; // MB } - /** - * Format bytes to human readable format - */ - formatBytes(bytes) { - if (bytes === 0) return '0 Bytes'; - - const k = 1024; - const sizes = ['Bytes', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; - } - /** * Cleanup old or unnecessary data */