diff --git a/app/routes/api_ups.py b/app/routes/api_ups.py
index 8f96acc..de38c32 100644
--- a/app/routes/api_ups.py
+++ b/app/routes/api_ups.py
@@ -299,7 +299,7 @@ async def get_ups_ui_tiles(ups_name: str, user=Depends(require_session)):
raw = r.get(f"ups:ui:tiles:{ups_name}")
default = {
"types": {}, "order": [], "hidden": [], "custom": [],
- "positions": {}, "card_size": None,
+ "positions": {}, "card_size": None, "exists": False,
}
if not raw:
return default
@@ -312,6 +312,7 @@ async def get_ups_ui_tiles(ups_name: str, user=Depends(require_session)):
"custom": data.get("custom", []),
"positions": data.get("positions", {}),
"card_size": data.get("card_size"),
+ "exists": True,
}
except Exception:
return default
diff --git a/app/static/js/dashboard.js b/app/static/js/dashboard.js
index 0db4df9..1cbf65c 100644
--- a/app/static/js/dashboard.js
+++ b/app/static/js/dashboard.js
@@ -234,10 +234,14 @@ async function loadServerTileConfig(name) {
scheduleServerTileSave(name);
}
-let pendingSave = null;
+const pendingSaves = {};
function scheduleServerTileSave(name) {
- if (pendingSave) clearTimeout(pendingSave);
- pendingSave = setTimeout(() => saveServerTileConfig(name), 800);
+ if (!name) return;
+ if (pendingSaves[name]) clearTimeout(pendingSaves[name]);
+ pendingSaves[name] = setTimeout(() => {
+ delete pendingSaves[name];
+ saveServerTileConfig(name);
+ }, 800);
}
function autoArrangeTiles(name, grid) {
@@ -314,14 +318,15 @@ function autoArrangeTiles(name, grid) {
grid.style.height = `${cardHeight - 70}px`;
// Save card size
- persistCardSize(name, cardWidth, cardHeight);
+ persistCardSize(name, cardWidth, cardHeight, false);
debugLog(`Auto-arranged ${tiles.length} tiles for ${name}, card size: ${cardWidth}x${cardHeight}`);
}
}
-function persistCardSize(name, width, height) {
+function persistCardSize(name, width, height, syncServer = true) {
savedCardSizes[name] = { width, height };
saveToStorage(CARD_SIZE_KEY, savedCardSizes);
+ if (syncServer) scheduleServerTileSave(name);
}
function restoreCardSize(name) {
@@ -340,6 +345,20 @@ function restoreCardSize(name) {
return false;
}
+function hasSavedPositions(name) {
+ return Object.keys(savedTilePos[name] || {}).length > 0;
+}
+
+function applyDefaultTileTypes(name) {
+ 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{}
+}
+
async function saveServerTileConfig(name) {
const types = savedTileTypes[name] || {};
const order = (savedTileOrder[name] || []);
@@ -407,7 +426,8 @@ function ensureUpsCard(name) {
div.id = `card-${name}`;
// Apply saved size if available
if (savedSizes[name]) {
- const { w, h } = savedSizes[name];
+ const w = savedSizes[name].width || savedSizes[name].w;
+ const h = savedSizes[name].height || savedSizes[name].h;
if (w) div.style.width = w + 'px';
if (h) div.style.height = h + 'px';
}
@@ -465,13 +485,7 @@ function ensureUpsCard(name) {
}
// 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{}
+ applyDefaultTileTypes(name);
}
// Initialize tiles
initTilesFor(name, div.querySelector('[data-tile-grid]'));
@@ -677,6 +691,9 @@ function createCustomTile(name, grid, tileCfg) {
tile.setAttribute('draggable','true');
const headerLabel = METRIC_LABELS[tileCfg.metric] ? METRIC_LABELS[tileCfg.metric] : tileCfg.metric;
tile.innerHTML = `
${escapeHtml(headerLabel)}
`;
+ const resizeHandle = document.createElement('div');
+ resizeHandle.className = 'tile-resize';
+ tile.appendChild(resizeHandle);
grid.appendChild(tile);
buildCustomTileVisualization(name, tile, tileCfg);
const removeBtn = tile.querySelector('[data-remove-tile]');
@@ -707,8 +724,9 @@ function rebuildAllTiles(name) {
function removeCustomTile(name, id, tileEl) {
savedCustomTiles[name] = (savedCustomTiles[name]||[]).filter(t => t.id !== id);
try { localStorage.setItem(CUSTOM_TILES_KEY, JSON.stringify(savedCustomTiles)); } catch(_) {}
+ const grid = tileEl?.parentElement;
if (tileEl) tileEl.remove();
- persistTileOrder(name, tileEl.parentElement);
+ if (grid) persistTileOrder(name, grid);
scheduleServerTileSave(name);
}
@@ -1508,22 +1526,16 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
- savedTileTypes[name] = {
- load_pct: 'gauge',
- volt_line: 'value',
- volt_output: 'value',
- watts_usage: 'value'
- };
- try { localStorage.setItem(TILE_TYPE_KEY, JSON.stringify(savedTileTypes)); } catch{}
+ applyDefaultTileTypes(name);
const cfg = await loadServerTileConfig(name);
- if (cfg) {
- savedTileTypes[name] = cfg.types || savedTileTypes[name] || {};
- savedTileOrder[name] = cfg.order || savedTileOrder[name] || [];
+ if (cfg && cfg.exists !== false) {
+ savedTileTypes[name] = Object.keys(cfg.types || {}).length ? cfg.types : savedTileTypes[name] || {};
+ savedTileOrder[name] = (cfg.order || []).length ? 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] || {};
+ savedCustomTiles[name] = (cfg.custom || []).length ? cfg.custom : savedCustomTiles[name] || [];
+ savedTilePos[name] = Object.keys(cfg.positions || {}).length ? 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);
@@ -1540,18 +1552,15 @@ document.addEventListener('DOMContentLoaded', () => {
// 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);
+ if (!hasSavedPositions(name)) {
+ autoArrangeTiles(name, grid);
+ persistTilePositions(name, grid);
+ } else if (!Object.keys(cfg.positions || {}).length) {
+ scheduleServerTileSave(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{}
+ // No server config: keep any local layout and backfill it to the server.
+ applyDefaultTileTypes(name);
const grid = card.querySelector('[data-tile-grid]');
debugLog('Initializing default tiles for', name, 'grid found:', !!grid);
grid.innerHTML='';
@@ -1559,18 +1568,13 @@ document.addEventListener('DOMContentLoaded', () => {
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'; }
+ if (!hasSavedPositions(name)) {
+ autoArrangeTiles(name, grid);
+ persistTilePositions(name, grid);
+ } else {
+ scheduleServerTileSave(name);
+ }
setTimeout(()=>{ Object.values(charts).forEach(ch=>{ try{ ch.resize(); }catch{} }); }, 50);
- persistTilePositions(name, grid);
- scheduleServerTileSave(name);
}
});
});
diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html
index e81fd28..3989a95 100644
--- a/app/templates/dashboard.html
+++ b/app/templates/dashboard.html
@@ -61,5 +61,5 @@
{% endblock %}
{% block scripts %}
-
+
{% endblock %}
diff --git a/tests/test_api_routes.py b/tests/test_api_routes.py
index bb4736b..eb13033 100644
--- a/tests/test_api_routes.py
+++ b/tests/test_api_routes.py
@@ -139,6 +139,40 @@ def test_ups_health_offline(authed_client, fake_redis):
assert body["fail_count"] == 3
+def test_ui_tile_layout_preserves_tile_and_card_sizes(authed_client):
+ payload = {
+ "types": {"watts_usage": "line"},
+ "order": ["load_pct", "watts_usage"],
+ "hidden": [],
+ "custom": [],
+ "positions": {
+ "load_pct": {"left": 20, "top": 40, "width": 260, "height": 180},
+ "watts_usage": {"left": 320, "top": 40, "width": 520, "height": 260},
+ },
+ "card_size": {"width": 900, "height": 460},
+ }
+
+ r = authed_client.post("/api/ups/u1/ui_tiles", json=payload)
+ assert r.status_code == 200
+
+ r = authed_client.get("/api/ups/u1/ui_tiles")
+ assert r.status_code == 200
+ body = r.json()
+ assert body["exists"] is True
+ assert body["positions"]["load_pct"]["width"] == 260
+ assert body["positions"]["load_pct"]["height"] == 180
+ assert body["positions"]["watts_usage"]["width"] == 520
+ assert body["positions"]["watts_usage"]["height"] == 260
+ assert body["card_size"] == {"width": 900, "height": 460}
+
+
+def test_ui_tile_layout_reports_missing_server_config(authed_client):
+ r = authed_client.get("/api/ups/u1/ui_tiles")
+
+ assert r.status_code == 200
+ assert r.json()["exists"] is False
+
+
def test_battery_health_empty(authed_client):
_add_ups(authed_client)
r = authed_client.get("/api/ups/u1/battery_health")