1use 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
35pub 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#[derive(Template)]
50#[template(path = "audit/body.html")]
51pub struct AuditBody {
52 pub base: String,
53 pub domain: String,
54 pub done: Option<DoneAudit>,
56 pub live: Option<AuditLive>,
58 pub failure: Option<String>,
60}
61
62impl AuditBody {
63 pub fn reloads_on_queue(&self) -> bool {
65 self.done.is_none() && self.live.is_none()
66 }
67
68 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 pub checks_chip: String,
85 pub stop_chip: Option<&'static str>,
87 pub kpis: Vec<Kpi>,
88 pub issues: Vec<IssueRow>,
89 pub codes: Vec<Segment>,
90 pub pages_label: String,
92 pub depth: Vec<Bar>,
93 pub times: Vec<HBar>,
94 pub times_aside: String,
96}
97
98pub struct Kpi {
99 pub label: &'static str,
100 pub swatch: &'static str,
102 pub parts: Vec<KpiPart>,
103 pub delta: String,
104 pub tone: &'static str,
106}
107
108pub struct KpiPart {
110 pub count: String,
112 pub text: String,
114 pub unit: String,
115}
116
117pub struct IssueRow {
118 pub severity: &'static str,
120 pub severity_label: &'static str,
121 pub title: &'static str,
122 pub href: Option<String>,
124 pub count: String,
125 pub pct: String,
127 pub bar: u32,
129 pub bar_class: &'static str,
130}
131
132pub struct Segment {
134 pub label: &'static str,
135 pub class: &'static str,
136 pub n: u32,
137 pub count: String,
138}
139
140pub 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
149pub struct HBar {
151 pub label: &'static str,
152 pub value: String,
153 pub width: u32,
154 pub class: &'static str,
155}
156
157#[derive(Template)]
160#[template(path = "audit/live.html")]
161pub struct AuditLive {
162 pub base: String,
163 pub banner: bool,
164 pub active: bool,
166 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
249async 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(×),
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 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
453fn 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
476fn 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
539fn 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 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 let site_wide = rows.iter().find(|r| r.href.is_none()).unwrap();
651 assert_eq!(site_wide.pct, "—");
652 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}