Compare commits

...
18 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
Ketan Patel 4b2b1cf411 Use SSH for Gitea workflow checkout
Build and Deploy / build-and-deploy (push) Failing after 5s
2026-06-19 18:56:43 -04:00
Ketan Patel db9c4054a5 Improve UI security and deploy workflow 2026-06-19 18:04:59 -04:00
Ketan Patel be7762e369 Fix template rendering for upgraded Starlette 2026-06-03 21:34:49 -04:00
Ketan Patel 34d8b5a299 Adding the sha based version pinnig 2026-06-03 21:21:06 -04:00
Ketan Patel cf3e12e9d2 Removing config/ups.yaml 2026-06-03 21:02:21 -04:00
K2patel 5cfa95b0b4 Merge pull request #4 from k2patel/goldKumar
Migrating to distrolless using melange and apko .. Should be good to go..
2026-04-16 23:35:59 -04:00
Ketan Patel aabe0b9fd3 Adding the install settings 2026-04-16 21:11:51 -04:00
Ketan Patel 5532eda43a Adding the correct values 2026-04-16 21:11:08 -04:00
Ketan Patel 08438b2252 Adding the helm chart 2026-04-16 21:10:03 -04:00
Ketan Patel 49d96722cb adding the replacement for k8s with charts 2026-04-16 21:08:32 -04:00
K2patel db265f22a5 Merge pull request #3 from k2patel/goldKumar
Gold kumar
2026-04-16 19:43:18 -04:00
52 changed files with 3419 additions and 442 deletions

No files matched your search

