Skip to main content

codoseo_web/routes/
explorer.rs

1//! `/s/{site}/explorer`: the URL explorer, modelled on Screaming Frog. Filters down the left
2//! (response codes, indexability, content type, failing checks), the URL grid with live search
3//! and infinite scroll, and a detail panel with four tabs: URL details, SERP snippet, inlinks
4//! and rebuilt HTTP headers. All state lives in the URL (`?filter=&q=&sel=&tab=`), so any view
5//! can be shared or reloaded.
6//!
7//! Fragments: `explorer/rows` (100 grid rows per request, keyset-paginated on `pages.id`),
8//! `explorer/detail` (the `#detail` panel) and `key-pages` (the star toggle). Each one sets
9//! `HX-Replace-Url` where it changes what the page shows, so the address bar stays shareable.
10
11use std::collections::HashSet;
12
13use askama::Template;
14use axum::extract::{Path, Query, State};
15use axum::http::{HeaderName, HeaderValue, StatusCode};
16use axum::response::{Html, IntoResponse, Redirect, Response};
17use axum::routing::{get, post};
18use axum::{Form, Router};
19use codoseo_checks::{MAX_INLINK_SAMPLES, Scope, def};
20use codoseo_core::check::{CheckId, IssueBits, Severity};
21use codoseo_core::page::Indexability;
22use codoseo_store::crawls::{Crawl, StoredSummary};
23use codoseo_store::explorer::{FilterCounts, GridRow, Inlink, PageDetail, PageFilter};
24use codoseo_store::sites::Site;
25use serde::Deserialize;
26use url::Url;
27use uuid::Uuid;
28
29use crate::auth::{CurrentUser, load_site, urlencode};
30use crate::error::AppError;
31use crate::fmt;
32use crate::layout::{Screen, Shell};
33use crate::render::{Hx, html};
34use crate::serp::{DESCRIPTION_LIMIT_PX, TITLE_LIMIT_PX, description_px, title_px, truncate_to_px};
35use crate::state::AppState;
36
37pub fn routes() -> Router<AppState> {
38    Router::new()
39        .route("/s/{site}/explorer", get(page))
40        .route("/s/{site}/explorer/rows", get(rows))
41        .route("/s/{site}/explorer/detail", get(detail))
42        .route("/s/{site}/key-pages", post(toggle_key_page))
43}
44
45/// Grid rows per request.
46const PAGE_SIZE: usize = 100;
47/// Stored inlinks shown in the Inlinks tab.
48const INLINKS_SHOWN: i64 = 50;
49/// Thresholds the grid and the details tab highlight (matching the checks where one exists).
50const TITLE_MAX_CHARS: usize = 60;
51const TITLE_MIN_CHARS: usize = 30;
52const DESCRIPTION_MAX_CHARS: usize = 155;
53const DESCRIPTION_MIN_CHARS: usize = 70;
54const THIN_WORDS: u32 = 200;
55const DEEP_CLICKS: u32 = 4;
56const SLOW_MS: u32 = 1000;
57
58/// The query string every explorer route reads. Unknown or malformed values fall back to
59/// the defaults instead of failing.
60#[derive(Debug, Default, Deserialize)]
61pub struct Params {
62    #[serde(default)]
63    filter: String,
64    #[serde(default)]
65    q: String,
66    #[serde(default)]
67    sel: String,
68    #[serde(default)]
69    tab: String,
70    #[serde(default)]
71    after: String,
72}
73
74impl Params {
75    fn filter(&self) -> PageFilter {
76        PageFilter::parse(&self.filter)
77    }
78
79    fn q(&self) -> &str {
80        self.q.trim()
81    }
82
83    fn sel(&self) -> Option<u64> {
84        parse_hex(&self.sel)
85    }
86
87    fn tab(&self) -> Tab {
88        Tab::parse(&self.tab)
89    }
90}
91
92/// The detail panel's tabs.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum Tab {
95    Details,
96    Serp,
97    Inlinks,
98    Headers,
99}
100
101impl Tab {
102    const ALL: [Tab; 4] = [Tab::Details, Tab::Serp, Tab::Inlinks, Tab::Headers];
103
104    fn parse(s: &str) -> Tab {
105        match s {
106            "serp" => Tab::Serp,
107            "inlinks" => Tab::Inlinks,
108            "headers" => Tab::Headers,
109            _ => Tab::Details,
110        }
111    }
112
113    pub fn key(self) -> &'static str {
114        match self {
115            Tab::Details => "details",
116            Tab::Serp => "serp",
117            Tab::Inlinks => "inlinks",
118            Tab::Headers => "headers",
119        }
120    }
121
122    fn label(self) -> &'static str {
123        match self {
124            Tab::Details => "URL details",
125            Tab::Serp => "SERP snippet",
126            Tab::Inlinks => "Inlinks",
127            Tab::Headers => "HTTP headers",
128        }
129    }
130}
131
132/// A URL hash as it appears in URLs: 16 lowercase hex digits.
133pub fn hex(hash: u64) -> String {
134    format!("{hash:016x}")
135}
136
137fn parse_hex(s: &str) -> Option<u64> {
138    let s = s.trim();
139    if s.len() != 16 || !s.bytes().all(|b| b.is_ascii_hexdigit()) {
140        return None;
141    }
142    u64::from_str_radix(s, 16).ok()
143}
144
145/// The shareable explorer URL for a view. `tab` is left out when it's the default.
146fn explorer_url(base: &str, filter: PageFilter, q: &str, sel: Option<u64>, tab: Tab) -> String {
147    let mut url = format!("{base}/explorer?filter={}", filter.key());
148    if !q.is_empty() {
149        url.push_str("&q=");
150        url.push_str(&urlencode(q));
151    }
152    if let Some(h) = sel {
153        url.push_str("&sel=");
154        url.push_str(&hex(h));
155    }
156    if tab != Tab::Details {
157        url.push_str("&tab=");
158        url.push_str(tab.key());
159    }
160    url
161}
162
163fn replace_url(url: &str) -> (HeaderName, HeaderValue) {
164    (
165        HeaderName::from_static("hx-replace-url"),
166        HeaderValue::from_str(url).unwrap_or_else(|_| HeaderValue::from_static("")),
167    )
168}
169
170// ── Templates ────────────────────────────────────────────
171
172#[derive(Template)]
173#[template(path = "explorer/index.html")]
174pub struct ExplorerPage {
175    pub shell: Shell,
176    pub base: String,
177    /// `None` until the site has a finished crawl.
178    pub view: Option<ExplorerView>,
179    /// A crawl is queued or running (the empty state says so instead of offering one).
180    pub crawl_active: bool,
181}
182
183pub struct ExplorerView {
184    pub groups: Vec<FilterGroup>,
185    pub filter_key: String,
186    pub q: String,
187    pub rows_url: String,
188    pub info: InfoSpan,
189    /// The counted RankOrg link (cloud only).
190    pub rankorg: Option<String>,
191    pub rows: RowsFragment,
192    pub detail: DetailPanel,
193}
194
195pub struct FilterGroup {
196    pub title: &'static str,
197    pub items: Vec<FilterLink>,
198}
199
200pub struct FilterLink {
201    pub label: String,
202    /// `bg-*` class for the swatch.
203    pub swatch: &'static str,
204    pub count: String,
205    pub zero: bool,
206    pub href: String,
207    pub active: bool,
208}
209
210/// `All URLs · 1,284 shown of 1,284`. Swapped out of band after a search.
211#[derive(Template)]
212#[template(path = "explorer/info.html")]
213pub struct InfoSpan {
214    pub text: String,
215    pub oob: bool,
216}
217
218/// A batch of grid rows, plus the sentinel that loads the next batch when scrolled into view.
219#[derive(Template)]
220#[template(path = "explorer/rows.html")]
221pub struct RowsFragment {
222    pub rows: Vec<RowView>,
223    /// The next batch's URL, when there is one.
224    pub more: Option<String>,
225    /// The first batch is empty: no URL matches.
226    pub empty: bool,
227    /// The toolbar count, sent out of band with a search's first batch.
228    pub info: Option<InfoSpan>,
229}
230
231pub struct RowView {
232    pub url: String,
233    /// Path plus query, what the Address column shows.
234    pub path: String,
235    pub status: String,
236    /// `t-*` tone for the status badge.
237    pub status_tone: &'static str,
238    pub index_label: &'static str,
239    pub index_class: &'static str,
240    pub title: String,
241    pub title_class: &'static str,
242    pub title_len: String,
243    pub title_len_class: &'static str,
244    pub words: String,
245    pub words_class: &'static str,
246    pub depth: String,
247    pub depth_class: &'static str,
248    pub inlinks: String,
249    pub response: String,
250    pub response_class: &'static str,
251    pub star: StarButton,
252    pub selected: bool,
253    pub detail_href: String,
254}
255
256/// The key-page star, in a grid row (`at = "row"`) or the detail tab bar (`"detail"`).
257#[derive(Template)]
258#[template(path = "explorer/star.html")]
259pub struct StarButton {
260    pub base: String,
261    pub hex: String,
262    pub on: bool,
263    /// Play the pop animation (just starred).
264    pub pop: bool,
265    pub at: &'static str,
266}
267
268/// The `#detail` panel.
269#[derive(Template)]
270#[template(path = "explorer/detail.html")]
271pub struct DetailPanel {
272    /// `None` when nothing is selected (no URL matches).
273    pub page: Option<DetailView>,
274}
275
276pub struct DetailView {
277    pub hex: String,
278    pub url: String,
279    pub tab: &'static str,
280    pub tabs: Vec<TabLink>,
281    pub star: StarButton,
282    pub details: Option<DetailsTab>,
283    pub serp: Option<SerpView>,
284    pub inlinks: Option<InlinksTab>,
285    /// The rebuilt response, one header per line.
286    pub headers: Option<String>,
287}
288
289pub struct TabLink {
290    pub label: &'static str,
291    pub href: String,
292    pub active: bool,
293    pub count: Option<String>,
294}
295
296pub struct DetailsTab {
297    pub fields: Vec<Field>,
298    pub issues: Vec<IssueChip>,
299}
300
301pub struct Field {
302    pub k: &'static str,
303    pub v: String,
304    /// A `c-*` colour class, or empty.
305    pub class: &'static str,
306    /// Shown as a link to this URL (opens the page itself, in a new tab).
307    pub href: Option<String>,
308}
309
310pub struct IssueChip {
311    pub title: &'static str,
312    /// `sev-*` class.
313    pub sev: &'static str,
314    pub href: String,
315}
316
317pub struct SerpView {
318    pub crumb_host: String,
319    /// ` › blog › post`
320    pub crumb_path: String,
321    /// The title as it would show, cut with ` …`; `None` when missing.
322    pub title: Option<String>,
323    pub description: Option<String>,
324    pub meters: Vec<MeterView>,
325}
326
327pub struct MeterView {
328    pub label: &'static str,
329    /// `612 / 580 px`
330    pub value: String,
331    pub value_class: &'static str,
332    /// `bg-*` class for the bar.
333    pub bar: &'static str,
334    pub pct: u32,
335    /// Where the limit marker sits.
336    pub limit_pct: u32,
337    pub note: &'static str,
338}
339
340pub struct InlinksTab {
341    pub rows: Vec<InlinkView>,
342    pub note: String,
343}
344
345pub struct InlinkView {
346    pub from: String,
347    /// Opens the linking page in the explorer.
348    pub href: String,
349    pub anchor: String,
350    pub anchor_class: &'static str,
351    pub kind: &'static str,
352    pub kind_class: &'static str,
353}
354
355// ── Handlers ─────────────────────────────────────────────
356
357/// `GET /s/{site}/explorer`: the full screen.
358async fn page(
359    State(state): State<AppState>,
360    user: CurrentUser,
361    Path(site_id): Path<Uuid>,
362    Query(params): Query<Params>,
363) -> Result<Response, AppError> {
364    let site = load_site(&state, &user, site_id).await?;
365    let shell = Shell::load(&state, &user, Some(&site), Screen::Explorer).await?;
366    let pool = &state.pool;
367    let base = format!("/s/{}", site.id);
368    let Some(crawl) = codoseo_store::crawls::latest_done(pool, site.id).await? else {
369        let crawl_active = codoseo_store::crawls::active(pool, site.id)
370            .await?
371            .is_some();
372        return Ok(html(&ExplorerPage {
373            shell,
374            base,
375            view: None,
376            crawl_active,
377        })?
378        .into_response());
379    };
380
381    let filter = params.filter();
382    let q = params.q();
383    let tab = params.tab();
384    let counts = codoseo_store::explorer::filter_counts(pool, crawl.id).await?;
385    let (shown, total) = codoseo_store::explorer::match_count(pool, crawl.id, filter, q).await?;
386    let batch =
387        codoseo_store::explorer::rows(pool, crawl.id, filter, q, 0, PAGE_SIZE as i64 + 1).await?;
388
389    // The selected page: `sel` when it's in this crawl, else the first row.
390    let mut selected = match params.sel() {
391        Some(h) => codoseo_store::explorer::page(pool, site.id, crawl.id, h).await?,
392        None => None,
393    };
394    if selected.is_none()
395        && let Some(first) = batch.first()
396    {
397        selected = codoseo_store::explorer::page(pool, site.id, crawl.id, first.url_hash).await?;
398    }
399    let sel = selected.as_ref().map(|p| p.url_hash);
400
401    let key_pages: HashSet<u64> = site.key_pages.iter().copied().collect();
402    let rows = rows_fragment(&base, filter, q, sel, batch, &key_pages, true, None);
403    let detail = detail_panel(&state, &site, &crawl, selected, tab, &key_pages).await?;
404    let view = ExplorerView {
405        groups: filter_groups(&base, filter, &counts, crawl.summary().as_ref()),
406        filter_key: filter.key(),
407        q: q.to_owned(),
408        rows_url: format!("{base}/explorer/rows"),
409        info: InfoSpan {
410            text: info_text(filter, shown, total),
411            oob: false,
412        },
413        rankorg: (state.config.mode == crate::config::Mode::Cloud)
414            .then(|| format!("/go/rankorg?src=explorer&site={}", site.id)),
415        rows,
416        detail,
417    };
418    Ok(html(&ExplorerPage {
419        shell,
420        base,
421        view: Some(view),
422        crawl_active: false,
423    })?
424    .into_response())
425}
426
427/// `GET /s/{site}/explorer/rows`: one batch of grid rows. Without `after` it's a fresh search,
428/// so it also swaps the toolbar count and updates the address bar.
429async fn rows(
430    State(state): State<AppState>,
431    user: CurrentUser,
432    hx: Hx,
433    Path(site_id): Path<Uuid>,
434    Query(params): Query<Params>,
435) -> Result<Response, AppError> {
436    let site = load_site(&state, &user, site_id).await?;
437    let base = format!("/s/{}", site.id);
438    let (filter, q, sel, tab) = (params.filter(), params.q(), params.sel(), params.tab());
439    let canonical = explorer_url(&base, filter, q, sel, tab);
440    if !hx.partial() {
441        return Ok(Redirect::to(&canonical).into_response());
442    }
443    let pool = &state.pool;
444    let crawl = codoseo_store::crawls::latest_done(pool, site.id)
445        .await?
446        .ok_or(AppError::NotFound)?;
447    let after = params.after.trim().parse::<i64>().ok();
448    let batch = codoseo_store::explorer::rows(
449        pool,
450        crawl.id,
451        filter,
452        q,
453        after.unwrap_or(0),
454        PAGE_SIZE as i64 + 1,
455    )
456    .await?;
457    let key_pages: HashSet<u64> = site.key_pages.iter().copied().collect();
458    let first = after.is_none();
459    let info = if first {
460        let (shown, total) =
461            codoseo_store::explorer::match_count(pool, crawl.id, filter, q).await?;
462        Some(InfoSpan {
463            text: info_text(filter, shown, total),
464            oob: true,
465        })
466    } else {
467        None
468    };
469    let fragment = rows_fragment(&base, filter, q, sel, batch, &key_pages, first, info);
470    let body = html(&fragment)?;
471    Ok(if first {
472        ([replace_url(&canonical)], body).into_response()
473    } else {
474        body.into_response()
475    })
476}
477
478/// `GET /s/{site}/explorer/detail`: the `#detail` panel for one page and tab.
479async fn detail(
480    State(state): State<AppState>,
481    user: CurrentUser,
482    hx: Hx,
483    Path(site_id): Path<Uuid>,
484    Query(params): Query<Params>,
485) -> Result<Response, AppError> {
486    let site = load_site(&state, &user, site_id).await?;
487    let base = format!("/s/{}", site.id);
488    let (filter, q, sel, tab) = (params.filter(), params.q(), params.sel(), params.tab());
489    let canonical = explorer_url(&base, filter, q, sel, tab);
490    if !hx.partial() {
491        return Ok(Redirect::to(&canonical).into_response());
492    }
493    let sel = sel.ok_or_else(|| AppError::BadRequest("Pick a URL from the list.".to_owned()))?;
494    let pool = &state.pool;
495    let crawl = codoseo_store::crawls::latest_done(pool, site.id)
496        .await?
497        .ok_or(AppError::NotFound)?;
498    let page = codoseo_store::explorer::page(pool, site.id, crawl.id, sel)
499        .await?
500        .ok_or(AppError::NotFound)?;
501    let key_pages: HashSet<u64> = site.key_pages.iter().copied().collect();
502    let panel = detail_panel(&state, &site, &crawl, Some(page), tab, &key_pages).await?;
503    Ok(([replace_url(&canonical)], html(&panel)?).into_response())
504}
505
506#[derive(Deserialize)]
507struct StarForm {
508    hash: String,
509}
510
511#[derive(Deserialize, Default)]
512struct StarAt {
513    #[serde(default)]
514    at: String,
515}
516
517/// `POST /s/{site}/key-pages`: stars or unstars a page. Returns the clicked star; the
518/// `keypage` event in `HX-Trigger` lets the other star for the same page follow.
519async fn toggle_key_page(
520    State(state): State<AppState>,
521    user: CurrentUser,
522    Path(site_id): Path<Uuid>,
523    Query(at): Query<StarAt>,
524    Form(form): Form<StarForm>,
525) -> Result<Response, AppError> {
526    let site = load_site(&state, &user, site_id).await?;
527    let pool = &state.pool;
528    let hash = parse_hex(&form.hash)
529        .ok_or_else(|| AppError::BadRequest("That isn't a page of this site.".to_owned()))?;
530    // Only pages the site actually has can be starred (unstarring always works).
531    if !site.key_pages.contains(&hash)
532        && !codoseo_store::explorer::page_exists(pool, site.id, hash).await?
533    {
534        return Err(AppError::NotFound);
535    }
536    let on = codoseo_store::sites::toggle_key_page(pool, user.id(), site.id, hash)
537        .await?
538        .ok_or(AppError::NotFound)?;
539    let star = StarButton {
540        base: format!("/s/{}", site.id),
541        hex: hex(hash),
542        on,
543        pop: on,
544        at: if at.at == "detail" { "detail" } else { "row" },
545    };
546    let message = if on {
547        "Marked as a key page. Changes to it are alerted first."
548    } else {
549        "Removed from key pages"
550    };
551    let trigger = serde_json::json!({
552        "toast": { "kind": "ok", "message": message },
553        "keypage": { "hash": star.hex, "on": on },
554    });
555    let trigger = HeaderValue::from_str(&trigger.to_string()).map_err(AppError::internal)?;
556    Ok((
557        StatusCode::OK,
558        [(HeaderName::from_static("hx-trigger"), trigger)],
559        Html(star.render()?),
560    )
561        .into_response())
562}
563
564// ── View building ────────────────────────────────────────
565
566fn filter_label(filter: PageFilter) -> String {
567    match filter {
568        PageFilter::All => "All URLs",
569        PageFilter::Status2xx => "2xx Success",
570        PageFilter::Status3xx => "3xx Redirect",
571        PageFilter::Status4xx => "4xx Client error",
572        PageFilter::Status5xx => "5xx Server error",
573        PageFilter::NoResponse => "No response",
574        PageFilter::Indexable => "Indexable",
575        PageFilter::NonIndexable => "Non-indexable",
576        PageFilter::Html => "HTML",
577        PageFilter::Image => "Images",
578        PageFilter::Other => "Other",
579        PageFilter::Check(id) => def(id).title,
580    }
581    .to_owned()
582}
583
584fn info_text(filter: PageFilter, shown: i64, total: i64) -> String {
585    format!(
586        "{} · {} shown of {}",
587        filter_label(filter),
588        fmt::thousands(shown),
589        fmt::thousands(total)
590    )
591}
592
593fn severity_swatch(s: Severity) -> &'static str {
594    match s {
595        Severity::Critical => "bg-err",
596        Severity::Warning => "bg-warn",
597        Severity::Notice => "bg-ghost",
598    }
599}
600
601fn severity_badge(s: Severity) -> &'static str {
602    match s {
603        Severity::Critical => "sev-critical",
604        Severity::Warning => "sev-warning",
605        Severity::Notice => "sev-notice",
606    }
607}
608
609fn filter_groups(
610    base: &str,
611    active: PageFilter,
612    counts: &FilterCounts,
613    summary: Option<&StoredSummary>,
614) -> Vec<FilterGroup> {
615    let link = |f: PageFilter, n: i64, swatch: &'static str| FilterLink {
616        label: filter_label(f),
617        swatch,
618        count: fmt::thousands(n),
619        zero: n == 0,
620        href: format!("{base}/explorer?filter={}", f.key()),
621        active: f == active,
622    };
623    let fixed = |f: PageFilter, swatch| link(f, counts.get(f).unwrap_or(0), swatch);
624
625    let mut codes = vec![
626        fixed(PageFilter::All, "bg-ink"),
627        fixed(PageFilter::Status2xx, "bg-ok"),
628        fixed(PageFilter::Status3xx, "bg-warn"),
629        fixed(PageFilter::Status4xx, "bg-err"),
630        fixed(PageFilter::Status5xx, "bg-5xx"),
631    ];
632    if counts.s0 > 0 || active == PageFilter::NoResponse {
633        codes.push(fixed(PageFilter::NoResponse, "bg-ghost"));
634    }
635
636    // Failing checks that set page bits: critical first, then by pages affected.
637    let mut failing: Vec<(CheckId, u32)> = summary
638        .map(|s| {
639            s.counts
640                .iter()
641                .filter_map(|(slug, n)| Some((CheckId::from_slug(slug)?, *n)))
642                .filter(|(id, _)| def(*id).scope != Scope::SiteWide)
643                .collect()
644        })
645        .unwrap_or_default();
646    if let PageFilter::Check(id) = active
647        && !failing.iter().any(|(c, _)| *c == id)
648    {
649        failing.push((id, 0));
650    }
651    failing.sort_by_key(|(id, n)| (def(*id).severity, std::cmp::Reverse(*n), *id));
652    let issues = failing
653        .into_iter()
654        .map(|(id, n)| {
655            link(
656                PageFilter::Check(id),
657                i64::from(n),
658                severity_swatch(def(id).severity),
659            )
660        })
661        .collect::<Vec<_>>();
662
663    let mut groups = vec![
664        FilterGroup {
665            title: "Response codes",
666            items: codes,
667        },
668        FilterGroup {
669            title: "Indexability",
670            items: vec![
671                fixed(PageFilter::Indexable, "bg-ok"),
672                fixed(PageFilter::NonIndexable, "bg-ghost"),
673            ],
674        },
675        FilterGroup {
676            title: "Content type",
677            items: vec![
678                fixed(PageFilter::Html, "bg-blue"),
679                fixed(PageFilter::Image, "bg-accent"),
680                fixed(PageFilter::Other, "bg-ghost"),
681            ],
682        },
683    ];
684    if !issues.is_empty() {
685        groups.push(FilterGroup {
686            title: "Issues",
687            items: issues,
688        });
689    }
690    groups
691}
692
693#[allow(clippy::too_many_arguments)]
694fn rows_fragment(
695    base: &str,
696    filter: PageFilter,
697    q: &str,
698    sel: Option<u64>,
699    mut batch: Vec<GridRow>,
700    key_pages: &HashSet<u64>,
701    first: bool,
702    info: Option<InfoSpan>,
703) -> RowsFragment {
704    let more = if batch.len() > PAGE_SIZE {
705        batch.truncate(PAGE_SIZE);
706        batch.last().map(|last| {
707            let mut url = format!("{base}/explorer/rows?filter={}", filter.key());
708            if !q.is_empty() {
709                url.push_str("&q=");
710                url.push_str(&urlencode(q));
711            }
712            url.push_str(&format!("&after={}", last.id));
713            url
714        })
715    } else {
716        None
717    };
718    RowsFragment {
719        empty: first && batch.is_empty(),
720        rows: batch
721            .iter()
722            .map(|r| row_view(base, r, sel == Some(r.url_hash), key_pages))
723            .collect(),
724        more,
725        info,
726    }
727}
728
729/// A 2xx response that reads as an HTML page (no content type counts, as in the checks).
730fn is_html_ok(status: u16, content_type: Option<&str>) -> bool {
731    (200..300).contains(&status)
732        && content_type.is_none_or(|ct| {
733            let ct = ct.to_ascii_lowercase();
734            ct.contains("text/html") || ct.contains("application/xhtml+xml")
735        })
736}
737
738fn status_tone(status: u16) -> &'static str {
739    match status {
740        200..=299 => "t-ok",
741        300..=399 => "t-warn",
742        400..=499 => "t-err",
743        500..=599 => "t-5xx",
744        _ => "t-muted",
745    }
746}
747
748fn status_colour(status: u16) -> &'static str {
749    match status {
750        200..=299 => "c-ok",
751        300..=399 => "c-warn",
752        400..=599 => "c-err",
753        _ => "c-muted",
754    }
755}
756
757/// `301 Moved Permanently`; `No response` for status 0.
758fn status_line(status: u16, indexability: Indexability) -> String {
759    if status == 0 {
760        return if indexability == Indexability::BlockedByRobots {
761            "Not fetched (blocked by robots.txt)".to_owned()
762        } else {
763            "No response".to_owned()
764        };
765    }
766    match StatusCode::from_u16(status)
767        .ok()
768        .and_then(|c| c.canonical_reason())
769    {
770        Some(reason) => format!("{status} {reason}"),
771        None => status.to_string(),
772    }
773}
774
775fn indexability_view(i: Indexability) -> (&'static str, &'static str) {
776    match i {
777        Indexability::Indexable => ("Indexable", "c-ok"),
778        Indexability::Noindex => ("Noindex", "c-warn"),
779        Indexability::Canonicalised => ("Canonicalised", "c-warn"),
780        Indexability::Redirected => ("Redirected", "c-warn"),
781        Indexability::ClientError => ("Client error", "c-err"),
782        Indexability::ServerError => ("Server error", "c-err"),
783        Indexability::BlockedByRobots => ("Blocked by robots.txt", "c-muted"),
784    }
785}
786
787/// Path plus query of a URL, what the Address column shows.
788fn path_of(url: &str) -> String {
789    match Url::parse(url) {
790        Ok(u) => match u.query() {
791            Some(q) => format!("{}?{q}", u.path()),
792            None => u.path().to_owned(),
793        },
794        Err(_) => url.to_owned(),
795    }
796}
797
798fn row_view(base: &str, r: &GridRow, selected: bool, key_pages: &HashSet<u64>) -> RowView {
799    let html_ok = is_html_ok(r.status, r.content_type.as_deref());
800    let (index_label, index_class) = indexability_view(r.indexability);
801    let title = r.title.as_deref().map(str::trim).unwrap_or_default();
802    let title_chars = title.chars().count();
803    let (title_text, title_class) = if title.is_empty() && html_ok {
804        ("Missing".to_owned(), "c-err")
805    } else {
806        (title.to_owned(), "")
807    };
808    let (words, words_class) = match r.word_count {
809        Some(n) if html_ok => (
810            fmt::thousands(n),
811            if n < THIN_WORDS { "c-warn" } else { "" },
812        ),
813        _ => ("—".to_owned(), "c-ghost"),
814    };
815    let (depth, depth_class) = match r.depth {
816        Some(d) => (d.to_string(), if d > DEEP_CLICKS { "c-warn" } else { "" }),
817        None => ("—".to_owned(), "c-ghost"),
818    };
819    let (response, response_class) = match r.response_ms {
820        Some(ms) => (
821            format!("{} ms", fmt::thousands(ms)),
822            if ms > SLOW_MS { "c-err" } else { "" },
823        ),
824        None => ("—".to_owned(), "c-ghost"),
825    };
826    let status = match r.status {
827        0 if r.indexability == Indexability::BlockedByRobots => "—".to_owned(),
828        0 => "ERR".to_owned(),
829        s => s.to_string(),
830    };
831    let h = hex(r.url_hash);
832    RowView {
833        url: r.url.clone(),
834        path: path_of(&r.url),
835        status,
836        status_tone: if r.status == 0 && r.indexability != Indexability::BlockedByRobots {
837            "t-err"
838        } else {
839            status_tone(r.status)
840        },
841        index_label,
842        index_class,
843        title: title_text,
844        title_class,
845        title_len: if title.is_empty() {
846            String::new()
847        } else {
848            title_chars.to_string()
849        },
850        title_len_class: if title_chars > TITLE_MAX_CHARS {
851            "c-warn"
852        } else {
853            ""
854        },
855        words,
856        words_class,
857        depth,
858        depth_class,
859        inlinks: fmt::thousands(r.inlinks),
860        response,
861        response_class,
862        star: StarButton {
863            base: base.to_owned(),
864            hex: h.clone(),
865            on: key_pages.contains(&r.url_hash),
866            pop: false,
867            at: "row",
868        },
869        selected,
870        detail_href: format!("{base}/explorer/detail?sel={h}"),
871    }
872}
873
874async fn detail_panel(
875    state: &AppState,
876    site: &Site,
877    crawl: &Crawl,
878    page: Option<PageDetail>,
879    tab: Tab,
880    key_pages: &HashSet<u64>,
881) -> Result<DetailPanel, AppError> {
882    let Some(p) = page else {
883        return Ok(DetailPanel { page: None });
884    };
885    let base = format!("/s/{}", site.id);
886    let h = hex(p.url_hash);
887    let tabs = Tab::ALL
888        .iter()
889        .map(|&t| TabLink {
890            label: t.label(),
891            href: format!("{base}/explorer/detail?sel={h}&tab={}", t.key()),
892            active: t == tab,
893            count: (t == Tab::Inlinks).then(|| fmt::thousands(p.inlinks)),
894        })
895        .collect();
896    let mut view = DetailView {
897        hex: h.clone(),
898        url: p.url.clone(),
899        tab: tab.key(),
900        tabs,
901        star: StarButton {
902            base: base.clone(),
903            hex: h,
904            on: key_pages.contains(&p.url_hash),
905            pop: false,
906            at: "detail",
907        },
908        details: None,
909        serp: None,
910        inlinks: None,
911        headers: None,
912    };
913    match tab {
914        Tab::Details => view.details = Some(details_tab(&base, &p)),
915        Tab::Serp => view.serp = Some(serp_view(&p)),
916        Tab::Inlinks => {
917            let links =
918                codoseo_store::explorer::inlinks(&state.pool, crawl.id, p.url_hash, INLINKS_SHOWN)
919                    .await?;
920            view.inlinks = Some(inlinks_tab(&base, links));
921        }
922        Tab::Headers => view.headers = Some(headers_text(&p)),
923    }
924    Ok(DetailPanel { page: Some(view) })
925}
926
927fn field(k: &'static str, v: impl Into<String>, class: &'static str) -> Field {
928    Field {
929        k,
930        v: v.into(),
931        class,
932        href: None,
933    }
934}
935
936/// Text or a dash when it's missing.
937fn or_dash(k: &'static str, v: Option<&str>) -> Field {
938    match v.map(str::trim).filter(|s| !s.is_empty()) {
939        Some(s) => field(k, s, ""),
940        None => field(k, "—", "c-ghost"),
941    }
942}
943
944/// `58 chars`, `72 chars · too long`.
945fn length_field(k: &'static str, text: Option<&str>, (min, max): (usize, usize)) -> Field {
946    let n = text.map_or(0, |t| t.trim().chars().count());
947    if n == 0 {
948        return field(k, "—", "c-ghost");
949    }
950    let unit = if n == 1 { "char" } else { "chars" };
951    if n > max {
952        field(k, format!("{n} {unit} · too long"), "c-warn")
953    } else if n < min {
954        field(k, format!("{n} {unit} · too short"), "c-warn")
955    } else {
956        field(k, format!("{n} {unit}"), "")
957    }
958}
959
960/// Missing text on a readable HTML page is an issue (red); elsewhere it's just absent.
961fn text_field(k: &'static str, text: Option<&str>, html_ok: bool) -> Field {
962    match text.map(str::trim).filter(|s| !s.is_empty()) {
963        Some(s) => field(k, s, ""),
964        None if html_ok => field(k, "Missing", "c-err"),
965        None => field(k, "—", "c-ghost"),
966    }
967}
968
969fn details_tab(base: &str, p: &PageDetail) -> DetailsTab {
970    let html_ok = is_html_ok(p.status, p.content_type.as_deref());
971    let (index_label, index_class) = indexability_view(p.indexability);
972    let canonical = match p
973        .canonical
974        .as_deref()
975        .map(str::trim)
976        .filter(|c| !c.is_empty())
977    {
978        Some(c) => Field {
979            href: Some(c.to_owned()),
980            ..field("Canonical", c, if c == p.url { "" } else { "c-warn" })
981        },
982        None => field("Canonical", "—", "c-ghost"),
983    };
984    let chain = if p.redirect_chain.is_empty() {
985        field("Redirect target / chain", "—", "c-ghost")
986    } else {
987        let hops = p
988            .redirect_chain
989            .iter()
990            .map(|(status, url)| format!("{status} {url}"))
991            .collect::<Vec<_>>()
992            .join(" → ");
993        let n = p.redirect_chain.len();
994        let lands = p
995            .redirect_target
996            .as_deref()
997            .map(|t| format!(" → {t}"))
998            .unwrap_or_default();
999        field(
1000            "Redirect target / chain",
1001            format!("{hops}{lands} ({n} hop{})", if n == 1 { "" } else { "s" }),
1002            if n > 1 { "c-warn" } else { "" },
1003        )
1004    };
1005    let fields = vec![
1006        Field {
1007            href: Some(p.url.clone()),
1008            ..field("Address", p.url.clone(), "")
1009        },
1010        field(
1011            "Status",
1012            status_line(p.status, p.indexability),
1013            status_colour(p.status),
1014        ),
1015        field("Indexability", index_label, index_class),
1016        or_dash("Content type", p.content_type.as_deref()),
1017        text_field("Title 1", p.title.as_deref(), html_ok),
1018        length_field(
1019            "Title length",
1020            p.title.as_deref(),
1021            (TITLE_MIN_CHARS, TITLE_MAX_CHARS),
1022        ),
1023        text_field("Meta description", p.meta_description.as_deref(), html_ok),
1024        length_field(
1025            "Description length",
1026            p.meta_description.as_deref(),
1027            (DESCRIPTION_MIN_CHARS, DESCRIPTION_MAX_CHARS),
1028        ),
1029        text_field("H1-1", p.h1.first().map(String::as_str), html_ok),
1030        field("H2 count", p.h2.len().to_string(), ""),
1031        canonical,
1032        or_dash("Meta robots", p.meta_robots.as_deref()),
1033        or_dash("X-Robots-Tag", p.x_robots_tag.as_deref()),
1034        match p.word_count {
1035            Some(n) if html_ok => field(
1036                "Word count",
1037                fmt::thousands(n),
1038                if n < THIN_WORDS { "c-warn" } else { "" },
1039            ),
1040            _ => field("Word count", "—", "c-ghost"),
1041        },
1042        match p.depth {
1043            Some(d) => field(
1044                "Crawl depth",
1045                format!("{d} click{}", if d == 1 { "" } else { "s" }),
1046                if d > DEEP_CLICKS { "c-warn" } else { "" },
1047            ),
1048            None => field("Crawl depth", "Not linked (sitemap only)", "c-muted"),
1049        },
1050        field(
1051            "Inlinks / outlinks",
1052            format!(
1053                "{} / {} internal · {} external",
1054                fmt::thousands(p.inlinks),
1055                fmt::thousands(p.outlinks_internal),
1056                fmt::thousands(p.outlinks_external)
1057            ),
1058            "",
1059        ),
1060        match p.response_ms {
1061            Some(ms) => field(
1062                "Response time",
1063                format!("{} ms", fmt::thousands(ms)),
1064                if ms > SLOW_MS { "c-err" } else { "" },
1065            ),
1066            None => field("Response time", "—", "c-ghost"),
1067        },
1068        match p.size_bytes {
1069            Some(b) => field("Size", kilobytes(b), ""),
1070            None => field("Size", "—", "c-ghost"),
1071        },
1072        field("In sitemap", if p.in_sitemap { "Yes" } else { "No" }, ""),
1073        chain,
1074    ];
1075
1076    let mut checks: Vec<CheckId> = IssueBits(p.issues)
1077        .iter()
1078        .filter_map(CheckId::from_bit)
1079        .collect();
1080    checks.sort_by_key(|id| (def(*id).severity, *id));
1081    let issues = checks
1082        .into_iter()
1083        .map(|id| IssueChip {
1084            title: def(id).title,
1085            sev: severity_badge(def(id).severity),
1086            href: format!("{base}/explorer?filter=check:{}", id.slug()),
1087        })
1088        .collect();
1089    DetailsTab { fields, issues }
1090}
1091
1092/// `23.4 KB`
1093fn kilobytes(bytes: u64) -> String {
1094    let tenths = (bytes.saturating_mul(10) + 512) / 1024;
1095    let whole = i64::try_from(tenths / 10).unwrap_or(i64::MAX);
1096    format!("{}.{} KB", fmt::thousands(whole), tenths % 10)
1097}
1098
1099/// Runs of whitespace as one space, the way the results page renders text.
1100fn collapse(s: &str) -> String {
1101    s.split_whitespace().collect::<Vec<_>>().join(" ")
1102}
1103
1104fn meter(
1105    label: &'static str,
1106    text: Option<&str>,
1107    limit: u32,
1108    measure: fn(&str) -> u32,
1109    missing: &'static str,
1110) -> MeterView {
1111    // The bar's full width is 125% of the limit, so the limit marker sits at 80%.
1112    let scale = limit * 5 / 4;
1113    match text {
1114        None => MeterView {
1115            label,
1116            value: format!("0 / {limit} px"),
1117            value_class: "c-err",
1118            bar: "bg-err",
1119            pct: 100,
1120            limit_pct: 80,
1121            note: missing,
1122        },
1123        Some(t) => {
1124            let px = measure(t);
1125            let over = px > limit;
1126            MeterView {
1127                label,
1128                value: format!("{} / {limit} px", fmt::thousands(px)),
1129                value_class: if over { "c-warn" } else { "" },
1130                bar: if over { "bg-warn" } else { "bg-ok" },
1131                pct: (px.saturating_mul(100) / scale.max(1)).min(100),
1132                limit_pct: 80,
1133                note: if over {
1134                    "Will be truncated in results"
1135                } else {
1136                    "Fits in results"
1137                },
1138            }
1139        }
1140    }
1141}
1142
1143fn serp_view(p: &PageDetail) -> SerpView {
1144    let title = p.title.as_deref().map(collapse).filter(|t| !t.is_empty());
1145    let description = p
1146        .meta_description
1147        .as_deref()
1148        .map(collapse)
1149        .filter(|d| !d.is_empty());
1150    let (crumb_host, crumb_path) = match Url::parse(&p.url) {
1151        Ok(u) => (
1152            u.host_str().unwrap_or_default().to_owned(),
1153            u.path_segments()
1154                .map(|segs| {
1155                    segs.filter(|s| !s.is_empty())
1156                        .map(|s| format!(" › {s}"))
1157                        .collect::<String>()
1158                })
1159                .unwrap_or_default(),
1160        ),
1161        Err(_) => (p.url.clone(), String::new()),
1162    };
1163    SerpView {
1164        crumb_host,
1165        crumb_path,
1166        title: title
1167            .as_deref()
1168            .map(|t| truncate_to_px(t, TITLE_LIMIT_PX, title_px).0),
1169        description: description
1170            .as_deref()
1171            .map(|d| truncate_to_px(d, DESCRIPTION_LIMIT_PX, description_px).0),
1172        meters: vec![
1173            meter(
1174                "Title width",
1175                title.as_deref(),
1176                TITLE_LIMIT_PX,
1177                title_px,
1178                "Missing title",
1179            ),
1180            meter(
1181                "Description width",
1182                description.as_deref(),
1183                DESCRIPTION_LIMIT_PX,
1184                description_px,
1185                "Missing meta description",
1186            ),
1187        ],
1188    }
1189}
1190
1191fn inlinks_tab(base: &str, links: Vec<Inlink>) -> InlinksTab {
1192    let rows = links
1193        .into_iter()
1194        .map(|l| {
1195            let href = match Url::parse(&l.from_url) {
1196                Ok(u) => format!(
1197                    "{base}/explorer?sel={}",
1198                    hex(codoseo_core::url::url_hash(&u))
1199                ),
1200                Err(_) => l.from_url.clone(),
1201            };
1202            let (anchor, anchor_class) = match l
1203                .anchor_text
1204                .as_deref()
1205                .map(str::trim)
1206                .filter(|a| !a.is_empty())
1207            {
1208                Some(a) => (a.to_owned(), ""),
1209                None => ("No anchor text".to_owned(), "c-ghost"),
1210            };
1211            InlinkView {
1212                from: l.from_url,
1213                href,
1214                anchor,
1215                anchor_class,
1216                kind: if l.nofollow { "Nofollow" } else { "Follow" },
1217                kind_class: if l.nofollow { "c-warn" } else { "c-ok" },
1218            }
1219        })
1220        .collect();
1221    InlinksTab {
1222        rows,
1223        note: format!(
1224            "Up to {MAX_INLINK_SAMPLES} sample inlinks are kept per page, plus every link to a \
1225             broken or redirecting page."
1226        ),
1227    }
1228}
1229
1230/// The response rebuilt from what the crawl stored (raw headers are never kept).
1231fn headers_text(p: &PageDetail) -> String {
1232    let mut lines = Vec::new();
1233    lines.push(if p.status == 0 {
1234        format!("HTTP/1.1 —  {}", status_line(0, p.indexability))
1235    } else {
1236        format!("HTTP/1.1 {}", status_line(p.status, p.indexability))
1237    });
1238    if let Some(ct) = p.content_type.as_deref().filter(|c| !c.is_empty()) {
1239        lines.push(format!("content-type: {ct}"));
1240    }
1241    if p.status != 0
1242        && let Some(size) = p.size_bytes
1243    {
1244        lines.push(format!("content-length: {size}"));
1245    }
1246    if let Some(x) = p.x_robots_tag.as_deref().filter(|x| !x.is_empty()) {
1247        lines.push(format!("x-robots-tag: {x}"));
1248    }
1249    // Each hop is the URL that answered with that status, so the page's own redirect points
1250    // at the next hop, or at the final target when there is only one hop.
1251    if (300..400).contains(&p.status)
1252        && let Some(next) = p
1253            .redirect_chain
1254            .get(1)
1255            .map(|(_, u)| u.as_str())
1256            .or(p.redirect_target.as_deref())
1257    {
1258        lines.push(format!("location: {next}"));
1259    }
1260    if !p.redirect_chain.is_empty() {
1261        lines.push(String::new());
1262        lines.push("# redirect chain".to_owned());
1263        for (i, (status, url)) in p.redirect_chain.iter().enumerate() {
1264            lines.push(format!("{}. {status} {url}", i + 1));
1265        }
1266        if let Some(target) = p.redirect_target.as_deref() {
1267            lines.push(format!("→ {target}"));
1268        }
1269    }
1270    lines.join("\n")
1271}
1272
1273#[cfg(test)]
1274mod tests {
1275    use super::*;
1276
1277    #[test]
1278    fn hashes_round_trip_as_sixteen_hex_digits() {
1279        for h in [0, 1, 0xab, u64::MAX, 0x00ab_12cd_0000_ffff] {
1280            let s = hex(h);
1281            assert_eq!(s.len(), 16);
1282            assert_eq!(parse_hex(&s), Some(h));
1283        }
1284        assert_eq!(parse_hex("abc"), None);
1285        assert_eq!(parse_hex("zzzzzzzzzzzzzzzz"), None);
1286        assert_eq!(parse_hex("+bcdef0123456789"), None);
1287    }
1288
1289    #[test]
1290    fn explorer_urls_leave_out_defaults() {
1291        let base = "/s/x";
1292        assert_eq!(
1293            explorer_url(base, PageFilter::All, "", None, Tab::Details),
1294            "/s/x/explorer?filter=all"
1295        );
1296        assert_eq!(
1297            explorer_url(base, PageFilter::Status4xx, "a b&c", Some(0xab), Tab::Serp),
1298            "/s/x/explorer?filter=s4&q=a+b%26c&sel=00000000000000ab&tab=serp"
1299        );
1300    }
1301
1302    #[test]
1303    fn sizes_in_kilobytes() {
1304        assert_eq!(kilobytes(0), "0.0 KB");
1305        assert_eq!(kilobytes(24_000), "23.4 KB");
1306        assert_eq!(kilobytes(2_048_000), "2,000.0 KB");
1307    }
1308
1309    #[test]
1310    fn address_column_shows_path_and_query() {
1311        assert_eq!(path_of("https://e.com/"), "/");
1312        assert_eq!(path_of("https://e.com/a/b?x=1"), "/a/b?x=1");
1313        assert_eq!(path_of("not a url"), "not a url");
1314    }
1315}