Skip to main content

codoseo_web/
layout.rs

1//! The app shell around every signed-in screen (v3 layout, warmbly chrome): sidebar with the
2//! site switcher, crawler status and nav groups; header with breadcrumb, ⌘K and Run crawl.
3//!
4//! Every screen template extends `base.html` and has a `shell: Shell` field, built once per
5//! request by [`Shell::load`].
6
7use codoseo_checks::def;
8use codoseo_core::check::{CheckId, Severity};
9use codoseo_core::plan::{Plan, PlanLimits};
10use codoseo_store::crawls::{Crawl, CrawlStatus};
11use codoseo_store::sites::Site;
12use uuid::Uuid;
13
14use crate::auth::CurrentUser;
15use crate::config::Mode;
16use crate::error::AppError;
17use crate::fmt;
18use crate::state::AppState;
19
20/// Which screen is showing, for the nav highlight and the breadcrumb.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Screen {
23    Explorer,
24    Audit,
25    Changes,
26    Crawls,
27    Sites,
28    Account,
29    Alerts,
30    ApiKeys,
31    Billing,
32    Admin,
33}
34
35impl Screen {
36    pub fn title(self) -> &'static str {
37        match self {
38            Screen::Explorer => "URL explorer",
39            Screen::Audit => "Site audit",
40            Screen::Changes => "Changes",
41            Screen::Crawls => "Crawls",
42            Screen::Sites => "Sites",
43            Screen::Account => "Account",
44            Screen::Alerts => "Alerts",
45            Screen::ApiKeys => "API keys",
46            Screen::Billing => "Billing",
47            Screen::Admin => "Admin",
48        }
49    }
50}
51
52pub struct UserView {
53    pub email: String,
54    pub initials: String,
55    /// `cloud · pro`, `self-hosted · owner`
56    pub plan_label: String,
57}
58
59pub struct SiteLink {
60    pub domain: String,
61    pub initial: String,
62    pub href: String,
63    pub active: bool,
64}
65
66pub struct SiteView {
67    pub id: Uuid,
68    pub domain: String,
69    pub initial: String,
70    /// `/s/{id}`
71    pub base: String,
72    /// `1,284 urls` or `not crawled yet`
73    pub urls_label: String,
74    /// `crawl #48 · 12m ago`
75    pub last_crawl: Option<String>,
76}
77
78pub struct NavItem {
79    pub label: &'static str,
80    pub href: String,
81    pub count: String,
82    /// The count is drawn in red (critical issues, critical changes).
83    pub alert: bool,
84    pub active: bool,
85    /// Keyboard shortcut shown in the tooltip, e.g. `G E`.
86    pub keys: &'static str,
87    /// Not built yet (M7): shown dimmed with a "soon" tag.
88    pub soon: bool,
89    /// Symbol id in `partials/icons.html`.
90    pub icon: &'static str,
91}
92
93pub struct NavGroup {
94    pub title: &'static str,
95    pub items: Vec<NavItem>,
96}
97
98/// The crawler status card in the sidebar.
99pub struct CrawlerView {
100    /// `idle`, `queued`, `running`, `failed`
101    pub state: &'static str,
102    pub label: String,
103    pub rows: Vec<(String, String)>,
104    /// Progress through the page budget while running, 0–100.
105    pub pct: Option<u8>,
106    /// Set while a crawl is queued or running: the card polls this URL every 2 s.
107    pub poll_url: Option<String>,
108}
109
110pub struct Shell {
111    pub screen: Screen,
112    pub user: UserView,
113    pub sites: Vec<SiteLink>,
114    pub site: Option<SiteView>,
115    pub nav: Vec<NavGroup>,
116    pub crawler: Option<CrawlerView>,
117    pub version: &'static str,
118    pub can_add_site: bool,
119}
120
121impl Shell {
122    pub fn title(&self) -> String {
123        match &self.site {
124            Some(s) => format!("{} · {} — CodoSEO", self.screen.title(), s.domain),
125            None => format!("{} — CodoSEO", self.screen.title()),
126        }
127    }
128
129    /// Loads everything the shell shows. `site` is the site in view, if the screen has one.
130    pub async fn load(
131        state: &AppState,
132        user: &CurrentUser,
133        site: Option<&Site>,
134        screen: Screen,
135    ) -> Result<Shell, AppError> {
136        let pool = &state.pool;
137        let sites = codoseo_store::sites::list_for_account(pool, user.id()).await?;
138        let limits = PlanLimits::for_plan(user.account.plan);
139        let can_add_site = limits
140            .max_sites
141            .is_none_or(|max| (sites.len() as u32) < max);
142
143        let mut site_view = None;
144        let mut nav = Vec::new();
145        let mut crawler = None;
146        if let Some(site) = site {
147            let latest = codoseo_store::crawls::latest_done(pool, site.id).await?;
148            let active = codoseo_store::crawls::active(pool, site.id).await?;
149            let changes = match &latest {
150                Some(c) => change_counts(state, c.id).await?,
151                None => (0, false),
152            };
153            let summary = latest.as_ref().and_then(Crawl::summary);
154            let base = format!("/s/{}", site.id);
155            site_view = Some(SiteView {
156                id: site.id,
157                domain: site.domain.clone(),
158                initial: initial(&site.domain),
159                base: base.clone(),
160                urls_label: match &summary {
161                    Some(s) => {
162                        let n = s.report_summary.pages;
163                        format!("{} url{}", fmt::thousands(n), if n == 1 { "" } else { "s" })
164                    }
165                    None => "not crawled yet".to_owned(),
166                },
167                last_crawl: latest.as_ref().map(|c| {
168                    let when = c.finished_at.map(fmt::ago).unwrap_or_default();
169                    format!("crawl #{} · {when}", c.number)
170                }),
171            });
172            nav = nav_groups(&base, screen, latest.as_ref(), summary.as_ref(), changes);
173            crawler = Some(crawler_view(
174                &base,
175                latest.as_ref(),
176                active.as_ref(),
177                user.account.plan,
178            ));
179        } else {
180            nav.push(NavGroup {
181                title: "WORKSPACE",
182                items: vec![workspace_item(
183                    "Sites",
184                    "/sites",
185                    screen == Screen::Sites,
186                    "",
187                    "i-globe",
188                )],
189            });
190        }
191        let mut account_items = vec![
192            workspace_item(
193                "Alerts",
194                "/settings/alerts",
195                screen == Screen::Alerts,
196                "",
197                "i-bell",
198            ),
199            workspace_item(
200                "API keys",
201                "/settings/api-keys",
202                screen == Screen::ApiKeys,
203                "",
204                "i-key",
205            ),
206            NavItem {
207                soon: true,
208                ..workspace_item("Schedule", "#", false, "", "i-calendar")
209            },
210        ];
211        // Billing exists on the cloud only; a free account is nudged toward the upgrade.
212        if state.config.mode == Mode::Cloud {
213            let mut billing = workspace_item(
214                "Billing",
215                "/billing",
216                screen == Screen::Billing,
217                "",
218                "i-card",
219            );
220            if user.account.plan == Plan::Free {
221                billing.count = "upgrade".to_owned();
222            }
223            account_items.push(billing);
224        }
225        account_items.push(workspace_item(
226            "Settings",
227            "/account",
228            screen == Screen::Account,
229            "G S",
230            "i-settings",
231        ));
232        nav.push(NavGroup {
233            title: if site.is_some() {
234                "WORKSPACE"
235            } else {
236                "ACCOUNT"
237            },
238            items: account_items,
239        });
240
241        let sites = sites
242            .iter()
243            .map(|s| SiteLink {
244                domain: s.domain.clone(),
245                initial: initial(&s.domain),
246                href: format!("/s/{}/audit", s.id),
247                active: site.is_some_and(|cur| cur.id == s.id),
248            })
249            .collect();
250
251        Ok(Shell {
252            screen,
253            user: user_view(user),
254            sites,
255            site: site_view,
256            nav,
257            crawler,
258            version: env!("CARGO_PKG_VERSION"),
259            can_add_site,
260        })
261    }
262}
263
264/// The sidebar crawler card for one site, on its own (the `/s/{site}/status` poll).
265pub async fn crawler_for(
266    state: &AppState,
267    site: &Site,
268    plan: Plan,
269) -> Result<CrawlerView, AppError> {
270    let latest = codoseo_store::crawls::latest_done(&state.pool, site.id).await?;
271    let active = codoseo_store::crawls::active(&state.pool, site.id).await?;
272    Ok(crawler_view(
273        &format!("/s/{}", site.id),
274        latest.as_ref(),
275        active.as_ref(),
276        plan,
277    ))
278}
279
280fn workspace_item(
281    label: &'static str,
282    href: &str,
283    active: bool,
284    keys: &'static str,
285    icon: &'static str,
286) -> NavItem {
287    NavItem {
288        label,
289        href: href.to_owned(),
290        count: String::new(),
291        alert: false,
292        active,
293        keys,
294        soon: false,
295        icon,
296    }
297}
298
299async fn change_counts(state: &AppState, crawl_id: Uuid) -> Result<(i64, bool), AppError> {
300    let row: (i64, Option<bool>) = sqlx::query_as(
301        "SELECT count(*), bool_or(severity = 'critical') FROM changes WHERE crawl_id = $1",
302    )
303    .bind(crawl_id)
304    .fetch_one(&state.pool)
305    .await?;
306    Ok((row.0, row.1.unwrap_or(false)))
307}
308
309pub fn initial(domain: &str) -> String {
310    domain
311        .trim_start_matches("www.")
312        .chars()
313        .next()
314        .map(|c| c.to_uppercase().to_string())
315        .unwrap_or_else(|| "?".to_owned())
316}
317
318fn user_view(user: &CurrentUser) -> UserView {
319    let email = &user.account.email;
320    let local = email.split('@').next().unwrap_or(email);
321    let mut parts = local
322        .split(['.', '_', '-', '+'])
323        .filter(|p| !p.is_empty())
324        .filter_map(|p| p.chars().next());
325    let initials: String = match (parts.next(), parts.next()) {
326        (Some(a), Some(b)) => [a, b].iter().collect(),
327        (Some(_), None) => local.chars().take(2).collect(),
328        _ => "?".to_owned(),
329    };
330    let plan = match user.account.plan {
331        Plan::Free => "cloud · free",
332        Plan::Pro => "cloud · pro",
333        Plan::Agency => "cloud · agency",
334        Plan::SelfHosted if user.account.is_owner => "self-hosted · owner",
335        Plan::SelfHosted => "self-hosted",
336    };
337    UserView {
338        email: email.clone(),
339        initials: initials.to_uppercase(),
340        plan_label: plan.to_owned(),
341    }
342}
343
344/// Affected-page counts by check slug, from the stored summary.
345fn check_count(summary: &codoseo_store::crawls::StoredSummary, id: CheckId) -> u32 {
346    summary
347        .counts
348        .iter()
349        .find(|(slug, _)| slug == id.slug())
350        .map_or(0, |(_, n)| *n)
351}
352
353fn nav_groups(
354    base: &str,
355    screen: Screen,
356    latest: Option<&Crawl>,
357    summary: Option<&codoseo_store::crawls::StoredSummary>,
358    (changes, critical_change): (i64, bool),
359) -> Vec<NavGroup> {
360    let n = |v: u32| {
361        if summary.is_some() {
362            fmt::thousands(v)
363        } else {
364            String::new()
365        }
366    };
367    let critical_checks = summary.map_or(0, |s| {
368        s.counts
369            .iter()
370            .filter_map(|(slug, _)| CheckId::from_slug(slug))
371            .filter(|id| def(*id).severity == Severity::Critical)
372            .count() as u32
373    });
374    let (pages, status, indexable) = summary.map_or((0, Default::default(), 0), |s| {
375        (
376            s.report_summary.pages,
377            s.report_summary.status,
378            s.report_summary.indexable,
379        )
380    });
381    let title_issues = summary.map_or(0, |s| {
382        [
383            CheckId::TitleMissing,
384            CheckId::TitleTooLong,
385            CheckId::TitleTooShort,
386            CheckId::TitleDuplicate,
387        ]
388        .iter()
389        .map(|&c| check_count(s, c))
390        .sum()
391    });
392    let alt = summary.map_or(0, |s| check_count(s, CheckId::ImagesMissingAlt));
393
394    let item = |label, href: String, count: String, alert, active, keys, icon| NavItem {
395        label,
396        href,
397        count,
398        alert,
399        active,
400        keys,
401        soon: false,
402        icon,
403    };
404    let explorer = |f: &str| format!("{base}/explorer?filter={f}");
405    vec![
406        NavGroup {
407            title: "PROJECT",
408            items: vec![
409                item(
410                    "URL explorer",
411                    format!("{base}/explorer"),
412                    n(pages),
413                    false,
414                    screen == Screen::Explorer,
415                    "G E",
416                    "i-explorer",
417                ),
418                item(
419                    "Site audit",
420                    format!("{base}/audit"),
421                    if critical_checks > 0 {
422                        n(critical_checks)
423                    } else {
424                        String::new()
425                    },
426                    critical_checks > 0,
427                    screen == Screen::Audit,
428                    "G A",
429                    "i-audit",
430                ),
431                item(
432                    "Changes",
433                    format!("{base}/changes"),
434                    if changes > 0 {
435                        fmt::thousands(changes)
436                    } else {
437                        String::new()
438                    },
439                    critical_change,
440                    screen == Screen::Changes,
441                    "G C",
442                    "i-changes",
443                ),
444                item(
445                    "Crawls",
446                    format!("{base}/crawls"),
447                    latest.map(|c| fmt::thousands(c.number)).unwrap_or_default(),
448                    false,
449                    screen == Screen::Crawls,
450                    "G H",
451                    "i-crawls",
452                ),
453            ],
454        },
455        NavGroup {
456            title: "REPORTS",
457            items: vec![
458                item(
459                    "Response codes",
460                    explorer("s4"),
461                    n(status.client_error + status.server_error),
462                    false,
463                    false,
464                    "",
465                    "i-codes",
466                ),
467                item(
468                    "Redirects",
469                    explorer("s3"),
470                    n(status.redirect),
471                    false,
472                    false,
473                    "",
474                    "i-redirect",
475                ),
476                item(
477                    "Indexability",
478                    explorer("nx"),
479                    n(pages.saturating_sub(indexable)),
480                    false,
481                    false,
482                    "",
483                    "i-index",
484                ),
485                item(
486                    "Page titles",
487                    explorer("check:title_too_long"),
488                    n(title_issues),
489                    false,
490                    false,
491                    "",
492                    "i-title",
493                ),
494                item(
495                    "Image alt text",
496                    explorer("check:images_missing_alt"),
497                    n(alt),
498                    false,
499                    false,
500                    "",
501                    "i-image",
502                ),
503            ],
504        },
505    ]
506}
507
508fn crawler_view(
509    base: &str,
510    latest: Option<&Crawl>,
511    active: Option<&Crawl>,
512    plan: Plan,
513) -> CrawlerView {
514    let poll_url = Some(format!("{base}/status"));
515    if let Some(a) = active {
516        if a.status == CrawlStatus::Running {
517            let p = a.progress();
518            let done = p.map_or(0, |p| p.pages_done);
519            let budget = PlanLimits::for_plan(plan)
520                .max_pages
521                .unwrap_or(10_000)
522                .max(1);
523            let pct = ((u64::from(done) * 100) / u64::from(budget)).min(99) as u8;
524            return CrawlerView {
525                state: "running",
526                label: format!("CRAWLING #{}", a.number),
527                rows: vec![
528                    ("pages".to_owned(), fmt::thousands(done)),
529                    (
530                        "elapsed".to_owned(),
531                        p.map(|p| fmt::millis(p.elapsed_ms))
532                            .unwrap_or_else(|| "—".to_owned()),
533                    ),
534                ],
535                pct: Some(pct),
536                poll_url,
537            };
538        }
539        return CrawlerView {
540            state: "queued",
541            label: format!("QUEUED #{}", a.number),
542            rows: vec![("queued".to_owned(), fmt::ago(a.queued_at))],
543            pct: None,
544            poll_url,
545        };
546    }
547    match latest {
548        Some(c) => CrawlerView {
549            state: "idle",
550            label: "CRAWLER IDLE".to_owned(),
551            rows: vec![
552                (
553                    "last crawl".to_owned(),
554                    c.finished_at.map(fmt::ago).unwrap_or_default(),
555                ),
556                (
557                    "health".to_owned(),
558                    c.health_score
559                        .map(|s| format!("{s}/100"))
560                        .unwrap_or_else(|| "—".to_owned()),
561                ),
562            ],
563            pct: None,
564            poll_url: None,
565        },
566        None => CrawlerView {
567            state: "idle",
568            label: "NO CRAWLS YET".to_owned(),
569            rows: vec![("next".to_owned(), "run your first crawl".to_owned())],
570            pct: None,
571            poll_url: None,
572        },
573    }
574}