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,128 @@
|
||||
/* admin.js — shared between global dashboard and sandbox drill-down */
|
||||
(function () {
|
||||
// ── Relative expiry display ─────────────────────────────────────────────────
|
||||
const expiresRaw = document.getElementById('expires-raw');
|
||||
const expiresVal = document.getElementById('expires-val');
|
||||
if (expiresRaw && expiresVal) {
|
||||
const d = new Date(expiresRaw.textContent.trim());
|
||||
const days = Math.round((d - Date.now()) / 86400000);
|
||||
expiresVal.textContent = days < 0 ? `expired ${-days}d ago` : `${days}d left`;
|
||||
expiresVal.style.color = days < 7 ? '#f85149' : days < 30 ? '#e3b341' : '#3fb950';
|
||||
}
|
||||
|
||||
// ── Request count formatting ────────────────────────────────────────────────
|
||||
const reqFmt = document.getElementById('req-fmt');
|
||||
if (reqFmt && window.__D) {
|
||||
const n = __D.totalRequests || 0;
|
||||
reqFmt.textContent = n >= 1e6 ? (n / 1e6).toFixed(1) + 'M'
|
||||
: n >= 1e3 ? (n / 1e3).toFixed(1) + 'k'
|
||||
: n.toString();
|
||||
}
|
||||
|
||||
// ── Timestamp ───────────────────────────────────────────────────────────────
|
||||
const ts = document.getElementById('refresh-ts');
|
||||
if (ts) ts.textContent = 'loaded ' + new Date().toLocaleTimeString();
|
||||
|
||||
// ── D3 world map ────────────────────────────────────────────────────────────
|
||||
const mapEl = document.getElementById('world-map');
|
||||
if (!mapEl || typeof d3 === 'undefined') return;
|
||||
|
||||
const W = mapEl.parentElement.clientWidth || 960;
|
||||
const H = Math.round(W * 0.42);
|
||||
mapEl.setAttribute('viewBox', `0 0 ${W} ${H}`);
|
||||
mapEl.style.height = H + 'px';
|
||||
|
||||
const projection = d3.geoNaturalEarth1()
|
||||
.scale(W / 6.3)
|
||||
.translate([W / 2, H / 2]);
|
||||
const path = d3.geoPath().projection(projection);
|
||||
const svg = d3.select(mapEl);
|
||||
|
||||
const TIER_COLORS = {
|
||||
FREE: '#484f58', STANDARD: '#388bfd', PROFESSIONAL: '#a371f7',
|
||||
COMMUNITY: '#3fb950', FOUNDER: '#e3b341', DEMO: '#58a6ff'
|
||||
};
|
||||
|
||||
fetch('https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json')
|
||||
.then(r => r.json())
|
||||
.then(world => {
|
||||
svg.append('g').selectAll('path')
|
||||
.data(topojson.feature(world, world.objects.countries).features)
|
||||
.join('path')
|
||||
.attr('d', path)
|
||||
.attr('fill', '#0d2033')
|
||||
.attr('stroke', '#1c2d3f')
|
||||
.attr('stroke-width', 0.4);
|
||||
|
||||
// Points: from stats (global) or sandbox (drill-down)
|
||||
let points = [];
|
||||
if (window.__D) {
|
||||
if (__D.registrarPoints) {
|
||||
// global dashboard
|
||||
points = __D.registrarPoints.map(p => ({
|
||||
lat: p.lat, lon: p.lon, label: p.name, tier: p.tier, type: 'registrar'
|
||||
}));
|
||||
} else if (__D.registrarLat != null) {
|
||||
// sandbox drill-down — registrar star
|
||||
points.push({ lat: __D.registrarLat, lon: __D.registrarLon, label: __D.name, tier: __D.tier, type: 'registrar' });
|
||||
// agent visits
|
||||
(__D.recentVisits || []).forEach(v => {
|
||||
points.push({ lat: v.lat, lon: v.lon, type: 'agent' });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Agent blinks
|
||||
svg.append('g').selectAll('circle.agent')
|
||||
.data(points.filter(p => p.type === 'agent'))
|
||||
.join('circle')
|
||||
.attr('class', 'agent-blink')
|
||||
.attr('cx', p => projection([p.lon, p.lat])?.[0])
|
||||
.attr('cy', p => projection([p.lon, p.lat])?.[1])
|
||||
.attr('r', 3)
|
||||
.style('animation-delay', (_, i) => `${(i * 0.4) % 2.8}s`);
|
||||
|
||||
// Registrar stars
|
||||
svg.append('g').selectAll('text.star')
|
||||
.data(points.filter(p => p.type === 'registrar'))
|
||||
.join('text')
|
||||
.attr('class', 'registrar-star')
|
||||
.attr('x', p => projection([p.lon, p.lat])?.[0])
|
||||
.attr('y', p => projection([p.lon, p.lat])?.[1])
|
||||
.attr('fill', p => TIER_COLORS[p.tier] || '#ffd700')
|
||||
.text('★')
|
||||
.append('title').text(p => `${p.label} (${p.tier})`);
|
||||
});
|
||||
|
||||
// ── Tier donut (global dashboard only) ─────────────────────────────────────
|
||||
const donutEl = document.getElementById('donut');
|
||||
const legendEl = document.getElementById('tier-legend');
|
||||
if (donutEl && window.__D && __D.byTier) {
|
||||
const data = Object.entries(__D.byTier).map(([tier, count]) => ({ tier, count }));
|
||||
const total = data.reduce((s, d) => s + d.count, 0);
|
||||
const cx = 60, cy = 60, r = 46, ri = 28;
|
||||
const svgD = d3.select(donutEl);
|
||||
const pie = d3.pie().value(d => d.count).sort(null);
|
||||
const arc = d3.arc().innerRadius(ri).outerRadius(r);
|
||||
|
||||
svgD.append('g').attr('transform', `translate(${cx},${cy})`)
|
||||
.selectAll('path').data(pie(data)).join('path')
|
||||
.attr('d', arc)
|
||||
.attr('fill', d => TIER_COLORS[d.data.tier] || '#484f58')
|
||||
.attr('stroke', '#0d1117').attr('stroke-width', 1.5)
|
||||
.append('title').text(d => `${d.data.tier}: ${d.data.count}`);
|
||||
|
||||
svgD.append('text').attr('x', cx).attr('y', cy + 5)
|
||||
.attr('text-anchor', 'middle').attr('dominant-baseline', 'middle')
|
||||
.attr('fill', '#e6edf3').attr('font-size', '18').attr('font-weight', '700')
|
||||
.text(total);
|
||||
|
||||
data.forEach(d => {
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = `<span class="tier-dot" style="background:${TIER_COLORS[d.tier] || '#484f58'}"></span>
|
||||
<span style="color:#8b949e">${d.tier}</span>
|
||||
<span style="margin-left:auto;color:#e6edf3">${d.count}</span>`;
|
||||
legendEl.appendChild(li);
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,14 @@
|
||||
quarkus.http.port=8084
|
||||
quarkus.smallrye-health.root-path=/q/health
|
||||
quarkus.log.level=${LOG_LEVEL:INFO}
|
||||
|
||||
# Registry REST client — all admin API calls go through here
|
||||
quarkus.rest-client.registry.url=${APIX_REGISTRY_URL:http://registry:8180}
|
||||
quarkus.rest-client.registry.connect-timeout=5000
|
||||
quarkus.rest-client.registry.read-timeout=15000
|
||||
|
||||
# Admin API key — must match APIX_API_KEY on the registry
|
||||
apix.admin.key=${APIX_API_KEY:dev-insecure-key-change-in-prod}
|
||||
|
||||
# Session cookie name
|
||||
apix.admin.cookie-name=apix_admin_session
|
||||
@@ -0,0 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>APIX Admin — Error</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background: #0d1117; color: #c9d1d9; font-family: 'SF Mono','Consolas',monospace; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
||||
.card { text-align: center; }
|
||||
.label { font-size: 0.75rem; color: #f85149; letter-spacing: 0.1em; margin-bottom: 0.5rem; }
|
||||
.msg { color: #8b949e; font-size: 0.9rem; margin-bottom: 1.5rem; }
|
||||
a { color: #58a6ff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="label">ERROR</div>
|
||||
<div class="msg">{message}</div>
|
||||
<a href="/">← Back to dashboard</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,111 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>APIX Admin — Dashboard</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: 14px; }
|
||||
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; }
|
||||
|
||||
.stats-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 1px; background: #21262d; border-bottom: 1px solid #21262d;
|
||||
}
|
||||
.stat { background: #0d1117; padding: 1.2rem 1.5rem; }
|
||||
.stat-label { font-size: 0.65rem; color: #484f58; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 0.3rem; }
|
||||
.stat-value { font-size: 1.8rem; color: #e6edf3; font-weight: 700; }
|
||||
.stat-sub { font-size: 0.7rem; color: #484f58; margin-top: 0.2rem; }
|
||||
|
||||
.layout { display: grid; grid-template-columns: 1fr 320px; gap: 1px; background: #21262d; }
|
||||
.panel { background: #0d1117; padding: 1.5rem; }
|
||||
.panel-title { font-size: 0.7rem; color: #484f58; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 1rem; }
|
||||
|
||||
#map-wrap { background: #060d18; border-bottom: 1px solid #21262d; }
|
||||
#world-map { display: block; width: 100%; }
|
||||
|
||||
.donut-wrap { display: flex; align-items: center; gap: 1.5rem; }
|
||||
#donut { width: 120px; height: 120px; flex-shrink: 0; }
|
||||
.tier-list { list-style: none; }
|
||||
.tier-list li { display: flex; align-items: center; gap: 0.5rem; font-size: 0.8rem; margin-bottom: 0.4rem; }
|
||||
.tier-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
|
||||
.footer { padding: 0.75rem 1.5rem; border-top: 1px solid #21262d; font-size: 0.7rem; color: #484f58; display: flex; justify-content: space-between; }
|
||||
|
||||
@keyframes star-glow {
|
||||
0%,100% { filter: drop-shadow(0 0 3px #ffd700) drop-shadow(0 0 6px #ffd70066); }
|
||||
50% { filter: drop-shadow(0 0 8px #ffd700) drop-shadow(0 0 18px #ffd700aa); }
|
||||
}
|
||||
.registrar-star { font-size: 12px; fill: #ffd700; dominant-baseline: central; text-anchor: middle; animation: star-glow 2.4s ease-in-out infinite; cursor: default; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<div class="nav-logo">APIX · <span>Admin</span></div>
|
||||
<a class="nav-link active" href="/">Dashboard</a>
|
||||
<a class="nav-link" href="/sandboxes">Sandboxes</a>
|
||||
<div class="nav-right"><a class="nav-link" href="/login/logout">Sign out</a></div>
|
||||
</nav>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat">
|
||||
<div class="stat-label">Total Sandboxes</div>
|
||||
<div class="stat-value">{stats.totalSandboxes}</div>
|
||||
<div class="stat-sub">{stats.activeSandboxes} active · {stats.expiredSandboxes} expired</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Services registered</div>
|
||||
<div class="stat-value">{stats.totalServices}</div>
|
||||
<div class="stat-sub">across all sandboxes</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Total Requests</div>
|
||||
<div class="stat-value" id="req-fmt">—</div>
|
||||
<div class="stat-sub">cumulative</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="map-wrap"><svg id="world-map"></svg></div>
|
||||
|
||||
<div class="layout">
|
||||
<div class="panel">
|
||||
<div class="panel-title">Tier distribution</div>
|
||||
<div class="donut-wrap">
|
||||
<svg id="donut" viewBox="0 0 120 120"></svg>
|
||||
<ul class="tier-list" id="tier-legend"></ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">Quick links</div>
|
||||
<p style="font-size:0.8rem;color:#8b949e;line-height:1.8">
|
||||
<a href="/sandboxes">All sandboxes →</a><br>
|
||||
<a href="/sandboxes?tier=COMMUNITY">COMMUNITY tier →</a><br>
|
||||
<a href="/sandboxes?tier=FREE">FREE tier →</a><br>
|
||||
<a href="/sandboxes?tier=FOUNDER">FOUNDER tier →</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<span>APIX Admin · internal</span>
|
||||
<span id="refresh-ts"></span>
|
||||
</div>
|
||||
|
||||
<script>var __D = {statsJson.raw};</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/topojson-client@3/dist/topojson-client.min.js"></script>
|
||||
<script src="/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,136 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{dashboard.name} · 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: 14px; }
|
||||
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:hover { color: #e6edf3; }
|
||||
.nav-right { margin-left: auto; }
|
||||
|
||||
.header { padding: 1rem 1.5rem; border-bottom: 1px solid #21262d; display: flex; align-items: center; gap: 1rem; }
|
||||
.sb-name { font-size: 1rem; color: #e6edf3; font-weight: 600; }
|
||||
.tier-badge {
|
||||
background: #161b22; border: 1px solid #30363d;
|
||||
color: #8b949e; padding: 0.1rem 0.5rem; border-radius: 12px;
|
||||
font-size: 0.7rem; letter-spacing: 0.05em;
|
||||
}
|
||||
.sb-id { font-size: 0.75rem; color: #484f58; }
|
||||
|
||||
.stats-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(200px,1fr));
|
||||
gap: 1px; background: #21262d; border-bottom: 1px solid #21262d;
|
||||
}
|
||||
.stat { background: #0d1117; padding: 1rem 1.5rem; }
|
||||
.stat-label { font-size: 0.65rem; color: #484f58; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 0.3rem; }
|
||||
.stat-value { font-size: 1.4rem; color: #e6edf3; font-weight: 600; }
|
||||
.stat-sub { font-size: 0.7rem; color: #484f58; margin-top: 0.2rem; }
|
||||
|
||||
#map-wrap { background: #060d18; }
|
||||
#world-map { display: block; width: 100%; }
|
||||
|
||||
.actions { padding: 1.5rem; border-top: 1px solid #21262d; display: flex; gap: 1rem; flex-wrap: wrap; }
|
||||
.actions-title { width: 100%; font-size: 0.7rem; color: #484f58; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 0.5rem; }
|
||||
form { display: flex; gap: 0.5rem; align-items: center; }
|
||||
select, button {
|
||||
background: #161b22; border: 1px solid #30363d; color: #c9d1d9;
|
||||
font-family: inherit; font-size: 0.8rem;
|
||||
border-radius: 4px; padding: 0.4rem 0.7rem; cursor: pointer;
|
||||
}
|
||||
select:focus, button:focus { outline: none; border-color: #58a6ff; }
|
||||
button.promote { background: #238636; border-color: #238636; color: #fff; font-weight: 600; }
|
||||
button.promote:hover { background: #2ea043; }
|
||||
|
||||
.footer { padding: 0.75rem 1.5rem; border-top: 1px solid #21262d; font-size: 0.7rem; color: #484f58; }
|
||||
|
||||
@keyframes star-glow {
|
||||
0%,100% { filter: drop-shadow(0 0 3px #ffd700) drop-shadow(0 0 6px #ffd70066); }
|
||||
50% { filter: drop-shadow(0 0 8px #ffd700) drop-shadow(0 0 18px #ffd700aa); }
|
||||
}
|
||||
.registrar-star { font-size: 14px; fill: #ffd700; dominant-baseline: central; text-anchor: middle; animation: star-glow 2.4s ease-in-out infinite; }
|
||||
@keyframes blink-pulse {
|
||||
0% { opacity:0; r:2; } 25% { opacity:0.9; r:5; } 65% { opacity:0.4; r:4; } 100% { opacity:0; r:2; }
|
||||
}
|
||||
.agent-blink { fill: #3d8bfd; opacity:0; animation: blink-pulse 2.8s ease-in-out infinite; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<div class="nav-logo">APIX · <span>Admin</span></div>
|
||||
<a class="nav-link" href="/">Dashboard</a>
|
||||
<a class="nav-link" href="/sandboxes">Sandboxes</a>
|
||||
<div class="nav-right"><a class="nav-link" href="/login/logout">Sign out</a></div>
|
||||
</nav>
|
||||
|
||||
<div class="header">
|
||||
<span class="sb-name">{dashboard.name}</span>
|
||||
<span class="tier-badge">{dashboard.tier}</span>
|
||||
<span class="sb-id">{dashboard.sandboxId}</span>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat">
|
||||
<div class="stat-label">Services</div>
|
||||
<div class="stat-value">{#if dashboard.usage.get('SERVICE_REGISTERED') != null}{dashboard.usage.get('SERVICE_REGISTERED')}{#else}0{/if}</div>
|
||||
<div class="stat-sub">of {#if dashboard.maxServices != null}{dashboard.maxServices}{#else}∞{/if} allowed</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Searches</div>
|
||||
<div class="stat-value">{#if dashboard.usage.get('SERVICE_SEARCHED') != null}{dashboard.usage.get('SERVICE_SEARCHED')}{#else}0{/if}</div>
|
||||
<div class="stat-sub">capability discovery calls</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Rate limit</div>
|
||||
<div class="stat-value">{dashboard.ratePerMinute}</div>
|
||||
<div class="stat-sub">req / min</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">Expires</div>
|
||||
<div class="stat-value" id="expires-val">—</div>
|
||||
<div class="stat-sub" id="expires-raw">{dashboard.expiresAt}</div>
|
||||
</div>
|
||||
{#if dashboard.registrarLocation != null}
|
||||
<div class="stat">
|
||||
<div class="stat-label">Location</div>
|
||||
<div class="stat-value" style="font-size:1rem">{dashboard.registrarLocation}</div>
|
||||
<div class="stat-sub">owner-declared</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div id="map-wrap"><svg id="world-map"></svg></div>
|
||||
|
||||
<div class="actions">
|
||||
<div class="actions-title">Admin actions</div>
|
||||
<form method="POST" action="/sandboxes/{dashboard.sandboxId}/promote">
|
||||
<select name="tier">
|
||||
<option value="FREE">FREE</option>
|
||||
<option value="STANDARD">STANDARD</option>
|
||||
<option value="PROFESSIONAL">PROFESSIONAL</option>
|
||||
<option value="COMMUNITY">COMMUNITY</option>
|
||||
<option value="FOUNDER">FOUNDER</option>
|
||||
<option value="DEMO">DEMO</option>
|
||||
</select>
|
||||
<button type="submit" class="promote">Promote tier</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="footer">sandbox id: {dashboard.sandboxId} · created {dashboard.createdAt}</div>
|
||||
|
||||
<script>var __D = {dataJson.raw};</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/topojson-client@3/dist/topojson-client.min.js"></script>
|
||||
<script src="/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,60 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>APIX Admin — Login</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
background: #0d1117; color: #c9d1d9;
|
||||
font-family: 'SF Mono', 'Consolas', 'Fira Code', monospace;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.card {
|
||||
width: 380px;
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
padding: 2rem;
|
||||
}
|
||||
.logo { color: #8b949e; font-size: 0.75rem; letter-spacing: 0.1em; margin-bottom: 1.5rem; }
|
||||
.logo span { color: #e6edf3; font-size: 1rem; font-weight: 600; }
|
||||
label { display: block; font-size: 0.75rem; color: #8b949e; margin-bottom: 0.4rem; letter-spacing: 0.04em; }
|
||||
input[type=password] {
|
||||
width: 100%; padding: 0.6rem 0.8rem;
|
||||
background: #0d1117; border: 1px solid #30363d;
|
||||
border-radius: 4px; color: #e6edf3;
|
||||
font-family: inherit; font-size: 0.9rem;
|
||||
outline: none; margin-bottom: 1rem;
|
||||
}
|
||||
input[type=password]:focus { border-color: #58a6ff; }
|
||||
button {
|
||||
width: 100%; padding: 0.65rem;
|
||||
background: #238636; border: none;
|
||||
border-radius: 4px; color: #fff;
|
||||
font-family: inherit; font-size: 0.9rem;
|
||||
cursor: pointer; font-weight: 600;
|
||||
}
|
||||
button:hover { background: #2ea043; }
|
||||
.error {
|
||||
background: #2d1b1b; border: 1px solid #f85149;
|
||||
color: #f85149; border-radius: 4px;
|
||||
padding: 0.5rem 0.8rem; font-size: 0.8rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">APIX · <span>Admin</span></div>
|
||||
{#if invalid}<div class="error">Invalid admin key. Try again.</div>{/if}
|
||||
<form method="POST" action="/login">
|
||||
<label>Admin API Key</label>
|
||||
<input type="password" name="key" autofocus autocomplete="current-password" placeholder="apix_...">
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head><meta charset="UTF-8"><title>Error · APIX Admin</title>
|
||||
<style>* { box-sizing:border-box;margin:0;padding:0; } body { background:#0d1117;color:#c9d1d9;font-family:monospace;display:flex;align-items:center;justify-content:center;min-height:100vh; } .card { text-align:center; } .label { color:#f85149;font-size:.75rem;margin-bottom:.5rem; } .msg { color:#8b949e;margin-bottom:1.5rem; } a { color:#58a6ff; }</style>
|
||||
</head>
|
||||
<body><div class="card"><div class="label">ERROR</div><div class="msg">{message}</div><a href="/sandboxes">← Back to list</a></div></body>
|
||||
</html>
|
||||
@@ -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