1use std::fmt::Write as _;
6
7use askama::Template;
8use axum::Router;
9use axum::extract::{Path, Query, State};
10use axum::http::{HeaderName, HeaderValue, StatusCode};
11use axum::response::{IntoResponse, Redirect, Response};
12use axum::routing::get;
13use codoseo_core::output::StopReason;
14use codoseo_core::plan::{Plan, PlanLimits};
15use codoseo_store::crawl_queue::CrawlTrigger;
16use codoseo_store::crawls::{Crawl, CrawlStatus, ManualOutcome, ManualWindow};
17use codoseo_store::sites::Site;
18use serde::Deserialize;
19use serde_json::json;
20use time::OffsetDateTime;
21use uuid::Uuid;
22
23use crate::auth::{CurrentUser, load_site};
24use crate::crawl_policy::{allowance_phrase, limit_message, manual_priority, plan_name, until};
25use crate::error::AppError;
26use crate::fmt;
27use crate::layout::{CrawlerView, Screen, Shell, crawler_for};
28use crate::render::{Hx, html};
29use crate::state::AppState;
30
31pub fn routes() -> Router<AppState> {
32 Router::new()
33 .route("/s/{site}/crawls", get(history).post(run))
34 .route("/s/{site}/status", get(status))
35}
36
37const HISTORY_LIMIT: i64 = 100;
39
40const COLS: &str = "52px minmax(190px, 2fr) minmax(96px, 1fr) 76px 84px 84px 92px 16px";
42
43pub struct CrawlRow {
45 pub tag: &'static str,
47 pub href: Option<String>,
48 pub number: String,
50 pub state: &'static str,
52 pub badge_class: &'static str,
53 pub label: &'static str,
54 pub detail: Option<String>,
56 pub detail_title: Option<String>,
58 pub detail_class: &'static str,
59 pub trigger: &'static str,
60 pub health: Option<(String, &'static str)>,
62 pub pages: String,
63 pub duration: String,
64 pub when: String,
66 pub when_full: String,
68}
69
70#[derive(Template)]
71#[template(path = "crawls/index.html")]
72pub struct CrawlsPage {
73 pub shell: Shell,
74 pub base: String,
75 pub total: String,
76 pub allowance: Option<String>,
77 pub list: CrawlList,
78}
79
80#[derive(Template)]
83#[template(path = "crawls/list.html")]
84pub struct CrawlList {
85 pub base: String,
86 pub domain: String,
87 pub cols: &'static str,
88 pub rows: Vec<CrawlRow>,
89 pub live: bool,
90 pub oob: Option<(String, Option<String>)>,
92}
93
94#[derive(Template)]
95#[template(path = "partials/crawler_partial.html")]
96pub struct CrawlerPartial {
97 pub crawler: CrawlerView,
98}
99
100fn parse_site_id(raw: &str) -> Result<Uuid, AppError> {
102 raw.parse().map_err(|_| AppError::NotFound)
103}
104
105async fn history(
106 State(state): State<AppState>,
107 user: CurrentUser,
108 hx: Hx,
109 Path(site): Path<String>,
110) -> Result<Response, AppError> {
111 let site = load_site(&state, &user, parse_site_id(&site)?).await?;
112 let crawls = codoseo_store::crawls::history(&state.pool, site.id, HISTORY_LIMIT).await?;
113 let now = OffsetDateTime::now_utc();
114 let base = format!("/s/{}", site.id);
115 let total = match crawls.first().map_or(0, |c| c.number) {
117 1 => "1 crawl".to_owned(),
118 n => format!("{} crawls", fmt::thousands(n)),
119 };
120 let allowance = allowance_note(&state, &site, user.account.plan, now).await?;
121 let mut list = CrawlList {
122 base: base.clone(),
123 domain: site.domain.clone(),
124 cols: COLS,
125 live: crawls.iter().any(Crawl::is_active),
126 rows: crawls.iter().map(|c| row(&base, c, now)).collect(),
127 oob: None,
128 };
129 if hx.partial() {
130 list.oob = Some((total, allowance));
131 return Ok(html(&list)?.into_response());
132 }
133 let shell = Shell::load(&state, &user, Some(&site), Screen::Crawls).await?;
134 Ok(html(&CrawlsPage {
135 shell,
136 base,
137 total,
138 allowance,
139 list,
140 })?
141 .into_response())
142}
143
144async fn run(
147 State(state): State<AppState>,
148 user: CurrentUser,
149 hx: Hx,
150 Path(site): Path<String>,
151) -> Result<Response, AppError> {
152 let site = load_site(&state, &user, parse_site_id(&site)?).await?;
153 let plan = user.account.plan;
154 let allowance = PlanLimits::for_plan(plan).manual_crawls;
155 let outcome = codoseo_store::crawls::enqueue_manual_checked(
156 &state.pool,
157 site.id,
158 &site.domain,
159 manual_priority(plan),
160 ManualWindow::for_allowance(allowance),
161 )
162 .await?;
163 let number = match outcome {
164 ManualOutcome::Queued { number, .. } => number,
165 ManualOutcome::Busy(status) => {
166 let what = if status == CrawlStatus::Running {
167 "running"
168 } else {
169 "queued"
170 };
171 return Err(AppError::Conflict(format!(
172 "A crawl is already {what} for this site."
173 )));
174 }
175 ManualOutcome::LimitReached { frees_at } => {
176 return Err(AppError::Limit(limit_message(
177 plan,
178 allowance,
179 frees_at - OffsetDateTime::now_utc(),
180 )));
181 }
182 };
183 if !hx.partial() {
184 return Ok(Redirect::to(&format!("/s/{}/crawls", site.id)).into_response());
185 }
186 let trigger = json!({
187 "toast": { "kind": "ok", "message": format!("Crawl #{number} queued") },
188 "crawlQueued": true,
189 });
190 Ok((StatusCode::NO_CONTENT, [hx_trigger(&trigger)]).into_response())
191}
192
193#[derive(Deserialize)]
194struct StatusQuery {
195 was: Option<String>,
197}
198
199async fn status(
203 State(state): State<AppState>,
204 user: CurrentUser,
205 Path(site): Path<String>,
206 Query(q): Query<StatusQuery>,
207) -> Result<Response, AppError> {
208 let site = load_site(&state, &user, parse_site_id(&site)?).await?;
209 let crawler = crawler_for(&state, &site, user.account.plan).await?;
210 let was_active = matches!(q.was.as_deref(), Some("queued" | "running"));
211 let now_active = matches!(crawler.state, "queued" | "running");
212 let mut res = html(&CrawlerPartial { crawler })?.into_response();
213 if was_active && !now_active {
214 let latest = codoseo_store::crawls::history(&state.pool, site.id, 1).await?;
215 if let Some(c) = latest.first() {
216 let trigger = json!({
217 "crawlFinished": true,
218 "toast": finished_toast(c),
219 });
220 let (name, value) = hx_trigger(&trigger);
221 res.headers_mut().insert(name, value);
222 }
223 }
224 Ok(res)
225}
226
227fn hx_trigger(events: &serde_json::Value) -> (HeaderName, HeaderValue) {
229 (
230 HeaderName::from_static("hx-trigger"),
231 HeaderValue::from_str(&ascii_json(events))
232 .unwrap_or_else(|_| HeaderValue::from_static("{}")),
233 )
234}
235
236fn ascii_json(v: &serde_json::Value) -> String {
239 let raw = v.to_string();
240 let mut out = String::with_capacity(raw.len());
241 for c in raw.chars() {
242 if c.is_ascii() {
243 out.push(c);
244 } else {
245 let mut units = [0u16; 2];
246 for unit in c.encode_utf16(&mut units) {
247 let _ = write!(out, "\\u{unit:04x}");
248 }
249 }
250 }
251 out
252}
253
254fn finished_toast(c: &Crawl) -> serde_json::Value {
257 if c.status == CrawlStatus::Failed {
258 let reason = c.failure_reason.as_deref().unwrap_or("unknown error");
259 return json!({ "kind": "error", "message": format!("Crawl #{} failed: {reason}", c.number) });
260 }
261 let message = match c.health_score {
262 Some(h) => format!("Crawl #{} finished · health {h}/100", c.number),
263 None => format!("Crawl #{} finished", c.number),
264 };
265 json!({ "kind": "ok", "message": message })
266}
267
268async fn allowance_note(
271 state: &AppState,
272 site: &Site,
273 plan: Plan,
274 now: OffsetDateTime,
275) -> Result<Option<String>, AppError> {
276 let allowance = PlanLimits::for_plan(plan).manual_crawls;
277 let (Some(window), Some(phrase)) = (
278 ManualWindow::for_allowance(allowance),
279 allowance_phrase(allowance),
280 ) else {
281 return Ok(None);
282 };
283 let since = now - window.window;
284 let used = codoseo_store::crawls::manual_count_since(&state.pool, site.id, since).await?;
285 let when = if used < window.max {
286 "available now".to_owned()
287 } else {
288 let oldest = codoseo_store::crawls::oldest_manual_since(&state.pool, site.id, since)
289 .await?
290 .unwrap_or(now);
291 format!("next one available {}", until(oldest + window.window - now))
292 };
293 Ok(Some(format!("{} plan: {phrase} · {when}", plan_name(plan))))
294}
295
296fn trigger_label(t: CrawlTrigger) -> &'static str {
297 match t {
298 CrawlTrigger::First => "First crawl",
299 CrawlTrigger::Manual => "Manual",
300 CrawlTrigger::Schedule => "Scheduled",
301 CrawlTrigger::Quick => "Quick audit",
302 }
303}
304
305fn health_tone(score: i16) -> &'static str {
306 match score {
307 s if s >= 90 => "c-ok",
308 s if s >= 70 => "c-warn",
309 _ => "c-err",
310 }
311}
312
313fn stop_note(stop: &StopReason) -> Option<String> {
315 match stop {
316 StopReason::Completed => None,
317 StopReason::PageLimit => Some("page limit reached".to_owned()),
318 StopReason::TimeLimit => Some("time limit reached".to_owned()),
319 StopReason::RobotsBlocked => Some("blocked by robots.txt".to_owned()),
320 StopReason::Unreachable(r) | StopReason::Blocked(r) => Some(r.clone()),
321 }
322}
323
324fn row(base: &str, c: &Crawl, now: OffsetDateTime) -> CrawlRow {
325 let progress = c.progress();
326 let summary = c.summary();
327 let dash = || "—".to_owned();
328 let pages_done = progress.as_ref().map(|p| p.pages_done);
329
330 let (state, badge_class, label, detail, detail_class) = match c.status {
331 CrawlStatus::Queued => {
332 let retry = c.failure_reason.is_some() && c.queued_at > now;
334 let detail = retry.then(|| format!("retry {}", until(c.queued_at - now)));
335 ("queued", "t-warn", "Queued", detail, "faint")
336 }
337 CrawlStatus::Running => {
338 let done = pages_done.unwrap_or(0);
339 let s = if done == 1 { "" } else { "s" };
340 let detail = Some(format!("{} page{s} so far", fmt::thousands(done)));
341 ("running", "t-muted", "Crawling", detail, "faint")
342 }
343 CrawlStatus::Done => {
344 let detail = summary.as_ref().and_then(|s| stop_note(&s.stop_reason));
345 ("done", "t-ok", "Done", detail, "faint")
346 }
347 CrawlStatus::Failed => {
348 let detail = Some(
349 c.failure_reason
350 .clone()
351 .unwrap_or_else(|| "unknown error".to_owned()),
352 );
353 ("failed", "t-err", "Failed", detail, "c-err")
354 }
355 };
356 let detail_title = match c.status {
357 CrawlStatus::Queued if detail.is_some() => c
358 .failure_reason
359 .as_ref()
360 .map(|r| format!("The last attempt failed: {r}")),
361 _ => detail.clone(),
362 };
363
364 let pages = match (c.status, &summary, pages_done) {
365 (CrawlStatus::Done, Some(s), _) => fmt::thousands(s.report_summary.pages),
366 (CrawlStatus::Running | CrawlStatus::Failed, _, Some(n)) => fmt::thousands(n),
367 _ => dash(),
368 };
369 let duration = match c.status {
370 CrawlStatus::Running => progress
371 .as_ref()
372 .map(|p| fmt::millis(p.elapsed_ms))
373 .or_else(|| c.started_at.map(|s| fmt::duration(now - s)))
374 .unwrap_or_else(dash),
375 _ => c.duration().map(fmt::duration).unwrap_or_else(dash),
376 };
377 let (verb, at) = match (c.finished_at, c.started_at) {
378 (Some(f), _) => ("Finished", f),
379 (None, Some(s)) => ("Started", s),
380 (None, None) => ("Queued", c.queued_at),
381 };
382 let href = (c.status == CrawlStatus::Done).then(|| format!("{base}/audit"));
383
384 CrawlRow {
385 tag: if href.is_some() { "a" } else { "div" },
386 href,
387 number: format!("#{}", c.number),
388 state,
389 badge_class,
390 label,
391 detail,
392 detail_title,
393 detail_class,
394 trigger: trigger_label(c.trigger),
395 health: c
396 .health_score
397 .filter(|_| c.status == CrawlStatus::Done)
398 .map(|h| (h.to_string(), health_tone(h))),
399 pages,
400 duration,
401 when: fmt::ago(at),
402 when_full: format!("{verb} {}", fmt::datetime(at)),
403 }
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409
410 #[test]
411 fn trigger_header_is_ascii_json() {
412 let events = json!({ "toast": { "message": "Crawl #2 finished · health 86/100 🦉" } });
413 let (_, value) = hx_trigger(&events);
414 let text = value.to_str().expect("visible ASCII only");
415 assert!(text.is_ascii() && text.contains("u00b7"), "{text}");
416 let back: serde_json::Value = serde_json::from_str(text).unwrap();
417 assert_eq!(back, events);
418 }
419
420 #[test]
421 fn health_tones() {
422 assert_eq!(health_tone(90), "c-ok");
423 assert_eq!(health_tone(89), "c-warn");
424 assert_eq!(health_tone(70), "c-warn");
425 assert_eq!(health_tone(69), "c-err");
426 }
427}