feat(dashboard): add time-frame filtering and used-models-only pricing
- All usage endpoints now accept ?period=today|24h|7d|30d|all|custom with optional &from=ISO&to=ISO for custom ranges - Time-series chart adapts granularity: hourly for today/24h, daily for 7d/30d/all - Analytics and Costs pages have period selector buttons with custom date-range picker - Pricing table on Costs page now only shows models that have actually been used (GET /models?used_only=true) - Cache-bust version bumped to v=6
This commit is contained in:
@@ -33,6 +33,8 @@ pub(super) struct ModelListParams {
|
|||||||
pub reasoning: Option<bool>,
|
pub reasoning: Option<bool>,
|
||||||
/// Only models that have pricing data.
|
/// Only models that have pricing data.
|
||||||
pub has_cost: Option<bool>,
|
pub has_cost: Option<bool>,
|
||||||
|
/// Only models that have been used in requests.
|
||||||
|
pub used_only: Option<bool>,
|
||||||
/// Sort field (name, id, provider, context_limit, input_cost, output_cost).
|
/// Sort field (name, id, provider, context_limit, input_cost, output_cost).
|
||||||
pub sort_by: Option<ModelSortBy>,
|
pub sort_by: Option<ModelSortBy>,
|
||||||
/// Sort direction (asc, desc).
|
/// Sort direction (asc, desc).
|
||||||
@@ -46,6 +48,22 @@ pub(super) async fn handle_get_models(
|
|||||||
let registry = &state.app_state.model_registry;
|
let registry = &state.app_state.model_registry;
|
||||||
let pool = &state.app_state.db_pool;
|
let pool = &state.app_state.db_pool;
|
||||||
|
|
||||||
|
// If used_only, fetch the set of models that appear in llm_requests
|
||||||
|
let used_models: Option<std::collections::HashSet<String>> =
|
||||||
|
if params.used_only.unwrap_or(false) {
|
||||||
|
match sqlx::query_scalar::<_, String>(
|
||||||
|
"SELECT DISTINCT model FROM llm_requests",
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(models) => Some(models.into_iter().collect()),
|
||||||
|
Err(_) => Some(std::collections::HashSet::new()),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
// Build filter from query params
|
// Build filter from query params
|
||||||
let filter = ModelFilter {
|
let filter = ModelFilter {
|
||||||
provider: params.provider,
|
provider: params.provider,
|
||||||
@@ -79,6 +97,14 @@ pub(super) async fn handle_get_models(
|
|||||||
|
|
||||||
for entry in &entries {
|
for entry in &entries {
|
||||||
let m_key = entry.model_key;
|
let m_key = entry.model_key;
|
||||||
|
|
||||||
|
// Skip models not in the used set (when used_only is active)
|
||||||
|
if let Some(ref used) = used_models {
|
||||||
|
if !used.contains(m_key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let m_meta = entry.metadata;
|
let m_meta = entry.metadata;
|
||||||
|
|
||||||
let mut enabled = true;
|
let mut enabled = true;
|
||||||
|
|||||||
@@ -1,16 +1,83 @@
|
|||||||
use axum::{extract::State, response::Json};
|
use axum::{
|
||||||
|
extract::{Query, State},
|
||||||
|
response::Json,
|
||||||
|
};
|
||||||
use chrono;
|
use chrono;
|
||||||
|
use serde::Deserialize;
|
||||||
use serde_json;
|
use serde_json;
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
use super::{ApiResponse, DashboardState};
|
use super::{ApiResponse, DashboardState};
|
||||||
|
|
||||||
pub(super) async fn handle_usage_summary(State(state): State<DashboardState>) -> Json<ApiResponse<serde_json::Value>> {
|
/// Query parameters for time-based filtering on usage endpoints.
|
||||||
let pool = &state.app_state.db_pool;
|
#[derive(Debug, Deserialize, Default)]
|
||||||
|
pub(super) struct UsagePeriodFilter {
|
||||||
|
/// Preset period: "today", "24h", "7d", "30d", "all" (default: "all")
|
||||||
|
pub period: Option<String>,
|
||||||
|
/// Custom range start (ISO 8601, e.g. "2025-06-01T00:00:00Z")
|
||||||
|
pub from: Option<String>,
|
||||||
|
/// Custom range end (ISO 8601)
|
||||||
|
pub to: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
// Total stats
|
impl UsagePeriodFilter {
|
||||||
let total_stats = sqlx::query(
|
/// Returns `(sql_fragment, bind_values)` for a WHERE clause.
|
||||||
|
/// The fragment is either empty (no filter) or " AND timestamp >= ? [AND timestamp <= ?]".
|
||||||
|
fn to_sql(&self) -> (String, Vec<String>) {
|
||||||
|
let period = self.period.as_deref().unwrap_or("all");
|
||||||
|
|
||||||
|
if period == "custom" {
|
||||||
|
let mut clause = String::new();
|
||||||
|
let mut binds = Vec::new();
|
||||||
|
if let Some(ref from) = self.from {
|
||||||
|
clause.push_str(" AND timestamp >= ?");
|
||||||
|
binds.push(from.clone());
|
||||||
|
}
|
||||||
|
if let Some(ref to) = self.to {
|
||||||
|
clause.push_str(" AND timestamp <= ?");
|
||||||
|
binds.push(to.clone());
|
||||||
|
}
|
||||||
|
return (clause, binds);
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
let cutoff = match period {
|
||||||
|
"today" => {
|
||||||
|
// Start of today (UTC)
|
||||||
|
let today = now.format("%Y-%m-%dT00:00:00Z").to_string();
|
||||||
|
Some(today)
|
||||||
|
}
|
||||||
|
"24h" => Some((now - chrono::Duration::hours(24)).to_rfc3339()),
|
||||||
|
"7d" => Some((now - chrono::Duration::days(7)).to_rfc3339()),
|
||||||
|
"30d" => Some((now - chrono::Duration::days(30)).to_rfc3339()),
|
||||||
|
_ => None, // "all" or unrecognized
|
||||||
|
};
|
||||||
|
|
||||||
|
match cutoff {
|
||||||
|
Some(ts) => (" AND timestamp >= ?".to_string(), vec![ts]),
|
||||||
|
None => (String::new(), vec![]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determine the time-series granularity label for grouping.
|
||||||
|
fn granularity(&self) -> &'static str {
|
||||||
|
match self.period.as_deref().unwrap_or("all") {
|
||||||
|
"today" | "24h" => "hour",
|
||||||
|
_ => "day",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn handle_usage_summary(
|
||||||
|
State(state): State<DashboardState>,
|
||||||
|
Query(filter): Query<UsagePeriodFilter>,
|
||||||
|
) -> Json<ApiResponse<serde_json::Value>> {
|
||||||
|
let pool = &state.app_state.db_pool;
|
||||||
|
let (period_clause, period_binds) = filter.to_sql();
|
||||||
|
|
||||||
|
// Total stats (filtered by period)
|
||||||
|
let period_sql = format!(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) as total_requests,
|
COUNT(*) as total_requests,
|
||||||
@@ -20,9 +87,15 @@ pub(super) async fn handle_usage_summary(State(state): State<DashboardState>) ->
|
|||||||
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read,
|
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read,
|
||||||
COALESCE(SUM(cache_write_tokens), 0) as total_cache_write
|
COALESCE(SUM(cache_write_tokens), 0) as total_cache_write
|
||||||
FROM llm_requests
|
FROM llm_requests
|
||||||
|
WHERE 1=1 {}
|
||||||
"#,
|
"#,
|
||||||
)
|
period_clause
|
||||||
.fetch_one(pool);
|
);
|
||||||
|
let mut q = sqlx::query(&period_sql);
|
||||||
|
for b in &period_binds {
|
||||||
|
q = q.bind(b);
|
||||||
|
}
|
||||||
|
let total_stats = q.fetch_one(pool);
|
||||||
|
|
||||||
// Today's stats
|
// Today's stats
|
||||||
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
|
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
|
||||||
@@ -99,41 +172,61 @@ pub(super) async fn handle_usage_summary(State(state): State<DashboardState>) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn handle_time_series(State(state): State<DashboardState>) -> Json<ApiResponse<serde_json::Value>> {
|
pub(super) async fn handle_time_series(
|
||||||
|
State(state): State<DashboardState>,
|
||||||
|
Query(filter): Query<UsagePeriodFilter>,
|
||||||
|
) -> Json<ApiResponse<serde_json::Value>> {
|
||||||
let pool = &state.app_state.db_pool;
|
let pool = &state.app_state.db_pool;
|
||||||
|
let (period_clause, period_binds) = filter.to_sql();
|
||||||
|
let granularity = filter.granularity();
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
// Determine the strftime format and default lookback
|
||||||
let twenty_four_hours_ago = now - chrono::Duration::hours(24);
|
let (strftime_fmt, _label_key, default_lookback) = match granularity {
|
||||||
|
"hour" => ("%H:00", "hour", chrono::Duration::hours(24)),
|
||||||
|
_ => ("%Y-%m-%d", "day", chrono::Duration::days(30)),
|
||||||
|
};
|
||||||
|
|
||||||
let result = sqlx::query(
|
// If no period filter, apply a sensible default lookback
|
||||||
|
let (clause, binds) = if period_clause.is_empty() {
|
||||||
|
let cutoff = (chrono::Utc::now() - default_lookback).to_rfc3339();
|
||||||
|
(" AND timestamp >= ?".to_string(), vec![cutoff])
|
||||||
|
} else {
|
||||||
|
(period_clause, period_binds)
|
||||||
|
};
|
||||||
|
|
||||||
|
let sql = format!(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
strftime('%H:00', timestamp) as hour,
|
strftime('{strftime_fmt}', timestamp) as bucket,
|
||||||
COUNT(*) as requests,
|
COUNT(*) as requests,
|
||||||
COALESCE(SUM(total_tokens), 0) as tokens,
|
COALESCE(SUM(total_tokens), 0) as tokens,
|
||||||
COALESCE(SUM(cost), 0.0) as cost
|
COALESCE(SUM(cost), 0.0) as cost
|
||||||
FROM llm_requests
|
FROM llm_requests
|
||||||
WHERE timestamp >= ?
|
WHERE 1=1 {clause}
|
||||||
GROUP BY hour
|
GROUP BY bucket
|
||||||
ORDER BY hour
|
ORDER BY bucket
|
||||||
"#,
|
"#,
|
||||||
)
|
);
|
||||||
.bind(twenty_four_hours_ago)
|
|
||||||
.fetch_all(pool)
|
let mut q = sqlx::query(&sql);
|
||||||
.await;
|
for b in &binds {
|
||||||
|
q = q.bind(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = q.fetch_all(pool).await;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(rows) => {
|
Ok(rows) => {
|
||||||
let mut series = Vec::new();
|
let mut series = Vec::new();
|
||||||
|
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let hour: String = row.get("hour");
|
let bucket: String = row.get("bucket");
|
||||||
let requests: i64 = row.get("requests");
|
let requests: i64 = row.get("requests");
|
||||||
let tokens: i64 = row.get("tokens");
|
let tokens: i64 = row.get("tokens");
|
||||||
let cost: f64 = row.get("cost");
|
let cost: f64 = row.get("cost");
|
||||||
|
|
||||||
series.push(serde_json::json!({
|
series.push(serde_json::json!({
|
||||||
"time": hour,
|
"time": bucket,
|
||||||
"requests": requests,
|
"requests": requests,
|
||||||
"tokens": tokens,
|
"tokens": tokens,
|
||||||
"cost": cost,
|
"cost": cost,
|
||||||
@@ -142,7 +235,8 @@ pub(super) async fn handle_time_series(State(state): State<DashboardState>) -> J
|
|||||||
|
|
||||||
Json(ApiResponse::success(serde_json::json!({
|
Json(ApiResponse::success(serde_json::json!({
|
||||||
"series": series,
|
"series": series,
|
||||||
"period": "24h"
|
"period": filter.period.as_deref().unwrap_or("all"),
|
||||||
|
"granularity": granularity,
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -152,11 +246,14 @@ pub(super) async fn handle_time_series(State(state): State<DashboardState>) -> J
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn handle_clients_usage(State(state): State<DashboardState>) -> Json<ApiResponse<serde_json::Value>> {
|
pub(super) async fn handle_clients_usage(
|
||||||
// Query database for client usage statistics
|
State(state): State<DashboardState>,
|
||||||
|
Query(filter): Query<UsagePeriodFilter>,
|
||||||
|
) -> Json<ApiResponse<serde_json::Value>> {
|
||||||
let pool = &state.app_state.db_pool;
|
let pool = &state.app_state.db_pool;
|
||||||
|
let (period_clause, period_binds) = filter.to_sql();
|
||||||
|
|
||||||
let result = sqlx::query(
|
let sql = format!(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
client_id,
|
client_id,
|
||||||
@@ -165,12 +262,19 @@ pub(super) async fn handle_clients_usage(State(state): State<DashboardState>) ->
|
|||||||
COALESCE(SUM(cost), 0.0) as cost,
|
COALESCE(SUM(cost), 0.0) as cost,
|
||||||
MAX(timestamp) as last_request
|
MAX(timestamp) as last_request
|
||||||
FROM llm_requests
|
FROM llm_requests
|
||||||
|
WHERE 1=1 {}
|
||||||
GROUP BY client_id
|
GROUP BY client_id
|
||||||
ORDER BY requests DESC
|
ORDER BY requests DESC
|
||||||
"#,
|
"#,
|
||||||
)
|
period_clause
|
||||||
.fetch_all(pool)
|
);
|
||||||
.await;
|
|
||||||
|
let mut q = sqlx::query(&sql);
|
||||||
|
for b in &period_binds {
|
||||||
|
q = q.bind(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = q.fetch_all(pool).await;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(rows) => {
|
Ok(rows) => {
|
||||||
@@ -204,11 +308,12 @@ pub(super) async fn handle_clients_usage(State(state): State<DashboardState>) ->
|
|||||||
|
|
||||||
pub(super) async fn handle_providers_usage(
|
pub(super) async fn handle_providers_usage(
|
||||||
State(state): State<DashboardState>,
|
State(state): State<DashboardState>,
|
||||||
|
Query(filter): Query<UsagePeriodFilter>,
|
||||||
) -> Json<ApiResponse<serde_json::Value>> {
|
) -> Json<ApiResponse<serde_json::Value>> {
|
||||||
// Query database for provider usage statistics
|
|
||||||
let pool = &state.app_state.db_pool;
|
let pool = &state.app_state.db_pool;
|
||||||
|
let (period_clause, period_binds) = filter.to_sql();
|
||||||
|
|
||||||
let result = sqlx::query(
|
let sql = format!(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
provider,
|
provider,
|
||||||
@@ -218,12 +323,19 @@ pub(super) async fn handle_providers_usage(
|
|||||||
COALESCE(SUM(cache_read_tokens), 0) as cache_read,
|
COALESCE(SUM(cache_read_tokens), 0) as cache_read,
|
||||||
COALESCE(SUM(cache_write_tokens), 0) as cache_write
|
COALESCE(SUM(cache_write_tokens), 0) as cache_write
|
||||||
FROM llm_requests
|
FROM llm_requests
|
||||||
|
WHERE 1=1 {}
|
||||||
GROUP BY provider
|
GROUP BY provider
|
||||||
ORDER BY requests DESC
|
ORDER BY requests DESC
|
||||||
"#,
|
"#,
|
||||||
)
|
period_clause
|
||||||
.fetch_all(pool)
|
);
|
||||||
.await;
|
|
||||||
|
let mut q = sqlx::query(&sql);
|
||||||
|
for b in &period_binds {
|
||||||
|
q = q.bind(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = q.fetch_all(pool).await;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(rows) => {
|
Ok(rows) => {
|
||||||
@@ -256,10 +368,14 @@ pub(super) async fn handle_providers_usage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn handle_detailed_usage(State(state): State<DashboardState>) -> Json<ApiResponse<serde_json::Value>> {
|
pub(super) async fn handle_detailed_usage(
|
||||||
|
State(state): State<DashboardState>,
|
||||||
|
Query(filter): Query<UsagePeriodFilter>,
|
||||||
|
) -> Json<ApiResponse<serde_json::Value>> {
|
||||||
let pool = &state.app_state.db_pool;
|
let pool = &state.app_state.db_pool;
|
||||||
|
let (period_clause, period_binds) = filter.to_sql();
|
||||||
|
|
||||||
let result = sqlx::query(
|
let sql = format!(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
strftime('%Y-%m-%d', timestamp) as date,
|
strftime('%Y-%m-%d', timestamp) as date,
|
||||||
@@ -272,13 +388,20 @@ pub(super) async fn handle_detailed_usage(State(state): State<DashboardState>) -
|
|||||||
COALESCE(SUM(cache_read_tokens), 0) as cache_read,
|
COALESCE(SUM(cache_read_tokens), 0) as cache_read,
|
||||||
COALESCE(SUM(cache_write_tokens), 0) as cache_write
|
COALESCE(SUM(cache_write_tokens), 0) as cache_write
|
||||||
FROM llm_requests
|
FROM llm_requests
|
||||||
|
WHERE 1=1 {}
|
||||||
GROUP BY date, client_id, provider, model
|
GROUP BY date, client_id, provider, model
|
||||||
ORDER BY date DESC
|
ORDER BY date DESC
|
||||||
LIMIT 100
|
LIMIT 200
|
||||||
"#,
|
"#,
|
||||||
)
|
period_clause
|
||||||
.fetch_all(pool)
|
);
|
||||||
.await;
|
|
||||||
|
let mut q = sqlx::query(&sql);
|
||||||
|
for b in &period_binds {
|
||||||
|
q = q.bind(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = q.fetch_all(pool).await;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(rows) => {
|
Ok(rows) => {
|
||||||
@@ -310,19 +433,32 @@ pub(super) async fn handle_detailed_usage(State(state): State<DashboardState>) -
|
|||||||
|
|
||||||
pub(super) async fn handle_analytics_breakdown(
|
pub(super) async fn handle_analytics_breakdown(
|
||||||
State(state): State<DashboardState>,
|
State(state): State<DashboardState>,
|
||||||
|
Query(filter): Query<UsagePeriodFilter>,
|
||||||
) -> Json<ApiResponse<serde_json::Value>> {
|
) -> Json<ApiResponse<serde_json::Value>> {
|
||||||
let pool = &state.app_state.db_pool;
|
let pool = &state.app_state.db_pool;
|
||||||
|
let (period_clause, period_binds) = filter.to_sql();
|
||||||
|
|
||||||
// Model breakdown
|
// Model breakdown
|
||||||
let models =
|
let model_sql = format!(
|
||||||
sqlx::query("SELECT model as label, COUNT(*) as value FROM llm_requests GROUP BY model ORDER BY value DESC")
|
"SELECT model as label, COUNT(*) as value FROM llm_requests WHERE 1=1 {} GROUP BY model ORDER BY value DESC",
|
||||||
.fetch_all(pool);
|
period_clause
|
||||||
|
);
|
||||||
|
let mut mq = sqlx::query(&model_sql);
|
||||||
|
for b in &period_binds {
|
||||||
|
mq = mq.bind(b);
|
||||||
|
}
|
||||||
|
let models = mq.fetch_all(pool);
|
||||||
|
|
||||||
// Client breakdown
|
// Client breakdown
|
||||||
let clients = sqlx::query(
|
let client_sql = format!(
|
||||||
"SELECT client_id as label, COUNT(*) as value FROM llm_requests GROUP BY client_id ORDER BY value DESC",
|
"SELECT client_id as label, COUNT(*) as value FROM llm_requests WHERE 1=1 {} GROUP BY client_id ORDER BY value DESC",
|
||||||
)
|
period_clause
|
||||||
.fetch_all(pool);
|
);
|
||||||
|
let mut cq = sqlx::query(&client_sql);
|
||||||
|
for b in &period_binds {
|
||||||
|
cq = cq.bind(b);
|
||||||
|
}
|
||||||
|
let clients = cq.fetch_all(pool);
|
||||||
|
|
||||||
match tokio::join!(models, clients) {
|
match tokio::join!(models, clients) {
|
||||||
(Ok(m_rows), Ok(c_rows)) => {
|
(Ok(m_rows), Ok(c_rows)) => {
|
||||||
|
|||||||
@@ -1069,6 +1069,28 @@ body {
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Period Selector */
|
||||||
|
.period-selector {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.period-selector .btn.active {
|
||||||
|
background: var(--blue);
|
||||||
|
color: var(--bg0);
|
||||||
|
border-color: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-sm {
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background: var(--bg1);
|
||||||
|
border: 1px solid var(--bg3);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--fg1);
|
||||||
|
}
|
||||||
|
|
||||||
/* Toast Container */
|
/* Toast Container */
|
||||||
.toast-container {
|
.toast-container {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>LLM Proxy Gateway - Admin Dashboard</title>
|
<title>LLM Proxy Gateway - Admin Dashboard</title>
|
||||||
<link rel="stylesheet" href="/css/dashboard.css?v=5">
|
<link rel="stylesheet" href="/css/dashboard.css?v=6">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
<link rel="icon" href="img/logo-icon.png" type="image/png" sizes="any">
|
<link rel="icon" href="img/logo-icon.png" type="image/png" sizes="any">
|
||||||
<link rel="apple-touch-icon" href="img/logo-icon.png">
|
<link rel="apple-touch-icon" href="img/logo-icon.png">
|
||||||
@@ -166,19 +166,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scripts (cache-busted with version query params) -->
|
<!-- Scripts (cache-busted with version query params) -->
|
||||||
<script src="/js/api.js?v=5"></script>
|
<script src="/js/api.js?v=6"></script>
|
||||||
<script src="/js/auth.js?v=5"></script>
|
<script src="/js/auth.js?v=6"></script>
|
||||||
<script src="/js/dashboard.js?v=5"></script>
|
<script src="/js/dashboard.js?v=6"></script>
|
||||||
<script src="/js/websocket.js?v=5"></script>
|
<script src="/js/websocket.js?v=6"></script>
|
||||||
<script src="/js/charts.js?v=5"></script>
|
<script src="/js/charts.js?v=6"></script>
|
||||||
<script src="/js/pages/overview.js?v=5"></script>
|
<script src="/js/pages/overview.js?v=6"></script>
|
||||||
<script src="/js/pages/analytics.js?v=5"></script>
|
<script src="/js/pages/analytics.js?v=6"></script>
|
||||||
<script src="/js/pages/costs.js?v=5"></script>
|
<script src="/js/pages/costs.js?v=6"></script>
|
||||||
<script src="/js/pages/clients.js?v=5"></script>
|
<script src="/js/pages/clients.js?v=6"></script>
|
||||||
<script src="/js/pages/providers.js?v=5"></script>
|
<script src="/js/pages/providers.js?v=6"></script>
|
||||||
<script src="/js/pages/models.js?v=5"></script>
|
<script src="/js/pages/models.js?v=6"></script>
|
||||||
<script src="/js/pages/monitoring.js?v=5"></script>
|
<script src="/js/pages/monitoring.js?v=6"></script>
|
||||||
<script src="/js/pages/settings.js?v=5"></script>
|
<script src="/js/pages/settings.js?v=6"></script>
|
||||||
<script src="/js/pages/logs.js?v=5"></script>
|
<script src="/js/pages/logs.js?v=6"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -317,6 +317,15 @@ class Dashboard {
|
|||||||
<p class="card-subtitle">Request volume and token distribution</p>
|
<p class="card-subtitle">Request volume and token distribution</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
|
<div class="period-selector" id="analytics-period">
|
||||||
|
<button class="btn btn-secondary btn-sm active" data-period="24h">24h</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" data-period="7d">7 Days</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" data-period="30d">30 Days</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" data-period="all">All</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" data-period="custom">
|
||||||
|
<i class="fas fa-calendar"></i> Custom
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button class="btn btn-secondary" id="export-data">
|
<button class="btn btn-secondary" id="export-data">
|
||||||
<i class="fas fa-download"></i> Export CSV
|
<i class="fas fa-download"></i> Export CSV
|
||||||
</button>
|
</button>
|
||||||
@@ -325,6 +334,13 @@ class Dashboard {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="analytics-custom-range" style="display:none; padding: 0 1.5rem 1rem; gap: 0.5rem; align-items: center;">
|
||||||
|
<label style="font-size:0.85rem; color:var(--fg4);">From</label>
|
||||||
|
<input type="date" id="analytics-from" class="input-sm">
|
||||||
|
<label style="font-size:0.85rem; color:var(--fg4);">To</label>
|
||||||
|
<input type="date" id="analytics-to" class="input-sm">
|
||||||
|
<button class="btn btn-primary btn-sm" id="analytics-apply-custom">Apply</button>
|
||||||
|
</div>
|
||||||
<div class="chart-container" style="height: 400px;">
|
<div class="chart-container" style="height: 400px;">
|
||||||
<canvas id="analytics-chart"></canvas>
|
<canvas id="analytics-chart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
@@ -359,6 +375,33 @@ class Dashboard {
|
|||||||
|
|
||||||
getCostsTemplate() {
|
getCostsTemplate() {
|
||||||
return `
|
return `
|
||||||
|
<div class="card" style="margin-bottom: 1.5rem;">
|
||||||
|
<div class="card-header">
|
||||||
|
<div>
|
||||||
|
<h3 class="card-title">Cost Overview</h3>
|
||||||
|
<p class="card-subtitle">Spending and pricing across providers</p>
|
||||||
|
</div>
|
||||||
|
<div class="card-actions">
|
||||||
|
<div class="period-selector" id="costs-period">
|
||||||
|
<button class="btn btn-secondary btn-sm" data-period="today">Today</button>
|
||||||
|
<button class="btn btn-secondary btn-sm active" data-period="7d">7 Days</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" data-period="30d">30 Days</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" data-period="all">All Time</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" data-period="custom">
|
||||||
|
<i class="fas fa-calendar"></i> Custom
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="costs-custom-range" style="display:none; padding: 0 1.5rem 1rem; gap: 0.5rem; align-items: center;">
|
||||||
|
<label style="font-size:0.85rem; color:var(--fg4);">From</label>
|
||||||
|
<input type="date" id="costs-from" class="input-sm">
|
||||||
|
<label style="font-size:0.85rem; color:var(--fg4);">To</label>
|
||||||
|
<input type="date" id="costs-to" class="input-sm">
|
||||||
|
<button class="btn btn-primary btn-sm" id="costs-apply-custom">Apply</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="stats-grid" id="cost-stats"></div>
|
<div class="stats-grid" id="cost-stats"></div>
|
||||||
|
|
||||||
<div class="grid-2">
|
<div class="grid-2">
|
||||||
@@ -378,7 +421,7 @@ class Dashboard {
|
|||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3 class="card-title">Active Model Pricing</h3>
|
<h3 class="card-title">Model Pricing (Used Models)</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table class="table" id="pricing-table">
|
<table class="table" id="pricing-table">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
class AnalyticsPage {
|
class AnalyticsPage {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.filters = {
|
this.filters = {
|
||||||
dateRange: '7d',
|
dateRange: '24h',
|
||||||
client: 'all',
|
client: 'all',
|
||||||
provider: 'all'
|
provider: 'all'
|
||||||
};
|
};
|
||||||
@@ -11,17 +11,29 @@ class AnalyticsPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
// Load data
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.loadClients(),
|
this.loadClients(),
|
||||||
this.loadCharts(),
|
this.loadCharts(),
|
||||||
this.loadUsageData()
|
this.loadUsageData()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Setup event listeners
|
|
||||||
this.setupEventListeners();
|
this.setupEventListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Build query string from current period filter. */
|
||||||
|
periodQuery() {
|
||||||
|
const p = this.filters.dateRange;
|
||||||
|
if (p === 'custom') {
|
||||||
|
const from = document.getElementById('analytics-from')?.value;
|
||||||
|
const to = document.getElementById('analytics-to')?.value;
|
||||||
|
let qs = '?period=custom';
|
||||||
|
if (from) qs += `&from=${from}T00:00:00Z`;
|
||||||
|
if (to) qs += `&to=${to}T23:59:59Z`;
|
||||||
|
return qs;
|
||||||
|
}
|
||||||
|
return `?period=${p}`;
|
||||||
|
}
|
||||||
|
|
||||||
async loadClients() {
|
async loadClients() {
|
||||||
try {
|
try {
|
||||||
const clients = await window.api.get('/clients');
|
const clients = await window.api.get('/clients');
|
||||||
@@ -34,13 +46,11 @@ class AnalyticsPage {
|
|||||||
renderClientFilter(clients) {
|
renderClientFilter(clients) {
|
||||||
const select = document.getElementById('client-filter');
|
const select = document.getElementById('client-filter');
|
||||||
if (!select) return;
|
if (!select) return;
|
||||||
|
|
||||||
// Clear existing options except "All Clients"
|
|
||||||
while (select.options.length > 1) {
|
while (select.options.length > 1) {
|
||||||
select.remove(1);
|
select.remove(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add client options
|
|
||||||
clients.forEach(client => {
|
clients.forEach(client => {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = client.id;
|
option.value = client.id;
|
||||||
@@ -58,19 +68,21 @@ class AnalyticsPage {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch each data source independently so one failure doesn't kill the others
|
const qs = this.periodQuery();
|
||||||
|
|
||||||
const [timeSeriesResult, breakdownResult] = await Promise.allSettled([
|
const [timeSeriesResult, breakdownResult] = await Promise.allSettled([
|
||||||
window.api.get('/usage/time-series'),
|
window.api.get(`/usage/time-series${qs}`),
|
||||||
window.api.get('/analytics/breakdown')
|
window.api.get(`/analytics/breakdown${qs}`)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Time-series chart
|
// Time-series chart
|
||||||
if (timeSeriesResult.status === 'fulfilled') {
|
if (timeSeriesResult.status === 'fulfilled') {
|
||||||
const series = timeSeriesResult.value.series || [];
|
const resp = timeSeriesResult.value;
|
||||||
|
const series = resp.series || [];
|
||||||
if (series.length > 0) {
|
if (series.length > 0) {
|
||||||
this.renderAnalyticsChart(series);
|
this.renderAnalyticsChart(series, resp.granularity);
|
||||||
} else {
|
} else {
|
||||||
this.showEmptyChart('analytics-chart', 'No request data in the last 24 hours');
|
this.showEmptyChart('analytics-chart', 'No request data for this period');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error('Error loading time series:', timeSeriesResult.reason);
|
console.error('Error loading time series:', timeSeriesResult.reason);
|
||||||
@@ -86,13 +98,13 @@ class AnalyticsPage {
|
|||||||
if (clients.length > 0) {
|
if (clients.length > 0) {
|
||||||
this.renderClientsChart(clients);
|
this.renderClientsChart(clients);
|
||||||
} else {
|
} else {
|
||||||
this.showEmptyChart('clients-chart', 'No client data available');
|
this.showEmptyChart('clients-chart', 'No client data for this period');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (models.length > 0) {
|
if (models.length > 0) {
|
||||||
this.renderModelsChart(models);
|
this.renderModelsChart(models);
|
||||||
} else {
|
} else {
|
||||||
this.showEmptyChart('models-chart', 'No model data available');
|
this.showEmptyChart('models-chart', 'No model data for this period');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error('Error loading analytics breakdown:', breakdownResult.reason);
|
console.error('Error loading analytics breakdown:', breakdownResult.reason);
|
||||||
@@ -118,28 +130,38 @@ class AnalyticsPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
renderAnalyticsChart(series) {
|
renderAnalyticsChart(series, granularity) {
|
||||||
const cm = window.chartManager;
|
const cm = window.chartManager;
|
||||||
if (!cm) return;
|
if (!cm) return;
|
||||||
|
|
||||||
|
const labels = series.map(s => {
|
||||||
|
// For daily granularity, shorten the date label
|
||||||
|
if (granularity === 'day') {
|
||||||
|
const d = luxon.DateTime.fromISO(s.time);
|
||||||
|
return d.isValid ? d.toFormat('MMM dd') : s.time;
|
||||||
|
}
|
||||||
|
return s.time;
|
||||||
|
});
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
labels: series.map(s => s.time),
|
labels,
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: 'Requests',
|
label: 'Requests',
|
||||||
data: series.map(s => s.requests),
|
data: series.map(s => s.requests),
|
||||||
color: '#fe8019', // orange
|
color: '#fe8019',
|
||||||
fill: true
|
fill: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Tokens',
|
label: 'Tokens',
|
||||||
data: series.map(s => s.tokens),
|
data: series.map(s => s.tokens),
|
||||||
color: '#b8bb26', // green
|
color: '#b8bb26',
|
||||||
fill: true,
|
fill: true,
|
||||||
hidden: true
|
hidden: true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
cm.createLineChart('analytics-chart', data);
|
cm.createLineChart('analytics-chart', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,10 +173,10 @@ class AnalyticsPage {
|
|||||||
datasets: [{
|
datasets: [{
|
||||||
label: 'Requests',
|
label: 'Requests',
|
||||||
data: clients.map(c => c.value),
|
data: clients.map(c => c.value),
|
||||||
color: '#83a598' // blue
|
color: '#83a598'
|
||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
|
|
||||||
cm.createHorizontalBarChart('clients-chart', data);
|
cm.createHorizontalBarChart('clients-chart', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,13 +188,14 @@ class AnalyticsPage {
|
|||||||
data: models.map(m => m.value),
|
data: models.map(m => m.value),
|
||||||
colors: cm.defaultColors
|
colors: cm.defaultColors
|
||||||
};
|
};
|
||||||
|
|
||||||
cm.createDoughnutChart('models-chart', data);
|
cm.createDoughnutChart('models-chart', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadUsageData() {
|
async loadUsageData() {
|
||||||
try {
|
try {
|
||||||
const usageData = await window.api.get('/usage/detailed');
|
const qs = this.periodQuery();
|
||||||
|
const usageData = await window.api.get(`/usage/detailed${qs}`);
|
||||||
this.renderUsageTable(usageData);
|
this.renderUsageTable(usageData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading usage data:', error);
|
console.error('Error loading usage data:', error);
|
||||||
@@ -182,9 +205,9 @@ class AnalyticsPage {
|
|||||||
renderUsageTable(data) {
|
renderUsageTable(data) {
|
||||||
const tableBody = document.querySelector('#usage-table tbody');
|
const tableBody = document.querySelector('#usage-table tbody');
|
||||||
if (!tableBody) return;
|
if (!tableBody) return;
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
tableBody.innerHTML = '<tr><td colspan="9" class="text-center">No historical data found</td></tr>';
|
tableBody.innerHTML = '<tr><td colspan="9" class="text-center">No usage data for this period</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,17 +235,47 @@ class AnalyticsPage {
|
|||||||
if (refreshBtn) {
|
if (refreshBtn) {
|
||||||
refreshBtn.onclick = () => this.refresh();
|
refreshBtn.onclick = () => this.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export button
|
|
||||||
const exportBtn = document.getElementById('export-data');
|
const exportBtn = document.getElementById('export-data');
|
||||||
if (exportBtn) {
|
if (exportBtn) {
|
||||||
exportBtn.onclick = () => this.exportData();
|
exportBtn.onclick = () => this.exportData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Period selector buttons
|
||||||
|
const periodContainer = document.getElementById('analytics-period');
|
||||||
|
if (periodContainer) {
|
||||||
|
periodContainer.querySelectorAll('[data-period]').forEach(btn => {
|
||||||
|
btn.onclick = () => {
|
||||||
|
// Toggle active class
|
||||||
|
periodContainer.querySelectorAll('[data-period]').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
|
||||||
|
const period = btn.dataset.period;
|
||||||
|
this.filters.dateRange = period;
|
||||||
|
|
||||||
|
// Show/hide custom range inputs
|
||||||
|
const customRange = document.getElementById('analytics-custom-range');
|
||||||
|
if (customRange) {
|
||||||
|
customRange.style.display = period === 'custom' ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (period !== 'custom') {
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom range apply button
|
||||||
|
const applyCustom = document.getElementById('analytics-apply-custom');
|
||||||
|
if (applyCustom) {
|
||||||
|
applyCustom.onclick = () => this.refresh();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async exportData() {
|
async exportData() {
|
||||||
// Simple CSV export
|
const qs = this.periodQuery();
|
||||||
const data = await window.api.get('/usage/detailed');
|
const data = await window.api.get(`/usage/detailed${qs}`);
|
||||||
if (!data || data.length === 0) return;
|
if (!data || data.length === 0) return;
|
||||||
|
|
||||||
const headers = Object.keys(data[0]).join(',');
|
const headers = Object.keys(data[0]).join(',');
|
||||||
|
|||||||
@@ -3,42 +3,73 @@
|
|||||||
class CostsPage {
|
class CostsPage {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.costData = null;
|
this.costData = null;
|
||||||
|
this.period = '7d';
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
// Load data
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.loadCostStats(),
|
this.loadCostStats(),
|
||||||
this.loadCostsChart(),
|
this.loadCostsChart(),
|
||||||
this.loadBudgetTracking(),
|
this.loadBudgetTracking(),
|
||||||
this.loadPricingTable()
|
this.loadPricingTable()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Setup event listeners
|
|
||||||
this.setupEventListeners();
|
this.setupEventListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Build query string from the current period. */
|
||||||
|
periodQuery() {
|
||||||
|
if (this.period === 'custom') {
|
||||||
|
const from = document.getElementById('costs-from')?.value;
|
||||||
|
const to = document.getElementById('costs-to')?.value;
|
||||||
|
let qs = '?period=custom';
|
||||||
|
if (from) qs += `&from=${from}T00:00:00Z`;
|
||||||
|
if (to) qs += `&to=${to}T23:59:59Z`;
|
||||||
|
return qs;
|
||||||
|
}
|
||||||
|
return `?period=${this.period}`;
|
||||||
|
}
|
||||||
|
|
||||||
async loadCostStats() {
|
async loadCostStats() {
|
||||||
try {
|
try {
|
||||||
const data = await window.api.get('/usage/summary');
|
const qs = this.periodQuery();
|
||||||
|
const data = await window.api.get(`/usage/summary${qs}`);
|
||||||
|
|
||||||
|
const totalCost = data.total_cost;
|
||||||
|
const totalTokens = data.total_tokens || 0;
|
||||||
|
const cacheReadTokens = data.total_cache_read_tokens || 0;
|
||||||
|
const cacheWriteTokens = data.total_cache_write_tokens || 0;
|
||||||
|
const todayCost = data.today_cost;
|
||||||
|
const totalRequests = data.total_requests;
|
||||||
|
|
||||||
|
// Compute days in the period for daily average
|
||||||
|
let periodDays = 1;
|
||||||
|
if (this.period === '7d') periodDays = 7;
|
||||||
|
else if (this.period === '30d') periodDays = 30;
|
||||||
|
else if (this.period === 'today') periodDays = 1;
|
||||||
|
else if (this.period === 'all') periodDays = Math.max(1, 30); // rough fallback
|
||||||
|
else if (this.period === 'custom') {
|
||||||
|
const from = document.getElementById('costs-from')?.value;
|
||||||
|
const to = document.getElementById('costs-to')?.value;
|
||||||
|
if (from && to) {
|
||||||
|
const diff = (new Date(to) - new Date(from)) / (1000 * 60 * 60 * 24);
|
||||||
|
periodDays = Math.max(1, Math.ceil(diff));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.costData = {
|
this.costData = {
|
||||||
totalCost: data.total_cost,
|
totalCost,
|
||||||
todayCost: data.today_cost,
|
todayCost,
|
||||||
weekCost: data.total_cost * 0.4, // Placeholder for weekly logic
|
totalRequests,
|
||||||
monthCost: data.total_cost,
|
avgDailyCost: totalCost / periodDays,
|
||||||
avgDailyCost: data.total_cost / 30, // Simplified
|
cacheReadTokens,
|
||||||
costTrend: 5.2,
|
cacheWriteTokens,
|
||||||
budgetUsed: Math.min(Math.round((data.total_cost / 100) * 100), 100), // Assuming $100 budget
|
totalTokens,
|
||||||
projectedMonthEnd: data.today_cost * 30,
|
|
||||||
cacheReadTokens: data.total_cache_read_tokens || 0,
|
|
||||||
cacheWriteTokens: data.total_cache_write_tokens || 0,
|
|
||||||
totalTokens: data.total_tokens || 0,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
this.renderCostStats();
|
this.renderCostStats();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading cost stats:', error);
|
console.error('Error loading cost stats:', error);
|
||||||
}
|
}
|
||||||
@@ -51,7 +82,15 @@ class CostsPage {
|
|||||||
const cacheHitRate = this.costData.totalTokens > 0
|
const cacheHitRate = this.costData.totalTokens > 0
|
||||||
? ((this.costData.cacheReadTokens / this.costData.totalTokens) * 100).toFixed(1)
|
? ((this.costData.cacheReadTokens / this.costData.totalTokens) * 100).toFixed(1)
|
||||||
: '0.0';
|
: '0.0';
|
||||||
|
|
||||||
|
const periodLabel = {
|
||||||
|
'today': 'Today',
|
||||||
|
'7d': 'Past 7 Days',
|
||||||
|
'30d': 'Past 30 Days',
|
||||||
|
'all': 'All Time',
|
||||||
|
'custom': 'Custom Range',
|
||||||
|
}[this.period] || this.period;
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-icon warning">
|
<div class="stat-icon warning">
|
||||||
@@ -66,21 +105,34 @@ class CostsPage {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-icon success">
|
<div class="stat-icon success">
|
||||||
<i class="fas fa-calendar-alt"></i>
|
<i class="fas fa-calendar-alt"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-content">
|
<div class="stat-content">
|
||||||
<div class="stat-value">${window.api.formatCurrency(this.costData.monthCost)}</div>
|
<div class="stat-value">${periodLabel}</div>
|
||||||
<div class="stat-label">This Month</div>
|
<div class="stat-label">Period</div>
|
||||||
<div class="stat-change">
|
<div class="stat-change">
|
||||||
<i class="fas fa-chart-line"></i>
|
${this.costData.totalRequests.toLocaleString()} requests
|
||||||
${window.api.formatCurrency(this.costData.avgDailyCost)}/day avg
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon primary">
|
||||||
|
<i class="fas fa-chart-line"></i>
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-value">${window.api.formatCurrency(this.costData.avgDailyCost)}</div>
|
||||||
|
<div class="stat-label">Daily Average</div>
|
||||||
|
<div class="stat-change">
|
||||||
|
<i class="fas fa-bolt"></i>
|
||||||
|
${cacheHitRate}% cache hit rate
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-icon primary">
|
<div class="stat-icon primary">
|
||||||
<i class="fas fa-bolt"></i>
|
<i class="fas fa-bolt"></i>
|
||||||
@@ -93,19 +145,6 @@ class CostsPage {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-icon danger">
|
|
||||||
<i class="fas fa-piggy-bank"></i>
|
|
||||||
</div>
|
|
||||||
<div class="stat-content">
|
|
||||||
<div class="stat-value">${this.costData.budgetUsed}%</div>
|
|
||||||
<div class="stat-label">Budget Used</div>
|
|
||||||
<div class="stat-change">
|
|
||||||
$${this.costData.projectedMonthEnd.toFixed(2)} projected
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,13 +153,14 @@ class CostsPage {
|
|||||||
const cm = window.chartManager || await window.waitForChartManager();
|
const cm = window.chartManager || await window.waitForChartManager();
|
||||||
if (!cm) { this.showEmptyChart('costs-chart', 'Chart system unavailable'); return; }
|
if (!cm) { this.showEmptyChart('costs-chart', 'Chart system unavailable'); return; }
|
||||||
|
|
||||||
const data = await window.api.get('/usage/providers');
|
const qs = this.periodQuery();
|
||||||
|
const data = await window.api.get(`/usage/providers${qs}`);
|
||||||
|
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
this.showEmptyChart('costs-chart', 'No provider spending data yet');
|
this.showEmptyChart('costs-chart', 'No provider spending data for this period');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chartData = {
|
const chartData = {
|
||||||
labels: data.map(item => item.provider),
|
labels: data.map(item => item.provider),
|
||||||
datasets: [{
|
datasets: [{
|
||||||
@@ -129,9 +169,9 @@ class CostsPage {
|
|||||||
color: '#fe8019'
|
color: '#fe8019'
|
||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
|
|
||||||
cm.createBarChart('costs-chart', chartData);
|
cm.createBarChart('costs-chart', chartData);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading costs chart:', error);
|
console.error('Error loading costs chart:', error);
|
||||||
this.showEmptyChart('costs-chart', 'Failed to load spending data');
|
this.showEmptyChart('costs-chart', 'Failed to load spending data');
|
||||||
@@ -158,15 +198,15 @@ class CostsPage {
|
|||||||
async loadBudgetTracking() {
|
async loadBudgetTracking() {
|
||||||
const container = document.getElementById('budget-progress');
|
const container = document.getElementById('budget-progress');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const providers = await window.api.get('/providers');
|
const providers = await window.api.get('/providers');
|
||||||
|
|
||||||
container.innerHTML = providers.filter(p => p.id !== 'ollama').map(provider => {
|
container.innerHTML = providers.filter(p => p.id !== 'ollama').map(provider => {
|
||||||
const used = provider.low_credit_threshold; // Not quite right but using available fields
|
const used = provider.low_credit_threshold;
|
||||||
const balance = provider.credit_balance;
|
const balance = provider.credit_balance;
|
||||||
const percentage = balance > 0 ? Math.max(0, Math.min(100, (1 - (used / balance)) * 100)) : 0;
|
const percentage = balance > 0 ? Math.max(0, Math.min(100, (1 - (used / balance)) * 100)) : 0;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="budget-item" style="margin-bottom: 1.5rem;">
|
<div class="budget-item" style="margin-bottom: 1.5rem;">
|
||||||
<div class="budget-header" style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
|
<div class="budget-header" style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
|
||||||
@@ -190,7 +230,8 @@ class CostsPage {
|
|||||||
|
|
||||||
async loadPricingTable() {
|
async loadPricingTable() {
|
||||||
try {
|
try {
|
||||||
const data = await window.api.get('/models');
|
// Only show models that have actually been used
|
||||||
|
const data = await window.api.get('/models?used_only=true');
|
||||||
this.renderPricingTable(data);
|
this.renderPricingTable(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading pricing data:', error);
|
console.error('Error loading pricing data:', error);
|
||||||
@@ -200,7 +241,12 @@ class CostsPage {
|
|||||||
renderPricingTable(data) {
|
renderPricingTable(data) {
|
||||||
const tableBody = document.querySelector('#pricing-table tbody');
|
const tableBody = document.querySelector('#pricing-table tbody');
|
||||||
if (!tableBody) return;
|
if (!tableBody) return;
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
tableBody.innerHTML = '<tr><td colspan="6" class="text-center" style="color:var(--fg4);">No models have been used yet</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
tableBody.innerHTML = data.map(row => {
|
tableBody.innerHTML = data.map(row => {
|
||||||
const cacheRead = row.cache_read_cost != null
|
const cacheRead = row.cache_read_cost != null
|
||||||
? `${window.api.formatCurrency(row.cache_read_cost)} / 1M`
|
? `${window.api.formatCurrency(row.cache_read_cost)} / 1M`
|
||||||
@@ -222,14 +268,42 @@ class CostsPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setupEventListeners() {
|
setupEventListeners() {
|
||||||
// ...
|
// Period selector buttons
|
||||||
|
const periodContainer = document.getElementById('costs-period');
|
||||||
|
if (periodContainer) {
|
||||||
|
periodContainer.querySelectorAll('[data-period]').forEach(btn => {
|
||||||
|
btn.onclick = () => {
|
||||||
|
periodContainer.querySelectorAll('[data-period]').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
|
||||||
|
const period = btn.dataset.period;
|
||||||
|
this.period = period;
|
||||||
|
|
||||||
|
// Show/hide custom range inputs
|
||||||
|
const customRange = document.getElementById('costs-custom-range');
|
||||||
|
if (customRange) {
|
||||||
|
customRange.style.display = period === 'custom' ? 'flex' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (period !== 'custom') {
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom range apply button
|
||||||
|
const applyCustom = document.getElementById('costs-apply-custom');
|
||||||
|
if (applyCustom) {
|
||||||
|
applyCustom.onclick = () => this.refresh();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh() {
|
refresh() {
|
||||||
this.loadCostStats();
|
this.loadCostStats();
|
||||||
this.loadCostsChart();
|
this.loadCostsChart();
|
||||||
this.loadBudgetTracking();
|
this.loadBudgetTracking();
|
||||||
this.loadPricingTable();
|
// Pricing table doesn't change with period (it's model metadata, not usage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user