Files
lunarfront-manager/frontend/index.html
ryan 7f2cf14d38
Some checks failed
Build & Release / build (push) Has been cancelled
feat: initial user provisioning with welcome email, resend welcome
- Provision form accepts optional initial admin user (no password needed)
- POST /customers/:slug/resend-welcome sends welcome email via customer backend
- Kebab menu "Resend Welcome" option with email input dialog
- Query latest version from backend image tags instead of chart tags

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:09:33 +00:00

1308 lines
66 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="navigate('customers')">
<span class="nav-icon"></span>
<span>Customers</span>
</div>
<div class="nav-item" id="nav-devpod" onclick="navigate('devpod')">
<span class="nav-icon"></span>
<span>Dev Pod</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 style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem">
<div class="page-title" style="margin-bottom:0">Customers</div>
<button class="btn btn-primary" onclick="openProvisionModal()">+ New Environment</button>
</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>
<button class="btn btn-slate" id="upgrade-all-btn" onclick="upgradeAll()">Upgrade All</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="navigate('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="">Decommission</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>
<!-- Dev Pod -->
<div id="page-devpod" class="page">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem">
<div class="page-title" style="margin-bottom:0">Dev Pod</div>
<div style="display:flex;gap:0.5rem">
<button class="btn btn-slate btn-sm" onclick="loadDevPod()">↻ Refresh</button>
</div>
</div>
<div class="detail-grid" id="devpod-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>
<!-- 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" id="kebab-deactivate" onclick="kebabAction('deactivate')">Pause</div>
<div class="kebab-item" id="kebab-reactivate" onclick="kebabAction('reactivate')" style="display:none">Resume</div>
<div class="kebab-item" onclick="kebabAction('upgrade')">Upgrade Chart</div>
<div class="kebab-item" onclick="kebabAction('resend-welcome')">Resend Welcome</div>
<div class="kebab-item danger" onclick="kebabAction('delete')">Decommission</div>
<div class="kebab-item" onclick="kebabAction('record')">Remove Record Only</div>
</div>
<!-- Resend welcome dialog -->
<div class="overlay" id="resend-overlay" onclick="if(event.target===this)closeResendDialog()">
<div class="dialog" style="max-width:380px;width:100%">
<h3>Resend Welcome Email</h3>
<p style="margin-bottom:0.8rem">Send a welcome email with a password setup link to a user on <strong id="resend-slug-label" style="color:#e6edf3"></strong>.</p>
<div class="form-group">
<label for="resend-email">Email address</label>
<input id="resend-email" type="email" placeholder="user@example.com" />
</div>
<div class="dialog-actions">
<button class="btn btn-slate" onclick="closeResendDialog()">Cancel</button>
<button class="btn" id="resend-confirm-btn" onclick="confirmResendWelcome()">Send</button>
</div>
</div>
</div>
<!-- Decommission confirm dialog -->
<div class="overlay" id="delete-overlay" onclick="if(event.target===this)closeDeleteDialog()">
<div class="dialog">
<h3>Decommission Customer</h3>
<p>This will permanently remove the deployment, 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()">Decommission</button>
</div>
</div>
</div>
<!-- Provision modal -->
<div class="overlay" id="provision-overlay" onclick="if(event.target===this)closeProvisionModal()">
<div class="dialog" style="max-width:480px;width:100%">
<h3>New Environment</h3>
<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>
<div style="border-top:1px solid #30363d;margin-top:0.8rem;padding-top:0.8rem">
<label style="font-size:0.82rem;color:#8b949e;margin-bottom:0.5rem;display:block">Initial Admin User (optional)</label>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.8rem">
<div class="form-group">
<label for="initial-first">First Name</label>
<input id="initial-first" type="text" placeholder="Jane" />
</div>
<div class="form-group">
<label for="initial-last">Last Name</label>
<input id="initial-last" type="text" placeholder="Doe" />
</div>
</div>
<div class="form-group">
<label for="initial-email">Email</label>
<input id="initial-email" type="email" placeholder="admin@example.com" />
</div>
</div>
<div id="provision-status" class="status"></div>
<div class="dialog-actions">
<button class="btn btn-slate" onclick="closeProvisionModal()">Cancel</button>
<button class="btn btn-primary" id="provision-btn" onclick="provision()">Provision</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);
handleRoute();
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';
handleRoute();
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 navigate(path) {
window.location.hash = path;
}
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();
}
function handleRoute() {
const hash = window.location.hash.slice(1) || 'customers';
if (hash.startsWith('customers/')) {
const slug = hash.slice('customers/'.length);
showPage('customers');
openDetail(slug);
} else if (hash === 'customers') {
showPage('customers');
} else if (hash === 'devpod') {
showPage('devpod');
loadDevPod();
} else if (hash === 'account') {
showPage('account');
} else {
showPage('customers');
}
}
window.addEventListener('hashchange', handleRoute);
// ── 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 === 'inactive') return '<span class="badge badge-gray">Inactive</span>';
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" onclick="navigate('customers/${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}','${r.status}')">⋮</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, status) {
event.stopPropagation();
kebabSlug = slug;
// Show deactivate or reactivate depending on status
document.getElementById('kebab-deactivate').style.display = status === 'inactive' ? 'none' : '';
document.getElementById('kebab-reactivate').style.display = status === 'inactive' ? '' : 'none';
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 === 'deactivate') deactivate(slug);
else if (action === 'reactivate') reactivate(slug);
else if (action === 'upgrade') upgradeCustomer(slug);
else if (action === 'resend-welcome') openResendWelcome(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 openProvisionModal() {
document.getElementById('provision-status').className = 'status';
document.getElementById('provision-status').textContent = '';
document.getElementById('provision-overlay').classList.add('open');
}
function closeProvisionModal() {
document.getElementById('provision-overlay').classList.remove('open');
}
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, infra, 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">Infrastructure</div>
<div class="stat-row">
<span class="stat-label">Database</span>
<span class="stat-value">${infra?.database?.exists
? '<span class="badge badge-green">Exists</span>'
: '<span class="badge badge-red">Not found</span>'}</span>
</div>
<div class="stat-row">
<span class="stat-label">PgBouncer</span>
<span class="stat-value">${infra?.pgbouncer?.configured
? '<span class="badge badge-green">Reachable</span>'
: '<span class="badge badge-red">Not configured</span>'}</span>
</div>
<div class="stat-row">
<span class="stat-label">Storage</span>
<span class="stat-value">${infra?.spaces?.configured
? '<span class="badge badge-green">Configured</span>'
: '<span class="badge badge-gray">Not configured</span>'}</span>
</div>
${infra?.spaces?.configured ? `
<div class="stat-row">
<span class="stat-label">Bucket</span>
<span class="stat-value" style="font-family:monospace;font-size:0.8rem">${infra.spaces.bucket}</span>
</div>
<div class="stat-row">
<span class="stat-label">Prefix</span>
<span class="stat-value" style="font-family:monospace;font-size:0.8rem">${infra.spaces.prefix}</span>
</div>` : ''}
<div class="stat-row">
<span class="stat-label">DNS</span>
<span class="stat-value">${infra?.dns?.exists
? '<span class="badge badge-green">Active</span>'
: '<span class="badge badge-red">Missing</span>'}</span>
</div>
<div class="stat-row">
<span class="stat-label">Health</span>
<span class="stat-value">${infra?.health?.reachable
? '<span class="badge badge-green">Healthy</span>'
: infra?.health?.status
? `<span class="badge badge-red">HTTP ${infra.health.status}</span>`
: '<span class="badge badge-gray">Unreachable</span>'}</span>
</div>
</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 ────────────────────────────────────────────────────────────────
let resendSlug = null;
function openResendWelcome(slug) {
resendSlug = slug;
document.getElementById('resend-slug-label').textContent = slug;
document.getElementById('resend-email').value = '';
document.getElementById('resend-overlay').classList.add('open');
}
function closeResendDialog() {
resendSlug = null;
document.getElementById('resend-overlay').classList.remove('open');
}
async function confirmResendWelcome() {
const slug = resendSlug;
const email = document.getElementById('resend-email').value.trim();
if (!slug || !email) return;
const btn = document.getElementById('resend-confirm-btn');
btn.disabled = true;
btn.textContent = 'Sending…';
try {
const res = await apiFetch(`/api/customers/${slug}/resend-welcome`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error(d.message ?? 'Failed to send');
}
closeResendDialog();
alert('Welcome email sent to ' + email);
} catch (err) {
alert(err.message);
} finally {
btn.disabled = false;
btn.textContent = 'Send';
}
}
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) navigate('customers');
loadCustomers();
} catch (err) {
alert(err.message);
} finally {
btn.disabled = false;
btn.textContent = 'Decommission';
}
}
async function deactivate(slug) {
if (!confirm(`Pause "${slug}"?\n\nThis removes the deployment but keeps the database and storage. You can resume later.`)) return;
const res = await apiFetch(`/api/customers/${slug}/deactivate`, { method: 'POST' });
if (res.ok) { loadCustomers(); if (currentDetailSlug === slug) loadDetail(slug, false); }
else { const d = await res.json().catch(() => ({})); alert(d.message ?? 'Failed'); }
}
async function reactivate(slug) {
if (!confirm(`Reactivate "${slug}"?\n\nThis will redeploy the application.`)) return;
const res = await apiFetch(`/api/customers/${slug}/reactivate`, { method: 'POST' });
if (res.ok) { loadCustomers(); if (currentDetailSlug === slug) loadDetail(slug, false); }
else { const d = await res.json().catch(() => ({})); alert(d.message ?? 'Failed'); }
}
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) navigate('customers');
loadCustomers();
} else {
const d = await res.json().catch(() => ({}));
alert(d.message ?? 'Failed to remove record');
}
}
// ── Upgrade ───────────────────────────────────────────────────────────────
async function upgradeCustomer(slug) {
const btn = event?.target;
if (btn) { btn.disabled = true; btn.textContent = 'Upgrading…'; }
try {
const res = await apiFetch(`/api/customers/${slug}/upgrade`, { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.message ?? 'Failed');
alert(`${slug} upgraded to chart ${data.version}`);
loadCustomers();
} catch (err) {
alert(`Upgrade failed: ${err.message}`);
} finally {
if (btn) { btn.disabled = false; btn.textContent = 'Upgrade Chart'; }
}
}
async function upgradeAll() {
const btn = document.getElementById('upgrade-all-btn');
btn.disabled = true;
btn.textContent = 'Upgrading…';
try {
const res = await apiFetch('/api/customers/upgrade-all', { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.message ?? 'Failed');
if (!data.upgraded.length) { alert('No provisioned customers to upgrade.'); return; }
alert(`Upgraded ${data.upgraded.length} customer(s) to chart ${data.version}:\n${data.upgraded.join(', ')}`);
loadCustomers();
} catch (err) {
alert(`Upgrade all failed: ${err.message}`);
} finally {
btn.disabled = false;
btn.textContent = 'Upgrade All';
}
}
// ── 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);
const initialFirst = document.getElementById('initial-first').value.trim();
const initialLast = document.getElementById('initial-last').value.trim();
const initialEmail = document.getElementById('initial-email').value.trim();
if (!name || !slug) return;
const body = { slug, name, modules, startDate, expirationDate };
if (initialEmail && initialFirst && initialLast) {
body.initialUser = { email: initialEmail, firstName: initialFirst, lastName: initialLast };
}
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(body),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message ?? 'Unknown error');
closeProvisionModal();
document.getElementById('customer-name').value = '';
document.getElementById('slug').value = '';
document.getElementById('expiration-date').value = '';
document.getElementById('initial-first').value = '';
document.getElementById('initial-last').value = '';
document.getElementById('initial-email').value = '';
document.querySelectorAll('.module-check').forEach(l => { l.classList.remove('checked'); l.querySelector('input').checked = false; });
loadCustomers();
} catch (err) {
status.textContent = `${err.message}`;
status.className = 'status error';
} finally {
btn.disabled = false;
btn.textContent = 'Provision';
}
}
// ── Dev Pod ──────────────────────────────────────────────────────────────
let devpodRefreshTimer = null;
async function loadDevPod() {
try {
const res = await apiFetch('/api/devpod/status');
if (!res.ok) throw new Error('Failed to load');
const data = await res.json();
renderDevPod(data);
} catch (err) {
document.getElementById('devpod-grid').innerHTML =
`<div class="stat-card stat-card-full" style="color:#f85149">Failed to load: ${err.message}</div>`;
}
}
function renderDevPod({ state, replicas, readyReplicas, image, pods }) {
const stateBadge = state === 'running' ? '<span class="badge badge-green">Running</span>'
: state === 'stopped' ? '<span class="badge badge-gray">Stopped</span>'
: state === 'starting' ? '<span class="badge badge-yellow">Starting</span>'
: '<span class="badge badge-red">Error</span>';
const pod = pods[0];
const uptime = pod?.startedAt ? fmtAge(pod.startedAt) : '—';
const startedAt = pod?.startedAt ? fmtDateTime(pod.startedAt) : '—';
const podsHtml = pods.length === 0
? '<div style="color:#484f58;font-size:0.845rem;padding:0.5rem 0">No pods running</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';
return `<tr>
<td class="pod-name">${p.name}</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 startDisabled = state === 'running' || state === 'starting';
const stopDisabled = state === 'stopped';
const restartDisabled = state === 'stopped';
document.getElementById('devpod-grid').innerHTML = `
<div class="stat-card stat-card-full">
<div class="card-title" style="display:flex;justify-content:space-between;align-items:center">
Status
<div style="display:flex;gap:0.5rem">
<button class="btn btn-primary btn-sm" id="devpod-start-btn" onclick="devpodAction('start')" ${startDisabled ? 'disabled' : ''}>Start</button>
<button class="btn btn-slate btn-sm" id="devpod-restart-btn" onclick="devpodAction('restart')" ${restartDisabled ? 'disabled' : ''}>Restart</button>
<button class="btn btn-danger btn-sm" id="devpod-stop-btn" onclick="devpodAction('stop')" ${stopDisabled ? 'disabled' : ''}>Stop</button>
</div>
</div>
<div style="display:flex;gap:2rem;margin-bottom:1rem;flex-wrap:wrap">
<div>
<span style="font-size:0.76rem;color:#8b949e;text-transform:uppercase;letter-spacing:0.04em">State </span>
${stateBadge}
</div>
<div>
<span style="font-size:0.76rem;color:#8b949e;text-transform:uppercase;letter-spacing:0.04em">Uptime </span>
<span style="color:#e6edf3;font-size:0.845rem;font-weight:500">${uptime}</span>
</div>
<div>
<span style="font-size:0.76rem;color:#8b949e;text-transform:uppercase;letter-spacing:0.04em">Started </span>
<span style="color:#8b949e;font-size:0.845rem">${startedAt}</span>
</div>
</div>
${podsHtml}
</div>
<div class="stat-card">
<div class="card-title">Details</div>
<div class="stat-row">
<span class="stat-label">Replicas</span>
<span class="stat-value">${readyReplicas} / ${replicas}</span>
</div>
<div class="stat-row">
<span class="stat-label">Image</span>
<span class="stat-value" style="font-family:monospace;font-size:0.78rem;word-break:break-all">${image || '—'}</span>
</div>
<div class="stat-row">
<span class="stat-label">Namespace</span>
<span class="stat-value" style="font-family:monospace;font-size:0.8rem">dev</span>
</div>
<div class="stat-row">
<span class="stat-label">Deployment</span>
<span class="stat-value" style="font-family:monospace;font-size:0.8rem">dev</span>
</div>
</div>
<div class="stat-card stat-card-full" id="devpod-keys-card">
<div class="card-title">SSH Keys</div>
<div id="devpod-keys-list" style="color:#8b949e;font-size:0.845rem">Loading…</div>
<div style="display:flex;gap:0.5rem;margin-top:1rem">
<input id="devpod-key-input" type="text" placeholder="ssh-ed25519 AAAA… user@host"
style="flex:1;background:#161b22;border:1px solid #30363d;border-radius:6px;padding:0.4rem 0.6rem;color:#e6edf3;font-family:monospace;font-size:0.78rem" />
<button class="btn btn-primary btn-sm" onclick="addSshKey()">Add Key</button>
</div>
</div>
`;
loadSshKeys();
// Auto-refresh while starting
clearInterval(devpodRefreshTimer);
if (state === 'starting') {
devpodRefreshTimer = setInterval(loadDevPod, 5000);
}
}
async function devpodAction(action) {
const btnId = `devpod-${action}-btn`;
const btn = document.getElementById(btnId);
const origText = btn?.textContent;
if (btn) { btn.disabled = true; btn.textContent = action === 'start' ? 'Starting…' : action === 'stop' ? 'Stopping…' : 'Restarting…'; }
try {
const res = await apiFetch(`/api/devpod/${action}`, { method: 'POST' });
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error(d.message ?? `${action} failed`);
}
// Wait a moment then refresh status
setTimeout(loadDevPod, 1500);
} catch (err) {
alert(`Dev pod ${action} failed: ${err.message}`);
if (btn) { btn.disabled = false; btn.textContent = origText; }
}
}
async function loadSshKeys() {
const el = document.getElementById('devpod-keys-list');
if (!el) return;
try {
const res = await apiFetch('/api/devpod/keys');
const { keys } = await res.json();
renderSshKeys(keys);
} catch {
if (el) el.innerHTML = '<span style="color:#f85149">Failed to load keys</span>';
}
}
function renderSshKeys(keys) {
const el = document.getElementById('devpod-keys-list');
if (!el) return;
if (!keys.length) { el.innerHTML = '<span style="color:#484f58">No SSH keys configured</span>'; return; }
el.innerHTML = keys.map(k => {
const parts = k.split(' ');
const label = parts.slice(2).join(' ') || parts[0];
const short = parts[1] ? parts[1].slice(0, 20) + '…' : k;
return `<div style="display:flex;justify-content:space-between;align-items:center;padding:0.35rem 0;border-bottom:1px solid #21262d">
<span style="font-family:monospace;font-size:0.78rem;color:#e6edf3">${label} <span style="color:#484f58">${short}</span></span>
<button class="btn btn-danger btn-sm" onclick="removeSshKey(${JSON.stringify(k)})">Remove</button>
</div>`;
}).join('');
}
async function addSshKey() {
const input = document.getElementById('devpod-key-input');
const key = input.value.trim();
if (!key) return;
try {
const res = await apiFetch('/api/devpod/keys', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ key }) });
if (!res.ok) throw new Error((await res.json()).message);
const { keys } = await res.json();
renderSshKeys(keys);
input.value = '';
} catch (err) {
alert('Failed to add key: ' + err.message);
}
}
async function removeSshKey(key) {
if (!confirm('Remove this SSH key?')) return;
try {
const res = await apiFetch('/api/devpod/keys', { method: 'DELETE', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ key }) });
if (!res.ok) throw new Error((await res.json()).message);
const { keys } = await res.json();
renderSshKeys(keys);
} catch (err) {
alert('Failed to remove key: ' + err.message);
}
}
// ── 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>