feat(admin): apix-admin UI at admin.api-index.org
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:
Carsten Rehfeld
2026-05-16 21:18:48 +02:00
parent e385d05ce1
commit 82a379e536
32 changed files with 1300 additions and 32 deletions
+6
View File
@@ -35,3 +35,9 @@ REGISTRY_BASE_URL=http://localhost:8180
# ── Logging ─────────────────────────────────────────────────────────────────── # ── Logging ───────────────────────────────────────────────────────────────────
# DEBUG in dev; INFO in prod # DEBUG in dev; INFO in prod
LOG_LEVEL=DEBUG LOG_LEVEL=DEBUG
# ── Observability ─────────────────────────────────────────────────────────────
# Grafana admin password. Change in any non-local environment.
GRAFANA_ADMIN_PASSWORD=admin
# Set to your public domain in production, e.g. https://grafana.api-index.org
GRAFANA_ROOT_URL=http://localhost:3000
+4
View File
@@ -7,6 +7,9 @@ logs/
# Local environment — never commit real credentials # Local environment — never commit real credentials
.env .env
# Bunny.net pull zone ID written by setup-bunnynet.sh
.bunnynet-pull-zone-id
# Maven build output # Maven build output
target/ target/
**/target/ **/target/
@@ -22,3 +25,4 @@ sanctions-cache/
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
/.allure/
+70
View File
@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.botstandards</groupId>
<artifactId>apix-parent</artifactId>
<version>${revision}</version>
</parent>
<artifactId>apix-admin</artifactId>
<name>APIX :: Admin</name>
<description>Internal maintainer UI at admin.api-index.org. API-key auth, no username/password. Port 8084.</description>
<dependencies>
<dependency>
<groupId>org.botstandards</groupId>
<artifactId>apix-common</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-qute</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-client</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-client-jackson</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-health</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit5</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>${quarkus.platform.group-id}</groupId>
<artifactId>quarkus-maven-plugin</artifactId>
<extensions>true</extensions>
<executions>
<execution>
<goals>
<goal>build</goal>
<goal>generate-code</goal>
<goal>generate-code-tests</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,46 @@
package org.botstandards.apix.admin.client;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import org.botstandards.apix.common.*;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
import java.util.Map;
@RegisterRestClient(configKey = "registry")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public interface AdminRegistryClient {
@GET
@Path("/admin/sandboxes")
AdminSandboxListResponse listSandboxes(
@HeaderParam("X-Admin-Key") String adminKey,
@QueryParam("page") int page,
@QueryParam("size") int size,
@QueryParam("tier") String tier);
@GET
@Path("/admin/stats")
AdminStatsResponse stats(@HeaderParam("X-Admin-Key") String adminKey);
@GET
@Path("/admin/sandboxes/{uuid}")
SandboxDashboardResponse sandboxDetail(
@HeaderParam("X-Admin-Key") String adminKey,
@PathParam("uuid") String uuid);
@PATCH
@Path("/admin/sandboxes/{uuid}/tier")
Map<String, Object> promoteTier(
@HeaderParam("X-Admin-Key") String adminKey,
@PathParam("uuid") String uuid,
Map<String, String> body);
@PATCH
@Path("/sandbox/admin/{uuid}/tier")
Map<String, Object> promoteTierLegacy(
@HeaderParam("X-Admin-Key") String adminKey,
@PathParam("uuid") String uuid,
Map<String, String> body);
}
@@ -0,0 +1,41 @@
package org.botstandards.apix.admin.filter;
import jakarta.ws.rs.container.ContainerRequestContext;
import jakarta.ws.rs.container.ContainerRequestFilter;
import jakarta.ws.rs.core.Cookie;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
import jakarta.ws.rs.ext.Provider;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.io.IOException;
import java.net.URI;
@Provider
public class AdminAuthFilter implements ContainerRequestFilter {
@ConfigProperty(name = "apix.admin.key")
String adminKey;
@ConfigProperty(name = "apix.admin.cookie-name")
String cookieName;
private static final java.util.Set<String> PUBLIC_PATHS = java.util.Set.of(
"/login", "/q/health", "/q/health/live", "/q/health/ready"
);
@Override
public void filter(ContainerRequestContext ctx) throws IOException {
UriInfo uri = ctx.getUriInfo();
String path = "/" + uri.getPath();
for (String pub : PUBLIC_PATHS) {
if (path.startsWith(pub)) return;
}
Cookie session = ctx.getCookies().get(cookieName);
if (session != null && adminKey.equals(session.getValue())) return;
ctx.abortWith(Response.seeOther(URI.create("/login")).build());
}
}
@@ -0,0 +1,62 @@
package org.botstandards.apix.admin.resource;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.quarkus.qute.CheckedTemplate;
import io.quarkus.qute.TemplateInstance;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import org.botstandards.apix.common.AdminStatsResponse;
import org.botstandards.apix.common.SandboxDashboardResponse;
import org.botstandards.apix.admin.client.AdminRegistryClient;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import org.jboss.logging.Logger;
@Path("/")
@Produces(MediaType.TEXT_HTML)
public class DashboardResource {
private static final Logger LOG = Logger.getLogger(DashboardResource.class);
@Inject @RestClient
AdminRegistryClient registry;
@Inject
ObjectMapper mapper;
@ConfigProperty(name = "apix.admin.key")
String adminKey;
@CheckedTemplate
static class Templates {
static native TemplateInstance global(AdminStatsResponse stats, String statsJson);
static native TemplateInstance sandbox(SandboxDashboardResponse dashboard, String dataJson);
static native TemplateInstance error(String message);
}
@GET
public TemplateInstance globalDashboard() {
try {
AdminStatsResponse stats = registry.stats(adminKey);
String json = mapper.writeValueAsString(stats).replace("</", "<\\/");
return Templates.global(stats, json);
} catch (Exception e) {
LOG.errorf(e, "Failed to load global stats");
return Templates.error("Could not load stats from registry");
}
}
@GET
@Path("/sandbox/{uuid}")
public TemplateInstance sandboxDashboard(@PathParam("uuid") String uuid) {
try {
SandboxDashboardResponse dashboard = registry.sandboxDetail(adminKey, uuid);
String json = mapper.writeValueAsString(dashboard).replace("</", "<\\/");
return Templates.sandbox(dashboard, json);
} catch (Exception e) {
LOG.errorf(e, "Failed to load sandbox %s", uuid);
return Templates.error("Could not load sandbox " + uuid);
}
}
}
@@ -0,0 +1,59 @@
package org.botstandards.apix.admin.resource;
import io.quarkus.qute.CheckedTemplate;
import io.quarkus.qute.TemplateInstance;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import java.net.URI;
@Path("/login")
public class LoginResource {
@ConfigProperty(name = "apix.admin.key")
String adminKey;
@ConfigProperty(name = "apix.admin.cookie-name")
String cookieName;
@CheckedTemplate
static class Templates {
static native TemplateInstance login(boolean invalid);
}
@GET
@Produces(MediaType.TEXT_HTML)
public TemplateInstance loginPage() {
return Templates.login(false);
}
@POST
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Response authenticate(@FormParam("key") String key) {
if (key == null || !adminKey.equals(key.strip())) {
return Response.ok(Templates.login(true).render())
.type(MediaType.TEXT_HTML)
.build();
}
NewCookie session = new NewCookie.Builder(cookieName)
.value(adminKey)
.path("/")
.httpOnly(true)
.sameSite(NewCookie.SameSite.STRICT)
.maxAge(28800) // 8 hours
.build();
return Response.seeOther(URI.create("/")).cookie(session).build();
}
@GET
@Path("/logout")
public Response logout() {
NewCookie expired = new NewCookie.Builder(cookieName)
.value("")
.path("/")
.maxAge(0)
.build();
return Response.seeOther(URI.create("/login")).cookie(expired).build();
}
}
@@ -0,0 +1,61 @@
package org.botstandards.apix.admin.resource;
import io.quarkus.qute.CheckedTemplate;
import io.quarkus.qute.TemplateInstance;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.*;
import org.botstandards.apix.common.AdminSandboxListResponse;
import org.botstandards.apix.admin.client.AdminRegistryClient;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import org.jboss.logging.Logger;
import java.net.URI;
import java.util.Map;
@Path("/sandboxes")
@Produces(MediaType.TEXT_HTML)
public class SandboxListResource {
private static final Logger LOG = Logger.getLogger(SandboxListResource.class);
@Inject @RestClient
AdminRegistryClient registry;
@ConfigProperty(name = "apix.admin.key")
String adminKey;
@CheckedTemplate
static class Templates {
static native TemplateInstance list(AdminSandboxListResponse result, int page, int size, String tier);
static native TemplateInstance error(String message);
}
@GET
public TemplateInstance list(
@QueryParam("page") @DefaultValue("0") int page,
@QueryParam("size") @DefaultValue("50") int size,
@QueryParam("tier") @DefaultValue("") String tier) {
try {
String tierParam = tier.isBlank() ? null : tier;
AdminSandboxListResponse result = registry.listSandboxes(adminKey, page, size, tierParam);
return Templates.list(result, page, size, tier);
} catch (Exception e) {
LOG.errorf(e, "Failed to load sandbox list");
return Templates.error("Could not load sandbox list");
}
}
@POST
@Path("/{uuid}/promote")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Response promote(@PathParam("uuid") String uuid, @FormParam("tier") String tier) {
try {
registry.promoteTier(adminKey, uuid, Map.of("tier", tier.toUpperCase()));
} catch (Exception e) {
LOG.errorf(e, "Failed to promote sandbox %s to %s", uuid, tier);
}
return Response.seeOther(URI.create("/sandbox/" + uuid)).build();
}
}
@@ -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>
@@ -0,0 +1,10 @@
package org.botstandards.apix.common;
import java.util.List;
public record AdminSandboxListResponse(
long total,
int page,
int size,
List<AdminSandboxSummary> sandboxes
) {}
@@ -0,0 +1,21 @@
package org.botstandards.apix.common;
import com.fasterxml.jackson.annotation.JsonInclude;
import java.time.Instant;
/** Per-sandbox row in the admin list and stats endpoints. */
public record AdminSandboxSummary(
String sandboxId,
String name,
String tier,
int ratePerMinute,
@JsonInclude(JsonInclude.Include.ALWAYS) Integer maxServices,
Instant createdAt,
Instant expiresAt,
boolean expired,
@JsonInclude(JsonInclude.Include.NON_NULL) String location,
@JsonInclude(JsonInclude.Include.NON_NULL) Double lat,
@JsonInclude(JsonInclude.Include.NON_NULL) Double lon,
long serviceCount,
long totalRequests
) {}
@@ -0,0 +1,17 @@
package org.botstandards.apix.common;
import java.util.List;
import java.util.Map;
public record AdminStatsResponse(
long totalSandboxes,
long activeSandboxes,
long expiredSandboxes,
Map<String, Long> byTier,
long totalServices,
long totalRequests,
/** Geo-points for the world map: one point per sandbox that provided a location. */
List<GeoPoint> registrarPoints
) {
public record GeoPoint(double lat, double lon, String name, String tier) {}
}
@@ -14,6 +14,6 @@ import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
public interface RegistryClient { public interface RegistryClient {
@GET @GET
@Path("/{uuid}") @Path("/{uuid}/dashboard")
SandboxDashboardResponse getDashboard(@PathParam("uuid") String uuid); SandboxDashboardResponse getDashboard(@PathParam("uuid") String uuid);
} }
@@ -0,0 +1,97 @@
package org.botstandards.apix.registry.resource;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.botstandards.apix.common.AdminSandboxListResponse;
import org.botstandards.apix.common.AdminStatsResponse;
import org.botstandards.apix.common.SandboxDashboardResponse;
import org.botstandards.apix.registry.service.SandboxService;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.eclipse.microprofile.openapi.annotations.Operation;
import java.util.Map;
import java.util.UUID;
@Path("/admin")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class AdminResource {
@Inject
SandboxService sandboxService;
@ConfigProperty(name = "apix.api-key", defaultValue = "")
String adminApiKey;
@GET
@Path("/sandboxes")
@Operation(summary = "List all sandboxes (admin)", description = "Requires X-Admin-Key.")
public AdminSandboxListResponse listSandboxes(
@HeaderParam("X-Admin-Key") String adminKey,
@QueryParam("page") @DefaultValue("0") int page,
@QueryParam("size") @DefaultValue("50") int size,
@QueryParam("tier") String tier) {
requireAdmin(adminKey);
if (size > 200) size = 200;
return sandboxService.listAllSandboxes(page, size, tier);
}
@GET
@Path("/stats")
@Operation(summary = "Global registry statistics (admin)", description = "Requires X-Admin-Key.")
public AdminStatsResponse stats(@HeaderParam("X-Admin-Key") String adminKey) {
requireAdmin(adminKey);
return sandboxService.getGlobalStats();
}
@GET
@Path("/sandboxes/{uuid}")
@Operation(summary = "Full dashboard view of any sandbox (admin)", description = "Requires X-Admin-Key.")
public SandboxDashboardResponse sandboxDetail(
@HeaderParam("X-Admin-Key") String adminKey,
@PathParam("uuid") String uuidStr) {
requireAdmin(adminKey);
UUID id;
try { id = UUID.fromString(uuidStr); }
catch (IllegalArgumentException e) {
throw new WebApplicationException(
Response.status(404).entity(Map.of("message", "Sandbox not found")).build());
}
return sandboxService.getDashboard(id);
}
@PATCH
@Path("/sandboxes/{uuid}/tier")
@Operation(summary = "Promote sandbox tier (admin)", description = "Requires X-Admin-Key.")
public Response promoteTier(
@HeaderParam("X-Admin-Key") String adminKey,
@PathParam("uuid") String uuidStr,
Map<String, String> body) {
requireAdmin(adminKey);
String newTier = body == null ? null : body.get("tier");
if (newTier == null || newTier.isBlank())
return Response.status(400).entity(Map.of("message", "Body must contain 'tier'")).build();
UUID id;
try { id = UUID.fromString(uuidStr); }
catch (IllegalArgumentException e) {
throw new WebApplicationException(
Response.status(404).entity(Map.of("message", "Sandbox not found")).build());
}
var sb = sandboxService.requireById(id);
sandboxService.promoteTier(sb, newTier.toUpperCase());
return Response.ok(Map.of(
"sandboxId", sb.id.toString(),
"tier", sb.tier,
"ratePerMinute", sb.ratePerMinute,
"expiresAt", sb.expiresAt.toString())).build();
}
private void requireAdmin(String key) {
if (adminApiKey.isBlank() || !adminApiKey.equals(key)) {
throw new WebApplicationException(
Response.status(401).entity(Map.of("message", "Invalid or missing admin key")).build());
}
}
}
@@ -7,6 +7,7 @@ import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.Response;
import org.botstandards.apix.common.BsmPayload; import org.botstandards.apix.common.BsmPayload;
import org.botstandards.apix.common.SandboxDashboardResponse;
import org.botstandards.apix.registry.dto.*; import org.botstandards.apix.registry.dto.*;
import org.botstandards.apix.registry.entity.SandboxEntity; import org.botstandards.apix.registry.entity.SandboxEntity;
import org.botstandards.apix.registry.service.SandboxService; import org.botstandards.apix.registry.service.SandboxService;
@@ -82,6 +83,19 @@ public class SandboxResource {
sb.expiresAt, sandboxService.sandboxLinks(base, id.toString())); sb.expiresAt, sandboxService.sandboxLinks(base, id.toString()));
} }
@GET
@Path("/{uuid}/dashboard")
@Operation(
summary = "Full sandbox dashboard — for the portal",
description = "Returns the complete sandbox view: tier caps, usage counters, " +
"recent agent visits with geo-resolved coordinates. " +
"Intended for the portal UI; no authentication required."
)
public SandboxDashboardResponse dashboard(@PathParam("uuid") String uuidStr) {
UUID id = parseUuid(uuidStr);
return sandboxService.getDashboard(id);
}
@POST @POST
@Path("/{uuid}/services") @Path("/{uuid}/services")
@SecurityRequirement(name = "SandboxApiKey") @SecurityRequirement(name = "SandboxApiKey")
@@ -454,6 +454,87 @@ public class SandboxService {
schemaLinks); schemaLinks);
} }
@SuppressWarnings("unchecked")
public AdminSandboxListResponse listAllSandboxes(int page, int size, String tierFilter) {
String tierClause = (tierFilter != null && !tierFilter.isBlank()) ? " AND s.tier = :tier" : "";
String countSql = "SELECT COUNT(*) FROM sandboxes s WHERE 1=1" + tierClause;
String listSql =
"SELECT s.id, s.name, s.tier, s.rate_per_minute, s.max_services, " +
"s.created_at, s.expires_at, s.registrar_location, s.registrar_lat, s.registrar_lon, " +
"COALESCE((SELECT COUNT(*) FROM services sv WHERE sv.sandbox_id = s.id::text), 0), " +
"COALESCE((SELECT SUM(request_count) FROM sandbox_usage_stats u WHERE u.sandbox_id = s.id::text), 0) " +
"FROM sandboxes s WHERE 1=1" + tierClause +
" ORDER BY s.created_at DESC LIMIT :lim OFFSET :off";
var countQ = em.createNativeQuery(countSql);
var listQ = em.createNativeQuery(listSql);
if (!tierClause.isBlank()) {
countQ.setParameter("tier", tierFilter.toUpperCase());
listQ.setParameter("tier", tierFilter.toUpperCase());
}
listQ.setParameter("lim", size).setParameter("off", page * size);
long total = ((Number) countQ.getSingleResult()).longValue();
Instant now = Instant.now();
List<AdminSandboxSummary> rows = ((List<Object[]>) listQ.getResultList()).stream()
.map(r -> new AdminSandboxSummary(
r[0].toString(),
(String) r[1],
(String) r[2],
((Number) r[3]).intValue(),
r[4] != null ? ((Number) r[4]).intValue() : null,
r[5] instanceof java.sql.Timestamp t ? t.toInstant() : null,
r[6] instanceof java.sql.Timestamp t ? t.toInstant() : null,
r[6] instanceof java.sql.Timestamp t && now.isAfter(t.toInstant()),
(String) r[7],
r[8] != null ? ((Number) r[8]).doubleValue() : null,
r[9] != null ? ((Number) r[9]).doubleValue() : null,
((Number) r[10]).longValue(),
((Number) r[11]).longValue()))
.toList();
return new AdminSandboxListResponse(total, page, size, rows);
}
@SuppressWarnings("unchecked")
public AdminStatsResponse getGlobalStats() {
Instant now = Instant.now();
long total = ((Number) em.createNativeQuery("SELECT COUNT(*) FROM sandboxes").getSingleResult()).longValue();
long active = ((Number) em.createNativeQuery("SELECT COUNT(*) FROM sandboxes WHERE expires_at > now()").getSingleResult()).longValue();
long expired = total - active;
List<Object[]> tierRows = em.createNativeQuery(
"SELECT tier, COUNT(*) FROM sandboxes GROUP BY tier ORDER BY tier")
.getResultList();
Map<String, Long> byTier = new LinkedHashMap<>();
for (Object[] r : tierRows) byTier.put((String) r[0], ((Number) r[1]).longValue());
long totalServices = ((Number) em.createNativeQuery(
"SELECT COUNT(*) FROM services").getSingleResult()).longValue();
long totalRequests = 0;
Object reqResult = em.createNativeQuery(
"SELECT COALESCE(SUM(request_count), 0) FROM sandbox_usage_stats").getSingleResult();
if (reqResult != null) totalRequests = ((Number) reqResult).longValue();
List<Object[]> geoRows = em.createNativeQuery(
"SELECT registrar_lat, registrar_lon, name, tier FROM sandboxes " +
"WHERE registrar_lat IS NOT NULL AND registrar_lon IS NOT NULL")
.getResultList();
List<AdminStatsResponse.GeoPoint> points = geoRows.stream()
.map(r -> new AdminStatsResponse.GeoPoint(
((Number) r[0]).doubleValue(),
((Number) r[1]).doubleValue(),
(String) r[2],
(String) r[3]))
.toList();
return new AdminStatsResponse(total, active, expired, byTier, totalServices, totalRequests, points);
}
public SandboxLinks sandboxLinks(String base) { public SandboxLinks sandboxLinks(String base) {
return sandboxLinks(base, null); return sandboxLinks(base, null);
} }
+14
View File
@@ -41,6 +41,20 @@ demo.api-index.org {
header -Server header -Server
} }
admin.api-index.org {
reverse_proxy admin-a:8084 admin-b:8084 {
lb_policy first
health_uri /q/health/live
health_interval 5s
fail_duration 30s
}
header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
header X-Content-Type-Options "nosniff"
header X-Frame-Options "DENY"
header Referrer-Policy "strict-origin"
header -Server
}
git.api-index.org { git.api-index.org {
reverse_proxy gitea:3001 reverse_proxy gitea:3001
header Strict-Transport-Security "max-age=31536000; includeSubDomains" header Strict-Transport-Security "max-age=31536000; includeSubDomains"
+8
View File
@@ -0,0 +1,8 @@
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S apix && adduser -S apix -G apix
WORKDIR /app
COPY apix-admin/target/quarkus-app/ quarkus-app/
RUN chown -R apix:apix /app
USER apix
EXPOSE 8084
ENTRYPOINT ["java", "-jar", "quarkus-app/quarkus-run.jar"]
+32
View File
@@ -173,6 +173,38 @@ services:
retries: 3 retries: 3
restart: unless-stopped restart: unless-stopped
# ── Admin UI (a/b) ────────────────────────────────────────────────────────
admin-a:
image: apix-admin:latest
environment:
APIX_REGISTRY_URL: http://registry-a:8180
APIX_API_KEY: ${APIX_API_KEY}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
depends_on:
- registry-a
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8084/q/health/live || exit 1"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
admin-b:
image: apix-admin:latest
environment:
APIX_REGISTRY_URL: http://registry-b:8180
APIX_API_KEY: ${APIX_API_KEY}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
depends_on:
- registry-b
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8084/q/health/live || exit 1"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
# ── Edge proxy ──────────────────────────────────────────────────────────── # ── Edge proxy ────────────────────────────────────────────────────────────
caddy: caddy:
+1
View File
@@ -18,6 +18,7 @@
<module>apix-spider</module> <module>apix-spider</module>
<module>apix-portal</module> <module>apix-portal</module>
<module>apix-demo</module> <module>apix-demo</module>
<module>apix-admin</module>
</modules> </modules>
<properties> <properties>
+5 -2
View File
@@ -59,24 +59,27 @@ docker build -f infra/Dockerfile.registry -t apix-registry:latest . -q
docker build -f infra/Dockerfile.portal -t apix-portal:latest . -q docker build -f infra/Dockerfile.portal -t apix-portal:latest . -q
docker build -f infra/Dockerfile.demo -t apix-demo:latest . -q docker build -f infra/Dockerfile.demo -t apix-demo:latest . -q
docker build -f infra/Dockerfile.spider -t apix-spider:latest . -q docker build -f infra/Dockerfile.spider -t apix-spider:latest . -q
docker build -f infra/Dockerfile.admin -t apix-admin:latest . -q
# ── 4. Rolling restart: a-stack (registry-a → portal-a + demo-a) ───────────── # ── 4. Rolling restart: a-stack (registry-a → portal-a + demo-a) ─────────────
log "Rolling restart: a-stack..." log "Rolling restart: a-stack..."
$COMPOSE up -d --no-deps --force-recreate registry-a $COMPOSE up -d --no-deps --force-recreate registry-a
wait_healthy "registry-a" wait_healthy "registry-a"
$COMPOSE up -d --no-deps --force-recreate portal-a demo-a $COMPOSE up -d --no-deps --force-recreate portal-a demo-a admin-a
wait_healthy "portal-a" wait_healthy "portal-a"
wait_healthy "demo-a" wait_healthy "demo-a"
wait_healthy "admin-a"
# ── 5. Rolling restart: b-stack ─────────────────────────────────────────────── # ── 5. Rolling restart: b-stack ───────────────────────────────────────────────
log "Rolling restart: b-stack..." log "Rolling restart: b-stack..."
$COMPOSE up -d --no-deps --force-recreate registry-b $COMPOSE up -d --no-deps --force-recreate registry-b
wait_healthy "registry-b" wait_healthy "registry-b"
$COMPOSE up -d --no-deps --force-recreate portal-b demo-b $COMPOSE up -d --no-deps --force-recreate portal-b demo-b admin-b
wait_healthy "portal-b" wait_healthy "portal-b"
wait_healthy "demo-b" wait_healthy "demo-b"
wait_healthy "admin-b"
# ── 6. Spider (cron job — restart acceptable) ───────────────────────────────── # ── 6. Spider (cron job — restart acceptable) ─────────────────────────────────
log "Restarting spider..." log "Restarting spider..."
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# promote-sandbox.sh — Promote a sandbox tier + fetch demo ecosystem UUID
# Run on the server: bash promote-sandbox.sh
set -euo pipefail
ENV_FILE="/opt/apix/.env"
REGISTRY="https://api-index.org"
# ── Adjust these ───────────────────────────────────────────────────────────────
SANDBOX_ID="25321050-4707-4e85-bd9f-877ab04e4a55"
NEW_TIER="COMMUNITY"
# ──────────────────────────────────────────────────────────────────────────────
ADMIN_KEY=$(grep -oP '(?<=APIX_API_KEY=).+' "$ENV_FILE" | tr -d '[:space:]')
if [[ -z "$ADMIN_KEY" ]]; then
echo "ERROR: APIX_API_KEY not found in $ENV_FILE" >&2
exit 1
fi
echo "==> Promoting sandbox $SANDBOX_ID to $NEW_TIER ..."
curl -s -X PATCH "$REGISTRY/sandbox/admin/$SANDBOX_ID/tier" \
-H "X-Admin-Key: $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d "{\"tier\":\"$NEW_TIER\"}" | python3 -m json.tool
echo ""
echo "==> Demo ecosystem sandbox UUID ..."
docker exec "$(docker ps -qf name=demo-a)" \
psql -U apix -d apix -t -A \
-c "SELECT value FROM demo_config WHERE key='sandbox.id';"
+8 -9
View File
@@ -11,6 +11,12 @@ info() { echo -e "${GREEN}[apix]${NC} $*"; }
warn() { echo -e "${YELLOW}[warn]${NC} $*"; } warn() { echo -e "${YELLOW}[warn]${NC} $*"; }
die() { echo -e "${RED}[fail]${NC} $*" >&2; exit 1; } die() { echo -e "${RED}[fail]${NC} $*" >&2; exit 1; }
_on_exit() {
echo ""
read -rp "Press Enter to close…" _
}
trap _on_exit EXIT
warn "This will DROP and recreate the local 'apix' database." warn "This will DROP and recreate the local 'apix' database."
read -rp "Continue? [y/N] " confirm read -rp "Continue? [y/N] " confirm
[[ "${confirm,,}" == "y" ]] || { echo "Aborted."; exit 0; } [[ "${confirm,,}" == "y" ]] || { echo "Aborted."; exit 0; }
@@ -58,12 +64,5 @@ for i in $(seq 1 30); do
sleep 1 sleep 1
done done
info "Running Liquibase migrations" info "Reset complete — starting dev servers (Quarkus applies migrations at startup)"
mvn -q liquibase:update -pl apix-registry \ exec "$SCRIPT_DIR/dev.sh"
-Dliquibase.url="jdbc:postgresql://localhost:${DB_PORT}/${DB_NAME}" \
-Dliquibase.username="$DB_USER" \
-Dliquibase.password="$DB_PASS"
info "Migrations applied"
info "Reset complete — starting dev servers"
"$SCRIPT_DIR/dev.sh"
+3
View File
@@ -13,6 +13,9 @@ TARGET="${1:-all}"
GREEN='\033[0;32m'; NC='\033[0m' GREEN='\033[0;32m'; NC='\033[0m'
info() { echo -e "${GREEN}[apix]${NC} $*"; } info() { echo -e "${GREEN}[apix]${NC} $*"; }
_on_exit() { echo ""; read -rp "Press Enter to close…" _; }
trap _on_exit EXIT
# ── tmux mode ──────────────────────────────────────────────────────────────── # ── tmux mode ────────────────────────────────────────────────────────────────
if command -v tmux &>/dev/null && tmux has-session -t apix-dev 2>/dev/null; then if command -v tmux &>/dev/null && tmux has-session -t apix-dev 2>/dev/null; then
_restart_window() { _restart_window() {
+6 -20
View File
@@ -116,27 +116,13 @@ for i in $(seq 1 30); do
sleep 1 sleep 1
done done
# ── 6. Liquibase migrations ─────────────────────────────────────────────────── # ── 6. Note on migrations ─────────────────────────────────────────────────────
step "Database migrations" step "Database migrations"
JDBC_URL="jdbc:postgresql://localhost:${DB_PORT}/${DB_NAME}" # Migrations are applied automatically by Quarkus at startup via
# quarkus.liquibase.migrate-at-start=true. Running them separately via the
if [[ ! -f "$PROJECT_ROOT/pom.xml" ]]; then # Maven Liquibase plugin records a different classpath prefix in DATABASECHANGELOG
warn "No pom.xml found — Maven project not scaffolded yet (WORKLOG Block 1 / C-00)." # than Quarkus uses, causing a "relation already exists" conflict on next start.
warn "Skipping Liquibase migrations. Run this script again after completing Block 1." info "Migrations will run automatically when Quarkus starts (migrate-at-start=true)."
else
# apix-registry depends on apix-common and apix-verification; install them
# to the local repository first so Maven can resolve them during the
# liquibase:update goal (no source files yet — this completes in seconds).
info "Installing shared modules to local repository…"
mvn -q install -pl apix-common,apix-verification -DskipTests
info "Running Liquibase migrations on $JDBC_URL"
mvn -q liquibase:update -pl apix-registry \
-Dliquibase.url="$JDBC_URL" \
-Dliquibase.username="$DB_USER" \
-Dliquibase.password="$DB_PASS"
info "Migrations applied"
fi
# ── Done ────────────────────────────────────────────────────────────────────── # ── Done ──────────────────────────────────────────────────────────────────────
echo "" echo ""