mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 00:59:33 +00:00
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:
co-authored by
Claude Opus 4.6
parent
493e4578d0
commit
b68d67bd35
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user