personal activity timeline and portfolio site. polls public sources (github, gitea, mercurial, bugzilla), stores raw payloads in postgres, and serves a dashboard + project detail views to a react frontend.
successor to the now-defunct grenade-events-react, which depended on mongodb stitch (retired by mongodb in september 2022).
crates/
moments-entities/ # types and dtos (event, source, project/daily summaries)
moments-core/ # ingestion traits, presentation reshape, poller loop
moments-data/ # postgres adapter, migrations, all event-source impls
moments-api/ # axum read-only http api + forge proxy + og image (binary)
moments-worker/ # ingestion daemon (binary)
ui/ # vite + react + swc + typescript frontend
asset/ # systemd, nginx, firewalld, manifest.yml
script/
render-site-conf.py # render the nginx vhost from env (shared by both workflows)
hg-ingest.sh # one-shot local hg clone + psql ingest
certify.sh # letsencrypt cert management
teardown.sh # service removal
db-perms.sh # postgres role + ident setup
architectural conventions follow grenade/architecture/generic.md.
| source | impl | endpoint | notes |
|---|---|---|---|
| github events | github.rs | /users/{user}/events | last 90 days, etag-optimised polling |
| github search | github_search.rs | /search/commits + /search/issues | historical backfill, 1000-result cap |
| github repo | github_repo.rs | /user/repos + /repos/{o}/{r}/commits | full commit history, no cap, weekly poll |
| gitea | gitea.rs | user + org activity feeds | auto-discovers orgs, filters by user |
| mercurial | hg.rs | json-log?rev=author() | revset-based, one-shot backfill then skip |
| bugzilla | bugzilla.rs | /rest/bug?creator= | mozilla bugzilla |
hg repos are archived (mozilla retired hg). the worker skips hg after the first successful scan. for bulk ingestion, script/hg-ingest.sh clones repos locally and inserts via psql, avoiding rate limits on hg-edge.mozilla.org.
| path | page | description |
|---|---|---|
/ or /dash | dashboard | contribution graphs (daily + all-time weekly) + ranked project cards with forge icons and language info |
/activity | timeline | filterable activity feed with source toggles, date range slider, and event limit |
/activity/:timespan | timeline | pre-filtered by date (YYYY-MM-DD) or range (YYYY-MM-DD..YYYY-MM-DD) |
/project/:source/* | project detail | repo readme, language breakdown bar, per-repo activity timeline |
/cv | resume | loaded from github gist, markdown-rendered |
shared layout provides nav header (dash, activity, cv + external links) and footer across all routes.
| method | path | description |
|---|---|---|
| GET | /v1/healthz | liveness probe |
| GET | /v1/events?from=&to=&source=&repo=&limit= | reshaped timeline items |
| GET | /v1/sources | per-source summary (count, earliest, latest) |
| GET | /v1/projects | per-repo aggregated stats (commits, issues, prs, date range) |
| GET | /v1/activity/daily?from=&to= | per-day event counts for contribution graphs |
| GET | /v1/forge/{source}/*?host= | proxy to github/gitea apis (avoids cors) |
| GET | /v1/og/contributions.png | server-rendered contribution graph as png (resvg) |
the og image endpoint renders the all-time weekly contribution graph as svg, rasterizes to png via resvg, and serves it with a 1-hour cache. used as the og:image meta tag for social media previews.
cargo build --workspace
cargo run -p moments-api # serves on 127.0.0.1:8080
cargo run -p moments-worker # starts all pollers
cd ui && npm install && npm run dev # vite dev server on :5173
the api expects a postgres reachable at DATABASE_URL. in production this is an mtls connection using the host cert. for local dev against a throwaway database:
DATABASE_URL=postgres://localhost/moments cargo run -p moments-api
migrations live in crates/moments-data/migrations/ and run automatically on worker startup. the api connects as moments_ro and never runs migrations — the worker (as moments_rw) is the schema owner.
private work is counted but never described: events.public gates the detail
endpoints (events, projects, activity/summary, languages/repos) while the
count endpoints (activity/daily, activity/hourly, sources, languages/daily)
include everything, so a private repo shows up as volume on the contribution graph
without leaking its name or commit messages. because that flag is stamped at ingest
and the pollers are all incremental, the github and gitea sources re-read current
repo visibility on each poll and rewrite events.public across the affected repo's
whole history — a repo flipped to private upstream stops being described, one
flipped back to public reappears.
deployment is driven by Gitea Actions, not an operator workstation:
.gitea/workflows/deploy.yml — on push to main (or manual dispatch): lint/test gate, build the api + worker as static musl binaries and the prerendered web bundle, then deploy each component over SSH as the gitea_ci user with scoped sudo (asset/sudoers.d/)..gitea/workflows/refresh.yml — daily schedule: (or manual): rebuilds and redeploys only the web tier, re-baking the prerendered crawler snapshot without bouncing the api/worker.both workflows carry the infra truth (hosts, ports, paths) in their env: blocks and render the nginx vhost through the shared script/render-site-conf.py, which fails the build if any template placeholder is unset rather than shipping it. one-time per-host provisioning (the gitea_ci user, its authorized_keys, the scoped sudoers drop-in) is script/infra-setup.sh.
the shape of the deployment:
| component | notes |
|---|---|
| api | binds the port from api.config.bind; firewalld service moments-api |
| worker | no listening port; pollers only |
| web | per-site nginx ingress; /api/* reverse-proxies to the api host |
| db | postgres mtls, passwordless |
postgres roles moments_rw and moments_ro must exist on the primary, with pg_ident.conf.d/<host>.conf mapping the api host's fqdn to moments_ro and the worker host's fqdn to moments_rw. see asset/sql/bootstrap-moments.sql, asset/postgres/ident.conf.tmpl, and script/db-perms.sh.
the worker's poller tokens are Gitea repo Actions secrets (QUERY_GITHUB_TOKEN, QUERY_GITEA_TOKEN — the bare GITHUB_TOKEN/GITEA_TOKEN names are reserved by Actions). deploy.yml's deploy-worker job substitutes them into the matching {{NAME}} placeholders in worker.env.tmpl at deploy time; secrets come from the runner environment and never touch a command line.
| variable | default | description |
|---|---|---|
DATABASE_URL | required | postgres connection string |
GITHUB_USER | grenade | github username |
GITHUB_TOKEN | optional | github pat for higher rate limits + private events |
POLL_INTERVAL_SECS | 600 | github events api poll interval |
SEARCH_POLL_INTERVAL_SECS | 86400 | github search backfill interval |
REPO_POLL_INTERVAL_SECS | 604800 | github per-repo commit enumeration (weekly) |
GITEA_HOST | git.lair.cafe | gitea instance hostname |
GITEA_USER | grenade | gitea username |
GITEA_TOKEN | optional | gitea token for org discovery |
GITEA_POLL_INTERVAL_SECS | 600 | gitea activity feed poll interval |
HG_HOST | hg-edge.mozilla.org | mercurial host |
HG_GROUPS | build,integration | hg repo groups to discover |
HG_REPOS | mozilla-central | individual hg repos |
HG_AUTHOR_TERMS | rthijssen,grenade | author substrings for revset queries |
HG_POLL_INTERVAL_SECS | 86400 | hg poll interval (skips after first scan) |
BUGZILLA_HOST | bugzilla.mozilla.org | bugzilla instance |
BUGZILLA_EMAIL | [email protected] | bugzilla creator email filter |
BUGZILLA_POLL_INTERVAL_SECS | 86400 | bugzilla poll interval |
| variable | default | description |
|---|---|---|
DATABASE_URL | required | postgres connection string (read-only role) |
BIND_ADDR | 127.0.0.1:8080 | api listen address |
90 activities
dc09f57 fix(web): stop redirecting visitors to the vhost's internal portRan and verified. Both pg hosts are reachable again, and the standby now has the app-owned file with the two mappings that matter:
frankie: moments.conf:cert_cn nikola.kosherinata.internal moments_ro
moments.conf:cert_cn frootmig.kosherinata.internal moments_rw
Live, not just on disk — postgres reloaded at 13:33:11 (pg_conf_load_time() confirms it, and the journal shows received SIGHUP, reloading configuration files right after the two appends to moments.conf).
That clears the failover risk. Worth recording that it was broader than this issue described: nikola → moments_ro was missing too, so a failover would have taken the api down as well, not only the worker.
The primary carries four mappings, the standby two:
magrathea: frootmig -> moments_rw, nikola -> moments_ro,
roosta -> moments_rw, roosta -> moments_ro
frankie: frootmig -> moments_rw, nikola -> moments_ro
The two roosta.hanzalova.internal (workstation) lines are absent on frankie, and that follows from how db-perms.sh works: it converges each host to mapping_pairs — which holds only the api and worker hosts — plus whatever legacy moments_* lines already existed on that host. magrathea had the workstation lines to migrate out of its host-named files; frankie had none, so it got only the two.
So the script converges each host against itself rather than against a common desired set, and hosts can stay permanently divergent. Consequence today is small: after a failover, direct psql from the workstation as moments_rw/moments_ro would not authenticate against frankie (the ssh + sudo -u postgres path in asset/sql/bootstrap-moments.sql still works). Filed separately if worth fixing.
6ee4cf5 feat(api): make healthz verify the schema, not just that the process is up7e186f7 fix(ci): restart the api only after the worker has finished migratingef8521b fix(ci): prerender the web bundle after the api/worker deploy, not beside it12e9d40 feat: count private-repo work in the activity summary815bfa7 fix: reconcile repo visibility instead of trusting the ingest-time flag8797cc7 feat(web): forge icons + language bars in activity summary cardsc66ce33 feat(web): fixed summary windows with a paging range navigator9577702 feat: summarise /activity by period with per-repo change counts9905cef Merge pull request 'fix(ci): share one nginx-vhost renderer; drop unused deploy.sh' (#5) from fix/render-drift-remove-deploy-sh into main3260bfb fix(ci): share one nginx-vhost renderer; drop unused deploy.shbb2f5b1 fix(web): stop binding 443 behind the edge SNI router; verify the reload3d7cbd9 fix(infra): own pg_ident mappings in an app-specific moments.conffd33acd feat(web): enable gzip in the nginx vhost5e4cf21 ci: prerender against the internal mesh API + fail loud if emptyba96cdd ci: install web deps with --ignore-scripts + explicit rebuild8e11d02 ci: move pnpm build-script allowlist to pnpm-workspace.yaml163a36f ci: allow esbuild/@swc native build scripts under pnpm 10ed5acd9 ci: split build across rust + fedora runners, deploy on fedora-44a7750de ci: install pnpm via npm (gongfoo rust image lacks corepack)3761333 fix: make the workspace pass the CI lint/test gate1b753f9 feat: prerender every route + Gitea Actions deploy70b4b26 style(blog): scale down in-content post headings908ab33 style(blog): smaller list headers, readable date color37c4490 feat(blog): prune posts removed or renamed upstreamcd3dc2d fix(ui): add @types/node for process.env in vite.config88ce993 feat(blog): add markdown blog sourced from a gitea repo2821548 feat(ui): add avg-by-hour panel to dashboard stats72eeb54 chore(deploy): self-heal /tmp perms before staging86411bb fix(worker): dedup gitea events from overlapping user and org feedsacb061b chore(deploy): build rust binaries in a podman container8a7177a feat(ui): render GFM and embedded HTML in project READMEs818a535 feat(worker): capture commits on non-default branches and forks9a8c095 chore: phrasing25eab2d feat: add robots.txt allowing all crawlers including social bots2130032 chore: update Cargo.lock for fontdb dependency92a6642 feat(ui): add meta description, og:locale, and og:site_name94b6fbe feat(ui): add og:logo meta tag pointing to 512px icon048646a feat(ui): add og:url meta tag for canonical URL1f2fea3 fix: load system fonts for OG image text rendering283b212 feat(ui): color contribution graph circles by dominant languagee8dcb5f feat(ui): show private activity count on timeline when no public eventsb41e8c3 feat: include private repo contributions in graph metricsf386e0b feat(ui): reshape all-time graph and add dashboard stats panels111a2af feat(ui): language distribution bar on project cards6f30a61 feat(ui): smooth language stream graph with Catmull-Rom splines1464327 fix: weight language graph by repo language proportionsee93429 feat: language stream graph on dashboardc66aaeb feat: discover contributed repos via GitHub GraphQL API2a20b47 fix: resolve clippy redundant_closure warning in moments-apif77a8ab fix: use since cursor in github-repo polls to prevent missed commits1679153 docs: add CLAUDE.md and ignore .zed/0aa53d3 docs: rewrite readme to reflect current architecturecd833b1 fix(ui): demote repos with >= 10k commits to end of dashboard293d112 fix: fall back to _repo in commit reshape for github-repo eventsef1e84a feat(ui): link forge icon to repo on project pagef8c13b5 fix: icon colors for dark backgroundsabc90c8 feat(ui): forge icon on project page headerd46a0e3 fix: add _repo fallback to events repo filter for github-repo commits2284a88 fix(ui): all-time graph as year rows with 52 weekly columns each1ca85fe feat(ui): all-time weekly contribution graph + date range timespan support822def3 fix(ui): scale contribution graph to full container width27ce16e feat(ui): contribution graph with daily activity heatmap7de2330 chore(ui): add favicon set to index.html7a4939c chore(ui): add favicon set to index.html0d350ce fix: decode base64 readme content as utf-8 instead of latin-11275a77 chore: update Cargo.lock for reqwest in moments-api6b9ce99 fix: proxy forge API requests to avoid CORS, case-insensitive readmef676ecd fix: try multiple readme filename casings for Gitea reposba21658 feat(ui): project readme, language bars, and per-card language summary80f3f7c feat(ui): project drill-down route with repo-filtered event timelinea70fab4 feat(ui): add /dash route, shared nav, project dashboard with /v1/projects APIa71b4e6 feat(github): per-repo commit enumeration for full history backfill2da9461 fix(hg): show clone errors, stable cwd; shrink timeline fonts3f3a1fb fix: connection string88fbbba feat(hg): revset-based author query, group discovery, one-shot ingest script1bbe55d feat(gitea): poll org activity feeds to capture cross-namespace events4c8a663 feat(ui): add /cv route, site-wide lowercase, no-cookies footer8867ff5 feat(deploy): manifest-driven config, teardown + db-perms, hardeningf30f949 fix: ensure root ownership when syncing staged folders7843c2c chore(deploy): co-locate api + worker on anjiec81512f fix: conventional paths, oolon fqdn, public certabce380 chore(deploy): strip infra commentary from asset/ config files52b7d0b fix(deploy): split ingress to oolon, expose api on nikola interface110b523 chore(deploy): add manifest, systemd units, nginx config, deploy.sh7919a2d feat(worker): add hg-edge and bugzilla pollersf750e8d feat(worker): add gitea activity feed poller4355353 fix(presentation): handle force-push, branch-create, empty pushesbf04f8a fix(api): log internal handler errorsbf7f829 fix(api): don't run migrations as moments_rob04afd8 feat(ui): scaffold vite + react 19 frontend7772393 feat(worker): add commits to github search backfille4052c4 feat(worker): add github search api source for historical backfill3c02535 feat: ingest private events; surface public-only003f427 feat(api): reshape raw events into TimelineItem418834c docs(asset/sql): document mtls and ssh-sudo run modes45ceec2 feat(worker): add github events pollere40d6b0 chore(asset): add postgres bootstrap and pg_ident template