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:
+97
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -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")
|
||||
|
||||
+81
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user