feat: show token usage and cost tracker in gateway status popover (#284)

* feat: show token usage, cost tracker, and uptime in gateway status popover

The "Connected" hover popover in the web gateway now displays three
sections: connection info (SSE/WS counts, uptime), daily cost tracker
(spend + actions/hr), and per-model token usage (input/output counts
with cost per model). Also fixes the field name mismatch between the
backend response and JS rendering that prevented the popover from
showing correct data.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address PR review — escape HTML in popover, add model_usage test

- Escape model name and cost strings with escapeHtml() before inserting
  into innerHTML to prevent XSS via crafted model names
- Add test_model_usage_per_model_tracking test covering multi-model
  token/cost accumulation in CostGuard

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-02-21 03:45:04 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 493e4578d0
commit b68d67bd35
9 changed files with 241 additions and 14 deletions
+10
View File
@@ -89,6 +89,8 @@ impl GatewayChannel {
skill_registry: None,
skill_catalog: None,
chat_rate_limiter: server::RateLimiter::new(30, 60),
cost_guard: None,
startup_time: std::time::Instant::now(),
});
Self {
@@ -119,6 +121,8 @@ impl GatewayChannel {
skill_registry: self.state.skill_registry.clone(),
skill_catalog: self.state.skill_catalog.clone(),
chat_rate_limiter: server::RateLimiter::new(30, 60),
cost_guard: self.state.cost_guard.clone(),
startup_time: self.state.startup_time,
};
mutate(&mut new_state);
self.state = Arc::new(new_state);
@@ -206,6 +210,12 @@ impl GatewayChannel {
self
}
/// Inject the cost guard for token/cost tracking in the status popover.
pub fn with_cost_guard(mut self, cg: Arc<crate::agent::cost_guard::CostGuard>) -> Self {
self.rebuild_state(|s| s.cost_guard = Some(cg));
self
}
/// Get the auth token (for printing to console on startup).
pub fn auth_token(&self) -> &str {
&self.auth_token
+43
View File
@@ -148,6 +148,10 @@ pub struct GatewayState {
pub skill_catalog: Option<Arc<crate::skills::catalog::SkillCatalog>>,
/// Rate limiter for chat endpoints (30 messages per 60 seconds).
pub chat_rate_limiter: RateLimiter,
/// Cost guard for token/cost tracking.
pub cost_guard: Option<Arc<crate::agent::cost_guard::CostGuard>>,
/// Server startup time for uptime calculation.
pub startup_time: std::time::Instant,
}
/// Start the gateway HTTP server.
@@ -2569,18 +2573,57 @@ async fn gateway_status_handler(
.map(|t| t.connection_count())
.unwrap_or(0);
let uptime_secs = state.startup_time.elapsed().as_secs();
let (daily_cost, actions_this_hour, model_usage) = if let Some(ref cg) = state.cost_guard {
let cost = cg.daily_spend().await;
let actions = cg.actions_this_hour().await;
let usage = cg.model_usage().await;
let models: Vec<ModelUsageEntry> = usage
.into_iter()
.map(|(model, tokens)| ModelUsageEntry {
model,
input_tokens: tokens.input_tokens,
output_tokens: tokens.output_tokens,
cost: format!("{:.6}", tokens.cost),
})
.collect();
(Some(format!("{:.4}", cost)), Some(actions), Some(models))
} else {
(None, None, None)
};
Json(GatewayStatusResponse {
sse_connections,
ws_connections,
total_connections: sse_connections + ws_connections,
uptime_secs,
daily_cost,
actions_this_hour,
model_usage,
})
}
#[derive(serde::Serialize)]
struct ModelUsageEntry {
model: String,
input_tokens: u64,
output_tokens: u64,
cost: String,
}
#[derive(serde::Serialize)]
struct GatewayStatusResponse {
sse_connections: u64,
ws_connections: u64,
total_connections: u64,
uptime_secs: u64,
#[serde(skip_serializing_if = "Option::is_none")]
daily_cost: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
actions_this_hour: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
model_usage: Option<Vec<ModelUsageEntry>>,
}
#[cfg(test)]
+65 -6
View File
@@ -2252,13 +2252,72 @@ function startGatewayStatusPolling() {
gatewayStatusInterval = setInterval(fetchGatewayStatus, 30000);
}
function formatTokenCount(n) {
if (n == null || n === 0) return '0';
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
return '' + n;
}
function formatCost(costStr) {
if (!costStr) return '$0.00';
var n = parseFloat(costStr);
if (n < 0.01) return '$' + n.toFixed(4);
return '$' + n.toFixed(2);
}
function shortModelName(model) {
// Strip provider prefix and shorten common model names
var m = model.indexOf('/') >= 0 ? model.split('/').pop() : model;
// Shorten dated suffixes
m = m.replace(/-20\d{6}$/, '');
return m;
}
function fetchGatewayStatus() {
apiFetch('/api/gateway/status').then((data) => {
const popover = document.getElementById('gateway-popover');
popover.innerHTML = '<div class="gw-stat"><span>SSE clients</span><span>' + (data.sse_clients || 0) + '</span></div>'
+ '<div class="gw-stat"><span>Log clients</span><span>' + (data.log_clients || 0) + '</span></div>'
+ '<div class="gw-stat"><span>Uptime</span><span>' + formatDuration(data.uptime_secs) + '</span></div>';
}).catch(() => {});
apiFetch('/api/gateway/status').then(function(data) {
var popover = document.getElementById('gateway-popover');
var html = '';
// Connection info
html += '<div class="gw-section-label">Connections</div>';
html += '<div class="gw-stat"><span>SSE</span><span>' + (data.sse_connections || 0) + '</span></div>';
html += '<div class="gw-stat"><span>WebSocket</span><span>' + (data.ws_connections || 0) + '</span></div>';
html += '<div class="gw-stat"><span>Uptime</span><span>' + formatDuration(data.uptime_secs) + '</span></div>';
// Cost tracker
if (data.daily_cost != null) {
html += '<div class="gw-divider"></div>';
html += '<div class="gw-section-label">Cost Today</div>';
html += '<div class="gw-stat"><span>Spent</span><span>' + formatCost(data.daily_cost) + '</span></div>';
if (data.actions_this_hour != null) {
html += '<div class="gw-stat"><span>Actions/hr</span><span>' + data.actions_this_hour + '</span></div>';
}
}
// Per-model token usage
if (data.model_usage && data.model_usage.length > 0) {
html += '<div class="gw-divider"></div>';
html += '<div class="gw-section-label">Token Usage</div>';
data.model_usage.sort(function(a, b) {
return (b.input_tokens + b.output_tokens) - (a.input_tokens + a.output_tokens);
});
for (var i = 0; i < data.model_usage.length; i++) {
var m = data.model_usage[i];
var name = escapeHtml(shortModelName(m.model));
html += '<div class="gw-model-row">'
+ '<span class="gw-model-name">' + name + '</span>'
+ '<span class="gw-model-cost">' + escapeHtml(formatCost(m.cost)) + '</span>'
+ '</div>';
html += '<div class="gw-token-detail">'
+ '<span>in: ' + formatTokenCount(m.input_tokens) + '</span>'
+ '<span>out: ' + formatTokenCount(m.output_tokens) + '</span>'
+ '</div>';
}
}
popover.innerHTML = html;
}).catch(function() {});
}
// Show/hide popover on hover
+46 -1
View File
@@ -2549,7 +2549,7 @@ mark {
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 12px;
min-width: 180px;
min-width: 220px;
box-shadow: var(--shadow);
z-index: 100;
}
@@ -2558,6 +2558,20 @@ mark {
display: block;
}
.gw-section-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted, var(--text-secondary));
margin-bottom: 4px;
font-weight: 600;
}
.gw-divider {
border-top: 1px solid var(--border);
margin: 8px 0;
}
.gw-stat {
display: flex;
justify-content: space-between;
@@ -2571,6 +2585,37 @@ mark {
font-weight: 500;
}
.gw-model-row {
display: flex;
justify-content: space-between;
font-size: 12px;
padding: 3px 0 0 0;
}
.gw-model-name {
color: var(--text);
font-weight: 500;
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 140px;
}
.gw-model-cost {
color: var(--accent, var(--text));
font-weight: 500;
font-size: 11px;
}
.gw-token-detail {
display: flex;
gap: 12px;
font-size: 10px;
color: var(--text-secondary);
padding: 1px 0 4px 0;
}
/* --- Extension install form --- */
.ext-install-form {
+2
View File
@@ -490,6 +490,8 @@ mod tests {
skill_registry: None,
skill_catalog: None,
chat_rate_limiter: crate::channels::web::server::RateLimiter::new(30, 60),
cost_guard: None,
startup_time: std::time::Instant::now(),
}
}
}