Compare commits

...
7 Commits
Author SHA1 Message Date
Ketan Patel acb6b25657 Honor dashboard UI config flags
Build and Deploy / build-and-deploy (push) Successful in 27s
2026-06-20 10:52:12 -04:00
Ketan Patel 94f7ed8411 Show energy cost on dashboard
Build and Deploy / build-and-deploy (push) Successful in 25s
2026-06-20 10:49:06 -04:00
Ketan Patel c7a0ed93ff Preserve dashboard tile sizes
Build and Deploy / build-and-deploy (push) Successful in 27s
2026-06-19 19:48:47 -04:00
Ketan Patel 9a1efb2c15 Respect HTTP deployments in CSP
Build and Deploy / build-and-deploy (push) Successful in 25s
2026-06-19 19:35:24 -04:00
Ketan Patel b27f045796 Align Gitea workflow checkout with existing deploy
Build and Deploy / build-and-deploy (push) Successful in 2m9s
2026-06-19 19:07:32 -04:00
Ketan Patel c7e949b074 Use global SSH key for Gitea checkout
Build and Deploy / build-and-deploy (push) Failing after 4s
2026-06-19 19:03:21 -04:00
Ketan Patel 3061bc4d1b Trust Gitea SSH host in workflow
Build and Deploy / build-and-deploy (push) Failing after 4s
2026-06-19 18:59:08 -04:00
14 changed files with 266 additions and 63 deletions

No files matched your search

+5 -1
View File
@@ -20,9 +20,13 @@ jobs:
steps:
- name: Checkout
shell: bash
env:
GITEA_TOKEN: ${{ github.token }}
run: |
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 checkout --force refs/remotes/origin/main
+6 -1
View File
@@ -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):
+4
View File
@@ -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
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()}
+2 -1
View File
@@ -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
+9 -4
View File
@@ -10,7 +10,7 @@ from starlette.types import ASGIApp
from .settings import settings
CSP_DIRECTIVES = (
CSP_DIRECTIVES_BASE = (
"default-src 'self'; "
"script-src 'self' https://cdn.jsdelivr.net; "
"style-src 'self' 'unsafe-inline'; "
@@ -20,11 +20,16 @@ CSP_DIRECTIVES = (
"base-uri 'self'; "
"form-action 'self'; "
"frame-ancestors 'none'; "
"object-src 'none'; "
"upgrade-insecure-requests"
"object-src 'none'"
)
def _csp_directives() -> str:
if settings.trust_proxy:
return f"{CSP_DIRECTIVES_BASE}; upgrade-insecure-requests"
return CSP_DIRECTIVES_BASE
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
def __init__(self, app: ASGIApp):
super().__init__(app)
@@ -34,7 +39,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
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(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=(), payment=()",
+4
View File
@@ -24,6 +24,7 @@
}
.card:hover { border-color: var(--border-hi); box-shadow: var(--shadow-md); }
.card.resizing { outline: 2px dashed var(--accent); }
#ups-cards.resize-disabled .card { resize: none; }
.card-header {
display: flex;
@@ -138,6 +139,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);
+87 -50
View File
@@ -136,6 +136,7 @@ try {
} catch(_) {}
// Set defaults
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
const METRIC_LABELS = {
'LOADPCT': 'UPS Load %',
@@ -155,8 +156,12 @@ 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
const SNAP_ORIGIN = 10; // default tile layout starts at 10px
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 toggleTileSelection(tile, additive) {
if (!additive) clearSelection();
@@ -164,6 +169,20 @@ function toggleTileSelection(tile, additive) {
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 ---
async function loadServerTileConfig(name) {
try {
@@ -234,10 +253,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 +337,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 +364,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] || []);
@@ -399,6 +437,7 @@ function ensureUpsCard(name) {
if (!card.style.width && savedCardSizes[name]) {
restoreCardSize(name);
}
applyCardFeatureVisibility(card);
return card;
}
const container = document.getElementById('ups-cards');
@@ -407,7 +446,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';
}
@@ -439,6 +479,7 @@ function ensureUpsCard(name) {
attachResizeBehavior(div, handle);
}
container.appendChild(div);
applyCardFeatureVisibility(div);
// Observe card for resize events to update footer position
if (typeof ro !== 'undefined') {
@@ -465,13 +506,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]'));
@@ -515,7 +550,7 @@ function initTilesFor(name, grid) {
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>`;
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);
grid.appendChild(energyTile);
}
@@ -677,6 +712,9 @@ function createCustomTile(name, grid, tileCfg) {
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>`;
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 +745,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);
}
@@ -1011,7 +1050,11 @@ evtSource.onmessage = (e) => {
const conn = card.querySelector('.ups-connection');
if (conn) conn.textContent = `${meta.host}:${meta.port}`;
// Update event footer asynchronously (lightweight)
if (uiCfg.show_events === false) {
clearEventFooter(meta.name);
} else {
updateEventFooter(meta.name);
}
});
Object.entries(snapshots).forEach(([name, snap]) => {
@@ -1176,11 +1219,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(()=>{});
}
});
@@ -1508,22 +1559,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 +1585,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 +1601,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'; }
setTimeout(()=>{ Object.values(charts).forEach(ch=>{ try{ ch.resize(); }catch{} }); }, 50);
if (!hasSavedPositions(name)) {
autoArrangeTiles(name, grid);
persistTilePositions(name, grid);
} else {
scheduleServerTileSave(name);
}
setTimeout(()=>{ Object.values(charts).forEach(ch=>{ try{ ch.resize(); }catch{} }); }, 50);
}
});
});
+12 -1
View File
@@ -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;
});
}
@@ -35,9 +42,13 @@
show_events: f.show_events.checked,
show_energy: f.show_energy.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_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),
+1 -1
View File
@@ -6,7 +6,7 @@
<title>{% block title %}UPS Dashboard{% endblock %}</title>
<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/style.css?v=5" />
<link rel="stylesheet" href="/static/css/style.css?v=6" />
{% block head_extra %}{% endblock %}
</head>
<body>
+1 -1
View File
@@ -61,5 +61,5 @@
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/dashboard.js?v=5"></script>
<script src="/static/js/dashboard.js?v=7"></script>
{% endblock %}
+30 -2
View File
@@ -30,6 +30,34 @@
</div>
<label class="switch"><input type="checkbox" name="color_badges"><span class="slider"></span></label>
</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-label">
<span class="toggle-title">Transfer-burst alerts</span>
@@ -47,7 +75,7 @@
</div>
<div class="field-row">
<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 class="form-bar">
<button type="submit" class="btn btn-primary">Save UI Settings</button>
@@ -116,5 +144,5 @@
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/settings.js?v=4"></script>
<script src="/static/js/settings.js?v=5"></script>
{% endblock %}
+51
View File
@@ -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")
@@ -139,6 +156,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")
+31 -1
View File
@@ -127,9 +127,39 @@ def test_ui_config_default(authed_client):
def test_ui_config_update(authed_client):
r = authed_client.put(
"/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
body = r.json()
assert body["ui"]["show_energy"] is True
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
+23
View File
@@ -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