refactoring step 3

This commit is contained in:
Ayzen
2025-09-30 14:23:21 +03:00
parent c9dc977204
commit 32d5aa48d9
8 changed files with 1456 additions and 1716 deletions

View File

@ -3,15 +3,16 @@
* Handles Plotly.js chart creation, updates, and management * 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 { export class ChartManager {
constructor(config, notifications) { constructor(config, notifications) {
this.config = config; this.config = config;
this.notifications = notifications; this.notifications = notifications;
this.charts = new Map(); // id -> { element, plotContainer, isVisible } this.charts = new Map();
this.chartData = new Map(); // id -> [{ timestamp, metadata, data, plotly_config }] this.chartData = new Map();
this.disabledProcessors = new Set(); this.disabledProcessors = new Set();
this.chartsGrid = null; this.chartsGrid = null;
@ -28,11 +29,7 @@ export class ChartManager {
lastUpdateTime: null lastUpdateTime: null
}; };
// Debounce timers for settings updates this.settingsManager = new ChartSettingsManager();
this.settingDebounceTimers = {};
// Track last setting values to prevent feedback loops
this.lastSettingValues = {};
this.plotlyConfig = { this.plotlyConfig = {
displayModeBar: true, displayModeBar: true,
@ -48,7 +45,7 @@ export class ChartManager {
paper_bgcolor: 'transparent', paper_bgcolor: 'transparent',
font: { family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif', size: 12, color: '#f1f5f9' }, font: { family: 'Inter, -apple-system, BlinkMacSystemFont, sans-serif', size: 12, color: '#f1f5f9' },
colorway: ['#3b82f6','#22c55e','#eab308','#ef4444','#8b5cf6','#06b6d4','#f59e0b','#10b981'], 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, showlegend: true,
legend: { legend: {
orientation: 'v', orientation: 'v',
@ -76,7 +73,6 @@ export class ChartManager {
console.log('Chart Manager initialized'); console.log('Chart Manager initialized');
} }
/** New input format - direct payload from WS: processor_result */
addResult(payload) { addResult(payload) {
try { try {
const { processor_id, timestamp, plotly_config, metadata, data } = payload; const { processor_id, timestamp, plotly_config, metadata, data } = payload;
@ -86,13 +82,11 @@ export class ChartManager {
} }
if (this.disabledProcessors.has(processor_id)) { if (this.disabledProcessors.has(processor_id)) {
console.log(` Skipping disabled processor: ${processor_id}`);
return; return;
} }
// Store only the latest data (no history needed)
this.chartData.set(processor_id, { 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 || {}, metadata: metadata || {},
data: data || {}, data: data || {},
plotly_config: plotly_config || { data: [], layout: {} } plotly_config: plotly_config || { data: [], layout: {} }
@ -132,7 +126,7 @@ export class ChartManager {
plotContainer._resizeObserver = ro; 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++; this.performanceStats.chartsCreated++;
if (this.config.animation) { if (this.config.animation) {
@ -144,7 +138,10 @@ export class ChartManager {
if (this.isPaused) return; if (this.isPaused) return;
const chart = this.charts.get(processorId); 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 { try {
const start = performance.now(); const start = performance.now();
@ -160,14 +157,14 @@ export class ChartManager {
await Plotly.react(chart.plotContainer, plotlyConfig.data || [], updateLayout, this.plotlyConfig); await Plotly.react(chart.plotContainer, plotlyConfig.data || [], updateLayout, this.plotlyConfig);
this.updateChartMetadata(processorId); this.updateChartMetadata(processorId);
// Only update settings if chart is newly created or if parameters actually changed
if (!chart.settingsInitialized) { if (!chart.settingsInitialized) {
this.updateChartSettings(processorId); this.updateChartSettings(processorId);
chart.settingsInitialized = true; chart.settingsInitialized = true;
} else { } else {
// Check if settings need updating without forcing it
this.updateChartSettings(processorId); this.updateChartSettings(processorId);
} }
const dt = performance.now() - start; const dt = performance.now() - start;
this.updatePerformanceStats(dt); this.updatePerformanceStats(dt);
}); });
@ -229,13 +226,11 @@ export class ChartManager {
</div> </div>
<div class="chart-card__meta"> <div class="chart-card__meta">
<div class="chart-card__timestamp" data-timestamp="">Last update: --</div> <div class="chart-card__timestamp" data-timestamp="">Last update: --</div>
<div class="chart-card__sweep" data-sweep="">Info: --</div> <div class="chart-card__sweep" data-sweep=""></div>
</div> </div>
`; `;
this.setupChartCardEvents(card, processorId); this.setupChartCardEvents(card, processorId);
// Initialize settings immediately
this.updateChartSettings(processorId); this.updateChartSettings(processorId);
if (typeof lucide !== 'undefined') lucide.createIcons({ attrs: { 'stroke-width': 1.5 } }); 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 chart = this.charts.get(processorId);
const latestData = this.chartData.get(processorId); const latestData = this.chartData.get(processorId);
if (!chart || !latestData) return; 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) { if (tsEl) {
const dt = latestData.timestamp instanceof Date ? latestData.timestamp : new Date(); const dt = latestData.timestamp instanceof Date ? latestData.timestamp : new Date();
tsEl.textContent = `Last update: ${dt.toLocaleTimeString()}`; tsEl.textContent = `Last update: ${dt.toLocaleTimeString()}`;
tsEl.dataset.timestamp = dt.toISOString(); 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) { updateChartSettings(processorId) {
const chart = this.charts.get(processorId); const chart = this.charts.get(processorId);
const settingsContainer = chart?.element?.querySelector('.chart-settings__controls'); 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 latestData = this.chartData.get(processorId);
const uiParameters = latestData?.metadata?.ui_parameters;
// Store current parameters to avoid unnecessary updates if (settingsContainer && latestData) {
if (!chart.lastUiParameters) chart.lastUiParameters = null; this.settingsManager.updateSettings(processorId, settingsContainer, latestData);
// 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
} }
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 = '<div class="settings-empty">No settings available</div>';
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); } toggleProcessor(id, enabled) { enabled ? this.showChart(id) : this.hideChart(id); }
showChart(id) { showChart(id) {
const c = this.charts.get(id); const c = this.charts.get(id);
if (c) { if (c) {
@ -488,6 +287,7 @@ export class ChartManager {
} }
this.updateEmptyStateVisibility(); this.updateEmptyStateVisibility();
} }
hideChart(id) { hideChart(id) {
const c = this.charts.get(id); const c = this.charts.get(id);
if (c) { c.element.classList.add('chart-card--hidden'); c.isVisible = false; } 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 timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const baseFilename = `${id}_${timestamp}`; const baseFilename = `${id}_${timestamp}`;
// Download image
await Plotly.downloadImage(c.plotContainer, { await Plotly.downloadImage(c.plotContainer, {
format: 'png', format: 'png',
width: 1200, width: 1200,
@ -531,8 +330,7 @@ export class ChartManager {
filename: `${baseFilename}_plot` filename: `${baseFilename}_plot`
}); });
// Prepare and download processor data const processorData = this.prepareDownloadData(id);
const processorData = this.prepareProcessorDownloadData(id);
if (processorData) { if (processorData) {
downloadJSON(processorData, `${baseFilename}_data.json`); downloadJSON(processorData, `${baseFilename}_data.json`);
} }
@ -552,7 +350,7 @@ export class ChartManager {
} }
} }
prepareProcessorDownloadData(processorId) { prepareDownloadData(processorId) {
const chart = this.charts.get(processorId); const chart = this.charts.get(processorId);
const latestData = this.chartData.get(processorId); const latestData = this.chartData.get(processorId);
@ -562,89 +360,17 @@ export class ChartManager {
processor_info: { processor_info: {
processor_id: processorId, processor_id: processorId,
processor_name: formatProcessorName(processorId), processor_name: formatProcessorName(processorId),
download_timestamp: new Date().toISOString(), download_timestamp: new Date().toISOString()
is_visible: chart.isVisible
}, },
current_data: { current_data: {
data: safeClone(latestData.data), data: safeClone(latestData.data),
metadata: safeClone(latestData.metadata), metadata: safeClone(latestData.metadata),
timestamp: latestData.timestamp instanceof Date ? latestData.timestamp.toISOString() : latestData.timestamp, timestamp: latestData.timestamp instanceof Date ? latestData.timestamp.toISOString() : latestData.timestamp,
plotly_config: safeClone(latestData.plotly_config) 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) { toggleFullscreen(id) {
const c = this.charts.get(id); const c = this.charts.get(id);
if (!c?.element) return; if (!c?.element) return;
@ -668,6 +394,7 @@ export class ChartManager {
hideEmptyState() { hideEmptyState() {
if (this.emptyState) this.emptyState.classList.add('empty-state--hidden'); if (this.emptyState) this.emptyState.classList.add('empty-state--hidden');
} }
updateEmptyStateVisibility() { updateEmptyStateVisibility() {
if (!this.emptyState) return; if (!this.emptyState) return;
const hasVisible = Array.from(this.charts.values()).some(c => c.isVisible); 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'); } pause() { this.isPaused = true; console.log('Chart updates paused'); }
resume() { this.isPaused = false; console.log('Chart updates resumed'); if (this.updateQueue.size) this.processUpdateQueue(); } 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); } getDisabledProcessors() { return Array.from(this.disabledProcessors); }
getStats() { getStats() {
return { return {
...this.performanceStats, ...this.performanceStats,
@ -792,6 +427,7 @@ export class ChartManager {
destroy() { destroy() {
console.log('Cleaning up Chart Manager...'); console.log('Cleaning up Chart Manager...');
this.clearAll(); this.clearAll();
this.settingsManager.destroy();
this.updateQueue.clear(); this.updateQueue.clear();
this.isUpdating = false; this.isUpdating = false;
this.isPaused = true; this.isPaused = true;

View File

@ -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 = '<div class="settings-empty">No settings available</div>';
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();
}
}

View File

@ -3,6 +3,8 @@
* Handles toast notifications and user feedback * Handles toast notifications and user feedback
*/ */
import { escapeHtml } from './utils.js';
export class NotificationManager { export class NotificationManager {
constructor() { constructor() {
this.container = null; this.container = null;
@ -183,10 +185,10 @@ export class NotificationManager {
const iconHtml = `<i data-lucide="${notification.config.icon}" class="notification__icon"></i>`; const iconHtml = `<i data-lucide="${notification.config.icon}" class="notification__icon"></i>`;
const titleHtml = notification.title ? const titleHtml = notification.title ?
`<div class="notification__title">${this.escapeHtml(notification.title)}</div>` : ''; `<div class="notification__title">${escapeHtml(notification.title)}</div>` : '';
const messageHtml = notification.message ? const messageHtml = notification.message ?
`<div class="notification__message">${this.escapeHtml(notification.message)}</div>` : ''; `<div class="notification__message">${escapeHtml(notification.message)}</div>` : '';
const actionsHtml = notification.actions.length > 0 ? const actionsHtml = notification.actions.length > 0 ?
this.createActionsHtml(notification.actions) : ''; this.createActionsHtml(notification.actions) : '';
@ -224,7 +226,7 @@ export class NotificationManager {
return ` return `
<button class="${buttonClass}" data-action="custom" data-action-id="${action.id}"> <button class="${buttonClass}" data-action="custom" data-action-id="${action.id}">
${action.icon ? `<i data-lucide="${action.icon}"></i>` : ''} ${action.icon ? `<i data-lucide="${action.icon}"></i>` : ''}
${this.escapeHtml(action.label)} ${escapeHtml(action.label)}
</button> </button>
`; `;
}).join(''); }).join('');
@ -471,18 +473,6 @@ export class NotificationManager {
return Math.max(...notifications.map(n => n.createdAt.getTime())); return Math.max(...notifications.map(n => n.createdAt.getTime()));
} }
/**
* Escape HTML to prevent XSS
*/
escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
/** /**
* Cleanup * Cleanup
*/ */

File diff suppressed because it is too large Load Diff

View File

@ -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 = '<option value="">No calibrations available</option>';
dd.disabled = true;
this.elements.setCalibrationBtn.disabled = true;
this.elements.viewPlotsBtn.disabled = true;
return;
}
dd.innerHTML = '<option value="">Select calibration...</option>';
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 = `<i data-lucide="clock"></i> Capturing ${std.toUpperCase()}...`;
btn.disabled = true;
btn.title = 'Standard is currently being captured';
} else if (isCompleted) {
btn.classList.add('btn--success');
btn.innerHTML = `<i data-lucide="check"></i> ${std.toUpperCase()}`;
btn.disabled = false;
btn.title = 'Click to recapture this standard';
} else if (isMissing) {
btn.classList.add('btn--primary');
btn.innerHTML = `<i data-lucide="radio"></i> 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 });
}
}

View File

@ -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 = '<option value="">No presets available</option>';
dd.disabled = true;
this.elements.setPresetBtn.disabled = true;
return;
}
dd.innerHTML = '<option value="">Select preset...</option>';
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 });
}
}

View File

@ -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 = '<option value="">No references available</option>';
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 = '<option value="">Select a reference...</option>';
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 });
}
}

View File

@ -3,6 +3,8 @@
* Handles localStorage persistence and user preferences * Handles localStorage persistence and user preferences
*/ */
import { formatBytes } from './utils.js';
export class StorageManager { export class StorageManager {
constructor() { constructor() {
this.prefix = 'vna_dashboard_'; this.prefix = 'vna_dashboard_';
@ -271,7 +273,7 @@ export class StorageManager {
return { return {
available: true, available: true,
totalSize, totalSize,
totalSizeFormatted: this.formatBytes(totalSize), totalSizeFormatted: formatBytes(totalSize),
itemSizes, itemSizes,
itemCount: Object.keys(itemSizes).length, itemCount: Object.keys(itemSizes).length,
storageQuotaMB: this.estimateStorageQuota() storageQuotaMB: this.estimateStorageQuota()
@ -291,19 +293,6 @@ export class StorageManager {
return 5; // MB 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 * Cleanup old or unnecessary data
*/ */