Skip to main content

codoseo_web/routes/
audit.rs

1//! `/s/{site}/audit`: the site audit, the landing screen for a site. The health score and
2//! KPIs, every failing check, and the response-code, depth and response-time charts, all from
3//! the latest finished crawl. Before the first crawl finishes it shows that crawl's live
4//! progress instead, refreshed from `/s/{site}/audit/live`.
5
6use askama::Template;
7use axum::Router;
8use axum::extract::{Path, Query, State};
9use axum::response::{IntoResponse, Response};
10use axum::routing::get;
11use codoseo_checks::{Scope, def};
12use codoseo_core::change::ChangeKind;
13use codoseo_core::check::{CheckId, Severity};
14use codoseo_core::output::StopReason;
15use codoseo_core::report::CrawlSummary;
16use codoseo_store::crawls::{self, Crawl, CrawlStatus, StoredSummary};
17use codoseo_store::reports::{self, ResponseBuckets};
18use codoseo_store::sites::Site;
19use serde::Deserialize;
20use uuid::Uuid;
21
22use crate::auth::{CurrentUser, load_site};
23use crate::error::AppError;
24use crate::fmt;
25use crate::layout::{Screen, Shell};
26use crate::render::{Hx, html};
27use crate::state::AppState;
28
29pub fn routes() -> Router<AppState> {
30    Router::new()
31        .route("/s/{site}/audit", get(page))
32        .route("/s/{site}/audit/live", get(live))
33}
34
35/// The site ID from the path; anything that isn't a UUID is a 404, like a missing site.
36pub fn site_id(raw: &str) -> Result<Uuid, AppError> {
37    Uuid::parse_str(raw).map_err(|_| AppError::NotFound)
38}
39
40#[derive(Template)]
41#[template(path = "audit/index.html")]
42pub struct AuditPage {
43    pub shell: Shell,
44    pub body: AuditBody,
45}
46
47/// Everything inside `#main`: one of the four states. Also the response to an htmx GET of
48/// the audit URL (the empty states reload it when a crawl is queued).
49#[derive(Template)]
50#[template(path = "audit/body.html")]
51pub struct AuditBody {
52    pub base: String,
53    pub domain: String,
54    /// The latest finished crawl's audit.
55    pub done: Option<DoneAudit>,
56    /// The first crawl's progress (no `done`), or the running-crawl banner slot (with `done`).
57    pub live: Option<AuditLive>,
58    /// Why the latest crawl failed, when nothing has finished yet.
59    pub failure: Option<String>,
60}
61
62impl AuditBody {
63    /// The empty and failed states reload themselves when a crawl is queued.
64    pub fn reloads_on_queue(&self) -> bool {
65        self.done.is_none() && self.live.is_none()
66    }
67
68    /// The KPI labels, for the skeleton strip while the first crawl runs.
69    pub fn kpi_labels(&self) -> [&'static str; 5] {
70        KPI_LABELS
71    }
72}
73
74const KPI_LABELS: [&str; 5] = [
75    "Health score",
76    "URLs crawled",
77    "Indexable",
78    "Avg response",
79    "Crawl time",
80];
81
82pub struct DoneAudit {
83    /// `37 of 40 checks passed`
84    pub checks_chip: String,
85    /// `page limit reached`, when the crawl stopped early.
86    pub stop_chip: Option<&'static str>,
87    pub kpis: Vec<Kpi>,
88    pub issues: Vec<IssueRow>,
89    pub codes: Vec<Segment>,
90    /// `1,284 URLs`
91    pub pages_label: String,
92    pub depth: Vec<Bar>,
93    pub times: Vec<HBar>,
94    /// `avg 182 ms`
95    pub times_aside: String,
96}
97
98pub struct Kpi {
99    pub label: &'static str,
100    /// Swatch background class.
101    pub swatch: &'static str,
102    pub parts: Vec<KpiPart>,
103    pub delta: String,
104    /// `up`, `down` or empty.
105    pub tone: &'static str,
106}
107
108/// One number of a KPI value plus its unit. Durations have two (`1` `m` `35` `s`).
109pub struct KpiPart {
110    /// The raw number for the count-up (`1284`, `90.2`).
111    pub count: String,
112    /// The same number, formatted (`1,284`).
113    pub text: String,
114    pub unit: String,
115}
116
117pub struct IssueRow {
118    /// `critical`, `warning`, `notice`
119    pub severity: &'static str,
120    pub severity_label: &'static str,
121    pub title: &'static str,
122    /// Explorer link; `None` for site-wide checks.
123    pub href: Option<String>,
124    pub count: String,
125    /// `12.4%`, or `—` for site-wide checks.
126    pub pct: String,
127    /// Minibar width relative to the largest count, 0–100.
128    pub bar: u32,
129    pub bar_class: &'static str,
130}
131
132/// A response-code class: one stackbar segment and one legend entry.
133pub struct Segment {
134    pub label: &'static str,
135    pub class: &'static str,
136    pub n: u32,
137    pub count: String,
138}
139
140/// One vertical bar.
141pub struct Bar {
142    pub label: String,
143    pub value: String,
144    pub height: u32,
145    pub class: &'static str,
146    pub tip: String,
147}
148
149/// One horizontal bar.
150pub struct HBar {
151    pub label: &'static str,
152    pub value: String,
153    pub width: u32,
154    pub class: &'static str,
155}
156
157/// The live crawl line: the first-crawl progress block, or the slim banner over a finished
158/// audit. Polls itself every 2 s while a crawl is queued or running.
159#[derive(Template)]
160#[template(path = "audit/live.html")]
161pub struct AuditLive {
162    pub base: String,
163    pub banner: bool,
164    /// A crawl is queued or running.
165    pub active: bool,
166    /// `queued` or `running`, for the status dot.
167    pub state: &'static str,
168    pub line: String,
169    pub sub: String,
170}
171
172impl AuditLive {
173    fn new(site: &Site, banner: bool, active: Option<&Crawl>) -> AuditLive {
174        let base = format!("/s/{}", site.id);
175        let Some(c) = active else {
176            return AuditLive {
177                base,
178                banner,
179                active: false,
180                state: "idle",
181                line: "Crawl finished".to_owned(),
182                sub: "Loading the audit…".to_owned(),
183            };
184        };
185        let running = c.status == CrawlStatus::Running;
186        let progress = c.progress();
187        let pages = progress.map_or(0, |p| p.pages_done);
188        let (line, sub) = match (banner, running) {
189            (true, true) => (
190                format!(
191                    "Crawl #{} running · {} pages",
192                    c.number,
193                    fmt::thousands(pages)
194                ),
195                String::new(),
196            ),
197            (true, false) => (format!("Crawl #{} queued", c.number), String::new()),
198            (false, true) => (
199                match progress {
200                    Some(p) => format!(
201                        "Crawling {} · {} pages · {}",
202                        site.domain,
203                        fmt::thousands(p.pages_done),
204                        fmt::millis(p.elapsed_ms)
205                    ),
206                    None => format!("Crawling {} · starting", site.domain),
207                },
208                "Your audit appears here the moment the crawl finishes.".to_owned(),
209            ),
210            (false, false) => (
211                format!("Starting a crawl of {}", site.domain),
212                format!(
213                    "Queued {}. It starts as soon as a crawler is free.",
214                    fmt::ago(c.queued_at)
215                ),
216            ),
217        };
218        AuditLive {
219            base,
220            banner,
221            active: true,
222            state: if running { "running" } else { "queued" },
223            line,
224            sub,
225        }
226    }
227}
228
229async fn page(
230    State(state): State<AppState>,
231    user: CurrentUser,
232    hx: Hx,
233    Path(site): Path<String>,
234) -> Result<Response, AppError> {
235    let site = load_site(&state, &user, site_id(&site)?).await?;
236    let body = load_body(&state, &site).await?;
237    if hx.partial() {
238        return Ok(html(&body)?.into_response());
239    }
240    let shell = Shell::load(&state, &user, Some(&site), Screen::Audit).await?;
241    Ok(html(&AuditPage { shell, body })?.into_response())
242}
243
244#[derive(Deserialize)]
245struct LiveQuery {
246    view: Option<String>,
247}
248
249/// The live line on its own, polled every 2 s (`?view=banner` for the slim banner).
250async fn live(
251    State(state): State<AppState>,
252    user: CurrentUser,
253    Path(site): Path<String>,
254    Query(q): Query<LiveQuery>,
255) -> Result<Response, AppError> {
256    let site = load_site(&state, &user, site_id(&site)?).await?;
257    let active = crawls::active(&state.pool, site.id).await?;
258    let banner = q.view.as_deref() == Some("banner");
259    Ok(html(&AuditLive::new(&site, banner, active.as_ref()))?.into_response())
260}
261
262async fn load_body(state: &AppState, site: &Site) -> Result<AuditBody, AppError> {
263    let pool = &state.pool;
264    let latest = crawls::latest_done(pool, site.id).await?;
265    let active = crawls::active(pool, site.id).await?;
266    let mut body = AuditBody {
267        base: format!("/s/{}", site.id),
268        domain: site.domain.clone(),
269        done: None,
270        live: None,
271        failure: None,
272    };
273    match latest {
274        Some(crawl) => {
275            body.done = Some(done_audit(state, site, &crawl).await?);
276            body.live = Some(AuditLive::new(site, true, active.as_ref()));
277        }
278        None if active.is_some() => {
279            body.live = Some(AuditLive::new(site, false, active.as_ref()));
280        }
281        None => {
282            let last = crawls::history(pool, site.id, 1).await?;
283            body.failure = last
284                .into_iter()
285                .find(|c| c.status == CrawlStatus::Failed)
286                .map(|c| {
287                    c.failure_reason
288                        .unwrap_or_else(|| "The crawl stopped unexpectedly.".to_owned())
289                });
290        }
291    }
292    Ok(body)
293}
294
295async fn done_audit(state: &AppState, site: &Site, crawl: &Crawl) -> Result<DoneAudit, AppError> {
296    let pool = &state.pool;
297    let base = format!("/s/{}", site.id);
298    let stored = crawl.summary();
299    let summary = stored
300        .as_ref()
301        .map(|s| s.report_summary.clone())
302        .unwrap_or_default();
303    let previous = crawls::previous_done(pool, site.id, crawl.id).await?;
304    let changes = reports::change_kind_counts(pool, crawl.id).await?;
305    let times = reports::response_time_buckets(pool, crawl.id).await?;
306
307    let checks_chip = format!(
308        "{} of {} checks passed",
309        crawl.checks_passed.unwrap_or(0),
310        crawl.checks_total.unwrap_or(0)
311    );
312    let stop_chip = stored.as_ref().and_then(|s| match s.stop_reason {
313        StopReason::PageLimit => Some("page limit reached"),
314        StopReason::TimeLimit => Some("time limit reached"),
315        _ => None,
316    });
317
318    Ok(DoneAudit {
319        checks_chip,
320        stop_chip,
321        kpis: kpis(
322            crawl,
323            &summary,
324            previous.as_ref(),
325            changes.kind(ChangeKind::NewUrl),
326            changes.kind(ChangeKind::RemovedUrl),
327        ),
328        issues: stored
329            .as_ref()
330            .map(|s| issue_rows(&base, s))
331            .unwrap_or_default(),
332        codes: segments(&summary),
333        pages_label: format!("{} URLs", fmt::thousands(summary.pages)),
334        depth: depth_bars(&summary),
335        times: time_bars(&times),
336        times_aside: format!("avg {} ms", fmt::thousands(summary.avg_response_ms)),
337    })
338}
339
340fn part(count: impl ToString, text: String, unit: &str) -> KpiPart {
341    KpiPart {
342        count: count.to_string(),
343        text,
344        unit: unit.to_owned(),
345    }
346}
347
348fn kpis(
349    crawl: &Crawl,
350    s: &CrawlSummary,
351    previous: Option<&Crawl>,
352    new_urls: i64,
353    removed_urls: i64,
354) -> Vec<Kpi> {
355    let score = crawl.health_score.unwrap_or(0);
356    let prev_score = previous.and_then(|p| Some((p.number, p.health_score?)));
357    let (health_delta, health_tone) = match prev_score {
358        None => ("— first crawl".to_owned(), ""),
359        Some((n, prev)) if score < prev => (format!("↓ {} vs crawl #{n}", prev - score), "down"),
360        Some((n, prev)) if score > prev => (format!("↑ {} vs crawl #{n}", score - prev), "up"),
361        Some((n, _)) => (format!("no change vs crawl #{n}"), ""),
362    };
363    let health_swatch = match score {
364        80.. => "bg-ok",
365        50..80 => "bg-warn",
366        _ => "bg-err",
367    };
368
369    let urls_delta = if previous.is_some() {
370        format!(
371            "+{} new · −{} removed",
372            fmt::thousands(new_urls),
373            fmt::thousands(removed_urls)
374        )
375    } else {
376        "— first crawl".to_owned()
377    };
378
379    let pct = fmt::pct1(u64::from(s.indexable), u64::from(s.pages));
380
381    // Faster is better, so a drop in response time is the good (`up`) colour.
382    let prev_avg =
383        previous.and_then(|p| Some((p.number, p.summary()?.report_summary.avg_response_ms)));
384    let avg = s.avg_response_ms;
385    let (avg_delta, avg_tone) = match prev_avg {
386        None => ("— first crawl".to_owned(), ""),
387        Some((n, prev)) if avg < prev => (
388            format!("↓ {} ms vs crawl #{n}", fmt::thousands(prev - avg)),
389            "up",
390        ),
391        Some((n, prev)) if avg > prev => (
392            format!("↑ {} ms vs crawl #{n}", fmt::thousands(avg - prev)),
393            "down",
394        ),
395        Some((n, _)) => (format!("no change vs crawl #{n}"), ""),
396    };
397
398    let duration = crawl.duration().unwrap_or_default();
399    let secs = duration.as_seconds_f64();
400    let rate = match f64::from(s.pages) / secs {
401        _ if secs < 0.5 => "— URL/s".to_owned(),
402        r if r < 0.1 => "< 0.1 URL/s".to_owned(),
403        r => format!("{r:.1} URL/s"),
404    };
405
406    vec![
407        Kpi {
408            label: KPI_LABELS[0],
409            swatch: health_swatch,
410            parts: vec![part(score, score.to_string(), "/100")],
411            delta: health_delta,
412            tone: health_tone,
413        },
414        Kpi {
415            label: KPI_LABELS[1],
416            swatch: "bg-ink",
417            parts: vec![part(s.pages, fmt::thousands(s.pages), "")],
418            delta: urls_delta,
419            tone: "",
420        },
421        Kpi {
422            label: KPI_LABELS[2],
423            swatch: "bg-ok",
424            parts: vec![part(&pct, pct.clone(), "%")],
425            delta: format!(
426                "{} of {}",
427                fmt::thousands(s.indexable),
428                fmt::thousands(s.pages)
429            ),
430            tone: "",
431        },
432        Kpi {
433            label: KPI_LABELS[3],
434            swatch: "bg-blue",
435            parts: vec![part(
436                s.avg_response_ms,
437                fmt::thousands(s.avg_response_ms),
438                "ms",
439            )],
440            delta: avg_delta,
441            tone: avg_tone,
442        },
443        Kpi {
444            label: KPI_LABELS[4],
445            swatch: "bg-accent",
446            parts: duration_parts(&fmt::duration(duration)),
447            delta: rate,
448            tone: "",
449        },
450    ]
451}
452
453/// `1m 35s` -> `[1 m] [35 s]`, so each number can count up on its own.
454fn duration_parts(formatted: &str) -> Vec<KpiPart> {
455    formatted
456        .split_whitespace()
457        .map(|piece| {
458            let split = piece
459                .find(|c: char| !c.is_ascii_digit())
460                .unwrap_or(piece.len());
461            let (digits, unit) = piece.split_at(split);
462            let n: u64 = digits.parse().unwrap_or(0);
463            part(n, n.to_string(), unit)
464        })
465        .collect()
466}
467
468fn severity_label(s: Severity) -> (&'static str, &'static str, &'static str) {
469    match s {
470        Severity::Critical => ("critical", "Critical", "bg-err"),
471        Severity::Warning => ("warning", "Warning", "bg-warn"),
472        Severity::Notice => ("notice", "Notice", "bg-ghost"),
473    }
474}
475
476/// Failing checks, critical first, then by affected pages.
477fn issue_rows(base: &str, s: &StoredSummary) -> Vec<IssueRow> {
478    let failing = codoseo_mcp::types::rank_failing(
479        s.counts
480            .iter()
481            .filter_map(|(slug, n)| Some((CheckId::from_slug(slug)?, *n))),
482    );
483    let max = failing
484        .iter()
485        .filter(|(id, _)| def(*id).scope != Scope::SiteWide)
486        .map(|&(_, n)| n)
487        .max()
488        .unwrap_or(0)
489        .max(1);
490    let pages = u64::from(s.report_summary.pages);
491    failing
492        .into_iter()
493        .map(|(id, n)| {
494            let d = def(id);
495            let (severity, severity_label, bar_class) = severity_label(d.severity);
496            let site_wide = d.scope == Scope::SiteWide;
497            IssueRow {
498                severity,
499                severity_label,
500                title: d.title,
501                href: (!site_wide).then(|| format!("{base}/explorer?filter=check:{}", id.slug())),
502                count: if site_wide {
503                    "—".to_owned()
504                } else {
505                    fmt::thousands(n)
506                },
507                pct: if site_wide {
508                    "—".to_owned()
509                } else {
510                    format!("{}%", fmt::pct1(u64::from(n), pages))
511                },
512                bar: if site_wide { 0 } else { (n * 100 / max).max(2) },
513                bar_class,
514            }
515        })
516        .collect()
517}
518
519fn segments(s: &CrawlSummary) -> Vec<Segment> {
520    let st = &s.status;
521    [
522        ("2xx", "bg-ok", st.ok),
523        ("3xx", "bg-warn", st.redirect),
524        ("4xx", "bg-err", st.client_error),
525        ("5xx", "bg-5xx", st.server_error),
526        ("No response", "bg-ghost", st.failed),
527        ("Blocked", "bg-ink", st.blocked),
528    ]
529    .into_iter()
530    .map(|(label, class, n)| Segment {
531        label,
532        class,
533        n,
534        count: fmt::thousands(n),
535    })
536    .collect()
537}
538
539/// Pages per click depth. Trailing empty buckets are dropped, but 0–3 always show; the last
540/// bucket is 10 and deeper. Pages found only in the sitemap get their own bar.
541fn depth_bars(s: &CrawlSummary) -> Vec<Bar> {
542    let last = s.depth.iter().rposition(|&n| n > 0).map_or(0, |i| i + 1);
543    let len = last.max(4);
544    let mut buckets: Vec<(String, u32, &'static str, String)> = (0..len)
545        .map(|i| {
546            let n = s.depth.get(i).copied().unwrap_or(0);
547            let label = if i >= 10 {
548                "10+".to_owned()
549            } else {
550                i.to_string()
551            };
552            let class = if i >= 5 { "bg-warn" } else { "bg-ok" };
553            let tip = match i {
554                0 => "the homepage".to_owned(),
555                1 => "1 click from the homepage".to_owned(),
556                i if i >= 10 => "10 or more clicks".to_owned(),
557                i => format!("{i} clicks from the homepage"),
558            };
559            (label, n, class, tip)
560        })
561        .collect();
562    if s.no_depth > 0 {
563        buckets.push((
564            "map".to_owned(),
565            s.no_depth,
566            "bg-ghost",
567            "found only in the sitemap".to_owned(),
568        ));
569    }
570    let max = buckets.iter().map(|b| b.1).max().unwrap_or(0).max(1);
571    buckets
572        .into_iter()
573        .map(|(label, n, class, tip)| Bar {
574            label,
575            value: fmt::thousands(n),
576            height: if n == 0 { 0 } else { (n * 100 / max).max(2) },
577            class,
578            tip,
579        })
580        .collect()
581}
582
583fn time_bars(t: &ResponseBuckets) -> Vec<HBar> {
584    let total = t.total().max(1);
585    [
586        ("< 200 ms", t.fast, "bg-ok"),
587        ("200–500", t.ok, "bg-ok"),
588        ("500–1000", t.slow, "bg-warn"),
589        ("> 1 s", t.very_slow, "bg-err"),
590    ]
591    .into_iter()
592    .map(|(label, n, class)| HBar {
593        label,
594        value: fmt::thousands(n),
595        width: u32::try_from(n * 100 / total).unwrap_or(100),
596        class,
597    })
598    .collect()
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604    use codoseo_core::report::StatusCounts;
605
606    fn stored(counts: Vec<(&str, u32)>, pages: u32) -> StoredSummary {
607        StoredSummary {
608            stop_reason: StopReason::Completed,
609            report_summary: CrawlSummary {
610                pages,
611                status: StatusCounts::default(),
612                ..Default::default()
613            },
614            counts: counts.into_iter().map(|(s, n)| (s.to_owned(), n)).collect(),
615        }
616    }
617
618    #[test]
619    fn issues_sort_by_severity_then_count() {
620        let s = stored(
621            vec![
622                ("og_missing", 40),
623                ("title_missing", 3),
624                ("http_4xx", 2),
625                ("description_missing", 9),
626                ("sitemap_missing", 1),
627                ("from_a_newer_version", 5),
628            ],
629            100,
630        );
631        let rows = issue_rows("/s/x", &s);
632        let titles: Vec<_> = rows.iter().map(|r| r.severity).collect();
633        assert_eq!(rows.len(), 5, "unknown slugs are skipped");
634        assert_eq!(titles[0], "critical");
635        assert_eq!(
636            rows[0].href.as_deref(),
637            Some("/s/x/explorer?filter=check:http_4xx")
638        );
639        // Warnings by count: description_missing (9) before title_missing (3).
640        assert!(
641            rows[1]
642                .href
643                .as_deref()
644                .unwrap()
645                .ends_with("description_missing")
646        );
647        assert!(rows[2].href.as_deref().unwrap().ends_with("title_missing"));
648        assert_eq!(rows[1].pct, "9.0%");
649        // Site-wide checks have no link and no share of the crawl.
650        let site_wide = rows.iter().find(|r| r.href.is_none()).unwrap();
651        assert_eq!(site_wide.pct, "—");
652        // The largest page count gets the full minibar.
653        assert_eq!(rows.iter().map(|r| r.bar).max(), Some(100));
654        assert!(issue_rows("/s/x", &stored(vec![], 10)).is_empty());
655    }
656
657    #[test]
658    fn depth_keeps_zero_to_three_and_marks_deep_pages() {
659        let s = CrawlSummary {
660            depth: vec![1, 5, 0, 0, 0, 0],
661            ..Default::default()
662        };
663        let bars = depth_bars(&s);
664        assert_eq!(
665            bars.iter().map(|b| b.label.as_str()).collect::<Vec<_>>(),
666            ["0", "1", "2", "3"]
667        );
668        assert_eq!(bars[1].height, 100);
669        assert_eq!(bars[0].height, 20);
670
671        let mut depth = vec![0; 11];
672        depth[0] = 1;
673        depth[6] = 2;
674        depth[10] = 4;
675        let bars = depth_bars(&CrawlSummary {
676            depth,
677            no_depth: 3,
678            ..Default::default()
679        });
680        assert_eq!(bars.len(), 12);
681        assert_eq!(bars[10].label, "10+");
682        assert_eq!(bars[6].class, "bg-warn");
683        assert_eq!(bars[4].class, "bg-ok");
684        assert_eq!(bars[11].value, "3");
685    }
686
687    #[test]
688    fn durations_split_into_count_up_parts() {
689        let parts = duration_parts("1m 35s");
690        assert_eq!(parts.len(), 2);
691        assert_eq!(
692            (parts[0].count.as_str(), parts[0].unit.as_str()),
693            ("1", "m")
694        );
695        assert_eq!(
696            (parts[1].count.as_str(), parts[1].unit.as_str()),
697            ("35", "s")
698        );
699        let parts = duration_parts("2h 05m");
700        assert_eq!(parts[1].text, "5");
701    }
702
703    #[test]
704    fn response_times_are_shares_of_the_crawl() {
705        let bars = time_bars(&ResponseBuckets {
706            fast: 3,
707            ok: 1,
708            slow: 0,
709            very_slow: 0,
710        });
711        assert_eq!(bars[0].width, 75);
712        assert_eq!(bars[1].width, 25);
713        assert_eq!(bars[3].class, "bg-err");
714        assert_eq!(time_bars(&ResponseBuckets::default())[0].width, 0);
715    }
716}