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
@@ -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.Response;
import org.botstandards.apix.common.BsmPayload;
import org.botstandards.apix.common.SandboxDashboardResponse;
import org.botstandards.apix.registry.dto.*;
import org.botstandards.apix.registry.entity.SandboxEntity;
import org.botstandards.apix.registry.service.SandboxService;
@@ -82,6 +83,19 @@ public class SandboxResource {
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
@Path("/{uuid}/services")
@SecurityRequirement(name = "SandboxApiKey")
@@ -454,6 +454,87 @@ public class SandboxService {
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) {
return sandboxLinks(base, null);
}