Compare commits

...

2 Commits

Author SHA1 Message Date
4c8a663288 feat(ui): add /cv route, site-wide lowercase, no-cookies footer
reproduces the legacy cv (previously at grenade.github.io/cv) as a
react-router /cv route, fetched at runtime from the same gist. moves
the lowercase aesthetic from per-element overrides to a single body-
level rule so a future toggle can flip it from one place. adds a small
site-wide footer noting why no cookie consent banner is shown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:22:44 +03:00
8867ff5df3 feat(deploy): manifest-driven config, teardown + db-perms, hardening
deploy.sh:
- never rsync into /; stage to /tmp on the remote and install at final
  paths via sudo bash heredoc, closing the parent-dir attribute leak
  that broke three hosts in the earlier rsync incident
- shell-quote heredoc args via ${var@Q}
- drop -A -X on the remaining (web) rsyncs
- generic worker.secrets loop reads (env-var → pass path) from manifest;
  GITEA_TOKEN now flows through automatically
- in-memory bash substitution for templates (secrets never on argv)
- simplify semanage port labelling: --add 2>/dev/null || --modify (the
  old grep pre-check matched only the first listed port)
- restorecon back to short flags (Fedora policycoreutils has no long
  forms; --recursive errored at deploy time)
- quieter health probe loop: curl diagnostics only on final failure

manifest as source of truth:
- api.config.bind drives BIND_ADDR, firewalld port, semanage label,
  health-probe URL
- web.config.{server_name,root,api_upstream} drives nginx render,
  rsync targets, restorecon scope
- nginx config renamed to site.conf.tmpl; firewalld svc to
  moments-api.xml.tmpl; both rendered at deploy time
- topology flip: api → nikola, worker → frootmig (anjie freed)

new scripts:
- script/teardown.sh: idempotent component teardown, never rsyncs,
  shared-state cleanup gated on absence of remaining env files,
  --remove-docroot guard against shallow / system paths
- script/db-perms.sh: rewritten — fixes grep/append role mismatch that
  appended duplicates on re-run, adds postgres reload, hits primary +
  standby in a single invocation

readme: genericized; deployment topology no longer carries real host
or site names.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 16:39:10 +03:00
22 changed files with 1408 additions and 303 deletions

View File

@@ -1,6 +1,6 @@
JOURNAL_STREAM=1
RUST_LOG=info,sqlx=warn,tower_http=info
BIND_ADDR=0.0.0.0:42424
BIND_ADDR={{BIND}}
DATABASE_URL=postgres://moments_ro@magrathea.kosherinata.internal:5432/moments?sslmode=verify-full&sslrootcert=/etc/pki/ca-trust/source/anchors/root-internal.pem&sslcert=/etc/pki/tls/misc/{{HOSTNAME}}.pem&sslkey=/etc/pki/tls/private/{{HOSTNAME}}.pem

View File

@@ -10,6 +10,7 @@ SEARCH_POLL_INTERVAL_SECS=86400
GITEA_HOST=git.lair.cafe
GITEA_USER=grenade
GITEA_TOKEN={{GITEA_TOKEN}}
GITEA_POLL_INTERVAL_SECS=600
HG_HOST=hg-edge.mozilla.org

View File

@@ -2,5 +2,5 @@
<service>
<short>moments-api</short>
<description>moments read-only HTTP API</description>
<port protocol="tcp" port="42424"/>
<port protocol="tcp" port="{{API_PORT}}"/>
</service>

View File

@@ -3,7 +3,7 @@ environments:
prod:
components:
api:
hosts: [anjie.kosherinata.internal]
hosts: [nikola.kosherinata.internal]
config:
bind: 0.0.0.0:42424
db_role: moments_ro
@@ -11,7 +11,7 @@ environments:
db_port: 5432
db_name: moments
worker:
hosts: [anjie.kosherinata.internal]
hosts: [frootmig.kosherinata.internal]
config:
db_role: moments_rw
db_host: magrathea.kosherinata.internal
@@ -27,10 +27,10 @@ environments:
bugzilla_email: rthijssen@mozilla.com
secrets:
GITHUB_TOKEN: github.com/grenade/admin-token
# GITEA_TOKEN, BUGZILLA_API_KEY: optional, omit unless required.
GITEA_TOKEN: git.lair.cafe/grenade/admin-token
web:
hosts: [oolon.kosherinata.internal]
config:
server_name: rob.tn
root: /var/www/rob.tn
api_upstream: http://anjie.kosherinata.internal:42424
api_upstream: http://nikola.kosherinata.internal:42424

View File

