Files
apcupsd-client/app/static/js/dashboard.js
T

1577 lines
59 KiB
JavaScript

// Debug configuration
const DEBUG_ENABLED = localStorage.getItem('ups_debug') === 'true' ||
new URLSearchParams(window.location.search).get('debug') === 'true';
// Debug logging helper
function debugLog(...args) {
if (DEBUG_ENABLED) console.log('[UPS Debug]', ...args);
}
function debugWarn(...args) {
if (DEBUG_ENABLED) console.warn('[UPS Debug]', ...args);
}
function debugError(...args) {
console.error('[UPS Error]', ...args); // Always log errors
}
// Debug control functions (accessible from browser console)
window.upsDebug = {
enable: () => {
localStorage.setItem('ups_debug', 'true');
console.log('UPS Debug mode enabled. Reload page to activate.');
location.reload();
},
disable: () => {
localStorage.removeItem('ups_debug');
console.log('UPS Debug mode disabled. Reload page to deactivate.');
location.reload();
},
status: () => {
console.log(`UPS Debug mode: ${DEBUG_ENABLED ? 'ENABLED' : 'DISABLED'}`);
if (!DEBUG_ENABLED) {
console.log('To enable: upsDebug.enable() or add ?debug=true to URL');
}
return DEBUG_ENABLED;
}
};
const evtSource = new EventSource('/api/stream');
const charts = {};
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[ch]));
}
function apiUpsPath(name, suffix = '') {
return `/api/ups/${encodeURIComponent(name)}${suffix}`;
}
function getCard(name) {
return document.getElementById(`card-${name}`);
}
function setDashboardSyncState(message, live = false) {
const el = document.getElementById('dashboard-sync-state');
const wrapper = el?.closest('.dashboard-clock');
if (el) el.textContent = message;
if (wrapper) wrapper.classList.toggle('live', live);
}
// -------- Fleet overview poller --------
async function refreshFleetOverview() {
try {
const r = await fetch('/api/ups/fleet/overview', { credentials: 'same-origin' });
if (!r.ok) return;
const d = await r.json();
const setText = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; };
setText('fleet-total', d.total ?? 0);
setText('fleet-online', d.counts?.online ?? 0);
setText('fleet-onbatt', d.counts?.on_battery ?? 0);
setText('fleet-offline', d.counts?.offline ?? 0);
setText('fleet-watts', d.total_watts != null ? Math.round(d.total_watts) : '—');
const minRT = d.min_timeleft_minutes;
setText('fleet-min-runtime', minRT != null ? `${Math.round(minRT)} m` : '—');
} catch (e) { setDashboardSyncState('Fleet summary unavailable', false); }
}
refreshFleetOverview();
setInterval(refreshFleetOverview, 10000);
// Tile registry and state
const TILE_REGISTRY = [
{ id: 'load_pct', metric: 'LOADPCT', label: 'UPS Load %', short: 'Load', types: ['gauge','line','bar'], defaultType: 'gauge' },
{ id: 'battery_pct', metric: 'BCHARGE', label: 'Battery %', short: 'Battery', types: ['gauge','line','bar'], defaultType: 'gauge' },
{ id: 'watts_usage', metric: 'DERIVED_WATTS', label: 'Watts', short: 'Watts', types: ['line','bar','value'], defaultType: 'line' },
{ id: 'volt_line', metric: 'LINEV', label: 'Line Voltage', short: 'Line V', types: ['value'], defaultType: 'value' },
{ id: 'volt_output', metric: 'OUTPUTV', label: 'Output Voltage', short: 'Output V', types: ['value'], defaultType: 'value', fallbackMetric: 'LINEV' }
];
const EXTRA_TILES = []; // Additional tiles beyond the core registry
// Storage keys and saved state
const TILE_TYPE_KEY = 'upsTileTypes.v1';
const TILE_ORDER_KEY = 'upsTileOrder.v1';
const TILE_HIDDEN_KEY = 'upsTileHidden.v1';
const CUSTOM_TILES_KEY = 'upsCustomTiles.v1';
const TILE_POS_KEY = 'upsTilePos.v1';
const CARD_SIZE_KEY = 'upsCardSizes.v1';
const CARD_MODE_KEY = 'upsCardMode.v1';
let savedTileTypes = {};
let savedTileOrder = {};
let savedTileHidden = {};
let savedCustomTiles = {};
let savedTilePos = {};
let savedCardSizes = {};
let savedSizes = {}; // Legacy card sizes for compatibility
let savedModes = {};
// Helper functions for localStorage
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 localStorage data
savedTileTypes = loadFromStorage(TILE_TYPE_KEY);
savedTileOrder = loadFromStorage(TILE_ORDER_KEY);
savedTileHidden = loadFromStorage(TILE_HIDDEN_KEY);
savedCustomTiles = loadFromStorage(CUSTOM_TILES_KEY);
savedTilePos = loadFromStorage(TILE_POS_KEY);
savedCardSizes = loadFromStorage(CARD_SIZE_KEY);
savedSizes = loadFromStorage(CARD_SIZE_KEY); // Legacy compatibility
savedModes = loadFromStorage(CARD_MODE_KEY);
// UI Configuration
const uiCfgEl = document.getElementById('ups-cards');
let uiCfg = loadFromStorage('data-ui', {});
try {
const dataUi = uiCfgEl?.getAttribute('data-ui');
if (dataUi) uiCfg = JSON.parse(dataUi);
} catch(_) {}
// Set defaults
uiCfg.allow_resize = uiCfg.allow_resize !== false;
// Human-friendly metric labels for dropdown & headers
const METRIC_LABELS = {
'LOADPCT': 'UPS Load %',
'BCHARGE': 'Battery %',
'DERIVED_WATTS': 'Watts',
'HEADROOM_PCT': 'Headroom %',
'RUNTIME_MINUTES': 'Runtime (min)',
'LINEV': 'Line Voltage',
'OUTPUTV': 'Output Voltage',
'BATTV': 'Battery Voltage'
};
// Maintain a map of last update timestamps to detect stale UPS
const lastUpdateTs = {};
const UPS_STALE_SECONDS = 120; // mark offline after 2 minutes inactivity
// Freeform tile layout enhancements
const SNAP_GRID = 20; // pixel grid for snapping
let selectedTiles = new Set();
function snap(v) { return Math.round(v / SNAP_GRID) * SNAP_GRID; }
function clearSelection() { selectedTiles.forEach(t => t.classList.remove('selected')); selectedTiles.clear(); }
function toggleTileSelection(tile, additive) {
if (!additive) clearSelection();
if (selectedTiles.has(tile)) { tile.classList.remove('selected'); selectedTiles.delete(tile); }
else { tile.classList.add('selected'); selectedTiles.add(tile); }
}
// --- Redis-backed tile layout persistence ---
async function loadServerTileConfig(name) {
try {
const resp = await fetch(apiUpsPath(name, '/ui_tiles'));
if (!resp.ok) return null;
return await resp.json();
} catch { return null; }
}
async function resetLayout(name) {
// Clear local state for this UPS
delete savedTilePos[name];
delete savedTileOrder[name];
delete savedTileHidden[name];
delete savedCustomTiles[name];
delete savedTileTypes[name];
delete savedCardSizes[name];
saveToStorage(TILE_POS_KEY, savedTilePos);
saveToStorage(TILE_ORDER_KEY, savedTileOrder);
saveToStorage(TILE_HIDDEN_KEY, savedTileHidden);
saveToStorage(CUSTOM_TILES_KEY, savedCustomTiles);
saveToStorage(TILE_TYPE_KEY, savedTileTypes);
saveToStorage(CARD_SIZE_KEY, savedCardSizes);
// Clear server layout
try {
await window.apiFetch(apiUpsPath(name, '/ui_tiles'), { method:'DELETE' });
debugLog(`Cleared server layout for ${name}`);
} catch(err) {
debugWarn(`Failed to clear server layout for ${name}:`, err);
}
// Rebuild grid with defaults then cascade layout
const card = getCard(name);
if (!card) return;
const grid = card.querySelector('[data-tile-grid]');
if (!grid) return;
grid.innerHTML = '';
// Destroy any existing charts for this UPS
Object.keys(charts).forEach(chartId => {
if (chartId.includes(`tile-${name}-`)) {
charts[chartId].destroy();
delete charts[chartId];
}
});
// Recreate default tile types
// Static default layout definition (positions and types)
savedTileTypes[name] = {
load_pct: 'gauge',
battery_pct: 'gauge',
volt_line: 'value',
volt_output: 'value',
watts_usage: 'line'
};
try { localStorage.setItem(TILE_TYPE_KEY, JSON.stringify(savedTileTypes)); } catch{}
initTilesFor(name, grid);
// Force rebuild all tiles with correct types
rebuildAllTiles(name);
// Auto-arrange tiles with optimal layout
autoArrangeTiles(name, grid);
debugLog(`Reset layout completed for ${name}`);
// Force chart resize after positioning
setTimeout(()=>{ Object.values(charts).forEach(ch=>{ try{ ch.resize(); }catch{} }); }, 50);
persistTilePositions(name, grid);
scheduleServerTileSave(name);
}
let pendingSave = null;
function scheduleServerTileSave(name) {
if (pendingSave) clearTimeout(pendingSave);
pendingSave = setTimeout(() => saveServerTileConfig(name), 800);
}
function autoArrangeTiles(name, grid) {
const tiles = Array.from(grid.querySelectorAll('.tile'));
if (tiles.length === 0) return;
// Define tile layout specifications
const tileSpecs = {
load_pct: { width: 200, height: 200, priority: 1 },
battery_pct: { width: 200, height: 200, priority: 2 },
volt_line: { width: 180, height: 80, priority: 3 },
volt_output: { width: 180, height: 80, priority: 4 },
watts_usage: { width: 400, height: 180, priority: 5 }
};
const padding = 10;
const gap = 10;
// Sort tiles by priority
tiles.sort((a, b) => {
const aId = a.dataset.tile;
const bId = b.dataset.tile;
return (tileSpecs[aId]?.priority || 99) - (tileSpecs[bId]?.priority || 99);
});
// Layout algorithm: pack tiles efficiently
let currentX = padding;
let currentY = padding;
let rowHeight = 0;
let maxRowWidth = 800; // Initial estimate, will be adjusted
tiles.forEach(tile => {
const tileId = tile.dataset.tile;
const spec = tileSpecs[tileId] || { width: 200, height: 150 };
// Check if tile fits in current row
if (currentX + spec.width > maxRowWidth && currentX > padding) {
// Move to next row
currentX = padding;
currentY += rowHeight + gap;
rowHeight = 0;
}
// Position tile
tile.style.left = `${currentX}px`;
tile.style.top = `${currentY}px`;
tile.style.width = `${spec.width}px`;
tile.style.height = `${spec.height}px`;
// Update position for next tile
currentX += spec.width + gap;
rowHeight = Math.max(rowHeight, spec.height);
});
// Calculate optimal card size
const maxX = Math.max(...tiles.map(tile =>
parseInt(tile.style.left) + parseInt(tile.style.width)
));
const maxY = Math.max(...tiles.map(tile =>
parseInt(tile.style.top) + parseInt(tile.style.height)
));
// Auto-size the UPS card
const card = getCard(name);
if (card) {
const cardWidth = Math.max(maxX + padding, 400);
const cardHeight = Math.max(maxY + padding + 50, 300); // +50 for header
card.style.width = `${cardWidth}px`;
card.style.height = `${cardHeight}px`;
// Update grid size
grid.style.width = `${cardWidth - 20}px`;
grid.style.height = `${cardHeight - 70}px`;
// Save card size
persistCardSize(name, cardWidth, cardHeight);
debugLog(`Auto-arranged ${tiles.length} tiles for ${name}, card size: ${cardWidth}x${cardHeight}`);
}
}
function persistCardSize(name, width, height) {
savedCardSizes[name] = { width, height };
saveToStorage(CARD_SIZE_KEY, savedCardSizes);
}
function restoreCardSize(name) {
const card = getCard(name);
const grid = card?.querySelector('[data-tile-grid]');
if (!card || !grid) return false;
const savedSize = savedCardSizes[name];
if (savedSize) {
card.style.width = `${savedSize.width}px`;
card.style.height = `${savedSize.height}px`;
grid.style.width = `${savedSize.width - 20}px`;
grid.style.height = `${savedSize.height - 70}px`;
return true;
}
return false;
}
async function saveServerTileConfig(name) {
const types = savedTileTypes[name] || {};
const order = (savedTileOrder[name] || []);
const hiddenArr = Object.entries(savedTileHidden[name] || {})
.filter(([_, v]) => v === true).map(([k]) => k);
const custom = (savedCustomTiles[name] || []).map(c => ({
id: c.id, metric: c.metric, chart: c.chart, source: c.source || 'live'
}));
const positions = savedTilePos[name] || {};
const card_size = savedCardSizes[name] || null;
try {
await window.apiFetch(apiUpsPath(name, '/ui_tiles'), {
method: 'POST',
body: JSON.stringify({ types, order, hidden: hiddenArr, custom, positions, card_size })
});
} catch {}
}
function ensureUpsCard(name) {
let card = getCard(name);
if (card) {
// Ensure add metric listener attached (server-rendered cards bypass creation path)
const addBtn = card.querySelector('[data-add-metric]');
if (addBtn && !addBtn.dataset.bound) {
addBtn.addEventListener('click', () => openMetricModal(name));
addBtn.dataset.bound = '1';
}
// Ensure reset layout listener attached
const resetBtn = card.querySelector('[data-reset-layout]');
if (resetBtn && !resetBtn.dataset.bound) {
resetBtn.addEventListener('click', () => resetLayout(name));
resetBtn.dataset.bound = '1';
}
// Inject mode toggle if resizing enabled and missing
if (uiCfg.allow_resize && !card.querySelector('[data-mode-btn]')) {
const header = card.querySelector('.card-header');
if (header) {
const btn = document.createElement('button');
btn.className = 'mode-toggle';
btn.setAttribute('data-mode-btn','');
btn.title = 'Toggle compact view';
btn.textContent = '⇳';
header.insertBefore(btn, header.querySelector('.add-metric-btn'));
btn.addEventListener('click', () => toggleMode(card));
}
}
// Inject resize handle if needed
if (uiCfg.allow_resize && !card.querySelector('.resize-handle')) {
const handle = document.createElement('div');
handle.className = 'resize-handle';
handle.title = 'Drag to resize';
handle.textContent = '⇲';
card.appendChild(handle);
attachResizeBehavior(card, handle);
}
// Restore saved card size if not already applied inline
if (!card.style.width && savedCardSizes[name]) {
restoreCardSize(name);
}
return card;
}
const container = document.getElementById('ups-cards');
const div = document.createElement('div');
div.className = 'card';
div.id = `card-${name}`;
// Apply saved size if available
if (savedSizes[name]) {
const { w, h } = savedSizes[name];
if (w) div.style.width = w + 'px';
if (h) div.style.height = h + 'px';
}
div.innerHTML = `
<div class="card-header">
<h2><span class="state-dot" data-state-dot></span>${escapeHtml(name)}</h2>
<div class="ups-connection" data-field-conn></div>
${uiCfg.allow_resize ? '<button class="mode-toggle" data-mode-btn title="Toggle compact view">⇳</button>' : ''}
<button class="add-metric-btn" data-add-metric title="Add metric tile">+</button>
<button class="reset-layout-btn" data-reset-layout title="Reset tile layout">⟳</button>
</div>
<div class="metrics">
<div class="highline">
<span class="ups-status unknown" data-field="STATUS">-</span>
<span class="badge" data-field="RUNTIME_MINUTES" data-badge="runtime">- m</span>
<span class="badge" data-field="DERIVED_WATTS" data-badge="watts">- W</span>
<span class="badge" data-field="HEADROOM_PCT" data-badge="headroom">- headroom</span>
</div>
<div class="tile-grid" data-tile-grid></div>
</div>
<div class="ups-footer" data-ups-footer></div>`;
// Add resize handle element (custom) to work consistently across browsers
if (uiCfg.allow_resize) {
const handle = document.createElement('div');
handle.className = 'resize-handle';
handle.title = 'Drag to resize';
handle.textContent = '⇲';
div.appendChild(handle);
attachResizeBehavior(div, handle);
}
container.appendChild(div);
// Observe card for resize events to update footer position
if (typeof ro !== 'undefined') {
ro.observe(div);
}
// Apply saved mode
if (savedModes[name] === 'compact') {
div.classList.add('compact');
}
if (uiCfg.allow_resize) {
const btn = div.querySelector('[data-mode-btn]');
if (btn) btn.addEventListener('click', () => toggleMode(div));
}
const addBtn = div.querySelector('[data-add-metric]');
if (addBtn) {
addBtn.addEventListener('click', () => openMetricModal(name));
addBtn.dataset.bound = '1';
}
const resetBtn = div.querySelector('[data-reset-layout]');
if (resetBtn) {
resetBtn.addEventListener('click', () => resetLayout(name));
resetBtn.dataset.bound = '1';
}
// Set default tile types for new UPS (static layout)
if (!savedTileTypes[name]) {
savedTileTypes[name] = {
load_pct: 'gauge',
volt_line: 'value',
volt_output: 'value',
watts_usage: 'value'
};
try { localStorage.setItem(TILE_TYPE_KEY, JSON.stringify(savedTileTypes)); } catch{}
}
// Initialize tiles
initTilesFor(name, div.querySelector('[data-tile-grid]'));
// Save defaults server-side
scheduleServerTileSave(name);
return div;
}
function initTilesFor(name, grid) {
debugLog('initTilesFor called for:', name, 'grid:', !!grid);
if (!grid) {
debugError('No grid found for UPS:', name);
return;
}
// Order: user-saved order or default registry order
let defs = [...TILE_REGISTRY];
const customOrder = savedTileOrder[name];
if (Array.isArray(customOrder)) {
defs.sort((a,b) => customOrder.indexOf(a.id) - customOrder.indexOf(b.id));
}
debugLog('Creating', defs.length, 'tiles from registry for', name);
defs.forEach((tileDef, idx) => {
debugLog('Creating tile:', tileDef.id, tileDef.label);
const tile = document.createElement('div');
tile.className = 'tile';
tile.dataset.tile = tileDef.id;
// Default cascade layout before positions applied
tile.style.left = (10 + (idx * 20)) + 'px';
tile.style.top = (10 + (idx * 20)) + 'px';
tile.innerHTML = `<div class="tile-controls">${renderTileSelect(name, tileDef)}</div><h4>${escapeHtml(tileDef.short || tileDef.label)}</h4><div class="tile-body"></div>`;
if (savedTileHidden[name] && savedTileHidden[name][tileDef.id]) tile.classList.add('hidden');
const resizeHandle = document.createElement('div');
resizeHandle.className = 'tile-resize';
tile.appendChild(resizeHandle);
grid.appendChild(tile);
attachTileBehavior(name, tile, tileDef);
});
if (uiCfg.show_energy) {
const energyTile = document.createElement('div');
energyTile.className = 'tile';
energyTile.dataset.tile = 'energy';
energyTile.style.left = '12px';
energyTile.style.top = '12px';
energyTile.innerHTML = `<h4>Energy Today</h4><div class="tile-body"><span class="big-value" data-energy-val>-- kWh</span></div>`;
const resizeHandle = document.createElement('div'); resizeHandle.className='tile-resize'; energyTile.appendChild(resizeHandle);
grid.appendChild(energyTile);
}
addTileLayoutControls(name, grid);
(savedCustomTiles[name] || []).forEach(ct => { createCustomTile(name, grid, ct); });
applyStoredPositions(name, grid);
}
function renderTileSelect(name, tileDef) {
if (tileDef.types.length <= 1) return '';
const cur = savedTileTypes[name]?.[tileDef.id] || tileDef.defaultType;
return `<select data-tile-select data-name="${escapeHtml(name)}" data-tile-id="${escapeHtml(tileDef.id)}">` +
tileDef.types.map(t => `<option value="${escapeHtml(t)}" ${t===cur?'selected':''}>${escapeHtml(t)}</option>`).join('') + '</select>';
}
function attachTileBehavior(name, tile, tileDef) {
const sel = tile.querySelector('[data-tile-select]');
if (sel) {
sel.addEventListener('change', e => {
const val = e.target.value;
savedTileTypes[name] = savedTileTypes[name] || {};
savedTileTypes[name][tileDef.id] = val;
try { localStorage.setItem(TILE_TYPE_KEY, JSON.stringify(savedTileTypes)); } catch(_) {}
scheduleServerTileSave(name);
buildTileVisualization(name, tile, tileDef);
});
}
buildTileVisualization(name, tile, tileDef);
}
function buildTileVisualization(name, tile, tileDef) {
const body = tile.querySelector('.tile-body');
const type = (savedTileTypes[name] && savedTileTypes[name][tileDef.id]) || tileDef.defaultType;
// Destroy existing chart if switching away from chart type
const chartId = `tile-${name}-${tileDef.id}`;
if (charts[chartId]) {
try {
charts[chartId].destroy();
} catch(e) {
debugWarn('Error destroying chart:', e);
}
delete charts[chartId];
}
body.innerHTML = '';
if (type === 'gauge') {
const metric = tileDef.metric;
body.innerHTML = `<div class="gauge" data-gauge-metric="${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 (type === 'value') {
const metric = tileDef.metric;
let unit = '';
if (metric === 'LINEV' || metric === 'OUTPUTV') {
unit = 'V';
} else if (metric === 'DERIVED_WATTS') {
unit = 'W';
}
body.innerHTML = `<div class="big-value-container"><span class="big-value" data-value-metric="${metric}">--</span><span class="value-unit">${unit}</span></div>`;
} else {
// Chart or big value
if (['line','bar'].includes(type)) {
const canvasId = `tile-${name}-${tileDef.id}`;
body.innerHTML = `<canvas id="${canvasId}" height="90"></canvas>`;
// Initialize chart if not exists
if (!charts[canvasId]) {
const ctx = document.getElementById(canvasId).getContext('2d');
// Special configuration for watts chart
const isWattsChart = tileDef.metric === 'DERIVED_WATTS';
const chartOptions = {
animation: false,
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: false, // Don't force zero for auto-scaling
title: {
display: isWattsChart,
text: 'Watts'
}
},
x: {
display: true,
title: {
display: isWattsChart,
text: 'Time (6h history)'
}
}
},
plugins: {
legend: {
display: false // Hide legend for cleaner look
}
}
};
// Override Y-axis for non-watts charts
if (!isWattsChart) {
chartOptions.scales.y.beginAtZero = true;
}
charts[canvasId] = new Chart(ctx, {
type: type === 'bar' ? 'bar' : 'line',
data: {
labels: [],
datasets: [{
label: tileDef.short || tileDef.label,
data: [],
borderColor: isWattsChart ? '#ff6b35' : '#4a9eff',
backgroundColor: isWattsChart ? 'rgba(255,107,53,0.15)' : 'rgba(74,158,255,0.25)',
tension: 0.3,
fill: isWattsChart,
pointRadius: 0, // Hide points for cleaner line
pointHoverRadius: 4
}]
},
options: chartOptions
});
// Load historical data for charts
if (tileDef.metric) {
debugLog(`Loading historical data for ${tileDef.metric} chart`);
loadTileHistoricalData(name, tileDef.metric, canvasId);
}
} else {
// change type if needed
if (charts[canvasId].config.type !== (type === 'bar' ? 'bar':'line')) {
charts[canvasId].config.type = type === 'bar' ? 'bar' : 'line';
charts[canvasId].update();
}
}
}
}
}
function buildCustomTileVisualization(name, tileEl, tileCfg) {
const body = tileEl.querySelector('.tile-body');
body.innerHTML = '';
if (tileCfg.chart === 'gauge') {
body.innerHTML = `<div class="gauge" data-custom-gauge><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 {
const canvasId = `tile-${name}-custom-${tileCfg.id}`;
body.innerHTML = `<canvas id="${canvasId}" height="90"></canvas>`;
if (!charts[canvasId]) {
const ctx = document.getElementById(canvasId).getContext('2d');
charts[canvasId] = new Chart(ctx, {
type: tileCfg.chart === 'bar' ? 'bar' : 'line',
data: { labels: [], datasets: [{ label: String(tileCfg.metric || ''), data: [], borderColor: '#4cc9c0', backgroundColor: 'rgba(76,201,192,0.20)', tension: 0.25 }]},
options: { animation:false, responsive:true, maintainAspectRatio:false, scales:{ y:{ beginAtZero:true } } }
});
}
}
}
function createCustomTile(name, grid, tileCfg) {
const tile = document.createElement('div');
tile.className = 'tile';
tile.dataset.tile = `custom-${tileCfg.id}`;
tile.setAttribute('draggable','true');
const headerLabel = METRIC_LABELS[tileCfg.metric] ? METRIC_LABELS[tileCfg.metric] : tileCfg.metric;
tile.innerHTML = `<div class="tile-controls"><button data-remove-tile title="Remove">✕</button></div><h4>${escapeHtml(headerLabel)}</h4><div class="tile-body"></div>`;
grid.appendChild(tile);
buildCustomTileVisualization(name, tile, tileCfg);
const removeBtn = tile.querySelector('[data-remove-tile]');
removeBtn.addEventListener('click', () => removeCustomTile(name, tileCfg.id, tile));
seedHistoricalData(name, tileCfg);
scheduleServerTileSave(name);
return tile;
}
function rebuildAllTiles(name) {
const card = getCard(name);
if (!card) return;
const grid = card.querySelector('[data-tile-grid]');
if (!grid) return;
// Find all existing tiles and rebuild their visualizations
grid.querySelectorAll('.tile').forEach(tile => {
const tileId = tile.dataset.tile;
if (tileId && !tileId.startsWith('custom-')) {
const tileDef = TILE_REGISTRY.find(t => t.id === tileId);
if (tileDef) {
buildTileVisualization(name, tile, tileDef);
}
}
});
}
function removeCustomTile(name, id, tileEl) {
savedCustomTiles[name] = (savedCustomTiles[name]||[]).filter(t => t.id !== id);
try { localStorage.setItem(CUSTOM_TILES_KEY, JSON.stringify(savedCustomTiles)); } catch(_) {}
if (tileEl) tileEl.remove();
persistTileOrder(name, tileEl.parentElement);
scheduleServerTileSave(name);
}
function seedHistoricalData(name, tileCfg) {
if (tileCfg.source !== 'history') return;
fetch(apiUpsPath(name, `/metric/${encodeURIComponent(tileCfg.metric)}?limit=120`)).then(r=>r.json()).then(points => {
const canvasId = `tile-${name}-custom-${tileCfg.id}`;
if (tileCfg.chart === 'gauge') return; // gauge only shows live
const c = charts[canvasId];
if (!c) return;
c.data.labels = points.map(p => new Date(p.ts*1000).toLocaleTimeString());
c.data.datasets[0].data = points.map(p => p.value);
c.update();
}).catch(()=>{});
}
function loadTileHistoricalData(name, metric, canvasId) {
// For watts, load 6 hours of data (assuming 5-second intervals = 4320 points)
// For other metrics, use smaller dataset
const limit = metric === 'DERIVED_WATTS' ? 4320 : 60;
fetch(apiUpsPath(name, `/metric/${encodeURIComponent(metric)}?limit=${limit}`)).then(r=>r.json()).then(points => {
const c = charts[canvasId];
if (!c) return;
if (metric === 'DERIVED_WATTS') {
// For watts chart, use smart time formatting and auto-scaling
configureWattsChart(c, points);
} else {
// Standard configuration for other charts
c.data.labels = points.map(p => new Date(p.ts*1000).toLocaleTimeString());
c.data.datasets[0].data = points.map(p => p.value);
c.update();
}
}).catch(err => {
debugWarn(`Failed to load historical data for ${metric}:`, err);
});
}
function configureWattsChart(chart, points) {
// Smart time label formatting - show every 5th minute
const labels = points.map(p => {
const date = new Date(p.ts * 1000);
const minutes = date.getMinutes();
// Show label only for 5-minute intervals (00, 05, 10, 15, 20, etc.)
if (minutes % 5 === 0) {
return date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: false
});
}
return ''; // Empty label for non-5-minute marks
});
const values = points.map(p => p.value);
// Smart scaling for better visibility
const minValue = Math.min(...values);
const maxValue = Math.max(...values);
const dataRange = maxValue - minValue;
let yMin, yMax;
if (dataRange <= 50) {
// For narrow ranges (like 100-200W), use tight ±10W padding for clear visibility
yMin = Math.max(0, minValue - 10);
yMax = maxValue + 10;
} else if (dataRange <= 100) {
// For medium ranges, use ±15W padding
yMin = Math.max(0, minValue - 15);
yMax = maxValue + 15;
} else {
// For large ranges, use 5% padding
const padding = dataRange * 0.05;
yMin = Math.max(0, minValue - padding);
yMax = maxValue + padding;
}
// Update chart data and scaling
chart.data.labels = labels;
chart.data.datasets[0].data = values;
// Update Y-axis scaling with smart range
chart.options.scales.y.min = Math.round(yMin);
chart.options.scales.y.max = Math.round(yMax);
// Configure X-axis to show fewer labels
chart.options.scales.x = {
display: true,
ticks: {
maxTicksLimit: 12, // Show maximum 12 time labels
callback: function(value, index) {
const label = this.getLabelForValue(value);
return label || undefined; // Only show non-empty labels
}
}
};
chart.update();
}
function openMetricModal(name) {
const backdrop = document.createElement('div');
backdrop.className = 'metric-modal-backdrop';
backdrop.innerHTML = `<div class="metric-modal"><h3>Add Metric</h3>
<label>Metric</label>
<select data-field="metric">
${Object.entries(METRIC_LABELS).map(([k,v]) => `<option value="${k}">${v} (${k})</option>`).join('')}
</select>
<label>Chart Type</label>
<select data-field="chart">
<option value="line">Line</option>
<option value="bar">Bar</option>
<option value="gauge">Gauge</option>
</select>
<label>Data Source</label>
<select data-field="source">
<option value="live">Live</option>
<option value="history">History + Live</option>
</select>
<div class="metric-modal-actions">
<button type="button" data-cancel>Cancel</button>
<button type="button" class="primary" data-add>Add</button>
</div>
</div>`;
document.body.appendChild(backdrop);
const cleanup = () => backdrop.remove();
backdrop.addEventListener('click', e => { if (e.target === backdrop) cleanup(); });
backdrop.querySelector('[data-cancel]').addEventListener('click', cleanup);
backdrop.querySelector('[data-add]').addEventListener('click', () => {
const metric = backdrop.querySelector('[data-field="metric"]').value;
const chart = backdrop.querySelector('[data-field="chart"]').value;
const source = backdrop.querySelector('[data-field="source"]').value;
const id = Date.now().toString(36) + Math.random().toString(36).slice(2,6);
const cfg = { id, metric, chart, source };
savedCustomTiles[name] = savedCustomTiles[name] || [];
savedCustomTiles[name].push(cfg);
try { localStorage.setItem(CUSTOM_TILES_KEY, JSON.stringify(savedCustomTiles)); } catch(_) {}
const card = getCard(name);
const grid = card.querySelector('[data-tile-grid]');
createCustomTile(name, grid, cfg);
persistTileOrder(name, grid);
cleanup();
});
}
function pushChartPoint(chartId, label, value, maxPoints=60) {
const c = charts[chartId];
if (!c) return;
// Special handling for watts chart
if (chartId.includes('watts_usage')) {
const now = new Date();
const minutes = now.getMinutes();
// Only show time label for 5-minute intervals
const timeLabel = (minutes % 5 === 0) ?
now.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: false }) : '';
c.data.labels.push(timeLabel);
c.data.datasets[0].data.push(value);
// Keep 6 hours of data (assuming 5-second intervals)
const maxWattsPoints = 4320; // 6 hours * 60 minutes * 12 (5-second intervals)
if (c.data.labels.length > maxWattsPoints) {
c.data.labels.shift();
c.data.datasets[0].data.shift();
}
// Smart auto-scale Y-axis for better visibility
const values = c.data.datasets[0].data.filter(v => v != null);
if (values.length > 0) {
const minValue = Math.min(...values);
const maxValue = Math.max(...values);
const dataRange = maxValue - minValue;
let yMin, yMax;
if (dataRange <= 50) {
// For narrow ranges (like 100-200W), use tight ±10W padding for clear visibility
yMin = Math.max(0, minValue - 10);
yMax = maxValue + 10;
} else if (dataRange <= 100) {
// For medium ranges, use ±15W padding
yMin = Math.max(0, minValue - 15);
yMax = maxValue + 15;
} else {
// For large ranges, use 5% padding
const padding = dataRange * 0.05;
yMin = Math.max(0, minValue - padding);
yMax = maxValue + padding;
}
c.options.scales.y.min = Math.round(yMin);
c.options.scales.y.max = Math.round(yMax);
}
} else {
// Standard handling for other charts
c.data.labels.push(label);
c.data.datasets[0].data.push(value);
if (c.data.labels.length > maxPoints) {
c.data.labels.shift();
c.data.datasets[0].data.shift();
}
}
c.update();
}
function updateValueTile(card, metric, value) {
const valueElement = card.querySelector(`[data-value-metric="${metric}"]`);
if (valueElement) {
valueElement.textContent = value;
// Color coding for voltage values based on US electrical specs
if (metric === 'LINEV' || metric === 'OUTPUTV') {
const numValue = parseFloat(value);
// US standard: 120V ±5% (114V-126V is acceptable range)
if (numValue >= 114 && numValue <= 126) {
valueElement.className = 'big-value voltage-good';
} else {
valueElement.className = 'big-value voltage-bad';
}
} else {
// Reset class for non-voltage metrics
valueElement.className = 'big-value';
}
}
}
function toggleMode(card) {
const name = card.id.replace('card-','');
if (card.classList.toggle('compact')) {
savedModes[name] = 'compact';
} else {
delete savedModes[name];
}
try { localStorage.setItem(CARD_MODE_KEY, JSON.stringify(savedModes)); } catch(_) {}
resizeChartsInCard(card);
}
function trimChart(chart) {
if (chart.data.labels.length > 60) {
chart.data.labels.shift();
chart.data.datasets[0].data.shift();
}
}
function updateStaleStatuses() {
const now = Date.now() / 1000;
Object.entries(lastUpdateTs).forEach(([name, ts]) => {
if (now - ts > UPS_STALE_SECONDS) {
const card = getCard(name);
if (card) {
const statusElement = card.querySelector('.ups-status');
if (statusElement) {
statusElement.textContent = 'STALE';
statusElement.className = 'ups-status unknown';
}
const stateDot = card.querySelector('[data-state-dot]');
if (stateDot) {
stateDot.className = 'state-dot';
}
}
}
});
}
setInterval(updateStaleStatuses, 10000);
evtSource.onmessage = (e) => {
let payload;
try {
payload = JSON.parse(e.data);
debugLog('SSE data received:', Object.keys(payload.snapshots || {}));
} catch (err) {
debugError('Bad SSE payload', err);
return;
}
const snapshots = payload.snapshots || {};
const upsMeta = payload.upsMeta || [];
setDashboardSyncState(`Live telemetry ${new Date().toLocaleTimeString()}`, true);
// Reconcile cards: add new, remove stale
const desiredNames = new Set(upsMeta.map(m => m.name));
const currentCards = Array.from(document.querySelectorAll('#ups-cards .card'));
currentCards.forEach(card => {
const name = card.id.replace('card-','');
if (!desiredNames.has(name)) {
// remove charts references
delete charts[`chart-${name}`];
delete charts[`chart-batt-${name}`];
card.remove();
delete lastUpdateTs[name];
}
});
upsMeta.forEach(meta => {
const card = ensureUpsCard(meta.name);
// update connection host:port display
const conn = card.querySelector('.ups-connection');
if (conn) conn.textContent = `${meta.host}:${meta.port}`;
// Update event footer asynchronously (lightweight)
updateEventFooter(meta.name);
});
Object.entries(snapshots).forEach(([name, snap]) => {
const card = ensureUpsCard(name);
lastUpdateTs[name] = Date.now() / 1000;
// Update connection info with model and serial if available
const conn = card.querySelector('.ups-connection');
if (conn) {
const meta = upsMeta.find(m => m.name === name);
let connText = meta ? `${meta.host}:${meta.port}` : '';
// Add model and serial info
const model = snap['MODEL'] || '';
const serial = snap['SERIALNO'] || '';
if (model || serial) {
const modelPart = model ? ` • ${model}` : '';
const serialPart = serial ? ` • S/N: ${serial}` : '';
connText += modelPart + serialPart;
}
conn.textContent = connText;
}
card.querySelectorAll('.val').forEach(span => {
const field = span.getAttribute('data-field');
span.textContent = snap[field] || '-';
});
const statusElement = card.querySelector('.ups-status');
const status = snap['STATUS'] || 'UNKNOWN';
if (statusElement) {
statusElement.textContent = status;
statusElement.className = 'ups-status ' + getStatusClass(status);
}
const stateDot = card.querySelector('[data-state-dot]');
if (stateDot) {
stateDot.className = 'state-dot ' + getStatusClass(status);
}
const load = parseFloat(snap['LOADPCT']) || 0;
const batt = parseFloat(snap['BCHARGE']) || 0;
const linev = parseFloat(snap['LINEV']) || 0;
// Use OUTPUTV if available, fallback to LINEV for UPS models that don't report output voltage
const outv = parseFloat(snap['OUTPUTV']) || parseFloat(snap['LINEV']) || 0;
const watts = parseFloat(snap['DERIVED_WATTS']) || 0;
const ts = new Date();
const tsLabel = ts.toLocaleTimeString();
// Update tile charts if present (only for gauge/chart types)
pushChartPoint(`tile-${name}-load_pct`, tsLabel, load);
pushChartPoint(`tile-${name}-battery_pct`, tsLabel, batt);
pushChartPoint(`tile-${name}-watts_usage`, tsLabel, watts);
// Update value displays
updateValueTile(card, 'LINEV', linev.toFixed(1));
updateValueTile(card, 'OUTPUTV', outv.toFixed(1));
// Update output voltage tile label if using fallback
if (!snap['OUTPUTV'] && snap['LINEV']) {
const outputTile = card.querySelector('.tile[data-tile="volt_output"]');
if (outputTile) {
const labelElement = outputTile.querySelector('.tile-label');
if (labelElement && labelElement.textContent === 'Output Voltage') {
labelElement.textContent = 'Output V (Line)';
}
}
}
updateValueTile(card, 'LOADPCT', load.toFixed(1));
updateValueTile(card, 'BCHARGE', batt.toFixed(1));
updateValueTile(card, 'DERIVED_WATTS', watts.toFixed(0));
// Update custom tiles (live)
(savedCustomTiles[name]||[]).forEach(ct => {
const metricValRaw = snap[ct.metric];
if (metricValRaw == null) return;
let metricVal;
try { metricVal = parseFloat(String(metricValRaw).split(/\s+/)[0]); } catch(_) { return; }
if (ct.chart === 'gauge') {
const gauge = card?.querySelector(`[data-tile-grid] .tile[data-tile="custom-${ct.id}"] [data-custom-gauge]`);
if (gauge) {
const fill = gauge.querySelector('.gauge-fill');
const text = gauge.querySelector('.gauge-text');
const pct = Math.max(0, Math.min(100, metricVal));
const total = 157;
const filled = (pct/100)*total;
if (fill) fill.setAttribute('stroke-dasharray', `${filled} ${total-filled}`);
if (text) text.textContent = pct.toFixed(0);
gauge.classList.remove('good','low','critical');
if (pct < 15) gauge.classList.add('critical'); else if (pct < 35) gauge.classList.add('low'); else gauge.classList.add('good');
}
} else {
const chartId = `tile-${name}-custom-${ct.id}`;
pushChartPoint(chartId, new Date().toLocaleTimeString(), metricVal, 120);
}
});
// Gauge updates (battery/load tiles)
card.querySelectorAll('[data-gauge-metric]').forEach(gauge => {
const metric = gauge.getAttribute('data-gauge-metric');
let rawVal = snap[metric];
if (rawVal == null) return;
let valNum;
try { valNum = parseFloat(String(rawVal).split(/\s+/)[0]); } catch(_) { return; }
if (isNaN(valNum)) return;
const pct = Math.max(0, Math.min(100, valNum));
const total = 157;
const filled = (pct / 100) * total;
const fill = gauge.querySelector('.gauge-fill');
const text = gauge.querySelector('.gauge-text');
if (fill) fill.setAttribute('stroke-dasharray', `${filled} ${total - filled}`);
if (text) text.textContent = pct.toFixed(0) + '%';
gauge.classList.remove('good','low','critical');
if (pct < 15) gauge.classList.add('critical');
else if (pct < 35) gauge.classList.add('low');
else gauge.classList.add('good');
});
// Highline badges text update
const runtimeBadge = card.querySelector('[data-badge="runtime"]');
if (runtimeBadge) runtimeBadge.textContent = (snap['RUNTIME_MINUTES'] ? snap['RUNTIME_MINUTES'] + ' m' : '- m');
const wattsBadge = card.querySelector('[data-badge="watts"]');
if (wattsBadge) wattsBadge.textContent = (snap['DERIVED_WATTS'] ? snap['DERIVED_WATTS'] + ' W' : '- W');
const headroomBadge = card.querySelector('[data-badge="headroom"]');
if (headroomBadge) headroomBadge.textContent = (snap['HEADROOM_PCT'] ? snap['HEADROOM_PCT'] + '% free' : '- headroom');
// Dynamic badge colors if enabled
if (uiCfg.color_badges) {
const rt = parseFloat(snap['RUNTIME_MINUTES']) || null;
const loadPct = parseFloat(snap['LOADPCT']) || null;
const headroom = parseFloat(snap['HEADROOM_PCT']) || null;
if (runtimeBadge && rt !== null) {
runtimeBadge.style.background = rt < 10 ? '#742a2a' : rt < 20 ? '#613515' : '#2c3e50';
}
if (headroomBadge && headroom !== null) {
headroomBadge.style.background = headroom < 10 ? '#6b2d2d' : headroom < 20 ? '#5a3d16' : '#3b3f2a';
}
if (wattsBadge && loadPct !== null) {
wattsBadge.style.background = loadPct > 90 ? '#6b2d2d' : loadPct > 75 ? '#6b4e19' : '#1f4f61';
}
}
});
// Fetch events & energy asynchronously (tiles)
upsMeta.forEach(meta => {
if (uiCfg.show_events === false) return;
fetch(apiUpsPath(meta.name, '/events')).then(r => r.json()).then(events => {
const card = getCard(meta.name);
if (!card) return;
const list = card.querySelector('[data-events]');
if (!list) return;
list.innerHTML = '';
events.slice(0,10).forEach(ev => {
const li = document.createElement('li');
if (ev.ts) {
const dt = new Date(ev.ts * 1000).toLocaleTimeString();
li.textContent = `[${dt}] ${ev.type}: ${ev.detail}`;
} else if (ev.raw) {
li.textContent = ev.raw;
} else {
li.textContent = JSON.stringify(ev);
}
list.appendChild(li);
});
}).catch(()=>{});
if (uiCfg.show_energy) {
fetch(apiUpsPath(meta.name, '/energy')).then(r => r.json()).then(data => {
if (!data || data.kwh_today == null) return;
const card = getCard(meta.name);
if (!card) return;
const energyVal = card.querySelector('[data-energy-val]');
if (energyVal) energyVal.textContent = data.kwh_today.toFixed(2) + ' kWh';
}).catch(()=>{});
}
});
};
async function updateEventFooter(name) {
try {
const resp = await fetch(apiUpsPath(name, '/events'));
if (!resp.ok) return;
const events = await resp.json();
if (!Array.isArray(events) || events.length === 0) return;
const latest = events[0];
const card = getCard(name);
if (!card) return;
const footer = card.querySelector('[data-ups-footer]');
if (!footer) return;
let text;
if (latest.ts) {
const dt = new Date(latest.ts * 1000).toLocaleTimeString();
text = `[${dt}] ${latest.type || ''} ${latest.detail || latest.msg || latest.raw || ''}`.trim();
} else {
text = latest.detail || latest.msg || latest.raw || '';
}
footer.textContent = '';
const marquee = document.createElement('span');
marquee.className = 'event-marquee';
marquee.textContent = text;
footer.appendChild(marquee);
} catch {}
}
function addTileLayoutControls(name, grid) {
grid.style.position = 'relative';
let activeTile = null; let dragOffsetX = 0, dragOffsetY = 0;
function pointerDown(e) {
const tile = e.target.closest('.tile');
if (!tile) return;
if (e.target.classList.contains('tile-resize')) return; // handled elsewhere
const additive = e.metaKey || e.ctrlKey || e.shiftKey;
toggleTileSelection(tile, additive);
activeTile = tile;
const rect = tile.getBoundingClientRect();
dragOffsetX = e.clientX - rect.left;
dragOffsetY = e.clientY - rect.top;
tile.classList.add('dragging');
window.addEventListener('pointermove', pointerMove);
window.addEventListener('pointerup', pointerUp, { once: true });
}
function pointerMove(e) {
if (!activeTile) return;
const gridRect = grid.getBoundingClientRect();
let targetX = e.clientX - gridRect.left - dragOffsetX;
let targetY = e.clientY - gridRect.top - dragOffsetY;
if (targetX < 0) targetX = 0; if (targetY < 0) targetY = 0;
// Group move if multiple selected
if (selectedTiles.size > 1) {
const baseX = parseInt(activeTile.style.left||'0',10) || 0;
const baseY = parseInt(activeTile.style.top||'0',10) || 0;
const dx = snap(targetX) - baseX;
const dy = snap(targetY) - baseY;
selectedTiles.forEach(t => {
const cx = parseInt(t.style.left||'0',10) || 0;
const cy = parseInt(t.style.top||'0',10) || 0;
t.style.left = snap(cx + dx) + 'px';
t.style.top = snap(cy + dy) + 'px';
});
} else {
activeTile.style.left = snap(targetX) + 'px';
activeTile.style.top = snap(targetY) + 'px';
}
}
function pointerUp() {
if (activeTile) activeTile.classList.remove('dragging');
persistTilePositions(name, grid);
activeTile = null;
window.removeEventListener('pointermove', pointerMove);
window.removeEventListener('pointerup', pointerUp);
window.removeEventListener('pointercancel', pointerUp);
window.removeEventListener('blur', pointerUp);
}
grid.addEventListener('pointerdown', pointerDown);
window.addEventListener('pointercancel', pointerUp);
window.addEventListener('blur', pointerUp);
// Resize handles
grid.addEventListener('pointerdown', e => {
if (!e.target.classList.contains('tile-resize')) return;
const tile = e.target.closest('.tile'); if (!tile) return;
e.preventDefault();
let startX = e.clientX, startY = e.clientY;
const startW = tile.offsetWidth, startH = tile.offsetHeight;
function onMove(ev) {
let nw = startW + (ev.clientX - startX); let nh = startH + (ev.clientY - startY);
nw = Math.max(140, nw); nh = Math.max(110, nh);
tile.style.width = snap(nw) + 'px'; tile.style.height = snap(nh) + 'px';
}
function onUp() { persistTilePositions(name, grid); window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onUp); }
window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', onUp, { once:true });
});
// Context menu hide
grid.addEventListener('contextmenu', e => {
const tile = e.target.closest('.tile'); if (!tile) return;
e.preventDefault();
const id = tile.dataset.tile; savedTileHidden[name] = savedTileHidden[name] || {};
const hidden = tile.classList.toggle('hidden');
savedTileHidden[name][id] = hidden;
try { localStorage.setItem(TILE_HIDDEN_KEY, JSON.stringify(savedTileHidden)); } catch{}
scheduleServerTileSave(name);
});
}
function persistTileOrder(name, grid) {
const order = Array.from(grid.querySelectorAll('.tile')).map(t => t.dataset.tile).filter(Boolean);
savedTileOrder[name] = order;
try { localStorage.setItem(TILE_ORDER_KEY, JSON.stringify(savedTileOrder)); } catch(_) {}
scheduleServerTileSave(name);
}
function persistTilePositions(name, grid) {
const positions = {};
let maxX = 0, maxY = 0;
grid.querySelectorAll('.tile').forEach(tile => {
const id = tile.dataset.tile;
if (id) {
const left = parseInt(tile.style.left || '0', 10);
const top = parseInt(tile.style.top || '0', 10);
const width = parseInt(tile.style.width || '0', 10) || tile.offsetWidth;
const height = parseInt(tile.style.height || '0', 10) || tile.offsetHeight;
positions[id] = { left, top, width, height };
// Track required card size (to grow card if tiles overflow)
maxX = Math.max(maxX, left + width);
maxY = Math.max(maxY, top + height);
}
});
savedTilePos[name] = positions;
try { localStorage.setItem(TILE_POS_KEY, JSON.stringify(savedTilePos)); } catch(_) {}
// Only grow the card if tiles overflow its current bounds. Never shrink
// the user's manually-sized card.
if (maxX > 0 && maxY > 0) {
const card = getCard(name);
if (card) {
const neededW = maxX + 20; // 20px right padding
const neededH = maxY + 70; // 70px for header + bottom padding
const currentW = card.offsetWidth;
const currentH = card.offsetHeight;
const newW = Math.max(currentW, neededW);
const newH = Math.max(currentH, neededH);
if (newW > currentW || newH > currentH) {
card.style.width = `${newW}px`;
card.style.height = `${newH}px`;
grid.style.width = `${newW - 20}px`;
grid.style.height = `${newH - 70}px`;
persistCardSize(name, newW, newH);
}
}
}
scheduleServerTileSave(name);
}
function applyStoredPositions(name, grid) {
const positions = savedTilePos[name] || {};
let hasStoredPositions = Object.keys(positions).length > 0;
// First, try to restore the card size
const cardRestored = restoreCardSize(name);
if (hasStoredPositions) {
// Apply stored tile positions
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';
if (pos.width) tile.style.width = pos.width + 'px';
if (pos.height) tile.style.height = pos.height + 'px';
}
});
} else {
// Apply auto-arrangement if no positions stored
autoArrangeTiles(name, grid);
// Persist these positions for future use
persistTilePositions(name, grid);
}
}
function getDragAfterElement(container, x, y) {
const tiles = [...container.querySelectorAll('.tile:not(.dragging)')];
return tiles.reduce((closest, child) => {
const rect = child.getBoundingClientRect();
const offset = y - rect.top - rect.height / 2;
if (offset < 0 && offset > closest.offset) {
return { offset, element: child };
} else {
return closest;
}
}, { offset: Number.NEGATIVE_INFINITY }).element;
}
function getStatusClass(status) {
const upperStatus = status.toUpperCase();
if (upperStatus.includes('ONLINE')) return 'online';
if (upperStatus.includes('ONBATT')) return 'onbattery';
if (upperStatus.includes('BATTERY')) return 'onbattery';
return 'unknown';
}
// Resize logic
function attachResizeBehavior(card, handle) {
let startX, startY, startW, startH, resizing = false;
const GRID = 40; // snap grid size
const MIN_W = 320;
const MIN_H = 260;
const MAX_W = 1000;
const MAX_H = 900;
function onPointerDown(e) {
e.preventDefault();
startX = e.clientX; startY = e.clientY;
const rect = card.getBoundingClientRect();
startW = rect.width; startH = rect.height;
resizing = true;
card.classList.add('resizing');
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp, { once: true });
}
function onPointerMove(e) {
if (!resizing) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
let newW = Math.max(MIN_W, Math.min(MAX_W, startW + dx));
let newH = Math.max(MIN_H, Math.min(MAX_H, startH + dy));
// Snap to grid
newW = Math.round(newW / GRID) * GRID;
newH = Math.round(newH / GRID) * GRID;
card.style.width = newW + 'px';
card.style.height = newH + 'px';
resizeChartsInCard(card);
}
function onPointerUp() {
resizing = false;
card.classList.remove('resizing');
// Persist size
const rect = card.getBoundingClientRect();
const name = card.id.replace('card-','');
const width = Math.round(rect.width);
const height = Math.round(rect.height);
persistCardSize(name, width, height);
// Also update grid size
const grid = card.querySelector('[data-tile-grid]');
if (grid) {
grid.style.width = `${width - 20}px`;
grid.style.height = `${height - 70}px`;
}
window.removeEventListener('pointermove', onPointerMove);
}
handle.addEventListener('pointerdown', onPointerDown);
}
function resizeChartsInCard(card) {
// Adjust canvas width to card inner width with padding consideration
const canvases = card.querySelectorAll('canvas.chart');
canvases.forEach(cv => {
const parentW = card.clientWidth - 40; // approximate padding + handle space
if (parentW > 0) {
cv.style.width = parentW + 'px';
}
});
// Trigger Chart.js resize
Object.values(charts).forEach(ch => {
try { ch.resize(); } catch(_) {}
});
}
// Watch cards for size changes (if user uses native resize or CSS changes)
const ro = new ResizeObserver(entries => {
entries.forEach(entry => {
if (entry.target.classList.contains('card')) {
resizeChartsInCard(entry.target);
// Footer is positioned via CSS automatically, but ensure it's visible
const footer = entry.target.querySelector('.ups-footer');
if (footer) {
footer.style.display = 'block';
}
}
});
});
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.card').forEach(c => ro.observe(c));
// Attach reset button listeners to existing cards
document.querySelectorAll('[data-reset-layout]').forEach(resetBtn => {
if (!resetBtn.dataset.bound) {
const card = resetBtn.closest('.card');
if (card) {
const name = card.id.replace('card-','');
resetBtn.addEventListener('click', () => resetLayout(name));
resetBtn.dataset.bound = '1';
}
}
});
// For each existing card, load server tile config (if any) and apply
const existing = Array.from(document.querySelectorAll('#ups-cards .card'));
existing.forEach(async card => {
const name = card.id.replace('card-','');
// Force correct tile types for cleaner interface (override any cached values)
// Destroy any existing charts for this UPS first
Object.keys(charts).forEach(chartId => {
if (chartId.includes(`tile-${name}-`)) {
try {
charts[chartId].destroy();
} catch(e) {}
delete charts[chartId];
}
});
savedTileTypes[name] = {
load_pct: 'gauge',
volt_line: 'value',
volt_output: 'value',
watts_usage: 'value'
};
try { localStorage.setItem(TILE_TYPE_KEY, JSON.stringify(savedTileTypes)); } catch{}
const cfg = await loadServerTileConfig(name);
if (cfg) {
savedTileTypes[name] = cfg.types || savedTileTypes[name] || {};
savedTileOrder[name] = cfg.order || savedTileOrder[name] || [];
const hiddenSet = {}; (cfg.hidden||[]).forEach(h => hiddenSet[h]=true);
savedTileHidden[name] = hiddenSet;
savedCustomTiles[name] = cfg.custom || [];
savedTilePos[name] = cfg.positions || savedTilePos[name] || {};
if (cfg.card_size && cfg.card_size.width && cfg.card_size.height) {
savedCardSizes[name] = { width: cfg.card_size.width, height: cfg.card_size.height };
saveToStorage(CARD_SIZE_KEY, savedCardSizes);
}
try { localStorage.setItem(TILE_TYPE_KEY, JSON.stringify(savedTileTypes)); } catch{}
try { localStorage.setItem(TILE_ORDER_KEY, JSON.stringify(savedTileOrder)); } catch{}
try { localStorage.setItem(TILE_HIDDEN_KEY, JSON.stringify(savedTileHidden)); } catch{}
try { localStorage.setItem(CUSTOM_TILES_KEY, JSON.stringify(savedCustomTiles)); } catch{}
try { localStorage.setItem(TILE_POS_KEY, JSON.stringify(savedTilePos)); } catch{}
// Rebuild tiles using loaded config
const grid = card.querySelector('[data-tile-grid]');
grid.innerHTML = '';
initTilesFor(name, grid);
// Force rebuild tiles with correct types
rebuildAllTiles(name);
// If no stored positions existed, perform an auto layout
if (!cfg.positions || Object.keys(cfg.positions).length === 0) {
resetLayout(name);
}
} else {
// No server config: apply default static layout
savedTileTypes[name] = {
load_pct: 'gauge',
volt_line: 'value',
volt_output: 'value',
watts_usage: 'value'
};
try { localStorage.setItem(TILE_TYPE_KEY, JSON.stringify(savedTileTypes)); } catch{}
const grid = card.querySelector('[data-tile-grid]');
debugLog('Initializing default tiles for', name, 'grid found:', !!grid);
grid.innerHTML='';
initTilesFor(name, grid);
debugLog('Tiles created, count:', grid.children.length);
// Force rebuild tiles with correct types
rebuildAllTiles(name);
// Apply static layout directly (same as in resetLayout)
const loadTile = grid.querySelector('.tile[data-tile="load_pct"]');
const lineTile = grid.querySelector('.tile[data-tile="volt_line"]');
const outTile = grid.querySelector('.tile[data-tile="volt_output"]');
const wattsTile = grid.querySelector('.tile[data-tile="watts_usage"]');
if (loadTile) { loadTile.style.left='10px'; loadTile.style.top='10px'; loadTile.style.width='200px'; loadTile.style.height='160px'; }
if (lineTile) { lineTile.style.left='230px'; lineTile.style.top='10px'; lineTile.style.width='180px'; lineTile.style.height='70px'; }
if (outTile) { outTile.style.left='230px'; outTile.style.top='100px'; outTile.style.width='180px'; outTile.style.height='70px'; }
if (wattsTile) { wattsTile.style.left='10px'; wattsTile.style.top='190px'; wattsTile.style.width='400px'; wattsTile.style.height='200px'; }
setTimeout(()=>{ Object.values(charts).forEach(ch=>{ try{ ch.resize(); }catch{} }); }, 50);
persistTilePositions(name, grid);
scheduleServerTileSave(name);
}
});
});