Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions site/components/Health.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
99 changes: 86 additions & 13 deletions site/components/Health.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ type PeopleData = {
since: number;
tried: number;
in_readme: number;
list: Person[];
removed: string[];
no_login: { decorations: number; in_readme: number };
};
Expand Down Expand Up @@ -415,7 +414,7 @@ function Panels({ d, now, reload }: { d: HealthData; now: number; reload: () =>
</Panel>
</div>

{d.people && <People p={d.people} reload={reload} />}
{d.people && <People p={d.people} stamp={d.now} reload={reload} />}
<Traffic minutes={d.traffic} />
<Log lines={d.recent} />
</>
Expand All @@ -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<void> }) {
/** 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<void> }) {
const [page, setPage] = useState(1);
const [find, setFind] = useState("");
const [query, setQuery] = useState("");
const [data, setData] = useState<PeoplePage | null>(null);
const [failed, setFailed] = useState(false);
const [asking, setAsking] = useState(false);
const top = useRef<HTMLElement>(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 (
<section className={`${s.panel} ${s.wide}`} aria-label={P.aria}>
<section ref={top} className={`${s.panel} ${s.wide}`} aria-label={P.aria}>
<header className={s.panelHead}>
<h2>{P.title}</h2>
<span className={s.peak}>{P.since(day(p.since))}</span>
Expand All @@ -456,16 +504,41 @@ function People({ p, reload }: { p: PeopleData; reload: () => Promise<void> }) {
<div className={s.notes}>
<p>{rich(P.noLoginNote(num(p.no_login.in_readme)), { strong: (c) => <strong>{c}</strong> })}</p>
<p>{P.rules}</p>
{p.list.length < p.tried && <p>{P.shown(num(p.list.length), num(p.tried))}</p>}
</div>
{p.list.length ? (
<ul className={s.people}>
{p.list.map((u) => (
<PersonRow key={u.login} u={u} reload={reload} />
))}
</ul>
{p.tried > 0 && (
<label className={s.search}>
<span className="visually-hidden">{P.findLabel}</span>
<input id="people-search" type="search" placeholder={P.findPlaceholder} value={find} onChange={(e) => setFind(e.target.value)} />
</label>
)}
{failed && (
<p className={s.warn} role="status">
{P.pageFailed(!!data)}
</p>
)}
{!data ? (
!failed && <div className={`${s.person} ${s.shimmer} ${s.personShimmer}`} aria-busy="true" aria-label={H.loading} />
) : list.length ? (
<>
<ul className={s.people} aria-busy={asking}>
{list.map((u) => (
<PersonRow key={u.login} u={u} reload={reload} />
))}
</ul>
{data.pages > 1 && (
<nav className={s.pager} aria-label={P.pagesLabel}>
<button type="button" className={s.toggle} onClick={() => go(data.page - 1)} disabled={data.page <= 1}>
{P.newer}
</button>
<span aria-live="polite">{P.showing(num(from), num(from + list.length - 1), num(data.total), data.page, data.pages)}</span>
<button type="button" className={s.toggle} onClick={() => go(data.page + 1)} disabled={data.page >= data.pages}>
{P.older}
</button>
</nav>
)}
</>
) : (
<p className={s.notes}>{P.empty}</p>
<p className={s.notes}>{query ? P.noMatch(query) : P.empty}</p>
)}
{(p.removed ?? []).length > 0 && (
<div className={s.notes}>
Expand Down Expand Up @@ -583,7 +656,7 @@ function PersonRow({ u, reload }: { u: Person; reload: () => Promise<void> }) {
<p>{u.first > 0 ? P.seen(day(u.first), day(u.last)) : P.before(u.last > 0 ? day(u.last) : null)}</p>
</div>
{cards.length > 0 && (
<details className={s.made} open>
<details className={s.made}>
<summary>{P.cardsTitle(cards.length)}</summary>
<ul className={s.cards}>
{cards.map((c) => (
Expand Down
3 changes: 2 additions & 1 deletion site/components/builder/LoginStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,8 @@ export default function LoginStep({
/>
<div className={s.soloFoot}>
<p className={s.help}>
{other ? tx.footOther : tx.footProfile}
{other ? tx.footOther : tx.footProfile}{" "}
{rich(tx.privacy, { a: (c) => <a href="/privacy" target="_blank" rel="noopener">{c}</a> })}
</p>
<div className={s.soloButtons}>
{onCancel && (
Expand Down
1 change: 1 addition & 0 deletions site/i18n/en/builderParts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. <a>What questlog keeps</a>.",
cancel: "Cancel",
checking: "Looking you up",
next: "Next",
Expand Down
11 changes: 10 additions & 1 deletion site/i18n/en/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
80 changes: 58 additions & 22 deletions src/people.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@ struct Seen {
blocked: BTreeSet<String>,
}

/// 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<String>,
}

pub struct People {
path: PathBuf,
seen: Mutex<Seen>,
Expand Down Expand Up @@ -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<String>) {
/// 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> {
Expand Down Expand Up @@ -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;
Expand All @@ -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"));
}
Expand All @@ -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::<Vec<_>>();
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())));
}
}
49 changes: 36 additions & 13 deletions src/server/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -159,17 +159,43 @@ pub(super) async fn admin_health<F: Fetcher>(State(app): State<Arc<App<F>>>, 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<usize>,
q: Option<String>,
}

/// /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<F: Fetcher>(
State(app): State<Arc<App<F>>>,
Query(q): Query<PeopleQuery>,
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<serde_json::Value> = list
.into_iter()
.map(|(login, p)| {
Expand All @@ -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
Expand Down
Loading
Loading