Improve UI security and deploy workflow

This commit is contained in:
Ketan Patel committed 2026-06-19 18:04:59 -04:00
1 parent be7762e369
commit db9c4054a5
13 files changed
+435 -129

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 --object-format=sha256 .
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
+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}
+7 -1
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
+9 -2
View File
@@ -12,14 +12,16 @@ from .settings import settings
CSP_DIRECTIVES = ( CSP_DIRECTIVES = (
"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'; "
"upgrade-insecure-requests"
) )
@@ -33,6 +35,11 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
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; }
} }
+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'));
}
});
}
})();
+67 -29
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);
@@ -142,7 +167,7 @@ function toggleTileSelection(tile, additive) {
// --- 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 +189,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;
@@ -276,7 +301,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
@@ -300,7 +325,7 @@ function persistCardSize(name, width, height) {
} }
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;
@@ -326,7 +351,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 +359,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]');
@@ -388,7 +413,7 @@ function ensureUpsCard(name) {
} }
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>
@@ -476,7 +501,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';
@@ -502,8 +527,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 +663,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 +676,7 @@ 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>`;
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 +687,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;
@@ -689,7 +714,7 @@ function removeCustomTile(name, id, tileEl) {
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 +730,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 +848,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 +961,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 +990,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));
@@ -1018,6 +1048,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 +1090,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 +1155,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,9 +1175,9 @@ 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; if (!data || data.kwh_today == null) return;
const card = document.getElementById(`card-${meta.name}`); const card = getCard(meta.name);
if (!card) return; if (!card) return;
const energyVal = card.querySelector('[data-energy-val]'); const energyVal = card.querySelector('[data-energy-val]');
if (energyVal) energyVal.textContent = data.kwh_today.toFixed(2) + ' kWh'; if (energyVal) energyVal.textContent = data.kwh_today.toFixed(2) + ' kWh';
@@ -1156,12 +1190,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 +1207,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 +1327,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
+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=5" />
{% 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>
+11
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">
+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>
+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 -1
View File
@@ -20,6 +20,10 @@ spec:
labels: labels:
{{- include "app.selectorLabels" . | nindent 8 }} {{- include "app.selectorLabels" . | nindent 8 }}
spec: spec:
{{- with .Values.app.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
securityContext: securityContext:
runAsUser: {{ .Values.app.securityContext.runAsUser }} runAsUser: {{ .Values.app.securityContext.runAsUser }}
runAsGroup: {{ .Values.app.securityContext.runAsGroup }} runAsGroup: {{ .Values.app.securityContext.runAsGroup }}
@@ -27,7 +31,8 @@ spec:
fsGroup: {{ .Values.app.securityContext.runAsGroup }} fsGroup: {{ .Values.app.securityContext.runAsGroup }}
containers: containers:
- name: web - name: web
image: {{ .Values.app.image }} image: {{ .Values.app.image | quote }}
imagePullPolicy: {{ .Values.app.imagePullPolicy | default "Always" }}
ports: ports:
- containerPort: {{ .Values.app.port }} - containerPort: {{ .Values.app.port }}
protocol: TCP protocol: TCP
+2
View File
@@ -2,6 +2,8 @@ namespace: apcupsd
app: app:
image: ghcr.io/k2patel/apcupsd-client:latest image: ghcr.io/k2patel/apcupsd-client:latest
imagePullPolicy: IfNotPresent
imagePullSecrets: []
replicas: 1 replicas: 1
port: 8000 port: 8000
resources: resources: