diff --git a/.gitea/workflows/build-and-deploy.yml b/.gitea/workflows/build-and-deploy.yml new file mode 100644 index 0000000..5e7385a --- /dev/null +++ b/.gitea/workflows/build-and-deploy.yml @@ -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 diff --git a/app/routes/api_auth.py b/app/routes/api_auth.py index e077b91..bf82c86 100644 --- a/app/routes/api_auth.py +++ b/app/routes/api_auth.py @@ -1,13 +1,14 @@ """Authentication endpoints: login, logout, first-run setup.""" import re -from fastapi import APIRouter, HTTPException, Request, Response +from fastapi import APIRouter, Depends, HTTPException, Request, Response from pydantic import BaseModel, Field from ..auth import ( clear_auth_cookies, get_stored_admin, is_admin_configured, + require_session_and_csrf, set_auth_cookies, store_admin, verify_password, @@ -43,7 +44,7 @@ async def api_login(request: Request, response: Response, payload: LoginRequest) @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) return {"ok": True} diff --git a/app/routes/pages.py b/app/routes/pages.py index 7bb64c4..1bb3418 100644 --- a/app/routes/pages.py +++ b/app/routes/pages.py @@ -5,6 +5,7 @@ from fastapi.templating import Jinja2Templates from ..auth import CSRF_COOKIE, current_user, is_admin_configured, make_csrf_token from ..config import load_config +from ..settings import settings router = APIRouter() templates = Jinja2Templates(directory="app/templates") @@ -15,7 +16,12 @@ def _ensure_csrf(request: Request, response): if not token: token = make_csrf_token() 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 diff --git a/app/security.py b/app/security.py index e8825c0..6634fd9 100644 --- a/app/security.py +++ b/app/security.py @@ -12,14 +12,16 @@ from .settings import settings CSP_DIRECTIVES = ( "default-src 'self'; " - "script-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'; " + "script-src 'self' https://cdn.jsdelivr.net; " "style-src 'self' 'unsafe-inline'; " "img-src 'self' data:; " "font-src 'self' data:; " "connect-src 'self'; " "base-uri '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("Referrer-Policy", "same-origin") 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: response.headers.setdefault( "Strict-Transport-Security", diff --git a/app/static/css/base.css b/app/static/css/base.css index a5bd24e..535a9b3 100644 --- a/app/static/css/base.css +++ b/app/static/css/base.css @@ -1,34 +1,34 @@ /* ======================================================================== - Design Tokens — dark theme (slate/indigo) + Design Tokens — power operations console ======================================================================== */ :root { /* Surfaces */ - --bg: #0b1220; - --surface: #131c2e; - --surface-2: #1a2540; - --surface-3: #223052; - --border: #263654; - --border-hi: #36507d; + --bg: #0d0f12; + --surface: #15191e; + --surface-2: #1d2329; + --surface-3: #273039; + --border: #303942; + --border-hi: #50606e; /* Text */ - --text: #e6edf7; - --text-muted: #9fb0cb; - --text-dim: #6c7fa2; + --text: #f2f4ef; + --text-muted: #b6c0b9; + --text-dim: #7f8b88; /* Brand / accent */ - --accent: #60a5fa; - --accent-hover: #3b82f6; - --accent-dim: #1e3a8a; + --accent: #f4b860; + --accent-hover: #d9922e; + --accent-dim: #49331a; /* Semantic */ - --success: #10b981; - --success-bg: #053b2d; - --warning: #f59e0b; - --warning-bg: #3a2608; - --danger: #ef4444; - --danger-bg: #3b0f0f; - --info: #38bdf8; - --info-bg: #052b3a; + --success: #38d07a; + --success-bg: #102f21; + --warning: #f4b860; + --warning-bg: #3d2a12; + --danger: #ff5a5f; + --danger-bg: #3e181b; + --info: #4cc9c0; + --info-bg: #102f31; /* Elevation */ --shadow-sm: 0 1px 2px rgba(0,0,0,.3); @@ -38,7 +38,7 @@ /* Radii & spacing */ --radius-sm: 4px; --radius-md: 6px; - --radius-lg: 10px; + --radius-lg: 8px; /* Typography */ --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; @@ -55,7 +55,10 @@ body { font-family: var(--font); font-size: 14px; 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); -webkit-font-smoothing: antialiased; } @@ -79,20 +82,49 @@ code, pre { font-family: var(--font-mono); } display: flex; align-items: center; gap: 24px; - padding: 10px 24px; - background: var(--surface); + padding: 12px 24px; + background: rgba(21, 25, 30, .94); border-bottom: 1px solid var(--border); box-shadow: var(--shadow-sm); position: sticky; top: 0; z-index: 100; + backdrop-filter: blur(12px); } .site-header .brand a { color: var(--text); - font-weight: 600; - font-size: 16px; + display: inline-flex; + 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 { display: flex; @@ -114,8 +146,9 @@ code, pre { font-family: var(--font-mono); } text-decoration: none; } .main-nav a.active { - background: var(--accent-dim); - color: var(--text); + background: rgba(244, 184, 96, .12); + color: var(--accent); + box-shadow: inset 0 0 0 1px rgba(244, 184, 96, .25); } .user-menu { display: flex; align-items: center; gap: 10px; } @@ -142,7 +175,7 @@ code, pre { font-family: var(--font-mono); } 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 @@ -157,6 +190,56 @@ code, pre { font-family: var(--font-mono); } } .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 select, .filters input { padding: 7px 10px; @@ -377,16 +460,24 @@ code, pre { font-family: var(--font-mono); } ======================================================================== */ .fleet-overview { margin-bottom: 20px; } .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-radius: var(--radius-md); - padding: 18px 22px; + padding: 18px 20px; display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); - gap: 20px; + gap: 0; 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 { color: var(--text-dim); 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; } .main-nav { width: 100%; } .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; } } diff --git a/app/static/js/auth.js b/app/static/js/auth.js new file mode 100644 index 0000000..a7dc456 --- /dev/null +++ b/app/static/js/auth.js @@ -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')); + } + }); + } +})(); diff --git a/app/static/js/dashboard.js b/app/static/js/dashboard.js index b6254ce..0db4df9 100644 --- a/app/static/js/dashboard.js +++ b/app/static/js/dashboard.js @@ -1,6 +1,6 @@ // Debug configuration 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 function debugLog(...args) { @@ -39,6 +39,31 @@ window.upsDebug = { const evtSource = new EventSource('/api/stream'); const charts = {}; +function escapeHtml(value) { + return String(value ?? '').replace(/[&<>"']/g, (ch) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }[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 -------- async function refreshFleetOverview() { try { @@ -53,7 +78,7 @@ async function refreshFleetOverview() { setText('fleet-watts', d.total_watts != null ? Math.round(d.total_watts) : '—'); const minRT = d.min_timeleft_minutes; setText('fleet-min-runtime', minRT != null ? `${Math.round(minRT)} m` : '—'); - } catch (e) { /* silent */ } + } catch (e) { setDashboardSyncState('Fleet summary unavailable', false); } } refreshFleetOverview(); setInterval(refreshFleetOverview, 10000); @@ -142,7 +167,7 @@ function toggleTileSelection(tile, additive) { // --- Redis-backed tile layout persistence --- async function loadServerTileConfig(name) { try { - const resp = await fetch(`/api/ups/${name}/ui_tiles`); + const resp = await fetch(apiUpsPath(name, '/ui_tiles')); if (!resp.ok) return null; return await resp.json(); } catch { return null; } @@ -164,13 +189,13 @@ async function loadServerTileConfig(name) { saveToStorage(CARD_SIZE_KEY, savedCardSizes); // Clear server layout 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}`); } catch(err) { debugWarn(`Failed to clear server layout for ${name}:`, err); } // Rebuild grid with defaults then cascade layout - const card = document.getElementById(`card-${name}`); + const card = getCard(name); if (!card) return; const grid = card.querySelector('[data-tile-grid]'); if (!grid) return; @@ -276,7 +301,7 @@ function autoArrangeTiles(name, grid) { )); // Auto-size the UPS card - const card = document.getElementById(`card-${name}`); + const card = getCard(name); if (card) { const cardWidth = Math.max(maxX + padding, 400); const cardHeight = Math.max(maxY + padding + 50, 300); // +50 for header @@ -300,7 +325,7 @@ function persistCardSize(name, width, height) { } function restoreCardSize(name) { - const card = document.getElementById(`card-${name}`); + const card = getCard(name); const grid = card?.querySelector('[data-tile-grid]'); if (!card || !grid) return false; @@ -326,7 +351,7 @@ async function saveServerTileConfig(name) { const positions = savedTilePos[name] || {}; const card_size = savedCardSizes[name] || null; try { - await window.apiFetch(`/api/ups/${name}/ui_tiles`, { + await window.apiFetch(apiUpsPath(name, '/ui_tiles'), { method: 'POST', body: JSON.stringify({ types, order, hidden: hiddenArr, custom, positions, card_size }) }); @@ -334,7 +359,7 @@ async function saveServerTileConfig(name) { } function ensureUpsCard(name) { - let card = document.getElementById(`card-${name}`); + let card = getCard(name); if (card) { // Ensure add metric listener attached (server-rendered cards bypass creation path) const addBtn = card.querySelector('[data-add-metric]'); @@ -388,7 +413,7 @@ function ensureUpsCard(name) { } div.innerHTML = `