1use 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#[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 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 pub base: String,
72 pub urls_label: String,
74 pub last_crawl: Option<String>,
76}
77
78pub struct NavItem {
79 pub label: &'static str,
80 pub href: String,
81 pub count: String,
82 pub alert: bool,
84 pub active: bool,
85 pub keys: &'static str,
87 pub soon: bool,
89 pub icon: &'static str,
91}
92
93pub struct NavGroup {
94 pub title: &'static str,
95 pub items: Vec<NavItem>,
96}
97
98pub struct CrawlerView {
100 pub state: &'static str,
102 pub label: String,
103 pub rows: Vec<(String, String)>,
104 pub pct: Option<u8>,
106 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 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 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
264pub 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
344fn 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}