Skip to main content

codoseo_web/routes/
admin.rs

1//! `/admin`: the funnel, the quick-audit queue and failed jobs, for the people running the
2//! instance. In the cloud that is whoever is listed in `ADMIN_EMAILS`; self-hosted, the owner.
3//! Everyone else gets the same 404 as a page that doesn't exist.
4
5use 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
23/// Failed jobs listed.
24const FAILED_JOBS_SHOWN: i64 = 20;
25
26pub fn routes() -> Router<AppState> {
27    Router::new().route("/admin", get(page))
28}
29
30/// Whether `account` runs this instance.
31pub 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    /// The `events.kind` value.
43    pub kind: &'static str,
44    pub label: &'static str,
45    pub unique: String,
46    pub events: String,
47    /// Share of the step before that got here, `66.7%`; `—` for the first step or no base.
48    pub conversion: String,
49    /// The raw unique count, for tests and scripts.
50    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                // RankOrg clicks branch off the report, and the 4-week step needs the
95                // scheduler (M7): neither is a step after the one before it yet.
96                _ 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            // Audits and emails that came through the no-key MCP tools, kept out of the
147            // website's numbers above.
148            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}