Legger til to nye API-endepunkter for brukersynlig ressursforbruk: - GET /my/usage — brukerens eget forbruk (filtrert på triggered_by) - GET /query/node_usage — forbruk for én node (kun eier/admin) Frontend: - /profile — profilside med grafstatistikk og forbruksoversikt - NodeUsage-komponent integrert i samlings-detaljsiden - Brukernavn i header lenker nå til profilsiden Tilgangssjekk: nodeforbruk krever created_by eller owner/admin-edge. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
355 lines
12 KiB
Rust
355 lines
12 KiB
Rust
// Brukersynlig forbruk — oppgave 15.9
|
|
//
|
|
// To endepunkter:
|
|
// GET /my/usage?days=30 — innlogget brukers eget forbruk
|
|
// GET /query/node_usage?node_id=<uuid>&days=30 — forbruk for en spesifikk node (kun eier)
|
|
//
|
|
// Bygger på resource_usage_log (oppgave 15.7) med filtrering på
|
|
// triggered_by (bruker) og target_node_id (node).
|
|
//
|
|
// Ref: docs/features/ressursforbruk.md
|
|
|
|
use axum::extract::State;
|
|
use axum::http::StatusCode;
|
|
use axum::Json;
|
|
use serde::{Deserialize, Serialize};
|
|
use sqlx::PgPool;
|
|
use uuid::Uuid;
|
|
|
|
use crate::auth::AuthUser;
|
|
use crate::AppState;
|
|
|
|
// =============================================================================
|
|
// Datatyper
|
|
// =============================================================================
|
|
|
|
/// Aggregert forbruk per ressurstype for én bruker eller node.
|
|
#[derive(Serialize, sqlx::FromRow)]
|
|
pub struct ResourceTypeSummary {
|
|
pub resource_type: String,
|
|
pub event_count: i64,
|
|
pub total_value: f64,
|
|
pub secondary_value: f64,
|
|
}
|
|
|
|
/// Daglig tidsserie for bruker/node.
|
|
#[derive(Serialize, sqlx::FromRow)]
|
|
pub struct DailyUsage {
|
|
pub day: chrono::DateTime<chrono::Utc>,
|
|
pub resource_type: String,
|
|
pub event_count: i64,
|
|
pub total_value: f64,
|
|
}
|
|
|
|
/// Grafstatistikk: noder og edges opprettet.
|
|
#[derive(Serialize)]
|
|
pub struct GraphStats {
|
|
pub nodes_created: i64,
|
|
pub edges_created: i64,
|
|
}
|
|
|
|
/// Samlet respons for brukerforbruk.
|
|
#[derive(Serialize)]
|
|
pub struct UserUsageResponse {
|
|
pub by_type: Vec<ResourceTypeSummary>,
|
|
pub daily: Vec<DailyUsage>,
|
|
pub graph: GraphStats,
|
|
}
|
|
|
|
/// Samlet respons for node-forbruk.
|
|
#[derive(Serialize)]
|
|
pub struct NodeUsageResponse {
|
|
pub node_id: Uuid,
|
|
pub node_title: Option<String>,
|
|
pub by_type: Vec<ResourceTypeSummary>,
|
|
pub daily: Vec<DailyUsage>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct UsageParams {
|
|
pub days: Option<i32>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct NodeUsageParams {
|
|
pub node_id: Uuid,
|
|
pub days: Option<i32>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct ErrorResponse {
|
|
pub error: String,
|
|
}
|
|
|
|
fn err(status: StatusCode, msg: &str) -> (StatusCode, Json<ErrorResponse>) {
|
|
(status, Json(ErrorResponse { error: msg.to_string() }))
|
|
}
|
|
|
|
// =============================================================================
|
|
// GET /my/usage — brukerens eget forbruk
|
|
// =============================================================================
|
|
|
|
pub async fn my_usage(
|
|
State(state): State<AppState>,
|
|
user: AuthUser,
|
|
axum::extract::Query(params): axum::extract::Query<UsageParams>,
|
|
) -> Result<Json<UserUsageResponse>, (StatusCode, Json<ErrorResponse>)> {
|
|
let days = params.days.unwrap_or(30).clamp(1, 365);
|
|
let user_id = user.node_id;
|
|
|
|
let by_type = fetch_user_by_type(&state.db, user_id, days)
|
|
.await
|
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &format!("DB-feil: {e}")))?;
|
|
|
|
let daily = fetch_user_daily(&state.db, user_id, days)
|
|
.await
|
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &format!("DB-feil: {e}")))?;
|
|
|
|
let graph = fetch_graph_stats_for_user(&state.db, user_id)
|
|
.await
|
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &format!("DB-feil: {e}")))?;
|
|
|
|
Ok(Json(UserUsageResponse { by_type, daily, graph }))
|
|
}
|
|
|
|
// =============================================================================
|
|
// GET /query/node_usage — forbruk for en spesifikk node
|
|
// =============================================================================
|
|
|
|
pub async fn node_usage(
|
|
State(state): State<AppState>,
|
|
user: AuthUser,
|
|
axum::extract::Query(params): axum::extract::Query<NodeUsageParams>,
|
|
) -> Result<Json<NodeUsageResponse>, (StatusCode, Json<ErrorResponse>)> {
|
|
let days = params.days.unwrap_or(30).clamp(1, 365);
|
|
let node_id = params.node_id;
|
|
|
|
// Sjekk at brukeren eier noden (created_by) eller har owner/admin-edge
|
|
let is_owner = check_node_access(&state.db, node_id, user.node_id)
|
|
.await
|
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &format!("DB-feil: {e}")))?;
|
|
|
|
if !is_owner {
|
|
return Err(err(StatusCode::FORBIDDEN, "Du har ikke tilgang til denne nodens forbruk"));
|
|
}
|
|
|
|
let node_title: Option<String> = sqlx::query_scalar("SELECT title FROM nodes WHERE id = $1")
|
|
.bind(node_id)
|
|
.fetch_optional(&state.db)
|
|
.await
|
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &format!("DB-feil: {e}")))?
|
|
.flatten();
|
|
|
|
let by_type = fetch_node_by_type(&state.db, node_id, days)
|
|
.await
|
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &format!("DB-feil: {e}")))?;
|
|
|
|
let daily = fetch_node_daily(&state.db, node_id, days)
|
|
.await
|
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &format!("DB-feil: {e}")))?;
|
|
|
|
Ok(Json(NodeUsageResponse { node_id, node_title, by_type, daily }))
|
|
}
|
|
|
|
// =============================================================================
|
|
// Tilgangssjekk
|
|
// =============================================================================
|
|
|
|
/// Bruker har tilgang til nodeforbruk hvis:
|
|
/// 1. De opprettet noden (created_by), eller
|
|
/// 2. De har en owner- eller admin-edge til noden
|
|
async fn check_node_access(db: &PgPool, node_id: Uuid, user_id: Uuid) -> Result<bool, sqlx::Error> {
|
|
let row: Option<(bool,)> = sqlx::query_as(
|
|
r#"
|
|
SELECT EXISTS(
|
|
SELECT 1 FROM nodes WHERE id = $1 AND created_by = $2
|
|
UNION ALL
|
|
SELECT 1 FROM edges WHERE target_node_id = $1 AND source_node_id = $2
|
|
AND edge_type IN ('owner', 'admin')
|
|
) AS ok
|
|
"#,
|
|
)
|
|
.bind(node_id)
|
|
.bind(user_id)
|
|
.fetch_optional(db)
|
|
.await?;
|
|
|
|
Ok(row.map(|r| r.0).unwrap_or(false))
|
|
}
|
|
|
|
// =============================================================================
|
|
// Spørringer — brukerforbruk
|
|
// =============================================================================
|
|
|
|
async fn fetch_user_by_type(
|
|
db: &PgPool,
|
|
user_id: Uuid,
|
|
days: i32,
|
|
) -> Result<Vec<ResourceTypeSummary>, sqlx::Error> {
|
|
sqlx::query_as::<_, ResourceTypeSummary>(
|
|
r#"
|
|
SELECT
|
|
r.resource_type,
|
|
COUNT(*)::BIGINT AS event_count,
|
|
COALESCE(SUM(
|
|
CASE r.resource_type
|
|
WHEN 'ai' THEN (r.detail->>'tokens_in')::FLOAT8
|
|
WHEN 'whisper' THEN (r.detail->>'duration_seconds')::FLOAT8
|
|
WHEN 'tts' THEN (r.detail->>'characters')::FLOAT8
|
|
WHEN 'cas' THEN (r.detail->>'size_bytes')::FLOAT8
|
|
WHEN 'bandwidth' THEN (r.detail->>'size_bytes')::FLOAT8
|
|
WHEN 'livekit' THEN (r.detail->>'participant_minutes')::FLOAT8
|
|
ELSE 0
|
|
END
|
|
), 0) AS total_value,
|
|
COALESCE(SUM(
|
|
CASE r.resource_type
|
|
WHEN 'ai' THEN (r.detail->>'tokens_out')::FLOAT8
|
|
ELSE 0
|
|
END
|
|
), 0) AS secondary_value
|
|
FROM resource_usage_log r
|
|
WHERE r.triggered_by = $1
|
|
AND r.created_at >= now() - make_interval(days := $2)
|
|
GROUP BY r.resource_type
|
|
ORDER BY total_value DESC
|
|
"#,
|
|
)
|
|
.bind(user_id)
|
|
.bind(days)
|
|
.fetch_all(db)
|
|
.await
|
|
}
|
|
|
|
async fn fetch_user_daily(
|
|
db: &PgPool,
|
|
user_id: Uuid,
|
|
days: i32,
|
|
) -> Result<Vec<DailyUsage>, sqlx::Error> {
|
|
sqlx::query_as::<_, DailyUsage>(
|
|
r#"
|
|
SELECT
|
|
date_trunc('day', r.created_at) AS day,
|
|
r.resource_type,
|
|
COUNT(*)::BIGINT AS event_count,
|
|
COALESCE(SUM(
|
|
CASE r.resource_type
|
|
WHEN 'ai' THEN (r.detail->>'tokens_in')::FLOAT8
|
|
WHEN 'whisper' THEN (r.detail->>'duration_seconds')::FLOAT8
|
|
WHEN 'tts' THEN (r.detail->>'characters')::FLOAT8
|
|
WHEN 'cas' THEN (r.detail->>'size_bytes')::FLOAT8
|
|
WHEN 'bandwidth' THEN (r.detail->>'size_bytes')::FLOAT8
|
|
WHEN 'livekit' THEN (r.detail->>'participant_minutes')::FLOAT8
|
|
ELSE 0
|
|
END
|
|
), 0) AS total_value
|
|
FROM resource_usage_log r
|
|
WHERE r.triggered_by = $1
|
|
AND r.created_at >= now() - make_interval(days := $2)
|
|
GROUP BY date_trunc('day', r.created_at), r.resource_type
|
|
ORDER BY day DESC, resource_type
|
|
"#,
|
|
)
|
|
.bind(user_id)
|
|
.bind(days)
|
|
.fetch_all(db)
|
|
.await
|
|
}
|
|
|
|
async fn fetch_graph_stats_for_user(db: &PgPool, user_id: Uuid) -> Result<GraphStats, sqlx::Error> {
|
|
let nodes: (i64,) = sqlx::query_as("SELECT COUNT(*)::BIGINT FROM nodes WHERE created_by = $1")
|
|
.bind(user_id)
|
|
.fetch_one(db)
|
|
.await?;
|
|
|
|
let edges: (i64,) = sqlx::query_as(
|
|
"SELECT COUNT(*)::BIGINT FROM edges WHERE created_by = $1",
|
|
)
|
|
.bind(user_id)
|
|
.fetch_one(db)
|
|
.await?;
|
|
|
|
Ok(GraphStats {
|
|
nodes_created: nodes.0,
|
|
edges_created: edges.0,
|
|
})
|
|
}
|
|
|
|
// =============================================================================
|
|
// Spørringer — nodeforbruk
|
|
// =============================================================================
|
|
|
|
async fn fetch_node_by_type(
|
|
db: &PgPool,
|
|
node_id: Uuid,
|
|
days: i32,
|
|
) -> Result<Vec<ResourceTypeSummary>, sqlx::Error> {
|
|
sqlx::query_as::<_, ResourceTypeSummary>(
|
|
r#"
|
|
SELECT
|
|
r.resource_type,
|
|
COUNT(*)::BIGINT AS event_count,
|
|
COALESCE(SUM(
|
|
CASE r.resource_type
|
|
WHEN 'ai' THEN (r.detail->>'tokens_in')::FLOAT8
|
|
WHEN 'whisper' THEN (r.detail->>'duration_seconds')::FLOAT8
|
|
WHEN 'tts' THEN (r.detail->>'characters')::FLOAT8
|
|
WHEN 'cas' THEN (r.detail->>'size_bytes')::FLOAT8
|
|
WHEN 'bandwidth' THEN (r.detail->>'size_bytes')::FLOAT8
|
|
WHEN 'livekit' THEN (r.detail->>'participant_minutes')::FLOAT8
|
|
ELSE 0
|
|
END
|
|
), 0) AS total_value,
|
|
COALESCE(SUM(
|
|
CASE r.resource_type
|
|
WHEN 'ai' THEN (r.detail->>'tokens_out')::FLOAT8
|
|
ELSE 0
|
|
END
|
|
), 0) AS secondary_value
|
|
FROM resource_usage_log r
|
|
WHERE r.target_node_id = $1
|
|
AND r.created_at >= now() - make_interval(days := $2)
|
|
GROUP BY r.resource_type
|
|
ORDER BY total_value DESC
|
|
"#,
|
|
)
|
|
.bind(node_id)
|
|
.bind(days)
|
|
.fetch_all(db)
|
|
.await
|
|
}
|
|
|
|
async fn fetch_node_daily(
|
|
db: &PgPool,
|
|
node_id: Uuid,
|
|
days: i32,
|
|
) -> Result<Vec<DailyUsage>, sqlx::Error> {
|
|
sqlx::query_as::<_, DailyUsage>(
|
|
r#"
|
|
SELECT
|
|
date_trunc('day', r.created_at) AS day,
|
|
r.resource_type,
|
|
COUNT(*)::BIGINT AS event_count,
|
|
COALESCE(SUM(
|
|
CASE r.resource_type
|
|
WHEN 'ai' THEN (r.detail->>'tokens_in')::FLOAT8
|
|
WHEN 'whisper' THEN (r.detail->>'duration_seconds')::FLOAT8
|
|
WHEN 'tts' THEN (r.detail->>'characters')::FLOAT8
|
|
WHEN 'cas' THEN (r.detail->>'size_bytes')::FLOAT8
|
|
WHEN 'bandwidth' THEN (r.detail->>'size_bytes')::FLOAT8
|
|
WHEN 'livekit' THEN (r.detail->>'participant_minutes')::FLOAT8
|
|
ELSE 0
|
|
END
|
|
), 0) AS total_value
|
|
FROM resource_usage_log r
|
|
WHERE r.target_node_id = $1
|
|
AND r.created_at >= now() - make_interval(days := $2)
|
|
GROUP BY date_trunc('day', r.created_at), r.resource_type
|
|
ORDER BY day DESC, resource_type
|
|
"#,
|
|
)
|
|
.bind(node_id)
|
|
.bind(days)
|
|
.fetch_all(db)
|
|
.await
|
|
}
|