Skip to main content

codoseo_web/routes/
changes.rs

1//! `/s/{site}/changes`: the latest finished crawl compared with the one before it. A diff
2//! summary, the changes themselves (filterable by severity with `?sev=`), the health-score
3//! history and the default alert rules.
4
5use askama::Template;
6use axum::Router;
7use axum::extract::{Path, Query, State};
8use axum::response::{IntoResponse, Response};
9use axum::routing::get;
10use codoseo_core::change::ChangeKind;
11use codoseo_core::check::Severity;
12use codoseo_store::crawls::{self, Crawl};
13use codoseo_store::reports::{self, ChangeCounts, ChangeRow, HealthPoint};
14use codoseo_store::sites::Site;
15use serde::Deserialize;
16use url::Url;
17
18use crate::auth::{CurrentUser, load_site};
19use crate::error::AppError;
20use crate::fmt;
21use crate::layout::{Screen, Shell};
22use crate::render::html;
23use crate::routes::audit::site_id;
24use crate::state::AppState;
25
26pub fn routes() -> Router<AppState> {
27    Router::new().route("/s/{site}/changes", get(page))
28}
29
30/// Most change rows one page shows; the tab counts still cover them all.
31const MAX_ROWS: i64 = 500;
32/// Finished crawls in the health-score chart.
33const HISTORY: i64 = 14;
34
35#[derive(Template)]
36#[template(path = "changes/index.html")]
37pub struct ChangesPage {
38    pub shell: Shell,
39    pub base: String,
40    /// Set when there is nothing to compare yet.
41    pub empty: Option<EmptyState>,
42    pub cmp: Option<Comparison>,
43}
44
45pub struct EmptyState {
46    pub title: &'static str,
47    pub text: String,
48    /// Offer Run crawl (no crawl is queued or running).
49    pub can_run: bool,
50}
51
52pub struct Comparison {
53    /// `#47 · Oct 2`
54    pub from_chip: String,
55    pub to_chip: String,
56    pub tiles: Vec<Tile>,
57    pub tabs: Vec<Tab>,
58    pub rows: Vec<ChangeView>,
59    /// Shown instead of rows: `Nothing changed between #47 and #48.`
60    pub nothing: Option<String>,
61    /// Shown when the current tab has no changes but others do.
62    pub tab_empty: Option<String>,
63    /// `Showing the first 500 of 1,234 changes.`
64    pub more: Option<String>,
65    pub spark: Vec<SparkBar>,
66    pub spark_first: String,
67    pub spark_last: String,
68    /// `86 · ↓ 7`
69    pub score_aside: String,
70    pub rules: Vec<Rule>,
71}
72
73pub struct Tile {
74    pub label: &'static str,
75    pub value: String,
76    /// Text colour class, or empty.
77    pub class: &'static str,
78}
79
80pub struct Tab {
81    pub label: &'static str,
82    pub href: String,
83    pub n: String,
84    pub active: bool,
85}
86
87pub struct ChangeView {
88    /// `critical`, `warning`, `notice`
89    pub severity: &'static str,
90    pub severity_label: &'static str,
91    pub title: String,
92    /// The URL's path and query; `None` for site-wide changes.
93    pub path: Option<String>,
94    /// Explorer selection link, when the URL is a page on the site that the latest crawl has.
95    pub href: Option<String>,
96    pub mode: &'static str,
97    pub mode_icon: &'static str,
98    pub before: String,
99    pub after: String,
100    /// ` critical`, ` warning` or empty, appended to the `.after` class.
101    pub after_class: &'static str,
102}
103
104pub struct SparkBar {
105    pub height: u32,
106    /// A background class, or empty for the soft default.
107    pub class: &'static str,
108    pub tip: String,
109}
110
111pub struct Rule {
112    pub label: &'static str,
113    pub mode: &'static str,
114    pub instant: bool,
115}
116
117#[derive(Deserialize)]
118pub struct ChangesQuery {
119    sev: Option<String>,
120}
121
122fn parse_severity(raw: Option<&str>) -> Option<Severity> {
123    raw.and_then(Severity::from_slug)
124}
125
126async fn page(
127    State(state): State<AppState>,
128    user: CurrentUser,
129    Path(site): Path<String>,
130    Query(q): Query<ChangesQuery>,
131) -> Result<Response, AppError> {
132    let site = load_site(&state, &user, site_id(&site)?).await?;
133    let shell = Shell::load(&state, &user, Some(&site), Screen::Changes).await?;
134    let base = format!("/s/{}", site.id);
135    let pool = &state.pool;
136
137    let latest = crawls::latest_done(pool, site.id).await?;
138    let previous = match &latest {
139        Some(l) => crawls::previous_done(pool, site.id, l.id).await?,
140        None => None,
141    };
142    let has_done = latest.is_some();
143    let (Some(latest), Some(previous)) = (latest, previous) else {
144        let can_run = crawls::active(pool, site.id).await?.is_none();
145        let empty = if has_done {
146            EmptyState {
147                title: "One crawl so far",
148                text: "Changes appear after your second crawl.".to_owned(),
149                can_run,
150            }
151        } else {
152            EmptyState {
153                title: "No finished crawl yet",
154                text: if can_run {
155                    "Each crawl is compared with the one before it. Run your first crawl to start."
156                        .to_owned()
157                } else {
158                    "Your first crawl is on its way. Changes appear after the second one."
159                        .to_owned()
160                },
161                can_run,
162            }
163        };
164        return Ok(html(&ChangesPage {
165            shell,
166            base,
167            empty: Some(empty),
168            cmp: None,
169        })?
170        .into_response());
171    };
172
173    let severity = parse_severity(q.sev.as_deref());
174    let counts = reports::change_kind_counts(pool, latest.id).await?;
175    let rows = reports::changes_for_crawl(pool, latest.id, severity, MAX_ROWS).await?;
176    let history = reports::health_history(pool, site.id, HISTORY).await?;
177
178    let in_tab = severity.map_or(counts.total(), |s| counts.severity(s));
179    let views = rows
180        .iter()
181        .map(|c| change_view(&site, &base, c, &previous, &latest))
182        .collect::<Vec<_>>();
183    let nothing = (counts.total() == 0).then(|| {
184        format!(
185            "Nothing changed between #{} and #{}.",
186            previous.number, latest.number
187        )
188    });
189    let tab_empty = (nothing.is_none() && views.is_empty()).then(|| match severity {
190        Some(s) => format!(
191            "No {} changes in this crawl.",
192            severity_label(s).1.to_lowercase()
193        ),
194        None => "No changes in this crawl.".to_owned(),
195    });
196    let more = (in_tab > views.len() as i64).then(|| {
197        format!(
198            "Showing the first {} of {} changes.",
199            fmt::thousands(views.len() as i64),
200            fmt::thousands(in_tab)
201        )
202    });
203    let (spark, spark_first, spark_last) = spark(&history);
204
205    let cmp = Comparison {
206        from_chip: chip(&previous),
207        to_chip: chip(&latest),
208        tiles: tiles(&counts),
209        tabs: tabs(&base, &counts, severity),
210        rows: views,
211        nothing,
212        tab_empty,
213        more,
214        spark,
215        spark_first,
216        spark_last,
217        score_aside: score_aside(&latest, &previous),
218        rules: rules(),
219    };
220    Ok(html(&ChangesPage {
221        shell,
222        base,
223        empty: None,
224        cmp: Some(cmp),
225    })?
226    .into_response())
227}
228
229/// `#47 · Oct 2`
230fn chip(c: &Crawl) -> String {
231    match c.finished_at {
232        Some(at) => format!("#{} · {}", c.number, fmt::date(at)),
233        None => format!("#{}", c.number),
234    }
235}
236
237fn tiles(c: &ChangeCounts) -> Vec<Tile> {
238    let tone = |n: i64, class| if n > 0 { class } else { "" };
239    // A sign only on a real change: "+12", "−34", but a plain "0".
240    let signed = |sign: char, n| {
241        if n == 0 {
242            "0".to_owned()
243        } else {
244            format!("{sign}{}", fmt::thousands(n))
245        }
246    };
247    let new = c.kind(ChangeKind::NewUrl);
248    let removed = c.kind(ChangeKind::RemovedUrl);
249    let noindex = c.kind(ChangeKind::BecameNoindex);
250    let titles = c.kind(ChangeKind::TitleChanged) + c.kind(ChangeKind::TitleRemoved);
251    vec![
252        Tile {
253            label: "New URLs",
254            value: signed('+', new),
255            class: tone(new, "c-ok"),
256        },
257        Tile {
258            label: "Removed URLs",
259            value: signed('−', removed),
260            class: tone(removed, "c-err"),
261        },
262        Tile {
263            label: "Status changed",
264            value: fmt::thousands(c.kind(ChangeKind::StatusChanged)),
265            class: "",
266        },
267        Tile {
268            label: "Became non-indexable",
269            value: fmt::thousands(noindex),
270            class: tone(noindex, "c-err"),
271        },
272        Tile {
273            label: "Titles changed",
274            value: fmt::thousands(titles),
275            class: "",
276        },
277    ]
278}
279
280fn tabs(base: &str, c: &ChangeCounts, current: Option<Severity>) -> Vec<Tab> {
281    let mut tabs = vec![Tab {
282        label: "All",
283        href: format!("{base}/changes"),
284        n: fmt::thousands(c.total()),
285        active: current.is_none(),
286    }];
287    for s in [Severity::Critical, Severity::Warning, Severity::Notice] {
288        let (slug, label) = severity_label(s);
289        tabs.push(Tab {
290            label,
291            href: format!("{base}/changes?sev={slug}"),
292            n: fmt::thousands(c.severity(s)),
293            active: current == Some(s),
294        });
295    }
296    tabs
297}
298
299fn severity_label(s: Severity) -> (&'static str, &'static str) {
300    match s {
301        Severity::Critical => ("critical", "Critical"),
302        Severity::Warning => ("warning", "Warning"),
303        Severity::Notice => ("notice", "Notice"),
304    }
305}
306
307/// The change kinds that alert instantly under the default rules (spec section 10), matching
308/// the alert jobs `finalize` queues. Everything else waits for the Monday digest.
309fn is_instant(kind: ChangeKind) -> bool {
310    matches!(
311        kind,
312        ChangeKind::BecameNoindex
313            | ChangeKind::ErrorSpike
314            | ChangeKind::RobotsTxtChanged
315            | ChangeKind::SitemapShrank
316    )
317}
318
319fn title(c: &ChangeRow) -> String {
320    match c.kind {
321        ChangeKind::NewUrl => "New URL".to_owned(),
322        ChangeKind::RemovedUrl => "Removed URL".to_owned(),
323        ChangeKind::StatusChanged => format!("Status {} → {}", c.before, c.after),
324        ChangeKind::BecameNoindex => "Became noindex".to_owned(),
325        ChangeKind::TitleChanged => "Title changed".to_owned(),
326        ChangeKind::TitleRemoved => "Title removed".to_owned(),
327        ChangeKind::CanonicalChanged => "Canonical changed".to_owned(),
328        ChangeKind::RedirectChainGrew => "Redirect chain grew".to_owned(),
329        ChangeKind::RobotsTxtChanged => "robots.txt changed".to_owned(),
330        ChangeKind::SitemapShrank => "Sitemap lost URLs".to_owned(),
331        ChangeKind::ErrorSpike => "4xx/5xx spike".to_owned(),
332        ChangeKind::SiteMoved => "Site moved".to_owned(),
333    }
334}
335
336/// `example.com` and `www.example.com` are the same site.
337fn same_site(host: &str, domain: &str) -> bool {
338    let strip = |h: &str| h.trim_start_matches("www.").to_ascii_lowercase();
339    strip(host) == strip(domain)
340}
341
342/// One side of the diff as people read it: counts get units, blanks say what they mean.
343fn diff_value(c: &ChangeRow, value: &str, crawl: &Crawl) -> String {
344    let count = |unit: &str, units: &str| match value.parse::<i64>() {
345        Ok(n) => format!(
346            "{} {}",
347            fmt::thousands(n),
348            if n == 1 { unit } else { units }
349        ),
350        Err(_) => value.to_owned(),
351    };
352    match c.kind {
353        _ if value.is_empty() => match c.kind {
354            ChangeKind::NewUrl | ChangeKind::RemovedUrl => {
355                format!("not in crawl #{}", crawl.number)
356            }
357            ChangeKind::TitleRemoved => "no title".to_owned(),
358            _ => "—".to_owned(),
359        },
360        ChangeKind::ErrorSpike => count("error page", "error pages"),
361        ChangeKind::SitemapShrank => count("URL", "URLs"),
362        ChangeKind::RedirectChainGrew => count("hop", "hops"),
363        _ => value.to_owned(),
364    }
365}
366
367fn change_view(
368    site: &Site,
369    base: &str,
370    c: &ChangeRow,
371    previous: &Crawl,
372    latest: &Crawl,
373) -> ChangeView {
374    let (severity, severity_label) = severity_label(c.severity);
375    let parsed = c.url.as_deref().and_then(|u| Url::parse(u).ok());
376    let path = match (&parsed, &c.url) {
377        (Some(u), _) => Some(match u.query() {
378            Some(q) => format!("{}?{q}", u.path()),
379            None => u.path().to_owned(),
380        }),
381        (None, Some(raw)) => Some(raw.clone()),
382        (None, None) => None,
383    };
384    // A removed URL isn't in the latest crawl, so there is nothing to select in the explorer.
385    let href = parsed
386        .as_ref()
387        .filter(|u| {
388            c.kind != ChangeKind::RemovedUrl
389                && u.host_str().is_some_and(|h| same_site(h, &site.domain))
390        })
391        .map(|u| {
392            format!(
393                "{base}/explorer?sel={:016x}",
394                codoseo_core::url::url_hash(u)
395            )
396        });
397    let instant = is_instant(c.kind);
398    ChangeView {
399        severity,
400        severity_label,
401        title: title(c),
402        path,
403        href,
404        mode: if instant {
405            "Instant alert"
406        } else {
407            "Weekly digest"
408        },
409        mode_icon: if instant { "i-bell" } else { "i-calendar" },
410        before: diff_value(c, &c.before, previous),
411        after: diff_value(c, &c.after, latest),
412        after_class: match c.severity {
413            Severity::Critical => " critical",
414            Severity::Warning => " warning",
415            Severity::Notice => "",
416        },
417    }
418}
419
420/// Bars scaled over the min..max score range (at least 6% tall). The latest bar is red when
421/// the score dropped.
422fn spark(history: &[HealthPoint]) -> (Vec<SparkBar>, String, String) {
423    let min = history.iter().map(|h| h.score).min().unwrap_or(0);
424    let max = history.iter().map(|h| h.score).max().unwrap_or(0);
425    let dropped = match history {
426        [.., a, b] => b.score < a.score,
427        _ => false,
428    };
429    let last = history.len().saturating_sub(1);
430    let bars = history
431        .iter()
432        .enumerate()
433        .map(|(i, h)| {
434            let height = if max > min {
435                6 + u32::try_from((h.score - min) * 94 / (max - min)).unwrap_or(0)
436            } else {
437                100
438            };
439            SparkBar {
440                height,
441                class: match (i == last, dropped) {
442                    (true, true) => "bg-err",
443                    (true, false) => "bg-ok",
444                    _ => "",
445                },
446                tip: format!("#{} · {}", h.number, h.score),
447            }
448        })
449        .collect();
450    let label = |h: Option<&HealthPoint>| h.map(|h| format!("#{}", h.number)).unwrap_or_default();
451    (bars, label(history.first()), label(history.last()))
452}
453
454/// `86 · ↓ 7`
455fn score_aside(latest: &Crawl, previous: &Crawl) -> String {
456    match (latest.health_score, previous.health_score) {
457        (Some(a), Some(b)) if a < b => format!("{a} · ↓ {}", b - a),
458        (Some(a), Some(b)) if a > b => format!("{a} · ↑ {}", a - b),
459        (Some(a), _) => a.to_string(),
460        (None, _) => String::new(),
461    }
462}
463
464/// The default alert rules (spec section 10).
465fn rules() -> Vec<Rule> {
466    let instant = |label| Rule {
467        label,
468        mode: "Instant",
469        instant: true,
470    };
471    vec![
472        instant("Key page becomes noindex"),
473        instant("Any 5xx or new 4xx spike"),
474        instant("robots.txt changes"),
475        instant("Sitemap loses 10%+ URLs"),
476        Rule {
477            label: "Everything else",
478            mode: "Monday digest",
479            instant: false,
480        },
481    ]
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487
488    fn point(number: i64, score: i16) -> HealthPoint {
489        HealthPoint { number, score }
490    }
491
492    #[test]
493    fn spark_scales_over_the_range_and_flags_a_drop() {
494        let (bars, first, last) = spark(&[point(3, 80), point(4, 90), point(5, 85)]);
495        assert_eq!(
496            bars.iter().map(|b| b.height).collect::<Vec<_>>(),
497            [6, 100, 53]
498        );
499        assert_eq!(bars[2].class, "bg-err");
500        assert_eq!(bars[0].class, "");
501        assert_eq!((first.as_str(), last.as_str()), ("#3", "#5"));
502
503        let (bars, ..) = spark(&[point(1, 70), point(2, 70)]);
504        assert_eq!(bars[1].height, 100);
505        assert_eq!(bars[1].class, "bg-ok");
506        assert!(spark(&[]).0.is_empty());
507    }
508
509    #[test]
510    fn severity_query_values() {
511        assert_eq!(parse_severity(Some("warning")), Some(Severity::Warning));
512        assert_eq!(parse_severity(Some("WARNING")), None);
513        assert_eq!(parse_severity(None), None);
514    }
515
516    #[test]
517    fn hosts_match_with_or_without_www() {
518        assert!(same_site("www.Example.com", "example.com"));
519        assert!(same_site("example.com", "www.example.com"));
520        assert!(!same_site("other.com", "example.com"));
521    }
522}