Files

6.4 KiB

Frontend Auth & Action Pattern — Ops Portal

Created: 2026-07-09 Applies to: /opt/ops-portal/static/ pages (index.html, servers.html, backups.html, services.html)

Architecture

Each page is a self-contained HTML file served by FastAPI with:

  1. Inline login overlay (no separate login page)
  2. Inline nav (not fetched via nav.html partial — auth-aware logout button is tightly coupled)
  3. Single JS module: /js/app.js (exposed as window.Ops)

JS Module (/js/app.js)

Auth

// Login: stores JWT in localStorage['ops_token']
await Ops.login('username', 'password');

// Check auth state
if (Ops.isAuthenticated()) { ... }

// Logout — clears storage + page reload
Ops.logout();

API Client

// All calls auto-attach Authorization header from stored token
var data = await Ops.apiFetch('/api/status');
var servers = await Ops.getServers();
var audit = await Ops.getAuditLog(10);

// Service actions (all POST with JWT)
await Ops.restartService('caddy');
await Ops.stopService('hermes');
await Ops.startService('ops-portal');

// Backup trigger
await Ops.triggerBackup('hermes-vps-backups');

// Server reboot (by Hetzner server ID)
await Ops.rebootServer(51140464);

401 handling: apiFetch() automatically clears localStorage and calls window.location.reload() on 401 responses, showing the login overlay again.

UI Components

// Toast notification (auto-dismiss 5s)
Ops.toast('Server reboot initiated', 'success');  // 'success' | 'error' | 'info'

// Confirmation dialog (returns Promise<boolean>)
var ok = await Ops.confirmDialog(
  'Reboot Server',
  'Are you sure you want to reboot wphost02?',
  'Reboot',
  'Cancel'
);

// Button loading state
Ops.spinning(document.getElementById('refreshBtn'), true);
Ops.spinning(document.getElementById('refreshBtn'), false);

// Nav initialization (hamburger toggle + active link + logout)
Ops.initNav();

// Formatting utilities
Ops.fmtTime('2026-07-09T12:22:28-04:00');  // "Jul 9, 12:22 PM"
Ops.fmtAge('...');                          // "3h 12m ago"
Ops.badgeHtml('ok');                        // '<span class="badge badge-ok">✓ ok</span>'
Ops.dotColor('running');                    // 'dot-green'
Ops.escapeHtml('<script>');                 // '&lt;script&gt;'
Ops.valOrDash(null);                        // '—'
Ops.updateLastUpdated();                    // Sets #lastUpdated textContent

Toast container (lazy-created)

If #toastContainer doesn't exist, Ops.toast() creates it as a <div> appended to document.body. Position: fixed; top: 20px; right: 20px; z-index: 10001. Styled via ops.css .toast, .toast-success, .toast-error, .toast-info.

Page Template Pattern

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
  <meta name="theme-color" content="#0f1628">
  <title>Page — Operations Dashboard</title>
  <link rel="stylesheet" href="/css/ops.css">
</head>
<body>

<!-- Login Overlay -->
<div class="login-overlay show" id="loginOverlay">
  <div class="login-card">
    <!-- logo, title, error div, form with #loginUser + #loginPass -->
  </div>
</div>

<!-- Navigation (inline, not fetched) -->
<nav class="ops-nav" id="opsNav">
  <!-- brand, nav-links with SVG icons, logout button -->
</nav>

<div class="page-wrapper">
  <header class="page-header">
    <h1>Page Title</h1>
    <div class="header-actions">
      <span class="last-updated" id="lastUpdated"></span>
      <button class="refresh-btn" onclick="loadPageData()">Refresh</button>
    </div>
  </header>
  <!-- card sections with data -->
</div>

<script src="/js/app.js"></script>
<script>
(function() {
  'use strict';

  // Auth guard
  if (!Ops.isAuthenticated()) {
    document.getElementById('loginOverlay').classList.add('show');
  } else {
    document.getElementById('logoutBtn').classList.add('show');
    loadPageData();
  }

  // Login handler
  window.handleLogin = async function(e) { ... await Ops.login(...) ... };

  // Data loading
  window.loadPageData = async function() { ... };

  // Action handlers with confirmation
  window.handleAction = async function(...) {
    if (await Ops.confirmDialog(...)) { ... await Ops.apiCall(...) ... Ops.toast(...) }
  };

  Ops.initNav();
  // Auto-refresh
  setInterval(function() {
    if (Ops.isAuthenticated()) loadPageData();
  }, 60000);
})();
</script>
</body>
</html>

Files created Jul 9, 2026

File Size Sections
/opt/ops-portal/static/index.html 19.3 KB Health grid, Quick actions (restart/trigger), Servers summary, Cron jobs, Audit log
/opt/ops-portal/static/servers.html 15.0 KB Quick specs (vCPUs/RAM), Sortable server table with click-to-expand details, Reboot button per server
/opt/ops-portal/static/backups.html 15.1 KB Summary bar, Bucket grid with Trigger button, Schedule table
/opt/ops-portal/static/services.html 14.7 KB Service control grid (Start/Stop/Restart per service), API health grid, Service audit log
/opt/ops-portal/static/css/ops.css 27.0 KB Full theme with auth-overlay, confirm-dialog, toast, action-card, svc-card styles
/opt/ops-portal/static/js/app.js 11.6 KB Auth module, API client, confirmation dialog, toast, utilities

CSS classes for auth UI (in ops.css)

  • .login-overlay — full-screen fixed overlay, display: none by default, .show sets display: flex
  • .login-card — centered dialog, max-width 400px, dark card with logo + form
  • .login-error — red alert box, hidden by default, .show reveals it
  • .confirm-overlay — modal backdrop with blur, .show = display: flex
  • .confirm-dialog — centered card with title + message + action buttons
  • .toast-container — fixed top-right notification area
  • .toast, .toast-success, .toast-error, .toast-info — slide-in animations

Key quirks

  • Login overlay is always in the HTML — it's hidden by CSS display: none. The JS just toggles .show class. No page redirect on login; the overlay hides and the page stays.
  • Auto-refresh stops when not authenticated — every setInterval callback checks Ops.isAuthenticated() before fetching. Stale data stays visible until login.
  • Caddy reload after config change: caddy reload --config /etc/caddy/Caddyfile or systemctl reload caddy. Full restart (systemctl stop/start) only needed when adding new Caddy hosts, not modifying existing ones.