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