diff --git a/site/components/Health.module.css b/site/components/Health.module.css index 913572a7..47e9a7c2 100644 --- a/site/components/Health.module.css +++ b/site/components/Health.module.css @@ -530,3 +530,23 @@ .personHead .tools { align-items: center; } + +.personShimmer { + min-height: 9rem; +} + +.pager { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + color: var(--muted); + font-size: 0.95rem; + font-variant-numeric: tabular-nums; +} + +.toggle:disabled { + opacity: 0.45; + cursor: default; +} diff --git a/site/components/Health.tsx b/site/components/Health.tsx index 832b9942..b687291b 100644 --- a/site/components/Health.tsx +++ b/site/components/Health.tsx @@ -39,7 +39,6 @@ type PeopleData = { since: number; tried: number; in_readme: number; - list: Person[]; removed: string[]; no_login: { decorations: number; in_readme: number }; }; @@ -415,7 +414,7 @@ function Panels({ d, now, reload }: { d: HealthData; now: number; reload: () => - {d.people && } + {d.people && } @@ -432,9 +431,58 @@ async function change(login: string, what: "remove" | "allow") { } /** Everyone who tried it, newest first: their GitHub profile, the stats saved for them, and the cards they made. */ -function People({ p, reload }: { p: PeopleData; reload: () => Promise }) { +/** What /admin/people answers: one page of the list, newest first. */ +type PeoplePage = { page: number; pages: number; per: number; total: number; list: Person[] }; + +function People({ p, stamp, reload }: { p: PeopleData; stamp: number; reload: () => Promise }) { + const [page, setPage] = useState(1); + const [find, setFind] = useState(""); + const [query, setQuery] = useState(""); + const [data, setData] = useState(null); + const [failed, setFailed] = useState(false); + const [asking, setAsking] = useState(false); + const top = useRef(null); + + // search after a short pause in typing, from the first page + useEffect(() => { + const id = setTimeout(() => { + setQuery(find.trim()); + setPage(1); + }, 300); + return () => clearTimeout(id); + }, [find]); + + // the page asked for, again with every health check so it stays current; a failed ask keeps the last list + useEffect(() => { + let live = true; + setAsking(true); + fetch(`/admin/people?page=${page}&q=${encodeURIComponent(query)}`, { cache: "no-store" }) + .then((r) => { + if (!r.ok) throw new Error(String(r.status)); + return r.json(); + }) + .then((v: PeoplePage) => { + if (live) { + setData(v); + setFailed(false); + } + }) + .catch(() => live && setFailed(true)) + .finally(() => live && setAsking(false)); + return () => { + live = false; + }; + }, [page, query, stamp]); + + const go = (to: number) => { + setPage(to); + top.current?.scrollIntoView({ block: "start" }); + }; + const list = data?.list ?? []; + const from = data && data.total ? (data.page - 1) * data.per + 1 : 0; + return ( -
+

{P.title}

{P.since(day(p.since))} @@ -456,16 +504,41 @@ function People({ p, reload }: { p: PeopleData; reload: () => Promise }) {

{rich(P.noLoginNote(num(p.no_login.in_readme)), { strong: (c) => {c} })}

{P.rules}

- {p.list.length < p.tried &&

{P.shown(num(p.list.length), num(p.tried))}

}
- {p.list.length ? ( -
    - {p.list.map((u) => ( - - ))} -
+ {p.tried > 0 && ( + + )} + {failed && ( +

+ {P.pageFailed(!!data)} +

+ )} + {!data ? ( + !failed &&
+ ) : list.length ? ( + <> +
    + {list.map((u) => ( + + ))} +
+ {data.pages > 1 && ( + + )} + ) : ( -

{P.empty}

+

{query ? P.noMatch(query) : P.empty}

)} {(p.removed ?? []).length > 0 && (
@@ -583,7 +656,7 @@ function PersonRow({ u, reload }: { u: Person; reload: () => Promise }) {

{u.first > 0 ? P.seen(day(u.first), day(u.last)) : P.before(u.last > 0 ? day(u.last) : null)}

{cards.length > 0 && ( -
+
{P.cardsTitle(cards.length)}
    {cards.map((c) => ( diff --git a/site/components/builder/LoginStep.tsx b/site/components/builder/LoginStep.tsx index 719583c6..0e3c1ec4 100644 --- a/site/components/builder/LoginStep.tsx +++ b/site/components/builder/LoginStep.tsx @@ -173,7 +173,8 @@ export default function LoginStep({ />

    - {other ? tx.footOther : tx.footProfile} + {other ? tx.footOther : tx.footProfile}{" "} + {rich(tx.privacy, { a: (c) => {c} })}

    {onCancel && ( diff --git a/site/i18n/en/builderParts.ts b/site/i18n/en/builderParts.ts index 9c94b295..3f71e36f 100644 --- a/site/i18n/en/builderParts.ts +++ b/site/i18n/en/builderParts.ts @@ -186,6 +186,7 @@ export const builderParts = { fieldHelp: "Example: octocat. Letters, digits and hyphens.", footOther: "Your text stays in this browser until you copy it.", footProfile: "Already have a profile README? We'll find it, and you can start from it.", + privacy: "Only public GitHub data is used. What questlog keeps.", cancel: "Cancel", checking: "Looking you up", next: "Next", diff --git a/site/i18n/en/status.ts b/site/i18n/en/status.ts index 865f7729..2afc93bc 100644 --- a/site/i18n/en/status.ts +++ b/site/i18n/en/status.ts @@ -157,7 +157,16 @@ export const health = { rules: "Counted when a browser or GitHub's image proxy gets one of their cards. Bots, the example logins, your own login and this page's previews are left out.", empty: "Nobody has tried it yet. The first person to make a card shows up here.", - shown: (shown: string, all: string) => `The ${shown} most recent of ${all} people are listed.`, + findLabel: "Find a person by their GitHub login", + findPlaceholder: "Find a login", + noMatch: (q: string) => `No login contains "${q}". Clear the search to see everyone.`, + pageFailed: (hasList: boolean) => + `This page of people did not load${hasList ? ", so the last list stays on screen" : ""}. It tries again with the next check.`, + pagesLabel: "Pages of people", + newer: "Newer", + older: "Older", + showing: (from: string, to: string, total: string, page: number, pages: number) => + `${from} to ${to} of ${total}, page ${page} of ${pages}`, profile: (login: string) => `${login} on GitHub`, readme: "In a README", tried1: "Only tried", diff --git a/src/people.rs b/src/people.rs index ac3f6f9e..6fa09c7d 100644 --- a/src/people.rs +++ b/src/people.rs @@ -46,6 +46,17 @@ struct Seen { blocked: BTreeSet, } +/// The numbers at the top of the admin page's people section. +pub struct Summary { + pub since: u64, + pub tried: usize, + pub in_readme: usize, + pub decorations: usize, + pub decorations_in_readme: usize, + /// Logins removed at the person's request. + pub removed: Vec, +} + pub struct People { path: PathBuf, seen: Mutex, @@ -137,22 +148,28 @@ impl People { touch(&mut self.seen.lock().unwrap().decorations, address, readme, now, MAX_DECORATIONS); } - /// For the admin page: when the tracking began, the `shown` most recent people (newest first), how many tried - /// it and how many have a card in a README, and the decorations (made, in a README). - pub fn report(&self, shown: usize) -> (u64, Vec<(String, Person)>, usize, usize, (usize, usize)) { - self.report_all(shown).0 + /// The numbers at the top of the admin page's people section. + pub fn summary(&self) -> Summary { + let s = self.seen.lock().unwrap(); + Summary { + since: s.since, + tried: s.people.len(), + in_readme: s.people.values().filter(|p| p.cards.values().any(|u| u.readme)).count(), + decorations: s.decorations.len(), + decorations_in_readme: s.decorations.values().filter(|u| u.readme).count(), + removed: s.blocked.iter().cloned().collect(), + } } - /// `report`, plus the blocked logins. - #[allow(clippy::type_complexity)] - pub fn report_all(&self, shown: usize) -> ((u64, Vec<(String, Person)>, usize, usize, (usize, usize)), Vec) { + /// One page of people whose login contains `find`, newest first: how many match, and the `per` people on page + /// `page` (counted from 1). + pub fn page(&self, find: &str, page: usize, per: usize) -> (usize, Vec<(String, Person)>) { let s = self.seen.lock().unwrap(); - let in_readme = s.people.values().filter(|p| p.cards.values().any(|u| u.readme)).count(); - let decorations = (s.decorations.len(), s.decorations.values().filter(|u| u.readme).count()); - let mut list: Vec<(String, Person)> = s.people.iter().map(|(l, p)| (l.clone(), p.clone())).collect(); - list.sort_by(|a, b| b.1.last.cmp(&a.1.last).then_with(|| a.0.cmp(&b.0))); - list.truncate(shown); - ((s.since, list, s.people.len(), in_readme, decorations), s.blocked.iter().cloned().collect()) + // ponytail: sorts the matches on every request; fine up to MAX_PEOPLE, an ordered index if that grows + let mut hits: Vec<(&String, u64)> = s.people.iter().filter(|(l, _)| l.contains(find)).map(|(l, p)| (l, p.last)).collect(); + hits.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0))); + let list = hits.iter().skip(page.saturating_sub(1) * per).take(per).map(|&(l, _)| (l.clone(), s.people[l].clone())).collect(); + (hits.len(), list) } pub fn save(&self) -> Result<(), BoxError> { @@ -183,8 +200,10 @@ mod tests { p.decoration("/decorations/reading-nook?title=Hi", true, 700); p.save().unwrap(); - let (since, list, tried, in_readme, decorations) = People::open(&dir, Vec::new, 999).report(10); - assert_eq!((since, tried, in_readme, decorations), (100, 2, 1, (1, 1))); + let p = People::open(&dir, Vec::new, 999); + let s = p.summary(); + assert_eq!((s.since, s.tried, s.in_readme, s.decorations, s.decorations_in_readme), (100, 2, 1, 1, 1)); + let (_, list) = p.page("", 1, 10); let logins: Vec<&str> = list.iter().map(|(l, _)| l.as_str()).collect(); assert_eq!(logins, ["ana", "sudi"], "newest first"); let ana = &list[0].1; @@ -203,16 +222,16 @@ mod tests { p.decoration("/decorations/workbench", true, 1000); assert_eq!(p.sweep(1000 + KEEP_SECS), 0, "a year to the second is kept"); assert_eq!(p.sweep(1001 + KEEP_SECS), 2, "ana, and old counted from when the tracking began"); - let (_, list, tried, _, decorations) = p.report(10); - assert_eq!((list[0].0.as_str(), tried, decorations), ("bo", 1, (0, 0))); + let (tried, list) = p.page("", 1, 10); + assert_eq!((list[0].0.as_str(), tried, p.summary().decorations), ("bo", 1, 0)); p.block("bo"); p.card("bo", "/bo/stats", false, 2000 + KEEP_SECS); p.save().unwrap(); let p = People::open(&dir, Vec::new, 0); assert!(p.is_blocked("bo")); - let ((_, _, tried, ..), blocked) = p.report_all(10); - assert_eq!((tried, blocked), (0, vec!["bo".to_string()]), "a blocked login is not listed again"); + let s = p.summary(); + assert_eq!((s.tried, s.removed), (0, vec!["bo".to_string()]), "a blocked login is not listed again"); p.allow("bo"); assert!(!p.is_blocked("bo")); } @@ -225,12 +244,29 @@ mod tests { for i in 0..CARDS_EACH + 5 { p.card("ana", &format!("/ana/stats?n={i}"), false, i as u64); } - let (_, list, ..) = p.report(1); + let (_, list) = p.page("", 1, 1); assert_eq!((list[0].1.cards.len(), list[0].1.last), (CARDS_EACH, (CARDS_EACH + 4) as u64)); std::fs::write(dir.join("people.json"), b"not json").unwrap(); - let (since, _, tried, ..) = People::open(&dir, Vec::new, 5).report(1); - assert_eq!((since, tried), (5, 0)); + let s = People::open(&dir, Vec::new, 5).summary(); + assert_eq!((s.since, s.tried), (5, 0)); assert!(dir.join("people.json.bad").exists()); } + + #[test] + fn pages_newest_first_and_finds_by_login() { + let dir = temp_dir("people-page"); + std::fs::create_dir_all(&dir).unwrap(); + let p = People::open(&dir, Vec::new, 0); + for i in 0..45u64 { + p.card(&format!("user{i:02}"), "/stats", false, 100 + i); + } + let logins = |list: Vec<(String, Person)>| list.into_iter().map(|(l, _)| l).collect::>(); + let (n, first) = p.page("", 1, 20); + assert_eq!((n, first.len(), first[0].0.as_str()), (45, 20, "user44")); + assert_eq!(logins(p.page("", 3, 20).1), ["user04", "user03", "user02", "user01", "user00"]); + assert!(p.page("", 4, 20).1.is_empty(), "past the last page"); + let (n, found) = p.page("er1", 1, 20); + assert_eq!((n, logins(found).first().cloned()), (10, Some("user19".to_string()))); + } } diff --git a/src/server/admin.rs b/src/server/admin.rs index 8f1f87cf..b9a0c4ed 100644 --- a/src/server/admin.rs +++ b/src/server/admin.rs @@ -14,8 +14,8 @@ use tokio::sync::RwLock; /// Never kept by a browser or an edge: it is behind a login. const ADMIN: &str = "private, no-store"; -/// People listed on the admin page, the most recent first. -const PEOPLE_SHOWN: usize = 200; +/// People on one page of the admin page's list. +const PEOPLE_PER_PAGE: usize = 5; /// Access's signing keys are kept this long, and fetched again at most this often for an unknown key id. const KEYS_FOR: Duration = Duration::from_secs(3600); const REFETCH_AFTER: Duration = Duration::from_secs(60); @@ -159,17 +159,43 @@ pub(super) async fn admin_health(State(app): State>>, req } let a = app.clone(); let disk = tokio::task::spawn_blocking(move || (a.cache.disk_bytes(), a.store.saved_count())).await.unwrap_or((None, None)); - let a = app.clone(); - let people = tokio::task::spawn_blocking(move || people(&a.store)).await.unwrap_or_default(); + let p = app.store.people.summary(); let Json(mut v) = full_health(&app, disk); - v["people"] = people; + v["people"] = serde_json::json!({ + "since": p.since, "tried": p.tried, "in_readme": p.in_readme, "removed": p.removed, + "no_login": {"decorations": p.decorations, "in_readme": p.decorations_in_readme}, + }); private(Json(v).into_response()) } -/// Everyone who tried it, newest first, with the cards they made and the GitHub stats saved for them (null once -/// the 30-day sweep removed them); and the decorations made without a login, as numbers only. -fn people(store: &Store) -> serde_json::Value { - let ((since, list, tried, in_readme, (decorations, decorations_in_readme)), blocked) = store.people.report_all(PEOPLE_SHOWN); +#[derive(serde::Deserialize)] +pub(super) struct PeopleQuery { + page: Option, + q: Option, +} + +/// /admin/people?page=N&q=text: one page of the people list, newest first, filtered by a part of the login. +pub(super) async fn admin_people( + State(app): State>>, + Query(q): Query, + req: axum::extract::Request, +) -> Response { + if !allowed(&app, req).await { + return private((StatusCode::NOT_FOUND, "not found").into_response()); + } + let find: String = q.q.unwrap_or_default().trim().to_ascii_lowercase().chars().take(39).collect(); + let a = app.clone(); + let v = tokio::task::spawn_blocking(move || people_page(&a.store, &find, q.page.unwrap_or(1).max(1))).await.unwrap_or_default(); + private(Json(v).into_response()) +} + +/// A page of people, with the cards they made and the GitHub stats saved for them (null once the 30-day sweep +/// removed them). A page past the end is clamped to the last one. +fn people_page(store: &Store, find: &str, page: usize) -> serde_json::Value { + let (total, list) = store.people.page(find, page, PEOPLE_PER_PAGE); + let pages = total.div_ceil(PEOPLE_PER_PAGE).max(1); + let (page, list) = if page > pages { (pages, store.people.page(find, pages, PEOPLE_PER_PAGE).1) } else { (page, list) }; + let list: Vec = list .into_iter() .map(|(login, p)| { @@ -183,10 +209,7 @@ fn people(store: &Store) -> serde_json::Value { serde_json::json!({"login": login, "first": p.first, "last": p.last, "cards": cards, "stats": stats}) }) .collect(); - serde_json::json!({ - "since": since, "tried": tried, "in_readme": in_readme, "list": list, "removed": blocked, - "no_login": {"decorations": decorations, "in_readme": decorations_in_readme}, - }) + serde_json::json!({"page": page, "pages": pages, "per": PEOPLE_PER_PAGE, "total": total, "list": list}) } /// A change from the admin page: a logged-in admin, and a header a form on another site can't send, so a page diff --git a/src/server/mod.rs b/src/server/mod.rs index 30c0e53f..3c62c8c8 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -5,7 +5,7 @@ mod cards; mod pages; pub use admin::Access; -use admin::{Outcome, admin_allow, admin_health, admin_remove, admin_status}; +use admin::{Outcome, admin_allow, admin_health, admin_people, admin_remove, admin_status}; use cards::*; use pages::*; @@ -121,6 +121,7 @@ pub fn router(app: Arc>) -> Router { .route("/sitemap.xml", get(|| async { static_file("sitemap.xml", PAGE) })) .route("/admin/status", get(admin_status::)) .route("/admin/health", get(admin_health::)) + .route("/admin/people", get(admin_people::)) .route("/admin/people/{login}/remove", axum::routing::post(admin_remove::)) .route("/admin/people/{login}/allow", axum::routing::post(admin_allow::)) // the admin page's shell is only served through its guarded address @@ -529,7 +530,7 @@ mod tests { assert_eq!(full["new_users"]["per_hour"], 600); assert!(full["traffic"].as_array().is_some_and(|t| t.len() == 60) && full["recent"].is_array(), "{full}"); let client = reqwest::Client::new(); - for path in ["/admin/health", "/admin/status", "/static/admin/status.html"] { + for path in ["/admin/health", "/admin/people", "/admin/status", "/static/admin/status.html"] { let r = client.get(format!("{base}{path}")).header("cf-ray", "x").send().await.unwrap(); assert_eq!(r.status(), 404, "{path} through Cloudflare without a login"); } @@ -559,7 +560,11 @@ mod tests { let p = &v["people"]; assert_eq!((p["tried"].as_u64(), p["in_readme"].as_u64()), (Some(1), Some(1)), "{p}"); assert_eq!(p["no_login"], serde_json::json!({"decorations": 2, "in_readme": 1})); - let ana = &p["list"][0]; + let page: serde_json::Value = reqwest::get(format!("{base}/admin/people?q=AN&page=9")).await.unwrap().json().await.unwrap(); + assert_eq!((page["page"].as_u64(), page["pages"].as_u64(), page["total"].as_u64()), (Some(1), Some(1), Some(1)), "{page}"); + let none: serde_json::Value = reqwest::get(format!("{base}/admin/people?q=zz")).await.unwrap().json().await.unwrap(); + assert_eq!((none["total"].as_u64(), none["list"].as_array().map(Vec::len)), (Some(0), Some(0))); + let ana = &page["list"][0]; assert_eq!((ana["login"].as_str(), ana["stats"]["followers"].as_u64()), (Some("ana"), Some(7))); let cards: Vec<(&str, bool)> = ana["cards"].as_array().unwrap().iter().map(|c| (c["address"].as_str().unwrap(), c["readme"] == true)).collect(); assert_eq!(cards.len(), 2, "{cards:?}");