use askama::Template; use askama_web::WebTemplate; use axum::extract::State; use axum::http::HeaderMap; use axum::response::IntoResponse; use k8s_openapi::api::apps::v1::Deployment; use k8s_openapi::api::core::v1::Secret; use kube::{Api, api::ListParams}; use crate::auth_guard::AuthUser; use crate::flash; use crate::k8s::SojuBouncer; use crate::page::PageContext; use crate::state::AppState; #[derive(Template, WebTemplate)] #[template(path = "dashboard.html")] pub struct DashboardTemplate { pub page: PageContext, pub has_bouncer: bool, pub bouncer_ready: bool, pub is_pro: bool, pub plan: String, pub networks: i64, pub channels: i64, pub messages_relayed: i64, pub bouncer_uptime: String, pub paste_count: i64, pub paste_storage: String, pub pic_count: i64, pub pic_storage: String, } #[derive(serde::Deserialize)] struct TxtUsage { paste_count: i64, storage_bytes: i64, } #[derive(serde::Deserialize)] struct PicsUsage { image_count: i64, storage_bytes: i64, } fn format_bytes(bytes: i64) -> String { if bytes < 1024 { format!("{bytes} B") } else if bytes < 1024 * 1024 { format!("{:.1} KB", bytes as f64 / 1024.0) } else if bytes < 1024 * 1024 * 1024 { format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) } else { format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) } } fn format_uptime(start: k8s_openapi::jiff::Timestamp) -> String { let now = k8s_openapi::jiff::Timestamp::now(); let dur = now - start; let total_hours = dur.get_hours(); let days = total_hours / 24; let hours = total_hours % 24; if days > 0 { format!("{days}d {hours}h") } else { format!("{hours}h") } } pub async fn index( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, ) -> impl IntoResponse { let bouncers: Api = Api::namespaced(state.kube.clone(), &state.namespace); let lp = ListParams::default().labels(&format!("irc.now/owner={}", user.sub)); let bouncer_list = bouncers.list(&lp).await.ok(); let has_bouncer = bouncer_list.as_ref().map(|l| !l.items.is_empty()).unwrap_or(false); let bouncer_ready = bouncer_list.as_ref() .and_then(|l| l.items.first()) .and_then(|b| b.status.as_ref()) .and_then(|s| s.conditions.first()) .map(|c| c.status == "True") .unwrap_or(false); let plan = user.plan.unwrap_or_else(|| "free".to_string()); let is_pro = plan == "pro"; let (mut networks, mut channels, mut messages_relayed) = (0i64, 0i64, 0i64); let mut bouncer_uptime = String::new(); if let Some(bouncer) = bouncer_list.as_ref().and_then(|l| l.items.first()) { let bouncer_name = bouncer.metadata.name.as_deref().unwrap_or_default(); let deploy_api: Api = Api::namespaced(state.kube.clone(), &state.namespace); if let Ok(deploy) = deploy_api.get(bouncer_name).await { if let Some(ts) = deploy.metadata.creation_timestamp.as_ref() { bouncer_uptime = format_uptime(ts.0); } } if let Ok(stats) = get_bouncer_stats(&state, bouncer_name).await { networks = stats.0; channels = stats.1; messages_relayed = stats.2; } } let txt_usage = async { let resp = state.http_client .get(format!("{}/api/usage/{}", state.txt_url, user.sub)) .send().await.ok()?; resp.json::().await.ok() }.await; let (paste_count, paste_storage_bytes) = txt_usage .map(|u| (u.paste_count, u.storage_bytes)) .unwrap_or((0, 0)); let pics_usage = async { let resp = state.http_client .get(format!("{}/api/usage/{}", state.pics_url, user.sub)) .send().await.ok()?; resp.json::().await.ok() }.await; let (pic_count, pic_storage_bytes) = pics_usage .map(|u| (u.image_count, u.storage_bytes)) .unwrap_or((0, 0)); let page = PageContext::from_request(&headers, &state.announcement); let clear = page.flash.is_some(); let template = DashboardTemplate { page, has_bouncer, bouncer_ready, is_pro, plan, networks, channels, messages_relayed, bouncer_uptime, paste_count, paste_storage: format_bytes(paste_storage_bytes), pic_count, pic_storage: format_bytes(pic_storage_bytes), }; if clear { (axum::response::AppendHeaders([flash::clear_flash()]), template).into_response() } else { template.into_response() } } async fn get_bouncer_stats( state: &AppState, bouncer_name: &str, ) -> Result<(i64, i64, i64), Box> { let secret_api: Api = Api::namespaced(state.kube.clone(), &state.namespace); let secret = secret_api.get(&format!("{bouncer_name}-db")).await?; let uri = secret.data.as_ref() .and_then(|d| d.get("uri")) .map(|b| String::from_utf8_lossy(&b.0).to_string()) .ok_or("missing uri in db secret")?; let (client, connection) = tokio_postgres::connect(&uri, tokio_postgres::NoTls).await?; tokio::spawn(async move { if let Err(e) = connection.await { tracing::warn!("soju db connection error: {e}"); } }); let networks: i64 = client .query_one(r#"SELECT COUNT(*) FROM "Network""#, &[]) .await? .get(0); let channels: i64 = client .query_one(r#"SELECT COUNT(*) FROM "Channel""#, &[]) .await? .get(0); let messages: i64 = client .query_one(r#"SELECT COUNT(*) FROM "MessageTarget""#, &[]) .await .map(|r| r.get(0)) .unwrap_or(0); Ok((networks, channels, messages)) }