feat(admin): apix-admin UI at admin.api-index.org
Deploy to Production / deploy (push) Failing after 47s
Deploy to Production / deploy (push) Failing after 47s
Internal maintainer dashboard: global stats, sandbox list with
tier-filter and pagination, sandbox drill-down with D3 world map,
tier-promotion form. API-key auth via HttpOnly cookie. Port 8084,
rolling a/b deploy alongside the existing service pairs.
- apix-common: AdminSandboxSummary, AdminSandboxListResponse, AdminStatsResponse DTOs
- apix-registry: AdminResource (4 endpoints), SandboxService.listAllSandboxes + getGlobalStats
- apix-registry: SandboxResource GET /{uuid}/dashboard endpoint (was missing — fixes portal 500)
- apix-portal: RegistryClient path corrected to /dashboard
- apix-admin: new Quarkus module — auth filter, registry REST client, 3 resources, 6 Qute templates, admin.js
- infra: Dockerfile.admin, docker-compose admin-a/b services, Caddyfile admin.api-index.org block
- scripts: deploy-bluegreen.sh extended for admin-a/b
Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sandboxes · APIX Admin</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background: #0d1117; color: #c9d1d9; font-family: 'SF Mono','Consolas','Fira Code',monospace; font-size: 13px; }
|
||||
a { color: #58a6ff; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
nav {
|
||||
display: flex; align-items: center; gap: 1.5rem;
|
||||
padding: 0.75rem 1.5rem; border-bottom: 1px solid #21262d; background: #161b22;
|
||||
}
|
||||
.nav-logo { color: #8b949e; font-size: 0.75rem; }
|
||||
.nav-logo span { color: #e6edf3; font-weight: 600; font-size: 0.9rem; }
|
||||
.nav-link { font-size: 0.8rem; color: #8b949e; }
|
||||
.nav-link.active, .nav-link:hover { color: #e6edf3; }
|
||||
.nav-right { margin-left: auto; }
|
||||
|
||||
.toolbar {
|
||||
padding: 0.75rem 1.5rem; border-bottom: 1px solid #21262d;
|
||||
display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap;
|
||||
}
|
||||
.toolbar-label { font-size: 0.7rem; color: #484f58; }
|
||||
.filter-link {
|
||||
font-size: 0.75rem; color: #8b949e;
|
||||
padding: 0.2rem 0.6rem; border: 1px solid #30363d; border-radius: 12px;
|
||||
}
|
||||
.filter-link:hover, .filter-link.active { color: #e6edf3; border-color: #58a6ff; }
|
||||
.count { margin-left: auto; font-size: 0.75rem; color: #484f58; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th {
|
||||
text-align: left; padding: 0.6rem 1.5rem;
|
||||
font-size: 0.65rem; color: #484f58; text-transform: uppercase; letter-spacing: 0.06em;
|
||||
border-bottom: 1px solid #21262d; background: #161b22;
|
||||
}
|
||||
td { padding: 0.55rem 1.5rem; border-bottom: 1px solid #161b22; vertical-align: middle; }
|
||||
tr:hover td { background: #161b22; }
|
||||
|
||||
.tier-badge {
|
||||
background: #0d1117; border: 1px solid #30363d;
|
||||
color: #8b949e; padding: 0.1rem 0.4rem; border-radius: 10px;
|
||||
font-size: 0.65rem; letter-spacing: 0.04em;
|
||||
}
|
||||
.tier-COMMUNITY { border-color: #1f6feb; color: #58a6ff; }
|
||||
.tier-FOUNDER { border-color: #9e6a03; color: #e3b341; }
|
||||
.tier-DEMO { border-color: #388bfd; color: #79c0ff; }
|
||||
|
||||
.expired { color: #f85149; }
|
||||
.ok { color: #3fb950; }
|
||||
|
||||
.pagination { padding: 1rem 1.5rem; display: flex; gap: 1rem; align-items: center; border-top: 1px solid #21262d; }
|
||||
.page-link { font-size: 0.8rem; color: #58a6ff; }
|
||||
.page-info { font-size: 0.75rem; color: #484f58; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<div class="nav-logo">APIX · <span>Admin</span></div>
|
||||
<a class="nav-link" href="/">Dashboard</a>
|
||||
<a class="nav-link active" href="/sandboxes">Sandboxes</a>
|
||||
<div class="nav-right"><a class="nav-link" href="/login/logout">Sign out</a></div>
|
||||
</nav>
|
||||
|
||||
<div class="toolbar">
|
||||
<span class="toolbar-label">Filter:</span>
|
||||
<a class="filter-link {#if tier == ''}active{/if}" href="/sandboxes">All</a>
|
||||
<a class="filter-link {#if tier == 'FREE'}active{/if}" href="/sandboxes?tier=FREE">FREE</a>
|
||||
<a class="filter-link {#if tier == 'COMMUNITY'}active{/if}" href="/sandboxes?tier=COMMUNITY">COMMUNITY</a>
|
||||
<a class="filter-link {#if tier == 'FOUNDER'}active{/if}" href="/sandboxes?tier=FOUNDER">FOUNDER</a>
|
||||
<a class="filter-link {#if tier == 'DEMO'}active{/if}" href="/sandboxes?tier=DEMO">DEMO</a>
|
||||
<span class="count">{result.total} total</span>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Tier</th>
|
||||
<th>Services</th>
|
||||
<th>Requests</th>
|
||||
<th>Location</th>
|
||||
<th>Expires</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#for sb in result.sandboxes}
|
||||
<tr>
|
||||
<td><a href="/sandbox/{sb.sandboxId}">{sb.name}</a>
|
||||
<div style="font-size:0.65rem;color:#484f58;margin-top:2px">{sb.sandboxId}</div>
|
||||
</td>
|
||||
<td><span class="tier-badge tier-{sb.tier}">{sb.tier}</span></td>
|
||||
<td>{sb.serviceCount}</td>
|
||||
<td>{sb.totalRequests}</td>
|
||||
<td>{#if sb.location != null}{sb.location}{#else}<span style="color:#484f58">—</span>{/if}</td>
|
||||
<td class="{#if sb.expired}expired{#else}ok{/if}" data-expires="{sb.expiresAt}">{sb.expiresAt}</td>
|
||||
</tr>
|
||||
{/for}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="pagination">
|
||||
{#if page > 0}
|
||||
<a class="page-link" href="/sandboxes?page={page - 1}&size={size}&tier={tier}">← Previous</a>
|
||||
{/if}
|
||||
<span class="page-info">Page {page + 1} · {result.sandboxes.size} of {result.total}</span>
|
||||
{#if (page + 1) * size < result.total}
|
||||
<a class="page-link" href="/sandboxes?page={page + 1}&size={size}&tier={tier}">Next →</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('[data-expires]').forEach(td => {
|
||||
const d = new Date(td.dataset.expires);
|
||||
const now = new Date();
|
||||
const days = Math.round((d - now) / 86400000);
|
||||
td.textContent = days < 0 ? `expired ${-days}d ago` : `in ${days}d`;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user