1use 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
30const MAX_ROWS: i64 = 500;
32const 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 pub empty: Option<EmptyState>,
42 pub cmp: Option<Comparison>,
43}
44
45pub struct EmptyState {
46 pub title: &'static str,
47 pub text: String,
48 pub can_run: bool,
50}
51
52pub struct Comparison {
53 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 pub nothing: Option<String>,
61 pub tab_empty: Option<String>,
63 pub more: Option<String>,
65 pub spark: Vec<SparkBar>,
66 pub spark_first: String,
67 pub spark_last: String,
68 pub score_aside: String,
70 pub rules: Vec<Rule>,
71}
72
73pub struct Tile {
74 pub label: &'static str,
75 pub value: String,
76 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 pub severity: &'static str,
90 pub severity_label: &'static str,
91 pub title: String,
92 pub path: Option<String>,
94 pub href: Option<String>,
96 pub mode: &'static str,
97 pub mode_icon: &'static str,
98 pub before: String,
99 pub after: String,
100 pub after_class: &'static str,
102}
103
104pub struct SparkBar {
105 pub height: u32,
106 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
229fn 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 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
307fn 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
336fn 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
342fn 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 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
420fn 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
454fn 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
464fn 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}