From 82a379e536ca03c93750e998c39459fdc760e84b Mon Sep 17 00:00:00 2001 From: Carsten Rehfeld Date: Sat, 16 May 2026 21:18:48 +0200 Subject: [PATCH] feat(admin): apix-admin UI at admin.api-index.org MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 6 + .gitignore | 4 + apix-admin/pom.xml | 70 +++++++++ .../admin/client/AdminRegistryClient.java | 46 ++++++ .../apix/admin/filter/AdminAuthFilter.java | 41 ++++++ .../admin/resource/DashboardResource.java | 62 ++++++++ .../apix/admin/resource/LoginResource.java | 59 ++++++++ .../admin/resource/SandboxListResource.java | 61 ++++++++ .../resources/META-INF/resources/admin.js | 128 +++++++++++++++++ .../src/main/resources/application.properties | 14 ++ .../templates/DashboardResource/error.html | 22 +++ .../templates/DashboardResource/global.html | 111 ++++++++++++++ .../templates/DashboardResource/sandbox.html | 136 ++++++++++++++++++ .../templates/LoginResource/login.html | 60 ++++++++ .../templates/SandboxListResource/error.html | 7 + .../templates/SandboxListResource/list.html | 124 ++++++++++++++++ .../apix/common/AdminSandboxListResponse.java | 10 ++ .../apix/common/AdminSandboxSummary.java | 21 +++ .../apix/common/AdminStatsResponse.java | 17 +++ .../apix/portal/client/RegistryClient.java | 2 +- .../apix/registry/resource/AdminResource.java | 97 +++++++++++++ .../registry/resource/SandboxResource.java | 14 ++ .../apix/registry/service/SandboxService.java | 81 +++++++++++ infra/Caddyfile | 14 ++ infra/Dockerfile.admin | 8 ++ infra/docker-compose.yml | 32 +++++ pom.xml | 1 + scripts/deploy-bluegreen.sh | 7 +- scripts/promote-sandbox.sh | 31 ++++ scripts/reset.sh | 17 ++- scripts/restart.sh | 3 + scripts/setup-dev.sh | 26 +--- 32 files changed, 1300 insertions(+), 32 deletions(-) create mode 100644 apix-admin/pom.xml create mode 100644 apix-admin/src/main/java/org/botstandards/apix/admin/client/AdminRegistryClient.java create mode 100644 apix-admin/src/main/java/org/botstandards/apix/admin/filter/AdminAuthFilter.java create mode 100644 apix-admin/src/main/java/org/botstandards/apix/admin/resource/DashboardResource.java create mode 100644 apix-admin/src/main/java/org/botstandards/apix/admin/resource/LoginResource.java create mode 100644 apix-admin/src/main/java/org/botstandards/apix/admin/resource/SandboxListResource.java create mode 100644 apix-admin/src/main/resources/META-INF/resources/admin.js create mode 100644 apix-admin/src/main/resources/application.properties create mode 100644 apix-admin/src/main/resources/templates/DashboardResource/error.html create mode 100644 apix-admin/src/main/resources/templates/DashboardResource/global.html create mode 100644 apix-admin/src/main/resources/templates/DashboardResource/sandbox.html create mode 100644 apix-admin/src/main/resources/templates/LoginResource/login.html create mode 100644 apix-admin/src/main/resources/templates/SandboxListResource/error.html create mode 100644 apix-admin/src/main/resources/templates/SandboxListResource/list.html create mode 100644 apix-common/src/main/java/org/botstandards/apix/common/AdminSandboxListResponse.java create mode 100644 apix-common/src/main/java/org/botstandards/apix/common/AdminSandboxSummary.java create mode 100644 apix-common/src/main/java/org/botstandards/apix/common/AdminStatsResponse.java create mode 100644 apix-registry/src/main/java/org/botstandards/apix/registry/resource/AdminResource.java create mode 100644 infra/Dockerfile.admin create mode 100644 scripts/promote-sandbox.sh diff --git a/.env.example b/.env.example index a9d4dbe..d67300e 100644 --- a/.env.example +++ b/.env.example @@ -35,3 +35,9 @@ REGISTRY_BASE_URL=http://localhost:8180 # ── Logging ─────────────────────────────────────────────────────────────────── # DEBUG in dev; INFO in prod 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 diff --git a/.gitignore b/.gitignore index 71146cc..ada1ac8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ logs/ # Local environment — never commit real credentials .env +# Bunny.net pull zone ID written by setup-bunnynet.sh +.bunnynet-pull-zone-id + # Maven build output target/ **/target/ @@ -22,3 +25,4 @@ sanctions-cache/ # OS .DS_Store Thumbs.db +/.allure/ diff --git a/apix-admin/pom.xml b/apix-admin/pom.xml new file mode 100644 index 0000000..094f1b7 --- /dev/null +++ b/apix-admin/pom.xml @@ -0,0 +1,70 @@ + + + 4.0.0 + + + org.botstandards + apix-parent + ${revision} + + + apix-admin + APIX :: Admin + Internal maintainer UI at admin.api-index.org. API-key auth, no username/password. Port 8084. + + + + org.botstandards + apix-common + + + + io.quarkus + quarkus-rest + + + io.quarkus + quarkus-rest-qute + + + io.quarkus + quarkus-rest-client + + + io.quarkus + quarkus-rest-client-jackson + + + io.quarkus + quarkus-smallrye-health + + + + io.quarkus + quarkus-junit5 + test + + + + + + + ${quarkus.platform.group-id} + quarkus-maven-plugin + true + + + + build + generate-code + generate-code-tests + + + + + + + diff --git a/apix-admin/src/main/java/org/botstandards/apix/admin/client/AdminRegistryClient.java b/apix-admin/src/main/java/org/botstandards/apix/admin/client/AdminRegistryClient.java new file mode 100644 index 0000000..0265611 --- /dev/null +++ b/apix-admin/src/main/java/org/botstandards/apix/admin/client/AdminRegistryClient.java @@ -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 promoteTier( + @HeaderParam("X-Admin-Key") String adminKey, + @PathParam("uuid") String uuid, + Map body); + + @PATCH + @Path("/sandbox/admin/{uuid}/tier") + Map promoteTierLegacy( + @HeaderParam("X-Admin-Key") String adminKey, + @PathParam("uuid") String uuid, + Map body); +} diff --git a/apix-admin/src/main/java/org/botstandards/apix/admin/filter/AdminAuthFilter.java b/apix-admin/src/main/java/org/botstandards/apix/admin/filter/AdminAuthFilter.java new file mode 100644 index 0000000..1b297fc --- /dev/null +++ b/apix-admin/src/main/java/org/botstandards/apix/admin/filter/AdminAuthFilter.java @@ -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 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()); + } +} diff --git a/apix-admin/src/main/java/org/botstandards/apix/admin/resource/DashboardResource.java b/apix-admin/src/main/java/org/botstandards/apix/admin/resource/DashboardResource.java new file mode 100644 index 0000000..defe785 --- /dev/null +++ b/apix-admin/src/main/java/org/botstandards/apix/admin/resource/DashboardResource.java @@ -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("= 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 = ` + ${d.tier} + ${d.count}`; + legendEl.appendChild(li); + }); + } +})(); diff --git a/apix-admin/src/main/resources/application.properties b/apix-admin/src/main/resources/application.properties new file mode 100644 index 0000000..017287c --- /dev/null +++ b/apix-admin/src/main/resources/application.properties @@ -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 diff --git a/apix-admin/src/main/resources/templates/DashboardResource/error.html b/apix-admin/src/main/resources/templates/DashboardResource/error.html new file mode 100644 index 0000000..7f9b8e1 --- /dev/null +++ b/apix-admin/src/main/resources/templates/DashboardResource/error.html @@ -0,0 +1,22 @@ + + + + +APIX Admin — Error + + + +
+
ERROR
+
{message}
+ ← Back to dashboard +
+ + diff --git a/apix-admin/src/main/resources/templates/DashboardResource/global.html b/apix-admin/src/main/resources/templates/DashboardResource/global.html new file mode 100644 index 0000000..5644427 --- /dev/null +++ b/apix-admin/src/main/resources/templates/DashboardResource/global.html @@ -0,0 +1,111 @@ + + + + + +APIX Admin — Dashboard + + + + + +
+
+
Total Sandboxes
+
{stats.totalSandboxes}
+
{stats.activeSandboxes} active · {stats.expiredSandboxes} expired
+
+
+
Services registered
+
{stats.totalServices}
+
across all sandboxes
+
+
+
Total Requests
+
+
cumulative
+
+
+ +
+ +
+
+
Tier distribution
+
+ +
    +
    +
    + +
    + + + + + + + + + diff --git a/apix-admin/src/main/resources/templates/DashboardResource/sandbox.html b/apix-admin/src/main/resources/templates/DashboardResource/sandbox.html new file mode 100644 index 0000000..145d903 --- /dev/null +++ b/apix-admin/src/main/resources/templates/DashboardResource/sandbox.html @@ -0,0 +1,136 @@ + + + + + +{dashboard.name} · APIX Admin + + + + + +
    + {dashboard.name} + {dashboard.tier} + {dashboard.sandboxId} +
    + +
    +
    +
    Services
    +
    {#if dashboard.usage.get('SERVICE_REGISTERED') != null}{dashboard.usage.get('SERVICE_REGISTERED')}{#else}0{/if}
    +
    of {#if dashboard.maxServices != null}{dashboard.maxServices}{#else}∞{/if} allowed
    +
    +
    +
    Searches
    +
    {#if dashboard.usage.get('SERVICE_SEARCHED') != null}{dashboard.usage.get('SERVICE_SEARCHED')}{#else}0{/if}
    +
    capability discovery calls
    +
    +
    +
    Rate limit
    +
    {dashboard.ratePerMinute}
    +
    req / min
    +
    +
    +
    Expires
    +
    +
    {dashboard.expiresAt}
    +
    + {#if dashboard.registrarLocation != null} +
    +
    Location
    +
    {dashboard.registrarLocation}
    +
    owner-declared
    +
    + {/if} +
    + +
    + +
    +
    Admin actions
    +
    + + +
    +
    + + + + + + + + + diff --git a/apix-admin/src/main/resources/templates/LoginResource/login.html b/apix-admin/src/main/resources/templates/LoginResource/login.html new file mode 100644 index 0000000..33d7c25 --- /dev/null +++ b/apix-admin/src/main/resources/templates/LoginResource/login.html @@ -0,0 +1,60 @@ + + + + + +APIX Admin — Login + + + +
    + + {#if invalid}
    Invalid admin key. Try again.
    {/if} +
    + + + +
    +
    + + diff --git a/apix-admin/src/main/resources/templates/SandboxListResource/error.html b/apix-admin/src/main/resources/templates/SandboxListResource/error.html new file mode 100644 index 0000000..a293803 --- /dev/null +++ b/apix-admin/src/main/resources/templates/SandboxListResource/error.html @@ -0,0 +1,7 @@ + + +Error · APIX Admin + + +
    ERROR
    {message}
    ← Back to list
    + diff --git a/apix-admin/src/main/resources/templates/SandboxListResource/list.html b/apix-admin/src/main/resources/templates/SandboxListResource/list.html new file mode 100644 index 0000000..deab1f1 --- /dev/null +++ b/apix-admin/src/main/resources/templates/SandboxListResource/list.html @@ -0,0 +1,124 @@ + + + + + +Sandboxes · APIX Admin + + + + + +
    + Filter: + All + FREE + COMMUNITY + FOUNDER + DEMO + {result.total} total +
    + + + + + + + + + + + + + + {#for sb in result.sandboxes} + + + + + + + + + {/for} + +
    NameTierServicesRequestsLocationExpires
    {sb.name} +
    {sb.sandboxId}
    +
    {sb.tier}{sb.serviceCount}{sb.totalRequests}{#if sb.location != null}{sb.location}{#else}{/if}{sb.expiresAt}
    + + + + + + diff --git a/apix-common/src/main/java/org/botstandards/apix/common/AdminSandboxListResponse.java b/apix-common/src/main/java/org/botstandards/apix/common/AdminSandboxListResponse.java new file mode 100644 index 0000000..aaacfc4 --- /dev/null +++ b/apix-common/src/main/java/org/botstandards/apix/common/AdminSandboxListResponse.java @@ -0,0 +1,10 @@ +package org.botstandards.apix.common; + +import java.util.List; + +public record AdminSandboxListResponse( + long total, + int page, + int size, + List sandboxes +) {} diff --git a/apix-common/src/main/java/org/botstandards/apix/common/AdminSandboxSummary.java b/apix-common/src/main/java/org/botstandards/apix/common/AdminSandboxSummary.java new file mode 100644 index 0000000..c4da175 --- /dev/null +++ b/apix-common/src/main/java/org/botstandards/apix/common/AdminSandboxSummary.java @@ -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 +) {} diff --git a/apix-common/src/main/java/org/botstandards/apix/common/AdminStatsResponse.java b/apix-common/src/main/java/org/botstandards/apix/common/AdminStatsResponse.java new file mode 100644 index 0000000..53c1fe3 --- /dev/null +++ b/apix-common/src/main/java/org/botstandards/apix/common/AdminStatsResponse.java @@ -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 byTier, + long totalServices, + long totalRequests, + /** Geo-points for the world map: one point per sandbox that provided a location. */ + List registrarPoints +) { + public record GeoPoint(double lat, double lon, String name, String tier) {} +} diff --git a/apix-portal/src/main/java/org/botstandards/apix/portal/client/RegistryClient.java b/apix-portal/src/main/java/org/botstandards/apix/portal/client/RegistryClient.java index a20901a..bdf2737 100644 --- a/apix-portal/src/main/java/org/botstandards/apix/portal/client/RegistryClient.java +++ b/apix-portal/src/main/java/org/botstandards/apix/portal/client/RegistryClient.java @@ -14,6 +14,6 @@ import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; public interface RegistryClient { @GET - @Path("/{uuid}") + @Path("/{uuid}/dashboard") SandboxDashboardResponse getDashboard(@PathParam("uuid") String uuid); } diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/resource/AdminResource.java b/apix-registry/src/main/java/org/botstandards/apix/registry/resource/AdminResource.java new file mode 100644 index 0000000..1cb6fb8 --- /dev/null +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/resource/AdminResource.java @@ -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 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()); + } + } +} diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/resource/SandboxResource.java b/apix-registry/src/main/java/org/botstandards/apix/registry/resource/SandboxResource.java index 8b1dcc3..e6dd475 100644 --- a/apix-registry/src/main/java/org/botstandards/apix/registry/resource/SandboxResource.java +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/resource/SandboxResource.java @@ -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") diff --git a/apix-registry/src/main/java/org/botstandards/apix/registry/service/SandboxService.java b/apix-registry/src/main/java/org/botstandards/apix/registry/service/SandboxService.java index 653d3fd..d2adf42 100644 --- a/apix-registry/src/main/java/org/botstandards/apix/registry/service/SandboxService.java +++ b/apix-registry/src/main/java/org/botstandards/apix/registry/service/SandboxService.java @@ -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 rows = ((List) 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 tierRows = em.createNativeQuery( + "SELECT tier, COUNT(*) FROM sandboxes GROUP BY tier ORDER BY tier") + .getResultList(); + Map 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 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 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); } diff --git a/infra/Caddyfile b/infra/Caddyfile index 3c2fef8..9124afc 100644 --- a/infra/Caddyfile +++ b/infra/Caddyfile @@ -41,6 +41,20 @@ demo.api-index.org { 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 { reverse_proxy gitea:3001 header Strict-Transport-Security "max-age=31536000; includeSubDomains" diff --git a/infra/Dockerfile.admin b/infra/Dockerfile.admin new file mode 100644 index 0000000..c3e410d --- /dev/null +++ b/infra/Dockerfile.admin @@ -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"] diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index eac04ad..adee931 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -173,6 +173,38 @@ services: retries: 3 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 ──────────────────────────────────────────────────────────── caddy: diff --git a/pom.xml b/pom.xml index 0594fd6..b9c46e8 100644 --- a/pom.xml +++ b/pom.xml @@ -18,6 +18,7 @@ apix-spider apix-portal apix-demo + apix-admin diff --git a/scripts/deploy-bluegreen.sh b/scripts/deploy-bluegreen.sh index 87248c3..8fe5026 100644 --- a/scripts/deploy-bluegreen.sh +++ b/scripts/deploy-bluegreen.sh @@ -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.demo -t apix-demo: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) ───────────── log "Rolling restart: a-stack..." $COMPOSE up -d --no-deps --force-recreate 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 "demo-a" +wait_healthy "admin-a" # ── 5. Rolling restart: b-stack ─────────────────────────────────────────────── log "Rolling restart: b-stack..." $COMPOSE up -d --no-deps --force-recreate 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 "demo-b" +wait_healthy "admin-b" # ── 6. Spider (cron job — restart acceptable) ───────────────────────────────── log "Restarting spider..." diff --git a/scripts/promote-sandbox.sh b/scripts/promote-sandbox.sh new file mode 100644 index 0000000..1e7a2e1 --- /dev/null +++ b/scripts/promote-sandbox.sh @@ -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';" diff --git a/scripts/reset.sh b/scripts/reset.sh index c7e9cf8..3f4b064 100644 --- a/scripts/reset.sh +++ b/scripts/reset.sh @@ -11,6 +11,12 @@ info() { echo -e "${GREEN}[apix]${NC} $*"; } warn() { echo -e "${YELLOW}[warn]${NC} $*"; } 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." read -rp "Continue? [y/N] " confirm [[ "${confirm,,}" == "y" ]] || { echo "Aborted."; exit 0; } @@ -58,12 +64,5 @@ for i in $(seq 1 30); do sleep 1 done -info "Running Liquibase migrations" -mvn -q liquibase:update -pl apix-registry \ - -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" +info "Reset complete — starting dev servers (Quarkus applies migrations at startup)" +exec "$SCRIPT_DIR/dev.sh" diff --git a/scripts/restart.sh b/scripts/restart.sh index 3a42179..f477f52 100644 --- a/scripts/restart.sh +++ b/scripts/restart.sh @@ -13,6 +13,9 @@ TARGET="${1:-all}" GREEN='\033[0;32m'; NC='\033[0m' info() { echo -e "${GREEN}[apix]${NC} $*"; } +_on_exit() { echo ""; read -rp "Press Enter to close…" _; } +trap _on_exit EXIT + # ── tmux mode ──────────────────────────────────────────────────────────────── if command -v tmux &>/dev/null && tmux has-session -t apix-dev 2>/dev/null; then _restart_window() { diff --git a/scripts/setup-dev.sh b/scripts/setup-dev.sh index f6d0312..2ae59a0 100644 --- a/scripts/setup-dev.sh +++ b/scripts/setup-dev.sh @@ -116,27 +116,13 @@ for i in $(seq 1 30); do sleep 1 done -# ── 6. Liquibase migrations ─────────────────────────────────────────────────── +# ── 6. Note on migrations ───────────────────────────────────────────────────── step "Database migrations" -JDBC_URL="jdbc:postgresql://localhost:${DB_PORT}/${DB_NAME}" - -if [[ ! -f "$PROJECT_ROOT/pom.xml" ]]; then - warn "No pom.xml found — Maven project not scaffolded yet (WORKLOG Block 1 / C-00)." - warn "Skipping Liquibase migrations. Run this script again after completing Block 1." -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 +# Migrations are applied automatically by Quarkus at startup via +# quarkus.liquibase.migrate-at-start=true. Running them separately via the +# Maven Liquibase plugin records a different classpath prefix in DATABASECHANGELOG +# than Quarkus uses, causing a "relation already exists" conflict on next start. +info "Migrations will run automatically when Quarkus starts (migrate-at-start=true)." # ── Done ────────────────────────────────────────────────────────────────────── echo ""