@@ -1,18 +1,18 @@
upstream moments_api {
server anjie.kosherinata.internal:42424 max_fails=3 fail_timeout=30s;
server {{API_UPSTREAM_ADDR}} max_fails=3 fail_timeout=30s;
keepalive 8;
}
server {
server_name rob.tn;
server_name {{SERVER_NAME}};
listen 443 ssl;
http2 on;
ssl_certificate /etc/letsencrypt/live/rob.tn/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/rob.tn/privkey.pem;
ssl_certificate /etc/letsencrypt/live/{{SERVER_NAME}}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{SERVER_NAME}}/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
root /var/www/rob.tn;
root {{DOCROOT}};
index index.html;
location / {
@@ -28,7 +28,7 @@ server {
location /api/ {
rewrite ^/api/(.*)$ /$1 break;
proxy_pass http://moments_api;
proxy_pass {{API_UPSTREAM_SCHEME}}://moments_api;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
@@ -38,6 +38,6 @@ server {
proxy_connect_timeout 5s;
}
access_log /var/log/nginx/rob.tn.access.log;
error_log /var/log/nginx/rob.tn.error.log;
access_log /var/log/nginx/{{SERVER_NAME}}.access.log;
error_log /var/log/nginx/{{SERVER_NAME}}.error.log;
}

View File

@@ -1,6 +1,6 @@
# moments
Personal activity timeline for [rob.tn](https://rob.tn). Polls public sources (GitHub, Gitea, hg-edge.mozilla.org, bugzilla.mozilla.org), stores raw payloads in Postgres, and serves a reshaped timeline to a static React frontend.
Personal activity timeline. Polls public sources (GitHub, Gitea, Mercurial, Bugzilla), stores raw payloads in Postgres, and serves a reshaped timeline to a static React frontend.
Successor to the now-defunct [grenade-events-react](https://github.com/grenade/grenade-events-react), which depended on MongoDB Stitch (retired by MongoDB in September 2022).
@@ -28,7 +28,7 @@ cargo run -p moments-api # serves on 127.0.0.1:8080
cargo run -p moments-worker # one-shot ingest tick (until --interval is wired up)
```
The API expects a Postgres reachable at `DATABASE_URL`. For magrathea, that's an mTLS connection using the host cert. For local dev against a throwaway database:
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:
```sh
DATABASE_URL=postgres://localhost/moments cargo run -p moments-api
@@ -39,37 +39,31 @@ Migrations live in `crates/moments-data/migrations/` and run automatically on wo
## Deployment
```sh
./script/deploy.sh prod all # api + worker + web
./script/deploy.sh prod api worker # subset
./script/deploy.sh prod default # api + web only (worker untouched)
./script/deploy.sh prod all --dry-run
./script/deploy.sh <env> all # api + worker + web
./script/deploy.sh <env> api worker # subset
./script/deploy.sh <env> default # api + web only (worker untouched)
./script/deploy.sh <env> all --dry-run
```
Topology:
Concrete hosts, ports, and the site's `server_name` live in `asset/manifest.yml`. The shape of the deployment:
| Component | Host | Notes |
| --------- | --------------------------------- | ------------------------------------------------------------------ |
| api | `anjie.kosherinata.internal` | binds `0.0.0.0:42424`; firewalld service `moments-api` |
| worker | `anjie.kosherinata.internal` | no listening port; pollers only |
| web | `oolon.kosherinata.internal` | per-site nginx ingress for rob.tn; `/api/*` → anjie across the WG |
| db | `magrathea.kosherinata.internal` | postgres mTLS, passwordless |
| 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 |
api and worker are co-located on `anjie` while `nikola` and `frootmig` are out for drive replacement.
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 → `moments_ro` and the worker host's FQDN → `moments_rw`. See `asset/sql/bootstrap-moments.sql`, `asset/postgres/ident.conf.tmpl`, and `script/db-perms.sh` (idempotently adds the cert_cn lines on the postgres primary + standby and reloads postgres).
Postgres roles `moments_rw` and `moments_ro` must exist on the primary, with `pg_ident.conf` mapping `anjie.kosherinata.internal` to **both** roles (one cert_cn line per mapping). See `asset/sql/bootstrap-moments.sql` and `asset/postgres/ident.conf.tmpl`.
Inter-host traffic over the WG mesh: web's nginx connects to the api host in plaintext. The mesh provides the encryption layer; per-hop TLS for an internal HTTP read-only API on already-public data is deferred. If that changes, swap the api binary to rustls + the host cert pair, and update the nginx upstream to `https://`.
Inter-host traffic over the WG mesh: oolon's nginx connects to `http://anjie.kosherinata.internal:42424` in plaintext. The mesh provides the encryption layer; per-hop TLS for an internal HTTP read-only API on already-public data is deferred. If that changes, swap the api binary to rustls + the host cert pair, and update the nginx upstream to `https://`.
Secrets are resolved at deploy time via `pass`. The mapping of env-var name → pass-store path lives under `worker.secrets` in `manifest.yml`; `deploy.sh` iterates the map, fetches each secret, and substitutes the matching `{{NAME}}` placeholder in `worker.env.tmpl`. To add a secret: add a `worker.secrets` entry, add `NAME={{NAME}}` to `worker.env.tmpl`, and ensure `pass show <path>` returns the value on the deploying machine.
Secrets resolved by `deploy.sh` via `pass`:
### First-time setup
- `github.com/grenade/admin-token` — GitHub PAT for events + search APIs (worker only).
After the first successful prod deploy:
Optional, set if needed in `worker.env`: `GITEA_TOKEN`, `BUGZILLA_API_KEY`.
### DNS cutover
`rob.tn` currently resolves to GitHub Pages. After the first successful prod deploy:
1. Update Cloudflare DNS for `rob.tn` to the WAN IP that fronts `oolon` (unproxied — see architecture doc §11).
2. Confirm `curl -fsS https://rob.tn/api/v1/healthz` returns `ok`.
3. Add an archival notice to the top of [grenade-events-react/readme.md](https://github.com/grenade/grenade-events-react) pointing at this repo, and archive the GitHub repo.
1. Point public DNS for the site at the web host's public IP (unproxied).
2. Confirm `curl --fail --silent --show-error https://<site>/api/v1/healthz` returns `ok`.
3. If migrating from a predecessor, archive the old repo with a pointer to this one.

63
script/db-perms.sh Executable file
View File

@@ -0,0 +1,63 @@
#!/usr/bin/env bash
#
# Idempotently add cert_cn → role mappings to pg_ident.conf.d on the moments
# postgres primary and standby, then reload postgres so the changes take
# effect. Re-running is a no-op (no duplicate lines, no spurious reload).
#
# Run from a workstation with ssh access to both pg hosts. This script ssh's
# out; do NOT run it on magrathea/frankie directly.
set -euo pipefail
api_host=nikola.kosherinata.internal
worker_host=frootmig.kosherinata.internal
pg_hosts=(
magrathea.kosherinata.internal
frankie.kosherinata.internal
)
# Each (cert_cn host, role) pair becomes one cert_cn line in
# pg_ident.conf.d/<cert_cn host>.conf on every pg host listed above.
mapping_pairs=(
"$api_host" moments_ro
"$worker_host" moments_rw
)
ident_dir=/var/lib/pgsql/18/data/pg_ident.conf.d
for pg_host in "${pg_hosts[@]}"; do
printf '==> %s\n' "$pg_host"
ssh -o BatchMode=yes "$pg_host" "sudo bash -s -- ${ident_dir@Q} ${mapping_pairs[@]@Q}" <<'REMOTE_EOF'
set -euo pipefail
ident_dir="$1"; shift
changed=0
while [[ $# -gt 0 ]]; do
cert_cn_host="$1"
role="$2"
shift 2
line="cert_cn ${cert_cn_host} ${role}"
file="${ident_dir}/${cert_cn_host}.conf"
# The heredoc runs as root via sudo bash, so [[ -f ]] and grep are fine
# without dropping privs. tee --append runs as postgres so a newly-created
# file lands with the conventional postgres:postgres ownership.
if [[ -f "$file" ]] && grep --fixed-strings --line-regexp --quiet -- "$line" "$file"; then
printf ' present: %s\n' "$line"
else
printf '%s\n' "$line" | sudo -u postgres tee --append "$file" >/dev/null
printf ' added: %s\n' "$line"
changed=1
fi
done
if (( changed )); then
systemctl reload postgresql-18
echo " reloaded postgresql-18"
else
echo " no changes; reload skipped"
fi
REMOTE_EOF
done

View File

@@ -68,10 +68,10 @@ command -v cargo >/dev/null 2>&1 || die "cargo is required"
# Resolve component list ----------------------------------------------------
env_path=".environments.${environment}"
yq -e "${env_path}" "$manifest" >/dev/null \
yq --exit-status "${env_path}" "$manifest" >/dev/null \
|| die "environment '$environment' not found in manifest"
mapfile -t all_components < <(yq -r "${env_path}.components | keys | .[]" "$manifest")
mapfile -t all_components < <(yq --raw-output "${env_path}.components | keys | .[]" "$manifest")
if [[ ${#components[@]} -eq 0 ]]; then
usage
@@ -108,75 +108,116 @@ deploy_api() {
local host="$1"
log "api -> $host"
local bind
bind="$(yq --raw-output "${env_path}.components.api.config.bind" "$manifest")"
[[ -n "$bind" && "$bind" != "null" ]] || die "api.config.bind missing in manifest"
[[ "$bind" == *:* ]] \
|| die "api.config.bind must be host:port form: '$bind'"
local api_port
api_port="${bind##*:}"
[[ "$api_port" =~ ^[0-9]+$ ]] \
|| die "api.config.bind port is not numeric: '$api_port'"
if (( dry_run )); then
printf '\033[2m[dry-run]\033[0m render api.env (HOSTNAME=%s) + units, rsync to %s:/, run sysusers/restorecon/semanage/systemctl on %s\n' \
"$host" "$host" "$host" >&2
printf '\033[2m[dry-run]\033[0m render api.env (HOSTNAME=%s, BIND=%s) + firewalld svc (port=%s) + units, stage to %s:/tmp/, install via heredoc, run sysusers/restorecon/semanage/systemctl on %s\n' \
"$host" "$bind" "$api_port" "$host" "$host" >&2
return 0
fi
local fqdn
fqdn="$host"
local fqdn="$host"
local stage
stage="$(mktemp -d)"
trap "rm -rf '$stage'" RETURN
stage="$(mktemp --directory)"
trap "rm --recursive --force '$stage'" RETURN
install -d "$stage/etc/moments" "$stage/etc/systemd/system" "$stage/etc/sysusers.d" "$stage/etc/firewalld/services" "$stage/usr/local/bin"
install --directory \
"$stage/etc/moments" \
"$stage/etc/systemd/system" \
"$stage/etc/sysusers.d" \
"$stage/etc/firewalld/services" \
"$stage/usr/local/bin"
# Render env file with hostname substitution.
sed "s|{{HOSTNAME}}|${fqdn}|g" "${repo_root}/asset/config/api.env.tmpl" \
> "$stage/etc/moments/api.env"
local rendered
rendered="$(<"${repo_root}/asset/config/api.env.tmpl")"
rendered=${rendered//'{{HOSTNAME}}'/$fqdn}
rendered=${rendered//'{{BIND}}'/$bind}
printf '%s\n' "$rendered" > "$stage/etc/moments/api.env"
sed "s|{{HOSTNAME}}|${fqdn}|g" "${repo_root}/asset/systemd/moments-api-cert.path" \
> "$stage/etc/systemd/system/moments-api-cert.path"
rendered="$(<"${repo_root}/asset/systemd/moments-api-cert.path")"
rendered=${rendered//'{{HOSTNAME}}'/$fqdn}
printf '%s\n' "$rendered" > "$stage/etc/systemd/system/moments-api-cert.path"
install -m 0644 "${repo_root}/asset/systemd/moments-api.service" "$stage/etc/systemd/system/"
install -m 0644 "${repo_root}/asset/systemd/moments-api-cert-reload.service" "$stage/etc/systemd/system/"
install -m 0644 "${repo_root}/asset/systemd/moments.sysusers.conf" "$stage/etc/sysusers.d/moments.conf"
install -m 0644 "${repo_root}/asset/firewalld/moments-api.xml" "$stage/etc/firewalld/services/moments-api.xml"
install -m 0755 "${repo_root}/target/release/moments-api" "$stage/usr/local/bin/moments-api"
rendered="$(<"${repo_root}/asset/firewalld/moments-api.xml.tmpl")"
rendered=${rendered//'{{API_PORT}}'/$api_port}
printf '%s\n' "$rendered" > "$stage/etc/firewalld/services/moments-api.xml"
chmod 0644 "$stage/etc/firewalld/services/moments-api.xml"
install --mode=0644 "${repo_root}/asset/systemd/moments-api.service" "$stage/etc/systemd/system/"
install --mode=0644 "${repo_root}/asset/systemd/moments-api-cert-reload.service" "$stage/etc/systemd/system/"
install --mode=0644 "${repo_root}/asset/systemd/moments.sysusers.conf" "$stage/etc/sysusers.d/moments.conf"
install --mode=0755 "${repo_root}/target/release/moments-api" "$stage/usr/local/bin/moments-api"
# Permissions on the rendered env: root-owned, moments group readable.
chmod 0640 "$stage/etc/moments/api.env"
if (( dry_run )); then
printf '\033[2m[dry-run]\033[0m rsync staged -> %s:/\n' "$host" >&2
else
# Stage to a tmpdir on the remote, then `install` each file at its final
# path via the heredoc. Never rsync into /, since rsync of staged parent
# dirs (etc/, usr/, ...) can leak ownership, ACLs and xattrs onto the
# live system dirs.
local remote_stage="/tmp/moments-deploy.api.${$}.${RANDOM}"
rsync \
--archive \
--hard-links \
--acls \
--xattrs \
--numeric-ids \
--chown root:root \
--rsync-path 'sudo rsync' \
--rsh='ssh -o BatchMode=yes' \
"$stage/" \
"${host}:/"
fi
"${host}:${remote_stage}/"
ssh_run "$host" "sudo bash -s" <<'REMOTE_EOF'
ssh_run "$host" "sudo bash -s -- ${remote_stage@Q} ${api_port@Q}" <<'REMOTE_EOF'
set -euo pipefail
fqdn="$(hostname -f)"
remote_stage="$1"
api_port="$2"
trap 'rm --recursive --force "$remote_stage"' EXIT
fqdn="$(hostname --fqdn)"
install --owner=root --group=root --mode=0644 \
"$remote_stage/etc/sysusers.d/moments.conf" \
/etc/sysusers.d/moments.conf
systemd-sysusers /etc/sysusers.d/moments.conf
install -d -o root -g moments -m 0750 /etc/moments
install -d -o moments -g moments -m 0750 /var/lib/moments
chown root:moments /etc/moments/api.env
chmod 0640 /etc/moments/api.env
install --directory --owner=root --group=moments --mode=0750 /etc/moments
install --directory --owner=moments --group=moments --mode=0750 /var/lib/moments
# Grant the moments user read access to the host private key — required for
# the postgres mTLS connection.
setfacl -m u:moments:r "/etc/pki/tls/private/${fqdn}.pem" || true
install --owner=root --group=moments --mode=0640 \
"$remote_stage/etc/moments/api.env" \
/etc/moments/api.env
install --owner=root --group=root --mode=0644 \
"$remote_stage/etc/systemd/system/moments-api.service" \
/etc/systemd/system/moments-api.service
install --owner=root --group=root --mode=0644 \
"$remote_stage/etc/systemd/system/moments-api-cert.path" \
/etc/systemd/system/moments-api-cert.path
install --owner=root --group=root --mode=0644 \
"$remote_stage/etc/systemd/system/moments-api-cert-reload.service" \
/etc/systemd/system/moments-api-cert-reload.service
install --owner=root --group=root --mode=0644 \
"$remote_stage/etc/firewalld/services/moments-api.xml" \
/etc/firewalld/services/moments-api.xml
install --owner=root --group=root --mode=0755 \
"$remote_stage/usr/local/bin/moments-api" \
/usr/local/bin/moments-api
# Label the API port. Idempotent — the -m fallback turns "already labelled"
# into a no-op.
if ! semanage port -l | awk '{print $1, $3}' | grep -qE "^http_port_t .*42424"; then
semanage port -a -t http_port_t -p tcp 42424 || \
semanage port -m -t http_port_t -p tcp 42424
fi
# Grant the moments user read access to the host private key for the
# postgres mTLS connection.
setfacl --modify=u:moments:r "/etc/pki/tls/private/${fqdn}.pem" || true
# Idempotent label: --add fails if the port is already labelled (we suppress
# that one stderr line); --modify is then a no-op or fixes a stale type.
semanage port --add --type=http_port_t --proto=tcp "$api_port" 2>/dev/null \
|| semanage port --modify --type=http_port_t --proto=tcp "$api_port"
# Firewalld: install the named service and enable it in the default zone.
firewall-cmd --reload
zone="$(firewall-cmd --get-default-zone)"
if ! firewall-cmd --zone="$zone" --query-service=moments-api >/dev/null 2>&1; then
@@ -191,17 +232,18 @@ systemctl enable --now moments-api-cert.path
systemctl enable --now moments-api.service
systemctl restart moments-api.service
# Health probe — hit the bound interface, not loopback, so we exercise the
# same path nginx will use from oolon.
# Quietly retry while the service binds; only show curl's diagnostics if
# every attempt fails. The journalctl tail on failure is the verbose source.
for i in 1 2 3 4 5 6 7 8 9 10; do
if curl -fsS "http://${fqdn}:42424/v1/healthz" >/dev/null; then
if curl --fail --silent "http://${fqdn}:${api_port}/v1/healthz" >/dev/null 2>&1; then
echo "moments-api healthy"
exit 0
fi
sleep 1
done
echo "moments-api did not become healthy" >&2
journalctl -u moments-api.service -n 50 --no-pager >&2
curl --fail --silent --show-error "http://${fqdn}:${api_port}/v1/healthz" >/dev/null || true
journalctl --unit=moments-api.service --lines=50 --no-pager >&2
exit 1
REMOTE_EOF
}
@@ -210,69 +252,112 @@ deploy_worker() {
local host="$1"
log "worker -> $host"
# Manifest entries under `worker.secrets` map env-var name -> pass store path.
# The script fetches each via `pass` and substitutes the matching {{NAME}}
# placeholder in worker.env.tmpl. Adding a new secret is then a manifest +
# template change; no script edit required.
local -a secret_lines secret_keys
mapfile -t secret_lines < <(yq --raw-output \
"${env_path}.components.worker.secrets // {} | to_entries | .[] | \"\(.key)=\(.value)\"" \
"$manifest")
local line
for line in "${secret_lines[@]}"; do
[[ -n "$line" ]] && secret_keys+=("${line%%=*}")
done
if (( dry_run )); then
printf '\033[2m[dry-run]\033[0m render worker.env (HOSTNAME=%s, GITHUB_TOKEN from pass) + units, rsync to %s:/, run sysusers/restorecon/systemctl on %s\n' \
"$host" "$host" "$host" >&2
printf '\033[2m[dry-run]\033[0m render worker.env (HOSTNAME=%s, secrets [%s] from pass) + units, stage to %s:/tmp/, install via heredoc, run sysusers/restorecon/systemctl on %s\n' \
"$host" "${secret_keys[*]:-none}" "$host" "$host" >&2
return 0
fi
local fqdn
fqdn="$host"
local github_token=""
if pass show github.com/grenade/admin-token >/dev/null 2>&1; then
github_token="$(pass show github.com/grenade/admin-token)"
else
warn "no github admin-token in pass; worker will run without GITHUB_TOKEN"
fi
local fqdn="$host"
local stage
stage="$(mktemp -d)"
trap "rm -rf '$stage'" RETURN
stage="$(mktemp --directory)"
trap "rm --recursive --force '$stage'" RETURN
install -d "$stage/etc/moments" "$stage/etc/systemd/system" "$stage/etc/sysusers.d" "$stage/usr/local/bin"
install --directory \
"$stage/etc/moments" \
"$stage/etc/systemd/system" \
"$stage/etc/sysusers.d" \
"$stage/usr/local/bin"
sed -e "s|{{HOSTNAME}}|${fqdn}|g" \
-e "s|{{GITHUB_TOKEN}}|${github_token}|g" \
"${repo_root}/asset/config/worker.env.tmpl" > "$stage/etc/moments/worker.env"
# Render templates in-memory so secrets never appear on a command line
# (sed would expose them to anything that can read /proc/<pid>/cmdline).
local rendered
rendered="$(<"${repo_root}/asset/config/worker.env.tmpl")"
rendered=${rendered//'{{HOSTNAME}}'/$fqdn}
local key pass_path value
for line in "${secret_lines[@]}"; do
[[ -z "$line" ]] && continue
key="${line%%=*}"
pass_path="${line#*=}"
if pass show "$pass_path" >/dev/null 2>&1; then
value="$(pass show "$pass_path")"
else
warn "no secret in pass at '${pass_path}' for ${key}; worker will run without ${key}"
value=""
fi
rendered=${rendered//"{{${key}}}"/$value}
done
printf '%s\n' "$rendered" > "$stage/etc/moments/worker.env"
sed "s|{{HOSTNAME}}|${fqdn}|g" "${repo_root}/asset/systemd/moments-worker-cert.path" \
> "$stage/etc/systemd/system/moments-worker-cert.path"
rendered="$(<"${repo_root}/asset/systemd/moments-worker-cert.path")"
rendered=${rendered//'{{HOSTNAME}}'/$fqdn}
printf '%s\n' "$rendered" > "$stage/etc/systemd/system/moments-worker-cert.path"
install -m 0644 "${repo_root}/asset/systemd/moments-worker.service" "$stage/etc/systemd/system/"
install -m 0644 "${repo_root}/asset/systemd/moments-worker-cert-reload.service" "$stage/etc/systemd/system/"
install -m 0644 "${repo_root}/asset/systemd/moments.sysusers.conf" "$stage/etc/sysusers.d/moments.conf"
install -m 0755 "${repo_root}/target/release/moments-worker" "$stage/usr/local/bin/moments-worker"
install --mode=0644 "${repo_root}/asset/systemd/moments-worker.service" "$stage/etc/systemd/system/"
install --mode=0644 "${repo_root}/asset/systemd/moments-worker-cert-reload.service" "$stage/etc/systemd/system/"
install --mode=0644 "${repo_root}/asset/systemd/moments.sysusers.conf" "$stage/etc/sysusers.d/moments.conf"
install --mode=0755 "${repo_root}/target/release/moments-worker" "$stage/usr/local/bin/moments-worker"
chmod 0640 "$stage/etc/moments/worker.env"
if (( dry_run )); then
printf '\033[2m[dry-run]\033[0m rsync staged -> %s:/\n' "$host" >&2
else
# Stage to a tmpdir on the remote, then `install` each file at its final
# path via the heredoc. Never rsync into /.
local remote_stage="/tmp/moments-deploy.worker.${$}.${RANDOM}"
rsync \
--archive \
--hard-links \
--acls \
--xattrs \
--numeric-ids \
--chown root:root \
--rsync-path 'sudo rsync' \
--rsh='ssh -o BatchMode=yes' \
"$stage/" \
"${host}:/"
fi
"${host}:${remote_stage}/"
ssh_run "$host" "sudo bash -s" <<'REMOTE_EOF'
ssh_run "$host" "sudo bash -s -- ${remote_stage@Q}" <<'REMOTE_EOF'
set -euo pipefail
fqdn="$(hostname -f)"
remote_stage="$1"
trap 'rm --recursive --force "$remote_stage"' EXIT
fqdn="$(hostname --fqdn)"
install --owner=root --group=root --mode=0644 \
"$remote_stage/etc/sysusers.d/moments.conf" \
/etc/sysusers.d/moments.conf
systemd-sysusers /etc/sysusers.d/moments.conf
install -d -o root -g moments -m 0750 /etc/moments
install -d -o moments -g moments -m 0750 /var/lib/moments
chown root:moments /etc/moments/worker.env
chmod 0640 /etc/moments/worker.env
install --directory --owner=root --group=moments --mode=0750 /etc/moments
install --directory --owner=moments --group=moments --mode=0750 /var/lib/moments
setfacl -m u:moments:r "/etc/pki/tls/private/${fqdn}.pem" || true
install --owner=root --group=moments --mode=0640 \
"$remote_stage/etc/moments/worker.env" \
/etc/moments/worker.env
install --owner=root --group=root --mode=0644 \
"$remote_stage/etc/systemd/system/moments-worker.service" \
/etc/systemd/system/moments-worker.service
install --owner=root --group=root --mode=0644 \
"$remote_stage/etc/systemd/system/moments-worker-cert.path" \
/etc/systemd/system/moments-worker-cert.path
install --owner=root --group=root --mode=0644 \
"$remote_stage/etc/systemd/system/moments-worker-cert-reload.service" \
/etc/systemd/system/moments-worker-cert-reload.service
install --owner=root --group=root --mode=0755 \
"$remote_stage/usr/local/bin/moments-worker" \
/usr/local/bin/moments-worker
setfacl --modify=u:moments:r "/etc/pki/tls/private/${fqdn}.pem" || true
restorecon -Rv /usr/local/bin/moments-worker /etc/moments /var/lib/moments
@@ -281,9 +366,8 @@ systemctl enable --now moments-worker-cert.path
systemctl enable --now moments-worker.service
systemctl restart moments-worker.service
# Liveness probe — worker doesn't expose a port, so check is-active.
if ! systemctl is-active --quiet moments-worker.service; then
journalctl -u moments-worker.service -n 50 --no-pager >&2
journalctl --unit=moments-worker.service --lines=50 --no-pager >&2
exit 1
fi
echo "moments-worker active"
@@ -294,61 +378,93 @@ deploy_web() {
local host="$1"
log "web -> $host"
local server_name web_root api_upstream
server_name="$(yq --raw-output "${env_path}.components.web.config.server_name" "$manifest")"
web_root="$(yq --raw-output "${env_path}.components.web.config.root" "$manifest")"
api_upstream="$(yq --raw-output "${env_path}.components.web.config.api_upstream" "$manifest")"
[[ -n "$server_name" && "$server_name" != "null" ]] || die "web.config.server_name missing in manifest"
[[ -n "$web_root" && "$web_root" != "null" ]] || die "web.config.root missing in manifest"
[[ -n "$api_upstream" && "$api_upstream" != "null" ]] || die "web.config.api_upstream missing in manifest"
[[ "$web_root" == /* ]] \
|| die "web.config.root must be an absolute path: '$web_root'"
[[ "$api_upstream" == http://* || "$api_upstream" == https://* ]] \
|| die "web.config.api_upstream must be a http(s) URL: '$api_upstream'"
local api_upstream_scheme api_upstream_addr api_upstream_port
api_upstream_scheme="${api_upstream%%://*}"
api_upstream_addr="${api_upstream#*://}"
[[ "$api_upstream_addr" == *:* ]] \
|| die "web.config.api_upstream must include an explicit port: '$api_upstream'"
api_upstream_port="${api_upstream_addr##*:}"
[[ "$api_upstream_port" =~ ^[0-9]+$ ]] \
|| die "extracted upstream port is not numeric: '$api_upstream_port'"
local site_conf_path="/etc/nginx/conf.d/${server_name}.conf"
if (( dry_run )); then
printf '\033[2m[dry-run]\033[0m rsync ui/dist/ to %s:/var/www/rob.tn/ + nginx config, run nginx -t/reload on %s\n' \
"$host" "$host" >&2
printf '\033[2m[dry-run]\033[0m render %s (server_name=%s, docroot=%s, upstream=%s://%s) + rsync ui/dist/ to %s:%s/, run nginx -t/reload on %s\n' \
"$site_conf_path" "$server_name" "$web_root" \
"$api_upstream_scheme" "$api_upstream_addr" \
"$host" "$web_root" "$host" >&2
return 0
fi
local stage
stage="$(mktemp -d)"
trap "rm -rf '$stage'" RETURN
stage="$(mktemp --directory)"
trap "rm --recursive --force '$stage'" RETURN
install -d "$stage/var/www/rob.tn" "$stage/etc/nginx/conf.d"
install --directory "${stage}${web_root}" "$stage/etc/nginx/conf.d"
rsync -a "${repo_root}/ui/dist/" "$stage/var/www/rob.tn/"
install -m 0644 "${repo_root}/asset/nginx/rob.tn.conf" "$stage/etc/nginx/conf.d/rob.tn.conf"
rsync --archive "${repo_root}/ui/dist/" "${stage}${web_root}/"
if (( dry_run )); then
printf '\033[2m[dry-run]\033[0m rsync staged -> %s:/\n' "$host" >&2
else
local rendered
rendered="$(<"${repo_root}/asset/nginx/site.conf.tmpl")"
rendered=${rendered//'{{SERVER_NAME}}'/$server_name}
rendered=${rendered//'{{DOCROOT}}'/$web_root}
rendered=${rendered//'{{API_UPSTREAM_SCHEME}}'/$api_upstream_scheme}
rendered=${rendered//'{{API_UPSTREAM_ADDR}}'/$api_upstream_addr}
printf '%s\n' "$rendered" > "${stage}${site_conf_path}"
chmod 0644 "${stage}${site_conf_path}"
# Both targets are leaf paths (the docroot itself, and a single named
# file) so rsync does not traverse /var or /etc parents — `--chown` is
# enough; -A/-X are intentionally absent.
rsync \
--archive \
--hard-links \
--acls \
--xattrs \
--numeric-ids \
--chown root:root \
--rsh='ssh -o BatchMode=yes' \
--rsync-path 'sudo rsync' \
--delete \
"$stage/var/www/rob.tn/" \
"${host}:/var/www/rob.tn/"
"${stage}${web_root}/" \
"${host}:${web_root}/"
rsync \
--archive \
--hard-links \
--acls \
--xattrs \
--numeric-ids \
--chown root:root \
--rsh='ssh -o BatchMode=yes' \
--rsync-path 'sudo rsync' \
"$stage/etc/nginx/conf.d/rob.tn.conf" \
"${host}:/etc/nginx/conf.d/rob.tn.conf"
fi
"${stage}${site_conf_path}" \
"${host}:${site_conf_path}"
ssh_run "$host" "sudo bash -s" <<'REMOTE_EOF'
ssh_run "$host" "sudo bash -s -- ${web_root@Q} ${site_conf_path@Q} ${api_upstream_port@Q}" <<'REMOTE_EOF'
set -euo pipefail
web_root="$1"
site_conf_path="$2"
api_upstream_port="$3"
# Allow nginx to make outbound connections to the moments-api upstream
# across the WG mesh.
setsebool -P httpd_can_network_connect on
# Label the upstream port so httpd_t may name_connect to it.
if ! semanage port -l | awk '{print $1, $3}' | grep -qE "^http_port_t .*42424"; then
semanage port -a -t http_port_t -p tcp 42424 || \
semanage port -m -t http_port_t -p tcp 42424
fi
# Idempotent label: --add fails if the port is already labelled (we suppress
# that one stderr line); --modify is then a no-op or fixes a stale type.
semanage port --add --type=http_port_t --proto=tcp "$api_upstream_port" 2>/dev/null \
|| semanage port --modify --type=http_port_t --proto=tcp "$api_upstream_port"
restorecon -Rv /var/www/rob.tn /etc/nginx/conf.d/rob.tn.conf
restorecon -Rv "$web_root" "$site_conf_path"
if ! nginx -t; then
echo "nginx config check failed" >&2
@@ -363,7 +479,7 @@ REMOTE_EOF
failed=()
for component in "${components[@]}"; do
mapfile -t hosts < <(yq -r "${env_path}.components.${component}.hosts[]" "$manifest")
mapfile -t hosts < <(yq --raw-output "${env_path}.components.${component}.hosts[]" "$manifest")
for host in "${hosts[@]}"; do
case "$component" in
api) deploy_api "$host" || failed+=("api@$host") ;;

288
script/teardown.sh Executable file
View File

@@ -0,0 +1,288 @@
#!/usr/bin/env bash
#
# moments teardown script.
#
# ./script/teardown.sh <environment> <host> [component...] [--dry-run]
# ./script/teardown.sh prod anjie.kosherinata.internal api worker
# ./script/teardown.sh prod oolon.kosherinata.internal web --remove-docroot
# ./script/teardown.sh prod anjie.kosherinata.internal all --dry-run
#
# Removes moments unit files, binaries, env files, firewalld service +
# definition, SELinux port label, and (when no moments component env files
# remain) the shared /etc/moments + /var/lib/moments dirs and the sysusers
# entry. Idempotent — safe to re-run.
#
# Notes:
# - The host argument is explicit on purpose: you typically tear down on
# hosts you've already removed from manifest.components.<c>.hosts.
# - Manifest is still read for env-wide config (api port, server_name,
# docroot path), so $environment must still resolve.
# - The `moments` user/group is intentionally NOT removed: any leftover
# file owned by it would become orphan-owned. Run `userdel moments`
# manually if you're certain there are none.
# - Web docroot is left intact unless --remove-docroot is given.
set -euo pipefail
shopt -s nullglob
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
manifest="${repo_root}/asset/manifest.yml"
dry_run=0
remove_docroot=0
usage() {
cat <<EOF >&2
usage: $(basename "$0") <environment> <host> [component...] [--dry-run] [--remove-docroot]
$(basename "$0") prod anjie.kosherinata.internal api worker
$(basename "$0") prod oolon.kosherinata.internal web --remove-docroot
$(basename "$0") prod anjie.kosherinata.internal all
components: api | worker | web | all
EOF
exit 2
}
log() { printf '\033[1;34m[teardown]\033[0m %s\n' "$*" >&2; }
warn() { printf '\033[1;33m[teardown]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31m[teardown]\033[0m %s\n' "$*" >&2; exit 1; }
ssh_run() {
local host="$1"; shift
if (( dry_run )); then
printf '\033[2m[dry-run]\033[0m ssh %s -- %s\n' "$host" "$*" >&2
else
ssh -o BatchMode=yes "$host" "$@"
fi
}
[[ $# -ge 2 ]] || usage
environment="$1"; shift
target_host="$1"; shift
components=()
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) dry_run=1 ;;
--remove-docroot) remove_docroot=1 ;;
*) components+=("$1") ;;
esac
shift
done
[[ -f "$manifest" ]] || die "manifest not found: $manifest"
command -v yq >/dev/null 2>&1 || die "yq is required"
env_path=".environments.${environment}"
yq --exit-status "${env_path}" "$manifest" >/dev/null \
|| die "environment '$environment' not found in manifest"
if [[ ${#components[@]} -eq 0 ]]; then
usage
fi
if [[ "${components[0]:-}" == "all" ]]; then
components=(api worker web)
fi
teardown_api() {
local host="$1"
log "api -> $host"
local bind api_port=""
bind="$(yq --raw-output "${env_path}.components.api.config.bind" "$manifest")"
if [[ -n "$bind" && "$bind" != "null" && "$bind" == *:* ]]; then
api_port="${bind##*:}"
[[ "$api_port" =~ ^[0-9]+$ ]] || api_port=""
fi
if (( dry_run )); then
printf '\033[2m[dry-run]\033[0m stop+disable moments-api units, remove unit files, /etc/moments/api.env, /usr/local/bin/moments-api, firewalld svc moments-api, SELinux label tcp/%s on %s\n' \
"${api_port:-<unknown>}" "$host" >&2
return 0
fi
ssh_run "$host" "sudo bash -s -- ${api_port@Q}" <<'REMOTE_EOF'
set -euo pipefail
api_port="$1"
# Stop + disable units. `disable --now` quietly does nothing on a unit that
# isn't loaded, but emits non-zero exit on some systemd versions when the
# file is already gone — swallow that so re-runs are clean.
for unit in moments-api.service moments-api-cert.path moments-api-cert-reload.service; do
systemctl disable --now "$unit" 2>/dev/null || true
done
rm --force \
/etc/systemd/system/moments-api.service \
/etc/systemd/system/moments-api-cert.path \
/etc/systemd/system/moments-api-cert-reload.service
systemctl daemon-reload
rm --force /etc/moments/api.env /usr/local/bin/moments-api
# Firewalld: remove service from default zone, then drop service definition.
zone="$(firewall-cmd --get-default-zone)"
if firewall-cmd --zone="$zone" --query-service=moments-api >/dev/null 2>&1; then
firewall-cmd --permanent --zone="$zone" --remove-service=moments-api
firewall-cmd --zone="$zone" --remove-service=moments-api 2>/dev/null || true
fi
rm --force /etc/firewalld/services/moments-api.xml
firewall-cmd --reload
# SELinux: remove the port label, if we know which port. --delete fails when
# the port wasn't user-labelled — that's fine, swallow it.
if [[ -n "$api_port" ]]; then
semanage port --delete --proto=tcp "$api_port" 2>/dev/null || true
fi
echo "moments-api torn down"
REMOTE_EOF
}
teardown_worker() {
local host="$1"
log "worker -> $host"
if (( dry_run )); then
printf '\033[2m[dry-run]\033[0m stop+disable moments-worker units, remove unit files, /etc/moments/worker.env, /usr/local/bin/moments-worker on %s\n' \
"$host" >&2
return 0
fi
ssh_run "$host" "sudo bash -s" <<'REMOTE_EOF'
set -euo pipefail
for unit in moments-worker.service moments-worker-cert.path moments-worker-cert-reload.service; do
systemctl disable --now "$unit" 2>/dev/null || true
done
rm --force \
/etc/systemd/system/moments-worker.service \
/etc/systemd/system/moments-worker-cert.path \
/etc/systemd/system/moments-worker-cert-reload.service
systemctl daemon-reload
rm --force /etc/moments/worker.env /usr/local/bin/moments-worker
echo "moments-worker torn down"
REMOTE_EOF
}
teardown_web() {
local host="$1"
log "web -> $host"
local server_name web_root
server_name="$(yq --raw-output "${env_path}.components.web.config.server_name" "$manifest")"
web_root="$(yq --raw-output "${env_path}.components.web.config.root" "$manifest")"
[[ -n "$server_name" && "$server_name" != "null" ]] || die "web.config.server_name missing in manifest"
[[ -n "$web_root" && "$web_root" != "null" ]] || die "web.config.root missing in manifest"
[[ "$web_root" == /* ]] || die "web.config.root must be an absolute path: '$web_root'"
# Refuse to recursively remove a shallow or system path even if the
# manifest says so.
if (( remove_docroot )); then
case "$web_root" in
/|/bin|/bin/*|/boot|/boot/*|/dev|/dev/*|/etc|/etc/*|/home|/home/*|/lib|/lib/*|/lib64|/lib64/*|/proc|/proc/*|/root|/root/*|/run|/run/*|/sbin|/sbin/*|/srv|/srv/*|/sys|/sys/*|/tmp|/tmp/*|/usr|/usr/*|/var|/var/lib|/var/log|/var/run|/var/spool|/var/www)
die "refusing to recursively remove a system path: '$web_root'"
;;
esac
# Require at least three path components (e.g. /var/www/<site>) to
# rule out things like /opt or /srv directly.
[[ "$web_root" =~ ^/[^/]+/[^/]+/[^/]+ ]] \
|| die "refusing to recursively remove a path with fewer than 3 components: '$web_root'"
fi
local site_conf_path="/etc/nginx/conf.d/${server_name}.conf"
if (( dry_run )); then
if (( remove_docroot )); then
printf '\033[2m[dry-run]\033[0m remove %s, recursively remove %s, nginx -t/reload on %s\n' \
"$site_conf_path" "$web_root" "$host" >&2
else
printf '\033[2m[dry-run]\033[0m remove %s, nginx -t/reload on %s (docroot %s left intact; pass --remove-docroot to also clear it)\n' \
"$site_conf_path" "$host" "$web_root" >&2
fi
return 0
fi
ssh_run "$host" "sudo bash -s -- ${site_conf_path@Q} ${web_root@Q} ${remove_docroot@Q}" <<'REMOTE_EOF'
set -euo pipefail
site_conf_path="$1"
web_root="$2"
remove_docroot="$3"
rm --force "$site_conf_path"
if nginx -t 2>&1; then
systemctl reload nginx
echo "nginx reloaded without ${site_conf_path}"
else
echo "nginx -t failed AFTER removing ${site_conf_path}; check other site configs" >&2
exit 1
fi
if [[ "$remove_docroot" == "1" && -d "$web_root" ]]; then
rm --recursive --force "$web_root"
echo "removed docroot ${web_root}"
fi
REMOTE_EOF
}
teardown_shared() {
local host="$1"
log "shared (post-component cleanup) -> $host"
if (( dry_run )); then
printf '\033[2m[dry-run]\033[0m if no api.env/worker.env remain: remove /etc/sysusers.d/moments.conf and rmdir /etc/moments + /var/lib/moments on %s (moments user left in place)\n' \
"$host" >&2
return 0
fi
ssh_run "$host" "sudo bash -s" <<'REMOTE_EOF'
set -euo pipefail
# If any component env still exists, leave shared state alone — another
# moments component is still using /etc/moments and the moments user.
if [[ -e /etc/moments/api.env || -e /etc/moments/worker.env ]]; then
echo "moments env files still present; leaving /etc/moments + /var/lib/moments + sysusers entry in place"
exit 0
fi
# rmdir refuses non-empty dirs — defensive against unknown stragglers.
rmdir /etc/moments 2>/dev/null || true
rmdir /var/lib/moments 2>/dev/null || true
rm --force /etc/sysusers.d/moments.conf
echo "shared state cleared (where empty); moments user/group intentionally left in place"
REMOTE_EOF
}
# Dispatch ------------------------------------------------------------------
failed=()
did_app=0
for component in "${components[@]}"; do
case "$component" in
api) teardown_api "$target_host" || failed+=("api@$target_host") ;;
worker) teardown_worker "$target_host" || failed+=("worker@$target_host") ;;
web) teardown_web "$target_host" || failed+=("web@$target_host") ;;
*) warn "unknown component: $component" ;;
esac
case "$component" in
api|worker) did_app=1 ;;
esac
done
# Shared cleanup runs after api/worker teardown. It's a no-op if either
# component still has its env file present on the host.
if (( did_app )); then
teardown_shared "$target_host" || failed+=("shared@$target_host")
fi
if [[ ${#failed[@]} -gt 0 ]]; then
die "failed: ${failed[*]}"
fi
log "teardown complete on $target_host"

View File

@@ -18,6 +18,7 @@
"react-bootstrap-icons": "^1.11.4",
"react-dom": "^19.0.0",
"react-markdown": "^9.0.1",
"react-router-dom": "^7.14.2",
"react-vertical-timeline-component": "^3.6.0"
},
"devDependencies": {

45
ui/pnpm-lock.yaml generated
View File

@@ -32,6 +32,9 @@ importers:
react-markdown:
specifier: ^9.0.1
version: 9.1.0(@types/react@19.2.14)(react@19.2.5)
react-router-dom:
specifier: ^7.14.2
version: 7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
react-vertical-timeline-component:
specifier: ^3.6.0
version: 3.6.0(react@19.2.5)
@@ -593,6 +596,10 @@ packages:
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
cookie@1.1.1:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@@ -867,6 +874,23 @@ packages:
'@types/react': '>=18'
react: '>=18'
react-router-dom@7.14.2:
resolution: {integrity: sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: '>=18'
react-dom: '>=18'
react-router@7.14.2:
resolution: {integrity: sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: '>=18'
react-dom: '>=18'
peerDependenciesMeta:
react-dom:
optional: true
react-stately@3.46.0:
resolution: {integrity: sha512-OdxhWvHgs2L4OJGIs7hnuTr5WjjMM6enhNEAMRqiekhF8+ITvA2LRwNftOZwcogaoCslGYq5S2VQTQwnm0GbCA==}
peerDependencies:
@@ -899,6 +923,9 @@ packages:
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -1378,6 +1405,8 @@ snapshots:
comma-separated-tokens@2.0.3: {}
cookie@1.1.1: {}
csstype@3.2.3: {}
debug@4.4.3:
@@ -1841,6 +1870,20 @@ snapshots:
transitivePeerDependencies:
- supports-color
react-router-dom@7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
react-router: 7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
react-router@7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
cookie: 1.1.1
react: 19.2.5
set-cookie-parser: 2.7.2
optionalDependencies:
react-dom: 19.2.5(react@19.2.5)
react-stately@3.46.0(react@19.2.5):
dependencies:
'@internationalized/date': 3.12.1
@@ -1920,6 +1963,8 @@ snapshots:
scheduler@0.27.0: {}
set-cookie-parser@2.7.2: {}
source-map-js@1.2.1: {}
space-separated-tokens@2.0.2: {}

View File

@@ -1,6 +1,7 @@
body {
background-color: #2c3e50;
color: #ecf0f1;
text-transform: lowercase;
}
.container {
@@ -36,3 +37,11 @@ a.hot-pink {
.vertical-timeline-element-content a {
color: #1565c0;
}
.site-footer {
margin-top: 3rem;
padding: 1rem 0;
font-size: 0.75rem;
opacity: 0.6;
text-align: center;
}

View File

@@ -1,132 +1,24 @@
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import Col from 'react-bootstrap/Col';
import Container from 'react-bootstrap/Container';
import Row from 'react-bootstrap/Row';
import { VerticalTimeline } from 'react-vertical-timeline-component';
import { Routes, Route } from 'react-router-dom';
import 'bootstrap/dist/css/bootstrap.min.css';
import 'rc-slider/assets/index.css';
import 'react-vertical-timeline-component/style.min.css';
import './App.css';
import { fetchEvents, fetchSources, type Source } from './api/client';
import { Filters } from './components/Filters';
import { TimelineEntry } from './components/TimelineEntry';
const RANGE_MIN = new Date('2010-01-01T00:00:00Z').getTime();
const RANGE_MAX = Date.now();
const externalLinks: { url: string; alt: string }[] = [
{ url: 'https://linkedin.com/in/thijssen/', alt: 'linkedin' },
{ url: 'https://stackoverflow.com/users/68115/grenade', alt: 'stackoverflow' },
{ url: 'https://github.com/grenade', alt: 'github' },
{ url: 'https://git.lair.cafe/grenade', alt: 'gitea' },
];
import { TimelineHome } from './pages/TimelineHome';
import { CvPage } from './pages/CvPage';
export default function App() {
const [enabledSources, setEnabledSources] = useState<Record<Source, boolean>>({
github: true,
gitea: true,
hg: true,
bugzilla: true,
});
const [rangeValue, setRangeValue] = useState<[number, number]>(() => {
const now = Date.now();
const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000;
return [thirtyDaysAgo, now];
});
const [limit, setLimit] = useState<number>(100);
const sourcesQ = useQuery({
queryKey: ['sources'],
queryFn: fetchSources,
refetchInterval: 60_000,
});
const activeSources = useMemo(
() =>
(Object.keys(enabledSources) as Source[]).filter((s) => enabledSources[s]),
[enabledSources],
);
const eventsQ = useQuery({
queryKey: ['events', rangeValue, activeSources, limit],
queryFn: () =>
fetchEvents({
from: new Date(rangeValue[0]),
to: new Date(rangeValue[1]),
sources: activeSources,
limit,
}),
refetchInterval: 60_000,
});
const events = eventsQ.data ?? [];
return (
<Container className="py-4">
<Row className="mb-3">
<Col>
<h1>hi, i'm rob</h1>
</Col>
<Col className="d-flex flex-wrap gap-3 justify-content-end align-items-center">
{externalLinks.map((el) => (
<a
key={el.url}
href={el.url}
title={el.alt}
target="_blank"
rel="noopener noreferrer"
>
{el.alt}
</a>
))}
</Col>
</Row>
<Row className="mb-4">
<Col>
<p>
i rarely say anything that warrants capital letters. if you're here
to see my resume, please go to{' '}
<a className="hot-pink" href="https://rob.tn/cv/">
https://rob.tn/cv
</a>
. a peek into the projects i'm working on is below.
</p>
</Col>
</Row>
<Filters
enabledSources={enabledSources}
onSourceToggle={(s, on) =>
setEnabledSources((prev) => ({ ...prev, [s]: on }))
}
rangeMin={RANGE_MIN}
rangeMax={RANGE_MAX}
rangeValue={rangeValue}
onRangeChange={setRangeValue}
limit={limit}
onLimitChange={setLimit}
summaries={sourcesQ.data}
/>
<Row>
<Col>
<p className="text-center" style={{ fontSize: '85%' }}>
{eventsQ.isLoading
? 'loading'
: eventsQ.isError
? `error: ${(eventsQ.error as Error).message}`
: `showing ${events.length} ${events.length === 1 ? 'activity' : 'activities'}`}
</p>
<VerticalTimeline>
{events.map((item) => (
<TimelineEntry key={item.id} item={item} />
))}
</VerticalTimeline>
</Col>
</Row>
</Container>
<>
<Routes>
<Route path="/" element={<TimelineHome />} />
<Route path="/cv" element={<CvPage />} />
</Routes>
<footer className="site-footer">
no cookies are set or read by this site, which is why no consent banner
is shown.
</footer>
</>
);
}

82
ui/src/api/cv.ts Normal file
View File

@@ -0,0 +1,82 @@
// Fetches the CV gist at runtime and returns the parsed config + file
// list. The legacy implementation (cv/src/App.js) hits the same endpoint
// and relies entirely on the inline `content` field — no per-file raw_url
// fetches. We do the same: one request, dedup'd via TanStack Query.
const GIST_OWNER = 'grenade';
const GIST_ID = '8e487477663c8e57c7bf31e8371f454a';
const GIST_API_URL = `https://api.github.com/gists/${GIST_ID}`;
const GIST_RAW_BASE = `https://gist.githubusercontent.com/${GIST_OWNER}/${GIST_ID}/raw`;
export const CV_PHOTO_URL = `${GIST_RAW_BASE}/rob.png`;
const CONFIG_FILENAME = 'cv-config.json';
export type SectionPlacement = 'body' | 'nav';
export type SortDirection = 'ascending' | 'descending';
export interface CvSectionConfig {
name: string;
filename_prefix: string;
order: number;
show_section_name: boolean;
placement: SectionPlacement;
sort?: {
on: 'filename';
direction: SortDirection;
};
}
export interface CvConfig {
sections: CvSectionConfig[];
}
export interface GistFile {
filename: string;
type: string;
language: string | null;
raw_url: string;
size: number;
truncated: boolean;
content: string;
}
interface GistResponse {
files: Record<string, GistFile>;
}
export interface CvData {
config: CvConfig;
files: Record<string, GistFile>;
}
export async function fetchCv(): Promise<CvData> {
const resp = await fetch(GIST_API_URL);
if (!resp.ok) {
throw new Error(`gist: HTTP ${resp.status} ${resp.statusText}`);
}
const gist = (await resp.json()) as GistResponse;
const cfgFile = gist.files[CONFIG_FILENAME];
if (!cfgFile) {
throw new Error(`gist: missing ${CONFIG_FILENAME}`);
}
const config = JSON.parse(cfgFile.content) as CvConfig;
return { config, files: gist.files };
}
// Pick out the gist files whose names start with the given prefix, applying
// the section's sort order. Mirrors the legacy filter at cv/src/App.js:67-68.
export function filesForSection(
data: CvData,
section: CvSectionConfig,
): GistFile[] {
const matches = Object.keys(data.files)
.filter((name) => name.startsWith(section.filename_prefix))
.sort();
if (section.sort?.direction === 'descending') {
matches.reverse();
}
return matches.map((name) => data.files[name]);
}

View File

@@ -0,0 +1,22 @@
import { Link } from 'react-router-dom';
import Image from 'react-bootstrap/Image';
import { CV_PHOTO_URL } from '../../api/cv';
export function CvHeader() {
return (
<div className="cv-header d-flex flex-column flex-md-row align-items-md-center gap-3 mb-4">
<Image
src={CV_PHOTO_URL}
alt="rob"
roundedCircle
className="cv-photo"
/>
<div className="flex-grow-1">
<h1 className="mb-1">curriculum vitae</h1>
<Link to="/" className="hot-pink">
timeline
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,66 @@
import ReactMarkdown from 'react-markdown';
import Card from 'react-bootstrap/Card';
import { type CvSectionConfig, type GistFile } from '../../api/cv';
import { entryAnchorId } from '../../lib/cvDates';
interface Props {
section: CvSectionConfig;
files: GistFile[];
}
// Pipe-delimited fields (e.g. "email | phone | github, linkedin" in the
// contact section) become one paragraph per field, so each lands on its own
// line with a paragraph gap. Within each pipe-segment, comma-separated values
// are stacked with a soft line break (markdown ` \n` -> `<br/>`) so multiple
// emails / phones / urls each get their own line at a tighter spacing.
function splitPipes(content: string): string {
return content
.split('\n')
.map((line) => {
if (!line.includes(' | ')) return line;
return line
.split(' | ')
.map((segment) =>
segment.includes(', ') ? segment.split(', ').join(' \n') : segment,
)
.join('\n\n');
})
.join('\n');
}
// Renders a single section. Each .md file becomes its own block. When
// `show_section_name` is true (e.g. experience, education) the entries are
// wrapped in cards and given anchor ids so the timeline sidebar can deep-link
// to them; otherwise (e.g. summary, contact) they render as flat markdown.
export function CvSection({ section, files }: Props) {
return (
<section id={section.name} className="cv-section mb-4">
{section.show_section_name && <h2 className="cv-section-name">{section.name}</h2>}
{files.map((file) => {
const content = section.show_section_name
? file.content
: splitPipes(file.content);
if (section.show_section_name) {
return (
<div
key={file.filename}
id={entryAnchorId(section.name, file.content)}
className="cv-entry mb-3"
>
<Card className="cv-card">
<Card.Body>
<ReactMarkdown>{content}</ReactMarkdown>
</Card.Body>
</Card>
</div>
);
}
return (
<div key={file.filename} className="cv-entry">
<ReactMarkdown>{content}</ReactMarkdown>
</div>
);
})}
</section>
);
}

View File

@@ -0,0 +1,98 @@
import type { ReactNode } from 'react';
import {
VerticalTimeline,
VerticalTimelineElement,
} from 'react-vertical-timeline-component';
import { type CvData, filesForSection } from '../../api/cv';
import { entryAnchorId, parseEntryHeader } from '../../lib/cvDates';
interface Props {
data: CvData;
}
// Tiny inline parser: turns "[text](url)" segments into <a> elements while
// leaving surrounding text alone. Used so the timeline title renders the
// company name as plain text and the linked website as an external link
// (matching how a markdown parser would render the same source).
function renderInlineLinks(text: string): ReactNode[] {
const parts: ReactNode[] = [];
const re = /\[([^\]]+)\]\(([^)]+)\)/g;
let last = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
if (m.index > last) parts.push(text.slice(last, m.index));
parts.push(
<a key={m.index} href={m[2]} target="_blank" rel="noopener noreferrer">
{m[1]}
</a>,
);
last = m.index + m[0].length;
}
if (last < text.length) parts.push(text.slice(last));
return parts;
}
// Sidebar timeline rendered from every body section that has `show_section_name`
// (i.e. timeline-eligible sections — experience and education in the current
// gist). Each element offers a small "→" link to the matching anchor in the
// body; the title and subtitle preserve any inline markdown links so they
// behave as proper external anchors.
export function CvTimeline({ data }: Props) {
const elements = data.config.sections
.filter((s) => s.placement === 'body' && s.show_section_name)
.flatMap((section) =>
filesForSection(data, section).map((file) => ({ section, file })),
);
if (elements.length === 0) return null;
return (
<div className="cv-timeline">
<h2 className="cv-section-name">timeline</h2>
<VerticalTimeline layout="1-column-left" lineColor="#ecf0f1">
{elements.map(({ section, file }) => {
const parsed = parseEntryHeader(file.content);
const anchor = `#${entryAnchorId(section.name, file.content)}`;
return (
<VerticalTimelineElement
key={file.filename}
date={parsed.interval.replace(/\s*\([^)]*\)/g, '')}
iconStyle={
parsed.iconUrl
? { background: '#ffffff', boxShadow: 'none' }
: { background: '#ff4081', color: '#ffffff' }
}
icon={
parsed.iconUrl ? (
<img
src={parsed.iconUrl}
alt={parsed.title}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
borderRadius: '50%',
padding: 4,
}}
/>
) : undefined
}
>
<h3 className="vertical-timeline-element-title">
{renderInlineLinks(parsed.titleMd)}
</h3>
{parsed.locationRoleMd && (
<h4 className="vertical-timeline-element-subtitle">
{renderInlineLinks(parsed.locationRoleMd)}
</h4>
)}
<a href={anchor} className="cv-timeline-anchor">
details
</a>
</VerticalTimelineElement>
);
})}
</VerticalTimeline>
</div>
);
}

91
ui/src/lib/cvDates.ts Normal file
View File

@@ -0,0 +1,91 @@
// Normalizes the date interval line of a CV entry. The legacy implementation
// at cv/src/App.js:139 chained .replace() calls per month name; this collapses
// that into a single regex pass.
const MONTH_ABBREV: Record<string, string> = {
january: 'jan',
february: 'feb',
march: 'mar',
april: 'apr',
// may stays "may"
june: 'jun',
july: 'jul',
august: 'aug',
september: 'sep',
october: 'oct',
november: 'nov',
december: 'dec',
};
const MONTH_RE = new RegExp(
`\\b(${Object.keys(MONTH_ABBREV).join('|')})\\b`,
'gi',
);
// Strip leading/trailing markdown # / whitespace, abbreviate months. Casing
// is left to the body-level `text-transform: lowercase` so a future toggle can
// flip it from a single place.
export function normalizeInterval(line: string): string {
return line
.replace(/^[\s#]+|[\s#]+$/g, '')
.replace(MONTH_RE, (m) => MONTH_ABBREV[m.toLowerCase()] ?? m);
}
// Strip markdown link syntax (e.g. "[text](url)") down to just the text.
export function stripMdLinks(s: string): string {
return s.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
}
// Parse the title / location-role / interval triple out of a CV entry's
// markdown content. If the first line embeds a PNG URL (icon-style entry),
// indices shift down by one.
export interface ParsedHeader {
// Plain text, markdown links stripped — for alt= attributes and similar.
title: string;
locationRole: string;
// Markdown source with leading #s/whitespace stripped — for inline rendering
// so [text](url) links render as proper anchors.
titleMd: string;
locationRoleMd: string;
iconUrl: string | null;
interval: string;
}
export function parseEntryHeader(content: string): ParsedHeader {
const lines = content.split('\n');
const firstLine = lines[0] ?? '';
const hasIcon = firstLine.includes('.png');
const iconUrl = hasIcon
? (firstLine.match(/https:[^ )\]]+\.png/)?.[0] ?? null)
: null;
const titleLine = hasIcon ? (lines[1] ?? '') : firstLine;
const locRoleLine = hasIcon ? (lines[2] ?? '') : (lines[1] ?? '');
const intervalLine = hasIcon ? (lines[3] ?? '') : (lines[2] ?? '');
const titleMd = titleLine.replace(/^[\s#]+|[\s#]+$/g, '');
const locationRoleMd = locRoleLine.replace(/^[\s#]+|[\s#]+$/g, '');
return {
title: stripMdLinks(titleMd),
locationRole: stripMdLinks(locationRoleMd),
titleMd,
locationRoleMd,
iconUrl,
interval: normalizeInterval(intervalLine),
};
}
// Anchor id for an entry: combines the section name and a slug of the title
// line. Mirrors the legacy id format at cv/src/App.js:71.
export function entryAnchorId(sectionName: string, content: string): string {
const lines = content.split('\n');
const firstLine = lines[0] ?? '';
const titleLine = firstLine.includes('.png') ? (lines[1] ?? '') : firstLine;
const slug = stripMdLinks(titleLine.replace(/^[\s#]+|[\s#]+$/g, ''))
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
return `${sectionName}-${slug}`;
}

View File

@@ -1,5 +1,6 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
@@ -15,7 +16,9 @@ const queryClient = new QueryClient({
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
);

98
ui/src/pages/CvPage.css Normal file
View File

@@ -0,0 +1,98 @@
.cv-photo {
width: 96px;
height: 96px;
object-fit: cover;
}
.cv-section-name {
margin-bottom: 0.75rem;
border-bottom: 1px solid rgba(236, 241, 241, 0.2);
padding-bottom: 0.25rem;
}
.cv-card {
background-color: #34495e;
color: #ecf0f1;
border: 1px solid rgba(236, 241, 241, 0.1);
}
.cv-card a {
color: #ff80ab;
}
.cv-card a:hover {
color: #ff4081;
}
.cv-card img {
max-width: 96px;
max-height: 48px;
background-color: #ffffff;
padding: 4px;
border-radius: 4px;
margin-bottom: 0.75rem;
}
.cv-card h3 {
font-size: 1.25rem;
}
.cv-card h4 {
font-size: 1.05rem;
opacity: 0.9;
}
.cv-card h5 {
font-size: 0.95rem;
opacity: 0.75;
font-style: italic;
}
.cv-timeline .vertical-timeline-element-content {
background-color: #34495e;
color: #ecf0f1;
box-shadow: 0 3px 0 #ff4081;
}
.cv-timeline .vertical-timeline-element-content a {
color: #ff80ab;
}
.cv-timeline .vertical-timeline-element-content a:hover {
color: #ff4081;
}
.cv-timeline h3.vertical-timeline-element-title {
font-size: 0.95rem;
margin: 0;
}
.cv-timeline h4.vertical-timeline-element-subtitle {
font-size: 0.8rem;
opacity: 0.85;
margin: 0.15rem 0 0.4rem;
font-weight: normal;
}
.cv-timeline .cv-timeline-anchor {
font-size: 0.75rem;
opacity: 0.85;
float: right;
margin-left: 0.75rem;
}
.cv-timeline .vertical-timeline-element-content::before {
border-right-color: #34495e;
border-left-color: #34495e;
}
.cv-timeline .vertical-timeline-element-date {
color: #ecf0f1 !important;
opacity: 0.8;
}
@media (max-width: 991px) {
.cv-timeline {
margin-top: 2rem;
}
}

108
ui/src/pages/CvPage.tsx Normal file
View File

@@ -0,0 +1,108 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import Alert from 'react-bootstrap/Alert';
import Col from 'react-bootstrap/Col';
import Container from 'react-bootstrap/Container';
import Row from 'react-bootstrap/Row';
import Spinner from 'react-bootstrap/Spinner';
import { fetchCv, filesForSection } from '../api/cv';
import { CvHeader } from '../components/cv/CvHeader';
import { CvSection } from '../components/cv/CvSection';
import { CvTimeline } from '../components/cv/CvTimeline';
import './CvPage.css';
export function CvPage() {
const { hash } = useLocation();
const cvQ = useQuery({
queryKey: ['cv-gist'],
queryFn: fetchCv,
staleTime: 5 * 60_000,
});
// Scroll to the anchored entry once the gist resolves and the section
// body has rendered its ids. Re-runs if the user changes the hash while
// already on the page.
useEffect(() => {
if (!cvQ.data || !hash) return;
const target = document.getElementById(hash.slice(1));
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, [cvQ.data, hash]);
if (cvQ.isLoading) {
return (
<Container className="py-4">
<CvHeader />
<div className="d-flex align-items-center gap-2">
<Spinner animation="border" role="status" size="sm" />
<span>loading cv</span>
</div>
</Container>
);
}
if (cvQ.isError) {
const msg = (cvQ.error as Error).message;
const rateHint = /403|rate limit/i.test(msg)
? ' (github limits unauthenticated requests to 60/hour per ip — try again shortly)'
: '';
return (
<Container className="py-4">
<CvHeader />
<Alert variant="danger">
<Alert.Heading>cv unavailable</Alert.Heading>
<p className="mb-2">
{msg}
{rateHint}
</p>
<button className="btn btn-outline-light" onClick={() => cvQ.refetch()}>
retry
</button>
</Alert>
</Container>
);
}
const data = cvQ.data!;
const bodySections = data.config.sections.filter((s) => s.placement === 'body');
const navSections = data.config.sections.filter((s) => s.placement === 'nav');
if (bodySections.length === 0 && navSections.length === 0) {
return (
<Container className="py-4">
<CvHeader />
<Alert variant="warning">cv unavailable: no sections in config</Alert>
</Container>
);
}
return (
<Container className="py-4">
<CvHeader />
<Row>
<Col lg={9} className="cv-body">
{bodySections.map((section) => (
<CvSection
key={section.name}
section={section}
files={filesForSection(data, section)}
/>
))}
</Col>
<Col lg={3} className="cv-sidebar">
{navSections.map((section) => (
<CvSection
key={section.name}
section={section}
files={filesForSection(data, section)}
/>
))}
<CvTimeline data={data} />
</Col>
</Row>
</Container>
);
}

View File

@@ -0,0 +1,128 @@
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import Col from 'react-bootstrap/Col';
import Container from 'react-bootstrap/Container';
import Row from 'react-bootstrap/Row';
import { VerticalTimeline } from 'react-vertical-timeline-component';
import { fetchEvents, fetchSources, type Source } from '../api/client';
import { Filters } from '../components/Filters';
import { TimelineEntry } from '../components/TimelineEntry';
const RANGE_MIN = new Date('2010-01-01T00:00:00Z').getTime();
const RANGE_MAX = Date.now();
const externalLinks: { url: string; alt: string }[] = [
{ url: 'https://linkedin.com/in/thijssen/', alt: 'linkedin' },
{ url: 'https://stackoverflow.com/users/68115/grenade', alt: 'stackoverflow' },
{ url: 'https://github.com/grenade', alt: 'github' },
{ url: 'https://git.lair.cafe/grenade', alt: 'gitea' },
];
export function TimelineHome() {
const [enabledSources, setEnabledSources] = useState<Record<Source, boolean>>({
github: true,
gitea: true,
hg: true,
bugzilla: true,
});
const [rangeValue, setRangeValue] = useState<[number, number]>(() => {
const now = Date.now();
const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000;
return [thirtyDaysAgo, now];
});
const [limit, setLimit] = useState<number>(100);
const sourcesQ = useQuery({
queryKey: ['sources'],
queryFn: fetchSources,
refetchInterval: 60_000,
});
const activeSources = useMemo(
() =>
(Object.keys(enabledSources) as Source[]).filter((s) => enabledSources[s]),
[enabledSources],
);
const eventsQ = useQuery({
queryKey: ['events', rangeValue, activeSources, limit],
queryFn: () =>
fetchEvents({
from: new Date(rangeValue[0]),
to: new Date(rangeValue[1]),
sources: activeSources,
limit,
}),
refetchInterval: 60_000,
});
const events = eventsQ.data ?? [];
return (
<Container className="py-4">
<Row className="mb-3">
<Col>
<h1>hi, i'm rob</h1>
</Col>
<Col className="d-flex flex-wrap gap-3 justify-content-end align-items-center">
{externalLinks.map((el) => (
<a
key={el.url}
href={el.url}
title={el.alt}
target="_blank"
rel="noopener noreferrer"
>
{el.alt}
</a>
))}
</Col>
</Row>
<Row className="mb-4">
<Col>
<p>
i rarely say anything that warrants capital letters. if you're here
to see my resume, please go to{' '}
<Link className="hot-pink" to="/cv">
/cv
</Link>
. a peek into the projects i'm working on is below.
</p>
</Col>
</Row>
<Filters
enabledSources={enabledSources}
onSourceToggle={(s, on) =>
setEnabledSources((prev) => ({ ...prev, [s]: on }))
}
rangeMin={RANGE_MIN}
rangeMax={RANGE_MAX}
rangeValue={rangeValue}
onRangeChange={setRangeValue}
limit={limit}
onLimitChange={setLimit}
summaries={sourcesQ.data}
/>
<Row>
<Col>
<p className="text-center" style={{ fontSize: '85%' }}>
{eventsQ.isLoading
? 'loading'
: eventsQ.isError
? `error: ${(eventsQ.error as Error).message}`
: `showing ${events.length} ${events.length === 1 ? 'activity' : 'activities'}`}
</p>
<VerticalTimeline>
{events.map((item) => (
<TimelineEntry key={item.id} item={item} />
))}
</VerticalTimeline>
</Col>
</Row>
</Container>
);
}