fix(admin): blinks + live rate chart on sandbox drill-down
Deploy to Production / deploy (push) Successful in 3m14s

Blink bug: agent visits were gated behind registrarLat != null — sandboxes
without a declared location showed no blinks at all. Now always enters the
sandbox branch when __D.sandboxId is present; registrar star still conditional.

Rate chart: rolling D3 area chart (72 buckets x 5s = 6min window) polls
GET /sandbox/{uuid}/poll every 5s, computes delta→rpm, renders with
CatmullRom curve. Red dashed limit line. Live value shown in stat box and
chart header. Poll endpoint added to DashboardResource (JSON, no HTML).

Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
This commit is contained in:
Carsten Rehfeld
2026-05-16 22:29:37 +02:00
parent 51e210ab68
commit c7bfa73d2f
3 changed files with 110 additions and 5 deletions
@@ -6,6 +6,7 @@ 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;
@@ -47,6 +48,24 @@ public class DashboardResource {
}
}
@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();
return Response.ok(Map.of(
"totalRequests", total,
"ratePerMinute", d.ratePerMinute()
)).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) {
@@ -62,10 +62,12 @@
points = __D.registrarPoints.map(p => ({
lat: p.lat, lon: p.lon, label: p.name, tier: p.tier, type: 'registrar'
}));
} else if (__D.registrarLat != null) {
// sandbox drill-down — registrar star
} 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 visits
}
// agent blinks — always, even when sandbox has no declared location
(__D.recentVisits || []).forEach(v => {
points.push({ lat: v.lat, lon: v.lon, type: 'agent' });
});
@@ -63,6 +63,15 @@ button.promote:hover { background: #2ea043; }
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 1rem; 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; }
</style>
</head>
<body>
@@ -93,7 +102,7 @@ button.promote:hover { background: #2ea043; }
<div class="stat">
<div class="stat-label">Rate limit</div>
<div class="stat-value">{dashboard.ratePerMinute}</div>
<div class="stat-sub">req / min</div>
<div class="stat-sub">now: <span id="rate-now"></span> req/min</div>
</div>
<div class="stat">
<div class="stat-label">Expires</div>
@@ -109,6 +118,15 @@ button.promote:hover { background: #2ea043; }
{/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="64"></svg>
</div>
<div id="map-wrap"><svg id="world-map"></svg></div>
<div class="actions">
@@ -132,5 +150,71 @@ button.promote:hover { background: #2ea043; }
<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>
<script>
(function () {
const INTERVAL = 5000;
const BUCKETS = 72; // 6 min at 5s
const rateLimit = __D.ratePerMinute || 1;
const sid = __D.sandboxId;
const buf = new Array(BUCKETS).fill(0);
let lastTotal = null;
const el = document.getElementById('rate-chart');
const W = el.parentElement.clientWidth || 900;
const H = 64;
el.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
el.style.width = '100%';
const xS = d3.scaleLinear().domain([0, BUCKETS - 1]).range([0, W]);
const yS = d3.scaleLinear().domain([0, Math.max(rateLimit * 1.1, 1)]).range([H, 0]);
const areaFn = d3.area()
.x(function(_, i) { return xS(i); })
.y0(H)
.y1(function(d) { return yS(d); })
.curve(d3.curveCatmullRom.alpha(0.5));
const svg = d3.select(el);
const path = svg.append('path').attr('class', 'rate-area');
// Red dashed limit line
const ly = yS(rateLimit);
svg.append('line')
.attr('x1', 0).attr('x2', W)
.attr('y1', ly).attr('y2', ly)
.attr('class', 'rate-limit-line');
function fmt(n) {
return n >= 1e6 ? (n/1e6).toFixed(1)+'M' : n >= 1e3 ? (n/1e3).toFixed(1)+'k' : String(n);
}
function render(rpm) {
buf.push(rpm);
buf.shift();
path.attr('d', areaFn(buf));
const txt = fmt(rpm) + ' req/min';
document.getElementById('rate-now').textContent = fmt(rpm);
document.getElementById('rate-live-val').textContent = txt;
}
async function poll() {
try {
const r = await fetch('/sandbox/' + sid + '/poll');
if (!r.ok) return;
const d = await r.json();
const total = Number(d.totalRequests) || 0;
if (lastTotal !== null) {
const delta = Math.max(0, total - lastTotal);
render(Math.round(delta * (60000 / INTERVAL)));
}
lastTotal = total;
} catch (_) {}
}
poll();
setInterval(poll, INTERVAL);
})();
</script>
</body>
</html>