Compare commits
7
Commits
4b2b1cf411
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acb6b25657 | ||
|
|
94f7ed8411 | ||
|
|
c7a0ed93ff | ||
|
|
9a1efb2c15 | ||
|
|
b27f045796 | ||
|
|
c7e949b074 | ||
|
|
3061bc4d1b |
No files matched your search
@@ -20,9 +20,13 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
shell: bash
|
shell: bash
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
git init .
|
git init .
|
||||||
git remote add origin "ssh://git@git.k2patel.in:2222/k2patel/apcupsd-client.git"
|
git remote add origin "https://git.k2patel.in/k2patel/apcupsd-client.git"
|
||||||
|
AUTH_HEADER="$(printf 'x-access-token:%s' "$GITEA_TOKEN" | base64 -w 0)"
|
||||||
|
git config --local "http.https://git.k2patel.in/.extraheader" "AUTHORIZATION: basic ${AUTH_HEADER}"
|
||||||
git -c protocol.version=2 fetch --no-tags --prune --depth=1 origin +refs/heads/main:refs/remotes/origin/main
|
git -c protocol.version=2 fetch --no-tags --prune --depth=1 origin +refs/heads/main:refs/remotes/origin/main
|
||||||
git checkout --force refs/remotes/origin/main
|
git checkout --force refs/remotes/origin/main
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -121,7 +121,12 @@ class UIConfig(BaseModel):
|
|||||||
allow_resize: bool = True
|
allow_resize: bool = True
|
||||||
enable_transfer_burst_alert: bool = False
|
enable_transfer_burst_alert: bool = False
|
||||||
enable_voltage_deviation_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):
|
class AppConfig(BaseModel):
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Configuration CRUD endpoints (UPS, SMTP, UI). All require session+CSRF for writes."""
|
"""Configuration CRUD endpoints (UPS, SMTP, UI). All require session+CSRF for writes."""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from ..auth import require_session, require_session_and_csrf
|
from ..auth import require_session, require_session_and_csrf
|
||||||
from ..config import SMTPConfig, UIConfig, UPSConfig
|
from ..config import SMTPConfig, UIConfig, UPSConfig
|
||||||
@@ -142,6 +143,9 @@ async def update_ui_config(
|
|||||||
for k, v in payload.items():
|
for k, v in payload.items():
|
||||||
if k in ui_dict:
|
if k in ui_dict:
|
||||||
ui_dict[k] = v
|
ui_dict[k] = v
|
||||||
|
try:
|
||||||
new_ui = UIConfig(**ui_dict)
|
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)
|
await config_manager.update_ui_config(new_ui)
|
||||||
return {"message": "UI config updated", "ui": new_ui.model_dump()}
|
return {"message": "UI config updated", "ui": new_ui.model_dump()}
|
||||||
@@ -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}")
|
raw = r.get(f"ups:ui:tiles:{ups_name}")
|
||||||
default = {
|
default = {
|
||||||
"types": {}, "order": [], "hidden": [], "custom": [],
|
"types": {}, "order": [], "hidden": [], "custom": [],
|
||||||
"positions": {}, "card_size": None,
|
"positions": {}, "card_size": None, "exists": False,
|
||||||
}
|
}
|
||||||
if not raw:
|
if not raw:
|
||||||
return default
|
return default
|
||||||
@@ -312,6 +312,7 @@ async def get_ups_ui_tiles(ups_name: str, user=Depends(require_session)):
|
|||||||
"custom": data.get("custom", []),
|
"custom": data.get("custom", []),
|
||||||
"positions": data.get("positions", {}),
|
"positions": data.get("positions", {}),
|
||||||
"card_size": data.get("card_size"),
|
"card_size": data.get("card_size"),
|
||||||
|
"exists": True,
|
||||||
}
|
}
|
||||||
except Exception:
|
except Exception:
|
||||||
return default
|
return default
|
||||||
|
|||||||
+9
-4
@@ -10,7 +10,7 @@ from starlette.types import ASGIApp
|
|||||||
|
|
||||||
from .settings import settings
|
from .settings import settings
|
||||||
|
|
||||||
CSP_DIRECTIVES = (
|
CSP_DIRECTIVES_BASE = (
|
||||||
"default-src 'self'; "
|
"default-src 'self'; "
|
||||||
"script-src 'self' https://cdn.jsdelivr.net; "
|
"script-src 'self' https://cdn.jsdelivr.net; "
|
||||||
"style-src 'self' 'unsafe-inline'; "
|
"style-src 'self' 'unsafe-inline'; "
|
||||||
@@ -20,11 +20,16 @@ CSP_DIRECTIVES = (
|
|||||||
"base-uri 'self'; "
|
"base-uri 'self'; "
|
||||||
"form-action 'self'; "
|
"form-action 'self'; "
|
||||||
"frame-ancestors 'none'; "
|
"frame-ancestors 'none'; "
|
||||||
"object-src 'none'; "
|
"object-src 'none'"
|
||||||
"upgrade-insecure-requests"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _csp_directives() -> str:
|
||||||
|
if settings.trust_proxy:
|
||||||
|
return f"{CSP_DIRECTIVES_BASE}; upgrade-insecure-requests"
|
||||||
|
return CSP_DIRECTIVES_BASE
|
||||||
|
|
||||||
|
|
||||||
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||||
def __init__(self, app: ASGIApp):
|
def __init__(self, app: ASGIApp):
|
||||||
super().__init__(app)
|
super().__init__(app)
|
||||||
@@ -34,7 +39,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|||||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||||
response.headers.setdefault("X-Frame-Options", "DENY")
|
response.headers.setdefault("X-Frame-Options", "DENY")
|
||||||
response.headers.setdefault("Referrer-Policy", "same-origin")
|
response.headers.setdefault("Referrer-Policy", "same-origin")
|
||||||
response.headers.setdefault("Content-Security-Policy", CSP_DIRECTIVES)
|
response.headers.setdefault("Content-Security-Policy", _csp_directives())
|
||||||
response.headers.setdefault(
|
response.headers.setdefault(
|
||||||
"Permissions-Policy",
|
"Permissions-Policy",
|
||||||
"camera=(), microphone=(), geolocation=(), payment=()",
|
"camera=(), microphone=(), geolocation=(), payment=()",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
}
|
}
|
||||||
.card:hover { border-color: var(--border-hi); box-shadow: var(--shadow-md); }
|
.card:hover { border-color: var(--border-hi); box-shadow: var(--shadow-md); }
|
||||||
.card.resizing { outline: 2px dashed var(--accent); }
|
.card.resizing { outline: 2px dashed var(--accent); }
|
||||||
|
#ups-cards.resize-disabled .card { resize: none; }
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -138,6 +139,9 @@
|
|||||||
.tile.hidden { display: none; }
|
.tile.hidden { display: none; }
|
||||||
.tile.dragging { opacity: 0.5; border: 1px dashed var(--accent); }
|
.tile.dragging { opacity: 0.5; border: 1px dashed var(--accent); }
|
||||||
.tile.selected { outline: 2px solid 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 { position: absolute; top: 4px; right: 4px; display: flex; gap: 4px; }
|
||||||
.tile-controls select {
|
.tile-controls select {
|
||||||
background: var(--surface-3);
|
background: var(--surface-3);
|
||||||
|
|||||||
+87
-50
@@ -136,6 +136,7 @@ try {
|
|||||||
} catch(_) {}
|
} catch(_) {}
|
||||||
// Set defaults
|
// Set defaults
|
||||||
uiCfg.allow_resize = uiCfg.allow_resize !== false;
|
uiCfg.allow_resize = uiCfg.allow_resize !== false;
|
||||||
|
if (uiCfgEl) uiCfgEl.classList.toggle('resize-disabled', uiCfg.allow_resize === false);
|
||||||
// Human-friendly metric labels for dropdown & headers
|
// Human-friendly metric labels for dropdown & headers
|
||||||
const METRIC_LABELS = {
|
const METRIC_LABELS = {
|
||||||
'LOADPCT': 'UPS Load %',
|
'LOADPCT': 'UPS Load %',
|
||||||
@@ -155,8 +156,12 @@ const lastUpdateTs = {};
|
|||||||
const UPS_STALE_SECONDS = 120; // mark offline after 2 minutes inactivity
|
const UPS_STALE_SECONDS = 120; // mark offline after 2 minutes inactivity
|
||||||
// Freeform tile layout enhancements
|
// Freeform tile layout enhancements
|
||||||
const SNAP_GRID = 20; // pixel grid for snapping
|
const SNAP_GRID = 20; // pixel grid for snapping
|
||||||
|
const SNAP_ORIGIN = 10; // default tile layout starts at 10px
|
||||||
let selectedTiles = new Set();
|
let selectedTiles = new Set();
|
||||||
function snap(v) { return Math.round(v / SNAP_GRID) * SNAP_GRID; }
|
function snap(v) {
|
||||||
|
if (v <= SNAP_ORIGIN) return SNAP_ORIGIN;
|
||||||
|
return SNAP_ORIGIN + Math.round((v - SNAP_ORIGIN) / SNAP_GRID) * SNAP_GRID;
|
||||||
|
}
|
||||||
function clearSelection() { selectedTiles.forEach(t => t.classList.remove('selected')); selectedTiles.clear(); }
|
function clearSelection() { selectedTiles.forEach(t => t.classList.remove('selected')); selectedTiles.clear(); }
|
||||||
function toggleTileSelection(tile, additive) {
|
function toggleTileSelection(tile, additive) {
|
||||||
if (!additive) clearSelection();
|
if (!additive) clearSelection();
|
||||||
@@ -164,6 +169,20 @@ function toggleTileSelection(tile, additive) {
|
|||||||
else { tile.classList.add('selected'); selectedTiles.add(tile); }
|
else { tile.classList.add('selected'); selectedTiles.add(tile); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyCardFeatureVisibility(card) {
|
||||||
|
const runtimeBadge = card.querySelector('[data-badge="runtime"]');
|
||||||
|
const wattsBadge = card.querySelector('[data-badge="watts"]');
|
||||||
|
const headroomBadge = card.querySelector('[data-badge="headroom"]');
|
||||||
|
if (runtimeBadge) runtimeBadge.hidden = uiCfg.show_runtime === false;
|
||||||
|
if (wattsBadge) wattsBadge.hidden = uiCfg.show_watts === false;
|
||||||
|
if (headroomBadge) headroomBadge.hidden = uiCfg.show_headroom === false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearEventFooter(name) {
|
||||||
|
const footer = getCard(name)?.querySelector('[data-ups-footer]');
|
||||||
|
if (footer) footer.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
// --- Redis-backed tile layout persistence ---
|
// --- Redis-backed tile layout persistence ---
|
||||||
async function loadServerTileConfig(name) {
|
async function loadServerTileConfig(name) {
|
||||||
try {
|
try {
|
||||||
@@ -234,10 +253,14 @@ async function loadServerTileConfig(name) {
|
|||||||
scheduleServerTileSave(name);
|
scheduleServerTileSave(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
let pendingSave = null;
|
const pendingSaves = {};
|
||||||
function scheduleServerTileSave(name) {
|
function scheduleServerTileSave(name) {
|
||||||
if (pendingSave) clearTimeout(pendingSave);
|
if (!name) return;
|
||||||
pendingSave = setTimeout(() => saveServerTileConfig(name), 800);
|
if (pendingSaves[name]) clearTimeout(pendingSaves[name]);
|
||||||
|
pendingSaves[name] = setTimeout(() => {
|
||||||
|
delete pendingSaves[name];
|
||||||
|
saveServerTileConfig(name);
|
||||||
|
}, 800);
|
||||||
}
|
}
|
||||||
|
|
||||||
function autoArrangeTiles(name, grid) {
|
function autoArrangeTiles(name, grid) {
|
||||||
@@ -314,14 +337,15 @@ function autoArrangeTiles(name, grid) {
|
|||||||
grid.style.height = `${cardHeight - 70}px`;
|
grid.style.height = `${cardHeight - 70}px`;
|
||||||
|
|
||||||
// Save card size
|
// 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}`);
|
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 };
|
savedCardSizes[name] = { width, height };
|
||||||
saveToStorage(CARD_SIZE_KEY, savedCardSizes);
|
saveToStorage(CARD_SIZE_KEY, savedCardSizes);
|
||||||
|
if (syncServer) scheduleServerTileSave(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
function restoreCardSize(name) {
|
function restoreCardSize(name) {
|
||||||
@@ -340,6 +364,20 @@ function restoreCardSize(name) {
|
|||||||
return false;
|
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) {
|
async function saveServerTileConfig(name) {
|
||||||
const types = savedTileTypes[name] || {};
|
const types = savedTileTypes[name] || {};
|
||||||
const order = (savedTileOrder[name] || []);
|
const order = (savedTileOrder[name] || []);
|
||||||
@@ -399,6 +437,7 @@ function ensureUpsCard(name) {
|
|||||||
if (!card.style.width && savedCardSizes[name]) {
|
if (!card.style.width && savedCardSizes[name]) {
|
||||||
restoreCardSize(name);
|
restoreCardSize(name);
|
||||||
}
|
}
|
||||||
|
applyCardFeatureVisibility(card);
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
const container = document.getElementById('ups-cards');
|
const container = document.getElementById('ups-cards');
|
||||||
@@ -407,7 +446,8 @@ function ensureUpsCard(name) {
|
|||||||
div.id = `card-${name}`;
|
div.id = `card-${name}`;
|
||||||
// Apply saved size if available
|
// Apply saved size if available
|
||||||
if (savedSizes[name]) {
|
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 (w) div.style.width = w + 'px';
|
||||||
if (h) div.style.height = h + 'px';
|
if (h) div.style.height = h + 'px';
|
||||||
}
|
}
|
||||||
@@ -439,6 +479,7 @@ function ensureUpsCard(name) {
|
|||||||
attachResizeBehavior(div, handle);
|
attachResizeBehavior(div, handle);
|
||||||
}
|
}
|
||||||
container.appendChild(div);
|
container.appendChild(div);
|
||||||
|
applyCardFeatureVisibility(div);
|
||||||
|
|
||||||
// Observe card for resize events to update footer position
|
// Observe card for resize events to update footer position
|
||||||
if (typeof ro !== 'undefined') {
|
if (typeof ro !== 'undefined') {
|
||||||
@@ -465,13 +506,7 @@ function ensureUpsCard(name) {
|
|||||||
}
|
}
|
||||||
// Set default tile types for new UPS (static layout)
|
// Set default tile types for new UPS (static layout)
|
||||||
if (!savedTileTypes[name]) {
|
if (!savedTileTypes[name]) {
|
||||||
savedTileTypes[name] = {
|
applyDefaultTileTypes(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
|
// Initialize tiles
|
||||||
initTilesFor(name, div.querySelector('[data-tile-grid]'));
|
initTilesFor(name, div.querySelector('[data-tile-grid]'));
|
||||||
@@ -515,7 +550,7 @@ function initTilesFor(name, grid) {
|
|||||||
energyTile.dataset.tile = 'energy';
|
energyTile.dataset.tile = 'energy';
|
||||||
energyTile.style.left = '12px';
|
energyTile.style.left = '12px';
|
||||||
energyTile.style.top = '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>`;
|
energyTile.innerHTML = `<h4>Energy Today</h4><div class="tile-body energy-summary"><span class="big-value" data-energy-val>-- kWh</span><span class="energy-cost" data-energy-cost>--</span></div>`;
|
||||||
const resizeHandle = document.createElement('div'); resizeHandle.className='tile-resize'; energyTile.appendChild(resizeHandle);
|
const resizeHandle = document.createElement('div'); resizeHandle.className='tile-resize'; energyTile.appendChild(resizeHandle);
|
||||||
grid.appendChild(energyTile);
|
grid.appendChild(energyTile);
|
||||||
}
|
}
|
||||||
@@ -677,6 +712,9 @@ function createCustomTile(name, grid, tileCfg) {
|
|||||||
tile.setAttribute('draggable','true');
|
tile.setAttribute('draggable','true');
|
||||||
const headerLabel = METRIC_LABELS[tileCfg.metric] ? METRIC_LABELS[tileCfg.metric] : tileCfg.metric;
|
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>`;
|
tile.innerHTML = `<div class="tile-controls"><button data-remove-tile title="Remove">✕</button></div><h4>${escapeHtml(headerLabel)}</h4><div class="tile-body"></div>`;
|
||||||
|
const resizeHandle = document.createElement('div');
|
||||||
|
resizeHandle.className = 'tile-resize';
|
||||||
|
tile.appendChild(resizeHandle);
|
||||||
grid.appendChild(tile);
|
grid.appendChild(tile);
|
||||||
buildCustomTileVisualization(name, tile, tileCfg);
|
buildCustomTileVisualization(name, tile, tileCfg);
|
||||||
const removeBtn = tile.querySelector('[data-remove-tile]');
|
const removeBtn = tile.querySelector('[data-remove-tile]');
|
||||||
@@ -707,8 +745,9 @@ function rebuildAllTiles(name) {
|
|||||||
function removeCustomTile(name, id, tileEl) {
|
function removeCustomTile(name, id, tileEl) {
|
||||||
savedCustomTiles[name] = (savedCustomTiles[name]||[]).filter(t => t.id !== id);
|
savedCustomTiles[name] = (savedCustomTiles[name]||[]).filter(t => t.id !== id);
|
||||||
try { localStorage.setItem(CUSTOM_TILES_KEY, JSON.stringify(savedCustomTiles)); } catch(_) {}
|
try { localStorage.setItem(CUSTOM_TILES_KEY, JSON.stringify(savedCustomTiles)); } catch(_) {}
|
||||||
|
const grid = tileEl?.parentElement;
|
||||||
if (tileEl) tileEl.remove();
|
if (tileEl) tileEl.remove();
|
||||||
persistTileOrder(name, tileEl.parentElement);
|
if (grid) persistTileOrder(name, grid);
|
||||||
scheduleServerTileSave(name);
|
scheduleServerTileSave(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1011,7 +1050,11 @@ evtSource.onmessage = (e) => {
|
|||||||
const conn = card.querySelector('.ups-connection');
|
const conn = card.querySelector('.ups-connection');
|
||||||
if (conn) conn.textContent = `${meta.host}:${meta.port}`;
|
if (conn) conn.textContent = `${meta.host}:${meta.port}`;
|
||||||
// Update event footer asynchronously (lightweight)
|
// Update event footer asynchronously (lightweight)
|
||||||
|
if (uiCfg.show_events === false) {
|
||||||
|
clearEventFooter(meta.name);
|
||||||
|
} else {
|
||||||
updateEventFooter(meta.name);
|
updateEventFooter(meta.name);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Object.entries(snapshots).forEach(([name, snap]) => {
|
Object.entries(snapshots).forEach(([name, snap]) => {
|
||||||
@@ -1176,11 +1219,19 @@ evtSource.onmessage = (e) => {
|
|||||||
}).catch(()=>{});
|
}).catch(()=>{});
|
||||||
if (uiCfg.show_energy) {
|
if (uiCfg.show_energy) {
|
||||||
fetch(apiUpsPath(meta.name, '/energy')).then(r => r.json()).then(data => {
|
fetch(apiUpsPath(meta.name, '/energy')).then(r => r.json()).then(data => {
|
||||||
if (!data || data.kwh_today == null) return;
|
|
||||||
const card = getCard(meta.name);
|
const card = getCard(meta.name);
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
const energyVal = card.querySelector('[data-energy-val]');
|
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(()=>{});
|
}).catch(()=>{});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1508,22 +1559,16 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
savedTileTypes[name] = {
|
applyDefaultTileTypes(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);
|
const cfg = await loadServerTileConfig(name);
|
||||||
if (cfg) {
|
if (cfg && cfg.exists !== false) {
|
||||||
savedTileTypes[name] = cfg.types || savedTileTypes[name] || {};
|
savedTileTypes[name] = Object.keys(cfg.types || {}).length ? cfg.types : savedTileTypes[name] || {};
|
||||||
savedTileOrder[name] = cfg.order || savedTileOrder[name] || [];
|
savedTileOrder[name] = (cfg.order || []).length ? cfg.order : savedTileOrder[name] || [];
|
||||||
const hiddenSet = {}; (cfg.hidden||[]).forEach(h => hiddenSet[h]=true);
|
const hiddenSet = {}; (cfg.hidden||[]).forEach(h => hiddenSet[h]=true);
|
||||||
savedTileHidden[name] = hiddenSet;
|
savedTileHidden[name] = hiddenSet;
|
||||||
savedCustomTiles[name] = cfg.custom || [];
|
savedCustomTiles[name] = (cfg.custom || []).length ? cfg.custom : savedCustomTiles[name] || [];
|
||||||
savedTilePos[name] = cfg.positions || savedTilePos[name] || {};
|
savedTilePos[name] = Object.keys(cfg.positions || {}).length ? cfg.positions : savedTilePos[name] || {};
|
||||||
if (cfg.card_size && cfg.card_size.width && cfg.card_size.height) {
|
if (cfg.card_size && cfg.card_size.width && cfg.card_size.height) {
|
||||||
savedCardSizes[name] = { width: cfg.card_size.width, height: cfg.card_size.height };
|
savedCardSizes[name] = { width: cfg.card_size.width, height: cfg.card_size.height };
|
||||||
saveToStorage(CARD_SIZE_KEY, savedCardSizes);
|
saveToStorage(CARD_SIZE_KEY, savedCardSizes);
|
||||||
@@ -1540,18 +1585,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// Force rebuild tiles with correct types
|
// Force rebuild tiles with correct types
|
||||||
rebuildAllTiles(name);
|
rebuildAllTiles(name);
|
||||||
// If no stored positions existed, perform an auto layout
|
// If no stored positions existed, perform an auto layout
|
||||||
if (!cfg.positions || Object.keys(cfg.positions).length === 0) {
|
if (!hasSavedPositions(name)) {
|
||||||
resetLayout(name);
|
autoArrangeTiles(name, grid);
|
||||||
|
persistTilePositions(name, grid);
|
||||||
|
} else if (!Object.keys(cfg.positions || {}).length) {
|
||||||
|
scheduleServerTileSave(name);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No server config: apply default static layout
|
// No server config: keep any local layout and backfill it to the server.
|
||||||
savedTileTypes[name] = {
|
applyDefaultTileTypes(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]');
|
const grid = card.querySelector('[data-tile-grid]');
|
||||||
debugLog('Initializing default tiles for', name, 'grid found:', !!grid);
|
debugLog('Initializing default tiles for', name, 'grid found:', !!grid);
|
||||||
grid.innerHTML='';
|
grid.innerHTML='';
|
||||||
@@ -1559,18 +1601,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
debugLog('Tiles created, count:', grid.children.length);
|
debugLog('Tiles created, count:', grid.children.length);
|
||||||
// Force rebuild tiles with correct types
|
// Force rebuild tiles with correct types
|
||||||
rebuildAllTiles(name);
|
rebuildAllTiles(name);
|
||||||
// Apply static layout directly (same as in resetLayout)
|
if (!hasSavedPositions(name)) {
|
||||||
const loadTile = grid.querySelector('.tile[data-tile="load_pct"]');
|
autoArrangeTiles(name, grid);
|
||||||
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);
|
persistTilePositions(name, grid);
|
||||||
|
} else {
|
||||||
scheduleServerTileSave(name);
|
scheduleServerTileSave(name);
|
||||||
}
|
}
|
||||||
|
setTimeout(()=>{ Object.values(charts).forEach(ch=>{ try{ ch.resize(); }catch{} }); }, 50);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
// Settings page
|
// Settings page
|
||||||
(async function () {
|
(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() {
|
async function loadUi() {
|
||||||
const r = await window.apiFetch('/api/config/ui');
|
const r = await window.apiFetch('/api/config/ui');
|
||||||
if (!r.ok) return;
|
if (!r.ok) return;
|
||||||
@@ -9,6 +15,7 @@
|
|||||||
const el = form.elements[k];
|
const el = form.elements[k];
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
if (el.type === 'checkbox') el.checked = !!v;
|
if (el.type === 'checkbox') el.checked = !!v;
|
||||||
|
else if (k === 'energy_cost_per_kwh') el.value = normalizeEnergyRate(v);
|
||||||
else el.value = v;
|
else el.value = v;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -35,9 +42,13 @@
|
|||||||
show_events: f.show_events.checked,
|
show_events: f.show_events.checked,
|
||||||
show_energy: f.show_energy.checked,
|
show_energy: f.show_energy.checked,
|
||||||
color_badges: f.color_badges.checked,
|
color_badges: f.color_badges.checked,
|
||||||
|
show_runtime: f.show_runtime.checked,
|
||||||
|
show_watts: f.show_watts.checked,
|
||||||
|
show_headroom: f.show_headroom.checked,
|
||||||
|
allow_resize: f.allow_resize.checked,
|
||||||
enable_transfer_burst_alert: f.enable_transfer_burst_alert.checked,
|
enable_transfer_burst_alert: f.enable_transfer_burst_alert.checked,
|
||||||
enable_voltage_deviation_alert: f.enable_voltage_deviation_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', {
|
const r = await window.apiFetch('/api/config/ui', {
|
||||||
method: 'PUT', body: JSON.stringify(payload),
|
method: 'PUT', body: JSON.stringify(payload),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<title>{% block title %}UPS Dashboard{% endblock %}</title>
|
<title>{% block title %}UPS Dashboard{% endblock %}</title>
|
||||||
<meta name="csrf-token" content="{{ request.cookies.get('csrf_token', '') }}" />
|
<meta name="csrf-token" content="{{ request.cookies.get('csrf_token', '') }}" />
|
||||||
<link rel="stylesheet" href="/static/css/base.css?v=5" />
|
<link rel="stylesheet" href="/static/css/base.css?v=5" />
|
||||||
<link rel="stylesheet" href="/static/css/style.css?v=5" />
|
<link rel="stylesheet" href="/static/css/style.css?v=6" />
|
||||||
{% block head_extra %}{% endblock %}
|
{% block head_extra %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -61,5 +61,5 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="/static/js/dashboard.js?v=5"></script>
|
<script src="/static/js/dashboard.js?v=7"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -30,6 +30,34 @@
|
|||||||
</div>
|
</div>
|
||||||
<label class="switch"><input type="checkbox" name="color_badges"><span class="slider"></span></label>
|
<label class="switch"><input type="checkbox" name="color_badges"><span class="slider"></span></label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="toggle-row">
|
||||||
|
<div class="toggle-label">
|
||||||
|
<span class="toggle-title">Show runtime badge</span>
|
||||||
|
<span class="toggle-help">Display estimated runtime on each UPS card</span>
|
||||||
|
</div>
|
||||||
|
<label class="switch"><input type="checkbox" name="show_runtime"><span class="slider"></span></label>
|
||||||
|
</div>
|
||||||
|
<div class="toggle-row">
|
||||||
|
<div class="toggle-label">
|
||||||
|
<span class="toggle-title">Show watts badge</span>
|
||||||
|
<span class="toggle-help">Display current load in watts on each UPS card</span>
|
||||||
|
</div>
|
||||||
|
<label class="switch"><input type="checkbox" name="show_watts"><span class="slider"></span></label>
|
||||||
|
</div>
|
||||||
|
<div class="toggle-row">
|
||||||
|
<div class="toggle-label">
|
||||||
|
<span class="toggle-title">Show headroom badge</span>
|
||||||
|
<span class="toggle-help">Display remaining load capacity on each UPS card</span>
|
||||||
|
</div>
|
||||||
|
<label class="switch"><input type="checkbox" name="show_headroom"><span class="slider"></span></label>
|
||||||
|
</div>
|
||||||
|
<div class="toggle-row">
|
||||||
|
<div class="toggle-label">
|
||||||
|
<span class="toggle-title">Allow resizing</span>
|
||||||
|
<span class="toggle-help">Enable card and tile resize handles on the dashboard</span>
|
||||||
|
</div>
|
||||||
|
<label class="switch"><input type="checkbox" name="allow_resize"><span class="slider"></span></label>
|
||||||
|
</div>
|
||||||
<div class="toggle-row">
|
<div class="toggle-row">
|
||||||
<div class="toggle-label">
|
<div class="toggle-label">
|
||||||
<span class="toggle-title">Transfer-burst alerts</span>
|
<span class="toggle-title">Transfer-burst alerts</span>
|
||||||
@@ -47,7 +75,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
<label class="field-label" for="energy-cost">Energy cost per kWh ($)</label>
|
<label class="field-label" for="energy-cost">Energy cost per kWh ($)</label>
|
||||||
<input class="field-input" id="energy-cost" type="number" step="0.001" min="0" name="energy_cost_per_kwh">
|
<input class="field-input" id="energy-cost" type="number" step="0.0001" min="0" name="energy_cost_per_kwh" inputmode="decimal" placeholder="0.1365">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-bar">
|
<div class="form-bar">
|
||||||
<button type="submit" class="btn btn-primary">Save UI Settings</button>
|
<button type="submit" class="btn btn-primary">Save UI Settings</button>
|
||||||
@@ -116,5 +144,5 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="/static/js/settings.js?v=4"></script>
|
<script src="/static/js/settings.js?v=5"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -120,6 +120,23 @@ def test_ups_energy_with_data(authed_client, fake_redis):
|
|||||||
assert body["kwh_today"] == 1.0
|
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):
|
def test_ups_health(authed_client, fake_redis):
|
||||||
_add_ups(authed_client)
|
_add_ups(authed_client)
|
||||||
fake_redis.set("ups:health:last_ok:u1", "12345")
|
fake_redis.set("ups:health:last_ok:u1", "12345")
|
||||||
@@ -139,6 +156,40 @@ def test_ups_health_offline(authed_client, fake_redis):
|
|||||||
assert body["fail_count"] == 3
|
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):
|
def test_battery_health_empty(authed_client):
|
||||||
_add_ups(authed_client)
|
_add_ups(authed_client)
|
||||||
r = authed_client.get("/api/ups/u1/battery_health")
|
r = authed_client.get("/api/ups/u1/battery_health")
|
||||||
|
|||||||
@@ -127,9 +127,39 @@ def test_ui_config_default(authed_client):
|
|||||||
def test_ui_config_update(authed_client):
|
def test_ui_config_update(authed_client):
|
||||||
r = authed_client.put(
|
r = authed_client.put(
|
||||||
"/api/config/ui",
|
"/api/config/ui",
|
||||||
json={"show_energy": True, "energy_cost_per_kwh": 0.15},
|
json={
|
||||||
|
"show_energy": True,
|
||||||
|
"energy_cost_per_kwh": 0.15,
|
||||||
|
"show_runtime": False,
|
||||||
|
"show_watts": False,
|
||||||
|
"show_headroom": False,
|
||||||
|
"allow_resize": False,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
body = r.json()
|
body = r.json()
|
||||||
assert body["ui"]["show_energy"] is True
|
assert body["ui"]["show_energy"] is True
|
||||||
assert body["ui"]["energy_cost_per_kwh"] == 0.15
|
assert body["ui"]["energy_cost_per_kwh"] == 0.15
|
||||||
|
assert body["ui"]["show_runtime"] is False
|
||||||
|
assert body["ui"]["show_watts"] is False
|
||||||
|
assert body["ui"]["show_headroom"] is False
|
||||||
|
assert body["ui"]["allow_resize"] is False
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""Security header behavior."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app import security
|
||||||
|
|
||||||
|
|
||||||
|
def test_csp_does_not_upgrade_http_when_proxy_not_trusted(app_client, monkeypatch):
|
||||||
|
monkeypatch.setattr(security.settings, "trust_proxy", False)
|
||||||
|
|
||||||
|
response = app_client.get("/healthz")
|
||||||
|
|
||||||
|
csp = response.headers["content-security-policy"]
|
||||||
|
assert "upgrade-insecure-requests" not in csp
|
||||||
|
|
||||||
|
|
||||||
|
def test_csp_upgrades_requests_when_proxy_is_trusted(app_client, monkeypatch):
|
||||||
|
monkeypatch.setattr(security.settings, "trust_proxy", True)
|
||||||
|
|
||||||
|
response = app_client.get("/healthz")
|
||||||
|
|
||||||
|
csp = response.headers["content-security-policy"]
|
||||||
|
assert "upgrade-insecure-requests" in csp
|
||||||
Reference in new issue
Block a user