Adding the Initial WIP

This commit is contained in:
Ketan Patel committed 2025-10-14 20:09:28 -04:00
commit 326479c338
25 files changed
+6629

No files matched your search

+400
View File
@@ -0,0 +1,400 @@
// Configuration management JavaScript
class ConfigManager {
constructor() {
this.init();
}
init() {
this.bindEvents();
this.loadUPSList();
}
bindEvents() {
// Add UPS button
document.getElementById('add-ups-btn').addEventListener('click', () => {
this.showUPSModal();
});
// Modal close buttons
document.querySelectorAll('.close').forEach(close => {
close.addEventListener('click', (e) => {
this.closeModal(e.target.closest('.modal'));
});
});
// UPS form submission
document.getElementById('ups-form').addEventListener('submit', (e) => {
e.preventDefault();
this.saveUPS();
});
// Test connection button
document.getElementById('test-connection-btn').addEventListener('click', () => {
this.testConnection();
});
// Cancel button
document.getElementById('cancel-btn').addEventListener('click', () => {
this.closeModal(document.getElementById('ups-modal'));
});
// Confirmation modal buttons
document.getElementById('confirm-yes').addEventListener('click', () => {
if (this.confirmCallback) {
this.confirmCallback();
}
this.closeModal(document.getElementById('confirm-modal'));
});
document.getElementById('confirm-no').addEventListener('click', () => {
this.closeModal(document.getElementById('confirm-modal'));
});
// Click outside modal to close
window.addEventListener('click', (e) => {
if (e.target.classList.contains('modal')) {
this.closeModal(e.target);
}
});
}
async loadUPSList() {
try {
const response = await fetch('/api/config/ups');
const upsList = await response.json();
if (!response.ok) {
throw new Error(upsList.detail || 'Failed to load UPS configurations');
}
this.renderUPSList(upsList);
} catch (error) {
this.showToast('Error loading UPS configurations: ' + error.message, 'error');
}
}
renderUPSList(upsList) {
const container = document.getElementById('ups-list');
if (upsList.length === 0) {
container.innerHTML = `
<div class="empty-state">
<h3>No UPS Configured</h3>
<p>Get started by clicking "Add UPS" above to configure your first UPS monitoring.</p>
</div>
`;
return;
}
container.innerHTML = upsList.map(ups => this.renderUPSItem(ups)).join('');
// Bind action buttons
container.querySelectorAll('.edit-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const upsName = e.target.dataset.upsName;
this.editUPS(upsName);
});
});
container.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const upsName = e.target.dataset.upsName;
this.confirmDelete(upsName);
});
});
container.querySelectorAll('.test-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const upsName = e.target.dataset.upsName;
this.testUPSConnection(upsName);
});
});
}
renderUPSItem(ups) {
const alertsEnabled = [
ups.alert_loadpct_high && `Load: ${ups.alert_loadpct_high}%`,
ups.alert_bcharge_low && `Battery: ${ups.alert_bcharge_low}%`,
ups.alert_on_battery && 'On Battery',
ups.alert_runtime_low_minutes && `Runtime: ${ups.alert_runtime_low_minutes}min`
].filter(Boolean);
return `
<div class="ups-item">
<div class="ups-item-header">
<div class="ups-item-title">${ups.name}</div>
<div class="ups-item-actions">
<button class="btn btn-secondary test-btn" data-ups-name="${ups.name}">Test</button>
<button class="btn btn-primary edit-btn" data-ups-name="${ups.name}">Edit</button>
<button class="btn btn-danger delete-btn" data-ups-name="${ups.name}">Delete</button>
</div>
</div>
<div class="ups-item-details">
<div class="ups-detail">
<div class="ups-detail-label">Host</div>
<div class="ups-detail-value">${ups.host}:${ups.port}</div>
</div>
<div class="ups-detail">
<div class="ups-detail-label">Polling Interval</div>
<div class="ups-detail-value">${ups.interval_seconds}s</div>
</div>
<div class="ups-detail">
<div class="ups-detail-label">Alerts</div>
<div class="ups-detail-value">${alertsEnabled.length > 0 ? alertsEnabled.join(', ') : 'None'}</div>
</div>
</div>
</div>
`;
}
showUPSModal(ups = null) {
const modal = document.getElementById('ups-modal');
const title = document.getElementById('modal-title');
const form = document.getElementById('ups-form');
// Reset form
form.reset();
if (ups) {
// Edit mode
title.textContent = 'Edit UPS';
this.populateForm(ups);
form.dataset.mode = 'edit';
form.dataset.upsName = ups.name;
} else {
// Add mode
title.textContent = 'Add UPS';
form.dataset.mode = 'add';
delete form.dataset.upsName;
}
modal.style.display = 'block';
}
populateForm(ups) {
document.getElementById('ups-name').value = ups.name || '';
document.getElementById('ups-host').value = ups.host || '';
document.getElementById('ups-port').value = ups.port || 3551;
document.getElementById('ups-interval').value = ups.interval_seconds || 30;
document.getElementById('ups-loadpct').value = ups.alert_loadpct_high || '';
document.getElementById('ups-bcharge').value = ups.alert_bcharge_low || '';
document.getElementById('ups-onbattery').checked = ups.alert_on_battery || false;
document.getElementById('ups-runtime').value = ups.alert_runtime_low_minutes || '';
}
closeModal(modal) {
modal.style.display = 'none';
}
async saveUPS() {
const form = document.getElementById('ups-form');
const formData = new FormData(form);
const mode = form.dataset.mode;
const upsName = form.dataset.upsName;
const data = {
name: formData.get('name'),
host: formData.get('host'),
port: parseInt(formData.get('port')) || 3551,
interval_seconds: parseInt(formData.get('interval_seconds')) || 30,
alert_on_battery: formData.has('alert_on_battery')
};
// Add optional alert thresholds
const loadpct = formData.get('alert_loadpct_high');
if (loadpct) data.alert_loadpct_high = parseFloat(loadpct);
const bcharge = formData.get('alert_bcharge_low');
if (bcharge) data.alert_bcharge_low = parseFloat(bcharge);
const runtime = formData.get('alert_runtime_low_minutes');
if (runtime) data.alert_runtime_low_minutes = parseFloat(runtime);
try {
let response;
if (mode === 'edit') {
response = await fetch(`/api/config/ups/${upsName}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
} else {
response = await fetch('/api/config/ups', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
}
const result = await response.json();
if (!response.ok) {
throw new Error(result.detail || 'Failed to save UPS configuration');
}
this.showToast(result.message, 'success');
this.closeModal(document.getElementById('ups-modal'));
this.loadUPSList();
} catch (error) {
this.showToast('Error saving UPS: ' + error.message, 'error');
}
}
async editUPS(upsName) {
try {
const response = await fetch(`/api/config/ups/${upsName}`);
const ups = await response.json();
if (!response.ok) {
throw new Error(ups.detail || 'Failed to load UPS configuration');
}
this.showUPSModal(ups);
} catch (error) {
this.showToast('Error loading UPS configuration: ' + error.message, 'error');
}
}
confirmDelete(upsName) {
const modal = document.getElementById('confirm-modal');
const message = document.getElementById('confirm-message');
message.textContent = `Are you sure you want to delete the UPS configuration "${upsName}"? This action cannot be undone.`;
this.confirmCallback = () => this.deleteUPS(upsName);
modal.style.display = 'block';
}
async deleteUPS(upsName) {
try {
const response = await fetch(`/api/config/ups/${upsName}`, {
method: 'DELETE'
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.detail || 'Failed to delete UPS configuration');
}
this.showToast(result.message, 'success');
this.loadUPSList();
} catch (error) {
this.showToast('Error deleting UPS: ' + error.message, 'error');
}
}
async testConnection() {
const form = document.getElementById('ups-form');
const formData = new FormData(form);
const data = {
name: formData.get('name') || 'test',
host: formData.get('host'),
port: parseInt(formData.get('port')) || 3551,
interval_seconds: 30,
alert_on_battery: false
};
if (!data.host) {
this.showToast('Host is required for connection test', 'error');
return;
}
const testBtn = document.getElementById('test-connection-btn');
const originalText = testBtn.textContent;
testBtn.textContent = 'Testing...';
testBtn.disabled = true;
try {
const response = await fetch('/api/config/ups/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await response.json();
let msg;
if (result.success) {
msg = 'Connection successful';
if (result.data && result.data.STATUS) {
msg += ` (STATUS=${result.data.STATUS})`;
}
this.showToast(msg, 'success');
} else {
if (result.connectivity && !result.connectivity.ok) {
msg = 'TCP connectivity failed: ' + (result.connectivity.error || 'unknown error');
} else if (result.protocol && !result.protocol.ok) {
msg = 'Protocol error after TCP success: ' + (result.protocol.error || 'unknown error');
} else {
msg = result.message || 'Connection failed';
}
this.showToast(msg, 'error');
}
} catch (error) {
this.showToast('Connection test failed: ' + error.message, 'error');
} finally {
testBtn.textContent = originalText;
testBtn.disabled = false;
}
}
async testUPSConnection(upsName) {
const testBtn = document.querySelector(`[data-ups-name="${upsName}"].test-btn`);
const originalText = testBtn.textContent;
testBtn.textContent = 'Testing...';
testBtn.disabled = true;
try {
const response = await fetch(`/api/config/ups/${upsName}/test`, {
method: 'POST'
});
const result = await response.json();
let msg;
if (result.success) {
msg = `Connection to ${upsName} successful`;
if (result.data && result.data.STATUS) {
msg += ` (STATUS=${result.data.STATUS})`;
}
this.showToast(msg, 'success');
} else {
if (result.connectivity && !result.connectivity.ok) {
msg = `TCP connectivity failed: ${result.connectivity.error}`;
} else if (result.protocol && !result.protocol.ok) {
msg = `Protocol error: ${result.protocol.error}`;
} else {
msg = result.message || 'Connection failed';
}
this.showToast(`Connection to ${upsName} failed: ${msg}`, 'error');
}
} catch (error) {
this.showToast(`Connection test failed: ${error.message}`, 'error');
} finally {
testBtn.textContent = originalText;
testBtn.disabled = false;
}
}
showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
// Auto-remove after 5 seconds
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 5000);
}
}
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
new ConfigManager();
});
+460
View File
@@ -0,0 +1,460 @@
// Clean UPS Dashboard - Simplified but Functional
const DEBUG_ENABLED = localStorage.getItem('ups_debug') === 'true' ||
new URLSearchParams(window.location.search).get('debug') === 'true';
function debugLog(...args) {
if (DEBUG_ENABLED) console.log('[UPS Debug]', ...args);
}
function debugError(...args) {
console.error('[UPS Error]', ...args);
}
// Debug control functions
window.upsDebug = {
enable: () => {
localStorage.setItem('ups_debug', 'true');
location.reload();
},
disable: () => {
localStorage.removeItem('ups_debug');
location.reload();
}
};
const evtSource = new EventSource('/api/stream');
const charts = {};
// Core tile definitions
const TILE_REGISTRY = [
{ id: 'load_pct', metric: 'LOADPCT', label: 'Load %', type: 'gauge' },
{ id: 'battery_pct', metric: 'BCHARGE', label: 'Battery %', type: 'gauge' },
{ id: 'watts_usage', metric: 'DERIVED_WATTS', label: 'Watts', type: 'line' },
{ id: 'volt_line', metric: 'LINEV', label: 'Line V', type: 'value' },
{ id: 'volt_output', metric: 'OUTPUTV', label: 'Output V', type: 'value', fallback: 'LINEV' }
];
// Storage for tile positions and card sizes
const TILE_POS_KEY = 'upsTilePos.v1';
const CARD_SIZE_KEY = 'upsCardSizes.v1';
let savedTilePos = {};
let savedCardSizes = {};
function loadFromStorage(key, defaultValue = {}) {
try { return JSON.parse(localStorage.getItem(key) || '{}'); } catch(_) { return defaultValue; }
}
function saveToStorage(key, data) {
try { localStorage.setItem(key, JSON.stringify(data)); } catch(_) {}
}
// Initialize storage
savedTilePos = loadFromStorage(TILE_POS_KEY);
savedCardSizes = loadFromStorage(CARD_SIZE_KEY);
// Initialize tiles for a UPS
function initTilesFor(name, grid) {
if (!grid) return;
debugLog('Creating tiles for UPS:', name);
TILE_REGISTRY.forEach((tileDef, idx) => {
const tile = document.createElement('div');
tile.className = 'tile';
tile.dataset.tile = tileDef.id;
// Default positioning
tile.style.left = (10 + (idx % 3) * 220) + 'px';
tile.style.top = (10 + Math.floor(idx / 3) * 180) + 'px';
tile.style.width = '200px';
tile.style.height = '160px';
tile.innerHTML = `
<h4>${tileDef.label}</h4>
<div class="tile-body">
${createTileContent(name, tileDef)}
</div>
`;
grid.appendChild(tile);
attachTileBehavior(name, tile, tileDef);
});
applyStoredPositions(name, grid);
}
function createTileContent(name, tileDef) {
if (tileDef.type === 'gauge') {
return `<div class="gauge" data-gauge="${tileDef.metric}">
<svg viewBox="0 0 120 70">
<path d="M10 60 A50 50 0 0 1 110 60" class="gauge-bg" />
<path d="M10 60 A50 50 0 0 1 110 60" class="gauge-fill" stroke-dasharray="0 157" />
<text x="60" y="45" class="gauge-text" text-anchor="middle">--%</text>
</svg>
</div>`;
} else if (tileDef.type === 'value') {
const unit = tileDef.metric.includes('V') ? 'V' : 'W';
return `<div class="big-value-container">
<span class="big-value" data-value="${tileDef.metric}">--</span>
<span class="value-unit">${unit}</span>
</div>`;
} else if (tileDef.type === 'line') {
const canvasId = `chart-${name}-${tileDef.id}`;
return `<canvas id="${canvasId}" height="90"></canvas>`;
}
return '<div>--</div>';
}
function attachTileBehavior(name, tile, tileDef) {
// Make tiles draggable
let isDragging = false;
let startX, startY, initialLeft, initialTop;
tile.addEventListener('mousedown', (e) => {
if (e.target.tagName === 'CANVAS') return; // Don't drag on charts
isDragging = true;
startX = e.clientX;
startY = e.clientY;
initialLeft = parseInt(tile.style.left) || 0;
initialTop = parseInt(tile.style.top) || 0;
tile.style.zIndex = 1000;
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
tile.style.left = (initialLeft + deltaX) + 'px';
tile.style.top = (initialTop + deltaY) + 'px';
});
document.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
tile.style.zIndex = '';
savePositions(name);
}
});
// Initialize chart if needed
if (tileDef.type === 'line') {
setTimeout(() => initChart(name, tileDef), 100);
}
}
function initChart(name, tileDef) {
const canvasId = `chart-${name}-${tileDef.id}`;
const canvas = document.getElementById(canvasId);
if (!canvas) return;
debugLog('Creating chart:', canvasId);
try {
charts[canvasId] = new Chart(canvas, {
type: 'line',
data: {
labels: [],
datasets: [{
label: tileDef.label,
data: [],
borderColor: '#ff6b35',
backgroundColor: 'rgba(255,107,53,0.15)',
tension: 0.3,
fill: true,
pointRadius: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: { beginAtZero: false },
x: { display: false }
},
plugins: { legend: { display: false } }
}
});
} catch (error) {
debugError('Chart creation failed:', canvasId, error);
}
}
function savePositions(name) {
const grid = document.querySelector(`#card-${name} [data-tile-grid]`);
if (!grid) return;
savedTilePos[name] = {};
grid.querySelectorAll('.tile').forEach(tile => {
const id = tile.dataset.tile;
savedTilePos[name][id] = {
left: parseInt(tile.style.left) || 0,
top: parseInt(tile.style.top) || 0,
width: parseInt(tile.style.width) || 200,
height: parseInt(tile.style.height) || 160
};
});
saveToStorage(TILE_POS_KEY, savedTilePos);
}
function applyStoredPositions(name, grid) {
const positions = savedTilePos[name];
if (!positions) return;
grid.querySelectorAll('.tile').forEach(tile => {
const id = tile.dataset.tile;
const pos = positions[id];
if (pos) {
tile.style.left = pos.left + 'px';
tile.style.top = pos.top + 'px';
tile.style.width = pos.width + 'px';
tile.style.height = pos.height + 'px';
}
});
}
// Reset layout function
function resetLayout(name) {
debugLog('Resetting layout for:', name);
const grid = document.querySelector(`#card-${name} [data-tile-grid]`);
if (!grid) return;
// Clear existing tiles and charts
Object.keys(charts).forEach(chartId => {
if (chartId.includes(name)) {
try { charts[chartId].destroy(); } catch(_) {}
delete charts[chartId];
}
});
grid.innerHTML = '';
// Clear saved positions
delete savedTilePos[name];
saveToStorage(TILE_POS_KEY, savedTilePos);
// Recreate tiles
initTilesFor(name, grid);
// Auto-arrange with proper spacing
autoArrangeTiles(name);
}
function autoArrangeTiles(name) {
const grid = document.querySelector(`#card-${name} [data-tile-grid]`);
if (!grid) return;
const tiles = Array.from(grid.querySelectorAll('.tile'));
const cols = 3;
const tileWidth = 200;
const tileHeight = 160;
const gap = 20;
tiles.forEach((tile, idx) => {
const col = idx % cols;
const row = Math.floor(idx / cols);
tile.style.left = (10 + col * (tileWidth + gap)) + 'px';
tile.style.top = (10 + row * (tileHeight + gap)) + 'px';
tile.style.width = tileWidth + 'px';
tile.style.height = tileHeight + 'px';
});
savePositions(name);
}
// Update functions
function updateUPSData(name, data) {
const card = document.getElementById(`card-${name}`);
if (!card) return;
// Ensure tiles exist
const grid = card.querySelector('[data-tile-grid]');
if (grid && grid.children.length === 0) {
initTilesFor(name, grid);
}
// Update status and badges
updateBadges(card, data);
// Update tiles
TILE_REGISTRY.forEach(tileDef => {
updateTile(name, tileDef, data);
});
}
function updateBadges(card, data) {
// Update status
const status = card.querySelector('[data-field="STATUS"]');
if (status) {
status.textContent = data.STATUS || 'UNKNOWN';
status.className = 'ups-status ' + (data.STATUS === 'ONLINE' ? 'online' : 'unknown');
}
// Update model/serial
const modelSerial = card.querySelector('[data-model-serial]');
if (modelSerial && (data.MODEL || data.SERIALNO)) {
const parts = [];
if (data.MODEL) parts.push(data.MODEL);
if (data.SERIALNO) parts.push(`S/N: ${data.SERIALNO}`);
modelSerial.textContent = parts.join(' • ');
}
// Update other badges
['RUNTIME_MINUTES', 'DERIVED_WATTS', 'HEADROOM_PCT'].forEach(field => {
const badge = card.querySelector(`[data-field="${field}"]`);
if (badge && data[field]) {
const value = parseFloat(data[field]) || 0;
const unit = field === 'RUNTIME_MINUTES' ? ' m' :
field === 'DERIVED_WATTS' ? ' W' : ' headroom';
badge.textContent = value.toFixed(0) + unit;
}
});
}
function updateTile(name, tileDef, data) {
const rawValue = data[tileDef.metric] || (tileDef.fallback ? data[tileDef.fallback] : null);
if (!rawValue) return;
const value = parseFloat(rawValue) || 0;
if (tileDef.type === 'gauge') {
updateGauge(name, tileDef.metric, value);
} else if (tileDef.type === 'value') {
updateValueDisplay(name, tileDef.metric, value);
} else if (tileDef.type === 'line') {
updateChart(name, tileDef, value);
}
}
function updateGauge(name, metric, value) {
const gauge = document.querySelector(`#card-${name} [data-gauge="${metric}"]`);
if (!gauge) return;
const fill = gauge.querySelector('.gauge-fill');
const text = gauge.querySelector('.gauge-text');
if (fill && text) {
const total = 157;
const filled = (value / 100) * total;
fill.setAttribute('stroke-dasharray', `${filled} ${total - filled}`);
text.textContent = value.toFixed(0) + '%';
gauge.className = 'gauge ' + (value < 15 ? 'critical' : value < 35 ? 'low' : 'good');
}
}
function updateValueDisplay(name, metric, value) {
const element = document.querySelector(`#card-${name} [data-value="${metric}"]`);
if (!element) return;
element.textContent = value.toFixed(1);
if (metric.includes('V')) {
element.className = 'big-value ' + (value >= 114 && value <= 126 ? 'voltage-good' : 'voltage-bad');
}
}
function updateChart(name, tileDef, value) {
const chartId = `chart-${name}-${tileDef.id}`;
const chart = charts[chartId];
if (!chart) return;
const now = new Date().toLocaleTimeString();
chart.data.labels.push(now);
chart.data.datasets[0].data.push(value);
// Keep last 60 points
if (chart.data.labels.length > 60) {
chart.data.labels.shift();
chart.data.datasets[0].data.shift();
}
// Smart scaling for watts
if (tileDef.id === 'watts_usage') {
const values = chart.data.datasets[0].data;
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min;
if (range <= 50) {
chart.options.scales.y.min = Math.max(0, min - 10);
chart.options.scales.y.max = max + 10;
}
}
chart.update('none');
}
// SSE event handling
evtSource.onmessage = (e) => {
try {
const payload = JSON.parse(e.data);
const snapshots = payload.snapshots || {};
Object.entries(snapshots).forEach(([name, snap]) => {
updateUPSData(name, snap);
});
} catch (err) {
debugError('SSE Error:', err);
}
};
// Card resize functionality
function persistCardSize(name, width, height) {
savedCardSizes[name] = { width, height };
saveToStorage(CARD_SIZE_KEY, savedCardSizes);
}
function restoreCardSize(name) {
const card = document.getElementById(`card-${name}`);
const saved = savedCardSizes[name];
if (card && saved) {
card.style.width = saved.width + 'px';
card.style.height = saved.height + 'px';
}
}
// Initialize on DOM ready
document.addEventListener('DOMContentLoaded', () => {
debugLog('Dashboard initializing...');
// Initialize existing cards
document.querySelectorAll('.card').forEach(card => {
const name = card.id.replace('card-', '');
const grid = card.querySelector('[data-tile-grid]');
if (grid) {
initTilesFor(name, grid);
restoreCardSize(name);
}
});
// Attach reset button listeners
document.addEventListener('click', (e) => {
if (e.target.matches('[data-reset-layout]')) {
const card = e.target.closest('.card');
const name = card.id.replace('card-', '');
resetLayout(name);
}
});
// Watch for new cards added dynamically
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE && node.classList?.contains('card')) {
const name = node.id.replace('card-', '');
const grid = node.querySelector('[data-tile-grid]');
if (grid) {
debugLog('New UPS card detected:', name);
initTilesFor(name, grid);
}
}
});
});
});
const upsCards = document.getElementById('ups-cards');
if (upsCards) {
observer.observe(upsCards, { childList: true });
}
});
File diff suppressed because it is too large. Load diff
+336
View File
@@ -0,0 +1,336 @@
// Simple UPS Dashboard
const DEBUG = localStorage.getItem('debug') === 'true' || true; // Temporarily enable debug
const log = DEBUG ? console.log.bind(console, '[UPS]') : () => {};
// Global state
const charts = {};
const evtSource = new EventSource('/api/stream');
let lastUpdateTs = {};
// Core tile types
const TILES = {
load_pct: { metric: 'LOADPCT', label: 'Load %', type: 'gauge' },
battery_pct: { metric: 'BCHARGE', label: 'Battery %', type: 'gauge' },
watts_usage: { metric: 'DERIVED_WATTS', label: 'Watts', type: 'line' },
volt_line: { metric: 'LINEV', label: 'Line V', type: 'value' },
volt_output: { metric: 'OUTPUTV', label: 'Output V', type: 'value' }
};
// Initialize on page load
document.addEventListener('DOMContentLoaded', () => {
log('DOM loaded, Chart.js available:', typeof Chart !== 'undefined');
initializeDashboard();
setupEventListeners();
setupDynamicCardObserver();
});
function setupDynamicCardObserver() {
// Watch for new UPS cards being added to the DOM
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE && node.classList?.contains('card')) {
const name = node.id.replace('card-', '');
log('New UPS card detected:', name);
createTiles(name);
initChartsForUPS(name);
}
});
});
});
const upsCards = document.getElementById('ups-cards');
if (upsCards) {
observer.observe(upsCards, { childList: true });
}
}
function initializeDashboard() {
document.querySelectorAll('.card').forEach(card => {
const name = card.id.replace('card-', '');
createTiles(name);
});
}
function setupEventListeners() {
// Reset button handlers
document.addEventListener('click', (e) => {
if (e.target.matches('[data-reset-layout]')) {
const card = e.target.closest('.card');
const name = card.id.replace('card-', '');
resetLayout(name);
}
});
}
function createTiles(upsName) {
log('Creating tiles for UPS:', upsName);
const grid = document.querySelector(`#card-${upsName} [data-tile-grid]`);
if (!grid) {
console.warn('Grid not found for UPS:', upsName);
return;
}
grid.innerHTML = '';
log('Creating', Object.keys(TILES).length, 'tiles');
Object.entries(TILES).forEach(([id, config], index) => {
log('Creating tile:', id, config);
const tile = document.createElement('div');
tile.className = 'tile';
tile.dataset.tile = id;
tile.innerHTML = `
<h4>${config.label}</h4>
<div class="tile-body">
${createTileContent(upsName, id, config)}
</div>
`;
// Simple positioning
const x = (index % 3) * 220 + 10;
const y = Math.floor(index / 3) * 180 + 10;
tile.style.left = x + 'px';
tile.style.top = y + 'px';
tile.style.width = '200px';
tile.style.height = '160px';
grid.appendChild(tile);
});
}
function createTileContent(upsName, tileId, config) {
if (config.type === 'gauge') {
return `<div class="gauge" data-gauge="${config.metric}">
<svg viewBox="0 0 120 70">
<path d="M10 60 A50 50 0 0 1 110 60" class="gauge-bg" />
<path d="M10 60 A50 50 0 0 1 110 60" class="gauge-fill" stroke-dasharray="0 157" />
<text x="60" y="45" class="gauge-text" text-anchor="middle">--%</text>
</svg>
</div>`;
} else if (config.type === 'value') {
const unit = config.metric.includes('V') ? 'V' : 'W';
return `<div class="big-value-container">
<span class="big-value" data-value="${config.metric}">--</span>
<span class="value-unit">${unit}</span>
</div>`;
} else if (config.type === 'line') {
const canvasId = `chart-${upsName}-${tileId}`;
return `<canvas id="${canvasId}" height="90"></canvas>`;
}
return '<div>--</div>';
}
function resetLayout(upsName) {
log('Resetting layout for', upsName);
createTiles(upsName);
// Clear any existing charts
Object.keys(charts).forEach(key => {
if (key.includes(upsName)) {
charts[key].destroy();
delete charts[key];
}
});
// Recreate charts
initChartsForUPS(upsName);
}
function initChartsForUPS(upsName) {
log('Initializing charts for UPS:', upsName);
Object.entries(TILES).forEach(([id, config]) => {
if (config.type === 'line') {
const canvasId = `chart-${upsName}-${id}`;
const canvas = document.getElementById(canvasId);
log('Looking for canvas:', canvasId, 'found:', !!canvas);
if (canvas) {
try {
charts[canvasId] = new Chart(canvas, {
type: 'line',
data: {
labels: [],
datasets: [{
label: config.label,
data: [],
borderColor: '#ff6b35',
backgroundColor: 'rgba(255,107,53,0.15)',
tension: 0.3,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: { beginAtZero: false },
x: { display: false }
},
plugins: { legend: { display: false } }
}
});
log('Chart created successfully for:', canvasId);
} catch (error) {
console.error('Error creating chart:', canvasId, error);
}
} else {
console.warn('Canvas not found for chart:', canvasId);
}
}
});
}
// SSE Event handling
evtSource.onmessage = (e) => {
try {
const payload = JSON.parse(e.data);
const snapshots = payload.snapshots || {};
Object.entries(snapshots).forEach(([name, snap]) => {
updateUPSData(name, snap);
});
} catch (err) {
console.error('SSE Error:', err);
}
};
function updateUPSData(name, data) {
const card = document.getElementById(`card-${name}`);
if (!card) return;
// Ensure tiles exist for this UPS (in case card was added dynamically)
const grid = card.querySelector('[data-tile-grid]');
if (grid && grid.children.length === 0) {
log('Creating tiles for new UPS:', name);
createTiles(name);
initChartsForUPS(name);
}
lastUpdateTs[name] = Date.now();
// Update status badges
updateBadges(card, data);
// Update tiles
Object.entries(TILES).forEach(([id, config]) => {
updateTile(name, id, config, data);
});
}
function updateBadges(card, data) {
// Update status
const status = card.querySelector('[data-field="STATUS"]');
if (status) {
status.textContent = data.STATUS || 'UNKNOWN';
status.className = 'ups-status ' + (data.STATUS === 'ONLINE' ? 'online' : 'unknown');
}
// Update model/serial display
const modelSerial = card.querySelector('[data-model-serial]');
if (modelSerial && (data.MODEL || data.SERIALNO)) {
const parts = [];
if (data.MODEL) parts.push(data.MODEL);
if (data.SERIALNO) parts.push(`S/N: ${data.SERIALNO}`);
modelSerial.textContent = parts.join(' • ');
}
// Update other badges
['RUNTIME_MINUTES', 'DERIVED_WATTS', 'HEADROOM_PCT'].forEach(field => {
const badge = card.querySelector(`[data-field="${field}"]`);
if (badge && data[field]) {
const value = parseFloat(data[field]) || 0;
const unit = field === 'RUNTIME_MINUTES' ? ' m' :
field === 'DERIVED_WATTS' ? ' W' : ' headroom';
badge.textContent = value.toFixed(0) + unit;
}
});
}
function updateTile(upsName, tileId, config, data) {
const rawValue = data[config.metric] || (config.metric === 'OUTPUTV' ? data['LINEV'] : null);
if (!rawValue) return;
const value = parseFloat(rawValue) || 0;
if (config.type === 'gauge') {
updateGauge(upsName, config.metric, value);
} else if (config.type === 'value') {
updateValueDisplay(upsName, config.metric, value);
} else if (config.type === 'line') {
updateChart(upsName, tileId, value);
}
}
function updateGauge(upsName, metric, value) {
const gauge = document.querySelector(`#card-${upsName} [data-gauge="${metric}"]`);
if (!gauge) return;
const fill = gauge.querySelector('.gauge-fill');
const text = gauge.querySelector('.gauge-text');
if (fill && text) {
const total = 157;
const filled = (value / 100) * total;
fill.setAttribute('stroke-dasharray', `${filled} ${total - filled}`);
text.textContent = value.toFixed(0) + '%';
// Color coding
gauge.className = 'gauge ' + (value < 15 ? 'critical' : value < 35 ? 'low' : 'good');
}
}
function updateValueDisplay(upsName, metric, value) {
const element = document.querySelector(`#card-${upsName} [data-value="${metric}"]`);
if (!element) return;
element.textContent = value.toFixed(1);
// Voltage color coding
if (metric.includes('V')) {
element.className = 'big-value ' + (value >= 114 && value <= 126 ? 'voltage-good' : 'voltage-bad');
}
}
function updateChart(upsName, tileId, value) {
const chartId = `chart-${upsName}-${tileId}`;
const chart = charts[chartId];
if (!chart) return;
const now = new Date().toLocaleTimeString();
chart.data.labels.push(now);
chart.data.datasets[0].data.push(value);
// Keep last 60 points
if (chart.data.labels.length > 60) {
chart.data.labels.shift();
chart.data.datasets[0].data.shift();
}
// Auto-scale for watts
if (tileId === 'watts_usage') {
const values = chart.data.datasets[0].data;
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min;
if (range <= 50) {
chart.options.scales.y.min = Math.max(0, min - 10);
chart.options.scales.y.max = max + 10;
}
}
chart.update('none');
}
// Initialize charts after DOM is ready
setTimeout(() => {
document.querySelectorAll('.card').forEach(card => {
const name = card.id.replace('card-', '');
initChartsForUPS(name);
});
}, 100);
// Debug controls
window.upsDebug = {
enable: () => { localStorage.setItem('debug', 'true'); location.reload(); },
disable: () => { localStorage.removeItem('debug'); location.reload(); }
};
File diff suppressed because it is too large. Load diff