use askama::Template;
use axum::Router;
use axum::extract::State;
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use codoseo_store::accounts::Account;
use codoseo_store::events::{self, EventKind, FunnelCount};
use codoseo_store::jobs::{self, FailedJob};
use codoseo_store::quick;
use crate::auth::{CurrentUser, email};
use crate::config::Mode;
use crate::error::AppError;
use crate::fmt;
use crate::layout::{Screen, Shell};
use crate::render::html;
use crate::state::AppState;
const FAILED_JOBS_SHOWN: i64 = 20;
pub fn routes() -> Router<AppState> {
Router::new().route("/admin", get(page))
}
pub fn is_admin(state: &AppState, account: &Account) -> bool {
match state.config.mode {
Mode::SelfHost => account.is_owner,
Mode::Cloud => state
.config
.admin_emails
.contains(&email::canonical(&account.email)),
}
}
pub struct FunnelRow {
pub kind: &'static str,
pub label: &'static str,
pub unique: String,
pub events: String,
pub conversion: String,
pub raw: i64,
}
pub struct FunnelTable {
pub title: &'static str,
pub rows: Vec<FunnelRow>,
}
pub struct JobRow {
pub kind: &'static str,
pub attempts: i16,
pub error: String,
pub when: String,
}
#[derive(Template)]
#[template(path = "admin/index.html")]
pub struct AdminPage {
pub shell: Shell,
pub tables: Vec<FunnelTable>,
pub queue_depth: i64,
pub jobs: Vec<JobRow>,
}
fn label(kind: EventKind) -> &'static str {
match kind {
EventKind::AuditStarted => "Audit started",
EventKind::AuditFinished => "Audit finished",
EventKind::EmailGiven => "Email given",
EventKind::LinkClicked => "Link clicked",
EventKind::FirstFullCrawl => "First full crawl",
EventKind::ActiveAfter4Weeks => "Active after 4 weeks",
EventKind::RankorgClick => "RankOrg click",
EventKind::ChannelTest => "Channel test",
}
}
fn table(title: &'static str, counts: &[FunnelCount]) -> FunnelTable {
let rows = counts
.iter()
.enumerate()
.map(|(i, c)| {
let conversion = match i {
0 => "—".to_owned(),
_ if matches!(
c.kind,
EventKind::RankorgClick | EventKind::ActiveAfter4Weeks
) =>
{
"—".to_owned()
}
_ if counts[i - 1].unique == 0 => "—".to_owned(),
_ => fmt::percent(c.unique, counts[i - 1].unique),
};
FunnelRow {
kind: c.kind.as_str(),
label: label(c.kind),
unique: fmt::thousands(c.unique),
events: fmt::thousands(c.events),
conversion,
raw: c.unique,
}
})
.collect();
FunnelTable { title, rows }
}
fn job_row(j: &FailedJob) -> JobRow {
JobRow {
kind: j.kind.slug(),
attempts: j.attempt,
error: j
.last_error
.clone()
.unwrap_or_else(|| "no error recorded".to_owned()),
when: fmt::ago(j.created_at),
}
}
async fn page(State(state): State<AppState>, user: CurrentUser) -> Result<Response, AppError> {
if !is_admin(&state, &user.account) {
return Err(AppError::NotFound);
}
let shell = Shell::load(&state, &user, None, Screen::Admin).await?;
let pool = &state.pool;
let week = events::funnel_counts(pool, 7).await?;
let month = events::funnel_counts(pool, 30).await?;
let agents = events::agent_funnel_counts(pool, 30).await?;
let jobs = jobs::failed_jobs(pool, FAILED_JOBS_SHOWN).await?;
Ok(html(&AdminPage {
shell,
tables: vec![
table("Last 7 days", &week),
table("Last 30 days", &month),
table("Agents, last 30 days", &agents),
],
queue_depth: quick::queue_depth(pool).await?,
jobs: jobs.iter().map(job_row).collect(),
})?
.into_response())
}