1use 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
45const PAGE_SIZE: usize = 100;
47const INLINKS_SHOWN: i64 = 50;
49const 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#[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#[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
132pub 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
145fn 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#[derive(Template)]
173#[template(path = "explorer/index.html")]
174pub struct ExplorerPage {
175 pub shell: Shell,
176 pub base: String,
177 pub view: Option<ExplorerView>,
179 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 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 pub swatch: &'static str,
204 pub count: String,
205 pub zero: bool,
206 pub href: String,
207 pub active: bool,
208}
209
210#[derive(Template)]
212#[template(path = "explorer/info.html")]
213pub struct InfoSpan {
214 pub text: String,
215 pub oob: bool,
216}
217
218#[derive(Template)]
220#[template(path = "explorer/rows.html")]
221pub struct RowsFragment {
222 pub rows: Vec<RowView>,
223 pub more: Option<String>,
225 pub empty: bool,
227 pub info: Option<InfoSpan>,
229}
230
231pub struct RowView {
232 pub url: String,
233 pub path: String,
235 pub status: String,
236 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#[derive(Template)]
258#[template(path = "explorer/star.html")]
259pub struct StarButton {
260 pub base: String,
261 pub hex: String,
262 pub on: bool,
263 pub pop: bool,
265 pub at: &'static str,
266}
267
268#[derive(Template)]
270#[template(path = "explorer/detail.html")]
271pub struct DetailPanel {
272 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 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 pub class: &'static str,
306 pub href: Option<String>,
308}
309
310pub struct IssueChip {
311 pub title: &'static str,
312 pub sev: &'static str,
314 pub href: String,
315}
316
317pub struct SerpView {
318 pub crumb_host: String,
319 pub crumb_path: String,
321 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 pub value: String,
331 pub value_class: &'static str,
332 pub bar: &'static str,
334 pub pct: u32,
335 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 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
355async 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 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
427async 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
478async 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
517async 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 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
564fn 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 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
729fn 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
757fn 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
787fn 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
936fn 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
944fn 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
960fn 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
1092fn 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
1099fn 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 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
1230fn 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 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}