diff --git a/apix-admin/src/main/resources/META-INF/resources/admin.js b/apix-admin/src/main/resources/META-INF/resources/admin.js
index 9999279..e69d8a9 100644
--- a/apix-admin/src/main/resources/META-INF/resources/admin.js
+++ b/apix-admin/src/main/resources/META-INF/resources/admin.js
@@ -128,3 +128,114 @@
});
}
})();
+
+// ── Sandbox live rate chart (sandbox drill-down only) ──────────────────────
+(function () {
+ const el = document.getElementById('rate-chart');
+ if (!el || !window.__D || !__D.sandboxId) return;
+
+ 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 ML = 36; // left margin for Y-axis labels
+ const MB = 18; // bottom margin for X-axis labels
+ const W = el.parentElement.clientWidth || 900;
+ const H = 96;
+ const CH = H - MB; // chart height (plot area)
+
+ el.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
+ el.style.width = '100%';
+
+ const yS = d3.scaleLinear().domain([0, rateLimit * 1.1]).range([CH, 0]);
+ const xS = d3.scaleLinear().domain([0, BUCKETS - 1]).range([ML, W]);
+
+ const areaFn = d3.area()
+ .x(function (_, i) { return xS(i); })
+ .y0(CH)
+ .y1(function (d) { return yS(d); })
+ .curve(d3.curveCatmullRom.alpha(0.5));
+
+ const svg = d3.select(el);
+
+ // Y-axis: 0 / 50 / 100% grid lines + labels
+ [0, 50, 100].forEach(function (pct) {
+ const 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 + '%');
+ });
+
+ // X-axis baseline + time labels
+ svg.append('line')
+ .attr('x1', ML).attr('x2', W)
+ .attr('y1', CH).attr('y2', CH)
+ .attr('class', 'axis-line');
+
+ [
+ { bucket: 0, label: '-6m', anchor: 'start' },
+ { bucket: BUCKETS / 2, label: '-3m', 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);
+ });
+
+ // 100% limit line (red dashed)
+ var ly = yS(rateLimit);
+ svg.append('line')
+ .attr('x1', ML).attr('x2', W)
+ .attr('y1', ly).attr('y2', ly)
+ .attr('class', 'rate-limit-line');
+
+ var path = svg.append('path').attr('class', 'rate-area');
+
+ 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));
+ var pct = rateLimit > 0 ? (rpm / rateLimit * 100).toFixed(1) : '0.0';
+ var txt = fmt(rpm) + ' req/min (' + pct + '%)';
+ var elNow = document.getElementById('rate-now');
+ var elLive = document.getElementById('rate-live-val');
+ if (elNow) elNow.textContent = fmt(rpm);
+ if (elLive) elLive.textContent = txt;
+ }
+
+ 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);
+ render(Math.round(delta * (60000 / INTERVAL)));
+ }
+ lastTotal = total;
+ } catch (_) {}
+ }
+
+ poll();
+ setInterval(poll, INTERVAL);
+})();
diff --git a/apix-admin/src/main/resources/templates/DashboardResource/global.html b/apix-admin/src/main/resources/templates/DashboardResource/global.html
index c9a9554..bf4cfbd 100644
--- a/apix-admin/src/main/resources/templates/DashboardResource/global.html
+++ b/apix-admin/src/main/resources/templates/DashboardResource/global.html
@@ -106,6 +106,6 @@ nav {
-
+