Compare commits
3 Commits
8bc0ce1fb8
...
82a379e536
| Author | SHA1 | Date | |
|---|---|---|---|
| 82a379e536 | |||
| e385d05ce1 | |||
| 134c8b3c6b |
@@ -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
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-9
@@ -453,6 +453,13 @@ public class IotTransitionSteps {
|
|||||||
lastResponse = given().get("/services/" + currentServiceId + "/history");
|
lastResponse = given().get("/services/" + currentServiceId + "/history");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Then — HTTP status assertion ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@Then("the response is HTTP {int}")
|
||||||
|
public void theResponseIsHttp(int status) {
|
||||||
|
lastResponse.then().statusCode(status);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Then — lifecycle state assertions ────────────────────────────────────
|
// ── Then — lifecycle state assertions ────────────────────────────────────
|
||||||
|
|
||||||
@Then("the service stage is {string}")
|
@Then("the service stage is {string}")
|
||||||
@@ -511,13 +518,6 @@ public class IotTransitionSteps {
|
|||||||
.body("find { it.type == 'LOCK_RELEASED' }.previousValue", equalTo("true"));
|
.body("find { it.type == 'LOCK_RELEASED' }.previousValue", equalTo("true"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Then — HTTP response assertions ──────────────────────────────────────
|
|
||||||
|
|
||||||
@Then("the response is HTTP {int}")
|
|
||||||
public void theResponseIsHttp(int statusCode) {
|
|
||||||
lastResponse.then().statusCode(statusCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Then("the error message contains {string}")
|
@Then("the error message contains {string}")
|
||||||
public void theErrorMessageContains(String message) {
|
public void theErrorMessageContains(String message) {
|
||||||
lastResponse.then().body("message", containsString(message));
|
lastResponse.then().body("message", containsString(message));
|
||||||
@@ -861,8 +861,9 @@ public class IotTransitionSteps {
|
|||||||
@When("^GET /services/\\{smartHubCloudId\\}/replacements\\?iotReady=true is called with no (?:authentication|Authorization) header$")
|
@When("^GET /services/\\{smartHubCloudId\\}/replacements\\?iotReady=true is called with no (?:authentication|Authorization) header$")
|
||||||
public void getReplacementsIotReady() {
|
public void getReplacementsIotReady() {
|
||||||
actionPhase = true;
|
actionPhase = true;
|
||||||
lastResponse = given().get(
|
lastResponse = given()
|
||||||
"/services/" + serviceIds.get("SmartHub Cloud") + "/replacements?iotReady=true");
|
.queryParam("iotReady", "true")
|
||||||
|
.get("/services/" + serviceIds.get("SmartHub Cloud") + "/replacements");
|
||||||
}
|
}
|
||||||
|
|
||||||
@When("^GET /services/\\{smartHubCloudId\\}/replacements\\?deviceClass=([^ ]+) is called with no (?:authentication|Authorization) header$")
|
@When("^GET /services/\\{smartHubCloudId\\}/replacements\\?deviceClass=([^ ]+) is called with no (?:authentication|Authorization) header$")
|
||||||
|
|||||||
+77
-28
@@ -33,7 +33,9 @@ public class SandboxSteps {
|
|||||||
/** Name of the most recently created sandbox. */
|
/** Name of the most recently created sandbox. */
|
||||||
private String currentSandboxName;
|
private String currentSandboxName;
|
||||||
/** Keys indexed by sandbox name for multi-sandbox scenarios. */
|
/** Keys indexed by sandbox name for multi-sandbox scenarios. */
|
||||||
private final Map<String, String> sandboxKeys = new HashMap<>();
|
private final Map<String, String> sandboxKeys = new HashMap<>();
|
||||||
|
/** UUIDs indexed by sandbox name — used for resource routing (name is display-only). */
|
||||||
|
private final Map<String, String> sandboxUuids = new HashMap<>();
|
||||||
|
|
||||||
// ── Given — sandbox creation ──────────────────────────────────────────────
|
// ── Given — sandbox creation ──────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -45,6 +47,7 @@ public class SandboxSteps {
|
|||||||
@Given("a production service {string} with endpoint {string} is registered")
|
@Given("a production service {string} with endpoint {string} is registered")
|
||||||
public void aProductionServiceIsRegistered(String serviceName, String endpoint) {
|
public void aProductionServiceIsRegistered(String serviceName, String endpoint) {
|
||||||
Map<String, Object> payload = buildServicePayload(serviceName, endpoint, "device.telemetry");
|
Map<String, Object> payload = buildServicePayload(serviceName, endpoint, "device.telemetry");
|
||||||
|
payload.put("serviceStage", "PRODUCTION");
|
||||||
given()
|
given()
|
||||||
.contentType(JSON)
|
.contentType(JSON)
|
||||||
.header(ADMIN_API_KEY_HEADER, ADMIN_API_KEY)
|
.header(ADMIN_API_KEY_HEADER, ADMIN_API_KEY)
|
||||||
@@ -57,14 +60,15 @@ public class SandboxSteps {
|
|||||||
|
|
||||||
@Given("a sandbox service with endpoint {string} and capability {string} is registered in {string}")
|
@Given("a sandbox service with endpoint {string} and capability {string} is registered in {string}")
|
||||||
public void aSandboxServiceIsRegistered(String endpoint, String capability, String sandboxName) {
|
public void aSandboxServiceIsRegistered(String endpoint, String capability, String sandboxName) {
|
||||||
String key = resolveKey(sandboxName);
|
String key = resolveKey(sandboxName);
|
||||||
|
String uuid = resolveUuid(sandboxName);
|
||||||
Map<String, Object> payload = buildServicePayload("SandboxService-" + endpoint.hashCode(), endpoint, capability);
|
Map<String, Object> payload = buildServicePayload("SandboxService-" + endpoint.hashCode(), endpoint, capability);
|
||||||
given()
|
given()
|
||||||
.contentType(JSON)
|
.contentType(JSON)
|
||||||
.header(SANDBOX_API_KEY_HEADER, key)
|
.header(SANDBOX_API_KEY_HEADER, key)
|
||||||
.body(payload)
|
.body(payload)
|
||||||
.when()
|
.when()
|
||||||
.post("/sandbox/" + sandboxName + "/services")
|
.post("/sandbox/" + uuid + "/services")
|
||||||
.then()
|
.then()
|
||||||
.statusCode(201);
|
.statusCode(201);
|
||||||
}
|
}
|
||||||
@@ -72,7 +76,8 @@ public class SandboxSteps {
|
|||||||
@Given("a sandbox service with endpoint {string} capability {string} and extension {string} is registered in {string}")
|
@Given("a sandbox service with endpoint {string} capability {string} and extension {string} is registered in {string}")
|
||||||
public void aSandboxServiceWithExtensionIsRegistered(String endpoint, String capability,
|
public void aSandboxServiceWithExtensionIsRegistered(String endpoint, String capability,
|
||||||
String extension, String sandboxName) {
|
String extension, String sandboxName) {
|
||||||
String key = resolveKey(sandboxName);
|
String key = resolveKey(sandboxName);
|
||||||
|
String uuid = resolveUuid(sandboxName);
|
||||||
int colon = extension.indexOf(':');
|
int colon = extension.indexOf(':');
|
||||||
Map<String, Object> extensions = colon > 0
|
Map<String, Object> extensions = colon > 0
|
||||||
? Map.of(extension.substring(0, colon), extension.substring(colon + 1))
|
? Map.of(extension.substring(0, colon), extension.substring(colon + 1))
|
||||||
@@ -84,19 +89,19 @@ public class SandboxSteps {
|
|||||||
.header(SANDBOX_API_KEY_HEADER, key)
|
.header(SANDBOX_API_KEY_HEADER, key)
|
||||||
.body(payload)
|
.body(payload)
|
||||||
.when()
|
.when()
|
||||||
.post("/sandbox/" + sandboxName + "/services")
|
.post("/sandbox/" + uuid + "/services")
|
||||||
.then()
|
.then()
|
||||||
.statusCode(201);
|
.statusCode(201);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Given("the sandbox root for {string} has been viewed once")
|
@Given("the sandbox root for {string} has been viewed once")
|
||||||
public void sandboxRootHasBeenViewedOnce(String sandboxName) {
|
public void sandboxRootHasBeenViewedOnce(String sandboxName) {
|
||||||
given().get("/sandbox/" + sandboxName).then().statusCode(200);
|
given().get("/sandbox/" + resolveUuid(sandboxName)).then().statusCode(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Given("the sandbox service list for {string} has been requested once")
|
@Given("the sandbox service list for {string} has been requested once")
|
||||||
public void sandboxServiceListHasBeenRequestedOnce(String sandboxName) {
|
public void sandboxServiceListHasBeenRequestedOnce(String sandboxName) {
|
||||||
given().get("/sandbox/" + sandboxName + "/services").then().statusCode(200);
|
given().get("/sandbox/" + resolveUuid(sandboxName) + "/services").then().statusCode(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Given("feedback has been submitted to {string} with scores {word}={int} {word}={int}")
|
@Given("feedback has been submitted to {string} with scores {word}={int} {word}={int}")
|
||||||
@@ -111,7 +116,8 @@ public class SandboxSteps {
|
|||||||
|
|
||||||
@Given("{int} services have been registered in sandbox {string}")
|
@Given("{int} services have been registered in sandbox {string}")
|
||||||
public void nServicesRegisteredInSandbox(int count, String sandboxName) {
|
public void nServicesRegisteredInSandbox(int count, String sandboxName) {
|
||||||
String key = resolveKey(sandboxName);
|
String key = resolveKey(sandboxName);
|
||||||
|
String uuid = resolveUuid(sandboxName);
|
||||||
for (int i = 0; i < count; i++) {
|
for (int i = 0; i < count; i++) {
|
||||||
Map<String, Object> payload = buildServicePayload(
|
Map<String, Object> payload = buildServicePayload(
|
||||||
"CapService-" + i, "https://cap-" + i + ".example.com", "cap.test");
|
"CapService-" + i, "https://cap-" + i + ".example.com", "cap.test");
|
||||||
@@ -120,7 +126,7 @@ public class SandboxSteps {
|
|||||||
.header(SANDBOX_API_KEY_HEADER, key)
|
.header(SANDBOX_API_KEY_HEADER, key)
|
||||||
.body(payload)
|
.body(payload)
|
||||||
.when()
|
.when()
|
||||||
.post("/sandbox/" + sandboxName + "/services")
|
.post("/sandbox/" + uuid + "/services")
|
||||||
.then()
|
.then()
|
||||||
.statusCode(201);
|
.statusCode(201);
|
||||||
}
|
}
|
||||||
@@ -141,6 +147,7 @@ public class SandboxSteps {
|
|||||||
currentSandboxKey = lastResponse.jsonPath().getString("apiKey");
|
currentSandboxKey = lastResponse.jsonPath().getString("apiKey");
|
||||||
currentSandboxName = name;
|
currentSandboxName = name;
|
||||||
sandboxKeys.put(name, currentSandboxKey);
|
sandboxKeys.put(name, currentSandboxKey);
|
||||||
|
sandboxUuids.put(name, lastResponse.jsonPath().getString("sandboxId"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,19 +179,29 @@ public class SandboxSteps {
|
|||||||
|
|
||||||
@When("the sandbox root for {string} is requested")
|
@When("the sandbox root for {string} is requested")
|
||||||
public void sandboxRootRequested(String sandboxName) {
|
public void sandboxRootRequested(String sandboxName) {
|
||||||
lastResponse = given().get("/sandbox/" + sandboxName).andReturn();
|
lastResponse = given().get("/sandbox/" + resolveUuid(sandboxName)).andReturn();
|
||||||
|
}
|
||||||
|
|
||||||
|
@When("the sandbox root for UUID {string} is requested")
|
||||||
|
public void sandboxRootRequestedByUuid(String uuid) {
|
||||||
|
lastResponse = given().get("/sandbox/" + uuid).andReturn();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── When — service operations ─────────────────────────────────────────────
|
// ── When — service operations ─────────────────────────────────────────────
|
||||||
|
|
||||||
@When("GET /services is called without authentication")
|
@When("GET \\/services is called without authentication")
|
||||||
public void getServicesWithoutAuth() {
|
public void getServicesWithoutAuth() {
|
||||||
lastResponse = given().get("/services").andReturn();
|
lastResponse = given().get("/services").andReturn();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@When("GET \\/services is called with capability {string} without authentication")
|
||||||
|
public void getServicesWithCapabilityWithoutAuth(String capability) {
|
||||||
|
lastResponse = given().get("/services?capability=" + capability).andReturn();
|
||||||
|
}
|
||||||
|
|
||||||
@When("the sandbox service list for {string} is requested")
|
@When("the sandbox service list for {string} is requested")
|
||||||
public void sandboxServiceListRequested(String sandboxName) {
|
public void sandboxServiceListRequested(String sandboxName) {
|
||||||
lastResponse = given().get("/sandbox/" + sandboxName + "/services").andReturn();
|
lastResponse = given().get("/sandbox/" + resolveUuid(sandboxName) + "/services").andReturn();
|
||||||
}
|
}
|
||||||
|
|
||||||
@When("a service is registered in sandbox {string} without an API key")
|
@When("a service is registered in sandbox {string} without an API key")
|
||||||
@@ -194,7 +211,7 @@ public class SandboxSteps {
|
|||||||
.contentType(JSON)
|
.contentType(JSON)
|
||||||
.body(payload)
|
.body(payload)
|
||||||
.when()
|
.when()
|
||||||
.post("/sandbox/" + sandboxName + "/services")
|
.post("/sandbox/" + resolveUuid(sandboxName) + "/services")
|
||||||
.andReturn();
|
.andReturn();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +223,7 @@ public class SandboxSteps {
|
|||||||
.header(SANDBOX_API_KEY_HEADER, key)
|
.header(SANDBOX_API_KEY_HEADER, key)
|
||||||
.body(payload)
|
.body(payload)
|
||||||
.when()
|
.when()
|
||||||
.post("/sandbox/" + sandboxName + "/services")
|
.post("/sandbox/" + resolveUuid(sandboxName) + "/services")
|
||||||
.andReturn();
|
.andReturn();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,21 +236,38 @@ public class SandboxSteps {
|
|||||||
.header(SANDBOX_API_KEY_HEADER, key)
|
.header(SANDBOX_API_KEY_HEADER, key)
|
||||||
.body(payload)
|
.body(payload)
|
||||||
.when()
|
.when()
|
||||||
.post("/sandbox/" + sandboxName + "/services")
|
.post("/sandbox/" + resolveUuid(sandboxName) + "/services")
|
||||||
.andReturn();
|
.andReturn();
|
||||||
}
|
}
|
||||||
|
|
||||||
@When("sandbox {string} services are searched by capability {string}")
|
@When("sandbox {string} services are searched by capability {string}")
|
||||||
public void sandboxServiceSearchCalled(String sandboxName, String capability) {
|
public void sandboxServiceSearchCalled(String sandboxName, String capability) {
|
||||||
lastResponse = given()
|
lastResponse = given()
|
||||||
.get("/sandbox/" + sandboxName + "/services?capability=" + capability)
|
.get("/sandbox/" + resolveUuid(sandboxName) + "/services?capability=" + capability)
|
||||||
.andReturn();
|
.andReturn();
|
||||||
}
|
}
|
||||||
|
|
||||||
@When("sandbox {string} services are searched by capability {string} and property {string}")
|
@When("sandbox {string} services are searched by capability {string} and property {string}")
|
||||||
public void sandboxServiceSearchCalledWithProperty(String sandboxName, String capability, String property) {
|
public void sandboxServiceSearchCalledWithProperty(String sandboxName, String capability, String property) {
|
||||||
lastResponse = given()
|
lastResponse = given()
|
||||||
.get("/sandbox/" + sandboxName + "/services?capability=" + capability + "&property=" + property)
|
.get("/sandbox/" + resolveUuid(sandboxName) + "/services?capability=" + capability + "&property=" + property)
|
||||||
|
.andReturn();
|
||||||
|
}
|
||||||
|
|
||||||
|
@When("GET \\/services is called with capability {string} and the sandbox key for {string}")
|
||||||
|
public void getServicesWithCapabilityAndSandboxKey(String capability, String sandboxName) {
|
||||||
|
String key = resolveKey(sandboxName);
|
||||||
|
lastResponse = given()
|
||||||
|
.header(SANDBOX_API_KEY_HEADER, key)
|
||||||
|
.get("/services?capability=" + capability)
|
||||||
|
.andReturn();
|
||||||
|
}
|
||||||
|
|
||||||
|
@When("GET \\/services is called with capability {string} and API key {string}")
|
||||||
|
public void getServicesWithCapabilityAndLiteralKey(String capability, String apiKey) {
|
||||||
|
lastResponse = given()
|
||||||
|
.header(SANDBOX_API_KEY_HEADER, apiKey)
|
||||||
|
.get("/services?capability=" + capability)
|
||||||
.andReturn();
|
.andReturn();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,17 +275,18 @@ public class SandboxSteps {
|
|||||||
|
|
||||||
@When("the telemetry for {string} is requested with the sandbox API key")
|
@When("the telemetry for {string} is requested with the sandbox API key")
|
||||||
public void telemetryRequestedWithKey(String sandboxName) {
|
public void telemetryRequestedWithKey(String sandboxName) {
|
||||||
String key = resolveKey(sandboxName);
|
String key = resolveKey(sandboxName);
|
||||||
|
String uuid = resolveUuid(sandboxName);
|
||||||
lastResponse = given()
|
lastResponse = given()
|
||||||
.header(SANDBOX_API_KEY_HEADER, key)
|
.header(SANDBOX_API_KEY_HEADER, key)
|
||||||
.when()
|
.when()
|
||||||
.get("/sandbox/" + sandboxName + "/telemetry")
|
.get("/sandbox/" + uuid + "/telemetry")
|
||||||
.andReturn();
|
.andReturn();
|
||||||
}
|
}
|
||||||
|
|
||||||
@When("the telemetry for {string} is requested without an API key")
|
@When("the telemetry for {string} is requested without an API key")
|
||||||
public void telemetryRequestedWithoutKey(String sandboxName) {
|
public void telemetryRequestedWithoutKey(String sandboxName) {
|
||||||
lastResponse = given().get("/sandbox/" + sandboxName + "/telemetry").andReturn();
|
lastResponse = given().get("/sandbox/" + resolveUuid(sandboxName) + "/telemetry").andReturn();
|
||||||
}
|
}
|
||||||
|
|
||||||
@When("the telemetry for {string} is requested with API key {string}")
|
@When("the telemetry for {string} is requested with API key {string}")
|
||||||
@@ -259,13 +294,13 @@ public class SandboxSteps {
|
|||||||
lastResponse = given()
|
lastResponse = given()
|
||||||
.header(SANDBOX_API_KEY_HEADER, key)
|
.header(SANDBOX_API_KEY_HEADER, key)
|
||||||
.when()
|
.when()
|
||||||
.get("/sandbox/" + sandboxName + "/telemetry")
|
.get("/sandbox/" + resolveUuid(sandboxName) + "/telemetry")
|
||||||
.andReturn();
|
.andReturn();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── When — feedback ───────────────────────────────────────────────────────
|
// ── When — feedback ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@When("GET /sandbox/feedback-schema is called without authentication")
|
@When("GET \\/sandbox\\/feedback-schema is called without authentication")
|
||||||
public void getFeedbackSchema() {
|
public void getFeedbackSchema() {
|
||||||
lastResponse = given().get("/sandbox/feedback-schema").andReturn();
|
lastResponse = given().get("/sandbox/feedback-schema").andReturn();
|
||||||
}
|
}
|
||||||
@@ -291,23 +326,24 @@ public class SandboxSteps {
|
|||||||
.contentType(JSON)
|
.contentType(JSON)
|
||||||
.body(Map.of("scores", Map.of()))
|
.body(Map.of("scores", Map.of()))
|
||||||
.when()
|
.when()
|
||||||
.post("/sandbox/" + sandboxName + "/feedback")
|
.post("/sandbox/" + resolveUuid(sandboxName) + "/feedback")
|
||||||
.andReturn();
|
.andReturn();
|
||||||
}
|
}
|
||||||
|
|
||||||
@When("the feedback aggregate for {string} is requested with the sandbox API key")
|
@When("the feedback aggregate for {string} is requested with the sandbox API key")
|
||||||
public void feedbackAggregateWithKey(String sandboxName) {
|
public void feedbackAggregateWithKey(String sandboxName) {
|
||||||
String key = resolveKey(sandboxName);
|
String key = resolveKey(sandboxName);
|
||||||
|
String uuid = resolveUuid(sandboxName);
|
||||||
lastResponse = given()
|
lastResponse = given()
|
||||||
.header(SANDBOX_API_KEY_HEADER, key)
|
.header(SANDBOX_API_KEY_HEADER, key)
|
||||||
.when()
|
.when()
|
||||||
.get("/sandbox/" + sandboxName + "/feedback")
|
.get("/sandbox/" + uuid + "/feedback")
|
||||||
.andReturn();
|
.andReturn();
|
||||||
}
|
}
|
||||||
|
|
||||||
@When("the feedback aggregate for {string} is requested without an API key")
|
@When("the feedback aggregate for {string} is requested without an API key")
|
||||||
public void feedbackAggregateWithoutKey(String sandboxName) {
|
public void feedbackAggregateWithoutKey(String sandboxName) {
|
||||||
lastResponse = given().get("/sandbox/" + sandboxName + "/feedback").andReturn();
|
lastResponse = given().get("/sandbox/" + resolveUuid(sandboxName) + "/feedback").andReturn();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Then — HTTP status ────────────────────────────────────────────────────
|
// ── Then — HTTP status ────────────────────────────────────────────────────
|
||||||
@@ -339,6 +375,11 @@ public class SandboxSteps {
|
|||||||
assertThat(lastResponse.jsonPath().getString("expiresAt")).isNotBlank();
|
assertThat(lastResponse.jsonPath().getString("expiresAt")).isNotBlank();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Then("the response contains _links.self")
|
||||||
|
public void responseContainsLinksSelf() {
|
||||||
|
assertThat(lastResponse.jsonPath().getString("_links.self.href")).as("_links.self.href").isNotBlank();
|
||||||
|
}
|
||||||
|
|
||||||
@Then("the response contains _links.self ending with {string}")
|
@Then("the response contains _links.self ending with {string}")
|
||||||
public void responseContainsLinksSelfEndingWith(String suffix) {
|
public void responseContainsLinksSelfEndingWith(String suffix) {
|
||||||
String href = lastResponse.jsonPath().getString("_links.self.href");
|
String href = lastResponse.jsonPath().getString("_links.self.href");
|
||||||
@@ -510,8 +551,10 @@ public class SandboxSteps {
|
|||||||
.andReturn();
|
.andReturn();
|
||||||
|
|
||||||
assertThat(r.statusCode()).as("sandbox creation for '%s' must return 201", name).isEqualTo(201);
|
assertThat(r.statusCode()).as("sandbox creation for '%s' must return 201", name).isEqualTo(201);
|
||||||
String key = r.jsonPath().getString("apiKey");
|
String key = r.jsonPath().getString("apiKey");
|
||||||
|
String uuid = r.jsonPath().getString("sandboxId");
|
||||||
sandboxKeys.put(name, key);
|
sandboxKeys.put(name, key);
|
||||||
|
sandboxUuids.put(name, uuid);
|
||||||
currentSandboxKey = key;
|
currentSandboxKey = key;
|
||||||
currentSandboxName = name;
|
currentSandboxName = name;
|
||||||
}
|
}
|
||||||
@@ -522,6 +565,12 @@ public class SandboxSteps {
|
|||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String resolveUuid(String sandboxName) {
|
||||||
|
String uuid = sandboxUuids.get(sandboxName);
|
||||||
|
assertThat(uuid).as("sandbox UUID for '%s' must be known — call createSandbox first", sandboxName).isNotNull();
|
||||||
|
return uuid;
|
||||||
|
}
|
||||||
|
|
||||||
private void submitFeedback(String sandboxName, Map<String, Integer> scores, String model, String provider) {
|
private void submitFeedback(String sandboxName, Map<String, Integer> scores, String model, String provider) {
|
||||||
submitFeedbackResponse(sandboxName, scores, model, provider);
|
submitFeedbackResponse(sandboxName, scores, model, provider);
|
||||||
}
|
}
|
||||||
@@ -536,7 +585,7 @@ public class SandboxSteps {
|
|||||||
.contentType(JSON)
|
.contentType(JSON)
|
||||||
.body(body)
|
.body(body)
|
||||||
.when()
|
.when()
|
||||||
.post("/sandbox/" + sandboxName + "/feedback")
|
.post("/sandbox/" + resolveUuid(sandboxName) + "/feedback")
|
||||||
.andReturn();
|
.andReturn();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-4
@@ -1,4 +1,4 @@
|
|||||||
package org.botstandards.apix.registry.bdd.device;
|
package org.botstandards.apix.registry.devicebdd;
|
||||||
|
|
||||||
import io.cucumber.core.cli.Main;
|
import io.cucumber.core.cli.Main;
|
||||||
import io.quarkus.test.junit.QuarkusTest;
|
import io.quarkus.test.junit.QuarkusTest;
|
||||||
@@ -9,8 +9,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
|||||||
/**
|
/**
|
||||||
* Runs all device navigation BDD scenarios inside the Quarkus test context.
|
* Runs all device navigation BDD scenarios inside the Quarkus test context.
|
||||||
*
|
*
|
||||||
* Uses its own glue package so step definitions do not conflict with
|
* Uses two glue packages: bdd (for TestSetup/truncateTables) and devicebdd
|
||||||
* IotTransitionSteps or OrgOnboardingSteps.
|
* (for DeviceNavigationSteps). The separation prevents DeviceNavigationSteps
|
||||||
|
* from being picked up by non-device CucumberITs that only scan --glue bdd.
|
||||||
*/
|
*/
|
||||||
@QuarkusTest
|
@QuarkusTest
|
||||||
public class DeviceCucumberIT {
|
public class DeviceCucumberIT {
|
||||||
@@ -18,7 +19,7 @@ public class DeviceCucumberIT {
|
|||||||
@Test
|
@Test
|
||||||
public void run() {
|
public void run() {
|
||||||
byte exitCode = Main.run(
|
byte exitCode = Main.run(
|
||||||
"--glue", "org.botstandards.apix.registry.bdd.device",
|
"--glue", "org.botstandards.apix.registry.devicebdd",
|
||||||
"--plugin", "pretty",
|
"--plugin", "pretty",
|
||||||
"--plugin", "json:target/cucumber-report-devices.json",
|
"--plugin", "json:target/cucumber-report-devices.json",
|
||||||
"--plugin", "io.qameta.allure.cucumber7jvm.AllureCucumber7Jvm",
|
"--plugin", "io.qameta.allure.cucumber7jvm.AllureCucumber7Jvm",
|
||||||
+8
-7
@@ -1,4 +1,4 @@
|
|||||||
package org.botstandards.apix.registry.bdd.device;
|
package org.botstandards.apix.registry.devicebdd;
|
||||||
|
|
||||||
import io.cucumber.java.en.And;
|
import io.cucumber.java.en.And;
|
||||||
import io.cucumber.java.en.Given;
|
import io.cucumber.java.en.Given;
|
||||||
@@ -16,7 +16,8 @@ import static org.hamcrest.Matchers.*;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Self-contained step definitions for the /devices top-level resource.
|
* Self-contained step definitions for the /devices top-level resource.
|
||||||
* Uses its own glue package so it does not conflict with IotTransitionSteps.
|
* Lives in a sibling package (devicebdd) to avoid recursive inclusion by
|
||||||
|
* non-device CucumberITs that use --glue bdd only.
|
||||||
* Cucumber creates a fresh instance per scenario — instance fields are scenario-scoped.
|
* Cucumber creates a fresh instance per scenario — instance fields are scenario-scoped.
|
||||||
*/
|
*/
|
||||||
public class DeviceNavigationSteps {
|
public class DeviceNavigationSteps {
|
||||||
@@ -97,27 +98,27 @@ public class DeviceNavigationSteps {
|
|||||||
|
|
||||||
// ── When ──────────────────────────────────────────────────────────────────
|
// ── When ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@When("GET /devices is called with no query params")
|
@When("GET \\/devices is called with no query params")
|
||||||
public void getDevicesRoot() {
|
public void getDevicesRoot() {
|
||||||
lastResponse = given().get("/devices");
|
lastResponse = given().get("/devices");
|
||||||
}
|
}
|
||||||
|
|
||||||
@When("GET / is called")
|
@When("GET \\/ is called")
|
||||||
public void getRoot() {
|
public void getRoot() {
|
||||||
lastResponse = given().get("/");
|
lastResponse = given().get("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
@When("GET /devices?capability={string} is called")
|
@When("^GET /devices\\?capability=(.+) is called$")
|
||||||
public void getDevicesByCapability(String capability) {
|
public void getDevicesByCapability(String capability) {
|
||||||
lastResponse = given().get("/devices?capability=" + capability);
|
lastResponse = given().get("/devices?capability=" + capability);
|
||||||
}
|
}
|
||||||
|
|
||||||
@When("GET /devices?deviceClass={string} is called")
|
@When("^GET /devices\\?deviceClass=(.+) is called$")
|
||||||
public void getDevicesByDeviceClass(String deviceClass) {
|
public void getDevicesByDeviceClass(String deviceClass) {
|
||||||
lastResponse = given().get("/devices?deviceClass=" + deviceClass);
|
lastResponse = given().get("/devices?deviceClass=" + deviceClass);
|
||||||
}
|
}
|
||||||
|
|
||||||
@When("GET /devices?protocol={string} is called")
|
@When("^GET /devices\\?protocol=(.+) is called$")
|
||||||
public void getDevicesByProtocol(String protocol) {
|
public void getDevicesByProtocol(String protocol) {
|
||||||
lastResponse = given().get("/devices?protocol=" + protocol);
|
lastResponse = given().get("/devices?protocol=" + protocol);
|
||||||
}
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
package org.botstandards.apix.registry.devicebdd;
|
||||||
|
|
||||||
|
import io.cucumber.java.After;
|
||||||
|
import io.cucumber.java.Before;
|
||||||
|
import io.quarkus.arc.Arc;
|
||||||
|
import io.restassured.RestAssured;
|
||||||
|
import org.botstandards.apix.registry.service.ClockService;
|
||||||
|
|
||||||
|
import java.sql.DriverManager;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public class TestSetup {
|
||||||
|
|
||||||
|
private static final Instant REFERENCE_INSTANT = Instant.parse("2025-01-01T00:00:00Z");
|
||||||
|
|
||||||
|
@Before(order = 0)
|
||||||
|
public void configureRestAssured() {
|
||||||
|
RestAssured.port = 8181;
|
||||||
|
RestAssured.enableLoggingOfRequestAndResponseIfValidationFails();
|
||||||
|
Arc.container().instance(ClockService.class).get().advance(REFERENCE_INSTANT);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Before(order = 1)
|
||||||
|
public void truncateTables() throws Exception {
|
||||||
|
try (var conn = DriverManager.getConnection(
|
||||||
|
"jdbc:postgresql://localhost:5432/apix", "apix", "apix");
|
||||||
|
var stmt = conn.createStatement()) {
|
||||||
|
stmt.execute("TRUNCATE TABLE service_replacements, service_versions, services, org_verification_events, organizations, sandbox_feedback, sandbox_usage_stats, sandboxes CASCADE");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
public void resetClock() {
|
||||||
|
Arc.container().instance(ClockService.class).get().reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -13,7 +13,7 @@ Feature: HATEOAS navigation and root key resolution
|
|||||||
Scenario: Root resource with valid sandbox API key includes _links.sandbox
|
Scenario: Root resource with valid sandbox API key includes _links.sandbox
|
||||||
When the root resource is requested with the sandbox API key for "nav-demo"
|
When the root resource is requested with the sandbox API key for "nav-demo"
|
||||||
Then the response code is 200
|
Then the response code is 200
|
||||||
And the response contains _links.sandbox ending with "/sandbox/nav-demo"
|
And the response contains _links.sandbox
|
||||||
|
|
||||||
Scenario: Root resource with unknown API key omits _links.sandbox
|
Scenario: Root resource with unknown API key omits _links.sandbox
|
||||||
When the root resource is requested with API key "apix_sb_unknownkey"
|
When the root resource is requested with API key "apix_sb_unknownkey"
|
||||||
@@ -28,6 +28,6 @@ Feature: HATEOAS navigation and root key resolution
|
|||||||
And the response contains _links.submitFeedback
|
And the response contains _links.submitFeedback
|
||||||
And the response contains _links.feedbackSchema
|
And the response contains _links.feedbackSchema
|
||||||
|
|
||||||
Scenario: Sandbox root for unknown name returns 404
|
Scenario: Sandbox root for unknown UUID returns 404
|
||||||
When the sandbox root for "does-not-exist" is requested
|
When the sandbox root for UUID "00000000-0000-0000-0000-000000000000" is requested
|
||||||
Then the response code is 404
|
Then the response code is 404
|
||||||
|
|||||||
+3
-3
@@ -7,7 +7,7 @@ Feature: Sandbox registration
|
|||||||
And the response contains an API key with prefix "apix_sb_"
|
And the response contains an API key with prefix "apix_sb_"
|
||||||
And the response contains tier "FREE"
|
And the response contains tier "FREE"
|
||||||
And the response contains a non-null expiresAt
|
And the response contains a non-null expiresAt
|
||||||
And the response contains _links.self ending with "/sandbox/peter-demo"
|
And the response contains _links.self
|
||||||
And the response contains _links.services
|
And the response contains _links.services
|
||||||
|
|
||||||
Scenario: Registration fails when name contains uppercase letters
|
Scenario: Registration fails when name contains uppercase letters
|
||||||
@@ -26,7 +26,7 @@ Feature: Sandbox registration
|
|||||||
When an agent registers a sandbox named "valid-name" with email "not-an-email"
|
When an agent registers a sandbox named "valid-name" with email "not-an-email"
|
||||||
Then the response code is 400
|
Then the response code is 400
|
||||||
|
|
||||||
Scenario: Duplicate name is rejected with 409
|
Scenario: Duplicate name is allowed since names are display-only labels
|
||||||
Given a sandbox named "duplicate-test" exists
|
Given a sandbox named "duplicate-test" exists
|
||||||
When an agent registers a sandbox named "duplicate-test" with email "other@example.com"
|
When an agent registers a sandbox named "duplicate-test" with email "other@example.com"
|
||||||
Then the response code is 409
|
Then the response code is 201
|
||||||
|
|||||||
+17
-1
@@ -6,7 +6,7 @@ Feature: Sandbox service isolation from production
|
|||||||
|
|
||||||
Scenario: Service registered in sandbox does not appear in production list
|
Scenario: Service registered in sandbox does not appear in production list
|
||||||
Given a sandbox service with endpoint "https://sandbox.example.com" and capability "test.cap" is registered in "isolation-test"
|
Given a sandbox service with endpoint "https://sandbox.example.com" and capability "test.cap" is registered in "isolation-test"
|
||||||
When GET /services is called without authentication
|
When GET /services is called with capability "test.cap" without authentication
|
||||||
Then the response code is 200
|
Then the response code is 200
|
||||||
And "https://sandbox.example.com" is not in the endpoint list
|
And "https://sandbox.example.com" is not in the endpoint list
|
||||||
|
|
||||||
@@ -43,3 +43,19 @@ Feature: Sandbox service isolation from production
|
|||||||
Then the response code is 200
|
Then the response code is 200
|
||||||
And "https://ext-eu.example.com" is in the endpoint list
|
And "https://ext-eu.example.com" is in the endpoint list
|
||||||
And "https://ext-us.example.com" is not in the endpoint list
|
And "https://ext-us.example.com" is not in the endpoint list
|
||||||
|
|
||||||
|
Scenario: GET /services with sandbox key scopes results to sandbox only
|
||||||
|
Given a sandbox service with endpoint "https://keyed-sb.example.com" and capability "keyed.cap" is registered in "isolation-test"
|
||||||
|
When GET /services is called with capability "keyed.cap" and the sandbox key for "isolation-test"
|
||||||
|
Then the response code is 200
|
||||||
|
And "https://keyed-sb.example.com" is in the endpoint list
|
||||||
|
|
||||||
|
Scenario: GET /services with sandbox key does not return production services
|
||||||
|
When GET /services is called with capability "device.telemetry" and the sandbox key for "isolation-test"
|
||||||
|
Then the response code is 200
|
||||||
|
And "https://prod.example.com" is not in the endpoint list
|
||||||
|
|
||||||
|
Scenario: GET /services with invalid sandbox key falls back to production
|
||||||
|
When GET /services is called with capability "device.telemetry" and API key "apix_sb_000invalid"
|
||||||
|
Then the response code is 200
|
||||||
|
And "https://prod.example.com" is in the endpoint list
|
||||||
|
|||||||
+5
@@ -61,11 +61,16 @@ public class QueryNormalisationService {
|
|||||||
public String canonicalForReplacements(MultivaluedMap<String, String> raw) {
|
public String canonicalForReplacements(MultivaluedMap<String, String> raw) {
|
||||||
Map<String, String> out = new TreeMap<>();
|
Map<String, String> out = new TreeMap<>();
|
||||||
put(out, "deviceClass", normaliseLower(raw.getFirst("deviceClass")));
|
put(out, "deviceClass", normaliseLower(raw.getFirst("deviceClass")));
|
||||||
|
put(out, "iotReady", normaliseIotReady(raw.getFirst("iotReady")));
|
||||||
put(out, "minOLevel", normaliseEnum(raw.getFirst("minOLevel")));
|
put(out, "minOLevel", normaliseEnum(raw.getFirst("minOLevel")));
|
||||||
put(out, "protocol", normaliseEnum(raw.getFirst("protocol")));
|
put(out, "protocol", normaliseEnum(raw.getFirst("protocol")));
|
||||||
return render(out);
|
return render(out);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String normaliseIotReady(String v) {
|
||||||
|
return "true".equalsIgnoreCase(v) ? "true" : null;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Individual value normalisers — package-private for unit tests ──────────
|
// ── Individual value normalisers — package-private for unit tests ──────────
|
||||||
|
|
||||||
String normaliseCapability(String v) {
|
String normaliseCapability(String v) {
|
||||||
|
|||||||
+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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -47,7 +47,7 @@ public class IndexResource {
|
|||||||
if (apiKey != null && !apiKey.isBlank()) {
|
if (apiKey != null && !apiKey.isBlank()) {
|
||||||
SandboxEntity sandbox = sandboxService.findByKey(apiKey);
|
SandboxEntity sandbox = sandboxService.findByKey(apiKey);
|
||||||
if (sandbox != null) {
|
if (sandbox != null) {
|
||||||
sandboxLink = new LinkRef(baseUrl + "/sandbox/" + sandbox.name);
|
sandboxLink = new LinkRef(baseUrl + "/sandbox/" + sandbox.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
-5
@@ -69,16 +69,31 @@ public class SandboxResource {
|
|||||||
@GET
|
@GET
|
||||||
@Path("/{uuid}")
|
@Path("/{uuid}")
|
||||||
@Operation(
|
@Operation(
|
||||||
summary = "Sandbox root — HATEOAS navigation and dashboard data",
|
summary = "Sandbox root — HATEOAS navigation",
|
||||||
description = "Returns sandbox metadata, navigation links, usage stats, and agent visit data. " +
|
description = "Returns sandbox metadata and navigation links. " +
|
||||||
"No authentication required. The UUID is the permanent resource identifier."
|
"No authentication required. The UUID is the permanent resource identifier."
|
||||||
)
|
)
|
||||||
public SandboxDashboardResponse index(@PathParam("uuid") String uuidStr) {
|
public SandboxIndexResponse index(@PathParam("uuid") String uuidStr) {
|
||||||
UUID id = parseUuid(uuidStr);
|
UUID id = parseUuid(uuidStr);
|
||||||
SandboxDashboardResponse dashboard = sandboxService.getDashboard(id);
|
var sb = sandboxService.requireById(id);
|
||||||
sandboxService.recordUsage(id.toString(), SandboxService.EVENT_SANDBOX_VIEWED);
|
sandboxService.recordUsage(id.toString(), SandboxService.EVENT_SANDBOX_VIEWED);
|
||||||
meters.counter("apix.sandbox.views", "sandbox", id.toString()).increment();
|
meters.counter("apix.sandbox.views", "sandbox", id.toString()).increment();
|
||||||
return dashboard;
|
String base = baseUrl + "/sandbox/" + id;
|
||||||
|
return new SandboxIndexResponse(sb.id.toString(), sb.name, sb.tier, sb.ratePerMinute,
|
||||||
|
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
|
||||||
|
|||||||
+23
-6
@@ -11,12 +11,15 @@ import org.botstandards.apix.registry.dto.ReplacementsResponse;
|
|||||||
import org.botstandards.apix.registry.dto.ServicePatchRequest;
|
import org.botstandards.apix.registry.dto.ServicePatchRequest;
|
||||||
import org.botstandards.apix.registry.dto.ServiceResponse;
|
import org.botstandards.apix.registry.dto.ServiceResponse;
|
||||||
import org.botstandards.apix.registry.dto.VersionHistoryEntry;
|
import org.botstandards.apix.registry.dto.VersionHistoryEntry;
|
||||||
|
import org.botstandards.apix.registry.entity.SandboxEntity;
|
||||||
import org.botstandards.apix.registry.service.RegistryService;
|
import org.botstandards.apix.registry.service.RegistryService;
|
||||||
|
import org.botstandards.apix.registry.service.SandboxService;
|
||||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||||
import org.eclipse.microprofile.openapi.annotations.Operation;
|
import org.eclipse.microprofile.openapi.annotations.Operation;
|
||||||
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
|
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
|
||||||
import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement;
|
import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement;
|
||||||
|
|
||||||
|
import jakarta.ws.rs.core.UriInfo;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -30,6 +33,9 @@ public class ServiceResource {
|
|||||||
@Inject
|
@Inject
|
||||||
RegistryService registryService;
|
RegistryService registryService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
SandboxService sandboxService;
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
MeterRegistry meters;
|
MeterRegistry meters;
|
||||||
|
|
||||||
@@ -83,6 +89,8 @@ public class ServiceResource {
|
|||||||
summary = "Search services by capability and optional extension properties",
|
summary = "Search services by capability and optional extension properties",
|
||||||
description = "Returns services matching the given capability keyword. " +
|
description = "Returns services matching the given capability keyword. " +
|
||||||
"Omitting ?stage= defaults to PRODUCTION only — this is the standard agent query. " +
|
"Omitting ?stage= defaults to PRODUCTION only — this is the standard agent query. " +
|
||||||
|
"When a valid sandbox API key is provided via X-Api-Key, results are scoped to that sandbox " +
|
||||||
|
"and stage defaults to DEVELOPMENT. " +
|
||||||
"Use ?stage=DEVELOPMENT or ?stage=BETA to discover pre-production services. " +
|
"Use ?stage=DEVELOPMENT or ?stage=BETA to discover pre-production services. " +
|
||||||
"Capability values are lowercase kebab-case strings defined by the registrant (e.g. nlp, translation, speech-to-text). " +
|
"Capability values are lowercase kebab-case strings defined by the registrant (e.g. nlp, translation, speech-to-text). " +
|
||||||
"The search matches exact capability tokens, not substrings. " +
|
"The search matches exact capability tokens, not substrings. " +
|
||||||
@@ -90,17 +98,20 @@ public class ServiceResource {
|
|||||||
"Multiple ?property= parameters are ANDed together. " +
|
"Multiple ?property= parameters are ANDed together. " +
|
||||||
"Example: ?capability=translation&property=region:eu&property=dataResidency:DE"
|
"Example: ?capability=translation&property=region:eu&property=dataResidency:DE"
|
||||||
)
|
)
|
||||||
public List<ServiceResponse> search(
|
public Response search(
|
||||||
@Parameter(description = "Capability keyword to search for (e.g. nlp, translation, speech-to-text). Required.", example = "nlp")
|
@Parameter(description = "Capability keyword to search for (e.g. nlp, translation, speech-to-text). Required.", example = "nlp")
|
||||||
@QueryParam("capability") String capability,
|
@QueryParam("capability") String capability,
|
||||||
@Parameter(description = "Lifecycle stage filter. Defaults to PRODUCTION if omitted. Valid values: DEVELOPMENT, BETA, PRODUCTION, DEPRECATED.", example = "PRODUCTION")
|
@Parameter(description = "Lifecycle stage filter. Defaults to PRODUCTION if omitted. Valid values: DEVELOPMENT, BETA, PRODUCTION, DEPRECATED.", example = "PRODUCTION")
|
||||||
@QueryParam("stage") String stage,
|
@QueryParam("stage") String stage,
|
||||||
@Parameter(description = "Extension property filter in key:value format. Matches against the service's extensions object. Repeatable for AND logic.", example = "region:eu")
|
@Parameter(description = "Extension property filter in key:value format. Matches against the service's extensions object. Repeatable for AND logic.", example = "region:eu")
|
||||||
@QueryParam("property") List<String> properties) {
|
@QueryParam("property") List<String> properties,
|
||||||
|
@HeaderParam("X-Api-Key") String incomingKey) {
|
||||||
if (capability == null || capability.isBlank()) {
|
if (capability == null || capability.isBlank()) {
|
||||||
throw new BadRequestException("capability query parameter is required");
|
throw new BadRequestException("capability query parameter is required");
|
||||||
}
|
}
|
||||||
var results = registryService.search(capability, stage, properties).stream()
|
SandboxEntity sandbox = sandboxService.findByKey(incomingKey);
|
||||||
|
String sandboxId = sandbox != null ? sandbox.id.toString() : null;
|
||||||
|
var results = registryService.search(capability, stage, properties, sandboxId).stream()
|
||||||
.map(ServiceResponse::from)
|
.map(ServiceResponse::from)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
@@ -114,7 +125,12 @@ public class ServiceResource {
|
|||||||
"capability", tv(capability))
|
"capability", tv(capability))
|
||||||
.record(results.size());
|
.record(results.size());
|
||||||
|
|
||||||
return results;
|
Response.ResponseBuilder rb = Response.ok(results);
|
||||||
|
// Sandbox responses are scoped to a specific API key — must not be cached by the CDN.
|
||||||
|
if (sandboxId != null) {
|
||||||
|
rb.header("Cache-Control", "private, no-store");
|
||||||
|
}
|
||||||
|
return rb.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PATCH
|
@PATCH
|
||||||
@@ -143,14 +159,15 @@ public class ServiceResource {
|
|||||||
"Optionally filter by minimum O-level, IoT readiness, device class, or protocol."
|
"Optionally filter by minimum O-level, IoT readiness, device class, or protocol."
|
||||||
)
|
)
|
||||||
public Response getReplacements(@PathParam("id") UUID id,
|
public Response getReplacements(@PathParam("id") UUID id,
|
||||||
|
@Context UriInfo uriInfo,
|
||||||
@Parameter(description = "Minimum O-level of replacement candidates.", example = "IDENTITY_VERIFIED")
|
@Parameter(description = "Minimum O-level of replacement candidates.", example = "IDENTITY_VERIFIED")
|
||||||
@QueryParam("minOLevel") String minOLevel,
|
@QueryParam("minOLevel") String minOLevel,
|
||||||
@Parameter(description = "If true, only return services with an IoT profile.")
|
|
||||||
@QueryParam("iotReady") Boolean iotReady,
|
|
||||||
@Parameter(description = "Filter by IoT device class (e.g. sensor, actuator, gateway).")
|
@Parameter(description = "Filter by IoT device class (e.g. sensor, actuator, gateway).")
|
||||||
@QueryParam("deviceClass") String deviceClass,
|
@QueryParam("deviceClass") String deviceClass,
|
||||||
@Parameter(description = "Filter by IoT protocol (e.g. MQTT, AMQP, HTTP).")
|
@Parameter(description = "Filter by IoT protocol (e.g. MQTT, AMQP, HTTP).")
|
||||||
@QueryParam("protocol") String protocol) {
|
@QueryParam("protocol") String protocol) {
|
||||||
|
String iotReadyStr = uriInfo.getQueryParameters().getFirst("iotReady");
|
||||||
|
boolean iotReady = "true".equalsIgnoreCase(iotReadyStr);
|
||||||
ReplacementsResponse body = registryService.getReplacements(id, minOLevel, iotReady, deviceClass, protocol);
|
ReplacementsResponse body = registryService.getReplacements(id, minOLevel, iotReady, deviceClass, protocol);
|
||||||
return Response.ok(body)
|
return Response.ok(body)
|
||||||
.header("Cache-Control", "public, max-age=60")
|
.header("Cache-Control", "public, max-age=60")
|
||||||
|
|||||||
+32
-24
@@ -127,18 +127,22 @@ public class RegistryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
public List<ServiceEntity> search(String capability, String stage, List<String> properties) {
|
public List<ServiceEntity> search(String capability, String stage, List<String> properties,
|
||||||
|
String sandboxId) {
|
||||||
ServiceStage targetStage = stage != null
|
ServiceStage targetStage = stage != null
|
||||||
? ServiceStage.valueOf(stage.toUpperCase())
|
? ServiceStage.valueOf(stage.toUpperCase())
|
||||||
: ServiceStage.PRODUCTION;
|
: sandboxId != null ? ServiceStage.DEVELOPMENT : ServiceStage.PRODUCTION;
|
||||||
|
|
||||||
List<String[]> props = parsePropertyFilters(properties);
|
List<String[]> props = parsePropertyFilters(properties);
|
||||||
|
String scopeClause = sandboxId != null
|
||||||
|
? "AND s.sandbox_id = :sid"
|
||||||
|
: "AND s.sandbox_id IS NULL";
|
||||||
StringBuilder sql = new StringBuilder(
|
StringBuilder sql = new StringBuilder(
|
||||||
"SELECT s.* FROM services s " +
|
"SELECT s.* FROM services s " +
|
||||||
"WHERE s.bsm_payload @> jsonb_build_object('capabilities', jsonb_build_array(:cap)) " +
|
"WHERE s.bsm_payload @> jsonb_build_object('capabilities', jsonb_build_array(:cap)) " +
|
||||||
"AND s.registry_status = 'ACTIVE' " +
|
"AND s.registry_status = 'ACTIVE' " +
|
||||||
"AND s.service_stage = :stage " +
|
"AND s.service_stage = :stage " +
|
||||||
"AND s.sandbox_id IS NULL");
|
scopeClause);
|
||||||
for (int i = 0; i < props.size(); i++) {
|
for (int i = 0; i < props.size(); i++) {
|
||||||
sql.append(" AND s.bsm_payload -> 'extensions' ->> :propKey").append(i)
|
sql.append(" AND s.bsm_payload -> 'extensions' ->> :propKey").append(i)
|
||||||
.append(" = :propValue").append(i);
|
.append(" = :propValue").append(i);
|
||||||
@@ -147,6 +151,7 @@ public class RegistryService {
|
|||||||
Query q = em.createNativeQuery(sql.toString(), ServiceEntity.class)
|
Query q = em.createNativeQuery(sql.toString(), ServiceEntity.class)
|
||||||
.setParameter("cap", capability)
|
.setParameter("cap", capability)
|
||||||
.setParameter("stage", targetStage.name());
|
.setParameter("stage", targetStage.name());
|
||||||
|
if (sandboxId != null) q.setParameter("sid", sandboxId);
|
||||||
for (int i = 0; i < props.size(); i++) {
|
for (int i = 0; i < props.size(); i++) {
|
||||||
q.setParameter("propKey" + i, props.get(i)[0]);
|
q.setParameter("propKey" + i, props.get(i)[0]);
|
||||||
q.setParameter("propValue" + i, props.get(i)[1]);
|
q.setParameter("propValue" + i, props.get(i)[1]);
|
||||||
@@ -167,7 +172,7 @@ public class RegistryService {
|
|||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
public ReplacementsResponse getReplacements(UUID deprecatedId, String minOLevelStr,
|
public ReplacementsResponse getReplacements(UUID deprecatedId, String minOLevelStr,
|
||||||
Boolean iotReady, String deviceClass, String protocol) {
|
boolean iotReady, String deviceClass, String protocol) {
|
||||||
ServiceEntity deprecated = requireById(deprecatedId);
|
ServiceEntity deprecated = requireById(deprecatedId);
|
||||||
|
|
||||||
// Locked services that are not yet DECOMMISSIONED block replacement discovery
|
// Locked services that are not yet DECOMMISSIONED block replacement discovery
|
||||||
@@ -175,36 +180,39 @@ public class RegistryService {
|
|||||||
return new ReplacementsResponse(deprecated.locked, deprecated.sunsetAt, List.of());
|
return new ReplacementsResponse(deprecated.locked, deprecated.sunsetAt, List.of());
|
||||||
}
|
}
|
||||||
|
|
||||||
List<ServiceEntity> candidates = em.createNativeQuery(
|
// INNER JOIN iot_profiles when IoT filters are active — explicit join guarantees
|
||||||
|
// row existence without relying on Hibernate EAGER proxy null-checks.
|
||||||
|
boolean needsIotJoin = iotReady
|
||||||
|
|| (deviceClass != null && !deviceClass.isBlank())
|
||||||
|
|| (protocol != null && !protocol.isBlank());
|
||||||
|
|
||||||
|
StringBuilder sql = new StringBuilder(
|
||||||
"SELECT s.* FROM services s " +
|
"SELECT s.* FROM services s " +
|
||||||
"INNER JOIN service_replacements sr ON sr.replacement_service_id = s.id " +
|
"INNER JOIN service_replacements sr ON sr.replacement_service_id = s.id ");
|
||||||
"WHERE sr.deprecated_service_id = :depId AND s.registry_status = 'ACTIVE'",
|
if (needsIotJoin) {
|
||||||
ServiceEntity.class)
|
sql.append("INNER JOIN iot_profiles ip ON ip.service_id = s.id ");
|
||||||
.setParameter("depId", deprecatedId)
|
|
||||||
.getResultList();
|
|
||||||
|
|
||||||
OLevel minOLevel = minOLevelStr != null ? OLevel.valueOf(minOLevelStr) : null;
|
|
||||||
|
|
||||||
Stream<ServiceEntity> stream = candidates.stream()
|
|
||||||
.filter(c -> minOLevel == null || c.olevel.ordinal() >= minOLevel.ordinal());
|
|
||||||
if (Boolean.TRUE.equals(iotReady)) {
|
|
||||||
stream = stream.filter(c -> c.iotProfile != null);
|
|
||||||
}
|
}
|
||||||
|
sql.append("WHERE sr.deprecated_service_id = :depId AND s.registry_status = 'ACTIVE'");
|
||||||
if (deviceClass != null && !deviceClass.isBlank()) {
|
if (deviceClass != null && !deviceClass.isBlank()) {
|
||||||
stream = stream.filter(c -> c.iotProfile != null
|
sql.append(" AND ip.device_classes @> jsonb_build_array(:deviceClass)");
|
||||||
&& c.iotProfile.deviceClasses != null
|
|
||||||
&& c.iotProfile.deviceClasses.contains(deviceClass));
|
|
||||||
}
|
}
|
||||||
if (protocol != null && !protocol.isBlank()) {
|
if (protocol != null && !protocol.isBlank()) {
|
||||||
stream = stream.filter(c -> c.iotProfile != null
|
sql.append(" AND ip.protocols @> jsonb_build_array(:protocol)");
|
||||||
&& c.iotProfile.protocols != null
|
|
||||||
&& c.iotProfile.protocols.contains(protocol));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Query q = em.createNativeQuery(sql.toString(), ServiceEntity.class)
|
||||||
|
.setParameter("depId", deprecatedId);
|
||||||
|
if (deviceClass != null && !deviceClass.isBlank()) q.setParameter("deviceClass", deviceClass);
|
||||||
|
if (protocol != null && !protocol.isBlank()) q.setParameter("protocol", protocol);
|
||||||
|
|
||||||
|
List<ServiceEntity> candidates = q.getResultList();
|
||||||
|
OLevel minOLevel = minOLevelStr != null ? OLevel.valueOf(minOLevelStr) : null;
|
||||||
|
|
||||||
return new ReplacementsResponse(
|
return new ReplacementsResponse(
|
||||||
deprecated.locked,
|
deprecated.locked,
|
||||||
deprecated.sunsetAt,
|
deprecated.sunsetAt,
|
||||||
stream
|
candidates.stream()
|
||||||
|
.filter(c -> minOLevel == null || c.olevel.ordinal() >= minOLevel.ordinal())
|
||||||
.sorted(Comparator.comparingInt((ServiceEntity c) -> c.olevel.ordinal()).reversed())
|
.sorted(Comparator.comparingInt((ServiceEntity c) -> c.olevel.ordinal()).reversed())
|
||||||
.map(c -> new ReplacementsResponse.Candidate(
|
.map(c -> new ReplacementsResponse.Candidate(
|
||||||
c.id, c.bsmPayload.name(), c.endpointUrl, c.olevel, c.serviceStage,
|
c.id, c.bsmPayload.name(), c.endpointUrl, c.olevel, c.serviceStage,
|
||||||
|
|||||||
+82
-1
@@ -377,7 +377,7 @@ public class SandboxService {
|
|||||||
em.createNativeQuery(
|
em.createNativeQuery(
|
||||||
"INSERT INTO sandbox_feedback " +
|
"INSERT INTO sandbox_feedback " +
|
||||||
"(sandbox_id, scores, agent_identifier, comment, model_identifier, model_provider) " +
|
"(sandbox_id, scores, agent_identifier, comment, model_identifier, model_provider) " +
|
||||||
"VALUES (:sid, :scores::jsonb, :agent, :comment, :modelId, :modelProvider)")
|
"VALUES (:sid, CAST(:scores AS jsonb), :agent, :comment, :modelId, :modelProvider)")
|
||||||
.setParameter("sid", sandboxId)
|
.setParameter("sid", sandboxId)
|
||||||
.setParameter("scores", scoresJson)
|
.setParameter("scores", scoresJson)
|
||||||
.setParameter("agent", req.agentIdentifier())
|
.setParameter("agent", req.agentIdentifier())
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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..."
|
||||||
|
|||||||
@@ -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
@@ -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"
|
|
||||||
|
|||||||
@@ -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
@@ -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 ""
|
||||||
|
|||||||
Reference in New Issue
Block a user