Compare commits
28 Commits
8bc0ce1fb8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 89aa2c47cb | |||
| 9b70599d98 | |||
| f047fa0e2d | |||
| 0b519a003f | |||
| bef9494622 | |||
| 02420fcbc4 | |||
| b85c24d9bf | |||
| f3566a3c1a | |||
| 1fd481a2d9 | |||
| 7ccc384ebc | |||
| 4d980d7fc3 | |||
| f895bcf0cf | |||
| a4a5a8e3f2 | |||
| fa473d2b61 | |||
| 435e1bda42 | |||
| de597f1d8b | |||
| 4908d5d6e0 | |||
| 0ee76ac535 | |||
| 6bc4059922 | |||
| 00e4731bde | |||
| 0d4f7934f7 | |||
| c7bfa73d2f | |||
| 51e210ab68 | |||
| 60dfcd58f6 | |||
| ad19c8f20f | |||
| 82a379e536 | |||
| e385d05ce1 | |||
| 134c8b3c6b |
@@ -35,3 +35,9 @@ REGISTRY_BASE_URL=http://localhost:8180
|
||||
# ── Logging ───────────────────────────────────────────────────────────────────
|
||||
# DEBUG in dev; INFO in prod
|
||||
LOG_LEVEL=DEBUG
|
||||
|
||||
# ── Observability ─────────────────────────────────────────────────────────────
|
||||
# Grafana admin password. Change in any non-local environment.
|
||||
GRAFANA_ADMIN_PASSWORD=admin
|
||||
# Set to your public domain in production, e.g. https://grafana.api-index.org
|
||||
GRAFANA_ROOT_URL=http://localhost:3000
|
||||
|
||||
+8
-1
@@ -1,5 +1,8 @@
|
||||
# Script run logs
|
||||
# Script run logs and runtime artifacts
|
||||
logs/
|
||||
.logs/
|
||||
.pids/
|
||||
C*Temp*.json
|
||||
|
||||
# flatten-maven-plugin resolves ${revision} into installed POMs
|
||||
.flattened-pom.xml
|
||||
@@ -7,6 +10,9 @@ logs/
|
||||
# Local environment — never commit real credentials
|
||||
.env
|
||||
|
||||
# Bunny.net pull zone ID written by setup-bunnynet.sh
|
||||
.bunnynet-pull-zone-id
|
||||
|
||||
# Maven build output
|
||||
target/
|
||||
**/target/
|
||||
@@ -22,3 +28,4 @@ sanctions-cache/
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
/.allure/
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
playwright-report/
|
||||
test-results/
|
||||
Generated
+76
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"name": "apix-admin-tests",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "apix-admin-tests",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.44.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "apix-admin-tests",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "playwright test",
|
||||
"test:headed": "playwright test --headed",
|
||||
"test:report": "playwright show-report"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.44.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// @ts-check
|
||||
const { defineConfig } = require('@playwright/test');
|
||||
|
||||
module.exports = defineConfig({
|
||||
testDir: './tests',
|
||||
timeout: 30_000,
|
||||
retries: 0,
|
||||
reporter: [['line'], ['html', { open: 'never' }]],
|
||||
use: {
|
||||
baseURL: process.env.APIX_ADMIN_URL || 'http://localhost:8084',
|
||||
// Cookie set by the auth filter; value must match APIX_API_KEY on the registry
|
||||
storageState: undefined,
|
||||
},
|
||||
});
|
||||
@@ -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.getRequestUri().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,82 @@
|
||||
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 java.util.Map;
|
||||
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}/poll")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public Response sandboxPoll(@PathParam("uuid") String uuid) {
|
||||
try {
|
||||
SandboxDashboardResponse d = registry.sandboxDetail(adminKey, uuid);
|
||||
long total = d.usage() == null ? 0L :
|
||||
d.usage().values().stream().mapToLong(Long::longValue).sum();
|
||||
String json = mapper.writeValueAsString(Map.of(
|
||||
"totalRequests", total,
|
||||
"ratePerMinute", d.ratePerMinute()
|
||||
));
|
||||
return Response.ok(json).type(MediaType.APPLICATION_JSON).build();
|
||||
} catch (Exception e) {
|
||||
LOG.errorf(e, "Poll failed for sandbox %s", uuid);
|
||||
return Response.serverError().build();
|
||||
}
|
||||
}
|
||||
|
||||
@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();
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
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,
|
||||
boolean hasPrev, boolean hasNext, int displayPage, int prevPage, int nextPage);
|
||||
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);
|
||||
boolean hasPrev = page > 0;
|
||||
boolean hasNext = (long)(page + 1) * size < result.total();
|
||||
return Templates.list(result, page, size, tier,
|
||||
hasPrev, hasNext, page + 1, page - 1, page + 1);
|
||||
} 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,247 @@
|
||||
/* 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.sandboxId) {
|
||||
// sandbox drill-down
|
||||
if (__D.registrarLat != null) {
|
||||
points.push({ lat: __D.registrarLat, lon: __D.registrarLon, label: __D.name, tier: __D.tier, type: 'registrar' });
|
||||
}
|
||||
// agent blinks — always, even when sandbox has no declared location
|
||||
(__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);
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
// ── Sandbox live rate chart (sandbox drill-down only) ──────────────────────
|
||||
(function () {
|
||||
var el = document.getElementById('rate-chart');
|
||||
if (!el || !window.__D || !__D.sandboxId) return;
|
||||
|
||||
// Admin always polls at 1 s when active; idles back to 5 s after 15 s of no traffic
|
||||
// Tier-dependent rate would live in a future sandbox-owner dashboard, not here
|
||||
var FAST_INTERVAL = 1000;
|
||||
var IDLE_INTERVAL = 5000;
|
||||
var IDLE_AFTER_MS = 15000; // 15 s inactivity → idle
|
||||
var CHART_TICK = 5000; // sliding-window size for rate calculation
|
||||
var BUCKETS = 72; // 72 s visible (1 s/bucket)
|
||||
|
||||
var rateLimit = __D.ratePerMinute || 1;
|
||||
var sid = __D.sandboxId;
|
||||
|
||||
var buf = new Array(BUCKETS).fill(0);
|
||||
var lastTotal = null;
|
||||
var lastActivity = Date.now();
|
||||
var currentInterval = FAST_INTERVAL;
|
||||
var recentPolls = []; // {t, delta} — sliding window entries
|
||||
|
||||
var ML = 36, MB = 18;
|
||||
var W = el.parentElement.clientWidth || 900;
|
||||
var H = 96, CH = H - MB;
|
||||
|
||||
el.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
|
||||
el.style.width = '100%';
|
||||
|
||||
var yS = d3.scaleLinear().domain([0, rateLimit * 1.1]).range([CH, 0]);
|
||||
var xS = d3.scaleLinear().domain([0, BUCKETS - 1]).range([ML, W]);
|
||||
|
||||
var areaFn = d3.area()
|
||||
.x(function (_, i) { return xS(i); })
|
||||
.y0(CH)
|
||||
.y1(function (d) { return yS(d); })
|
||||
.curve(d3.curveCatmullRom.alpha(0.5));
|
||||
|
||||
var svg = d3.select(el);
|
||||
|
||||
[0, 50, 100].forEach(function (pct) {
|
||||
var y = yS(rateLimit * pct / 100);
|
||||
svg.append('line').attr('x1', ML).attr('x2', W).attr('y1', y).attr('y2', y).attr('class', 'axis-line');
|
||||
svg.append('text').attr('x', ML - 4).attr('y', y + 3).attr('text-anchor', 'end').attr('class', 'axis-label').text(pct + '%');
|
||||
});
|
||||
|
||||
svg.append('line').attr('x1', ML).attr('x2', W).attr('y1', CH).attr('y2', CH).attr('class', 'axis-line');
|
||||
|
||||
[{bucket: 0, label: '-72s', anchor: 'start'}, {bucket: BUCKETS / 2, label: '-36s', anchor: 'middle'}, {bucket: BUCKETS - 1, label: 'now', anchor: 'end'}]
|
||||
.forEach(function (t) {
|
||||
svg.append('text').attr('x', xS(t.bucket)).attr('y', H - 4).attr('text-anchor', t.anchor).attr('class', 'axis-label').text(t.label);
|
||||
});
|
||||
|
||||
var ly = yS(rateLimit);
|
||||
svg.append('line').attr('x1', ML).attr('x2', W).attr('y1', ly).attr('y2', ly).attr('class', 'rate-limit-line');
|
||||
|
||||
// Clip path — area can never escape the plot region regardless of data spikes
|
||||
var clipId = 'rc-' + sid.slice(0, 8);
|
||||
svg.append('defs').append('clipPath').attr('id', clipId)
|
||||
.append('rect').attr('x', ML).attr('y', 0).attr('width', W - ML).attr('height', CH);
|
||||
|
||||
var path = svg.append('path').attr('class', 'rate-area').attr('clip-path', 'url(#' + clipId + ')');
|
||||
|
||||
function fmt(n) {
|
||||
return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? (n / 1e3).toFixed(1) + 'k' : String(n);
|
||||
}
|
||||
|
||||
function updateLabel(rpm) {
|
||||
var pct = (rpm / rateLimit * 100).toFixed(1);
|
||||
var elNow = document.getElementById('rate-now');
|
||||
var elLive = document.getElementById('rate-live-val');
|
||||
if (elNow) elNow.textContent = fmt(rpm);
|
||||
if (elLive) elLive.textContent = fmt(rpm) + ' req/min (' + pct + '%)';
|
||||
}
|
||||
|
||||
// Sliding-window rate: sum of deltas in the last CHART_TICK ms, extrapolated to rpm.
|
||||
// No hard reset at bucket boundaries — rate transitions are smooth.
|
||||
function liveRpm() {
|
||||
var now = Date.now();
|
||||
var cutoff = now - CHART_TICK;
|
||||
while (recentPolls.length && recentPolls[0].t < cutoff) recentPolls.shift();
|
||||
var sum = recentPolls.reduce(function (s, e) { return s + e.delta; }, 0);
|
||||
return Math.min(Math.round(sum * (60000 / CHART_TICK)), rateLimit);
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
var r = await fetch('/sandbox/' + sid + '/poll');
|
||||
if (!r.ok) return;
|
||||
var d = await r.json();
|
||||
var total = Number(d.totalRequests) || 0;
|
||||
if (lastTotal !== null) {
|
||||
var delta = Math.max(0, total - lastTotal);
|
||||
if (delta > 0) {
|
||||
recentPolls.push({t: Date.now(), delta: delta});
|
||||
lastActivity = Date.now();
|
||||
}
|
||||
var rpm = liveRpm();
|
||||
updateLabel(rpm);
|
||||
// Flush + redraw in one step — no separate timer, no competing transitions
|
||||
buf.push(rpm); buf.shift();
|
||||
path.transition().duration(950).ease(d3.easeLinear).attr('d', areaFn(buf));
|
||||
}
|
||||
lastTotal = total;
|
||||
currentInterval = (Date.now() - lastActivity > IDLE_AFTER_MS) ? IDLE_INTERVAL : FAST_INTERVAL;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function scheduleNext() {
|
||||
var interval = currentInterval;
|
||||
setTimeout(function () { poll().then(scheduleNext); }, interval);
|
||||
}
|
||||
|
||||
poll(); // set lastTotal immediately
|
||||
scheduleNext();
|
||||
})();
|
||||
@@ -0,0 +1,18 @@
|
||||
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
|
||||
|
||||
# Static assets — short cache so deploys take effect without hard-refresh
|
||||
quarkus.http.static-resources.max-age=300
|
||||
quarkus.http.static-resources.immutable-paths=
|
||||
@@ -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?v=12"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,156 @@
|
||||
<!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; }
|
||||
|
||||
#rate-wrap { background: #060d18; padding: 0 1.5rem 0.5rem; border-bottom: 1px solid #21262d; }
|
||||
.rate-header { display: flex; align-items: baseline; gap: 0.75rem; padding: 0.6rem 0 0.4rem; }
|
||||
.rate-title { font-size: 0.65rem; color: #484f58; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
.rate-live { font-size: 0.75rem; color: #388bfd; }
|
||||
.rate-limit-label { font-size: 0.65rem; color: #f8514966; margin-left: auto; }
|
||||
#rate-chart { display: block; width: 100%; overflow: visible; }
|
||||
.rate-area { fill: #1f6feb33; stroke: #388bfd; stroke-width: 1.5; }
|
||||
.rate-limit-line { stroke: #f85149; stroke-width: 0.8; stroke-dasharray: 4 3; opacity: 0.5; }
|
||||
.axis-label { font-size: 9px; fill: #484f58; font-family: 'SF Mono','Consolas','Fira Code',monospace; }
|
||||
.axis-line { stroke: #21262d; stroke-width: 0.5; }
|
||||
</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">now: <span id="rate-now">—</span> 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="rate-wrap">
|
||||
<div class="rate-header">
|
||||
<span class="rate-title">Live rate</span>
|
||||
<span class="rate-live" id="rate-live-val">— req/min</span>
|
||||
<span class="rate-limit-label">limit: {dashboard.ratePerMinute}/min ──</span>
|
||||
</div>
|
||||
<svg id="rate-chart" height="96"></svg>
|
||||
</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?v=12"></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,130 @@
|
||||
<!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>Created</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 data-created="{sb.createdAt}" style="color:#8b949e">{sb.createdAt}</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 hasPrev}
|
||||
<a class="page-link" href="/sandboxes?page={prevPage}&size={size}&tier={tier}">← Previous</a>
|
||||
{/if}
|
||||
<span class="page-info">Page {displayPage} · {result.sandboxes.size} of {result.total}</span>
|
||||
{#if hasNext}
|
||||
<a class="page-link" href="/sandboxes?page={nextPage}&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');
|
||||
});
|
||||
document.querySelectorAll('[data-created]').forEach(td => {
|
||||
const d = new Date(td.dataset.created);
|
||||
td.textContent = d.toISOString().slice(0, 10);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,408 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Playwright tests for the sandbox live rate chart (admin.js).
|
||||
*
|
||||
* Strategy
|
||||
* ─────────
|
||||
* Tests are fully self-contained — no live Quarkus server required.
|
||||
*
|
||||
* page.route() intercepts:
|
||||
* /sandbox/:id → minimal HTML fixture with __D baked in
|
||||
* /admin.js → real admin.js from local disk
|
||||
* /sandbox/:id/poll → controlled JSON per test
|
||||
* CDN (d3, topojson) → pass-through (real CDN, requires internet)
|
||||
* world-atlas CDN → empty TopoJSON stub (no countries, no network dep)
|
||||
*
|
||||
* Environment variables
|
||||
* ─────────────────────
|
||||
* APIX_ADMIN_URL Base URL used as origin for route interception
|
||||
* (default: http://localhost:8084). Does not need to be live.
|
||||
*/
|
||||
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const SANDBOX_ID = '29a22236-d2fa-471a-902d-caec592f19b0';
|
||||
const RATE_LIMIT = 60;
|
||||
const POLL_PATH = `/sandbox/${SANDBOX_ID}/poll`;
|
||||
const PAGE_PATH = `/sandbox/${SANDBOX_ID}`;
|
||||
|
||||
const ADMIN_JS_PATH = path.resolve(
|
||||
__dirname,
|
||||
'../src/main/resources/META-INF/resources/admin.js'
|
||||
);
|
||||
|
||||
// Minimal world-atlas stub — valid TopoJSON, no countries, no CDN hit.
|
||||
const TOPO_STUB = JSON.stringify({
|
||||
type: 'Topology',
|
||||
objects: { countries: { type: 'GeometryCollection', geometries: [] } },
|
||||
arcs: [],
|
||||
transform: { scale: [1, 1], translate: [0, 0] },
|
||||
});
|
||||
|
||||
// Default __D payload (mirrors SandboxDashboardResponse).
|
||||
const BASE_D = {
|
||||
sandboxId: SANDBOX_ID,
|
||||
name: 'chart-demo',
|
||||
tier: 'FREE',
|
||||
ratePerMinute: RATE_LIMIT,
|
||||
expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(),
|
||||
registrarLat: 52.52,
|
||||
registrarLon: 13.405,
|
||||
recentVisits: [],
|
||||
usage: {},
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the minimal sandbox HTML fixture with __D embedded.
|
||||
* Mirrors the structure of sandbox.html just enough for admin.js to work.
|
||||
*/
|
||||
function buildFixture(D) {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>${D.name} · APIX Admin (test)</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; }
|
||||
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-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; }
|
||||
.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%; }
|
||||
#rate-wrap { background: #060d18; padding: 0 1.5rem 0.5rem; border-bottom: 1px solid #21262d; }
|
||||
.rate-header { display: flex; align-items: baseline; gap: 0.75rem; padding: 0.6rem 0 0.4rem; }
|
||||
.rate-title { font-size: 0.65rem; color: #484f58; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
.rate-live { font-size: 0.75rem; color: #388bfd; }
|
||||
.rate-limit-label { font-size: 0.65rem; color: #f8514966; margin-left: auto; }
|
||||
#rate-chart { display: block; width: 100%; overflow: visible; }
|
||||
.rate-area { fill: #1f6feb33; stroke: #388bfd; stroke-width: 1.5; }
|
||||
.rate-limit-line { stroke: #f85149; stroke-width: 0.8; stroke-dasharray: 4 3; opacity: 0.5; }
|
||||
.axis-label { font-size: 9px; fill: #484f58;
|
||||
font-family: 'SF Mono','Consolas','Fira Code',monospace; }
|
||||
.axis-line { stroke: #21262d; stroke-width: 0.5; }
|
||||
@keyframes star-glow {
|
||||
0%,100% { filter: drop-shadow(0 0 3px #ffd700); }
|
||||
50% { filter: drop-shadow(0 0 8px #ffd700); }
|
||||
}
|
||||
.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">${D.name}</span>
|
||||
<span class="tier-badge">${D.tier}</span>
|
||||
<span class="sb-id">${D.sandboxId}</span>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-label">Rate limit</div>
|
||||
<div class="stat-value">${D.ratePerMinute}</div>
|
||||
<div class="stat-sub">now: <span id="rate-now">—</span> 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">${D.expiresAt}</div>
|
||||
</div>
|
||||
|
||||
<div id="rate-wrap">
|
||||
<div class="rate-header">
|
||||
<span class="rate-title">Live rate</span>
|
||||
<span class="rate-live" id="rate-live-val">— req/min</span>
|
||||
<span class="rate-limit-label">limit: ${D.ratePerMinute}/min ──</span>
|
||||
</div>
|
||||
<svg id="rate-chart" height="96"></svg>
|
||||
</div>
|
||||
|
||||
<div id="map-wrap"><svg id="world-map"></svg></div>
|
||||
|
||||
<script>var __D = ${JSON.stringify(D)};</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>`;
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Wire common routes (page HTML, admin.js, CDN stubs) and navigate.
|
||||
*
|
||||
* @param {import('@playwright/test').Page} page
|
||||
* @param {object} [dOverride] Fields merged into BASE_D for this test
|
||||
*/
|
||||
async function loadPage(page, dOverride = {}) {
|
||||
const D = { ...BASE_D, ...dOverride };
|
||||
const html = buildFixture(D);
|
||||
const js = fs.readFileSync(ADMIN_JS_PATH, 'utf8');
|
||||
|
||||
await page.route(PAGE_PATH, route =>
|
||||
route.fulfill({ contentType: 'text/html; charset=utf-8', body: html })
|
||||
);
|
||||
await page.route('**/admin.js**', route =>
|
||||
route.fulfill({ contentType: 'application/javascript', body: js })
|
||||
);
|
||||
// Stub world-atlas so map renders without internet
|
||||
await page.route('**/world-atlas*/**', route =>
|
||||
route.fulfill({ contentType: 'application/json', body: TOPO_STUB })
|
||||
);
|
||||
|
||||
await page.goto(PAGE_PATH);
|
||||
// Wait for D3 and admin.js to initialise
|
||||
await page.waitForFunction(() => typeof d3 !== 'undefined', { timeout: 15000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire a controllable poll mock and return control handles.
|
||||
*
|
||||
* @param {import('@playwright/test').Page} page
|
||||
* @returns {{ setTotal: (n: number) => void, getCallCount: () => number }}
|
||||
*/
|
||||
function mockPoll(page) {
|
||||
let total = 0;
|
||||
let callCount = 0;
|
||||
|
||||
page.route(`**${POLL_PATH}`, route => {
|
||||
callCount++;
|
||||
route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ totalRequests: total, ratePerMinute: RATE_LIMIT }),
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
setTotal: (n) => { total = n; },
|
||||
getCallCount: () => callCount,
|
||||
};
|
||||
}
|
||||
|
||||
// ── tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Sandbox live rate chart', () => {
|
||||
|
||||
// ── 1. Static structure ───────────────────────────────────────────────────
|
||||
|
||||
test('axes render — Y labels 0/50/100% and X labels -72s/-36s/now', async ({ page }) => {
|
||||
mockPoll(page);
|
||||
await loadPage(page);
|
||||
|
||||
const chart = page.locator('#rate-chart');
|
||||
await expect(chart).toBeVisible();
|
||||
|
||||
for (const label of ['0%', '50%', '100%']) {
|
||||
await expect(
|
||||
chart.locator(`text.axis-label:text-is("${label}")`)
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
for (const label of ['-72s', '-36s', 'now']) {
|
||||
await expect(
|
||||
chart.locator(`text.axis-label:text-is("${label}")`)
|
||||
).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('rate-limit line is rendered', async ({ page }) => {
|
||||
mockPoll(page);
|
||||
await loadPage(page);
|
||||
|
||||
const limitLine = page.locator('#rate-chart line.rate-limit-line');
|
||||
await expect(limitLine).toHaveCount(1);
|
||||
|
||||
const y = await limitLine.getAttribute('y1');
|
||||
expect(Number(y)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('area path exists with clip-path attribute', async ({ page }) => {
|
||||
mockPoll(page);
|
||||
await loadPage(page);
|
||||
|
||||
const areaPath = page.locator('#rate-chart path.rate-area');
|
||||
await expect(areaPath).toHaveCount(1);
|
||||
|
||||
const clip = await areaPath.getAttribute('clip-path');
|
||||
expect(clip).toMatch(/url\(#rc-/);
|
||||
});
|
||||
|
||||
// ── 2. Live label ─────────────────────────────────────────────────────────
|
||||
|
||||
test('live label shows "req/min (N%)" format after traffic', async ({ page }) => {
|
||||
const poll = mockPoll(page);
|
||||
await loadPage(page);
|
||||
|
||||
// First poll sets lastTotal = 0; second poll with total=30 → delta=30 → rpm > 0
|
||||
poll.setTotal(0);
|
||||
await page.waitForTimeout(1300); // first poll fires
|
||||
poll.setTotal(30);
|
||||
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const el = document.getElementById('rate-live-val');
|
||||
return el && el.textContent.includes('req/min') && el.textContent.includes('%');
|
||||
},
|
||||
{ timeout: 6000 }
|
||||
);
|
||||
|
||||
const label = await page.locator('#rate-live-val').textContent();
|
||||
expect(label).toMatch(/\d+\s*req\/min\s*\(\d+(\.\d+)?%\)/);
|
||||
});
|
||||
|
||||
test('live label starts as placeholder before any traffic', async ({ page }) => {
|
||||
mockPoll(page);
|
||||
await loadPage(page);
|
||||
|
||||
const label = await page.locator('#rate-live-val').textContent();
|
||||
expect(label).toMatch(/—|0\s*req\/min/);
|
||||
});
|
||||
|
||||
// ── 3. Chart path updates ─────────────────────────────────────────────────
|
||||
|
||||
test('area path d attribute fills in after positive delta', async ({ page }) => {
|
||||
const poll = mockPoll(page);
|
||||
await loadPage(page);
|
||||
|
||||
poll.setTotal(0);
|
||||
await page.waitForTimeout(1300); // first poll → lastTotal = 0
|
||||
|
||||
poll.setTotal(10);
|
||||
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const p = document.querySelector('#rate-chart path.rate-area');
|
||||
const d = p && p.getAttribute('d');
|
||||
return d && d.length > 10;
|
||||
},
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
const d = await page.locator('#rate-chart path.rate-area').getAttribute('d');
|
||||
expect(d).toBeTruthy();
|
||||
expect(d.length).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
test('poll fires at ~1s intervals when traffic is active', async ({ page }) => {
|
||||
const poll = mockPoll(page);
|
||||
await loadPage(page);
|
||||
|
||||
poll.setTotal(5);
|
||||
await page.waitForTimeout(1300);
|
||||
poll.setTotal(10);
|
||||
await page.waitForTimeout(3500); // ~3 more 1s intervals
|
||||
|
||||
// At 1s cadence, 4.8s total → ≥ 4 calls
|
||||
expect(poll.getCallCount()).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
// ── 4. Rate decay ─────────────────────────────────────────────────────────
|
||||
|
||||
test('live rpm decays to 0 after CHART_TICK (5s) with no new requests', async ({ page }) => {
|
||||
const poll = mockPoll(page);
|
||||
await loadPage(page);
|
||||
|
||||
// Prime: two polls with delta=10 each
|
||||
poll.setTotal(10);
|
||||
await page.waitForTimeout(1300);
|
||||
poll.setTotal(20);
|
||||
await page.waitForTimeout(1300);
|
||||
|
||||
// Confirm non-zero before cutting traffic
|
||||
const before = await page.locator('#rate-live-val').textContent();
|
||||
expect(before).not.toMatch(/^0\s*req/);
|
||||
|
||||
// Total stays at 20 → all subsequent deltas = 0
|
||||
// After CHART_TICK (5000ms) the sliding window expires
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const el = document.getElementById('rate-live-val');
|
||||
return el && el.textContent.startsWith('0');
|
||||
},
|
||||
{ timeout: 9000 }
|
||||
);
|
||||
|
||||
const after = await page.locator('#rate-live-val').textContent();
|
||||
expect(after).toMatch(/^0\s*req\/min/);
|
||||
});
|
||||
|
||||
// ── 5. Overflow clamping ──────────────────────────────────────────────────
|
||||
|
||||
test('area path stays within chart bounds under burst traffic', async ({ page }) => {
|
||||
const poll = mockPoll(page);
|
||||
await loadPage(page);
|
||||
|
||||
poll.setTotal(0);
|
||||
await page.waitForTimeout(1300);
|
||||
poll.setTotal(RATE_LIMIT * 10); // 600 req — 10× rate limit burst
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const inBounds = await page.evaluate(() => {
|
||||
const p = document.querySelector('#rate-chart path.rate-area');
|
||||
if (!p) return true; // path not drawn yet — not a clipping failure
|
||||
const svgEl = document.getElementById('rate-chart');
|
||||
const chartH = svgEl ? Number(svgEl.getAttribute('height')) : 96;
|
||||
const bbox = p.getBBox();
|
||||
return bbox.y >= 0 && bbox.y + bbox.height <= chartH;
|
||||
});
|
||||
|
||||
expect(inBounds).toBe(true);
|
||||
});
|
||||
|
||||
// ── 6. Header elements ────────────────────────────────────────────────────
|
||||
|
||||
test('sandbox name and tier badge visible in header', async ({ page }) => {
|
||||
mockPoll(page);
|
||||
await loadPage(page);
|
||||
|
||||
await expect(page.locator('.sb-name')).toContainText('chart-demo');
|
||||
await expect(page.locator('.tier-badge')).toContainText('FREE');
|
||||
});
|
||||
|
||||
test('expires stat shows days-left with green color when > 30d out', async ({ page }) => {
|
||||
mockPoll(page);
|
||||
await loadPage(page);
|
||||
|
||||
const expiresVal = page.locator('#expires-val');
|
||||
await expect(expiresVal).toBeVisible();
|
||||
|
||||
const text = await expiresVal.textContent();
|
||||
expect(text).toMatch(/\d+d left/);
|
||||
|
||||
const color = await expiresVal.evaluate(el =>
|
||||
window.getComputedStyle(el).color
|
||||
);
|
||||
expect(color).toBe('rgb(63, 185, 80)'); // #3fb950
|
||||
});
|
||||
});
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package org.botstandards.apix.common;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record ServiceBrowsePage(long total, int page, int size, List<ServiceSummaryDto> items) {}
|
||||
@@ -1,17 +1,18 @@
|
||||
package org.botstandards.apix.common;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
public record ServiceSummaryDto(
|
||||
UUID id,
|
||||
String name,
|
||||
String description,
|
||||
List<String> capabilities,
|
||||
OLevel oLevel,
|
||||
LivenessStatus livenessStatus,
|
||||
ServiceStage serviceStage,
|
||||
RegistryStatus registryStatus,
|
||||
String endpoint,
|
||||
Instant lastCheckedAt
|
||||
String registrantName,
|
||||
String registrantJurisdiction
|
||||
) {}
|
||||
|
||||
@@ -14,6 +14,6 @@ import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
|
||||
public interface RegistryClient {
|
||||
|
||||
@GET
|
||||
@Path("/{uuid}")
|
||||
@Path("/{uuid}/dashboard")
|
||||
SandboxDashboardResponse getDashboard(@PathParam("uuid") String uuid);
|
||||
}
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package org.botstandards.apix.portal.client;
|
||||
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import org.botstandards.apix.common.ServiceBrowsePage;
|
||||
import org.botstandards.apix.portal.dto.PortalServiceView;
|
||||
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
|
||||
|
||||
@RegisterRestClient(configKey = "registry")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public interface ServiceRegistryClient {
|
||||
|
||||
@GET
|
||||
@Path("/services/browse")
|
||||
ServiceBrowsePage browseServices(
|
||||
@QueryParam("capability") String capability,
|
||||
@QueryParam("page") int page,
|
||||
@QueryParam("size") int size);
|
||||
|
||||
@GET
|
||||
@Path("/services/{id}")
|
||||
PortalServiceView getService(@PathParam("id") String id);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package org.botstandards.apix.portal.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import org.botstandards.apix.common.LivenessStatus;
|
||||
import org.botstandards.apix.common.OLevel;
|
||||
import org.botstandards.apix.common.OrgType;
|
||||
import org.botstandards.apix.common.RegistryStatus;
|
||||
import org.botstandards.apix.common.ServiceStage;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record PortalServiceView(
|
||||
UUID id,
|
||||
String name,
|
||||
String description,
|
||||
String endpoint,
|
||||
List<String> capabilities,
|
||||
String registrantName,
|
||||
String registrantJurisdiction,
|
||||
OrgType registrantOrgType,
|
||||
String openApiSpecUrl,
|
||||
String mcpSpecUrl,
|
||||
String policyUrl,
|
||||
String securityContactUrl,
|
||||
OLevel oLevel,
|
||||
LivenessStatus livenessStatus,
|
||||
ServiceStage serviceStage,
|
||||
RegistryStatus registryStatus,
|
||||
Instant registeredAt,
|
||||
Instant lastUpdatedAt,
|
||||
Instant sunsetAt,
|
||||
String migrationGuideUrl
|
||||
) {}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package org.botstandards.apix.portal.resource;
|
||||
|
||||
import io.quarkus.qute.CheckedTemplate;
|
||||
import io.quarkus.qute.TemplateInstance;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.DefaultValue;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import org.botstandards.apix.common.ServiceBrowsePage;
|
||||
import org.botstandards.apix.portal.client.ServiceRegistryClient;
|
||||
import org.botstandards.apix.portal.dto.PortalServiceView;
|
||||
import org.eclipse.microprofile.rest.client.inject.RestClient;
|
||||
import org.jboss.logging.Logger;
|
||||
|
||||
@Path("/browse")
|
||||
public class ServiceBrowserResource {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(ServiceBrowserResource.class);
|
||||
private static final int PAGE_SIZE = 20;
|
||||
|
||||
@Inject
|
||||
@RestClient
|
||||
ServiceRegistryClient registryClient;
|
||||
|
||||
@CheckedTemplate
|
||||
static class Templates {
|
||||
static native TemplateInstance browse(ServiceBrowsePage page, String capability, int currentPage, int prevPage, int nextPage, long totalPages, boolean hasPrev, boolean hasNext);
|
||||
static native TemplateInstance detail(PortalServiceView service);
|
||||
static native TemplateInstance error(String message);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.TEXT_HTML)
|
||||
public TemplateInstance browse(
|
||||
@QueryParam("capability") @DefaultValue("") String capability,
|
||||
@QueryParam("page") @DefaultValue("0") int page) {
|
||||
String cap = capability.isBlank() ? null : capability.strip();
|
||||
ServiceBrowsePage result;
|
||||
try {
|
||||
result = registryClient.browseServices(cap, page, PAGE_SIZE);
|
||||
} catch (Exception e) {
|
||||
LOG.errorf(e, "Failed to fetch service list");
|
||||
return Templates.error("Registry unavailable — please try again shortly.");
|
||||
}
|
||||
boolean hasNext = (long) (page + 1) * PAGE_SIZE < result.total();
|
||||
long totalPages = (result.total() + PAGE_SIZE - 1) / PAGE_SIZE;
|
||||
return Templates.browse(result, capability.strip(), page, page - 1, page + 1, totalPages, page > 0, hasNext);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{id}")
|
||||
@Produces(MediaType.TEXT_HTML)
|
||||
public TemplateInstance detail(@PathParam("id") String id) {
|
||||
PortalServiceView service;
|
||||
try {
|
||||
service = registryClient.getService(id);
|
||||
} catch (WebApplicationException e) {
|
||||
if (e.getResponse().getStatus() == 404) {
|
||||
return Templates.error("Service not found.");
|
||||
}
|
||||
LOG.errorf(e, "Failed to fetch service %s", id);
|
||||
return Templates.error("Registry unavailable — please try again shortly.");
|
||||
} catch (Exception e) {
|
||||
LOG.errorf(e, "Failed to fetch service %s", id);
|
||||
return Templates.error("Registry unavailable — please try again shortly.");
|
||||
}
|
||||
return Templates.detail(service);
|
||||
}
|
||||
}
|
||||
@@ -390,6 +390,13 @@
|
||||
<h2>Try it</h2>
|
||||
|
||||
<div class="draft-suite" style="margin-bottom:2.5rem">
|
||||
<div class="draft-item" style="background:#f9f9f9">
|
||||
<span class="label">Browse</span>
|
||||
<div class="content">
|
||||
<a href="/browse">Service Registry — browse & search all registered services →</a>
|
||||
<div class="sub">Human-navigable view of the same index agents query. Filter by capability, read BSM records, check trust levels.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="draft-item">
|
||||
<span class="label">API root</span>
|
||||
<div class="content">
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>APIX Registry · Browse Services</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;
|
||||
line-height: 1.6;
|
||||
}
|
||||
a { color: #58a6ff; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
/* ── Header ── */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #21262d;
|
||||
}
|
||||
.header-logo { color: #8b949e; font-size: 0.8rem; }
|
||||
.header-title { font-size: 1rem; color: #e6edf3; font-weight: 600; }
|
||||
|
||||
/* ── Search ── */
|
||||
.search-bar {
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid #21262d;
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
}
|
||||
.search-input {
|
||||
flex: 1;
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
color: #c9d1d9;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
}
|
||||
.search-input:focus { border-color: #58a6ff; }
|
||||
.search-input::placeholder { color: #484f58; }
|
||||
.btn {
|
||||
background: #21262d;
|
||||
border: 1px solid #30363d;
|
||||
color: #c9d1d9;
|
||||
padding: 0.45rem 0.9rem;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn:hover { border-color: #58a6ff; color: #58a6ff; }
|
||||
.btn-link { background: transparent; border: none; color: #484f58; cursor: pointer; font-family: inherit; font-size: 13px; }
|
||||
.btn-link:hover { color: #58a6ff; }
|
||||
|
||||
/* ── Stats bar ── */
|
||||
.stats-bar {
|
||||
padding: 0.45rem 1.5rem;
|
||||
font-size: 0.7rem;
|
||||
color: #484f58;
|
||||
border-bottom: 1px solid #21262d;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* ── Service list ── */
|
||||
.service-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
background: #21262d;
|
||||
}
|
||||
|
||||
.service-card {
|
||||
background: #0d1117;
|
||||
padding: 1rem 1.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.75rem;
|
||||
align-items: start;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.service-card:hover { background: #161b22; }
|
||||
|
||||
.card-name {
|
||||
color: #e6edf3;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.card-name a { color: #e6edf3; }
|
||||
.card-name a:hover { color: #58a6ff; text-decoration: none; }
|
||||
|
||||
.card-desc {
|
||||
color: #8b949e;
|
||||
font-size: 0.78rem;
|
||||
margin-top: 0.2rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 72ch;
|
||||
}
|
||||
|
||||
.caps {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.cap-tag {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
color: #8b949e;
|
||||
padding: 0.05rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
font-size: 0.68rem;
|
||||
color: #484f58;
|
||||
margin-top: 0.45rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* ── Liveness dot ── */
|
||||
.dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dot-LIVE { background: #3fb950; box-shadow: 0 0 5px #3fb95088; }
|
||||
.dot-DEGRADED { background: #d29922; }
|
||||
.dot-UNREACHABLE { background: #f85149; }
|
||||
.dot-PENDING { background: #484f58; }
|
||||
|
||||
/* ── O-level badge ── */
|
||||
.o-badge {
|
||||
font-size: 0.62rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
align-self: flex-start;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
.o-UNVERIFIED { color: #484f58; border-color: #30363d; background: transparent; }
|
||||
.o-IDENTITY_VERIFIED { color: #d29922; border-color: #d2992255; background: #d2992211; }
|
||||
.o-LEGAL_ENTITY_VERIFIED { color: #e3b341; border-color: #e3b34155; background: #e3b34111; }
|
||||
.o-HYGIENE_VERIFIED { color: #58a6ff; border-color: #58a6ff55; background: #58a6ff11; }
|
||||
.o-OPERATIONALLY_VERIFIED { color: #3fb950; border-color: #3fb95055; background: #3fb95011; }
|
||||
.o-AUDITED { color: #bc8cff; border-color: #bc8cff55; background: #bc8cff11; }
|
||||
|
||||
/* ── Pagination ── */
|
||||
.pagination {
|
||||
padding: 1rem 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-top: 1px solid #21262d;
|
||||
font-size: 0.78rem;
|
||||
color: #484f58;
|
||||
}
|
||||
.page-btn { display: inline-block; color: #8b949e; border: 1px solid #30363d; padding: 0.3rem 0.65rem; border-radius: 4px; }
|
||||
.page-btn:hover { border-color: #58a6ff; color: #58a6ff; text-decoration: none; }
|
||||
.page-btn-off { color: #30363d; border: 1px solid #21262d; padding: 0.3rem 0.65rem; border-radius: 4px; }
|
||||
|
||||
/* ── Empty state ── */
|
||||
.empty {
|
||||
padding: 4rem 1.5rem;
|
||||
text-align: center;
|
||||
color: #484f58;
|
||||
}
|
||||
.empty h2 { font-size: 1rem; margin-bottom: 0.5rem; color: #30363d; }
|
||||
|
||||
/* ── Footer ── */
|
||||
.footer {
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid #21262d;
|
||||
font-size: 0.7rem;
|
||||
color: #484f58;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<span class="header-logo"><a href="/browse">APIX</a></span>
|
||||
<span class="header-title">Service Registry</span>
|
||||
</div>
|
||||
|
||||
<form class="search-bar" method="get" action="/browse">
|
||||
<input class="search-input" type="text" name="capability" value="{capability}"
|
||||
placeholder="search by capability (e.g. nlp, translation, speech-to-text)"
|
||||
autocomplete="off" spellcheck="false">
|
||||
<button class="btn" type="submit">Search</button>
|
||||
{#if capability.isEmpty() == false}
|
||||
<a href="/browse" class="btn-link">clear</a>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
<div class="stats-bar">
|
||||
{page.total} service{#if page.total != 1}s{/if}
|
||||
{#if capability.isEmpty()}in production · public registry{#else}matching «{capability}»{/if}
|
||||
</div>
|
||||
|
||||
{#if page.items.isEmpty()}
|
||||
<div class="empty">
|
||||
<h2>no services found</h2>
|
||||
<p>
|
||||
{#if capability.isEmpty()}
|
||||
No production services are registered yet.
|
||||
{#else}
|
||||
No production services match capability «{capability}».
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{#else}
|
||||
<div class="service-list">
|
||||
{#for s in page.items}
|
||||
<div class="service-card" onclick="location.href='/browse/{s.id}'">
|
||||
<div>
|
||||
<div class="card-name"><a href="/browse/{s.id}">{s.name}</a></div>
|
||||
{#if s.description != null}
|
||||
<div class="card-desc">{s.description}</div>
|
||||
{/if}
|
||||
{#if s.capabilities != null && s.capabilities.isEmpty() == false}
|
||||
<div class="caps">
|
||||
{#for cap in s.capabilities}
|
||||
<span class="cap-tag">{cap}</span>
|
||||
{/for}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="card-meta">
|
||||
<span class="dot dot-{s.livenessStatus}"></span>
|
||||
<span>{s.livenessStatus}</span>
|
||||
{#if s.registrantName != null}
|
||||
<span>·</span>
|
||||
<span>{s.registrantName}</span>
|
||||
{/if}
|
||||
{#if s.registrantJurisdiction != null}
|
||||
<span>·</span>
|
||||
<span>{s.registrantJurisdiction}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="o-badge o-{s.oLevel}">{s.oLevel}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/for}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="pagination">
|
||||
<span>
|
||||
{#if hasPrev}
|
||||
<a href="/browse?page={prevPage}{#if capability.isEmpty() == false}&capability={capability}{/if}" class="page-btn">← prev</a>
|
||||
{#else}
|
||||
<span class="page-btn-off">← prev</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span>page {currentPage + 1} of {totalPages}</span>
|
||||
<span>
|
||||
{#if hasNext}
|
||||
<a href="/browse?page={nextPage}{#if capability.isEmpty() == false}&capability={capability}{/if}" class="page-btn">next →</a>
|
||||
{#else}
|
||||
<span class="page-btn-off">next →</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<span>APIX Registry · open protocol</span>
|
||||
<span>
|
||||
<a href="https://api-index.org">api-index.org</a> ·
|
||||
<a href="https://api-index.org/q/openapi">OpenAPI</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,258 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{service.name} · APIX Registry</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;
|
||||
line-height: 1.6;
|
||||
}
|
||||
a { color: #58a6ff; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
/* ── Header ── */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.9rem 1.5rem;
|
||||
border-bottom: 1px solid #21262d;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.back-link { color: #8b949e; font-size: 0.78rem; }
|
||||
.back-link:hover { color: #58a6ff; }
|
||||
.header-name { font-size: 1rem; color: #e6edf3; font-weight: 600; flex: 1; }
|
||||
.header-badges { display: flex; align-items: center; gap: 0.5rem; }
|
||||
|
||||
/* ── Liveness ── */
|
||||
.dot {
|
||||
width: 8px; height: 8px; border-radius: 50%; display: inline-block;
|
||||
}
|
||||
.dot-LIVE { background: #3fb950; box-shadow: 0 0 6px #3fb95088; }
|
||||
.dot-DEGRADED { background: #d29922; }
|
||||
.dot-UNREACHABLE { background: #f85149; }
|
||||
.dot-PENDING { background: #484f58; }
|
||||
|
||||
/* ── O-level badge ── */
|
||||
.o-badge {
|
||||
font-size: 0.65rem; padding: 0.15rem 0.55rem; border-radius: 10px;
|
||||
border: 1px solid; letter-spacing: 0.04em; text-transform: uppercase;
|
||||
}
|
||||
.o-UNVERIFIED { color: #484f58; border-color: #30363d; }
|
||||
.o-IDENTITY_VERIFIED { color: #d29922; border-color: #d2992255; background: #d2992211; }
|
||||
.o-LEGAL_ENTITY_VERIFIED { color: #e3b341; border-color: #e3b34155; background: #e3b34111; }
|
||||
.o-HYGIENE_VERIFIED { color: #58a6ff; border-color: #58a6ff55; background: #58a6ff11; }
|
||||
.o-OPERATIONALLY_VERIFIED { color: #3fb950; border-color: #3fb95055; background: #3fb95011; }
|
||||
.o-AUDITED { color: #bc8cff; border-color: #bc8cff55; background: #bc8cff11; }
|
||||
|
||||
/* ── Stage badge ── */
|
||||
.stage-badge {
|
||||
font-size: 0.62rem; padding: 0.12rem 0.45rem; border-radius: 3px;
|
||||
background: #161b22; border: 1px solid #30363d; color: #8b949e;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ── Body ── */
|
||||
.detail-body {
|
||||
max-width: 820px;
|
||||
padding: 2rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* ── Section ── */
|
||||
.section {
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid #21262d;
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr;
|
||||
gap: 0.5rem 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
.section:last-child { border-bottom: none; }
|
||||
.section-label {
|
||||
font-size: 0.65rem;
|
||||
color: #484f58;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
padding-top: 0.1rem;
|
||||
}
|
||||
.section-value { color: #c9d1d9; word-break: break-all; }
|
||||
.section-value a { word-break: break-all; }
|
||||
|
||||
/* ── Caps ── */
|
||||
.caps { display: flex; flex-wrap: wrap; gap: 0.3rem; }
|
||||
.cap-tag {
|
||||
background: #161b22; border: 1px solid #30363d; color: #8b949e;
|
||||
padding: 0.1rem 0.45rem; border-radius: 3px; font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* ── O-level description ── */
|
||||
.o-desc {
|
||||
font-size: 0.75rem;
|
||||
color: #8b949e;
|
||||
margin-top: 0.4rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: #161b22;
|
||||
border: 1px solid #21262d;
|
||||
border-radius: 4px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Links group ── */
|
||||
.links-list { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.link-item { font-size: 0.8rem; }
|
||||
.link-label { color: #484f58; font-size: 0.65rem; letter-spacing: 0.05em; text-transform: uppercase; margin-right: 0.5rem; }
|
||||
|
||||
/* ── ID display ── */
|
||||
.uuid { font-size: 0.75rem; color: #484f58; font-family: inherit; }
|
||||
|
||||
/* ── Footer ── */
|
||||
.footer {
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid #21262d;
|
||||
font-size: 0.7rem;
|
||||
color: #484f58;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<a href="/browse" class="back-link">← Registry</a>
|
||||
<span class="header-name">{service.name}</span>
|
||||
<div class="header-badges">
|
||||
<span class="dot dot-{service.livenessStatus}"></span>
|
||||
<span class="o-badge o-{service.oLevel}">{service.oLevel}</span>
|
||||
<span class="stage-badge">{service.serviceStage}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-body">
|
||||
|
||||
{#if service.description != null}
|
||||
<div class="section">
|
||||
<span class="section-label">Description</span>
|
||||
<span class="section-value">{service.description}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="section">
|
||||
<span class="section-label">Endpoint</span>
|
||||
<span class="section-value"><a href="{service.endpoint}">{service.endpoint}</a></span>
|
||||
</div>
|
||||
|
||||
{#if service.capabilities != null && service.capabilities.isEmpty() == false}
|
||||
<div class="section">
|
||||
<span class="section-label">Capabilities</span>
|
||||
<div class="caps">
|
||||
{#for cap in service.capabilities}
|
||||
<span class="cap-tag">{cap}</span>
|
||||
{/for}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="section">
|
||||
<span class="section-label">Trust Level</span>
|
||||
<div>
|
||||
<span class="o-badge o-{service.oLevel}" style="font-size:0.75rem;padding:0.2rem 0.6rem;">{service.oLevel}</span>
|
||||
<div class="o-desc">
|
||||
{#if service.oLevel.name() == "UNVERIFIED"}
|
||||
O0 · No verification performed. Default for all newly registered services.
|
||||
{/if}
|
||||
{#if service.oLevel.name() == "IDENTITY_VERIFIED"}
|
||||
O1 · DNS ownership of the registrant domain verified via a DNS TXT record challenge.
|
||||
{/if}
|
||||
{#if service.oLevel.name() == "LEGAL_ENTITY_VERIFIED"}
|
||||
O2 · Legal entity confirmed via GLEIF LEI database or OpenCorporates registry.
|
||||
{/if}
|
||||
{#if service.oLevel.name() == "HYGIENE_VERIFIED"}
|
||||
O3 · Service passes technical hygiene checks — security.txt present, spec accessible, endpoint responding within SLA.
|
||||
{/if}
|
||||
{#if service.oLevel.name() == "OPERATIONALLY_VERIFIED"}
|
||||
O4 · Demonstrated operational history with continuous liveness monitoring.
|
||||
{/if}
|
||||
{#if service.oLevel.name() == "AUDITED"}
|
||||
Highest level · Full independent audit completed by an APIX-accredited auditor.
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if service.openApiSpecUrl != null || service.mcpSpecUrl != null || service.policyUrl != null || service.securityContactUrl != null}
|
||||
<div class="section">
|
||||
<span class="section-label">Links</span>
|
||||
<div class="links-list">
|
||||
{#if service.openApiSpecUrl != null}
|
||||
<div class="link-item"><span class="link-label">OpenAPI</span><a href="{service.openApiSpecUrl}">{service.openApiSpecUrl}</a></div>
|
||||
{/if}
|
||||
{#if service.mcpSpecUrl != null}
|
||||
<div class="link-item"><span class="link-label">MCP Spec</span><a href="{service.mcpSpecUrl}">{service.mcpSpecUrl}</a></div>
|
||||
{/if}
|
||||
{#if service.policyUrl != null}
|
||||
<div class="link-item"><span class="link-label">Policy</span><a href="{service.policyUrl}">{service.policyUrl}</a></div>
|
||||
{/if}
|
||||
{#if service.securityContactUrl != null}
|
||||
<div class="link-item"><span class="link-label">Security</span><a href="{service.securityContactUrl}">{service.securityContactUrl}</a></div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="section">
|
||||
<span class="section-label">Registrant</span>
|
||||
<div>
|
||||
<div>{#if service.registrantName != null}{service.registrantName}{#else}—{/if}</div>
|
||||
{#if service.registrantJurisdiction != null}
|
||||
<div style="font-size:0.75rem;color:#8b949e;margin-top:0.15rem;">{service.registrantJurisdiction}{#if service.registrantOrgType != null} · {service.registrantOrgType}{/if}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<span class="section-label">Lifecycle</span>
|
||||
<div style="display:flex;flex-direction:column;gap:0.25rem;font-size:0.8rem;">
|
||||
<div><span style="color:#484f58;">stage</span> {service.serviceStage}</div>
|
||||
<div><span style="color:#484f58;">liveness</span> {service.livenessStatus}</div>
|
||||
{#if service.registeredAt != null}
|
||||
<div><span style="color:#484f58;">registered</span> {service.registeredAt}</div>
|
||||
{/if}
|
||||
{#if service.lastUpdatedAt != null}
|
||||
<div><span style="color:#484f58;">updated</span> {service.lastUpdatedAt}</div>
|
||||
{/if}
|
||||
{#if service.sunsetAt != null}
|
||||
<div style="color:#f85149;"><span style="color:#f8514988;">sunset</span> {service.sunsetAt}</div>
|
||||
{/if}
|
||||
{#if service.migrationGuideUrl != null}
|
||||
<div><span style="color:#484f58;">migration</span> <a href="{service.migrationGuideUrl}">{service.migrationGuideUrl}</a></div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<span class="section-label">Service ID</span>
|
||||
<span class="uuid">{service.id}</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<span><a href="/browse">← Back to registry</a></span>
|
||||
<span><a href="https://api-index.org/services/{service.id}">JSON record</a> · <a href="https://api-index.org">api-index.org</a></span>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>APIX Registry · Error</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;
|
||||
line-height: 1.6;
|
||||
}
|
||||
a { color: #58a6ff; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #21262d;
|
||||
}
|
||||
.header-logo { color: #8b949e; font-size: 0.8rem; }
|
||||
.error-body {
|
||||
padding: 4rem 1.5rem;
|
||||
text-align: center;
|
||||
color: #484f58;
|
||||
}
|
||||
.error-body h2 { color: #f85149; font-size: 1rem; margin-bottom: 0.75rem; }
|
||||
.error-body p { margin-bottom: 1.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<span class="header-logo"><a href="/browse">APIX</a></span>
|
||||
</div>
|
||||
|
||||
<div class="error-body">
|
||||
<h2>error</h2>
|
||||
<p>{message}</p>
|
||||
<a href="/browse">← Back to registry</a>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+10
-9
@@ -453,6 +453,13 @@ public class IotTransitionSteps {
|
||||
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("the service stage is {string}")
|
||||
@@ -511,13 +518,6 @@ public class IotTransitionSteps {
|
||||
.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}")
|
||||
public void theErrorMessageContains(String 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$")
|
||||
public void getReplacementsIotReady() {
|
||||
actionPhase = true;
|
||||
lastResponse = given().get(
|
||||
"/services/" + serviceIds.get("SmartHub Cloud") + "/replacements?iotReady=true");
|
||||
lastResponse = given()
|
||||
.queryParam("iotReady", "true")
|
||||
.get("/services/" + serviceIds.get("SmartHub Cloud") + "/replacements");
|
||||
}
|
||||
|
||||
@When("^GET /services/\\{smartHubCloudId\\}/replacements\\?deviceClass=([^ ]+) is called with no (?:authentication|Authorization) header$")
|
||||
|
||||
+152
-28
@@ -33,7 +33,9 @@ public class SandboxSteps {
|
||||
/** Name of the most recently created sandbox. */
|
||||
private String currentSandboxName;
|
||||
/** 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 ──────────────────────────────────────────────
|
||||
|
||||
@@ -45,6 +47,7 @@ public class SandboxSteps {
|
||||
@Given("a production service {string} with endpoint {string} is registered")
|
||||
public void aProductionServiceIsRegistered(String serviceName, String endpoint) {
|
||||
Map<String, Object> payload = buildServicePayload(serviceName, endpoint, "device.telemetry");
|
||||
payload.put("serviceStage", "PRODUCTION");
|
||||
given()
|
||||
.contentType(JSON)
|
||||
.header(ADMIN_API_KEY_HEADER, ADMIN_API_KEY)
|
||||
@@ -57,22 +60,25 @@ public class SandboxSteps {
|
||||
|
||||
@Given("a sandbox service with endpoint {string} and capability {string} is registered in {string}")
|
||||
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);
|
||||
given()
|
||||
.contentType(JSON)
|
||||
.header(SANDBOX_API_KEY_HEADER, key)
|
||||
.body(payload)
|
||||
.when()
|
||||
.post("/sandbox/" + sandboxName + "/services")
|
||||
.post("/sandbox/" + uuid + "/services")
|
||||
.then()
|
||||
.statusCode(201);
|
||||
}
|
||||
|
||||
|
||||
@Given("a sandbox service with endpoint {string} capability {string} and extension {string} is registered in {string}")
|
||||
public void aSandboxServiceWithExtensionIsRegistered(String endpoint, String capability,
|
||||
String extension, String sandboxName) {
|
||||
String key = resolveKey(sandboxName);
|
||||
String key = resolveKey(sandboxName);
|
||||
String uuid = resolveUuid(sandboxName);
|
||||
int colon = extension.indexOf(':');
|
||||
Map<String, Object> extensions = colon > 0
|
||||
? Map.of(extension.substring(0, colon), extension.substring(colon + 1))
|
||||
@@ -84,19 +90,19 @@ public class SandboxSteps {
|
||||
.header(SANDBOX_API_KEY_HEADER, key)
|
||||
.body(payload)
|
||||
.when()
|
||||
.post("/sandbox/" + sandboxName + "/services")
|
||||
.post("/sandbox/" + uuid + "/services")
|
||||
.then()
|
||||
.statusCode(201);
|
||||
}
|
||||
|
||||
@Given("the sandbox root for {string} has been viewed once")
|
||||
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")
|
||||
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}")
|
||||
@@ -111,7 +117,8 @@ public class SandboxSteps {
|
||||
|
||||
@Given("{int} services have been registered in sandbox {string}")
|
||||
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++) {
|
||||
Map<String, Object> payload = buildServicePayload(
|
||||
"CapService-" + i, "https://cap-" + i + ".example.com", "cap.test");
|
||||
@@ -120,7 +127,7 @@ public class SandboxSteps {
|
||||
.header(SANDBOX_API_KEY_HEADER, key)
|
||||
.body(payload)
|
||||
.when()
|
||||
.post("/sandbox/" + sandboxName + "/services")
|
||||
.post("/sandbox/" + uuid + "/services")
|
||||
.then()
|
||||
.statusCode(201);
|
||||
}
|
||||
@@ -141,6 +148,7 @@ public class SandboxSteps {
|
||||
currentSandboxKey = lastResponse.jsonPath().getString("apiKey");
|
||||
currentSandboxName = name;
|
||||
sandboxKeys.put(name, currentSandboxKey);
|
||||
sandboxUuids.put(name, lastResponse.jsonPath().getString("sandboxId"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,19 +180,29 @@ public class SandboxSteps {
|
||||
|
||||
@When("the sandbox root for {string} is requested")
|
||||
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("GET /services is called without authentication")
|
||||
@When("GET \\/services is called without authentication")
|
||||
public void getServicesWithoutAuth() {
|
||||
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")
|
||||
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")
|
||||
@@ -194,7 +212,7 @@ public class SandboxSteps {
|
||||
.contentType(JSON)
|
||||
.body(payload)
|
||||
.when()
|
||||
.post("/sandbox/" + sandboxName + "/services")
|
||||
.post("/sandbox/" + resolveUuid(sandboxName) + "/services")
|
||||
.andReturn();
|
||||
}
|
||||
|
||||
@@ -206,7 +224,7 @@ public class SandboxSteps {
|
||||
.header(SANDBOX_API_KEY_HEADER, key)
|
||||
.body(payload)
|
||||
.when()
|
||||
.post("/sandbox/" + sandboxName + "/services")
|
||||
.post("/sandbox/" + resolveUuid(sandboxName) + "/services")
|
||||
.andReturn();
|
||||
}
|
||||
|
||||
@@ -219,21 +237,60 @@ public class SandboxSteps {
|
||||
.header(SANDBOX_API_KEY_HEADER, key)
|
||||
.body(payload)
|
||||
.when()
|
||||
.post("/sandbox/" + sandboxName + "/services")
|
||||
.post("/sandbox/" + resolveUuid(sandboxName) + "/services")
|
||||
.andReturn();
|
||||
}
|
||||
|
||||
@When("sandbox {string} services are searched by capability {string}")
|
||||
public void sandboxServiceSearchCalled(String sandboxName, String capability) {
|
||||
lastResponse = given()
|
||||
.get("/sandbox/" + sandboxName + "/services?capability=" + capability)
|
||||
.get("/sandbox/" + resolveUuid(sandboxName) + "/services?capability=" + capability)
|
||||
.andReturn();
|
||||
}
|
||||
|
||||
@When("sandbox {string} services are searched by capability {string} and property {string}")
|
||||
public void sandboxServiceSearchCalledWithProperty(String sandboxName, String capability, String property) {
|
||||
lastResponse = given()
|
||||
.get("/sandbox/" + sandboxName + "/services?capability=" + capability + "&property=" + property)
|
||||
.get("/sandbox/" + resolveUuid(sandboxName) + "/services?capability=" + capability + "&property=" + property)
|
||||
.andReturn();
|
||||
}
|
||||
|
||||
@When("sandbox {string} services are searched by capability {string} and stage {string}")
|
||||
public void sandboxServiceSearchCalledWithStage(String sandboxName, String capability, String stage) {
|
||||
lastResponse = given()
|
||||
.get("/sandbox/" + resolveUuid(sandboxName) + "/services?capability=" + capability + "&stage=" + stage)
|
||||
.andReturn();
|
||||
}
|
||||
|
||||
@When("a sandbox service with stage {string} and endpoint {string} and capability {string} is registered in {string}")
|
||||
public void whenASandboxServiceWithStageIsRegistered(String stage, String endpoint, String capability, String sandboxName) {
|
||||
String key = resolveKey(sandboxName);
|
||||
String uuid = resolveUuid(sandboxName);
|
||||
Map<String, Object> payload = buildServicePayload("SandboxService-" + endpoint.hashCode(), endpoint, capability);
|
||||
payload.put("serviceStage", stage);
|
||||
lastResponse = given()
|
||||
.contentType(JSON)
|
||||
.header(SANDBOX_API_KEY_HEADER, key)
|
||||
.body(payload)
|
||||
.when()
|
||||
.post("/sandbox/" + uuid + "/services")
|
||||
.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();
|
||||
}
|
||||
|
||||
@@ -241,17 +298,18 @@ public class SandboxSteps {
|
||||
|
||||
@When("the telemetry for {string} is requested with the sandbox API key")
|
||||
public void telemetryRequestedWithKey(String sandboxName) {
|
||||
String key = resolveKey(sandboxName);
|
||||
String key = resolveKey(sandboxName);
|
||||
String uuid = resolveUuid(sandboxName);
|
||||
lastResponse = given()
|
||||
.header(SANDBOX_API_KEY_HEADER, key)
|
||||
.when()
|
||||
.get("/sandbox/" + sandboxName + "/telemetry")
|
||||
.get("/sandbox/" + uuid + "/telemetry")
|
||||
.andReturn();
|
||||
}
|
||||
|
||||
@When("the telemetry for {string} is requested without an API key")
|
||||
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}")
|
||||
@@ -259,13 +317,13 @@ public class SandboxSteps {
|
||||
lastResponse = given()
|
||||
.header(SANDBOX_API_KEY_HEADER, key)
|
||||
.when()
|
||||
.get("/sandbox/" + sandboxName + "/telemetry")
|
||||
.get("/sandbox/" + resolveUuid(sandboxName) + "/telemetry")
|
||||
.andReturn();
|
||||
}
|
||||
|
||||
// ── When — feedback ───────────────────────────────────────────────────────
|
||||
|
||||
@When("GET /sandbox/feedback-schema is called without authentication")
|
||||
@When("GET \\/sandbox\\/feedback-schema is called without authentication")
|
||||
public void getFeedbackSchema() {
|
||||
lastResponse = given().get("/sandbox/feedback-schema").andReturn();
|
||||
}
|
||||
@@ -291,23 +349,24 @@ public class SandboxSteps {
|
||||
.contentType(JSON)
|
||||
.body(Map.of("scores", Map.of()))
|
||||
.when()
|
||||
.post("/sandbox/" + sandboxName + "/feedback")
|
||||
.post("/sandbox/" + resolveUuid(sandboxName) + "/feedback")
|
||||
.andReturn();
|
||||
}
|
||||
|
||||
@When("the feedback aggregate for {string} is requested with the sandbox API key")
|
||||
public void feedbackAggregateWithKey(String sandboxName) {
|
||||
String key = resolveKey(sandboxName);
|
||||
String key = resolveKey(sandboxName);
|
||||
String uuid = resolveUuid(sandboxName);
|
||||
lastResponse = given()
|
||||
.header(SANDBOX_API_KEY_HEADER, key)
|
||||
.when()
|
||||
.get("/sandbox/" + sandboxName + "/feedback")
|
||||
.get("/sandbox/" + uuid + "/feedback")
|
||||
.andReturn();
|
||||
}
|
||||
|
||||
@When("the feedback aggregate for {string} is requested without an API key")
|
||||
public void feedbackAggregateWithoutKey(String sandboxName) {
|
||||
lastResponse = given().get("/sandbox/" + sandboxName + "/feedback").andReturn();
|
||||
lastResponse = given().get("/sandbox/" + resolveUuid(sandboxName) + "/feedback").andReturn();
|
||||
}
|
||||
|
||||
// ── Then — HTTP status ────────────────────────────────────────────────────
|
||||
@@ -339,6 +398,11 @@ public class SandboxSteps {
|
||||
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}")
|
||||
public void responseContainsLinksSelfEndingWith(String suffix) {
|
||||
String href = lastResponse.jsonPath().getString("_links.self.href");
|
||||
@@ -491,6 +555,58 @@ public class SandboxSteps {
|
||||
assertThat(actual).as("submissionsByProvider." + provider).isEqualTo(count);
|
||||
}
|
||||
|
||||
// ── When — admin operations ───────────────────────────────────────────────
|
||||
|
||||
@When("the admin requests the sandbox list")
|
||||
public void adminRequestsSandboxList() {
|
||||
lastResponse = given()
|
||||
.header("X-Admin-Key", ADMIN_API_KEY)
|
||||
.accept("application/json")
|
||||
.when()
|
||||
.get("/admin/sandboxes")
|
||||
.andReturn();
|
||||
}
|
||||
|
||||
@When("the admin requests the sandbox list without a key")
|
||||
public void adminRequestsSandboxListWithoutKey() {
|
||||
lastResponse = given()
|
||||
.accept("application/json")
|
||||
.when()
|
||||
.get("/admin/sandboxes")
|
||||
.andReturn();
|
||||
}
|
||||
|
||||
@When("the admin deletes sandbox {string}")
|
||||
public void adminDeletesSandbox(String sandboxName) {
|
||||
lastResponse = given()
|
||||
.header("X-Admin-Key", ADMIN_API_KEY)
|
||||
.when()
|
||||
.delete("/admin/sandboxes/" + resolveUuid(sandboxName))
|
||||
.andReturn();
|
||||
}
|
||||
|
||||
// ── Then — admin list assertions ──────────────────────────────────────────
|
||||
|
||||
@Then("the admin list total is at least {int}")
|
||||
public void adminListTotalAtLeast(int min) {
|
||||
int total = lastResponse.jsonPath().getInt("total");
|
||||
assertThat(total).as("admin list total").isGreaterThanOrEqualTo(min);
|
||||
}
|
||||
|
||||
@Then("every entry in the admin list has a non-null createdAt")
|
||||
public void everyEntryHasCreatedAt() {
|
||||
List<Object> values = lastResponse.jsonPath().getList("sandboxes.createdAt");
|
||||
assertThat(values).as("sandboxes.createdAt list").isNotEmpty();
|
||||
assertThat(values).as("no null createdAt entries").doesNotContainNull();
|
||||
}
|
||||
|
||||
@Then("every entry in the admin list has a non-null expiresAt")
|
||||
public void everyEntryHasExpiresAt() {
|
||||
List<String> values = lastResponse.jsonPath().getList("sandboxes.expiresAt");
|
||||
assertThat(values).as("sandboxes.expiresAt list").isNotEmpty();
|
||||
assertThat(values).as("no null expiresAt entries").doesNotContainNull();
|
||||
}
|
||||
|
||||
// ── Then — error assertions ───────────────────────────────────────────────
|
||||
|
||||
@Then("the sandbox error contains {string}")
|
||||
@@ -510,8 +626,10 @@ public class SandboxSteps {
|
||||
.andReturn();
|
||||
|
||||
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);
|
||||
sandboxUuids.put(name, uuid);
|
||||
currentSandboxKey = key;
|
||||
currentSandboxName = name;
|
||||
}
|
||||
@@ -522,6 +640,12 @@ public class SandboxSteps {
|
||||
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) {
|
||||
submitFeedbackResponse(sandboxName, scores, model, provider);
|
||||
}
|
||||
@@ -536,7 +660,7 @@ public class SandboxSteps {
|
||||
.contentType(JSON)
|
||||
.body(body)
|
||||
.when()
|
||||
.post("/sandbox/" + sandboxName + "/feedback")
|
||||
.post("/sandbox/" + resolveUuid(sandboxName) + "/feedback")
|
||||
.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.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.
|
||||
*
|
||||
* Uses its own glue package so step definitions do not conflict with
|
||||
* IotTransitionSteps or OrgOnboardingSteps.
|
||||
* Uses two glue packages: bdd (for TestSetup/truncateTables) and devicebdd
|
||||
* (for DeviceNavigationSteps). The separation prevents DeviceNavigationSteps
|
||||
* from being picked up by non-device CucumberITs that only scan --glue bdd.
|
||||
*/
|
||||
@QuarkusTest
|
||||
public class DeviceCucumberIT {
|
||||
@@ -18,7 +19,7 @@ public class DeviceCucumberIT {
|
||||
@Test
|
||||
public void run() {
|
||||
byte exitCode = Main.run(
|
||||
"--glue", "org.botstandards.apix.registry.bdd.device",
|
||||
"--glue", "org.botstandards.apix.registry.devicebdd",
|
||||
"--plugin", "pretty",
|
||||
"--plugin", "json:target/cucumber-report-devices.json",
|
||||
"--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.Given;
|
||||
@@ -16,7 +16,8 @@ import static org.hamcrest.Matchers.*;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
public class DeviceNavigationSteps {
|
||||
@@ -97,27 +98,27 @@ public class DeviceNavigationSteps {
|
||||
|
||||
// ── When ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@When("GET /devices is called with no query params")
|
||||
@When("GET \\/devices is called with no query params")
|
||||
public void getDevicesRoot() {
|
||||
lastResponse = given().get("/devices");
|
||||
}
|
||||
|
||||
@When("GET / is called")
|
||||
@When("GET \\/ is called")
|
||||
public void getRoot() {
|
||||
lastResponse = given().get("/");
|
||||
}
|
||||
|
||||
@When("GET /devices?capability={string} is called")
|
||||
@When("^GET /devices\\?capability=(.+) is called$")
|
||||
public void getDevicesByCapability(String 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) {
|
||||
lastResponse = given().get("/devices?deviceClass=" + deviceClass);
|
||||
}
|
||||
|
||||
@When("GET /devices?protocol={string} is called")
|
||||
@When("^GET /devices\\?protocol=(.+) is called$")
|
||||
public void getDevicesByProtocol(String 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();
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
Feature: Admin sandbox list endpoint
|
||||
|
||||
Scenario: Admin retrieves the sandbox list and each entry includes a createdAt timestamp
|
||||
Given a sandbox named "admin-list-test" exists
|
||||
When the admin requests the sandbox list
|
||||
Then the response code is 200
|
||||
And the admin list total is at least 1
|
||||
And every entry in the admin list has a non-null createdAt
|
||||
And every entry in the admin list has a non-null expiresAt
|
||||
|
||||
Scenario: Admin sandbox list request without credentials is rejected
|
||||
When the admin requests the sandbox list without a key
|
||||
Then the response code is 401
|
||||
|
||||
Scenario: Admin can delete a sandbox and it no longer appears
|
||||
Given a sandbox named "admin-delete-target" exists
|
||||
When the admin deletes sandbox "admin-delete-target"
|
||||
Then the response code is 204
|
||||
+3
-3
@@ -13,7 +13,7 @@ Feature: HATEOAS navigation and root key resolution
|
||||
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"
|
||||
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
|
||||
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.feedbackSchema
|
||||
|
||||
Scenario: Sandbox root for unknown name returns 404
|
||||
When the sandbox root for "does-not-exist" is requested
|
||||
Scenario: Sandbox root for unknown UUID returns 404
|
||||
When the sandbox root for UUID "00000000-0000-0000-0000-000000000000" is requested
|
||||
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 tier "FREE"
|
||||
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
|
||||
|
||||
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"
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
And "https://ext-eu.example.com" is 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
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
Feature: Service stage semantics on creation and sandbox search
|
||||
|
||||
# Creation rules:
|
||||
# DEVELOPMENT → allowed (default when omitted)
|
||||
# PRODUCTION → allowed (established services going live immediately)
|
||||
# DEPRECATED / DECOMMISSIONED → rejected (lifecycle-only stages, not creation stages)
|
||||
#
|
||||
# Sandbox search default:
|
||||
# no ?stage= param → DEVELOPMENT (find services you just registered)
|
||||
|
||||
Background:
|
||||
Given a sandbox named "stage-test" exists
|
||||
|
||||
Scenario: Service created without stage defaults to DEVELOPMENT
|
||||
Given a sandbox service with endpoint "https://dev-default.example.com" and capability "stage.test" is registered in "stage-test"
|
||||
When sandbox "stage-test" services are searched by capability "stage.test"
|
||||
Then the response code is 200
|
||||
And "https://dev-default.example.com" is in the endpoint list
|
||||
|
||||
Scenario: Service created with explicit DEVELOPMENT stage is found by default sandbox search
|
||||
Given a sandbox service with stage "DEVELOPMENT" and endpoint "https://dev-explicit.example.com" and capability "stage.test" is registered in "stage-test"
|
||||
When sandbox "stage-test" services are searched by capability "stage.test"
|
||||
Then the response code is 200
|
||||
And "https://dev-explicit.example.com" is in the endpoint list
|
||||
|
||||
Scenario: Service created with PRODUCTION stage is not returned by default sandbox search
|
||||
Given a sandbox service with stage "PRODUCTION" and endpoint "https://prod-explicit.example.com" and capability "stage.test" is registered in "stage-test"
|
||||
When sandbox "stage-test" services are searched by capability "stage.test"
|
||||
Then the response code is 200
|
||||
And "https://prod-explicit.example.com" is not in the endpoint list
|
||||
|
||||
Scenario: Service created with PRODUCTION stage is found when searching with stage=PRODUCTION
|
||||
Given a sandbox service with stage "PRODUCTION" and endpoint "https://prod-search.example.com" and capability "stage.test" is registered in "stage-test"
|
||||
When sandbox "stage-test" services are searched by capability "stage.test" and stage "PRODUCTION"
|
||||
Then the response code is 200
|
||||
And "https://prod-search.example.com" is in the endpoint list
|
||||
|
||||
Scenario: Creating a service with DEPRECATED stage is rejected
|
||||
When a sandbox service with stage "DEPRECATED" and endpoint "https://dep.example.com" and capability "stage.test" is registered in "stage-test"
|
||||
Then the response code is 400
|
||||
|
||||
Scenario: Creating a service with DECOMMISSIONED stage is rejected
|
||||
When a sandbox service with stage "DECOMMISSIONED" and endpoint "https://dec.example.com" and capability "stage.test" is registered in "stage-test"
|
||||
Then the response code is 400
|
||||
+5
@@ -61,11 +61,16 @@ public class QueryNormalisationService {
|
||||
public String canonicalForReplacements(MultivaluedMap<String, String> raw) {
|
||||
Map<String, String> out = new TreeMap<>();
|
||||
put(out, "deviceClass", normaliseLower(raw.getFirst("deviceClass")));
|
||||
put(out, "iotReady", normaliseIotReady(raw.getFirst("iotReady")));
|
||||
put(out, "minOLevel", normaliseEnum(raw.getFirst("minOLevel")));
|
||||
put(out, "protocol", normaliseEnum(raw.getFirst("protocol")));
|
||||
return render(out);
|
||||
}
|
||||
|
||||
String normaliseIotReady(String v) {
|
||||
return "true".equalsIgnoreCase(v) ? "true" : null;
|
||||
}
|
||||
|
||||
// ── Individual value normalisers — package-private for unit tests ──────────
|
||||
|
||||
String normaliseCapability(String v) {
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
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();
|
||||
}
|
||||
|
||||
@DELETE
|
||||
@Path("/sandboxes/{uuid}")
|
||||
@Operation(summary = "Delete a sandbox and all its services (admin)", description = "Requires X-Admin-Key. DEMO-tier sandboxes are protected.")
|
||||
public Response deleteSandbox(
|
||||
@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());
|
||||
}
|
||||
sandboxService.deleteSandbox(id);
|
||||
return Response.noContent().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()) {
|
||||
SandboxEntity sandbox = sandboxService.findByKey(apiKey);
|
||||
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
|
||||
@Path("/{uuid}")
|
||||
@Operation(
|
||||
summary = "Sandbox root — HATEOAS navigation and dashboard data",
|
||||
description = "Returns sandbox metadata, navigation links, usage stats, and agent visit data. " +
|
||||
summary = "Sandbox root — HATEOAS navigation",
|
||||
description = "Returns sandbox metadata and navigation links. " +
|
||||
"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);
|
||||
SandboxDashboardResponse dashboard = sandboxService.getDashboard(id);
|
||||
var sb = sandboxService.requireById(id);
|
||||
sandboxService.recordUsage(id.toString(), SandboxService.EVENT_SANDBOX_VIEWED);
|
||||
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
|
||||
|
||||
+45
-6
@@ -7,16 +7,20 @@ import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.*;
|
||||
import org.botstandards.apix.common.BsmPayload;
|
||||
import org.botstandards.apix.common.OLevel;
|
||||
import org.botstandards.apix.common.ServiceBrowsePage;
|
||||
import org.botstandards.apix.registry.dto.ReplacementsResponse;
|
||||
import org.botstandards.apix.registry.dto.ServicePatchRequest;
|
||||
import org.botstandards.apix.registry.dto.ServiceResponse;
|
||||
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.SandboxService;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
import org.eclipse.microprofile.openapi.annotations.Operation;
|
||||
import org.eclipse.microprofile.openapi.annotations.parameters.Parameter;
|
||||
import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement;
|
||||
|
||||
import jakarta.ws.rs.core.UriInfo;
|
||||
import java.net.URI;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -30,6 +34,9 @@ public class ServiceResource {
|
||||
@Inject
|
||||
RegistryService registryService;
|
||||
|
||||
@Inject
|
||||
SandboxService sandboxService;
|
||||
|
||||
@Inject
|
||||
MeterRegistry meters;
|
||||
|
||||
@@ -52,6 +59,27 @@ public class ServiceResource {
|
||||
.build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/browse")
|
||||
@Operation(
|
||||
summary = "Browse production services",
|
||||
description = "Returns a paginated list of PRODUCTION services in the public registry. " +
|
||||
"Optionally filter by capability keyword. No authentication required. " +
|
||||
"Intended for human-facing portal UIs and developer tooling. " +
|
||||
"Maximum page size is 100."
|
||||
)
|
||||
public ServiceBrowsePage browse(
|
||||
@Parameter(description = "Optional capability filter (e.g. nlp, translation). Omit to list all production services.", example = "translation")
|
||||
@QueryParam("capability") String capability,
|
||||
@Parameter(description = "Zero-based page index.", example = "0")
|
||||
@QueryParam("page") @DefaultValue("0") int page,
|
||||
@Parameter(description = "Items per page. Capped at 100.", example = "20")
|
||||
@QueryParam("size") @DefaultValue("20") int size) {
|
||||
if (size < 1) size = 1;
|
||||
if (size > 100) size = 100;
|
||||
return registryService.browse(capability, page, size);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{id}")
|
||||
@Operation(
|
||||
@@ -83,6 +111,8 @@ public class ServiceResource {
|
||||
summary = "Search services by capability and optional extension properties",
|
||||
description = "Returns services matching the given capability keyword. " +
|
||||
"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. " +
|
||||
"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. " +
|
||||
@@ -90,17 +120,20 @@ public class ServiceResource {
|
||||
"Multiple ?property= parameters are ANDed together. " +
|
||||
"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")
|
||||
@QueryParam("capability") String capability,
|
||||
@Parameter(description = "Lifecycle stage filter. Defaults to PRODUCTION if omitted. Valid values: DEVELOPMENT, BETA, PRODUCTION, DEPRECATED.", example = "PRODUCTION")
|
||||
@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")
|
||||
@QueryParam("property") List<String> properties) {
|
||||
@QueryParam("property") List<String> properties,
|
||||
@HeaderParam("X-Api-Key") String incomingKey) {
|
||||
if (capability == null || capability.isBlank()) {
|
||||
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)
|
||||
.toList();
|
||||
|
||||
@@ -114,7 +147,12 @@ public class ServiceResource {
|
||||
"capability", tv(capability))
|
||||
.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
|
||||
@@ -143,14 +181,15 @@ public class ServiceResource {
|
||||
"Optionally filter by minimum O-level, IoT readiness, device class, or protocol."
|
||||
)
|
||||
public Response getReplacements(@PathParam("id") UUID id,
|
||||
@Context UriInfo uriInfo,
|
||||
@Parameter(description = "Minimum O-level of replacement candidates.", example = "IDENTITY_VERIFIED")
|
||||
@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).")
|
||||
@QueryParam("deviceClass") String deviceClass,
|
||||
@Parameter(description = "Filter by IoT protocol (e.g. MQTT, AMQP, HTTP).")
|
||||
@QueryParam("protocol") String protocol) {
|
||||
String iotReadyStr = uriInfo.getQueryParameters().getFirst("iotReady");
|
||||
boolean iotReady = "true".equalsIgnoreCase(iotReadyStr);
|
||||
ReplacementsResponse body = registryService.getReplacements(id, minOLevel, iotReady, deviceClass, protocol);
|
||||
return Response.ok(body)
|
||||
.header("Cache-Control", "public, max-age=60")
|
||||
|
||||
+81
-24
@@ -19,6 +19,9 @@ import org.botstandards.apix.registry.entity.ServiceEntity;
|
||||
import org.botstandards.apix.registry.entity.ServiceReplacementEntity;
|
||||
import org.botstandards.apix.registry.entity.ServiceVersionEntity;
|
||||
|
||||
import org.botstandards.apix.common.ServiceBrowsePage;
|
||||
import org.botstandards.apix.common.ServiceSummaryDto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.stream.Stream;
|
||||
@@ -127,18 +130,22 @@ public class RegistryService {
|
||||
}
|
||||
|
||||
@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.valueOf(stage.toUpperCase())
|
||||
: ServiceStage.PRODUCTION;
|
||||
: sandboxId != null ? ServiceStage.DEVELOPMENT : ServiceStage.PRODUCTION;
|
||||
|
||||
List<String[]> props = parsePropertyFilters(properties);
|
||||
String scopeClause = sandboxId != null
|
||||
? "AND s.sandbox_id = :sid"
|
||||
: "AND s.sandbox_id IS NULL";
|
||||
StringBuilder sql = new StringBuilder(
|
||||
"SELECT s.* FROM services s " +
|
||||
"WHERE s.bsm_payload @> jsonb_build_object('capabilities', jsonb_build_array(:cap)) " +
|
||||
"AND s.registry_status = 'ACTIVE' " +
|
||||
"AND s.service_stage = :stage " +
|
||||
"AND s.sandbox_id IS NULL");
|
||||
scopeClause);
|
||||
for (int i = 0; i < props.size(); i++) {
|
||||
sql.append(" AND s.bsm_payload -> 'extensions' ->> :propKey").append(i)
|
||||
.append(" = :propValue").append(i);
|
||||
@@ -147,6 +154,7 @@ public class RegistryService {
|
||||
Query q = em.createNativeQuery(sql.toString(), ServiceEntity.class)
|
||||
.setParameter("cap", capability)
|
||||
.setParameter("stage", targetStage.name());
|
||||
if (sandboxId != null) q.setParameter("sid", sandboxId);
|
||||
for (int i = 0; i < props.size(); i++) {
|
||||
q.setParameter("propKey" + i, props.get(i)[0]);
|
||||
q.setParameter("propValue" + i, props.get(i)[1]);
|
||||
@@ -167,7 +175,7 @@ public class RegistryService {
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public ReplacementsResponse getReplacements(UUID deprecatedId, String minOLevelStr,
|
||||
Boolean iotReady, String deviceClass, String protocol) {
|
||||
boolean iotReady, String deviceClass, String protocol) {
|
||||
ServiceEntity deprecated = requireById(deprecatedId);
|
||||
|
||||
// Locked services that are not yet DECOMMISSIONED block replacement discovery
|
||||
@@ -175,36 +183,39 @@ public class RegistryService {
|
||||
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 " +
|
||||
"INNER JOIN service_replacements sr ON sr.replacement_service_id = s.id " +
|
||||
"WHERE sr.deprecated_service_id = :depId AND s.registry_status = 'ACTIVE'",
|
||||
ServiceEntity.class)
|
||||
.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);
|
||||
"INNER JOIN service_replacements sr ON sr.replacement_service_id = s.id ");
|
||||
if (needsIotJoin) {
|
||||
sql.append("INNER JOIN iot_profiles ip ON ip.service_id = s.id ");
|
||||
}
|
||||
sql.append("WHERE sr.deprecated_service_id = :depId AND s.registry_status = 'ACTIVE'");
|
||||
if (deviceClass != null && !deviceClass.isBlank()) {
|
||||
stream = stream.filter(c -> c.iotProfile != null
|
||||
&& c.iotProfile.deviceClasses != null
|
||||
&& c.iotProfile.deviceClasses.contains(deviceClass));
|
||||
sql.append(" AND ip.device_classes @> jsonb_build_array(:deviceClass)");
|
||||
}
|
||||
if (protocol != null && !protocol.isBlank()) {
|
||||
stream = stream.filter(c -> c.iotProfile != null
|
||||
&& c.iotProfile.protocols != null
|
||||
&& c.iotProfile.protocols.contains(protocol));
|
||||
sql.append(" AND ip.protocols @> jsonb_build_array(: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(
|
||||
deprecated.locked,
|
||||
deprecated.sunsetAt,
|
||||
stream
|
||||
candidates.stream()
|
||||
.filter(c -> minOLevel == null || c.olevel.ordinal() >= minOLevel.ordinal())
|
||||
.sorted(Comparator.comparingInt((ServiceEntity c) -> c.olevel.ordinal()).reversed())
|
||||
.map(c -> new ReplacementsResponse.Candidate(
|
||||
c.id, c.bsmPayload.name(), c.endpointUrl, c.olevel, c.serviceStage,
|
||||
@@ -245,6 +256,52 @@ public class RegistryService {
|
||||
return stream.toList();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public ServiceBrowsePage browse(String capability, int page, int size) {
|
||||
boolean hasCapability = capability != null && !capability.isBlank();
|
||||
String capClause = hasCapability
|
||||
? " AND s.bsm_payload @> jsonb_build_object('capabilities', jsonb_build_array(:cap))"
|
||||
: "";
|
||||
String whereClause =
|
||||
"s.registry_status = 'ACTIVE' AND s.service_stage = 'PRODUCTION' AND s.sandbox_id IS NULL"
|
||||
+ capClause;
|
||||
|
||||
Query countQ = em.createNativeQuery(
|
||||
"SELECT COUNT(*) FROM services s WHERE " + whereClause);
|
||||
if (hasCapability) countQ.setParameter("cap", capability);
|
||||
long total = ((Number) countQ.getSingleResult()).longValue();
|
||||
|
||||
Query listQ = em.createNativeQuery(
|
||||
"SELECT s.* FROM services s WHERE " + whereClause
|
||||
+ " ORDER BY s.registered_at DESC LIMIT :lim OFFSET :off",
|
||||
ServiceEntity.class)
|
||||
.setParameter("lim", size)
|
||||
.setParameter("off", (long) page * size);
|
||||
if (hasCapability) listQ.setParameter("cap", capability);
|
||||
|
||||
List<ServiceSummaryDto> items = ((List<ServiceEntity>) listQ.getResultList()).stream()
|
||||
.map(this::toSummary)
|
||||
.toList();
|
||||
return new ServiceBrowsePage(total, page, size, items);
|
||||
}
|
||||
|
||||
private ServiceSummaryDto toSummary(ServiceEntity e) {
|
||||
BsmPayload b = e.bsmPayload;
|
||||
return new ServiceSummaryDto(
|
||||
e.id,
|
||||
b.name(),
|
||||
b.description(),
|
||||
b.capabilities(),
|
||||
e.olevel,
|
||||
e.livenessStatus,
|
||||
e.serviceStage,
|
||||
e.registryStatus,
|
||||
b.endpoint(),
|
||||
b.registrantName(),
|
||||
b.registrantJurisdiction()
|
||||
);
|
||||
}
|
||||
|
||||
public long countAll() {
|
||||
return ((Number) em.createNativeQuery(
|
||||
"SELECT COUNT(*) FROM services WHERE registry_status = 'ACTIVE' AND sandbox_id IS NULL")
|
||||
|
||||
+123
-2
@@ -20,6 +20,7 @@ import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
import java.util.HexFormat;
|
||||
@@ -199,7 +200,13 @@ public class SandboxService {
|
||||
service.livenessStatus = LivenessStatus.PENDING;
|
||||
service.registeredAt = now;
|
||||
service.registrantOrgType = payload.registrantOrgType() != null ? payload.registrantOrgType() : OrgType.INDIVIDUAL;
|
||||
service.serviceStage = payload.serviceStage() != null ? payload.serviceStage() : ServiceStage.DEVELOPMENT;
|
||||
ServiceStage requestedStage = payload.serviceStage() != null ? payload.serviceStage() : ServiceStage.DEVELOPMENT;
|
||||
if (requestedStage == ServiceStage.DEPRECATED || requestedStage == ServiceStage.DECOMMISSIONED) {
|
||||
throw new WebApplicationException(Response.status(400)
|
||||
.entity(Map.of("message", "Stage " + requestedStage + " is a lifecycle-only stage and cannot be set at creation"))
|
||||
.build());
|
||||
}
|
||||
service.serviceStage = requestedStage;
|
||||
service.registryStatus = RegistryStatus.ACTIVE;
|
||||
service.version = 1;
|
||||
|
||||
@@ -377,7 +384,7 @@ public class SandboxService {
|
||||
em.createNativeQuery(
|
||||
"INSERT INTO sandbox_feedback " +
|
||||
"(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("scores", scoresJson)
|
||||
.setParameter("agent", req.agentIdentifier())
|
||||
@@ -454,6 +461,91 @@ public class SandboxService {
|
||||
schemaLinks);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public AdminSandboxListResponse listAllSandboxes(int page, int size, String tierFilter) {
|
||||
String tierClause = (tierFilter != null && !tierFilter.isBlank()) ? " AND s.tier = :tier" : "";
|
||||
|
||||
String countSql = "SELECT COUNT(*) FROM sandboxes s WHERE 1=1" + tierClause;
|
||||
String listSql =
|
||||
"SELECT s.id, s.name, s.tier, s.rate_per_minute, s.max_services, " +
|
||||
"s.created_at, s.expires_at, s.registrar_location, s.registrar_lat, s.registrar_lon, " +
|
||||
"COALESCE((SELECT COUNT(*) FROM services sv WHERE sv.sandbox_id = s.id::text), 0), " +
|
||||
"COALESCE((SELECT SUM(request_count) FROM sandbox_usage_stats u WHERE u.sandbox_id = s.id::text), 0) " +
|
||||
"FROM sandboxes s WHERE 1=1" + tierClause +
|
||||
" ORDER BY s.created_at DESC LIMIT :lim OFFSET :off";
|
||||
|
||||
var countQ = em.createNativeQuery(countSql);
|
||||
var listQ = em.createNativeQuery(listSql);
|
||||
if (!tierClause.isBlank()) {
|
||||
countQ.setParameter("tier", tierFilter.toUpperCase());
|
||||
listQ.setParameter("tier", tierFilter.toUpperCase());
|
||||
}
|
||||
listQ.setParameter("lim", size).setParameter("off", page * size);
|
||||
|
||||
long total = ((Number) countQ.getSingleResult()).longValue();
|
||||
Instant now = Instant.now();
|
||||
|
||||
List<AdminSandboxSummary> rows = ((List<Object[]>) listQ.getResultList()).stream()
|
||||
.map(r -> {
|
||||
Instant createdAt = toInstant(r[5]);
|
||||
Instant expiresAt = toInstant(r[6]);
|
||||
return new AdminSandboxSummary(
|
||||
r[0].toString(),
|
||||
(String) r[1],
|
||||
(String) r[2],
|
||||
((Number) r[3]).intValue(),
|
||||
r[4] != null ? ((Number) r[4]).intValue() : null,
|
||||
createdAt,
|
||||
expiresAt,
|
||||
expiresAt != null && now.isAfter(expiresAt),
|
||||
(String) r[7],
|
||||
r[8] != null ? ((Number) r[8]).doubleValue() : null,
|
||||
r[9] != null ? ((Number) r[9]).doubleValue() : null,
|
||||
((Number) r[10]).longValue(),
|
||||
((Number) r[11]).longValue());
|
||||
})
|
||||
.toList();
|
||||
|
||||
return new AdminSandboxListResponse(total, page, size, rows);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public AdminStatsResponse getGlobalStats() {
|
||||
Instant now = Instant.now();
|
||||
|
||||
long total = ((Number) em.createNativeQuery("SELECT COUNT(*) FROM sandboxes").getSingleResult()).longValue();
|
||||
long active = ((Number) em.createNativeQuery("SELECT COUNT(*) FROM sandboxes WHERE expires_at > now()").getSingleResult()).longValue();
|
||||
long expired = total - active;
|
||||
|
||||
List<Object[]> tierRows = em.createNativeQuery(
|
||||
"SELECT tier, COUNT(*) FROM sandboxes GROUP BY tier ORDER BY tier")
|
||||
.getResultList();
|
||||
Map<String, Long> byTier = new LinkedHashMap<>();
|
||||
for (Object[] r : tierRows) byTier.put((String) r[0], ((Number) r[1]).longValue());
|
||||
|
||||
long totalServices = ((Number) em.createNativeQuery(
|
||||
"SELECT COUNT(*) FROM services").getSingleResult()).longValue();
|
||||
|
||||
long totalRequests = 0;
|
||||
Object reqResult = em.createNativeQuery(
|
||||
"SELECT COALESCE(SUM(request_count), 0) FROM sandbox_usage_stats").getSingleResult();
|
||||
if (reqResult != null) totalRequests = ((Number) reqResult).longValue();
|
||||
|
||||
List<Object[]> geoRows = em.createNativeQuery(
|
||||
"SELECT registrar_lat, registrar_lon, name, tier FROM sandboxes " +
|
||||
"WHERE registrar_lat IS NOT NULL AND registrar_lon IS NOT NULL")
|
||||
.getResultList();
|
||||
List<AdminStatsResponse.GeoPoint> points = geoRows.stream()
|
||||
.map(r -> new AdminStatsResponse.GeoPoint(
|
||||
((Number) r[0]).doubleValue(),
|
||||
((Number) r[1]).doubleValue(),
|
||||
(String) r[2],
|
||||
(String) r[3]))
|
||||
.toList();
|
||||
|
||||
return new AdminStatsResponse(total, active, expired, byTier, totalServices, totalRequests, points);
|
||||
}
|
||||
|
||||
public SandboxLinks sandboxLinks(String base) {
|
||||
return sandboxLinks(base, null);
|
||||
}
|
||||
@@ -578,5 +670,34 @@ public class SandboxService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes a sandbox and all its registered services. DEMO tier sandboxes are protected. */
|
||||
@Transactional
|
||||
public void deleteSandbox(UUID id) {
|
||||
SandboxEntity sb = requireById(id);
|
||||
if ("DEMO".equals(sb.tier)) {
|
||||
throw new WebApplicationException(
|
||||
Response.status(403).entity(Map.of("message", "DEMO-tier sandboxes are permanent and cannot be deleted")).build());
|
||||
}
|
||||
em.createQuery("DELETE FROM ServiceEntity s WHERE s.sandboxId = :sid")
|
||||
.setParameter("sid", id.toString())
|
||||
.executeUpdate();
|
||||
em.remove(em.contains(sb) ? sb : em.merge(sb));
|
||||
}
|
||||
|
||||
public record SandboxCreationResult(SandboxEntity sandbox, String plainKey, String plainMaintenanceKey) {}
|
||||
|
||||
/**
|
||||
* Converts a native-query timestamp column to Instant.
|
||||
* Hibernate 6 returns LocalDateTime for timestamptz native queries; older drivers return Timestamp.
|
||||
* LocalDateTime from a timestamptz column represents UTC.
|
||||
*/
|
||||
private static Instant toInstant(Object col) {
|
||||
if (col instanceof java.sql.Timestamp t) return t.toInstant();
|
||||
if (col instanceof OffsetDateTime odt) return odt.toInstant();
|
||||
if (col instanceof java.time.ZonedDateTime z) return z.toInstant();
|
||||
if (col instanceof java.time.LocalDateTime ldt) return ldt.toInstant(java.time.ZoneOffset.UTC);
|
||||
if (col instanceof java.util.Date d) return d.toInstant();
|
||||
if (col instanceof java.time.temporal.Temporal t) { try { return Instant.from(t); } catch (Exception ignored) {} }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* APIX Discovery Demo — for OpenClaw
|
||||
*
|
||||
* Shows how an agent finds and uses services without hardcoded URLs.
|
||||
* This is what ClawHub does manually. APIX makes it autonomous.
|
||||
*
|
||||
* Requirements: Node 18+ (native fetch, no dependencies)
|
||||
* Run: node apix-demo.mjs
|
||||
*/
|
||||
|
||||
const REGISTRY_ROOT = 'https://api-index.org';
|
||||
const SANDBOX_REGISTER = `${REGISTRY_ROOT}/sandbox/register`;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function get(url) {
|
||||
const res = await fetch(url, { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error(`GET ${url} → ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function post(url, body, headers = {}) {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...headers },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`POST ${url} → ${res.status}: ${text}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function line(char = '─', n = 60) { return char.repeat(n); }
|
||||
function hdr(label) { console.log(`\n${line()}\n ${label}\n${line()}`); }
|
||||
|
||||
// ─── Demo ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
|
||||
console.log('\n APIX Discovery Demo');
|
||||
console.log(' The open agent service registry — api-index.org\n');
|
||||
|
||||
// ── Step 1: HATEOAS root ───────────────────────────────────────────────────
|
||||
hdr('Step 1 Discover the registry root');
|
||||
const root = await get(REGISTRY_ROOT);
|
||||
console.log(` Registry: ${root.name}`);
|
||||
console.log(` Services registered: ${root.stats.registeredServices}`);
|
||||
console.log(` Navigation links:`);
|
||||
for (const [rel, link] of Object.entries(root._links)) {
|
||||
console.log(` ${rel.padEnd(20)} ${link.href}`);
|
||||
}
|
||||
console.log('\n → An agent starts here. No hardcoded service URLs anywhere.');
|
||||
|
||||
// ── Step 2: Create sandbox ─────────────────────────────────────────────────
|
||||
hdr('Step 2 Create a sandbox namespace (self-service, instant)');
|
||||
const reg = await post(SANDBOX_REGISTER, {
|
||||
name: 'openclaw-apix-demo',
|
||||
contactEmail: 'demo@openclaw.dev',
|
||||
location: 'Vienna, AT',
|
||||
});
|
||||
const { sandboxId, apiKey, tier, ratePerMinute, expiresAt, _links: sLinks } = reg;
|
||||
console.log(` Sandbox ID : ${sandboxId}`);
|
||||
console.log(` Tier : ${tier} | Rate limit: ${ratePerMinute} req/min`);
|
||||
console.log(` Expires : ${expiresAt}`);
|
||||
console.log(` Dashboard : ${reg.dashboardUrl ?? sLinks?.self?.href ?? '—'}`);
|
||||
console.log('\n → Each sandbox is isolated. Any agent or developer can create one instantly.');
|
||||
|
||||
const servicesHref = sLinks.services?.href ?? `${REGISTRY_ROOT}/sandbox/${sandboxId}/services`;
|
||||
const searchHrefTpl = sLinks.servicesSearch?.href
|
||||
?? `${REGISTRY_ROOT}/sandbox/${sandboxId}/services{?capability,stage}`;
|
||||
|
||||
// ── Step 3: Register services (BSM manifests) ──────────────────────────────
|
||||
hdr('Step 3 Register three carrier services (BSM manifests)');
|
||||
|
||||
const carriers = [
|
||||
{
|
||||
name: 'NordLogistik Economy EU',
|
||||
description: 'Economy EU road & rail freight. 5-day delivery. DE/AT/CH specialist.',
|
||||
endpoint: 'https://demo.api-index.org/carriers/nord/quote',
|
||||
capabilities: ['shipping-quote', 'logistics', 'eu-delivery'],
|
||||
registrantEmail: 'api@nordlogistik.example',
|
||||
registrantName: 'NordLogistik GmbH',
|
||||
registrantJurisdiction:'DE',
|
||||
registrantOrgType: 'COMMERCIAL',
|
||||
pricing: { billingModel: 'PER_CALL', pricePerCall: 0.012, currency: 'APX', billingUnit: 'per-call' },
|
||||
bsmVersion: '0.1',
|
||||
serviceStage: 'PRODUCTION',
|
||||
},
|
||||
{
|
||||
name: 'SwiftCargo Express',
|
||||
description: 'Global express shipping. 2-day delivery to major hubs worldwide.',
|
||||
endpoint: 'https://demo.api-index.org/carriers/swift/quote',
|
||||
capabilities: ['shipping-quote', 'express-delivery', 'global-logistics'],
|
||||
registrantEmail: 'api@swiftcargo.example',
|
||||
registrantName: 'SwiftCargo Ltd',
|
||||
registrantJurisdiction:'GB',
|
||||
registrantOrgType: 'COMMERCIAL',
|
||||
pricing: { billingModel: 'PER_CALL', pricePerCall: 0.035, currency: 'APX', billingUnit: 'per-call' },
|
||||
bsmVersion: '0.1',
|
||||
serviceStage: 'PRODUCTION',
|
||||
},
|
||||
{
|
||||
name: 'PacRim Express',
|
||||
description: 'Cost-optimised Asia-Pacific shipping. SEA, JP, KR, AU routes.',
|
||||
endpoint: 'https://demo.api-index.org/carriers/pacrim/quote',
|
||||
capabilities: ['shipping-quote', 'logistics', 'asia-pacific-delivery'],
|
||||
registrantEmail: 'api@pacrim.example',
|
||||
registrantName: 'PacRim Express Pte',
|
||||
registrantJurisdiction:'SG',
|
||||
registrantOrgType: 'COMMERCIAL',
|
||||
pricing: { billingModel: 'PER_CALL', pricePerCall: 0.018, currency: 'APX', billingUnit: 'per-call' },
|
||||
bsmVersion: '0.1',
|
||||
serviceStage: 'PRODUCTION',
|
||||
},
|
||||
];
|
||||
|
||||
for (const manifest of carriers) {
|
||||
await post(servicesHref, manifest, { 'X-Api-Key': apiKey });
|
||||
console.log(` ✓ Registered: ${manifest.name} (${manifest.registrantJurisdiction})`);
|
||||
}
|
||||
console.log('\n → Any provider self-registers. The agent discovers them automatically.');
|
||||
|
||||
// ── Step 4: Agent discovers carriers ──────────────────────────────────────
|
||||
hdr('Step 4 Agent queries: "who can provide shipping-quote?"');
|
||||
// Strip the URI template expression {?...} and build the URL properly
|
||||
const searchBase = searchHrefTpl.replace(/\{[^}]+\}/, '');
|
||||
const searchUrl = `${searchBase}?capability=shipping-quote&stage=PRODUCTION`;
|
||||
console.log(` Query: GET ${searchUrl}\n`);
|
||||
|
||||
const found = await get(searchUrl);
|
||||
console.log(` Found ${found.length} carrier service(s):\n`);
|
||||
for (const svc of found) {
|
||||
const p = svc.pricing;
|
||||
const price = p ? `${p.pricePerCall} ${p.currency}/${p.billingUnit}` : 'unknown';
|
||||
console.log(` ┌─ ${svc.name}`);
|
||||
console.log(` │ Jurisdiction : ${svc.registrantJurisdiction}`);
|
||||
console.log(` │ Capabilities : ${svc.capabilities?.join(', ')}`);
|
||||
console.log(` │ Price : ${price}`);
|
||||
console.log(` └─ Endpoint : ${svc.endpoint}`);
|
||||
console.log();
|
||||
}
|
||||
console.log(' → The agent has all three endpoints. It never knew about them before this query.');
|
||||
|
||||
// ── Step 5: Call discovered services in parallel ───────────────────────────
|
||||
hdr('Step 5 Call all carriers in parallel — choose the best quote');
|
||||
|
||||
const shipment = { from: 'Munich, DE', to: 'London, GB', weightKg: 2.5, dimensions: '30x20x15cm' };
|
||||
console.log(` Shipment: ${JSON.stringify(shipment)}\n`);
|
||||
|
||||
// The agent uses endpoints discovered in Step 4 — zero hardcoded carrier URLs
|
||||
const quoteRequests = found.map(async svc => {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const quote = await post(svc.endpoint, shipment).catch(() => mockQuote(svc.name));
|
||||
return { svcName: svc.name, quote, ms: Date.now() - start };
|
||||
} catch {
|
||||
return { svcName: svc.name, quote: mockQuote(svc.name), ms: Date.now() - start };
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(quoteRequests);
|
||||
results.sort((a, b) => a.quote.price.amount - b.quote.price.amount);
|
||||
|
||||
for (const { svcName, quote } of results) {
|
||||
const best = results[0].svcName === svcName ? ' ← best price' : '';
|
||||
console.log(` ${svcName.padEnd(28)} ${quote.price.amount.toFixed(2)} ${quote.price.currency} · ${quote.estimatedDays}d${best}`);
|
||||
}
|
||||
|
||||
const winner = results[0];
|
||||
console.log(`\n Selected: ${winner.svcName} (${winner.quote.price.amount.toFixed(2)} ${winner.quote.price.currency})`);
|
||||
|
||||
// ── Summary ────────────────────────────────────────────────────────────────
|
||||
hdr('Summary');
|
||||
console.log(' What just happened:\n');
|
||||
console.log(' 1. Started at a single URL: https://api-index.org');
|
||||
console.log(' 2. HATEOAS navigation — followed links, never guessed URLs');
|
||||
console.log(' 3. Created an isolated sandbox in one POST');
|
||||
console.log(' 4. Three carriers registered their own BSM manifests');
|
||||
console.log(' 5. Agent queried capability="shipping-quote" — found all three');
|
||||
console.log(' 6. Called them in parallel, picked the best quote');
|
||||
console.log();
|
||||
console.log(' No hardcoded carrier URLs. No ClawHub lookup. No human in the loop.');
|
||||
console.log();
|
||||
console.log(' When a new carrier registers in APIX, every OpenClaw agent that');
|
||||
console.log(' queries "shipping-quote" discovers it automatically — on the next query.');
|
||||
console.log();
|
||||
console.log(` Sandbox dashboard: ${reg.dashboardUrl ?? `https://www.api-index.org/sandbox/${sandboxId}`}`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
// Fallback mock quotes (in case demo endpoints are not reachable)
|
||||
function mockQuote(name) {
|
||||
const prices = {
|
||||
'NordLogistik Economy EU': { amount: 12.40, currency: 'APX' },
|
||||
'SwiftCargo Express': { amount: 35.00, currency: 'APX' },
|
||||
'PacRim Express': { amount: 18.20, currency: 'APX' },
|
||||
};
|
||||
const days = { 'NordLogistik Economy EU': 5, 'SwiftCargo Express': 2, 'PacRim Express': 7 };
|
||||
return {
|
||||
carrier: name,
|
||||
price: prices[name] ?? { amount: 20.00, currency: 'APX' },
|
||||
estimatedDays: days[name] ?? 5,
|
||||
trackingAvailable: true,
|
||||
};
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('\n Error:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -41,6 +41,20 @@ demo.api-index.org {
|
||||
header -Server
|
||||
}
|
||||
|
||||
admin.api-index.org {
|
||||
reverse_proxy admin-a:8084 admin-b:8084 {
|
||||
lb_policy first
|
||||
health_uri /q/health/live
|
||||
health_interval 5s
|
||||
fail_duration 30s
|
||||
}
|
||||
header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
header X-Content-Type-Options "nosniff"
|
||||
header X-Frame-Options "DENY"
|
||||
header Referrer-Policy "strict-origin"
|
||||
header -Server
|
||||
}
|
||||
|
||||
git.api-index.org {
|
||||
reverse_proxy gitea:3001
|
||||
header Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
|
||||
@@ -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
|
||||
restart: unless-stopped
|
||||
|
||||
# ── Admin UI (a/b) ────────────────────────────────────────────────────────
|
||||
|
||||
admin-a:
|
||||
image: apix-admin:latest
|
||||
environment:
|
||||
APIX_REGISTRY_URL: http://registry-a:8180
|
||||
APIX_API_KEY: ${APIX_API_KEY}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
depends_on:
|
||||
- registry-a
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8084/q/health/live || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
|
||||
admin-b:
|
||||
image: apix-admin:latest
|
||||
environment:
|
||||
APIX_REGISTRY_URL: http://registry-b:8180
|
||||
APIX_API_KEY: ${APIX_API_KEY}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
depends_on:
|
||||
- registry-b
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8084/q/health/live || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
|
||||
# ── Edge proxy ────────────────────────────────────────────────────────────
|
||||
|
||||
caddy:
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<module>apix-spider</module>
|
||||
<module>apix-portal</module>
|
||||
<module>apix-demo</module>
|
||||
<module>apix-admin</module>
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Sandbox cleanup — deletes all non-protected sandboxes from the APIX registry.
|
||||
*
|
||||
* Protected sandboxes (never deleted):
|
||||
* - tier = DEMO (server-enforced, returns 403)
|
||||
* - name matching KEEP_NAMES below
|
||||
*
|
||||
* Usage:
|
||||
* APIX_ADMIN_KEY=<key> node scripts/cleanup-sandboxes.mjs [--dry-run]
|
||||
*
|
||||
* The admin key is in /opt/apix/.env on the VPS (APIX_API_KEY=...).
|
||||
*/
|
||||
|
||||
const REGISTRY_ROOT = 'https://api-index.org';
|
||||
const KEEP_NAMES = new Set(['apix-demo-ecosystem']);
|
||||
const DRY_RUN = process.argv.includes('--dry-run');
|
||||
const ADMIN_KEY = process.env.APIX_ADMIN_KEY;
|
||||
|
||||
if (!ADMIN_KEY) {
|
||||
console.error('Error: set APIX_ADMIN_KEY environment variable');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function adminGet(path) {
|
||||
const res = await fetch(`${REGISTRY_ROOT}${path}`, {
|
||||
headers: { Accept: 'application/json', 'X-Admin-Key': ADMIN_KEY },
|
||||
});
|
||||
if (!res.ok) throw new Error(`GET ${path} → ${res.status}: ${await res.text()}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function adminDelete(path) {
|
||||
const res = await fetch(`${REGISTRY_ROOT}${path}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-Admin-Key': ADMIN_KEY },
|
||||
});
|
||||
if (res.status === 204) return;
|
||||
throw new Error(`DELETE ${path} → ${res.status}: ${await res.text()}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`\n APIX Sandbox Cleanup${DRY_RUN ? ' [DRY RUN — nothing will be deleted]' : ''}\n`);
|
||||
|
||||
let page = 0;
|
||||
const toDelete = [];
|
||||
const keep = [];
|
||||
|
||||
while (true) {
|
||||
const data = await adminGet(`/admin/sandboxes?page=${page}&size=50`);
|
||||
const sandboxes = data.sandboxes ?? data.items ?? data;
|
||||
if (!Array.isArray(sandboxes) || sandboxes.length === 0) break;
|
||||
|
||||
for (const sb of sandboxes) {
|
||||
const name = sb.name ?? sb.sandboxName ?? '—';
|
||||
const tier = sb.tier ?? '?';
|
||||
const id = sb.sandboxId ?? sb.id;
|
||||
if (KEEP_NAMES.has(name) || tier === 'DEMO') {
|
||||
keep.push({ id, name, tier });
|
||||
} else {
|
||||
toDelete.push({ id, name, tier });
|
||||
}
|
||||
}
|
||||
|
||||
if (sandboxes.length < 50) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
console.log(` Keeping (${keep.length}):`);
|
||||
for (const sb of keep) console.log(` ✓ ${sb.name.padEnd(36)} ${sb.tier} ${sb.id}`);
|
||||
|
||||
console.log(`\n Deleting (${toDelete.length}):`);
|
||||
for (const sb of toDelete) {
|
||||
const label = `${sb.name.padEnd(36)} ${sb.tier} ${sb.id}`;
|
||||
if (DRY_RUN) {
|
||||
console.log(` ○ ${label} [skipped — dry run]`);
|
||||
} else {
|
||||
try {
|
||||
await adminDelete(`/admin/sandboxes/${sb.id}`);
|
||||
console.log(` ✗ ${label} deleted`);
|
||||
} catch (err) {
|
||||
console.log(` ! ${label} ERROR: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n Done.\n');
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('\n Error:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -59,24 +59,27 @@ docker build -f infra/Dockerfile.registry -t apix-registry:latest . -q
|
||||
docker build -f infra/Dockerfile.portal -t apix-portal:latest . -q
|
||||
docker build -f infra/Dockerfile.demo -t apix-demo:latest . -q
|
||||
docker build -f infra/Dockerfile.spider -t apix-spider:latest . -q
|
||||
docker build -f infra/Dockerfile.admin -t apix-admin:latest . -q
|
||||
|
||||
# ── 4. Rolling restart: a-stack (registry-a → portal-a + demo-a) ─────────────
|
||||
log "Rolling restart: a-stack..."
|
||||
$COMPOSE up -d --no-deps --force-recreate registry-a
|
||||
wait_healthy "registry-a"
|
||||
|
||||
$COMPOSE up -d --no-deps --force-recreate portal-a demo-a
|
||||
$COMPOSE up -d --no-deps --force-recreate portal-a demo-a admin-a
|
||||
wait_healthy "portal-a"
|
||||
wait_healthy "demo-a"
|
||||
wait_healthy "admin-a"
|
||||
|
||||
# ── 5. Rolling restart: b-stack ───────────────────────────────────────────────
|
||||
log "Rolling restart: b-stack..."
|
||||
$COMPOSE up -d --no-deps --force-recreate registry-b
|
||||
wait_healthy "registry-b"
|
||||
|
||||
$COMPOSE up -d --no-deps --force-recreate portal-b demo-b
|
||||
$COMPOSE up -d --no-deps --force-recreate portal-b demo-b admin-b
|
||||
wait_healthy "portal-b"
|
||||
wait_healthy "demo-b"
|
||||
wait_healthy "admin-b"
|
||||
|
||||
# ── 6. Spider (cron job — restart acceptable) ─────────────────────────────────
|
||||
log "Restarting spider..."
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
$VPS = "deploy@204.168.156.179"
|
||||
$MVN = "C:\Users\Anwender\IdeaProjects\Claude\bot-service-index\apix-mvp"
|
||||
|
||||
Set-Location $MVN
|
||||
|
||||
Write-Host "Copying web content..."
|
||||
Copy-Item "$MVN\..\web\api-index.org\index.html" `
|
||||
"$MVN\apix-portal\src\main\resources\META-INF\resources\index.html" -Force
|
||||
|
||||
Write-Host "Building portal..."
|
||||
mvn clean package -DskipTests -pl apix-portal -am -q
|
||||
|
||||
Write-Host "Transferring to VPS..."
|
||||
scp "$MVN\apix-portal\target\quarkus-app\app\apix-portal-1.0-SNAPSHOT.jar" `
|
||||
"${VPS}:/opt/apix/apix-portal/target/quarkus-app/app/apix-portal-1.0-SNAPSHOT.jar"
|
||||
scp "$MVN\apix-portal\target\quarkus-app\quarkus\quarkus-application.dat" `
|
||||
"${VPS}:/opt/apix/apix-portal/target/quarkus-app/quarkus/quarkus-application.dat"
|
||||
|
||||
Write-Host "Rebuilding image and restarting portal..."
|
||||
ssh $VPS "cd /opt/apix && docker build -f infra/Dockerfile.portal -t apix-portal:latest . -q && docker compose -f infra/docker-compose.yml --env-file .env up -d portal"
|
||||
|
||||
Write-Host "Done. https://www.api-index.org/"
|
||||
@@ -0,0 +1,50 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
$VPS = "deploy@204.168.156.179"
|
||||
$MVN = "C:\Users\Anwender\IdeaProjects\Claude\bot-service-index\apix-mvp"
|
||||
$QUARKUS_APP = "$MVN\apix-registry\target\quarkus-app"
|
||||
$VPS_DEST = "/opt/apix/apix-registry/target/quarkus-app"
|
||||
|
||||
Set-Location $MVN
|
||||
|
||||
Write-Host "Building registry..."
|
||||
mvn clean package -DskipTests -pl apix-registry -am -q
|
||||
|
||||
Write-Host "Syncing quarkus-app to VPS (rsync, only changed files)..."
|
||||
$rsyncSrc = $QUARKUS_APP -replace '^([A-Za-z]):\\', '/cygdrive/$1/' -replace '\\', '/'
|
||||
rsync -az --delete `
|
||||
"${rsyncSrc}/" `
|
||||
"${VPS}:${VPS_DEST}/"
|
||||
|
||||
Write-Host "Rebuilding Docker image on VPS..."
|
||||
ssh $VPS 'cd /opt/apix && docker build -f infra/Dockerfile.registry -t apix-registry:latest . -q'
|
||||
|
||||
Write-Host "Rolling restart: registry-a..."
|
||||
ssh $VPS 'cd /opt/apix && docker compose -f infra/docker-compose.yml --env-file .env up -d --no-deps registry-a'
|
||||
|
||||
Write-Host "Waiting for registry-a to become healthy..."
|
||||
$elapsed = 0
|
||||
do {
|
||||
Start-Sleep -Seconds 5; $elapsed += 5
|
||||
$status = ssh $VPS 'docker inspect infra-registry-a-1 --format "{{.State.Health.Status}}" 2>/dev/null || echo missing'
|
||||
Write-Host " registry-a: $status (${elapsed}s)"
|
||||
} while ($status -ne "healthy" -and $elapsed -lt 120)
|
||||
|
||||
if ($status -ne "healthy") {
|
||||
Write-Host "ERROR: registry-a did not become healthy -- aborting, registry-b untouched"
|
||||
ssh $VPS 'docker logs infra-registry-a-1 --tail 20 2>&1'
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Restarting registry-b..."
|
||||
ssh $VPS 'cd /opt/apix && docker compose -f infra/docker-compose.yml --env-file .env up -d --no-deps registry-b'
|
||||
|
||||
Write-Host "Waiting for registry-b..."
|
||||
$elapsed = 0
|
||||
do {
|
||||
Start-Sleep -Seconds 5; $elapsed += 5
|
||||
$statusB = ssh $VPS 'docker inspect infra-registry-b-1 --format "{{.State.Health.Status}}" 2>/dev/null || echo missing'
|
||||
Write-Host " registry-b: $statusB (${elapsed}s)"
|
||||
} while ($statusB -ne "healthy" -and $elapsed -lt 120)
|
||||
|
||||
Write-Host "registry-a: $status registry-b: $statusB"
|
||||
Write-Host "Done. https://api-index.org/"
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
cat > /opt/apix/.env << EOF
|
||||
APIX_DB_USER=apix
|
||||
APIX_DB_PASSWORD=$(openssl rand -base64 32)
|
||||
APIX_DB_NAME=apix
|
||||
APIX_DB_PORT=5432
|
||||
APIX_API_KEY=$(openssl rand -hex 32)
|
||||
APIX_REGISTRY_BASE_URL=https://api-index.org
|
||||
APIX_REGISTRY_NAME=APIX Registry
|
||||
GLEIF_API_URL=https://api.gleif.org/api/v1
|
||||
OPENCORPORATES_API_KEY=
|
||||
APIX_MAIL_SIGNING_PRIVATE_KEY=
|
||||
APIX_MAIL_SIGNING_PUBLIC_KEY=
|
||||
APIX_MAIL_SIGNING_KID=2026-05
|
||||
SPIDER_INTERVAL_MINUTES=15
|
||||
GRAFANA_ADMIN_PASSWORD=$(openssl rand -base64 16)
|
||||
GRAFANA_ROOT_URL=http://localhost:3000
|
||||
LOG_LEVEL=INFO
|
||||
EOF
|
||||
|
||||
chmod 600 /opt/apix/.env
|
||||
echo "Generated /opt/apix/.env:"
|
||||
cat /opt/apix/.env
|
||||
@@ -0,0 +1,603 @@
|
||||
{
|
||||
"__inputs": [
|
||||
{
|
||||
"name": "DS_LOKI",
|
||||
"label": "Loki",
|
||||
"description": "Loki receiving Bunny.net CDN access logs via Promtail (promtail-cdn-logs.yaml). Shows ALL requests including CDN cache hits.",
|
||||
"type": "datasource",
|
||||
"pluginId": "loki",
|
||||
"pluginName": "Loki"
|
||||
},
|
||||
{
|
||||
"name": "DS_PROMETHEUS",
|
||||
"label": "Prometheus",
|
||||
"description": "Prometheus scraping the APIX registry /q/metrics. Shows only cache misses that reached the origin.",
|
||||
"type": "datasource",
|
||||
"pluginId": "prometheus",
|
||||
"pluginName": "Prometheus"
|
||||
}
|
||||
],
|
||||
"__requires": [
|
||||
{ "type": "grafana", "id": "grafana", "name": "Grafana", "version": "10.0.0" },
|
||||
{ "type": "panel", "id": "timeseries", "name": "Time series", "version": "" },
|
||||
{ "type": "panel", "id": "stat", "name": "Stat", "version": "" },
|
||||
{ "type": "panel", "id": "gauge", "name": "Gauge", "version": "" },
|
||||
{ "type": "panel", "id": "bargauge", "name": "Bar gauge", "version": "" },
|
||||
{ "type": "panel", "id": "piechart", "name": "Pie chart", "version": "" },
|
||||
{ "type": "panel", "id": "barchart", "name": "Bar chart", "version": "" },
|
||||
{ "type": "datasource", "id": "loki", "name": "Loki", "version": "" },
|
||||
{ "type": "datasource", "id": "prometheus", "name": "Prometheus", "version": "" }
|
||||
],
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": { "type": "grafana", "uid": "-- Grafana --" },
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "Live APIX Registry traffic — OpenClaw community demo. Loki panels cover ALL requests (CDN cache hits + misses) via Bunny.net syslog forwarding. Prometheus panels cover origin cache misses only. Refresh every 5s.",
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 1,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"refresh": "5s",
|
||||
"schemaVersion": 38,
|
||||
"tags": ["apix", "demo", "openclaw"],
|
||||
"time": { "from": "now-15m", "to": "now" },
|
||||
"timepicker": {},
|
||||
"timezone": "browser",
|
||||
"title": "APIX Registry — OpenClaw Demo",
|
||||
"uid": "apix-demo-v1",
|
||||
"version": 1,
|
||||
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"current": {},
|
||||
"hide": 0,
|
||||
"includeAll": false,
|
||||
"label": "Loki datasource",
|
||||
"multi": false,
|
||||
"name": "DS_LOKI",
|
||||
"options": [],
|
||||
"query": "loki",
|
||||
"refresh": 1,
|
||||
"type": "datasource"
|
||||
},
|
||||
{
|
||||
"current": {},
|
||||
"hide": 0,
|
||||
"includeAll": false,
|
||||
"label": "Prometheus datasource",
|
||||
"multi": false,
|
||||
"name": "DS_PROMETHEUS",
|
||||
"options": [],
|
||||
"query": "prometheus",
|
||||
"refresh": 1,
|
||||
"type": "datasource"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"panels": [
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"type": "stat",
|
||||
"title": "CDN Searches",
|
||||
"description": "Total /services + /devices requests in the selected time range, including CDN cache hits.",
|
||||
"gridPos": { "x": 0, "y": 0, "w": 6, "h": 3 },
|
||||
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
|
||||
"options": {
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"orientation": "auto",
|
||||
"textMode": "auto",
|
||||
"colorMode": "background",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "#1f60c4", "value": null },
|
||||
{ "color": "#37872d", "value": 500 },
|
||||
{ "color": "#8f3bb8", "value": 10000 }
|
||||
]
|
||||
},
|
||||
"unit": "short",
|
||||
"noValue": "0"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
|
||||
"editorMode": "code",
|
||||
"expr": "sum(count_over_time({job=\"apix-cdn\", req_path=~\"/services.*|/devices.*\"}[$__range]))",
|
||||
"instant": true,
|
||||
"queryType": "instant",
|
||||
"legendFormat": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"id": 2,
|
||||
"type": "gauge",
|
||||
"title": "Cache Hit Ratio",
|
||||
"description": "Percentage of CDN requests served from edge cache without hitting the origin VPS.",
|
||||
"gridPos": { "x": 6, "y": 0, "w": 6, "h": 3 },
|
||||
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
|
||||
"options": {
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"orientation": "auto",
|
||||
"showThresholdLabels": false,
|
||||
"showThresholdMarkers": true,
|
||||
"minVizWidth": 75,
|
||||
"minVizHeight": 75
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "#c4162a", "value": null },
|
||||
{ "color": "#ff9830", "value": 50 },
|
||||
{ "color": "#37872d", "value": 80 }
|
||||
]
|
||||
},
|
||||
"unit": "percent",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"noValue": "—"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
|
||||
"editorMode": "code",
|
||||
"expr": "sum(count_over_time({job=\"apix-cdn\", cache_status=\"HIT\"}[$__range])) / sum(count_over_time({job=\"apix-cdn\"}[$__range])) * 100",
|
||||
"instant": true,
|
||||
"queryType": "instant",
|
||||
"legendFormat": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"id": 3,
|
||||
"type": "stat",
|
||||
"title": "Active PoPs",
|
||||
"description": "Number of distinct Bunny.net edge PoPs that served traffic in the selected time range.",
|
||||
"gridPos": { "x": 12, "y": 0, "w": 6, "h": 3 },
|
||||
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
|
||||
"options": {
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"orientation": "auto",
|
||||
"textMode": "auto",
|
||||
"colorMode": "background",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "#1f60c4", "value": null },
|
||||
{ "color": "#37872d", "value": 3 },
|
||||
{ "color": "#8f3bb8", "value": 8 }
|
||||
]
|
||||
},
|
||||
"unit": "short",
|
||||
"noValue": "0"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
|
||||
"editorMode": "code",
|
||||
"expr": "count(sum by (cdn_pop) (count_over_time({job=\"apix-cdn\"}[$__range])))",
|
||||
"instant": true,
|
||||
"queryType": "instant",
|
||||
"legendFormat": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"id": 4,
|
||||
"type": "stat",
|
||||
"title": "Searches / sec",
|
||||
"description": "Current CDN search request rate across all edge PoPs.",
|
||||
"gridPos": { "x": 18, "y": 0, "w": 6, "h": 3 },
|
||||
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
|
||||
"options": {
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"orientation": "auto",
|
||||
"textMode": "auto",
|
||||
"colorMode": "background",
|
||||
"graphMode": "area"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "thresholds" },
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "#1f60c4", "value": null },
|
||||
{ "color": "#37872d", "value": 1 },
|
||||
{ "color": "#f2cc0c", "value": 10 },
|
||||
{ "color": "#8f3bb8", "value": 100 }
|
||||
]
|
||||
},
|
||||
"unit": "reqps",
|
||||
"decimals": 2,
|
||||
"noValue": "0"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
|
||||
"editorMode": "code",
|
||||
"expr": "sum(rate({job=\"apix-cdn\", req_path=~\"/services.*|/devices.*\"}[$__rate_interval]))",
|
||||
"instant": false,
|
||||
"queryType": "range",
|
||||
"legendFormat": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"id": 10,
|
||||
"type": "row",
|
||||
"title": "Capability Traffic — Real-Time",
|
||||
"collapsed": false,
|
||||
"gridPos": { "x": 0, "y": 3, "w": 24, "h": 1 },
|
||||
"panels": []
|
||||
},
|
||||
|
||||
{
|
||||
"id": 5,
|
||||
"type": "timeseries",
|
||||
"title": "Requests per Second by Capability",
|
||||
"description": "Live stream of capability search rates across all CDN PoPs. Each line = one capability being queried. This includes CDN cache hits — the spike you see is the real community traffic.",
|
||||
"gridPos": { "x": 0, "y": 4, "w": 24, "h": 8 },
|
||||
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
|
||||
"options": {
|
||||
"tooltip": { "mode": "multi", "sort": "desc" },
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "right",
|
||||
"calcs": ["lastNotNull", "max", "sum"]
|
||||
}
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"lineWidth": 2,
|
||||
"fillOpacity": 8,
|
||||
"gradientMode": "opacity",
|
||||
"showPoints": "never",
|
||||
"spanNulls": false
|
||||
},
|
||||
"unit": "reqps"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
|
||||
"editorMode": "code",
|
||||
"expr": "sum by (capability) (rate({job=\"apix-cdn\", capability!=\"\"}[$__rate_interval]))",
|
||||
"instant": false,
|
||||
"queryType": "range",
|
||||
"legendFormat": "{{capability}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"id": 20,
|
||||
"type": "row",
|
||||
"title": "Distribution",
|
||||
"collapsed": false,
|
||||
"gridPos": { "x": 0, "y": 12, "w": 24, "h": 1 },
|
||||
"panels": []
|
||||
},
|
||||
|
||||
{
|
||||
"id": 6,
|
||||
"type": "bargauge",
|
||||
"title": "Top 10 Capabilities",
|
||||
"description": "Most queried capabilities in the time range (all CDN traffic). These are the API categories the OpenClaw community is actively using.",
|
||||
"gridPos": { "x": 0, "y": 13, "w": 15, "h": 8 },
|
||||
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
|
||||
"options": {
|
||||
"reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false },
|
||||
"orientation": "horizontal",
|
||||
"displayMode": "gradient",
|
||||
"valueMode": "color",
|
||||
"showUnfilled": true,
|
||||
"minVizWidth": 8,
|
||||
"minVizHeight": 16,
|
||||
"namePlacement": "left",
|
||||
"legendDisplayMode": "hidden"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "continuous-BlPu" },
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [{ "color": "blue", "value": null }]
|
||||
},
|
||||
"unit": "short"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
|
||||
"editorMode": "code",
|
||||
"expr": "topk(10, sum by (capability) (count_over_time({job=\"apix-cdn\", capability!=\"\"}[$__range])))",
|
||||
"instant": true,
|
||||
"queryType": "instant",
|
||||
"legendFormat": "{{capability}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"id": 7,
|
||||
"type": "piechart",
|
||||
"title": "Cache Status",
|
||||
"description": "HIT = served from CDN edge, no origin load. MISS = edge cache cold, forwarded to origin. BYPASS = edge rule prevented caching (/q/* health paths).",
|
||||
"gridPos": { "x": 15, "y": 13, "w": 9, "h": 8 },
|
||||
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
|
||||
"options": {
|
||||
"pieType": "donut",
|
||||
"displayLabels": ["name", "percent"],
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "right",
|
||||
"calcs": ["sum"]
|
||||
}
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "fixed" },
|
||||
"unit": "short"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "HIT" },
|
||||
"properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#37872d" } }]
|
||||
},
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "MISS" },
|
||||
"properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#ff9830" } }]
|
||||
},
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "BYPASS" },
|
||||
"properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#6e7279" } }]
|
||||
}
|
||||
]
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
|
||||
"editorMode": "code",
|
||||
"expr": "sum by (cache_status) (count_over_time({job=\"apix-cdn\"}[$__range]))",
|
||||
"instant": true,
|
||||
"queryType": "instant",
|
||||
"legendFormat": "{{cache_status}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"id": 30,
|
||||
"type": "row",
|
||||
"title": "Geographic Distribution",
|
||||
"collapsed": false,
|
||||
"gridPos": { "x": 0, "y": 21, "w": 24, "h": 1 },
|
||||
"panels": []
|
||||
},
|
||||
|
||||
{
|
||||
"id": 8,
|
||||
"type": "barchart",
|
||||
"title": "Requests by CDN PoP",
|
||||
"description": "Which Bunny.net edge PoPs served traffic. PoP names come from the syslog HOSTNAME field (e.g. sg.b-cdn.net = Singapore, hk.b-cdn.net = Hong Kong). Asia traffic is visible here even though the origin VPS is in Europe.",
|
||||
"gridPos": { "x": 0, "y": 22, "w": 16, "h": 7 },
|
||||
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
|
||||
"options": {
|
||||
"orientation": "horizontal",
|
||||
"xTickLabelRotation": 0,
|
||||
"xTickLabelMaxLength": 30,
|
||||
"groupWidth": 0.7,
|
||||
"barWidth": 0.97,
|
||||
"fillOpacity": 80,
|
||||
"gradientMode": "none",
|
||||
"tooltip": { "mode": "single", "sort": "none" },
|
||||
"legend": { "displayMode": "hidden", "placement": "bottom" }
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "continuous-BlYlRd" },
|
||||
"custom": { "fillOpacity": 80 },
|
||||
"unit": "short"
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
|
||||
"editorMode": "code",
|
||||
"expr": "topk(15, sum by (cdn_pop) (count_over_time({job=\"apix-cdn\"}[$__range])))",
|
||||
"instant": true,
|
||||
"queryType": "instant",
|
||||
"legendFormat": "{{cdn_pop}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"id": 9,
|
||||
"type": "piechart",
|
||||
"title": "/services vs /devices",
|
||||
"description": "Split of AI agent API searches (/services) vs IoT device discovery (/devices). As the OpenClaw community grows, /services should dominate initially, with /devices rising as embedded systems adopt APIX.",
|
||||
"gridPos": { "x": 16, "y": 22, "w": 8, "h": 7 },
|
||||
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
|
||||
"options": {
|
||||
"pieType": "pie",
|
||||
"displayLabels": ["name", "percent"],
|
||||
"legend": {
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"calcs": []
|
||||
}
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": { "unit": "short" },
|
||||
"overrides": [
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "/services" },
|
||||
"properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#1f60c4" } }]
|
||||
},
|
||||
{
|
||||
"matcher": { "id": "byName", "options": "/devices" },
|
||||
"properties": [{ "id": "color", "value": { "mode": "fixed", "fixedColor": "#37872d" } }]
|
||||
}
|
||||
]
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "loki", "uid": "${DS_LOKI}" },
|
||||
"editorMode": "code",
|
||||
"expr": "sum by (req_path) (count_over_time({job=\"apix-cdn\", req_path=~\"/services|/devices\"}[$__range]))",
|
||||
"instant": true,
|
||||
"queryType": "instant",
|
||||
"legendFormat": "{{req_path}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"id": 40,
|
||||
"type": "row",
|
||||
"title": "Origin Metrics (cache misses only — Prometheus)",
|
||||
"collapsed": false,
|
||||
"gridPos": { "x": 0, "y": 29, "w": 24, "h": 1 },
|
||||
"panels": []
|
||||
},
|
||||
|
||||
{
|
||||
"id": 11,
|
||||
"type": "timeseries",
|
||||
"title": "Avg Results per Search (origin)",
|
||||
"description": "Average result set size per capability search, measured at origin. Low values mean a capability is registered but has few services. Zero means no services are registered for that capability yet — demand without supply.",
|
||||
"gridPos": { "x": 0, "y": 30, "w": 12, "h": 7 },
|
||||
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||
"options": {
|
||||
"tooltip": { "mode": "multi", "sort": "desc" },
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "right",
|
||||
"calcs": ["lastNotNull", "mean"]
|
||||
}
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"lineWidth": 2,
|
||||
"fillOpacity": 5,
|
||||
"showPoints": "never",
|
||||
"spanNulls": true
|
||||
},
|
||||
"unit": "short",
|
||||
"decimals": 1
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||
"expr": "rate(apix_search_result_count_sum{resource=\"services\"}[$__rate_interval]) / rate(apix_search_result_count_count{resource=\"services\"}[$__rate_interval])",
|
||||
"legendFormat": "services",
|
||||
"instant": false,
|
||||
"range": true
|
||||
},
|
||||
{
|
||||
"refId": "B",
|
||||
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||
"expr": "rate(apix_search_result_count_sum{resource=\"devices\"}[$__rate_interval]) / rate(apix_search_result_count_count{resource=\"devices\"}[$__rate_interval])",
|
||||
"legendFormat": "devices",
|
||||
"instant": false,
|
||||
"range": true
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"id": 12,
|
||||
"type": "timeseries",
|
||||
"title": "Origin Search Rate by Capability (cache misses)",
|
||||
"description": "Prometheus counters at origin — only incremented on CDN cache misses. The ratio between this panel and the Loki 'Requests/sec' panel above shows the CDN offload factor: if origin sees 1 req/s and Loki shows 50 req/s, the CDN is absorbing 98% of traffic.",
|
||||
"gridPos": { "x": 12, "y": 30, "w": 12, "h": 7 },
|
||||
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||
"options": {
|
||||
"tooltip": { "mode": "multi", "sort": "desc" },
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "right",
|
||||
"calcs": ["lastNotNull", "max"]
|
||||
}
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": { "mode": "palette-classic" },
|
||||
"custom": {
|
||||
"lineWidth": 1,
|
||||
"fillOpacity": 4,
|
||||
"showPoints": "never",
|
||||
"spanNulls": true,
|
||||
"lineStyle": { "fill": "dash", "dash": [4, 4] }
|
||||
},
|
||||
"unit": "reqps",
|
||||
"decimals": 3
|
||||
}
|
||||
},
|
||||
"targets": [
|
||||
{
|
||||
"refId": "A",
|
||||
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
|
||||
"expr": "sum by (capability) (rate(apix_search_services_total[$__rate_interval]))",
|
||||
"legendFormat": "{{capability}}",
|
||||
"instant": false,
|
||||
"range": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
]
|
||||
}
|
||||
@@ -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';"
|
||||
@@ -0,0 +1,116 @@
|
||||
# Promtail config: Bunny.net CDN access logs → Loki
|
||||
#
|
||||
# Deploy on the same VPS as the APIX registry (or any host reachable from
|
||||
# the internet on port 5514). Bunny.net streams one TCP Syslog line per
|
||||
# request — including CDN cache hits — giving live telemetry that the
|
||||
# origin Prometheus metrics alone cannot provide.
|
||||
#
|
||||
# Setup:
|
||||
# 1. Install Promtail: https://grafana.com/docs/loki/latest/clients/promtail/installation/
|
||||
# 2. Copy this file to /etc/promtail/cdn-logs.yaml
|
||||
# 3. Fill in LOKI_PUSH_URL, LOKI_USERNAME, LOKI_PASSWORD below
|
||||
# 4. Open TCP 5514 in the VPS firewall
|
||||
# 5. systemctl enable --now promtail
|
||||
# 6. Run setup-bunnynet.sh with SYSLOG_HOST=<this-vps-ip>
|
||||
#
|
||||
# Verify stream is working:
|
||||
# curl -s https://api-index.org/services?capability=nlp
|
||||
# journalctl -u promtail -f # should show the log line within 1-2 seconds
|
||||
#
|
||||
# Grafana dashboard queries (LogQL):
|
||||
# Capability hit rate (live):
|
||||
# count_over_time({job="apix-cdn"} | label_format capability="capability" [1m])
|
||||
# Cache ratio:
|
||||
# sum by (cache_status) (count_over_time({job="apix-cdn"}[5m]))
|
||||
# Zero-result detection (requires apix.search.result_count from Prometheus):
|
||||
# use a mixed datasource panel combining Loki counts with Prometheus summaries
|
||||
|
||||
server:
|
||||
http_listen_port: 9080
|
||||
grpc_listen_port: 0
|
||||
log_level: info
|
||||
|
||||
clients:
|
||||
- url: https://LOKI_PUSH_URL/loki/api/v1/push # e.g. logs-prod-xxx.grafana.net/loki/api/v1/push
|
||||
basic_auth:
|
||||
username: "LOKI_USERNAME" # Grafana Cloud: stack user ID (numeric)
|
||||
password: "LOKI_PASSWORD" # Grafana Cloud: service account token (logs:write scope)
|
||||
|
||||
positions:
|
||||
filename: /tmp/promtail-cdn-positions.yaml
|
||||
|
||||
scrape_configs:
|
||||
- job_name: apix-cdn-logs
|
||||
syslog:
|
||||
listen_address: 0.0.0.0:5514
|
||||
label_structured_data: false
|
||||
labels:
|
||||
job: apix-cdn
|
||||
component: bunnynet
|
||||
|
||||
relabel_configs:
|
||||
# Bunny.net includes the PoP hostname in the syslog HOSTNAME field
|
||||
# e.g. "pa.b-cdn.net" (Paris), "sg.b-cdn.net" (Singapore)
|
||||
# This is how PoPs are visible even on cache hits that never reach origin.
|
||||
- source_labels: [__syslog_message_hostname]
|
||||
target_label: cdn_pop
|
||||
|
||||
pipeline_stages:
|
||||
# ── Parse Apache Combined Log Format + Bunny.net cache status suffix ──
|
||||
# Line: 1.2.3.x - - [12/May/2026:10:00:00 +0000] "GET /services?cap=nlp HTTP/1.1" 200 1234 "-" "Agent/1.0" HIT
|
||||
- regex:
|
||||
expression: '"(?P<http_method>[A-Z]+) (?P<full_path>[^" ]+) HTTP/[^"]*" (?P<http_status>\d+) \d+ "[^"]*" "[^"]*" (?P<cache_status>\S+)$'
|
||||
|
||||
- labels:
|
||||
http_method:
|
||||
http_status:
|
||||
cache_status: # HIT | MISS | BYPASS — primary cache ratio signal
|
||||
|
||||
# ── Split path from query string ──────────────────────────────────────
|
||||
- regex:
|
||||
source: full_path
|
||||
expression: '^(?P<req_path>[^?]+)(\?(?P<query_string>.+))?$'
|
||||
|
||||
- labels:
|
||||
req_path: # /services /devices /devices/{id}/replacements
|
||||
|
||||
# ── Extract search parameters ─────────────────────────────────────────
|
||||
# capability is the primary analytics dimension
|
||||
- regex:
|
||||
source: query_string
|
||||
expression: '(?:^|&)capability=(?P<capability>[^&]*)'
|
||||
|
||||
- labels:
|
||||
capability: # nlp | translation | speech-to-text | … | (empty = nav doc)
|
||||
|
||||
- regex:
|
||||
source: query_string
|
||||
expression: '(?:^|&)deviceClass=(?P<device_class>[^&]*)'
|
||||
|
||||
- labels:
|
||||
device_class: # sensor | actuator | gateway | …
|
||||
|
||||
- regex:
|
||||
source: query_string
|
||||
expression: '(?:^|&)protocol=(?P<protocol>[^&]*)'
|
||||
|
||||
- labels:
|
||||
protocol: # MQTT | AMQP | HTTP | …
|
||||
|
||||
# stage only appears explicitly when non-PRODUCTION (canonical form omits it)
|
||||
- regex:
|
||||
source: query_string
|
||||
expression: '(?:^|&)stage=(?P<stage>[^&]*)'
|
||||
|
||||
- labels:
|
||||
stage: # STAGING | DEVELOPMENT — absent means PRODUCTION
|
||||
|
||||
# ── Drop Quarkus internal paths — never cache, no analytics value ─────
|
||||
- drop:
|
||||
source: req_path
|
||||
expression: '^/q/'
|
||||
|
||||
# ── Timestamp from log line (keeps Loki time accurate) ────────────────
|
||||
- timestamp:
|
||||
source: __syslog_message_timestamp
|
||||
format: RFC3339
|
||||
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env bash
|
||||
# Download Bunny.net access logs and produce a capability frequency report.
|
||||
#
|
||||
# This is the only way to see CDN-cached request frequency — the origin
|
||||
# Prometheus metrics only see cache misses. A weekly cron run pushing the
|
||||
# output to Grafana covers >99% of analysis needs without real-time overhead.
|
||||
#
|
||||
# What this script answers:
|
||||
# - Which capabilities are queried most often (all requests, including hits)?
|
||||
# - Which device class / protocol combinations are active?
|
||||
# - What is the cache hit ratio per endpoint?
|
||||
# - Which full query string combinations are most common?
|
||||
#
|
||||
# Usage:
|
||||
# BUNNYNET_API_KEY=your-key PULL_ZONE_ID=12345 ./scripts/query-report.sh
|
||||
# BUNNYNET_API_KEY=your-key PULL_ZONE_ID=12345 DAYS=30 ./scripts/query-report.sh
|
||||
#
|
||||
# Output:
|
||||
# Human-readable report to stdout.
|
||||
# With PROMETHEUS_PUSH_URL set, also pushes counters to a Prometheus
|
||||
# Pushgateway (e.g. your dedicated analytics Grafana stack) so the weekly
|
||||
# numbers become a time-series rather than a point-in-time dump.
|
||||
#
|
||||
# PROMETHEUS_PUSH_URL=https://pushgateway.your-grafana.example/metrics/job/apix-cdn-report \
|
||||
# BUNNYNET_API_KEY=your-key PULL_ZONE_ID=12345 ./scripts/query-report.sh
|
||||
#
|
||||
# Prerequisites: curl, gzip, awk (any POSIX awk)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BUNNYNET_API_KEY="${BUNNYNET_API_KEY:?BUNNYNET_API_KEY is required}"
|
||||
PULL_ZONE_ID="${PULL_ZONE_ID:-$(cat .bunnynet-pull-zone-id 2>/dev/null || echo '')}"
|
||||
PULL_ZONE_ID="${PULL_ZONE_ID:?PULL_ZONE_ID is required (or run setup-bunnynet.sh first)}"
|
||||
DAYS="${DAYS:-7}"
|
||||
PROMETHEUS_PUSH_URL="${PROMETHEUS_PUSH_URL:-}"
|
||||
LOGGING_BASE="https://logging.bunnycdn.com"
|
||||
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
touch "${WORK}/combined.log"
|
||||
|
||||
# ── 1. Download logs ──────────────────────────────────────────────────────────
|
||||
|
||||
echo "==> Downloading Bunny.net access logs — pull zone ${PULL_ZONE_ID}, last ${DAYS} days ..."
|
||||
|
||||
for i in $(seq 1 "$DAYS"); do
|
||||
# Support both GNU date (Linux) and BSD date (macOS)
|
||||
if date --version >/dev/null 2>&1; then
|
||||
DATE=$(date -d "-${i} days" +%Y/%m/%d)
|
||||
else
|
||||
DATE=$(date -v "-${i}d" +%Y/%m/%d)
|
||||
fi
|
||||
Y=$(echo "$DATE" | cut -d/ -f1)
|
||||
M=$(echo "$DATE" | cut -d/ -f2)
|
||||
D=$(echo "$DATE" | cut -d/ -f3)
|
||||
|
||||
GZ="${WORK}/${Y}-${M}-${D}.gz"
|
||||
STATUS=$(curl -s -o "$GZ" -w "%{http_code}" \
|
||||
-H "AccessKey: ${BUNNYNET_API_KEY}" \
|
||||
"${LOGGING_BASE}/${Y}/${M}/${D}/${PULL_ZONE_ID}/")
|
||||
|
||||
if [ "$STATUS" = "200" ] && [ -s "$GZ" ]; then
|
||||
gunzip -c "$GZ" >> "${WORK}/combined.log"
|
||||
printf " %s-%s-%s OK\n" "$Y" "$M" "$D"
|
||||
else
|
||||
printf " %s-%s-%s no data (HTTP %s)\n" "$Y" "$M" "$D" "$STATUS"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ! -s "${WORK}/combined.log" ]; then
|
||||
echo ""
|
||||
echo "No log data found for the requested period."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 2. Isolate search requests ────────────────────────────────────────────────
|
||||
|
||||
grep -E '"GET /services\?' "${WORK}/combined.log" > "${WORK}/svc.log" || true
|
||||
grep -E '"GET /devices\?' "${WORK}/combined.log" > "${WORK}/dev.log" || true
|
||||
grep -E '"GET /devices\?|"GET /services\?' "${WORK}/combined.log" > "${WORK}/search.log" || true
|
||||
grep -E '"GET /[^"?]+/replacements\?' "${WORK}/combined.log" > "${WORK}/repl.log" || true
|
||||
|
||||
TOTAL=$(wc -l < "${WORK}/combined.log")
|
||||
SEARCH=$(wc -l < "${WORK}/search.log")
|
||||
|
||||
echo ""
|
||||
printf "Total log entries : %d\n" "$TOTAL"
|
||||
printf "Search requests : %d (GET /services + GET /devices)\n" "$SEARCH"
|
||||
echo ""
|
||||
|
||||
# ── Helper: extract query string from a log line ──────────────────────────────
|
||||
# Input line: 1.2.3.x - - [date] "GET /services?capability=nlp HTTP/1.1" 200 … HIT
|
||||
# Output: capability=nlp
|
||||
|
||||
extract_qs() {
|
||||
awk '{
|
||||
if (match($0, /"GET [^?]+"?\?([^" ]+) HTTP/, arr)) print arr[1]
|
||||
}' "$1"
|
||||
}
|
||||
|
||||
extract_param() {
|
||||
local param="$1" file="$2"
|
||||
extract_qs "$file" | tr "&" "\n" | grep "^${param}=" | cut -d= -f2-
|
||||
}
|
||||
|
||||
# ── 3. Cache hit ratio ────────────────────────────────────────────────────────
|
||||
|
||||
echo "=== Cache hit ratio (search endpoints) ==================================="
|
||||
HITS=$(awk '$NF=="HIT"' "${WORK}/search.log" | wc -l || echo 0)
|
||||
MISS=$(awk '$NF=="MISS"' "${WORK}/search.log" | wc -l || echo 0)
|
||||
BYPS=$(awk '$NF=="BYPASS"' "${WORK}/search.log" | wc -l || echo 0)
|
||||
printf " HIT : %d\n" "$HITS"
|
||||
printf " MISS : %d\n" "$MISS"
|
||||
printf " BYPASS : %d\n" "$BYPS"
|
||||
echo ""
|
||||
|
||||
# ── 4. Top capabilities ───────────────────────────────────────────────────────
|
||||
|
||||
echo "=== Top capabilities GET /services ======================================"
|
||||
extract_param "capability" "${WORK}/svc.log" \
|
||||
| sort | uniq -c | sort -rn | head -20 \
|
||||
| awk '{printf " %7d %s\n", $1, $2}'
|
||||
echo ""
|
||||
|
||||
echo "=== Top capabilities GET /devices ======================================="
|
||||
extract_param "capability" "${WORK}/dev.log" \
|
||||
| sort | uniq -c | sort -rn | head -20 \
|
||||
| awk '{printf " %7d %s\n", $1, $2}'
|
||||
echo ""
|
||||
|
||||
# ── 5. Device class and protocol ─────────────────────────────────────────────
|
||||
|
||||
echo "=== Device class breakdown ================================================"
|
||||
extract_param "deviceClass" "${WORK}/search.log" \
|
||||
| sort | uniq -c | sort -rn | head -10 \
|
||||
| awk '{printf " %7d %s\n", $1, $2}'
|
||||
echo ""
|
||||
|
||||
echo "=== Protocol breakdown ===================================================="
|
||||
extract_param "protocol" "${WORK}/search.log" \
|
||||
| sort | uniq -c | sort -rn | head -10 \
|
||||
| awk '{printf " %7d %s\n", $1, $2}'
|
||||
echo ""
|
||||
|
||||
# ── 6. Stage breakdown (services) — canonical form omits PRODUCTION ───────────
|
||||
|
||||
echo "=== Stage breakdown GET /services (explicit only) ======================="
|
||||
echo " Note: requests without ?stage= are PRODUCTION (canonical form omits it)"
|
||||
extract_param "stage" "${WORK}/svc.log" \
|
||||
| sort | uniq -c | sort -rn \
|
||||
| awk '{printf " %7d %s\n", $1, $2}'
|
||||
IMPLICIT_PROD=$(extract_qs "${WORK}/svc.log" | grep -vc "stage=" || true)
|
||||
printf " %7d PRODUCTION (implicit)\n" "$IMPLICIT_PROD"
|
||||
echo ""
|
||||
|
||||
# ── 7. Top full query string combinations ────────────────────────────────────
|
||||
|
||||
echo "=== Top 30 query string combinations ====================================="
|
||||
extract_qs "${WORK}/search.log" \
|
||||
| sort | uniq -c | sort -rn | head -30 \
|
||||
| awk '{printf " %7d ?%s\n", $1, $2}'
|
||||
echo ""
|
||||
|
||||
echo "=== Replacements endpoint top parameters ================================="
|
||||
if [ -s "${WORK}/repl.log" ]; then
|
||||
extract_qs "${WORK}/repl.log" \
|
||||
| sort | uniq -c | sort -rn | head -10 \
|
||||
| awk '{printf " %7d ?%s\n", $1, $2}'
|
||||
else
|
||||
echo " (no replacement requests in period)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── 8. Push to Prometheus Pushgateway (optional) ─────────────────────────────
|
||||
#
|
||||
# Pushes capability counters as Prometheus metrics so they appear as a
|
||||
# time-series in the dedicated analytics Grafana instance.
|
||||
# Run weekly via cron to build a trend chart without real-time overhead.
|
||||
|
||||
if [ -n "$PROMETHEUS_PUSH_URL" ]; then
|
||||
echo "==> Pushing counters to Prometheus Pushgateway ..."
|
||||
|
||||
{
|
||||
echo "# HELP apix_cdn_capability_requests_total Capability search requests (all, including CDN cache hits)"
|
||||
echo "# TYPE apix_cdn_capability_requests_total counter"
|
||||
|
||||
extract_param "capability" "${WORK}/svc.log" \
|
||||
| sort | uniq -c \
|
||||
| awk '{printf "apix_cdn_capability_requests_total{resource=\"services\",capability=\"%s\"} %d\n", $2, $1}'
|
||||
|
||||
extract_param "capability" "${WORK}/dev.log" \
|
||||
| sort | uniq -c \
|
||||
| awk '{printf "apix_cdn_capability_requests_total{resource=\"devices\",capability=\"%s\"} %d\n", $2, $1}'
|
||||
|
||||
echo "# HELP apix_cdn_cache_requests_total CDN cache disposition for search endpoints"
|
||||
echo "# TYPE apix_cdn_cache_requests_total counter"
|
||||
printf "apix_cdn_cache_requests_total{status=\"hit\"} %d\n" "$HITS"
|
||||
printf "apix_cdn_cache_requests_total{status=\"miss\"} %d\n" "$MISS"
|
||||
printf "apix_cdn_cache_requests_total{status=\"bypass\"} %d\n" "$BYPS"
|
||||
|
||||
echo "# HELP apix_cdn_deviceclass_requests_total Device class breakdown"
|
||||
echo "# TYPE apix_cdn_deviceclass_requests_total counter"
|
||||
extract_param "deviceClass" "${WORK}/search.log" \
|
||||
| sort | uniq -c \
|
||||
| awk '{printf "apix_cdn_deviceclass_requests_total{deviceClass=\"%s\"} %d\n", $2, $1}'
|
||||
|
||||
echo "# HELP apix_cdn_protocol_requests_total Protocol breakdown"
|
||||
echo "# TYPE apix_cdn_protocol_requests_total counter"
|
||||
extract_param "protocol" "${WORK}/search.log" \
|
||||
| sort | uniq -c \
|
||||
| awk '{printf "apix_cdn_protocol_requests_total{protocol=\"%s\"} %d\n", $2, $1}'
|
||||
|
||||
} | curl -sf --data-binary @- "${PROMETHEUS_PUSH_URL}" && \
|
||||
echo " Done." || echo " WARNING: Pushgateway push failed (non-fatal)."
|
||||
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ── 9. Summary ────────────────────────────────────────────────────────────────
|
||||
|
||||
echo "Report complete."
|
||||
printf "Period : last %d days\n" "$DAYS"
|
||||
printf "Pull zone : %s\n" "$PULL_ZONE_ID"
|
||||
[ -n "$PROMETHEUS_PUSH_URL" ] && echo "Pushed to : ${PROMETHEUS_PUSH_URL}"
|
||||
+8
-9
@@ -11,6 +11,12 @@ info() { echo -e "${GREEN}[apix]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[warn]${NC} $*"; }
|
||||
die() { echo -e "${RED}[fail]${NC} $*" >&2; exit 1; }
|
||||
|
||||
_on_exit() {
|
||||
echo ""
|
||||
read -rp "Press Enter to close…" _
|
||||
}
|
||||
trap _on_exit EXIT
|
||||
|
||||
warn "This will DROP and recreate the local 'apix' database."
|
||||
read -rp "Continue? [y/N] " confirm
|
||||
[[ "${confirm,,}" == "y" ]] || { echo "Aborted."; exit 0; }
|
||||
@@ -58,12 +64,5 @@ for i in $(seq 1 30); do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
info "Running Liquibase migrations"
|
||||
mvn -q liquibase:update -pl apix-registry \
|
||||
-Dliquibase.url="jdbc:postgresql://localhost:${DB_PORT}/${DB_NAME}" \
|
||||
-Dliquibase.username="$DB_USER" \
|
||||
-Dliquibase.password="$DB_PASS"
|
||||
info "Migrations applied"
|
||||
|
||||
info "Reset complete — starting dev servers"
|
||||
"$SCRIPT_DIR/dev.sh"
|
||||
info "Reset complete — starting dev servers (Quarkus applies migrations at startup)"
|
||||
exec "$SCRIPT_DIR/dev.sh"
|
||||
|
||||
@@ -13,6 +13,9 @@ TARGET="${1:-all}"
|
||||
GREEN='\033[0;32m'; NC='\033[0m'
|
||||
info() { echo -e "${GREEN}[apix]${NC} $*"; }
|
||||
|
||||
_on_exit() { echo ""; read -rp "Press Enter to close…" _; }
|
||||
trap _on_exit EXIT
|
||||
|
||||
# ── tmux mode ────────────────────────────────────────────────────────────────
|
||||
if command -v tmux &>/dev/null && tmux has-session -t apix-dev 2>/dev/null; then
|
||||
_restart_window() {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env bash
|
||||
# Seed RS-01: Bot Standards Foundation (org) + APIX registry (service).
|
||||
# Run once against a freshly started registry. Safe to re-run — 409s are ignored.
|
||||
#
|
||||
# Usage:
|
||||
# REGISTRY_URL=http://localhost:8180 API_KEY=dev-insecure-key-change-in-prod ./scripts/seed-self-registration.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REGISTRY_URL="${REGISTRY_URL:-http://localhost:8180}"
|
||||
API_KEY="${API_KEY:-dev-insecure-key-change-in-prod}"
|
||||
BSF_EMAIL="${BSF_EMAIL:-carsten@botstandards.org}"
|
||||
|
||||
echo "==> Registering Bot Standards Foundation (org) ..."
|
||||
ORG_RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-X POST "${REGISTRY_URL}/organizations" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Api-Key: ${API_KEY}" \
|
||||
-d '{
|
||||
"registrantName": "Bot Standards Foundation",
|
||||
"registrantEmail": "'"${BSF_EMAIL}"'",
|
||||
"registrantJurisdiction": "CH",
|
||||
"registrantOrgType": "FOUNDATION",
|
||||
"domain": "botstandards.org",
|
||||
"targetOLevel": "IDENTITY_VERIFIED"
|
||||
}')
|
||||
|
||||
if [ "${ORG_RESPONSE}" = "409" ]; then
|
||||
echo " botstandards.org already registered — skipping."
|
||||
elif [ "${ORG_RESPONSE}" = "201" ] || [ "${ORG_RESPONSE}" = "200" ]; then
|
||||
echo " Registered. HTTP ${ORG_RESPONSE}"
|
||||
else
|
||||
echo " ERROR: HTTP ${ORG_RESPONSE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "==> Registering APIX registry service (RS-01) ..."
|
||||
SVC_RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-X POST "${REGISTRY_URL}/services" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Api-Key: ${API_KEY}" \
|
||||
-d '{
|
||||
"name": "APIX Registry",
|
||||
"description": "The open autonomous agent service discovery registry. Agents navigate here to find and invoke any registered service by capability — no hardcoded URLs required.",
|
||||
"endpoint": "'"${REGISTRY_URL}"'/",
|
||||
"capabilities": ["service.discovery", "service.query", "service.registration"],
|
||||
"registrantEmail": "'"${BSF_EMAIL}"'",
|
||||
"registrantName": "Bot Standards Foundation",
|
||||
"registrantJurisdiction": "CH",
|
||||
"registrantOrgType": "FOUNDATION",
|
||||
"bsmVersion": "0.1",
|
||||
"serviceStage": "DEVELOPMENT"
|
||||
}')
|
||||
|
||||
if [ "${SVC_RESPONSE}" = "409" ]; then
|
||||
echo " APIX registry service already registered — skipping."
|
||||
elif [ "${SVC_RESPONSE}" = "201" ] || [ "${SVC_RESPONSE}" = "200" ]; then
|
||||
echo " Registered. HTTP ${SVC_RESPONSE}"
|
||||
else
|
||||
echo " ERROR: HTTP ${SVC_RESPONSE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "==> Verifying registry root ..."
|
||||
curl -s "${REGISTRY_URL}/" | python3 -m json.tool 2>/dev/null || \
|
||||
curl -s "${REGISTRY_URL}/"
|
||||
|
||||
echo ""
|
||||
echo "Done. Registry root at ${REGISTRY_URL}/"
|
||||
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env bash
|
||||
# Configure a Bunny.net pull zone for the APIX registry.
|
||||
#
|
||||
# What this script does:
|
||||
# 1. Creates a pull zone pointing at the origin VPS
|
||||
# 2. Enables query-string vary cache (so /services?capability=nlp and
|
||||
# /services?capability=translation are cached as separate entries)
|
||||
# 3. Sets the edge TTL to follow the origin Cache-Control headers
|
||||
# 4. Adds the custom hostname (api-index.org)
|
||||
# 5. Prints the CDN hostname to use in your DNS CNAME record
|
||||
#
|
||||
# Prerequisites:
|
||||
# - A Bunny.net account with an API key (bunny.net → Account → API)
|
||||
# - The origin VPS is reachable at ORIGIN_URL before running this
|
||||
# - DNS for api-index.org is under your control
|
||||
#
|
||||
# Usage:
|
||||
# BUNNYNET_API_KEY=your-key \
|
||||
# ORIGIN_URL=https://your-vps-ip-or-hostname \
|
||||
# CUSTOM_HOSTNAME=api-index.org \
|
||||
# ./scripts/setup-bunnynet.sh
|
||||
#
|
||||
# Optional — enable real-time Loki log forwarding for live telemetry:
|
||||
# SYSLOG_HOST=your-vps-ip \
|
||||
# SYSLOG_PORT=5514 \
|
||||
# ./scripts/setup-bunnynet.sh
|
||||
#
|
||||
# Optional — install weekly cron job (Mondays 06:00) for CDN analytics report:
|
||||
# INSTALL_CRON=true \
|
||||
# ./scripts/setup-bunnynet.sh
|
||||
#
|
||||
# Deploy scripts/promtail-cdn-logs.yaml on the VPS before setting SYSLOG_HOST.
|
||||
# Bunny.net will start streaming access logs (one syslog line per request,
|
||||
# including CDN cache hits) to Promtail, which pushes to your Loki instance.
|
||||
#
|
||||
# CDN selection rationale:
|
||||
# Bunny.net is European (Slovenia), GDPR-compliant, has PoPs in Shanghai,
|
||||
# Singapore, Tokyo, Hong Kong, and Frankfurt. No founding-member conflict
|
||||
# (Cloudflare and AWS are both founding member candidates and must not
|
||||
# operate infrastructure over the registry).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BUNNYNET_API_KEY="${BUNNYNET_API_KEY:?BUNNYNET_API_KEY is required}"
|
||||
ORIGIN_URL="${ORIGIN_URL:?ORIGIN_URL is required (e.g. https://1.2.3.4)}"
|
||||
CUSTOM_HOSTNAME="${CUSTOM_HOSTNAME:-api-index.org}"
|
||||
PULL_ZONE_NAME="${PULL_ZONE_NAME:-apix-registry}"
|
||||
BUNNYNET_API="https://api.bunny.net"
|
||||
|
||||
# Optional — set SYSLOG_HOST to enable real-time log forwarding to Promtail/Loki.
|
||||
# Bunny.net streams one syslog line per request (all requests, not just misses).
|
||||
# Start Promtail with scripts/promtail-cdn-logs.yaml on the VPS first.
|
||||
SYSLOG_HOST="${SYSLOG_HOST:-}"
|
||||
SYSLOG_PORT="${SYSLOG_PORT:-5514}"
|
||||
INSTALL_CRON="${INSTALL_CRON:-false}"
|
||||
|
||||
echo "==> Creating Bunny.net pull zone '${PULL_ZONE_NAME}' → ${ORIGIN_URL} ..."
|
||||
|
||||
CREATE_RESPONSE=$(curl -sf -X POST "${BUNNYNET_API}/pullzone" \
|
||||
-H "AccessKey: ${BUNNYNET_API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"Name": "'"${PULL_ZONE_NAME}"'",
|
||||
"OriginUrl": "'"${ORIGIN_URL}"'",
|
||||
"Type": 0,
|
||||
"EnableQueryStringVaryCache": true,
|
||||
"UseBackgroundUpdate": false,
|
||||
"EnableWebPVary": false,
|
||||
"EnableCountryCodeVary": false,
|
||||
"EnableMobileVary": false,
|
||||
"EnableHostnameVary": false,
|
||||
"IgnoreQueryStrings": false,
|
||||
"CacheControlMaxAgeOverride": -1,
|
||||
"CacheControlBrowserMaxAgeOverride": -1,
|
||||
"EnableLogging": true,
|
||||
"LoggingIPAnonymizationEnabled": true
|
||||
}')
|
||||
|
||||
# -1 on both MaxAgeOverride fields means "respect the origin Cache-Control header"
|
||||
# The application sets: GET / → max-age=60, GET /services|/devices → max-age=30
|
||||
|
||||
PULL_ZONE_ID=$(echo "${CREATE_RESPONSE}" | python3 -c "import sys,json; print(json.load(sys.stdin)['Id'])")
|
||||
CDN_HOSTNAME=$(echo "${CREATE_RESPONSE}" | python3 -c "import sys,json; print(json.load(sys.stdin)['Hostnames'][0]['Value'])")
|
||||
|
||||
echo " Pull zone ID : ${PULL_ZONE_ID}"
|
||||
echo " CDN hostname : ${CDN_HOSTNAME}"
|
||||
|
||||
echo ""
|
||||
echo "==> Adding custom hostname '${CUSTOM_HOSTNAME}' to pull zone ..."
|
||||
|
||||
curl -sf -X POST "${BUNNYNET_API}/pullzone/${PULL_ZONE_ID}/addHostname" \
|
||||
-H "AccessKey: ${BUNNYNET_API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"Hostname": "'"${CUSTOM_HOSTNAME}"'"}' > /dev/null
|
||||
|
||||
echo " Done."
|
||||
|
||||
echo ""
|
||||
echo "==> Adding edge rule: bypass cache for internal paths (/q/*) ..."
|
||||
|
||||
curl -sf -X POST "${BUNNYNET_API}/pullzone/${PULL_ZONE_ID}/edgerules/addOrUpdate" \
|
||||
-H "AccessKey: ${BUNNYNET_API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"Enabled": true,
|
||||
"Description": "Bypass cache for Quarkus health and metrics endpoints",
|
||||
"ActionType": 15,
|
||||
"Triggers": [{
|
||||
"Type": 0,
|
||||
"PatternMatches": ["/q/"],
|
||||
"PatternMatchingType": 12
|
||||
}],
|
||||
"ActionParameter1": "",
|
||||
"ActionParameter2": ""
|
||||
}' > /dev/null
|
||||
|
||||
echo " Done."
|
||||
|
||||
if [ -n "${SYSLOG_HOST}" ]; then
|
||||
echo ""
|
||||
echo "==> Enabling real-time log forwarding → ${SYSLOG_HOST}:${SYSLOG_PORT} (TCP Syslog) ..."
|
||||
|
||||
curl -sf -X POST "${BUNNYNET_API}/pullzone/${PULL_ZONE_ID}" \
|
||||
-H "AccessKey: ${BUNNYNET_API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"LogForwardingEnabled": true,
|
||||
"LogForwardingHostname": "'"${SYSLOG_HOST}"'",
|
||||
"LogForwardingPort": '"${SYSLOG_PORT}"',
|
||||
"LogForwardingProtocol": 1,
|
||||
"LogForwardingToken": ""
|
||||
}' > /dev/null
|
||||
|
||||
echo " Done."
|
||||
echo " Bunny.net will forward every request (hits + misses) as a syslog line."
|
||||
echo " Promtail → Loki → Grafana will show live capability traffic within seconds."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "==> Purging initial cache to ensure clean state ..."
|
||||
|
||||
curl -sf -X POST "${BUNNYNET_API}/pullzone/${PULL_ZONE_ID}/purgeCache" \
|
||||
-H "AccessKey: ${BUNNYNET_API_KEY}" > /dev/null
|
||||
|
||||
echo " Done."
|
||||
|
||||
echo ""
|
||||
echo "======================================================================"
|
||||
echo " Setup complete. Next steps:"
|
||||
echo ""
|
||||
echo " 1. Add a DNS CNAME record:"
|
||||
echo " ${CUSTOM_HOSTNAME} CNAME ${CDN_HOSTNAME}"
|
||||
echo ""
|
||||
echo " 2. Wait for DNS propagation (~5 minutes for most registrars)"
|
||||
echo ""
|
||||
echo " 3. Bunny.net will provision a free TLS certificate automatically"
|
||||
echo " once the CNAME is live. No manual cert management needed."
|
||||
echo ""
|
||||
echo " 4. Verify the CDN is caching correctly:"
|
||||
echo " curl -sI https://${CUSTOM_HOSTNAME}/ | grep -i cache-control"
|
||||
echo " Expected: Cache-Control: public, max-age=60"
|
||||
echo ""
|
||||
echo " 5. Test from Asia (requires a remote machine or VPN):"
|
||||
echo " curl -w '%{time_total}s\n' -o /dev/null -s https://${CUSTOM_HOSTNAME}/"
|
||||
echo " First request: ~200ms (cache miss → origin). Subsequent: <20ms (edge)."
|
||||
echo ""
|
||||
echo " CDN hostname : ${CDN_HOSTNAME}"
|
||||
echo " Pull zone ID : ${PULL_ZONE_ID}"
|
||||
echo ""
|
||||
echo " Log access (Bunny.net access logs, gzip, one file per day):"
|
||||
echo " curl -H 'AccessKey: \$BUNNYNET_API_KEY' \\"
|
||||
echo " https://logging.bunnycdn.com/YYYY/MM/DD/${PULL_ZONE_ID}/ -o logs.gz"
|
||||
echo ""
|
||||
echo " Query frequency report (last 7 days):"
|
||||
echo " BUNNYNET_API_KEY=\$BUNNYNET_API_KEY PULL_ZONE_ID=${PULL_ZONE_ID} \\"
|
||||
echo " ./scripts/query-report.sh"
|
||||
echo ""
|
||||
echo " Logs are IP-anonymised (last octet zeroed) for GDPR compliance."
|
||||
echo ""
|
||||
if [ -n "${SYSLOG_HOST}" ]; then
|
||||
echo " Live Loki forwarding : ENABLED → ${SYSLOG_HOST}:${SYSLOG_PORT}"
|
||||
echo " Every request (CDN hit and miss) streams as a syslog line."
|
||||
echo " Grafana dashboard shows capability traffic in real-time."
|
||||
echo " Promtail config: scripts/promtail-cdn-logs.yaml"
|
||||
else
|
||||
echo " Live Loki forwarding : DISABLED (re-run with SYSLOG_HOST=<vps-ip> to enable)"
|
||||
echo " Without this, only cache misses appear in origin Prometheus metrics."
|
||||
echo " For the OpenClaw demo: enable this before the session starts."
|
||||
fi
|
||||
echo "======================================================================"
|
||||
|
||||
# Persist pull zone ID for query-report.sh convenience
|
||||
echo "${PULL_ZONE_ID}" > .bunnynet-pull-zone-id
|
||||
echo ""
|
||||
echo "Pull zone ID saved to .bunnynet-pull-zone-id (add to .gitignore)."
|
||||
|
||||
if [ "${INSTALL_CRON}" = "true" ]; then
|
||||
echo ""
|
||||
echo "==> Installing weekly cron job for CDN analytics report ..."
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPORT_SCRIPT="${SCRIPT_DIR}/query-report.sh"
|
||||
LOG_DIR="${SCRIPT_DIR}/../logs"
|
||||
mkdir -p "${LOG_DIR}"
|
||||
|
||||
CRON_LINE="0 6 * * 1 BUNNYNET_API_KEY=${BUNNYNET_API_KEY} PULL_ZONE_ID=${PULL_ZONE_ID} ${REPORT_SCRIPT} >> ${LOG_DIR}/cdn-report.log 2>&1"
|
||||
|
||||
# Install without duplicating — removes any existing query-report.sh entry first
|
||||
( crontab -l 2>/dev/null | grep -v "query-report\.sh"; echo "${CRON_LINE}" ) | crontab -
|
||||
|
||||
echo " Done."
|
||||
echo " Schedule : every Monday at 06:00"
|
||||
echo " Log : ${LOG_DIR}/cdn-report.log"
|
||||
echo ""
|
||||
echo " Verify with: crontab -l | grep query-report"
|
||||
fi
|
||||
+6
-20
@@ -116,27 +116,13 @@ for i in $(seq 1 30); do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# ── 6. Liquibase migrations ───────────────────────────────────────────────────
|
||||
# ── 6. Note on migrations ─────────────────────────────────────────────────────
|
||||
step "Database migrations"
|
||||
JDBC_URL="jdbc:postgresql://localhost:${DB_PORT}/${DB_NAME}"
|
||||
|
||||
if [[ ! -f "$PROJECT_ROOT/pom.xml" ]]; then
|
||||
warn "No pom.xml found — Maven project not scaffolded yet (WORKLOG Block 1 / C-00)."
|
||||
warn "Skipping Liquibase migrations. Run this script again after completing Block 1."
|
||||
else
|
||||
# apix-registry depends on apix-common and apix-verification; install them
|
||||
# to the local repository first so Maven can resolve them during the
|
||||
# liquibase:update goal (no source files yet — this completes in seconds).
|
||||
info "Installing shared modules to local repository…"
|
||||
mvn -q install -pl apix-common,apix-verification -DskipTests
|
||||
|
||||
info "Running Liquibase migrations on $JDBC_URL"
|
||||
mvn -q liquibase:update -pl apix-registry \
|
||||
-Dliquibase.url="$JDBC_URL" \
|
||||
-Dliquibase.username="$DB_USER" \
|
||||
-Dliquibase.password="$DB_PASS"
|
||||
info "Migrations applied"
|
||||
fi
|
||||
# Migrations are applied automatically by Quarkus at startup via
|
||||
# quarkus.liquibase.migrate-at-start=true. Running them separately via the
|
||||
# Maven Liquibase plugin records a different classpath prefix in DATABASECHANGELOG
|
||||
# than Quarkus uses, causing a "relation already exists" conflict on next start.
|
||||
info "Migrations will run automatically when Quarkus starts (migrate-at-start=true)."
|
||||
|
||||
# ── Done ──────────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
|
||||
Reference in New Issue
Block a user