Files
lunarfront-manager/frontend/index.html
Ryan Moon b11b51aa1e
Some checks failed
Build & Release / build (push) Has been cancelled
feat: customer detail page, size snapshots table, Spaces provisioning, Redis status cache
2026-04-03 20:07:19 -05:00

874 lines
46 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LunarFront Manager</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: "Inter", system-ui, sans-serif; background: #0d1117; color: #c9d1d9; min-height: 100vh; }
/* ── Login ── */
#login-view { display: none; min-height: 100vh; align-items: center; justify-content: center; }
.login-box { background: #161b22; border: 1px solid #30363d; border-radius: 10px; padding: 2.5rem; width: 100%; max-width: 360px; }
.login-box h1 { font-size: 1.1rem; color: #e6edf3; margin-bottom: 0.3rem; font-weight: 600; }
.login-box p { font-size: 0.82rem; color: #8b949e; margin-bottom: 1.8rem; }
/* ── App shell ── */
#app-view { display: none; height: 100vh; }
.shell { display: flex; height: 100%; }
/* ── Sidebar ── */
.sidebar { width: 220px; background: #13161f; border-right: 1px solid #21262d; display: flex; flex-direction: column; flex-shrink: 0; }
.sidebar-brand { padding: 1.2rem 1.2rem 1rem; font-size: 0.88rem; font-weight: 700; color: #e6edf3; letter-spacing: 0.06em; text-transform: uppercase; border-bottom: 1px solid #21262d; }
.sidebar-nav { flex: 1; padding: 0.6rem 0.5rem; }
.nav-item { display: flex; align-items: center; gap: 0.65rem; padding: 0.55rem 0.8rem; border-radius: 6px; font-size: 0.845rem; color: #8b949e; cursor: pointer; transition: background 0.12s, color 0.12s; user-select: none; }
.nav-item:hover { background: #1c2128; color: #c9d1d9; }
.nav-item.active { background: #1c2128; color: #e6edf3; }
.nav-icon { font-size: 0.9rem; width: 1.1rem; text-align: center; opacity: 0.7; }
.sidebar-user { padding: 0.7rem; border-top: 1px solid #21262d; }
.user-btn { display: flex; align-items: center; gap: 0.65rem; padding: 0.55rem 0.8rem; border-radius: 6px; cursor: pointer; transition: background 0.12s; width: 100%; background: none; border: none; color: #8b949e; text-align: left; }
.user-btn:hover { background: #1c2128; color: #c9d1d9; }
.user-menu { display: none; flex-direction: column; gap: 1px; padding: 0 0.5rem 0.3rem; }
.user-menu.open { display: flex; }
.user-menu-item { font-size: 0.8rem; padding: 0.42rem 0.8rem; border-radius: 6px; cursor: pointer; color: #8b949e; transition: background 0.12s, color 0.12s; }
.user-menu-item:hover { background: #1c2128; color: #e6edf3; }
.user-menu-item.danger:hover { background: #3d1a1a; color: #f85149; }
.user-avatar { width: 26px; height: 26px; border-radius: 50%; background: #1d3a6e; border: 1px solid #30363d; display: flex; align-items: center; justify-content: center; font-size: 0.72rem; font-weight: 700; color: #79c0ff; flex-shrink: 0; }
.user-name { font-size: 0.83rem; }
/* ── Main content ── */
.main { flex: 1; overflow-y: auto; padding: 2rem 2.5rem; }
.page { display: none; }
.page.active { display: block; }
.page-title { font-size: 1rem; font-weight: 600; color: #e6edf3; margin-bottom: 1.5rem; letter-spacing: 0.01em; }
/* ── Cards ── */
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1.5rem; max-width: 520px; }
.card-title { font-size: 0.78rem; font-weight: 600; color: #8b949e; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 1rem; }
/* ── Form elements ── */
.form-group { margin-bottom: 1.1rem; }
label { display: block; font-size: 0.76rem; color: #8b949e; margin-bottom: 0.35rem; text-transform: uppercase; letter-spacing: 0.05em; font-weight: 500; }
input[type=text], input[type=password], input[type=date] { width: 100%; padding: 0.55rem 0.75rem; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-size: 0.875rem; }
input:focus { outline: none; border-color: #58a6ff; box-shadow: 0 0 0 3px rgba(88,166,255,0.1); }
input[type=date]::-webkit-calendar-picker-indicator { filter: invert(0.5); }
/* ── Modules ── */
.modules-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.4rem; }
.module-check { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.6rem; border: 1px solid #30363d; border-radius: 6px; cursor: pointer; font-size: 0.82rem; color: #8b949e; transition: border-color 0.12s, color 0.12s, background 0.12s; user-select: none; }
.module-check:hover { border-color: #58a6ff; color: #c9d1d9; }
.module-check input { display: none; }
.module-check.checked { border-color: #1d3a6e; background: #0d1f3a; color: #79c0ff; }
/* ── Buttons ── */
.btn { padding: 0.55rem 1.1rem; border-radius: 6px; font-size: 0.875rem; cursor: pointer; border: none; font-weight: 500; transition: background 0.12s, opacity 0.12s; }
.btn-primary { background: #1d3a6e; color: #79c0ff; border: 1px solid #1d4ed8; }
.btn-primary:hover { background: #1e4080; }
.btn-primary:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-slate { background: transparent; color: #8b949e; border: 1px solid #30363d; }
.btn-slate:hover { background: #1c2128; color: #c9d1d9; border-color: #484f58; }
.btn-danger { background: transparent; color: #f85149; border: 1px solid #3d1a1a; }
.btn-danger:hover { background: #3d1a1a; border-color: #f85149; }
.btn-sm { padding: 0.3rem 0.7rem; font-size: 0.78rem; }
/* ── Status messages ── */
.status { margin-top: 1rem; font-size: 0.83rem; padding: 0.55rem 0.8rem; border-radius: 6px; display: none; }
.status.success { background: #0d2818; color: #3fb950; border: 1px solid #1a4731; display: block; }
.status.error { background: #2d0f0f; color: #f85149; border: 1px solid #4a1a1a; display: block; }
/* ── Badges ── */
.badge { display: inline-flex; align-items: center; padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.72rem; font-weight: 500; }
.badge-blue { background: #1d3a6e; color: #79c0ff; border: 1px solid #1d4ed8; }
.badge-green { background: #0d2818; color: #3fb950; border: 1px solid #1a4731; }
.badge-red { background: #2d0f0f; color: #f85149; border: 1px solid #4a1a1a; }
.badge-yellow { background: #2d1f0a; color: #e3a04a; border: 1px solid #5a3a10; }
.badge-gray { background: #1c2128; color: #8b949e; border: 1px solid #30363d; }
/* ── Table ── */
.table-toolbar { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1rem; }
.search-input { background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 0.5rem 0.75rem; color: #c9d1d9; font-size: 0.875rem; width: 260px; }
.search-input:focus { outline: none; border-color: #58a6ff; }
.table-wrap { background: #161b22; border: 1px solid #30363d; border-radius: 8px; overflow: hidden; }
table { width: 100%; border-collapse: collapse; font-size: 0.845rem; }
thead { background: #1c2128; }
th { padding: 0.65rem 1rem; text-align: left; font-size: 0.76rem; font-weight: 600; color: #8b949e; text-transform: uppercase; letter-spacing: 0.05em; border-bottom: 1px solid #30363d; white-space: nowrap; user-select: none; }
th.sortable { cursor: pointer; }
th.sortable:hover { color: #c9d1d9; }
th .sort-icon { margin-left: 4px; opacity: 0.4; font-size: 0.7rem; }
th.sort-active { color: #58a6ff; }
th.sort-active .sort-icon { opacity: 1; }
td { padding: 0.7rem 1rem; border-bottom: 1px solid #21262d; color: #c9d1d9; vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: #1c2128; }
.tag { display: inline-block; padding: 0.15rem 0.45rem; border-radius: 4px; font-size: 0.72rem; font-weight: 500; background: #1d3a6e; color: #79c0ff; border: 1px solid #1d4ed8; margin: 1px 2px 1px 0; }
.tag-expired { background: #2d1a0a; color: #e3956a; border-color: #5a3010; }
.empty-row td { text-align: center; color: #484f58; padding: 2.5rem; }
/* ── Pagination ── */
.pagination { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1rem; border-top: 1px solid #21262d; font-size: 0.8rem; color: #8b949e; }
.pagination-btns { display: flex; gap: 0.4rem; align-items: center; }
/* ── Kebab menu ── */
.kebab-btn { background: none; border: none; color: #8b949e; cursor: pointer; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 1.1rem; line-height: 1; transition: background 0.1s, color 0.1s; }
.kebab-btn:hover { background: #21262d; color: #c9d1d9; }
#kebab-menu { display: none; position: fixed; background: #161b22; border: 1px solid #30363d; border-radius: 6px; min-width: 160px; z-index: 200; box-shadow: 0 8px 24px rgba(0,0,0,0.4); overflow: hidden; }
#kebab-menu.open { display: block; }
.kebab-item { padding: 0.55rem 1rem; font-size: 0.845rem; color: #c9d1d9; cursor: pointer; transition: background 0.1s; }
.kebab-item:hover { background: #1c2128; }
.kebab-item.danger { color: #f85149; }
.kebab-item.danger:hover { background: #3d1a1a; }
/* ── Customer detail page ── */
.detail-header { display: flex; align-items: flex-start; gap: 1rem; margin-bottom: 1.5rem; flex-wrap: wrap; }
.detail-title { font-size: 1.1rem; font-weight: 700; color: #e6edf3; }
.detail-slug { font-size: 0.82rem; color: #8b949e; margin-top: 0.2rem; font-family: monospace; }
.detail-meta { display: flex; gap: 0.4rem; align-items: center; flex-wrap: wrap; margin-top: 0.5rem; }
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@media (max-width: 900px) { .detail-grid { grid-template-columns: 1fr; } }
.stat-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1.2rem 1.5rem; }
.stat-card-full { grid-column: 1 / -1; }
.stat-card .card-title { margin-bottom: 0.8rem; }
.stat-row { display: flex; justify-content: space-between; align-items: center; padding: 0.35rem 0; border-bottom: 1px solid #21262d; font-size: 0.845rem; }
.stat-row:last-child { border-bottom: none; }
.stat-label { color: #8b949e; }
.stat-value { color: #e6edf3; font-weight: 500; text-align: right; }
/* ── Pods table in detail ── */
.pods-table { width: 100%; border-collapse: collapse; font-size: 0.82rem; margin-top: 0.5rem; }
.pods-table th { text-align: left; color: #8b949e; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em; padding: 0 0 0.5rem 0; border-bottom: 1px solid #21262d; }
.pods-table td { padding: 0.5rem 0; border-bottom: 1px solid #21262d; vertical-align: middle; }
.pods-table tr:last-child td { border-bottom: none; }
.pod-name { font-family: monospace; font-size: 0.8rem; color: #c9d1d9; }
.pod-status-running { color: #3fb950; }
.pod-status-pending { color: #e3a04a; }
.pod-status-failed { color: #f85149; }
.pod-status-unknown { color: #8b949e; }
/* ── Size history table ── */
.history-table { width: 100%; border-collapse: collapse; font-size: 0.82rem; }
.history-table th { text-align: left; color: #8b949e; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em; padding: 0 0 0.5rem 0; border-bottom: 1px solid #21262d; }
.history-table td { padding: 0.45rem 0; border-bottom: 1px solid #21262d; color: #c9d1d9; }
.history-table tr:last-child td { border-bottom: none; }
/* ── Skeleton ── */
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
.skeleton { background: #21262d; border-radius: 4px; animation: pulse 1.5s infinite; display: inline-block; }
/* ── Step list ── */
.step-list { display: flex; flex-direction: column; gap: 0.4rem; }
.step-item { display: flex; align-items: center; gap: 0.6rem; font-size: 0.845rem; }
.step-icon { width: 16px; height: 16px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 0.65rem; flex-shrink: 0; }
.step-done { background: #0d2818; color: #3fb950; border: 1px solid #1a4731; }
.step-failed { background: #2d0f0f; color: #f85149; border: 1px solid #4a1a1a; }
.step-pending { background: #1c2128; color: #8b949e; border: 1px solid #30363d; }
/* ── Confirm overlay ── */
.overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6); align-items: center; justify-content: center; z-index: 100; }
.overlay.open { display: flex; }
.dialog { background: #161b22; border: 1px solid #30363d; border-radius: 10px; padding: 1.8rem; max-width: 380px; width: 100%; }
.dialog h3 { font-size: 0.95rem; color: #e6edf3; margin-bottom: 0.5rem; }
.dialog p { font-size: 0.845rem; color: #8b949e; margin-bottom: 1.4rem; line-height: 1.5; }
.dialog-actions { display: flex; gap: 0.6rem; justify-content: flex-end; }
</style>
</head>
<body>
<!-- Login -->
<div id="login-view">
<div class="login-box">
<h1>LunarFront Manager</h1>
<p>Sign in to continue</p>
<div class="form-group">
<label for="login-username">Username</label>
<input id="login-username" type="text" autocomplete="username" />
</div>
<div class="form-group">
<label for="login-password">Password</label>
<input id="login-password" type="password" autocomplete="current-password" onkeydown="if(event.key==='Enter')login()" />
</div>
<button class="btn btn-primary" style="width:100%;margin-top:0.5rem" onclick="login()">Sign In</button>
<div id="login-status" class="status"></div>
</div>
</div>
<!-- App -->
<div id="app-view">
<div class="shell">
<aside class="sidebar">
<div class="sidebar-brand">LunarFront</div>
<nav class="sidebar-nav">
<div class="nav-item" id="nav-customers" onclick="showPage('customers')">
<span class="nav-icon"></span>
<span>Customers</span>
</div>
<div class="nav-item" id="nav-create-env" onclick="showPage('create-env')">
<span class="nav-icon"></span>
<span>Create Env</span>
</div>
</nav>
<div class="sidebar-user">
<button class="user-btn" onclick="toggleUserMenu()">
<div class="user-avatar" id="user-initials">?</div>
<span class="user-name" id="user-name-label">...</span>
</button>
<div class="user-menu" id="user-menu">
<div class="user-menu-item" onclick="showPage('account');toggleUserMenu()">Account</div>
<div class="user-menu-item danger" onclick="logout()">Sign Out</div>
</div>
</div>
</aside>
<main class="main">
<!-- Customers list -->
<div id="page-customers" class="page">
<div class="page-title">Customers</div>
<div class="table-toolbar">
<input class="search-input" id="customers-search" type="text" placeholder="Search customers…" oninput="onSearchInput()" />
<button class="btn btn-slate" onclick="loadCustomers()">Refresh</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="sortable sort-active" data-col="slug" onclick="setSort('slug')">Slug <span class="sort-icon" id="sort-icon-slug"></span></th>
<th>Name</th>
<th>Status</th>
<th>Modules</th>
<th class="sortable" data-col="start_date" onclick="setSort('start_date')">Start <span class="sort-icon" id="sort-icon-start_date"></span></th>
<th class="sortable" data-col="expiration_date" onclick="setSort('expiration_date')">Expires <span class="sort-icon" id="sort-icon-expiration_date"></span></th>
<th class="sortable" data-col="created_at" onclick="setSort('created_at')">Created <span class="sort-icon" id="sort-icon-created_at"></span></th>
<th class="sortable" data-col="updated_at" onclick="setSort('updated_at')">Updated <span class="sort-icon" id="sort-icon-updated_at"></span></th>
<th style="width:40px"></th>
</tr>
</thead>
<tbody id="customers-tbody">
<tr class="empty-row"><td colspan="9">Loading…</td></tr>
</tbody>
</table>
<div class="pagination" id="customers-pagination" style="display:none">
<span id="pagination-info" style="font-size:0.78rem;color:#8b949e"></span>
<div class="pagination-btns">
<button class="btn btn-slate btn-sm" id="btn-prev" onclick="changePage(-1)">← Prev</button>
<span id="pagination-page" style="font-size:0.8rem;color:#8b949e;min-width:60px;text-align:center"></span>
<button class="btn btn-slate btn-sm" id="btn-next" onclick="changePage(1)">Next →</button>
</div>
</div>
</div>
</div>
<!-- Customer Detail -->
<div id="page-customer-detail" class="page">
<div style="margin-bottom:1.2rem">
<button class="btn btn-slate btn-sm" onclick="showPage('customers')">← Customers</button>
</div>
<div class="detail-header">
<div style="flex:1">
<div class="detail-title" id="detail-name"></div>
<div class="detail-slug" id="detail-slug"></div>
<div class="detail-meta" id="detail-meta"></div>
</div>
<div style="display:flex;gap:0.5rem;align-items:center">
<button class="btn btn-slate btn-sm" onclick="refreshDetail()">↻ Refresh Status</button>
<button class="btn btn-danger btn-sm" id="detail-delete-btn" onclick="">Delete</button>
</div>
</div>
<div class="detail-grid" id="detail-grid">
<div class="stat-card stat-card-full">
<div class="card-title">Loading…</div>
<div style="height:80px;display:flex;align-items:center;justify-content:center;color:#484f58;font-size:0.845rem">Fetching status…</div>
</div>
</div>
</div>
<!-- Create Env -->
<div id="page-create-env" class="page">
<div class="page-title">Create Environment</div>
<div class="card">
<div class="form-group">
<label for="customer-name">Customer Name</label>
<input id="customer-name" type="text" placeholder="Acme Shop" />
</div>
<div class="form-group">
<label for="slug">Slug</label>
<input id="slug" type="text" placeholder="acme-shop" pattern="[a-z0-9-]+" />
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.8rem">
<div class="form-group">
<label for="start-date">Start Date</label>
<input id="start-date" type="date" />
</div>
<div class="form-group">
<label for="expiration-date">Expiration Date</label>
<input id="expiration-date" type="date" />
</div>
</div>
<div class="form-group">
<label>Modules</label>
<div class="modules-grid">
<label class="module-check" id="mod-pos"><input type="checkbox" value="pos" onchange="toggleModule(this)" /> POS</label>
<label class="module-check" id="mod-inventory"><input type="checkbox" value="inventory" onchange="toggleModule(this)" /> Inventory</label>
<label class="module-check" id="mod-rentals"><input type="checkbox" value="rentals" onchange="toggleModule(this)" /> Rentals</label>
<label class="module-check" id="mod-scheduling"><input type="checkbox" value="scheduling" onchange="toggleModule(this)" /> Scheduling</label>
<label class="module-check" id="mod-repairs"><input type="checkbox" value="repairs" onchange="toggleModule(this)" /> Repairs</label>
<label class="module-check" id="mod-accounting"><input type="checkbox" value="accounting" onchange="toggleModule(this)" /> Accounting</label>
</div>
</div>
<button class="btn btn-primary" id="provision-btn" onclick="provision()" style="margin-top:0.4rem">Provision</button>
<div id="provision-status" class="status"></div>
</div>
</div>
<!-- Account -->
<div id="page-account" class="page">
<div class="page-title">Account</div>
<div class="card">
<div class="form-group">
<label for="new-password">New Password</label>
<input id="new-password" type="password" autocomplete="new-password" />
</div>
<div class="form-group">
<label for="confirm-password">Confirm Password</label>
<input id="confirm-password" type="password" autocomplete="new-password" />
</div>
<button class="btn btn-slate" onclick="changePassword()">Update Password</button>
<div id="account-status" class="status"></div>
</div>
</div>
</main>
</div>
</div>
<!-- Kebab dropdown (shared, repositioned via JS) -->
<div id="kebab-menu">
<div class="kebab-item" onclick="kebabAction('view')">View Details</div>
<div class="kebab-item danger" onclick="kebabAction('delete')">Delete</div>
<div class="kebab-item" onclick="kebabAction('record')">Remove Record Only</div>
</div>
<!-- Delete confirm dialog -->
<div class="overlay" id="delete-overlay" onclick="if(event.target===this)closeDeleteDialog()">
<div class="dialog">
<h3>Delete Customer</h3>
<p>This will remove the ArgoCD app, database, storage, and all associated resources for <strong id="delete-slug-label" style="color:#e6edf3"></strong>. This cannot be undone.</p>
<div class="dialog-actions">
<button class="btn btn-slate" onclick="closeDeleteDialog()">Cancel</button>
<button class="btn btn-danger" id="delete-confirm-btn" onclick="confirmDelete()">Delete</button>
</div>
</div>
</div>
<script>
let currentUser = null;
let tableState = { page: 1, limit: 25, q: '', sort: 'created_at', order: 'desc', total: 0, totalPages: 1 };
let searchTimer = null;
let pendingDeleteSlug = null;
let kebabSlug = null;
let currentDetailSlug = null;
function authHeaders() {
const t = localStorage.getItem('token');
return t ? { Authorization: 'Bearer ' + t } : {};
}
function apiFetch(url, opts = {}) {
return fetch(url, { ...opts, headers: { ...authHeaders(), ...(opts.headers || {}) } });
}
// ── Auth ──────────────────────────────────────────────────────────────────
async function checkAuth() {
if (!localStorage.getItem('token')) { showLogin(); return; }
const res = await apiFetch('/api/auth/me');
if (res.ok) {
const data = await res.json();
setUser(data.username);
showPage('customers');
loadCustomers();
} else {
localStorage.removeItem('token');
showLogin();
}
}
function showLogin() {
document.getElementById('login-view').style.display = 'flex';
document.getElementById('app-view').style.display = 'none';
}
function setUser(username) {
currentUser = username;
document.getElementById('user-name-label').textContent = username;
document.getElementById('user-initials').textContent = username[0].toUpperCase();
document.getElementById('app-view').style.display = 'block';
}
async function login() {
const username = document.getElementById('login-username').value.trim();
const password = document.getElementById('login-password').value;
const status = document.getElementById('login-status');
status.className = 'status';
try {
const res = await fetch('/api/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message ?? 'Login failed');
localStorage.setItem('token', data.token);
setUser(data.username);
document.getElementById('login-view').style.display = 'none';
showPage('customers');
loadCustomers();
} catch (err) {
status.textContent = err.message;
status.className = 'status error';
}
}
function logout() {
localStorage.removeItem('token');
document.getElementById('app-view').style.display = 'none';
showLogin();
document.getElementById('login-password').value = '';
}
function toggleUserMenu() {
document.getElementById('user-menu').classList.toggle('open');
}
// ── Navigation ────────────────────────────────────────────────────────────
function showPage(name) {
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.getElementById('page-' + name)?.classList.add('active');
const navEl = document.getElementById('nav-' + name);
if (navEl) navEl.classList.add('active');
document.getElementById('user-menu').classList.remove('open');
closeKebab();
}
// ── Customers table ───────────────────────────────────────────────────────
async function loadCustomers() {
const s = tableState;
const params = new URLSearchParams({ page: s.page, limit: s.limit, sort: s.sort, order: s.order });
if (s.q) params.set('q', s.q);
const res = await apiFetch('/api/customers?' + params);
if (!res.ok) return;
const { data, pagination } = await res.json();
tableState.total = pagination.total;
tableState.totalPages = pagination.totalPages;
renderTable(data, pagination);
}
function customerStatusBadge(r) {
if (r.status === 'provisioning') return '<span class="badge badge-yellow">Provisioning</span>';
if (r.status === 'failed') {
const failedStep = Object.entries(r.steps || {}).find(([,v]) => v === 'failed');
const label = failedStep ? failedStep[0].replace('_', ' ') : 'unknown';
return `<span class="badge badge-red" title="Failed at: ${label}">Failed</span>`;
}
if (r.status === 'provisioned') {
const allDone = Object.values(r.steps || {}).every(v => v === 'done');
if (allDone) return '<span class="badge badge-green">Provisioned</span>';
return '<span class="badge badge-yellow">Partial</span>';
}
return `<span class="badge badge-gray">${r.status}</span>`;
}
function renderTable(rows, pagination) {
const tbody = document.getElementById('customers-tbody');
if (rows.length === 0) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="9">No customers found</td></tr>';
} else {
tbody.innerHTML = rows.map(r => {
const expired = r.expiration_date && new Date(r.expiration_date) < new Date();
const modules = (r.modules || []).map(m => `<span class="tag">${m}</span>`).join('') || '<span style="color:#484f58">—</span>';
const expiry = r.expiration_date
? `<span class="${expired ? 'tag tag-expired' : ''}">${fmtDate(r.expiration_date)}</span>`
: '<span style="color:#484f58">—</span>';
return `<tr style="cursor:pointer" ondblclick="openDetail('${r.slug}')">
<td style="font-weight:500;color:#e6edf3">${r.slug}</td>
<td>${r.name || '<span style="color:#484f58">—</span>'}</td>
<td>${customerStatusBadge(r)}</td>
<td>${modules}</td>
<td>${fmtDate(r.start_date)}</td>
<td>${expiry}</td>
<td style="color:#8b949e">${fmtDateTime(r.created_at)}</td>
<td style="color:#8b949e">${fmtDateTime(r.updated_at)}</td>
<td><button class="kebab-btn" onclick="openKebab(event,'${r.slug}')">⋮</button></td>
</tr>`;
}).join('');
}
const pagEl = document.getElementById('customers-pagination');
pagEl.style.display = 'flex';
const start = (pagination.page - 1) * pagination.limit + 1;
const end = Math.min(pagination.page * pagination.limit, pagination.total);
document.getElementById('pagination-info').textContent =
pagination.total === 0 ? '0 results' : `${start}${end} of ${pagination.total}`;
document.getElementById('pagination-page').textContent = `Page ${pagination.page} / ${pagination.totalPages}`;
document.getElementById('btn-prev').disabled = pagination.page <= 1;
document.getElementById('btn-next').disabled = pagination.page >= pagination.totalPages;
document.querySelectorAll('th[data-col]').forEach(th => {
const col = th.dataset.col;
const icon = document.getElementById('sort-icon-' + col);
th.classList.remove('sort-active');
if (icon) icon.textContent = '↕';
if (col === tableState.sort) {
th.classList.add('sort-active');
if (icon) icon.textContent = tableState.order === 'asc' ? '↑' : '↓';
}
});
}
function setSort(col) {
if (tableState.sort === col) tableState.order = tableState.order === 'asc' ? 'desc' : 'asc';
else { tableState.sort = col; tableState.order = 'asc'; }
tableState.page = 1;
loadCustomers();
}
function changePage(delta) {
const next = tableState.page + delta;
if (next < 1 || next > tableState.totalPages) return;
tableState.page = next;
loadCustomers();
}
function onSearchInput() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
tableState.q = document.getElementById('customers-search').value.trim();
tableState.page = 1;
loadCustomers();
}, 300);
}
// ── Kebab menu ────────────────────────────────────────────────────────────
function openKebab(event, slug) {
event.stopPropagation();
kebabSlug = slug;
const menu = document.getElementById('kebab-menu');
const rect = event.currentTarget.getBoundingClientRect();
menu.style.top = (rect.bottom + 4) + 'px';
menu.style.left = Math.min(rect.left, window.innerWidth - 170) + 'px';
menu.classList.add('open');
}
function closeKebab() {
document.getElementById('kebab-menu').classList.remove('open');
kebabSlug = null;
}
function kebabAction(action) {
const slug = kebabSlug;
closeKebab();
if (!slug) return;
if (action === 'view') openDetail(slug);
else if (action === 'delete') openDeleteDialog(slug);
else if (action === 'record') removeRecord(slug);
}
document.addEventListener('click', e => {
if (!document.getElementById('kebab-menu').contains(e.target)) closeKebab();
});
// ── Customer detail page ──────────────────────────────────────────────────
function openDetail(slug) {
currentDetailSlug = slug;
document.getElementById('detail-name').textContent = '…';
document.getElementById('detail-slug').textContent = slug;
document.getElementById('detail-meta').innerHTML = '';
document.getElementById('detail-delete-btn').onclick = () => openDeleteDialog(slug);
document.getElementById('detail-grid').innerHTML = `
<div class="stat-card stat-card-full">
<div class="card-title">Loading</div>
<div style="padding:1rem 0;display:flex;gap:1rem;flex-wrap:wrap">
${[120,90,140,80].map(w => `<span class="skeleton" style="width:${w}px;height:14px"></span>`).join('')}
</div>
</div>`;
showPage('customer-detail');
loadDetail(slug, false);
}
function refreshDetail() {
if (currentDetailSlug) loadDetail(currentDetailSlug, true);
}
async function loadDetail(slug, forceRefresh) {
const url = `/api/customers/${slug}/overview${forceRefresh ? '?refresh=1' : ''}`;
try {
const res = await apiFetch(url);
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
renderDetail(data);
} catch (err) {
document.getElementById('detail-grid').innerHTML =
`<div class="stat-card stat-card-full" style="color:#f85149">Failed to load: ${err.message}</div>`;
}
}
function renderDetail({ customer, status, sizeHistory }) {
// Header
document.getElementById('detail-name').textContent = customer.name || customer.slug;
document.getElementById('detail-slug').textContent = customer.slug;
const expired = customer.expiration_date && new Date(customer.expiration_date) < new Date();
const meta = [
customerStatusBadge(customer),
...(customer.modules || []).map(m => `<span class="tag">${m}</span>`),
expired ? `<span class="badge badge-red">Expired ${fmtDate(customer.expiration_date)}</span>` : '',
].filter(Boolean).join('');
document.getElementById('detail-meta').innerHTML = meta;
// Compute overall health badge
const pods = status.pods || [];
const argocd = status.argocd;
const allReady = pods.length > 0 && pods.every(p => p.ready);
const hasRestarts = pods.some(p => p.restarts > 5);
const argoHealthy = argocd?.healthStatus === 'Healthy';
const argoSynced = argocd?.syncStatus === 'Synced';
// ── Live status card ──────────────────────────────────────────────────
const argoSyncBadge = !argocd ? '<span class="badge badge-gray">Unknown</span>'
: argocd.syncStatus === 'Synced' ? '<span class="badge badge-green">Synced</span>'
: argocd.syncStatus === 'OutOfSync' ? '<span class="badge badge-yellow">OutOfSync</span>'
: `<span class="badge badge-gray">${argocd.syncStatus}</span>`;
const argoHealthBadge = !argocd ? '<span class="badge badge-gray">Unknown</span>'
: argocd.healthStatus === 'Healthy' ? '<span class="badge badge-green">Healthy</span>'
: argocd.healthStatus === 'Degraded' ? '<span class="badge badge-red">Degraded</span>'
: argocd.healthStatus === 'Progressing' ? '<span class="badge badge-yellow">Progressing</span>'
: `<span class="badge badge-gray">${argocd.healthStatus}</span>`;
const conditionsHtml = (argocd?.conditions ?? []).filter(c => c.message)
.map(c => `<div style="font-size:0.78rem;color:#f85149;margin-top:0.4rem">⚠ ${c.message}</div>`).join('');
const podsHtml = pods.length === 0
? '<div style="color:#484f58;font-size:0.845rem;padding:0.5rem 0">No pods found</div>'
: `<table class="pods-table">
<thead><tr><th>Pod</th><th>Ready</th><th>Status</th><th>Restarts</th><th>Age</th></tr></thead>
<tbody>${pods.map(p => {
const cls = p.status === 'Running' ? 'pod-status-running'
: p.status === 'Pending' ? 'pod-status-pending'
: p.status === 'Failed' ? 'pod-status-failed' : 'pod-status-unknown';
const shortName = p.name.replace(new RegExp(`^customer-${customer.slug}-`), '');
return `<tr>
<td class="pod-name">${shortName}</td>
<td style="color:#8b949e">${p.readyCount}/${p.totalCount}</td>
<td class="${cls}">${p.status}</td>
<td style="color:${p.restarts > 5 ? '#f85149' : '#8b949e'}">${p.restarts}</td>
<td style="color:#8b949e">${p.startedAt ? fmtAge(p.startedAt) : '—'}</td>
</tr>`;
}).join('')}</tbody>
</table>`;
const cachedAt = status.cachedAt ? `<span style="color:#484f58;font-size:0.75rem">cached ${fmtDateTime(status.cachedAt)}</span>` : '';
// ── Provisioning steps card ───────────────────────────────────────────
const STEP_LABELS = { database: 'Database', database_user: 'DB User', database_setup: 'Schema', pool: 'Connection Pool', namespace: 'K8s Namespace', secrets: 'Secrets', storage: 'Object Storage', chart: 'ArgoCD Chart' };
const stepsHtml = Object.entries(STEP_LABELS).map(([key, label]) => {
const s = (customer.steps || {})[key];
const cls = s === 'done' ? 'step-done' : s === 'failed' ? 'step-failed' : 'step-pending';
const icon = s === 'done' ? '✓' : s === 'failed' ? '✗' : '·';
return `<div class="step-item">
<div class="step-icon ${cls}">${icon}</div>
<span style="color:${s === 'done' ? '#c9d1d9' : s === 'failed' ? '#f85149' : '#484f58'}">${label}</span>
</div>`;
}).join('');
// ── Size history card ─────────────────────────────────────────────────
const latestSize = sizeHistory[0] || null;
const sizeRowsHtml = sizeHistory.length === 0
? '<div style="color:#484f58;font-size:0.845rem">No size data yet — collected every 12 hours</div>'
: `<table class="history-table">
<thead><tr><th>Date</th><th>Database</th><th>Storage</th><th>Objects</th></tr></thead>
<tbody>${sizeHistory.slice(0, 7).map(s => `<tr>
<td style="color:#8b949e">${fmtDate(s.recorded_at)}</td>
<td>${s.db_size_bytes != null ? fmtBytes(s.db_size_bytes) : '—'}</td>
<td>${s.spaces_size_bytes != null ? fmtBytes(s.spaces_size_bytes) : '—'}</td>
<td style="color:#8b949e">${s.spaces_object_count ?? '—'}</td>
</tr>`).join('')}</tbody>
</table>`;
document.getElementById('detail-grid').innerHTML = `
<div class="stat-card stat-card-full">
<div class="card-title" style="display:flex;justify-content:space-between;align-items:center">
Live Status ${cachedAt}
</div>
<div style="display:flex;gap:1.5rem;margin-bottom:1rem;flex-wrap:wrap">
<div><span style="font-size:0.76rem;color:#8b949e;text-transform:uppercase;letter-spacing:0.04em">Sync </span>${argoSyncBadge}</div>
<div><span style="font-size:0.76rem;color:#8b949e;text-transform:uppercase;letter-spacing:0.04em">Health </span>${argoHealthBadge}</div>
</div>
${conditionsHtml}
${podsHtml}
</div>
<div class="stat-card">
<div class="card-title">Provisioning Steps</div>
<div class="step-list">${stepsHtml}</div>
<div style="margin-top:1rem;font-size:0.78rem;color:#8b949e">Provisioned ${fmtDateTime(customer.created_at)}</div>
</div>
<div class="stat-card">
<div class="card-title">Size History</div>
${sizeRowsHtml}
</div>
`;
}
// ── Delete ────────────────────────────────────────────────────────────────
function openDeleteDialog(slug) {
pendingDeleteSlug = slug;
document.getElementById('delete-slug-label').textContent = slug;
document.getElementById('delete-overlay').classList.add('open');
}
function closeDeleteDialog() {
pendingDeleteSlug = null;
document.getElementById('delete-overlay').classList.remove('open');
}
async function confirmDelete() {
const slug = pendingDeleteSlug;
if (!slug) return;
const btn = document.getElementById('delete-confirm-btn');
btn.disabled = true;
btn.textContent = 'Deleting…';
try {
const res = await apiFetch(`/api/customers/${slug}`, { method: 'DELETE' });
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error(d.message ?? 'Delete failed');
}
closeDeleteDialog();
if (currentDetailSlug === slug) showPage('customers');
loadCustomers();
} catch (err) {
alert(err.message);
} finally {
btn.disabled = false;
btn.textContent = 'Delete';
}
}
async function removeRecord(slug) {
if (!confirm(`Remove "${slug}" from the manager database only?\n\nThis will NOT delete the ArgoCD app or DO database.`)) return;
const res = await apiFetch(`/api/customers/${slug}/record`, { method: 'DELETE' });
if (res.ok) {
if (currentDetailSlug === slug) showPage('customers');
loadCustomers();
} else {
const d = await res.json().catch(() => ({}));
alert(d.message ?? 'Failed to remove record');
}
}
// ── Provision ─────────────────────────────────────────────────────────────
function toggleModule(checkbox) {
checkbox.closest('.module-check').classList.toggle('checked', checkbox.checked);
}
async function provision() {
const name = document.getElementById('customer-name').value.trim();
const slug = document.getElementById('slug').value.trim();
const startDate = document.getElementById('start-date').value;
const expirationDate = document.getElementById('expiration-date').value || null;
const modules = [...document.querySelectorAll('.module-check input:checked')].map(el => el.value);
if (!name || !slug) return;
const btn = document.getElementById('provision-btn');
const status = document.getElementById('provision-status');
btn.disabled = true;
btn.textContent = 'Provisioning…';
status.className = 'status';
try {
const res = await apiFetch('/api/customers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ slug, name, modules, startDate, expirationDate }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message ?? 'Unknown error');
status.textContent = `${data.slug} provisioned successfully`;
status.className = 'status success';
document.getElementById('customer-name').value = '';
document.getElementById('slug').value = '';
document.getElementById('expiration-date').value = '';
document.querySelectorAll('.module-check').forEach(l => { l.classList.remove('checked'); l.querySelector('input').checked = false; });
} catch (err) {
status.textContent = `${err.message}`;
status.className = 'status error';
} finally {
btn.disabled = false;
btn.textContent = 'Provision';
}
}
// ── Account ───────────────────────────────────────────────────────────────
async function changePassword() {
const pw = document.getElementById('new-password').value;
const confirm = document.getElementById('confirm-password').value;
const status = document.getElementById('account-status');
status.className = 'status';
if (pw !== confirm) { status.textContent = 'Passwords do not match'; status.className = 'status error'; return; }
try {
const res = await apiFetch('/api/auth/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: pw }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message ?? 'Failed');
status.textContent = 'Password updated';
status.className = 'status success';
document.getElementById('new-password').value = '';
document.getElementById('confirm-password').value = '';
} catch (err) {
status.textContent = err.message;
status.className = 'status error';
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
function fmtDate(val) {
if (!val) return '—';
return new Date(val).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
function fmtDateTime(val) {
if (!val) return '—';
return new Date(val).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function fmtAge(startedAt) {
const secs = Math.floor((Date.now() - new Date(startedAt)) / 1000);
if (secs < 60) return `${secs}s`;
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ${mins % 60}m`;
return `${Math.floor(hrs / 24)}d`;
}
function fmtBytes(bytes) {
if (bytes == null) return '—';
bytes = Number(bytes);
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
}
// ── Init ──────────────────────────────────────────────────────────────────
document.getElementById('start-date').value = new Date().toISOString().slice(0, 10);
checkAuth();
</script>
</body>
</html>