diff --git a/app/config.py b/app/config.py
index 76e3f7b..1a3dc3b 100644
--- a/app/config.py
+++ b/app/config.py
@@ -121,7 +121,12 @@ class UIConfig(BaseModel):
allow_resize: bool = True
enable_transfer_burst_alert: bool = False
enable_voltage_deviation_alert: bool = False
- energy_cost_per_kwh: float = 0.0
+ energy_cost_per_kwh: float = Field(0.0, ge=0)
+
+ @field_validator("energy_cost_per_kwh", mode="after")
+ @classmethod
+ def round_energy_cost_per_kwh(cls, value: float) -> float:
+ return round(float(value), 4)
class AppConfig(BaseModel):
diff --git a/app/routes/api_config.py b/app/routes/api_config.py
index d7a066a..19c0119 100644
--- a/app/routes/api_config.py
+++ b/app/routes/api_config.py
@@ -1,6 +1,7 @@
"""Configuration CRUD endpoints (UPS, SMTP, UI). All require session+CSRF for writes."""
from fastapi import APIRouter, Depends, HTTPException, Request
+from pydantic import ValidationError
from ..auth import require_session, require_session_and_csrf
from ..config import SMTPConfig, UIConfig, UPSConfig
@@ -142,6 +143,9 @@ async def update_ui_config(
for k, v in payload.items():
if k in ui_dict:
ui_dict[k] = v
- new_ui = UIConfig(**ui_dict)
+ try:
+ new_ui = UIConfig(**ui_dict)
+ except ValidationError as e:
+ raise HTTPException(status_code=422, detail=e.errors())
await config_manager.update_ui_config(new_ui)
return {"message": "UI config updated", "ui": new_ui.model_dump()}
diff --git a/app/static/css/style.css b/app/static/css/style.css
index 7659f1e..cd3a36a 100644
--- a/app/static/css/style.css
+++ b/app/static/css/style.css
@@ -138,6 +138,9 @@
.tile.hidden { display: none; }
.tile.dragging { opacity: 0.5; border: 1px dashed var(--accent); }
.tile.selected { outline: 2px solid var(--accent); }
+.tile .energy-summary { flex-direction: column; gap: 4px; text-align: center; }
+.tile .energy-summary .big-value { font-size: 18px; line-height: 1.2; }
+.tile .energy-cost { color: var(--success); font-size: 13px; font-weight: 700; }
.tile-controls { position: absolute; top: 4px; right: 4px; display: flex; gap: 4px; }
.tile-controls select {
background: var(--surface-3);
diff --git a/app/static/js/dashboard.js b/app/static/js/dashboard.js
index 1cbf65c..4714e6e 100644
--- a/app/static/js/dashboard.js
+++ b/app/static/js/dashboard.js
@@ -529,7 +529,7 @@ function initTilesFor(name, grid) {
energyTile.dataset.tile = 'energy';
energyTile.style.left = '12px';
energyTile.style.top = '12px';
- energyTile.innerHTML = `
Energy Today
-- kWh
`;
+ energyTile.innerHTML = `Energy Today
-- kWh--
`;
const resizeHandle = document.createElement('div'); resizeHandle.className='tile-resize'; energyTile.appendChild(resizeHandle);
grid.appendChild(energyTile);
}
@@ -1194,11 +1194,19 @@ evtSource.onmessage = (e) => {
}).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';
+ const energyCost = card.querySelector('[data-energy-cost]');
+ if (!data || data.kwh_today == null) {
+ if (energyVal) energyVal.textContent = '-- kWh';
+ if (energyCost) energyCost.textContent = '--';
+ return;
+ }
+ if (energyVal) energyVal.textContent = `${Number(data.kwh_today).toFixed(4)} kWh`;
+ if (energyCost) {
+ energyCost.textContent = data.cost_today == null ? '--' : `$${Number(data.cost_today).toFixed(4)}`;
+ }
}).catch(()=>{});
}
});
diff --git a/app/static/js/settings.js b/app/static/js/settings.js
index d794915..9faae24 100644
--- a/app/static/js/settings.js
+++ b/app/static/js/settings.js
@@ -1,5 +1,11 @@
// Settings page
(async function () {
+ function normalizeEnergyRate(value) {
+ const parsed = Number.parseFloat(value);
+ if (!Number.isFinite(parsed) || parsed < 0) return '0.0000';
+ return parsed.toFixed(4);
+ }
+
async function loadUi() {
const r = await window.apiFetch('/api/config/ui');
if (!r.ok) return;
@@ -9,6 +15,7 @@
const el = form.elements[k];
if (!el) return;
if (el.type === 'checkbox') el.checked = !!v;
+ else if (k === 'energy_cost_per_kwh') el.value = normalizeEnergyRate(v);
else el.value = v;
});
}
@@ -37,7 +44,7 @@
color_badges: f.color_badges.checked,
enable_transfer_burst_alert: f.enable_transfer_burst_alert.checked,
enable_voltage_deviation_alert: f.enable_voltage_deviation_alert.checked,
- energy_cost_per_kwh: parseFloat(f.energy_cost_per_kwh.value) || 0,
+ energy_cost_per_kwh: Number.parseFloat(normalizeEnergyRate(f.energy_cost_per_kwh.value)),
};
const r = await window.apiFetch('/api/config/ui', {
method: 'PUT', body: JSON.stringify(payload),
diff --git a/app/templates/base.html b/app/templates/base.html
index 65d94b7..3c3d2ea 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -6,7 +6,7 @@
{% block title %}UPS Dashboard{% endblock %}
-
+
{% block head_extra %}{% endblock %}
diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html
index 3989a95..ceedd00 100644
--- a/app/templates/dashboard.html
+++ b/app/templates/dashboard.html
@@ -61,5 +61,5 @@
{% endblock %}
{% block scripts %}
-
+
{% endblock %}
diff --git a/app/templates/settings.html b/app/templates/settings.html
index a197362..182a37b 100644
--- a/app/templates/settings.html
+++ b/app/templates/settings.html
@@ -47,7 +47,7 @@
-
+
@@ -116,5 +116,5 @@
{% endblock %}
{% block scripts %}
-
+
{% endblock %}
diff --git a/tests/test_api_routes.py b/tests/test_api_routes.py
index eb13033..ffdcefb 100644
--- a/tests/test_api_routes.py
+++ b/tests/test_api_routes.py
@@ -120,6 +120,23 @@ def test_ups_energy_with_data(authed_client, fake_redis):
assert body["kwh_today"] == 1.0
+def test_ups_energy_uses_four_decimal_cost_rate(authed_client, fake_redis):
+ _add_ups(authed_client)
+ authed_client.put(
+ "/api/config/ui",
+ json={"show_energy": True, "energy_cost_per_kwh": 0.1365},
+ )
+ day_str = time.strftime("%Y%m%d")
+ fake_redis.set(f"ups:energy:u1:{day_str}", "7200000") # 2 kWh
+
+ r = authed_client.get("/api/ups/u1/energy")
+
+ assert r.status_code == 200
+ body = r.json()
+ assert body["kwh_today"] == 2.0
+ assert body["cost_today"] == 0.273
+
+
def test_ups_health(authed_client, fake_redis):
_add_ups(authed_client)
fake_redis.set("ups:health:last_ok:u1", "12345")
diff --git a/tests/test_config_api.py b/tests/test_config_api.py
index 155a947..524ac94 100644
--- a/tests/test_config_api.py
+++ b/tests/test_config_api.py
@@ -133,3 +133,22 @@ def test_ui_config_update(authed_client):
body = r.json()
assert body["ui"]["show_energy"] is True
assert body["ui"]["energy_cost_per_kwh"] == 0.15
+
+
+def test_ui_config_energy_cost_keeps_four_decimal_rate(authed_client):
+ r = authed_client.put(
+ "/api/config/ui",
+ json={"show_energy": True, "energy_cost_per_kwh": 0.13654},
+ )
+
+ assert r.status_code == 200
+ assert r.json()["ui"]["energy_cost_per_kwh"] == 0.1365
+
+
+def test_ui_config_rejects_negative_energy_cost(authed_client):
+ r = authed_client.put(
+ "/api/config/ui",
+ json={"energy_cost_per_kwh": -0.1},
+ )
+
+ assert r.status_code == 422