codoseo_web/routes/
admin.rs1use askama::Template;
6use axum::Router;
7use axum::extract::State;
8use axum::response::{IntoResponse, Response};
9use axum::routing::get;
10use codoseo_store::accounts::Account;
11use codoseo_store::events::{self, EventKind, FunnelCount};
12use codoseo_store::jobs::{self, FailedJob};
13use codoseo_store::quick;
14
15use crate::auth::{CurrentUser, email};
16use crate::config::Mode;
17use crate::error::AppError;
18use crate::fmt;
19use crate::layout::{Screen, Shell};
20use crate::render::html;
21use crate::state::AppState;
22
23const FAILED_JOBS_SHOWN: i64 = 20;
25
26pub fn routes() -> Router<AppState> {
27 Router::new().route("/admin", get(page))
28}
29
30pub fn is_admin(state: &AppState, account: &Account) -> bool {
32 match state.config.mode {
33 Mode::SelfHost => account.is_owner,
34 Mode::Cloud => state
35 .config
36 .admin_emails
37 .contains(&email::canonical(&account.email)),
38 }
39}
40
41pub struct FunnelRow {
42 pub kind: &'static str,
44 pub label: &'static str,
45 pub unique: String,
46 pub events: String,
47 pub conversion: String,
49 pub raw: i64,
51}
52
53pub struct FunnelTable {
54 pub title: &'static str,
55 pub rows: Vec<FunnelRow>,
56}
57
58pub struct JobRow {
59 pub kind: &'static str,
60 pub attempts: i16,
61 pub error: String,
62 pub when: String,
63}
64
65#[derive(Template)]
66#[template(path = "admin/index.html")]
67pub struct AdminPage {
68 pub shell: Shell,
69 pub tables: Vec<FunnelTable>,
70 pub queue_depth: i64,
71 pub jobs: Vec<JobRow>,
72}
73
74fn label(kind: EventKind) -> &'static str {
75 match kind {
76 EventKind::AuditStarted => "Audit started",
77 EventKind::AuditFinished => "Audit finished",
78 EventKind::EmailGiven => "Email given",
79 EventKind::LinkClicked => "Link clicked",
80 EventKind::FirstFullCrawl => "First full crawl",
81 EventKind::ActiveAfter4Weeks => "Active after 4 weeks",
82 EventKind::RankorgClick => "RankOrg click",
83 EventKind::ChannelTest => "Channel test",
84 }
85}
86
87fn table(title: &'static str, counts: &[FunnelCount]) -> FunnelTable {
88 let rows = counts
89 .iter()
90 .enumerate()
91 .map(|(i, c)| {
92 let conversion = match i {
93 0 => "—".to_owned(),
94 _ if matches!(
97 c.kind,
98 EventKind::RankorgClick | EventKind::ActiveAfter4Weeks
99 ) =>
100 {
101 "—".to_owned()
102 }
103 _ if counts[i - 1].unique == 0 => "—".to_owned(),
104 _ => fmt::percent(c.unique, counts[i - 1].unique),
105 };
106 FunnelRow {
107 kind: c.kind.as_str(),
108 label: label(c.kind),
109 unique: fmt::thousands(c.unique),
110 events: fmt::thousands(c.events),
111 conversion,
112 raw: c.unique,
113 }
114 })
115 .collect();
116 FunnelTable { title, rows }
117}
118
119fn job_row(j: &FailedJob) -> JobRow {
120 JobRow {
121 kind: j.kind.slug(),
122 attempts: j.attempt,
123 error: j
124 .last_error
125 .clone()
126 .unwrap_or_else(|| "no error recorded".to_owned()),
127 when: fmt::ago(j.created_at),
128 }
129}
130
131async fn page(State(state): State<AppState>, user: CurrentUser) -> Result<Response, AppError> {
132 if !is_admin(&state, &user.account) {
133 return Err(AppError::NotFound);
134 }
135 let shell = Shell::load(&state, &user, None, Screen::Admin).await?;
136 let pool = &state.pool;
137 let week = events::funnel_counts(pool, 7).await?;
138 let month = events::funnel_counts(pool, 30).await?;
139 let agents = events::agent_funnel_counts(pool, 30).await?;
140 let jobs = jobs::failed_jobs(pool, FAILED_JOBS_SHOWN).await?;
141 Ok(html(&AdminPage {
142 shell,
143 tables: vec![
144 table("Last 7 days", &week),
145 table("Last 30 days", &month),
146 table("Agents, last 30 days", &agents),
149 ],
150 queue_depth: quick::queue_depth(pool).await?,
151 jobs: jobs.iter().map(job_row).collect(),
152 })?
153 .into_response())
154}