+116
View File
@@ -0,0 +1,116 @@
name: Build and Deploy
on:
push:
branches: [main]
tags: ["v*"]
workflow_dispatch:
env:
REGISTRY: git.k2patel.in
IMAGE_NAME: k2patel/apcupsd-client
HELM_RELEASE: apcupsd-client
HELM_NAMESPACE: apcupsd
CHART_PATH: ./chart
IMAGE_PULL_SECRET: gitea-registry
jobs:
build-and-deploy:
runs-on: docker
steps:
- name: Checkout
shell: bash
env:
GITEA_TOKEN: ${{ github.token }}
run: |
git init .
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
- name: Determine image tag
id: tag
shell: bash
run: |
if [[ "${{ gitea.ref }}" == refs/tags/v* ]]; then
echo "tag=${{ gitea.ref_name }}" >> "$GITHUB_OUTPUT"
else
echo "tag=$(printf '%s' '${{ gitea.sha }}' | cut -c1-12)" >> "$GITHUB_OUTPUT"
fi
- name: Login to Gitea registry
shell: bash
run: |
echo "${{ secrets.CONTAINER_TOKEN }}" | docker login "${{ env.REGISTRY }}" \
--username "${{ vars.CONTAINER_USER }}" \
--password-stdin
- name: Build image
shell: bash
run: |
docker build \
-t "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}" \
-t "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" \
.
- name: Push image
shell: bash
run: |
docker push "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}"
docker push "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
- name: Install Kubernetes tools
shell: bash
run: |
curl -fsSL -o kubectl "https://dl.k8s.io/release/$(curl -fsSL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
chmod +x kubectl
sudo mv kubectl /usr/local/bin/
curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
- name: Deploy Helm chart
shell: bash
env:
KUBECONFIG_DATA: ${{ secrets.KUBECONFIG_DATA }}
CONTAINER_USER: ${{ vars.CONTAINER_USER }}
CONTAINER_TOKEN: ${{ secrets.CONTAINER_TOKEN }}
run: |
if [[ -z "$KUBECONFIG_DATA" ]]; then
echo "::error::KUBECONFIG_DATA secret is not configured for this repository"
exit 1
fi
if printf '%s' "$KUBECONFIG_DATA" | tr -d '\r\n ' | base64 -d > /tmp/kubeconfig 2>/tmp/kubeconfig-decode.err; then
echo "Decoded KUBECONFIG_DATA as base64"
else
echo "KUBECONFIG_DATA is not base64; using it as raw kubeconfig content"
printf '%s' "$KUBECONFIG_DATA" > /tmp/kubeconfig
fi
chmod 600 /tmp/kubeconfig
export KUBECONFIG=/tmp/kubeconfig
kubectl create namespace "$HELM_NAMESPACE" \
--dry-run=client \
-o yaml | kubectl apply -f -
kubectl create secret docker-registry "$IMAGE_PULL_SECRET" \
--docker-server="$REGISTRY" \
--docker-username="$CONTAINER_USER" \
--docker-password="$CONTAINER_TOKEN" \
--namespace "$HELM_NAMESPACE" \
--dry-run=client \
-o yaml | kubectl apply -f -
if ! helm status "$HELM_RELEASE" --namespace "$HELM_NAMESPACE" >/dev/null 2>&1; then
echo "::error::Helm release $HELM_RELEASE is not installed. Bootstrap it with chart secrets before enabling automated image deploys."
exit 1
fi
helm upgrade "$HELM_RELEASE" "$CHART_PATH" \
--namespace "$HELM_NAMESPACE" \
--reuse-values \
--set app.image="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}" \
--set app.imagePullPolicy=IfNotPresent \
--set "app.imagePullSecrets[0].name=$IMAGE_PULL_SECRET"
rm -f /tmp/kubeconfig
+11 -6
View File
@@ -18,14 +18,13 @@ jobs:
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: '3.12' python-version: '3.14'
cache: pip cache: pip
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -r requirements.txt pip install --require-hashes -r requirements.dev.txt
pip install coverage
- name: Ruff lint - name: Ruff lint
run: ruff check . run: ruff check .
@@ -43,6 +42,13 @@ jobs:
contents: read contents: read
packages: write packages: write
id-token: write id-token: write
strategy:
matrix:
include:
- image_tag: ghcr.io/k2patel/apcupsd-client:latest
melange_config: melange.yaml
- image_tag: ghcr.io/k2patel/apcupsd-client-dev:latest
melange_config: melange.dev.yaml
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -55,7 +61,7 @@ jobs:
- name: Build package with melange - name: Build package with melange
run: | run: |
melange build melange.yaml \ melange build ${{ matrix.melange_config }} \
--signing-key melange.rsa \ --signing-key melange.rsa \
--arch x86_64,aarch64 --arch x86_64,aarch64
@@ -71,6 +77,5 @@ jobs:
with: with:
config: apko.yaml config: apko.yaml
archs: x86_64,aarch64 archs: x86_64,aarch64
tag: ghcr.io/k2patel/apcupsd-client:latest tag: ${{ matrix.image_tag }}
keyring-append: melange.rsa.pub keyring-append: melange.rsa.pub
+3 -2
View File
@@ -52,8 +52,9 @@ Thumbs.db
*.tmp *.tmp
*.temp *.temp
# Kubernetes secrets (sops-encrypted original stays local) # Helm values (contain environment-specific config)
k8s/secret.yaml chart/values.yaml
chart/values-secret.yaml
# Melange / apko build artifacts # Melange / apko build artifacts
packages/ packages/
+10 -6
View File
@@ -1,7 +1,10 @@
# syntax=docker/dockerfile:1.6 # syntax=docker/dockerfile:1.6
# NOTE: The primary build method is now melange + apko (see melange.yaml / apko.yaml). # NOTE: The primary build method is now melange + apko (see melange.yaml / apko.yaml).
# This Dockerfile is kept for local dev and Docker Compose backward compatibility. # This Dockerfile is kept for local dev and Docker Compose backward compatibility.
FROM python:3.12.7-slim AS builder ARG PYTHON_IMAGE=python:3.14.5-slim@sha256:c845af9399020c7e562969a13689e929074a10fd057acd1b1fad06a2fb068e97
ARG REQUIREMENTS_FILE=requirements.txt
FROM ${PYTHON_IMAGE} AS builder
ARG REQUIREMENTS_FILE
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
@@ -13,11 +16,12 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential gcc \ && apt-get install -y --no-install-recommends build-essential gcc \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY requirements.txt ./ COPY ${REQUIREMENTS_FILE} requirements.txt
RUN pip install --upgrade pip && pip wheel --wheel-dir /wheels -r requirements.txt RUN pip install --upgrade pip && pip wheel --require-hashes --wheel-dir /wheels -r requirements.txt
FROM python:3.12.7-slim AS runtime FROM ${PYTHON_IMAGE} AS runtime
ARG REQUIREMENTS_FILE
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
@@ -33,8 +37,8 @@ RUN apt-get update \
&& useradd --system --uid 10001 --gid 10001 --home /app --shell /usr/sbin/nologin appuser && useradd --system --uid 10001 --gid 10001 --home /app --shell /usr/sbin/nologin appuser
COPY --from=builder /wheels /wheels COPY --from=builder /wheels /wheels
COPY requirements.txt ./ COPY ${REQUIREMENTS_FILE} requirements.txt
RUN pip install --no-index --find-links=/wheels -r requirements.txt \ RUN pip install --no-index --find-links=/wheels --require-hashes -r requirements.txt \
&& rm -rf /wheels && rm -rf /wheels
COPY app ./app COPY app ./app
+6 -3
View File
@@ -23,7 +23,7 @@ A production-ready FastAPI + Redis dashboard for monitoring multiple APC UPS dev
- SSRF host validation (rejects loopback/link-local; private IPs gated by `ALLOW_PRIVATE_IPS`) - SSRF host validation (rejects loopback/link-local; private IPs gated by `ALLOW_PRIVATE_IPS`)
- SMTP password **only** from env — never persisted to Redis - SMTP password **only** from env — never persisted to Redis
- Subprocess timeout on `apcaccess` (10s), rate-limiting on auth/config, security headers + CSP - Subprocess timeout on `apcaccess` (10s), rate-limiting on auth/config, security headers + CSP
- Non-root container (UID 10001), pinned `python:3.12.7-slim` multi-stage build - Non-root container (UID 10001), digest-pinned `python:3.14.5-slim` multi-stage build
- `/healthz`, `/readyz`, `/metrics` (Prometheus), JSON structured logs with request-ID correlation - `/healthz`, `/readyz`, `/metrics` (Prometheus), JSON structured logs with request-ID correlation
- GitHub Actions pipeline runs ruff + pytest before building/publishing the image - GitHub Actions pipeline runs ruff + pytest before building/publishing the image
@@ -82,9 +82,12 @@ Your remote APC UPS hosts must run `apcupsd` with the Network Information Server
## Development ## Development
Production Docker builds install `requirements.txt`; local development and CI use
`requirements.dev.txt`.
```bash ```bash
python3.12 -m venv .venv && . .venv/bin/activate python3.14 -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt pip install -r requirements.dev.txt
# Run tests (uses fakeredis) # Run tests (uses fakeredis)
pytest tests/ pytest tests/
+6 -1
View File
@@ -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):
+3 -2
View File
@@ -1,13 +1,14 @@
"""Authentication endpoints: login, logout, first-run setup.""" """Authentication endpoints: login, logout, first-run setup."""
import re import re
from fastapi import APIRouter, HTTPException, Request, Response from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from ..auth import ( from ..auth import (
clear_auth_cookies, clear_auth_cookies,
get_stored_admin, get_stored_admin,
is_admin_configured, is_admin_configured,
require_session_and_csrf,
set_auth_cookies, set_auth_cookies,
store_admin, store_admin,
verify_password, verify_password,
@@ -43,7 +44,7 @@ async def api_login(request: Request, response: Response, payload: LoginRequest)
@router.post("/api/logout") @router.post("/api/logout")
async def api_logout(response: Response): async def api_logout(response: Response, user=Depends(require_session_and_csrf)):
clear_auth_cookies(response) clear_auth_cookies(response)
return {"ok": True} return {"ok": True}
+5 -1
View File
@@ -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
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) 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()}
+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}") 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
+18 -8
View File
@@ -5,6 +5,7 @@ from fastapi.templating import Jinja2Templates
from ..auth import CSRF_COOKIE, current_user, is_admin_configured, make_csrf_token from ..auth import CSRF_COOKIE, current_user, is_admin_configured, make_csrf_token
from ..config import load_config from ..config import load_config
from ..settings import settings
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="app/templates") templates = Jinja2Templates(directory="app/templates")
@@ -15,7 +16,12 @@ def _ensure_csrf(request: Request, response):
if not token: if not token:
token = make_csrf_token() token = make_csrf_token()
response.set_cookie( response.set_cookie(
CSRF_COOKIE, token, httponly=False, samesite="lax", path="/" CSRF_COOKIE,
token,
httponly=False,
samesite="lax",
secure=settings.trust_proxy,
path="/",
) )
return token return token
@@ -37,9 +43,9 @@ async def dashboard(request: Request):
cfg = load_config() cfg = load_config()
user = current_user(request) user = current_user(request)
response = templates.TemplateResponse( response = templates.TemplateResponse(
request,
"dashboard.html", "dashboard.html",
{ {
"request": request,
"ups_list": cfg.ups, "ups_list": cfg.ups,
"ui_cfg": cfg.ui.model_dump(), "ui_cfg": cfg.ui.model_dump(),
"current_user": user, "current_user": user,
@@ -56,8 +62,9 @@ async def config_page(request: Request):
if redirect: if redirect:
return redirect return redirect
response = templates.TemplateResponse( response = templates.TemplateResponse(
request,
"config.html", "config.html",
{"request": request, "current_user": current_user(request), "active_nav": "config"}, {"current_user": current_user(request), "active_nav": "config"},
) )
_ensure_csrf(request, response) _ensure_csrf(request, response)
return response return response
@@ -69,8 +76,9 @@ async def events_page(request: Request):
if redirect: if redirect:
return redirect return redirect
response = templates.TemplateResponse( response = templates.TemplateResponse(
request,
"events.html", "events.html",
{"request": request, "current_user": current_user(request), "active_nav": "events"}, {"current_user": current_user(request), "active_nav": "events"},
) )
_ensure_csrf(request, response) _ensure_csrf(request, response)
return response return response
@@ -82,8 +90,9 @@ async def alerts_page(request: Request):
if redirect: if redirect:
return redirect return redirect
response = templates.TemplateResponse( response = templates.TemplateResponse(
request,
"alerts.html", "alerts.html",
{"request": request, "current_user": current_user(request), "active_nav": "alerts"}, {"current_user": current_user(request), "active_nav": "alerts"},
) )
_ensure_csrf(request, response) _ensure_csrf(request, response)
return response return response
@@ -95,8 +104,9 @@ async def settings_page(request: Request):
if redirect: if redirect:
return redirect return redirect
response = templates.TemplateResponse( response = templates.TemplateResponse(
request,
"settings.html", "settings.html",
{"request": request, "current_user": current_user(request), "active_nav": "settings"}, {"current_user": current_user(request), "active_nav": "settings"},
) )
_ensure_csrf(request, response) _ensure_csrf(request, response)
return response return response
@@ -108,11 +118,11 @@ async def login_page(request: Request):
return RedirectResponse("/setup", status_code=302) return RedirectResponse("/setup", status_code=302)
if current_user(request): if current_user(request):
return RedirectResponse("/", status_code=302) return RedirectResponse("/", status_code=302)
return templates.TemplateResponse("login.html", {"request": request}) return templates.TemplateResponse(request, "login.html")
@router.get("/setup", response_class=HTMLResponse) @router.get("/setup", response_class=HTMLResponse)
async def setup_page(request: Request): async def setup_page(request: Request):
if is_admin_configured(): if is_admin_configured():
return RedirectResponse("/login", status_code=302) return RedirectResponse("/login", status_code=302)
return templates.TemplateResponse("setup.html", {"request": request}) return templates.TemplateResponse(request, "setup.html")
+16 -4
View File
@@ -10,19 +10,26 @@ 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 'unsafe-inline'; " "script-src 'self' https://cdn.jsdelivr.net; "
"style-src 'self' 'unsafe-inline'; " "style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; " "img-src 'self' data:; "
"font-src 'self' data:; " "font-src 'self' data:; "
"connect-src 'self'; " "connect-src 'self'; "
"base-uri 'self'; " "base-uri 'self'; "
"form-action 'self'; " "form-action 'self'; "
"frame-ancestors 'none'" "frame-ancestors 'none'; "
"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): class SecurityHeadersMiddleware(BaseHTTPMiddleware):
def __init__(self, app: ASGIApp): def __init__(self, app: ASGIApp):
super().__init__(app) super().__init__(app)
@@ -32,7 +39,12 @@ 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(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=(), payment=()",
)
response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
if settings.trust_proxy: if settings.trust_proxy:
response.headers.setdefault( response.headers.setdefault(
"Strict-Transport-Security", "Strict-Transport-Security",
+130 -36
View File
@@ -1,34 +1,34 @@
/* ======================================================================== /* ========================================================================
Design Tokens — dark theme (slate/indigo) Design Tokens — power operations console
======================================================================== */ ======================================================================== */
:root { :root {
/* Surfaces */ /* Surfaces */
--bg: #0b1220; --bg: #0d0f12;
--surface: #131c2e; --surface: #15191e;
--surface-2: #1a2540; --surface-2: #1d2329;
--surface-3: #223052; --surface-3: #273039;
--border: #263654; --border: #303942;
--border-hi: #36507d; --border-hi: #50606e;
/* Text */ /* Text */
--text: #e6edf7; --text: #f2f4ef;
--text-muted: #9fb0cb; --text-muted: #b6c0b9;
--text-dim: #6c7fa2; --text-dim: #7f8b88;
/* Brand / accent */ /* Brand / accent */
--accent: #60a5fa; --accent: #f4b860;
--accent-hover: #3b82f6; --accent-hover: #d9922e;
--accent-dim: #1e3a8a; --accent-dim: #49331a;
/* Semantic */ /* Semantic */
--success: #10b981; --success: #38d07a;
--success-bg: #053b2d; --success-bg: #102f21;
--warning: #f59e0b; --warning: #f4b860;
--warning-bg: #3a2608; --warning-bg: #3d2a12;
--danger: #ef4444; --danger: #ff5a5f;
--danger-bg: #3b0f0f; --danger-bg: #3e181b;
--info: #38bdf8; --info: #4cc9c0;
--info-bg: #052b3a; --info-bg: #102f31;
/* Elevation */ /* Elevation */
--shadow-sm: 0 1px 2px rgba(0,0,0,.3); --shadow-sm: 0 1px 2px rgba(0,0,0,.3);
@@ -38,7 +38,7 @@
/* Radii & spacing */ /* Radii & spacing */
--radius-sm: 4px; --radius-sm: 4px;
--radius-md: 6px; --radius-md: 6px;
--radius-lg: 10px; --radius-lg: 8px;
/* Typography */ /* Typography */
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
@@ -55,7 +55,10 @@ body {
font-family: var(--font); font-family: var(--font);
font-size: 14px; font-size: 14px;
line-height: 1.5; line-height: 1.5;
background: var(--bg); background:
linear-gradient(90deg, rgba(244, 184, 96, .045) 1px, transparent 1px) 0 0 / 56px 56px,
linear-gradient(0deg, rgba(76, 201, 192, .035) 1px, transparent 1px) 0 0 / 56px 56px,
linear-gradient(180deg, #101317 0%, var(--bg) 42%);
color: var(--text); color: var(--text);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
@@ -79,20 +82,49 @@ code, pre { font-family: var(--font-mono); }
display: flex; display: flex;
align-items: center; align-items: center;
gap: 24px; gap: 24px;
padding: 10px 24px; padding: 12px 24px;
background: var(--surface); background: rgba(21, 25, 30, .94);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 100; z-index: 100;
backdrop-filter: blur(12px);
} }
.site-header .brand a { .site-header .brand a {
color: var(--text); color: var(--text);
font-weight: 600; display: inline-flex;
font-size: 16px; align-items: center;
gap: 10px;
}
.site-header .brand a:hover { text-decoration: none; color: var(--text); }
.brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 26px;
border: 1px solid rgba(244, 184, 96, .65);
border-radius: var(--radius-sm);
color: var(--accent);
background: rgba(244, 184, 96, .08);
font-family: var(--font-mono);
font-size: 11px;
font-weight: 700;
}
.brand-copy {
display: flex;
flex-direction: column;
line-height: 1.05;
}
.brand-title { font-weight: 700; font-size: 15px; }
.brand-subtitle {
color: var(--text-dim);
font-size: 10px;
text-transform: uppercase;
letter-spacing: .8px;
margin-top: 4px;
} }
.site-header .brand a:hover { text-decoration: none; color: var(--accent); }
.main-nav { .main-nav {
display: flex; display: flex;
@@ -114,8 +146,9 @@ code, pre { font-family: var(--font-mono); }
text-decoration: none; text-decoration: none;
} }
.main-nav a.active { .main-nav a.active {
background: var(--accent-dim); background: rgba(244, 184, 96, .12);
color: var(--text); color: var(--accent);
box-shadow: inset 0 0 0 1px rgba(244, 184, 96, .25);
} }
.user-menu { display: flex; align-items: center; gap: 10px; } .user-menu { display: flex; align-items: center; gap: 10px; }
@@ -142,7 +175,7 @@ code, pre { font-family: var(--font-mono); }
border-color: var(--danger); border-color: var(--danger);
} }
.site-main { padding: 20px 24px; max-width: 1600px; margin: 0 auto; } .site-main { padding: 22px 24px 32px; max-width: 1600px; margin: 0 auto; }
/* ======================================================================== /* ========================================================================
Page scaffolding Page scaffolding
@@ -157,6 +190,56 @@ code, pre { font-family: var(--font-mono); }
} }
.page-header h1 { margin: 0; font-size: 22px; } .page-header h1 { margin: 0; font-size: 22px; }
.dashboard-header {
display: flex;
align-items: end;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.dashboard-header h1 {
margin: 0;
font-size: 26px;
line-height: 1.15;
}
.eyebrow {
margin: 0 0 4px;
color: var(--accent);
font-size: 11px;
font-weight: 700;
letter-spacing: .8px;
text-transform: uppercase;
}
.dashboard-clock {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 32px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: rgba(21, 25, 30, .86);
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 12px;
}
.pulse-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-dim);
box-shadow: 0 0 0 0 rgba(76, 201, 192, .35);
}
.dashboard-clock.live .pulse-dot {
background: var(--success);
animation: statusPulse 1.8s ease-out infinite;
}
@keyframes statusPulse {
0% { box-shadow: 0 0 0 0 rgba(56, 208, 122, .32); }
70% { box-shadow: 0 0 0 8px rgba(56, 208, 122, 0); }
100% { box-shadow: 0 0 0 0 rgba(56, 208, 122, 0); }
}
.filters { display: flex; gap: 8px; flex-wrap: wrap; } .filters { display: flex; gap: 8px; flex-wrap: wrap; }
.filters select, .filters input { .filters select, .filters input {
padding: 7px 10px; padding: 7px 10px;
@@ -377,16 +460,24 @@ code, pre { font-family: var(--font-mono); }
======================================================================== */ ======================================================================== */
.fleet-overview { margin-bottom: 20px; } .fleet-overview { margin-bottom: 20px; }
.fleet-card { .fleet-card {
background: var(--surface); background: linear-gradient(180deg, rgba(29, 35, 41, .94), rgba(21, 25, 30, .94));
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
padding: 18px 22px; padding: 18px 20px;
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 20px; gap: 0;
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
.fleet-stat { display: flex; flex-direction: column; gap: 4px; min-width: 0; } .fleet-stat {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
padding: 2px 18px;
border-left: 1px solid rgba(80, 96, 110, .42);
}
.fleet-stat:first-child { border-left: 0; padding-left: 0; }
.fleet-stat-label { .fleet-stat-label {
color: var(--text-dim); color: var(--text-dim);
font-size: 11px; font-size: 11px;
@@ -451,6 +542,9 @@ code, pre { font-family: var(--font-mono); }
.site-header { flex-direction: column; align-items: flex-start; gap: 12px; padding: 12px 16px; } .site-header { flex-direction: column; align-items: flex-start; gap: 12px; padding: 12px 16px; }
.main-nav { width: 100%; } .main-nav { width: 100%; }
.site-main { padding: 16px; } .site-main { padding: 16px; }
.fleet-card { grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 14px; padding: 14px 16px; } .dashboard-header { align-items: flex-start; flex-direction: column; }
.dashboard-header h1 { font-size: 22px; }
.fleet-card { grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); padding: 14px 16px; }
.fleet-stat { border-left: 0; padding: 8px 0; }
.fleet-stat-value { font-size: 20px; } .fleet-stat-value { font-size: 20px; }
} }
+4
View File
@@ -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);
+64
View File
@@ -0,0 +1,64 @@
// Login and first-run setup handlers. Kept external so CSP can block inline JS.
(function () {
async function parseError(resp, fallback) {
const err = await resp.json().catch(() => ({}));
return err.detail || fallback;
}
function showError(id, message) {
const errBox = document.getElementById(id);
if (!errBox) return;
errBox.textContent = message;
errBox.hidden = false;
}
const loginForm = document.getElementById('login-form');
if (loginForm) {
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const errBox = document.getElementById('login-error');
if (errBox) errBox.hidden = true;
const resp = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: document.getElementById('username').value,
password: document.getElementById('password').value,
}),
});
if (resp.ok) {
window.location.href = '/';
} else {
showError('login-error', await parseError(resp, 'Login failed'));
}
});
}
const setupForm = document.getElementById('setup-form');
if (setupForm) {
setupForm.addEventListener('submit', async (e) => {
e.preventDefault();
const errBox = document.getElementById('setup-error');
if (errBox) errBox.hidden = true;
const pw = document.getElementById('password').value;
const pw2 = document.getElementById('password2').value;
if (pw !== pw2) {
showError('setup-error', 'Passwords do not match');
return;
}
const resp = await fetch('/api/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: document.getElementById('username').value,
password: pw,
}),
});
if (resp.ok) {
window.location.href = '/';
} else {
showError('setup-error', await parseError(resp, 'Setup failed'));
}
});
}
})();
+156 -81
View File
@@ -1,6 +1,6 @@
// Debug configuration // Debug configuration
const DEBUG_ENABLED = localStorage.getItem('ups_debug') === 'true' || const DEBUG_ENABLED = localStorage.getItem('ups_debug') === 'true' ||
new URLSearchParams(window.location.search).get('debug') === 'true' || true; // Temporarily enable debug new URLSearchParams(window.location.search).get('debug') === 'true';
// Debug logging helper // Debug logging helper
function debugLog(...args) { function debugLog(...args) {
@@ -39,6 +39,31 @@ window.upsDebug = {
const evtSource = new EventSource('/api/stream'); const evtSource = new EventSource('/api/stream');
const charts = {}; const charts = {};
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[ch]));
}
function apiUpsPath(name, suffix = '') {
return `/api/ups/${encodeURIComponent(name)}${suffix}`;
}
function getCard(name) {
return document.getElementById(`card-${name}`);
}
function setDashboardSyncState(message, live = false) {
const el = document.getElementById('dashboard-sync-state');
const wrapper = el?.closest('.dashboard-clock');
if (el) el.textContent = message;
if (wrapper) wrapper.classList.toggle('live', live);
}
// -------- Fleet overview poller -------- // -------- Fleet overview poller --------
async function refreshFleetOverview() { async function refreshFleetOverview() {
try { try {
@@ -53,7 +78,7 @@ async function refreshFleetOverview() {
setText('fleet-watts', d.total_watts != null ? Math.round(d.total_watts) : '—'); setText('fleet-watts', d.total_watts != null ? Math.round(d.total_watts) : '—');
const minRT = d.min_timeleft_minutes; const minRT = d.min_timeleft_minutes;
setText('fleet-min-runtime', minRT != null ? `${Math.round(minRT)} m` : '—'); setText('fleet-min-runtime', minRT != null ? `${Math.round(minRT)} m` : '—');
} catch (e) { /* silent */ } } catch (e) { setDashboardSyncState('Fleet summary unavailable', false); }
} }
refreshFleetOverview(); refreshFleetOverview();
setInterval(refreshFleetOverview, 10000); setInterval(refreshFleetOverview, 10000);
@@ -111,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 %',
@@ -130,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();
@@ -139,10 +169,24 @@ 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 {
const resp = await fetch(`/api/ups/${name}/ui_tiles`); const resp = await fetch(apiUpsPath(name, '/ui_tiles'));
if (!resp.ok) return null; if (!resp.ok) return null;
return await resp.json(); return await resp.json();
} catch { return null; } } catch { return null; }
@@ -164,13 +208,13 @@ async function loadServerTileConfig(name) {
saveToStorage(CARD_SIZE_KEY, savedCardSizes); saveToStorage(CARD_SIZE_KEY, savedCardSizes);
// Clear server layout // Clear server layout
try { try {
await window.apiFetch(`/api/ups/${name}/ui_tiles`, { method:'DELETE' }); await window.apiFetch(apiUpsPath(name, '/ui_tiles'), { method:'DELETE' });
debugLog(`Cleared server layout for ${name}`); debugLog(`Cleared server layout for ${name}`);
} catch(err) { } catch(err) {
debugWarn(`Failed to clear server layout for ${name}:`, err); debugWarn(`Failed to clear server layout for ${name}:`, err);
} }
// Rebuild grid with defaults then cascade layout // Rebuild grid with defaults then cascade layout
const card = document.getElementById(`card-${name}`); const card = getCard(name);
if (!card) return; if (!card) return;
const grid = card.querySelector('[data-tile-grid]'); const grid = card.querySelector('[data-tile-grid]');
if (!grid) return; if (!grid) return;
@@ -209,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) {
@@ -276,7 +324,7 @@ function autoArrangeTiles(name, grid) {
)); ));
// Auto-size the UPS card // Auto-size the UPS card
const card = document.getElementById(`card-${name}`); const card = getCard(name);
if (card) { if (card) {
const cardWidth = Math.max(maxX + padding, 400); const cardWidth = Math.max(maxX + padding, 400);
const cardHeight = Math.max(maxY + padding + 50, 300); // +50 for header const cardHeight = Math.max(maxY + padding + 50, 300); // +50 for header
@@ -289,18 +337,19 @@ 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) {
const card = document.getElementById(`card-${name}`); const card = getCard(name);
const grid = card?.querySelector('[data-tile-grid]'); const grid = card?.querySelector('[data-tile-grid]');
if (!card || !grid) return false; if (!card || !grid) return false;
@@ -315,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] || []);
@@ -326,7 +389,7 @@ async function saveServerTileConfig(name) {
const positions = savedTilePos[name] || {}; const positions = savedTilePos[name] || {};
const card_size = savedCardSizes[name] || null; const card_size = savedCardSizes[name] || null;
try { try {
await window.apiFetch(`/api/ups/${name}/ui_tiles`, { await window.apiFetch(apiUpsPath(name, '/ui_tiles'), {
method: 'POST', method: 'POST',
body: JSON.stringify({ types, order, hidden: hiddenArr, custom, positions, card_size }) body: JSON.stringify({ types, order, hidden: hiddenArr, custom, positions, card_size })
}); });
@@ -334,7 +397,7 @@ async function saveServerTileConfig(name) {
} }
function ensureUpsCard(name) { function ensureUpsCard(name) {
let card = document.getElementById(`card-${name}`); let card = getCard(name);
if (card) { if (card) {
// Ensure add metric listener attached (server-rendered cards bypass creation path) // Ensure add metric listener attached (server-rendered cards bypass creation path)
const addBtn = card.querySelector('[data-add-metric]'); const addBtn = card.querySelector('[data-add-metric]');
@@ -374,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');
@@ -382,13 +446,14 @@ 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';
} }
div.innerHTML = ` div.innerHTML = `
<div class="card-header"> <div class="card-header">
<h2>${name}</h2> <h2><span class="state-dot" data-state-dot></span>${escapeHtml(name)}</h2>
<div class="ups-connection" data-field-conn></div> <div class="ups-connection" data-field-conn></div>
${uiCfg.allow_resize ? '<button class="mode-toggle" data-mode-btn title="Toggle compact view">⇳</button>' : ''} ${uiCfg.allow_resize ? '<button class="mode-toggle" data-mode-btn title="Toggle compact view">⇳</button>' : ''}
<button class="add-metric-btn" data-add-metric title="Add metric tile">+</button> <button class="add-metric-btn" data-add-metric title="Add metric tile">+</button>
@@ -414,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') {
@@ -440,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]'));
@@ -476,7 +536,7 @@ function initTilesFor(name, grid) {
// Default cascade layout before positions applied // Default cascade layout before positions applied
tile.style.left = (10 + (idx * 20)) + 'px'; tile.style.left = (10 + (idx * 20)) + 'px';
tile.style.top = (10 + (idx * 20)) + 'px'; tile.style.top = (10 + (idx * 20)) + 'px';
tile.innerHTML = `<div class="tile-controls">${renderTileSelect(name, tileDef)}</div><h4>${tileDef.short || tileDef.label}</h4><div class="tile-body"></div>`; tile.innerHTML = `<div class="tile-controls">${renderTileSelect(name, tileDef)}</div><h4>${escapeHtml(tileDef.short || tileDef.label)}</h4><div class="tile-body"></div>`;
if (savedTileHidden[name] && savedTileHidden[name][tileDef.id]) tile.classList.add('hidden'); if (savedTileHidden[name] && savedTileHidden[name][tileDef.id]) tile.classList.add('hidden');
const resizeHandle = document.createElement('div'); const resizeHandle = document.createElement('div');
resizeHandle.className = 'tile-resize'; resizeHandle.className = 'tile-resize';
@@ -490,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);
} }
@@ -502,8 +562,8 @@ function initTilesFor(name, grid) {
function renderTileSelect(name, tileDef) { function renderTileSelect(name, tileDef) {
if (tileDef.types.length <= 1) return ''; if (tileDef.types.length <= 1) return '';
const cur = savedTileTypes[name]?.[tileDef.id] || tileDef.defaultType; const cur = savedTileTypes[name]?.[tileDef.id] || tileDef.defaultType;
return `<select data-tile-select data-name="${name}" data-tile-id="${tileDef.id}">` + return `<select data-tile-select data-name="${escapeHtml(name)}" data-tile-id="${escapeHtml(tileDef.id)}">` +
tileDef.types.map(t => `<option value="${t}" ${t===cur?'selected':''}>${t}</option>`).join('') + '</select>'; tileDef.types.map(t => `<option value="${escapeHtml(t)}" ${t===cur?'selected':''}>${escapeHtml(t)}</option>`).join('') + '</select>';
} }
function attachTileBehavior(name, tile, tileDef) { function attachTileBehavior(name, tile, tileDef) {
@@ -638,7 +698,7 @@ function buildCustomTileVisualization(name, tileEl, tileCfg) {
const ctx = document.getElementById(canvasId).getContext('2d'); const ctx = document.getElementById(canvasId).getContext('2d');
charts[canvasId] = new Chart(ctx, { charts[canvasId] = new Chart(ctx, {
type: tileCfg.chart === 'bar' ? 'bar' : 'line', type: tileCfg.chart === 'bar' ? 'bar' : 'line',
data: { labels: [], datasets: [{ label: tileCfg.metric, data: [], borderColor: '#9f7aea', backgroundColor: 'rgba(159,122,234,0.25)', tension: 0.25 }]}, data: { labels: [], datasets: [{ label: String(tileCfg.metric || ''), data: [], borderColor: '#4cc9c0', backgroundColor: 'rgba(76,201,192,0.20)', tension: 0.25 }]},
options: { animation:false, responsive:true, maintainAspectRatio:false, scales:{ y:{ beginAtZero:true } } } options: { animation:false, responsive:true, maintainAspectRatio:false, scales:{ y:{ beginAtZero:true } } }
}); });
} }
@@ -651,7 +711,10 @@ function createCustomTile(name, grid, tileCfg) {
tile.dataset.tile = `custom-${tileCfg.id}`; tile.dataset.tile = `custom-${tileCfg.id}`;
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>${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]');
@@ -662,7 +725,7 @@ function createCustomTile(name, grid, tileCfg) {
} }
function rebuildAllTiles(name) { function rebuildAllTiles(name) {
const card = document.getElementById(`card-${name}`); const card = getCard(name);
if (!card) return; if (!card) return;
const grid = card.querySelector('[data-tile-grid]'); const grid = card.querySelector('[data-tile-grid]');
if (!grid) return; if (!grid) return;
@@ -682,14 +745,15 @@ 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);
} }
function seedHistoricalData(name, tileCfg) { function seedHistoricalData(name, tileCfg) {
if (tileCfg.source !== 'history') return; if (tileCfg.source !== 'history') return;
fetch(`/api/ups/${name}/metric/${tileCfg.metric}?limit=120`).then(r=>r.json()).then(points => { fetch(apiUpsPath(name, `/metric/${encodeURIComponent(tileCfg.metric)}?limit=120`)).then(r=>r.json()).then(points => {
const canvasId = `tile-${name}-custom-${tileCfg.id}`; const canvasId = `tile-${name}-custom-${tileCfg.id}`;
if (tileCfg.chart === 'gauge') return; // gauge only shows live if (tileCfg.chart === 'gauge') return; // gauge only shows live
const c = charts[canvasId]; const c = charts[canvasId];
@@ -705,7 +769,7 @@ function loadTileHistoricalData(name, metric, canvasId) {
// For other metrics, use smaller dataset // For other metrics, use smaller dataset
const limit = metric === 'DERIVED_WATTS' ? 4320 : 60; const limit = metric === 'DERIVED_WATTS' ? 4320 : 60;
fetch(`/api/ups/${name}/metric/${metric}?limit=${limit}`).then(r=>r.json()).then(points => { fetch(apiUpsPath(name, `/metric/${encodeURIComponent(metric)}?limit=${limit}`)).then(r=>r.json()).then(points => {
const c = charts[canvasId]; const c = charts[canvasId];
if (!c) return; if (!c) return;
@@ -823,7 +887,7 @@ function openMetricModal(name) {
savedCustomTiles[name] = savedCustomTiles[name] || []; savedCustomTiles[name] = savedCustomTiles[name] || [];
savedCustomTiles[name].push(cfg); savedCustomTiles[name].push(cfg);
try { localStorage.setItem(CUSTOM_TILES_KEY, JSON.stringify(savedCustomTiles)); } catch(_) {} try { localStorage.setItem(CUSTOM_TILES_KEY, JSON.stringify(savedCustomTiles)); } catch(_) {}
const card = document.getElementById(`card-${name}`); const card = getCard(name);
const grid = card.querySelector('[data-tile-grid]'); const grid = card.querySelector('[data-tile-grid]');
createCustomTile(name, grid, cfg); createCustomTile(name, grid, cfg);
persistTileOrder(name, grid); persistTileOrder(name, grid);
@@ -936,13 +1000,17 @@ function updateStaleStatuses() {
const now = Date.now() / 1000; const now = Date.now() / 1000;
Object.entries(lastUpdateTs).forEach(([name, ts]) => { Object.entries(lastUpdateTs).forEach(([name, ts]) => {
if (now - ts > UPS_STALE_SECONDS) { if (now - ts > UPS_STALE_SECONDS) {
const card = document.getElementById(`card-${name}`); const card = getCard(name);
if (card) { if (card) {
const statusElement = card.querySelector('.ups-status'); const statusElement = card.querySelector('.ups-status');
if (statusElement) { if (statusElement) {
statusElement.textContent = 'STALE'; statusElement.textContent = 'STALE';
statusElement.className = 'ups-status unknown'; statusElement.className = 'ups-status unknown';
} }
const stateDot = card.querySelector('[data-state-dot]');
if (stateDot) {
stateDot.className = 'state-dot';
}
} }
} }
}); });
@@ -961,6 +1029,7 @@ evtSource.onmessage = (e) => {
const snapshots = payload.snapshots || {}; const snapshots = payload.snapshots || {};
const upsMeta = payload.upsMeta || []; const upsMeta = payload.upsMeta || [];
setDashboardSyncState(`Live telemetry ${new Date().toLocaleTimeString()}`, true);
// Reconcile cards: add new, remove stale // Reconcile cards: add new, remove stale
const desiredNames = new Set(upsMeta.map(m => m.name)); const desiredNames = new Set(upsMeta.map(m => m.name));
@@ -981,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)
updateEventFooter(meta.name); if (uiCfg.show_events === false) {
clearEventFooter(meta.name);
} else {
updateEventFooter(meta.name);
}
}); });
Object.entries(snapshots).forEach(([name, snap]) => { Object.entries(snapshots).forEach(([name, snap]) => {
@@ -1018,6 +1091,10 @@ evtSource.onmessage = (e) => {
statusElement.textContent = status; statusElement.textContent = status;
statusElement.className = 'ups-status ' + getStatusClass(status); statusElement.className = 'ups-status ' + getStatusClass(status);
} }
const stateDot = card.querySelector('[data-state-dot]');
if (stateDot) {
stateDot.className = 'state-dot ' + getStatusClass(status);
}
const load = parseFloat(snap['LOADPCT']) || 0; const load = parseFloat(snap['LOADPCT']) || 0;
const batt = parseFloat(snap['BCHARGE']) || 0; const batt = parseFloat(snap['BCHARGE']) || 0;
@@ -1056,7 +1133,7 @@ evtSource.onmessage = (e) => {
let metricVal; let metricVal;
try { metricVal = parseFloat(String(metricValRaw).split(/\s+/)[0]); } catch(_) { return; } try { metricVal = parseFloat(String(metricValRaw).split(/\s+/)[0]); } catch(_) { return; }
if (ct.chart === 'gauge') { if (ct.chart === 'gauge') {
const gauge = document.querySelector(`#card-${name} [data-tile-grid] .tile[data-tile="custom-${ct.id}"] [data-custom-gauge]`); const gauge = card?.querySelector(`[data-tile-grid] .tile[data-tile="custom-${ct.id}"] [data-custom-gauge]`);
if (gauge) { if (gauge) {
const fill = gauge.querySelector('.gauge-fill'); const fill = gauge.querySelector('.gauge-fill');
const text = gauge.querySelector('.gauge-text'); const text = gauge.querySelector('.gauge-text');
@@ -1121,8 +1198,8 @@ evtSource.onmessage = (e) => {
// Fetch events & energy asynchronously (tiles) // Fetch events & energy asynchronously (tiles)
upsMeta.forEach(meta => { upsMeta.forEach(meta => {
if (uiCfg.show_events === false) return; if (uiCfg.show_events === false) return;
fetch(`/api/ups/${meta.name}/events`).then(r => r.json()).then(events => { fetch(apiUpsPath(meta.name, '/events')).then(r => r.json()).then(events => {
const card = document.getElementById(`card-${meta.name}`); const card = getCard(meta.name);
if (!card) return; if (!card) return;
const list = card.querySelector('[data-events]'); const list = card.querySelector('[data-events]');
if (!list) return; if (!list) return;
@@ -1141,12 +1218,20 @@ evtSource.onmessage = (e) => {
}); });
}).catch(()=>{}); }).catch(()=>{});
if (uiCfg.show_energy) { if (uiCfg.show_energy) {
fetch(`/api/ups/${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 = document.getElementById(`card-${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(()=>{});
} }
}); });
@@ -1156,12 +1241,12 @@ evtSource.onmessage = (e) => {
async function updateEventFooter(name) { async function updateEventFooter(name) {
try { try {
const resp = await fetch(`/api/ups/${name}/events`); const resp = await fetch(apiUpsPath(name, '/events'));
if (!resp.ok) return; if (!resp.ok) return;
const events = await resp.json(); const events = await resp.json();
if (!Array.isArray(events) || events.length === 0) return; if (!Array.isArray(events) || events.length === 0) return;
const latest = events[0]; const latest = events[0];
const card = document.getElementById(`card-${name}`); const card = getCard(name);
if (!card) return; if (!card) return;
const footer = card.querySelector('[data-ups-footer]'); const footer = card.querySelector('[data-ups-footer]');
if (!footer) return; if (!footer) return;
@@ -1173,7 +1258,11 @@ async function updateEventFooter(name) {
} else { } else {
text = latest.detail || latest.msg || latest.raw || ''; text = latest.detail || latest.msg || latest.raw || '';
} }
footer.innerHTML = `<span class="event-marquee">${text}</span>`; footer.textContent = '';
const marquee = document.createElement('span');
marquee.className = 'event-marquee';
marquee.textContent = text;
footer.appendChild(marquee);
} catch {} } catch {}
} }
@@ -1289,7 +1378,7 @@ function persistTilePositions(name, grid) {
// Only grow the card if tiles overflow its current bounds. Never shrink // Only grow the card if tiles overflow its current bounds. Never shrink
// the user's manually-sized card. // the user's manually-sized card.
if (maxX > 0 && maxY > 0) { if (maxX > 0 && maxY > 0) {
const card = document.getElementById(`card-${name}`); const card = getCard(name);
if (card) { if (card) {
const neededW = maxX + 20; // 20px right padding const neededW = maxX + 20; // 20px right padding
const neededH = maxY + 70; // 70px for header + bottom padding const neededH = maxY + 70; // 70px for header + bottom padding
@@ -1470,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);
@@ -1502,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='';
@@ -1521,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"]'); persistTilePositions(name, grid);
const outTile = grid.querySelector('.tile[data-tile="volt_output"]'); } else {
const wattsTile = grid.querySelector('.tile[data-tile="watts_usage"]'); scheduleServerTileSave(name);
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); setTimeout(()=>{ Object.values(charts).forEach(ch=>{ try{ ch.resize(); }catch{} }); }, 50);
persistTilePositions(name, grid);
scheduleServerTileSave(name);
} }
}); });
}); });
+12 -1
View File
@@ -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),
+11 -3
View File
@@ -5,13 +5,21 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<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=4" /> <link rel="stylesheet" href="/static/css/base.css?v=5" />
<link rel="stylesheet" href="/static/css/style.css?v=4" /> <link rel="stylesheet" href="/static/css/style.css?v=6" />
{% block head_extra %}{% endblock %} {% block head_extra %}{% endblock %}
</head> </head>
<body> <body>
<header class="site-header"> <header class="site-header">
<div class="brand"><a href="/">⚡ APC UPS Dashboard</a></div> <div class="brand">
<a href="/">
<span class="brand-mark" aria-hidden="true">APC</span>
<span class="brand-copy">
<span class="brand-title">UPS Dashboard</span>
<span class="brand-subtitle">Power continuity</span>
</span>
</a>
</div>
<nav class="main-nav"> <nav class="main-nav">
<a href="/" class="{% if active_nav == 'dashboard' %}active{% endif %}">Dashboard</a> <a href="/" class="{% if active_nav == 'dashboard' %}active{% endif %}">Dashboard</a>
<a href="/events" class="{% if active_nav == 'events' %}active{% endif %}">Events</a> <a href="/events" class="{% if active_nav == 'events' %}active{% endif %}">Events</a>
+12 -1
View File
@@ -4,6 +4,17 @@
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<section class="dashboard-header" aria-labelledby="dashboard-title">
<div>
<p class="eyebrow">Live fleet status</p>
<h1 id="dashboard-title">Power continuity dashboard</h1>
</div>
<div class="dashboard-clock" aria-live="polite">
<span class="pulse-dot" aria-hidden="true"></span>
<span id="dashboard-sync-state">Waiting for UPS telemetry</span>
</div>
</section>
<section class="fleet-overview" id="fleet-overview"> <section class="fleet-overview" id="fleet-overview">
<div class="fleet-card"> <div class="fleet-card">
<div class="fleet-stat"> <div class="fleet-stat">
@@ -50,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 %}
+4 -23
View File
@@ -8,8 +8,10 @@
<link rel="stylesheet" href="/static/css/login.css" /> <link rel="stylesheet" href="/static/css/login.css" />
</head> </head>
<body class="auth-body"> <body class="auth-body">
<div class="auth-card"> <div class="auth-card" data-auth-card="login">
<div class="auth-mark">APC</div>
<h1>UPS Dashboard</h1> <h1>UPS Dashboard</h1>
<p class="muted">Sign in to monitor runtime, load, transfers, and alerts.</p>
<form id="login-form"> <form id="login-form">
<div class="form-field"> <div class="form-field">
<label for="username">Username</label> <label for="username">Username</label>
@@ -23,27 +25,6 @@
<div id="login-error" class="login-error" hidden></div> <div id="login-error" class="login-error" hidden></div>
</form> </form>
</div> </div>
<script> <script src="/static/js/auth.js?v=1"></script>
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const errBox = document.getElementById('login-error');
errBox.hidden = true;
const resp = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: document.getElementById('username').value,
password: document.getElementById('password').value,
}),
});
if (resp.ok) {
window.location.href = '/';
} else {
const err = await resp.json().catch(() => ({}));
errBox.textContent = err.detail || 'Login failed';
errBox.hidden = false;
}
});
</script>
</body> </body>
</html> </html>
+30 -2
View File
@@ -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 %}
+5 -32
View File
@@ -8,9 +8,10 @@
<link rel="stylesheet" href="/static/css/login.css" /> <link rel="stylesheet" href="/static/css/login.css" />
</head> </head>
<body class="auth-body"> <body class="auth-body">
<div class="auth-card wide"> <div class="auth-card wide" data-auth-card="setup">
<div class="auth-mark">APC</div>
<h1>First-run Setup</h1> <h1>First-run Setup</h1>
<p class="muted">Create the admin account. Passwords must be at least 8 characters.</p> <p class="muted">Create the admin account before exposing UPS controls.</p>
<form id="setup-form"> <form id="setup-form">
<div class="form-field"> <div class="form-field">
<label for="username">Admin username</label> <label for="username">Admin username</label>
@@ -27,36 +28,8 @@
<button type="submit" class="btn-primary btn-block">Create admin account</button> <button type="submit" class="btn-primary btn-block">Create admin account</button>
<div id="setup-error" class="login-error" hidden></div> <div id="setup-error" class="login-error" hidden></div>
</form> </form>
<p class="muted" style="margin-top:16px;">After setup you'll be redirected to the dashboard. Configure UPS devices and SMTP from the nav.</p> <p class="muted auth-note">After setup you will be redirected to the dashboard. Configure UPS devices and SMTP from the nav.</p>
</div> </div>
<script> <script src="/static/js/auth.js?v=1"></script>
document.getElementById('setup-form').addEventListener('submit', async (e) => {
e.preventDefault();
const errBox = document.getElementById('setup-error');
errBox.hidden = true;
const pw = document.getElementById('password').value;
const pw2 = document.getElementById('password2').value;
if (pw !== pw2) {
errBox.textContent = 'Passwords do not match';
errBox.hidden = false;
return;
}
const resp = await fetch('/api/setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: document.getElementById('username').value,
password: pw,
}),
});
if (resp.ok) {
window.location.href = '/';
} else {
const err = await resp.json().catch(() => ({}));
errBox.textContent = err.detail || 'Setup failed';
errBox.hidden = false;
}
});
</script>
</body> </body>
</html> </html>
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v2
name: apcupsd-client
description: FastAPI + Redis UPS monitoring dashboard
type: application
version: 1.0.0
appVersion: "1.0.0"
+110
View File
@@ -0,0 +1,110 @@
# Helm Deployment Setup
## Prerequisites
- helm v3
- sops
- age (for key generation)
- kubectl with cluster access
## 1. Install helm-secrets plugin
The plugin must register as `getter/v1` type so it works via the `secrets://` protocol handler.
```bash
# Install helm-secrets as a getter/downloader plugin
helm plugin install https://github.com/jkroepke/helm-secrets --verify=false
```
Verify it registered correctly:
```bash
helm plugin list
```
Expected output:
```
NAME VERSION TYPE ...
secrets 4.x.x getter/v1 ...
```
The `getter/v1` type means helm-secrets acts as a protocol handler.
Use `secrets://` prefix on encrypted value files instead of `helm secrets <command>`:
```bash
# Correct usage with getter/v1
helm install my-release ./chart -f values.yaml -f secrets://values-secret.yaml
# NOT: helm secrets install ... (that requires wrapper type, not getter)
```
## 2. Generate an age key
```bash
age-keygen -o ~/.config/sops/age/keys.txt
```
Note the public key from the output (starts with `age1...`).
## 3. Configure sops
Create `.sops.yaml` in the repo root:
```yaml
creation_rules:
- path_regex: values-secret\.yaml$
age: age1yourpublickeyhere
```
## 4. Create values files
```bash
cp chart/values.yaml.example chart/values.yaml
cp chart/values-secret.yaml.example chart/values-secret.yaml
```
Edit `chart/values.yaml` with your environment config (non-sensitive).
Edit `chart/values-secret.yaml` with sensitive values:
```bash
# Generate a session secret
python -c "import secrets; print(secrets.token_urlsafe(32))"
# Generate an admin password hash
python -c "from passlib.hash import argon2; print(argon2.hash('yourpassword'))"
```
## 5. Encrypt secrets
```bash
sops -e -i chart/values-secret.yaml
```
To edit later:
```bash
sops chart/values-secret.yaml # opens in $EDITOR, re-encrypts on save
```
Or decrypt/encrypt in place:
```bash
sops -d -i chart/values-secret.yaml # decrypt
# edit the file
sops -e -i chart/values-secret.yaml # re-encrypt
```
## 6. Deploy
```bash
./deploy.sh install # first time
./deploy.sh upgrade # subsequent deploys
./deploy.sh diff # preview changes (requires helm-diff plugin)
./deploy.sh template # render manifests locally
./deploy.sh status # show running pods/services
./deploy.sh logs # tail app logs
./deploy.sh restart # rolling restart
./deploy.sh destroy # uninstall
```
+20
View File
@@ -0,0 +1,20 @@
{{- define "app.labels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/part-of: {{ .Chart.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}
{{- define "app.selectorLabels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
{{- end -}}
{{- define "redis.labels" -}}
app.kubernetes.io/name: redis
app.kubernetes.io/part-of: {{ .Chart.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}
{{- define "redis.selectorLabels" -}}
app.kubernetes.io/name: redis
{{- end -}}
+11
View File
@@ -0,0 +1,11 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ .Chart.Name }}-config
namespace: {{ .Values.namespace }}
labels:
{{- include "app.labels" . | nindent 4 }}
data:
{{- range $key, $val := .Values.config }}
{{ $key }}: {{ $val | quote }}
{{- end }}
+70
View File
@@ -0,0 +1,70 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Chart.Name }}
namespace: {{ .Values.namespace }}
labels:
{{- include "app.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.app.replicas }}
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
{{- include "app.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "app.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.app.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
securityContext:
runAsUser: {{ .Values.app.securityContext.runAsUser }}
runAsGroup: {{ .Values.app.securityContext.runAsGroup }}
runAsNonRoot: true
fsGroup: {{ .Values.app.securityContext.runAsGroup }}
containers:
- name: web
image: {{ .Values.app.image | quote }}
imagePullPolicy: {{ .Values.app.imagePullPolicy | default "Always" }}
ports:
- containerPort: {{ .Values.app.port }}
protocol: TCP
envFrom:
- configMapRef:
name: {{ .Chart.Name }}-config
- secretRef:
name: {{ .Chart.Name }}-secret
livenessProbe:
httpGet:
path: {{ .Values.app.probes.liveness.path }}
port: {{ .Values.app.port }}
initialDelaySeconds: {{ .Values.app.probes.liveness.initialDelaySeconds }}
periodSeconds: {{ .Values.app.probes.liveness.periodSeconds }}
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: {{ .Values.app.probes.readiness.path }}
port: {{ .Values.app.port }}
initialDelaySeconds: {{ .Values.app.probes.readiness.initialDelaySeconds }}
periodSeconds: {{ .Values.app.probes.readiness.periodSeconds }}
timeoutSeconds: 5
failureThreshold: 3
resources:
{{- toYaml .Values.app.resources | nindent 12 }}
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
+18
View File
@@ -0,0 +1,18 @@
{{- if .Values.redis.enabled }}
apiVersion: v1
kind: Service
metadata:
name: redis
namespace: {{ .Values.namespace }}
labels:
{{- include "redis.labels" . | nindent 4 }}
spec:
type: ClusterIP
selector:
{{- include "redis.selectorLabels" . | nindent 4 }}
ports:
- name: redis
protocol: TCP
port: {{ .Values.redis.port }}
targetPort: {{ .Values.redis.port }}
{{- end }}
@@ -1,29 +1,29 @@
{{- if .Values.redis.enabled }}
apiVersion: apps/v1 apiVersion: apps/v1
kind: StatefulSet kind: StatefulSet
metadata: metadata:
name: redis name: redis
namespace: apcupsd namespace: {{ .Values.namespace }}
labels: labels:
app.kubernetes.io/name: redis {{- include "redis.labels" . | nindent 4 }}
app.kubernetes.io/part-of: apcupsd-client
spec: spec:
serviceName: redis serviceName: redis
replicas: 1 replicas: 1
selector: selector:
matchLabels: matchLabels:
app.kubernetes.io/name: redis {{- include "redis.selectorLabels" . | nindent 6 }}
template: template:
metadata: metadata:
labels: labels:
app.kubernetes.io/name: redis {{- include "redis.selectorLabels" . | nindent 8 }}
spec: spec:
securityContext: securityContext:
runAsUser: 999 runAsUser: {{ .Values.redis.securityContext.runAsUser }}
runAsGroup: 999 runAsGroup: {{ .Values.redis.securityContext.runAsGroup }}
fsGroup: 999 fsGroup: {{ .Values.redis.securityContext.runAsGroup }}
containers: containers:
- name: redis - name: redis
image: redis:7-alpine image: {{ .Values.redis.image }}
command: command:
- redis-server - redis-server
- --appendonly - --appendonly
@@ -31,7 +31,7 @@ spec:
- --appendfsync - --appendfsync
- everysec - everysec
ports: ports:
- containerPort: 6379 - containerPort: {{ .Values.redis.port }}
protocol: TCP protocol: TCP
livenessProbe: livenessProbe:
exec: exec:
@@ -52,12 +52,7 @@ spec:
timeoutSeconds: 3 timeoutSeconds: 3
failureThreshold: 3 failureThreshold: 3
resources: resources:
requests: {{- toYaml .Values.redis.resources | nindent 12 }}
memory: "64Mi"
cpu: "50m"
limits:
memory: "128Mi"
cpu: "250m"
volumeMounts: volumeMounts:
- name: redis-data - name: redis-data
mountPath: /data mountPath: /data
@@ -69,4 +64,5 @@ spec:
- ReadWriteOnce - ReadWriteOnce
resources: resources:
requests: requests:
storage: 1Gi storage: {{ .Values.redis.storage }}
{{- end }}
+12
View File
@@ -0,0 +1,12 @@
apiVersion: v1
kind: Secret
metadata:
name: {{ .Chart.Name }}-secret
namespace: {{ .Values.namespace }}
labels:
{{- include "app.labels" . | nindent 4 }}
type: Opaque
stringData:
{{- range $key, $val := .Values.secrets }}
{{ $key }}: {{ $val | quote }}
{{- end }}
+16
View File
@@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: {{ .Chart.Name }}
namespace: {{ .Values.namespace }}
labels:
{{- include "app.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
selector:
{{- include "app.selectorLabels" . | nindent 4 }}
ports:
- name: http
protocol: TCP
port: {{ .Values.service.port }}
targetPort: {{ .Values.app.port }}
+7
View File
@@ -0,0 +1,7 @@
secrets:
# Generate with: python -c "import secrets; print(secrets.token_urlsafe(32))"
SESSION_SECRET: ""
# Generate with: python -c "from passlib.hash import argon2; print(argon2.hash('yourpassword'))"
ADMIN_PASSWORD_HASH: ""
# Optional SMTP password
SMTP_PASSWORD: ""
+58
View File
@@ -0,0 +1,58 @@
namespace: apcupsd
app:
image: ghcr.io/k2patel/apcupsd-client:latest
imagePullPolicy: IfNotPresent
imagePullSecrets: []
replicas: 1
port: 8000
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "500m"
securityContext:
runAsUser: 10001
runAsGroup: 10001
probes:
liveness:
path: /healthz
initialDelaySeconds: 15
periodSeconds: 30
readiness:
path: /readyz
initialDelaySeconds: 5
periodSeconds: 10
service:
type: LoadBalancer
port: 80
redis:
enabled: true
image: redis:7-alpine
port: 6379
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "128Mi"
cpu: "250m"
storage: 1Gi
securityContext:
runAsUser: 999
runAsGroup: 999
config:
ADMIN_USERNAME: "admin"
REDIS_URL: "redis://redis:6379/0"
ALLOW_PRIVATE_IPS: "true"
TRUST_PROXY: "false"
LOG_LEVEL: "INFO"
RATE_LIMIT_ENABLED: "true"
SESSION_MAX_AGE_SECONDS: "1209600"
TZ: "UTC"
UPS_CONFIG_PATH: "/config/ups.yaml"
-21
View File
@@ -1,21 +0,0 @@
ups:
- name: ups1
host: 192.168.1.10
port: 3551
interval_seconds: 30
alert_loadpct_high: 80
alert_bcharge_low: 40
alert_on_battery: true
alert_runtime_low_minutes: 10
smtp:
host: 192.168.1.50
port: 25
username: ""
password: "" # or set env SMTP_PASSWORD
use_tls: false
use_ssl: false
from_addr: ups-monitor@example.local
to_addrs:
- admin@example.local
subject_prefix: "[UPS]"
Executable
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
set -euo pipefail
# Deploy apcupsd-client via Helm with sops-encrypted secrets
# Usage: ./deploy.sh [install|upgrade|diff|destroy|status|logs|restart]
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CHART_DIR="${SCRIPT_DIR}/chart"
RELEASE="apcupsd-client"
NAMESPACE="apcupsd"
VALUES="${CHART_DIR}/values.yaml"
SECRETS="secrets://${CHART_DIR}/values-secret.yaml"
ACTION="${1:-upgrade}"
# Preflight checks
for cmd in helm sops; do
if ! command -v "$cmd" &>/dev/null; then
echo "ERROR: $cmd not found in PATH" >&2
exit 1
fi
done
# Verify helm-secrets plugin is available
if ! helm plugin list | grep -q secrets; then
echo "ERROR: helm-secrets plugin not installed. Install with: helm plugin install https://github.com/jkroepke/helm-secrets" >&2
exit 1
fi
# Check if secrets file exists
if [[ ! -f "${CHART_DIR}/values-secret.yaml" ]]; then
echo "ERROR: values-secret.yaml not found. Copy values-secret.yaml.example and encrypt with sops." >&2
exit 1
fi
case "$ACTION" in
install)
echo "--- Installing ${RELEASE} ---"
helm install "$RELEASE" "$CHART_DIR" \
-n "$NAMESPACE" --create-namespace \
-f "$VALUES" \
-f "$SECRETS"
echo "--- Install complete ---"
kubectl -n "$NAMESPACE" get pods
;;
upgrade)
echo "--- Upgrading ${RELEASE} ---"
helm upgrade "$RELEASE" "$CHART_DIR" \
-n "$NAMESPACE" --create-namespace --install \
-f "$VALUES" \
-f "$SECRETS"
echo "--- Upgrade complete ---"
kubectl -n "$NAMESPACE" get pods
;;
diff)
if ! helm plugin list | grep -q diff; then
echo "ERROR: helm-diff plugin not installed. Install with: helm plugin install https://github.com/databus23/helm-diff" >&2
exit 1
fi
helm diff upgrade "$RELEASE" "$CHART_DIR" \
-n "$NAMESPACE" \
-f "$VALUES" \
-f "$SECRETS" || true
;;
template)
helm template "$RELEASE" "$CHART_DIR" \
-n "$NAMESPACE" \
-f "$VALUES" \
-f "$SECRETS"
;;
destroy)
echo "--- Uninstalling ${RELEASE} ---"
helm uninstall "$RELEASE" -n "$NAMESPACE" --ignore-not-found
echo "--- Destroyed ---"
;;
status)
kubectl -n "$NAMESPACE" get all
;;
logs)
kubectl -n "$NAMESPACE" logs -l app.kubernetes.io/name=apcupsd-client -f --tail=50
;;
restart)
kubectl -n "$NAMESPACE" rollout restart deployment/apcupsd-client
kubectl -n "$NAMESPACE" rollout status deployment/apcupsd-client --timeout=120s
;;
*)
echo "Usage: $0 {install|upgrade|diff|template|destroy|status|logs|restart}" >&2
exit 1
;;
esac
-69
View File
@@ -1,69 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: apcupsd-client
namespace: apcupsd
labels:
app.kubernetes.io/name: apcupsd-client
app.kubernetes.io/part-of: apcupsd-client
spec:
replicas: 1
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
selector:
matchLabels:
app.kubernetes.io/name: apcupsd-client
template:
metadata:
labels:
app.kubernetes.io/name: apcupsd-client
spec:
securityContext:
runAsUser: 10001
runAsGroup: 10001
runAsNonRoot: true
fsGroup: 10001
containers:
- name: web
image: ghcr.io/k2patel/apcupsd-client:latest
ports:
- containerPort: 8000
protocol: TCP
envFrom:
- secretRef:
name: apcupsd-client-secret
livenessProbe:
httpGet:
path: /healthz
port: 8000
initialDelaySeconds: 15
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /readyz
port: 8000
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "500m"
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
volumeMounts:
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
-10
View File
@@ -1,10 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- secret.yaml
- redis-statefulset.yaml
- redis-service.yaml
- deployment.yaml
- service.yaml
-6
View File
@@ -1,6 +0,0 @@
apiVersion: v1
kind: Namespace
metadata:
name: apcupsd
labels:
app.kubernetes.io/part-of: apcupsd-client
-17
View File
@@ -1,17 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: redis
namespace: apcupsd
labels:
app.kubernetes.io/name: redis
app.kubernetes.io/part-of: apcupsd-client
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: redis
ports:
- name: redis
protocol: TCP
port: 6379
targetPort: 6379
-31
View File
@@ -1,31 +0,0 @@
apiVersion: v1
kind: Secret
metadata:
name: apcupsd-client-secret
namespace: apcupsd
labels:
app.kubernetes.io/name: apcupsd-client
app.kubernetes.io/part-of: apcupsd-client
type: Opaque
stringData:
# Auth (required in production)
# Generate with: python -c "import secrets; print(secrets.token_urlsafe(32))"
SESSION_SECRET: ""
ADMIN_USERNAME: "admin"
# Generate with: python -c "from passlib.hash import argon2; print(argon2.hash('yourpassword'))"
ADMIN_PASSWORD_HASH: ""
SESSION_MAX_AGE_SECONDS: "1209600"
TRUST_PROXY: "false"
# Redis
REDIS_URL: "redis://redis:6379/0"
# SMTP (optional)
SMTP_PASSWORD: ""
# Network
ALLOW_PRIVATE_IPS: "true"
# Observability
LOG_LEVEL: "INFO"
RATE_LIMIT_ENABLED: "true"
# Locale
TZ: "UTC"
# Legacy migration (unused unless /config is mounted)
UPS_CONFIG_PATH: "/config/ups.yaml"
-17
View File
@@ -1,17 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: apcupsd-client
namespace: apcupsd
labels:
app.kubernetes.io/name: apcupsd-client
app.kubernetes.io/part-of: apcupsd-client
spec:
type: LoadBalancer
selector:
app.kubernetes.io/name: apcupsd-client
ports:
- name: http
protocol: TCP
port: 10280
targetPort: 8000
+70
View File
@@ -0,0 +1,70 @@
package:
name: apcupsd-client-build
version: 1.0.0
epoch: 0
description: Build stage for apcupsd-client dev image (not installed directly)
copyright:
- license: MIT
environment:
contents:
repositories:
- https://dl-cdn.alpinelinux.org/alpine/v3.21/main
- https://dl-cdn.alpinelinux.org/alpine/v3.21/community
packages:
- alpine-baselayout
- busybox
- python3
- py3-pip
- py3-virtualenv
pipeline:
- name: Create virtualenv and install dependencies
runs: |
set -ex
mkdir -p "${{targets.destdir}}/app"
python3 -m virtualenv "${{targets.destdir}}/app/.venv"
"${{targets.destdir}}/app/.venv/bin/pip" install \
--no-cache-dir \
--only-binary :all: \
--require-hashes \
-r requirements.dev.txt
- name: Copy application source
runs: |
set -ex
cp -r app/ "${{targets.destdir}}/app/app/"
- name: Fix virtualenv shebangs
runs: |
set -ex
find "${{targets.destdir}}/app/.venv/bin" -type f -exec \
sed -i "s|${{targets.destdir}}||g" {} +
- name: Strip build artifacts from virtualenv
runs: |
set -ex
rm -rf "${{targets.destdir}}/app/.venv/bin/pip"*
rm -rf "${{targets.destdir}}/app/.venv/bin/wheel"*
rm -rf "${{targets.destdir}}/app/.venv/lib/python*/site-packages/pip"
rm -rf "${{targets.destdir}}/app/.venv/lib/python*/site-packages/pip-*"
rm -rf "${{targets.destdir}}/app/.venv/lib/python*/site-packages/setuptools"
rm -rf "${{targets.destdir}}/app/.venv/lib/python*/site-packages/setuptools-*"
rm -rf "${{targets.destdir}}/app/.venv/lib/python*/site-packages/wheel"
rm -rf "${{targets.destdir}}/app/.venv/lib/python*/site-packages/wheel-*"
find "${{targets.destdir}}/app/.venv" -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true
find "${{targets.destdir}}/app/.venv" -name "*.pyc" -delete 2>/dev/null || true
subpackages:
- name: apcupsd-client
description: FastAPI + Redis UPS monitoring dashboard with dev dependencies
options:
no-depends: true
dependencies:
runtime:
- python3
pipeline:
- runs: |
set -ex
mkdir -p "${{targets.subpkgdir}}"
mv "${{targets.destdir}}/app" "${{targets.subpkgdir}}/app"
+2 -1
View File
@@ -27,7 +27,8 @@ pipeline:
"${{targets.destdir}}/app/.venv/bin/pip" install \ "${{targets.destdir}}/app/.venv/bin/pip" install \
--no-cache-dir \ --no-cache-dir \
--only-binary :all: \ --only-binary :all: \
-r requirements.prod.txt --require-hashes \
-r requirements.txt
- name: Copy application source - name: Copy application source
runs: | runs: |
+2 -2
View File
@@ -2,7 +2,7 @@
name = "apcupsd-client" name = "apcupsd-client"
version = "0.2.0" version = "0.2.0"
description = "FastAPI + Redis dashboard for monitoring multiple APC UPS devices via apcupsd NIS" description = "FastAPI + Redis dashboard for monitoring multiple APC UPS devices via apcupsd NIS"
requires-python = ">=3.12" requires-python = ">=3.14"
readme = "README.md" readme = "README.md"
license = { text = "MIT" } license = { text = "MIT" }
@@ -17,7 +17,7 @@ filterwarnings = [
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100
target-version = "py312" target-version = "py314"
extend-exclude = [".venv", "app/static"] extend-exclude = [".venv", "app/static"]
[tool.ruff.lint] [tool.ruff.lint]
+9
View File
@@ -0,0 +1,9 @@
-r requirements.in
# Dev / test dependencies
coverage
pytest
pytest-asyncio
httpx
fakeredis
ruff
+1161
View File
File diff suppressed because it is too large. Load diff
+15
View File
@@ -0,0 +1,15 @@
fastapi
uvicorn[standard]
pyyaml
redis
pydantic
pydantic-settings
jinja2
python-multipart
orjson
passlib[argon2]
itsdangerous
slowapi
python-json-logger
tenacity
prometheus-client
-15
View File
@@ -1,15 +0,0 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
pyyaml==6.0.2
redis==5.0.7
pydantic==2.9.2
pydantic-settings==2.5.2
jinja2==3.1.4
python-multipart==0.0.9
orjson==3.10.7
passlib[argon2]==1.7.4
itsdangerous==2.2.0
slowapi==0.1.9
python-json-logger==2.0.7
tenacity==9.0.0
prometheus-client==0.21.0
+980 -22
View File
File diff suppressed because it is too large. Load diff
+51
View File
@@ -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")
+13
View File
@@ -52,6 +52,19 @@ def test_login_rejects_before_setup(app_client, fake_redis):
assert r.status_code == 409 assert r.status_code == 409
def test_login_page_renders_when_configured(app_client, fake_redis):
store_admin("admin", "testpassword123")
r = app_client.get("/login")
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
def test_setup_page_renders_before_configured(app_client, fake_redis):
r = app_client.get("/setup")
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
def test_login_happy_path(app_client, fake_redis): def test_login_happy_path(app_client, fake_redis):
store_admin("admin", "testpassword123") store_admin("admin", "testpassword123")
r = app_client.post( r = app_client.post(
+31 -1
View File
@@ -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
+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