{user["handle"]}
+{user["did"]}
+-
+
- Status +
- {user["status"] || "unknown"} +
- Records +
- {user["recordCount"] || 0} +
diff --git a/assets/css/components/shell.css b/assets/css/components/shell.css index c21222a..1ea0f1e 100644 --- a/assets/css/components/shell.css +++ b/assets/css/components/shell.css @@ -300,6 +300,167 @@ font-weight: 700; } +.public-users { + display: grid; + gap: var(--space-3); + grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr)); +} + +.public-user-card { + overflow: hidden; + background: var(--surface-raised); + box-shadow: var(--border-in); +} + +.public-user-card__banner { + height: 4.7rem; + background: var(--accent-soft); +} + +.public-user-card__banner--fallback { + background: + repeating-linear-gradient(135deg, rgb(255 255 255 / 0.45) 0 0.45rem, transparent 0.45rem 0.9rem), + linear-gradient(90deg, var(--win-title), var(--info)); +} + +.public-user-card__banner img, +.public-user-card__avatar img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.public-user-card__body { + display: grid; + gap: var(--space-2); + padding: 0 var(--space-3) var(--space-3); +} + +.public-user-card__avatar { + display: grid; + place-items: center; + width: 3.4rem; + height: 3.4rem; + margin-block-start: -1.7rem; + overflow: hidden; + border: 2px solid var(--surface-raised); + background: white; + box-shadow: var(--border-out); + font-family: var(--font-display); + font-size: var(--step-1); +} + +.public-user-card__avatar--fallback { + color: white; + background: var(--win-title); +} + +.public-user-card__handle { + font-size: var(--step-0); + overflow-wrap: anywhere; +} + +.public-user-card__did, +.public-record-facts dd, +.collection-summary__name { + font-family: var(--font-mono); + overflow-wrap: anywhere; +} + +.public-user-card__did, +.public-user-card__facts, +.public-record-facts, +.collection-summary, +.commit-histogram { + font-size: var(--step--1); +} + +.public-user-card__facts { + display: grid; + grid-template-columns: max-content 1fr; + gap: var(--space-1) var(--space-2); +} + +.public-user-card__facts dt { + font-weight: 700; +} + +.public-user-card__facts dd { + margin: 0; +} + +.stats-dashboard__empty { + padding: var(--space-3); + background: var(--surface-sunken); + box-shadow: var(--border-in); +} + +.commit-histogram { + display: grid; + align-items: end; + gap: var(--space-2); + grid-template-columns: repeat(8, minmax(2.4rem, 1fr)); + min-height: 10rem; + padding: var(--space-3); + background: var(--surface-sunken); + box-shadow: var(--border-in); +} + +.commit-histogram__bar { + display: grid; + grid-template-rows: 7rem auto auto; + gap: var(--space-1); + align-items: end; + min-width: 0; + text-align: center; +} + +.commit-histogram__fill { + align-self: end; + min-height: 0.25rem; + background: var(--info); + box-shadow: inset -1px -1px 0 var(--win-dark), inset 1px 1px 0 rgb(255 255 255 / 0.5); + transition: height 160ms ease; +} + +.commit-histogram__bar p, +.commit-histogram__bar strong { + min-width: 0; + font-size: 0.72rem; +} + +.collection-summary { + display: grid; + gap: var(--space-2); +} + +.collection-summary__row { + display: grid; + grid-template-columns: minmax(0, 1fr) 4rem; + gap: var(--space-2); + align-items: center; +} + +.collection-summary__count { + font-weight: 700; + text-align: end; +} + +.collection-summary__track { + grid-column: 1 / -1; + height: 0.75rem; + background: var(--surface-sunken); + box-shadow: var(--border-in); +} + +.collection-summary__track span { + display: block; + height: 100%; + background: var(--success); + transition: width 160ms ease; +} + .status-light { display: inline-block; width: 0.72rem; diff --git a/docs/tasks/16-public-stats-dashboard.md b/docs/tasks/16-public-stats-dashboard.md index babd037..7068814 100644 --- a/docs/tasks/16-public-stats-dashboard.md +++ b/docs/tasks/16-public-stats-dashboard.md @@ -36,17 +36,17 @@ keeping admin-only operations and sensitive internals private. - [x] T16-13: Add Hurl smoke test `test/smoke/public-stats.hurl`. - [x] T16-14: Document cache behavior if stats are cached. Include `generatedAt` in the JSON response either way. -- [ ] T16-15: Rename the public account detail concept to "users". -- [ ] T16-16: Add the public `users` group described in the stats spec. -- [ ] T16-17: Add public avatar and banner support for user cards. -- [ ] T16-18: Render user cards on `/stats`. -- [ ] T16-19: Add the public `latestRecord` group described in the stats spec. -- [ ] T16-20: Render a "Latest Indexed Record" section on `/stats`. -- [ ] T16-21: Add repo storage support for weekly commit counts. -- [ ] T16-22: Add the public `commitWeeks` group described in the stats spec. -- [ ] T16-23: Render a compact weekly commit histogram on `/stats`. -- [ ] T16-24: Add repo storage support for collection summaries. -- [ ] T16-25: Add the public `collections` group described in the stats spec. +- [x] T16-15: Rename the public account detail concept to "users". +- [x] T16-16: Add the public `users` group described in the stats spec. +- [x] T16-17: Add public avatar and banner support for user cards. +- [x] T16-18: Render user cards on `/stats`. +- [x] T16-19: Add the public `latestRecord` group described in the stats spec. +- [x] T16-20: Render a "Latest Indexed Record" section on `/stats`. +- [x] T16-21: Add repo storage support for weekly commit counts. +- [x] T16-22: Add the public `commitWeeks` group described in the stats spec. +- [x] T16-23: Render a compact weekly commit histogram on `/stats`. +- [x] T16-24: Add repo storage support for collection summaries. +- [x] T16-25: Add the public `collections` group described in the stats spec. - [ ] T16-26: Render collection summary rows on `/stats` with count bars. - [ ] T16-27: Extend public stats tests for `users`, avatar/banner URLs, `latestRecord`, `commitWeeks`, and collection summaries. diff --git a/lib/tempest/public_stats.ex b/lib/tempest/public_stats.ex index e7ddf76..979544a 100644 --- a/lib/tempest/public_stats.ex +++ b/lib/tempest/public_stats.ex @@ -9,13 +9,20 @@ defmodule Tempest.PublicStats do alias Tempest.Storage.Timestamp alias Tempest.{Config, Repo, RepoStorage, Sequencer, Storage} + @user_limit 12 + @collection_limit 10 + @commit_week_limit 8 + @doc """ Returns public aggregate stats without admin-only fields or local paths. """ def summary(opts \\ []) do config = Keyword.get_lazy(opts, :config, &Config.load!/0) + accounts = hosted_accounts(@user_limit) + week_ranges = commit_week_ranges() repo_scan = scan_repos(config) - health = health(config, repo_scan.error_count) + details = scan_details(accounts, week_ranges, config) + health = health(config, repo_scan.error_count + details.error_count) %{ "status" => health["status"], @@ -23,6 +30,10 @@ defmodule Tempest.PublicStats do "generatedAt" => Timestamp.iso8601_utc(), "uptimeSeconds" => uptime_seconds(), "metrics" => metrics(repo_scan), + "users" => details.users, + "latestRecord" => details.latest_record, + "commitWeeks" => commit_weeks(week_ranges, details.commit_week_counts), + "collections" => collection_summaries(details.collections), "health" => health } end @@ -43,6 +54,129 @@ defmodule Tempest.PublicStats do } end + defp scan_details(accounts, week_ranges, config) do + week_starts = Enum.map(week_ranges, & &1.week_start) + + accounts + |> Enum.reduce(empty_detail_scan(), fn account, acc -> + case repo_status_counts(account.did, config) do + {:ok, counts} -> + scan_account_details(account, counts, week_starts, config, acc) + + {:error, _reason} -> + acc + end + end) + |> Map.update!(:users, &Enum.reverse/1) + end + + defp scan_account_details(account, counts, week_starts, config, acc) do + with {:ok, profile} <- repo_profile_blobs(account.did, config), + {:ok, latest_record} <- repo_latest_public_record(account.did, config), + {:ok, commit_week_counts} <- repo_commit_week_counts(account.did, week_starts, config), + {:ok, collection_summaries} <- repo_collection_summaries(account.did, config) do + user = public_user(account, counts, profile) + + %{ + acc + | users: [user | acc.users], + latest_record: newest_record(acc.latest_record, public_latest_record(account, latest_record)), + commit_week_counts: merge_counts(acc.commit_week_counts, commit_week_counts), + collections: collection_summaries ++ acc.collections + } + else + {:error, _reason} -> + %{acc | error_count: acc.error_count + 1} + end + end + + defp empty_detail_scan do + %{users: [], latest_record: nil, commit_week_counts: %{}, collections: [], error_count: 0} + end + + defp public_user(account, counts, profile) do + %{ + "did" => account.did, + "handle" => account.handle, + "status" => account.status, + "recordCount" => Map.get(counts, :record_count, 0), + "lastIndexedAt" => Map.get(counts, :latest_activity_at), + "avatarUrl" => blob_url(account.did, Map.get(profile, :avatar_cid)), + "bannerUrl" => blob_url(account.did, Map.get(profile, :banner_cid)) + } + end + + defp public_latest_record(_account, nil), do: nil + + defp public_latest_record(account, record) do + %{ + "did" => account.did, + "handle" => account.handle, + "collection" => record.collection, + "rkey" => record.rkey, + "cid" => record.cid, + "indexedAt" => record.indexed_at + } + end + + defp newest_record(nil, record), do: record + defp newest_record(record, nil), do: record + + defp newest_record(%{"indexedAt" => left} = current, %{"indexedAt" => right} = candidate) do + if right > left, do: candidate, else: current + end + + defp merge_counts(left, right) do + Map.merge(left, right, fn _key, left_count, right_count -> left_count + right_count end) + end + + defp commit_week_ranges do + current_week_start = + Date.utc_today() + |> week_start() + + 0..(@commit_week_limit - 1) + |> Enum.map(fn offset -> Date.add(current_week_start, -7 * offset) end) + |> Enum.reverse() + |> Enum.map(fn week_start -> %{week_start: week_start, week_end: Date.add(week_start, 6)} end) + end + + defp commit_weeks(week_ranges, counts) do + Enum.map(week_ranges, fn range -> + %{ + "weekStart" => Date.to_iso8601(range.week_start), + "weekEnd" => Date.to_iso8601(range.week_end), + "commitCount" => Map.get(counts, range.week_start, 0) + } + end) + end + + defp collection_summaries(summaries) do + summaries + |> Enum.reduce(%{}, fn summary, acc -> + Map.update(acc, summary.collection, summary.record_count, &(&1 + summary.record_count)) + end) + |> Enum.map(fn {collection, record_count} -> %{"collection" => collection, "recordCount" => record_count} end) + |> Enum.sort_by(fn summary -> {-summary["recordCount"], summary["collection"]} end) + |> Enum.take(@collection_limit) + end + + defp week_start(date), do: Date.add(date, 1 - Date.day_of_week(date)) + + defp hosted_accounts(limit) do + Account + |> where([a], a.active == true and a.status == "active") + |> order_by([a], asc: a.handle, asc: a.did) + |> limit(^limit) + |> Repo.all() + end + + defp blob_url(_did, nil), do: nil + + defp blob_url(did, cid) when is_binary(did) and is_binary(cid) do + "/xrpc/com.atproto.sync.getBlob?" <> URI.encode_query(%{"did" => did, "cid" => cid}) + end + defp scan_repos(config) do Account |> where([a], a.active == true and a.status == "active") @@ -73,6 +207,38 @@ defmodule Tempest.PublicStats do _kind, _reason -> {:error, :repo_stats_failed} end + defp repo_profile_blobs(did, config) do + RepoStorage.public_profile_blobs(did, config) + rescue + _error -> {:error, :repo_profile_failed} + catch + _kind, _reason -> {:error, :repo_profile_failed} + end + + defp repo_latest_public_record(did, config) do + RepoStorage.latest_public_record(did, config) + rescue + _error -> {:error, :repo_latest_record_failed} + catch + _kind, _reason -> {:error, :repo_latest_record_failed} + end + + defp repo_commit_week_counts(did, week_starts, config) do + RepoStorage.commit_week_counts(did, week_starts, config) + rescue + _error -> {:error, :repo_commit_weeks_failed} + catch + _kind, _reason -> {:error, :repo_commit_weeks_failed} + end + + defp repo_collection_summaries(did, config) do + RepoStorage.collection_summaries(did, config) + rescue + _error -> {:error, :repo_collection_summaries_failed} + catch + _kind, _reason -> {:error, :repo_collection_summaries_failed} + end + defp empty_repo_scan do %{commit_count: 0, collection_count: 0, record_count: 0, latest_activity_at: nil, error_count: 0} end diff --git a/lib/tempest/repo_storage.ex b/lib/tempest/repo_storage.ex index a78c001..762aaba 100644 --- a/lib/tempest/repo_storage.ex +++ b/lib/tempest/repo_storage.ex @@ -447,6 +447,129 @@ defmodule Tempest.RepoStorage do end end + @doc """ + Returns the latest current record summary for a public stats snapshot. + """ + def latest_public_record(did, %Config{} = config \\ Config.load!()) when is_binary(did) do + with {:ok, conn, _path} <- open_repo(config, did) do + result = + with {:ok, rows} <- + fetch_all( + conn, + """ + SELECT collection, rkey, cid, updated_at + FROM records + ORDER BY updated_at DESC, path ASC + LIMIT 1 + """, + [] + ) do + case rows do + [[collection, rkey, cid, updated_at]] -> + {:ok, %{collection: collection, rkey: rkey, cid: cid, indexed_at: updated_at}} + + [] -> + {:ok, nil} + end + end + + close_and_return(result, conn) + end + end + + @doc """ + Returns per-collection current record counts for public stats. + """ + def collection_summaries(did, %Config{} = config \\ Config.load!()) when is_binary(did) do + with {:ok, conn, _path} <- open_repo(config, did) do + result = + with {:ok, rows} <- + fetch_all( + conn, + """ + SELECT collection, COUNT(*) AS record_count + FROM records + GROUP BY collection + ORDER BY record_count DESC, collection ASC + """, + [] + ) do + {:ok, + Enum.map(rows, fn [collection, record_count] -> %{collection: collection, record_count: record_count} end)} + end + + close_and_return(result, conn) + end + end + + @doc """ + Returns commit counts grouped into the supplied Monday week starts. + """ + def commit_week_counts(did, week_starts, %Config{} = config \\ Config.load!()) + when is_binary(did) and is_list(week_starts) do + with {:ok, conn, _path} <- open_repo(config, did) do + result = + with {:ok, rows} <- fetch_all(conn, "SELECT inserted_at FROM commits", []) do + week_start_set = MapSet.new(week_starts) + + counts = + rows + |> Enum.map(fn [inserted_at] -> inserted_at end) + |> Enum.reduce(%{}, fn inserted_at, acc -> + case week_start_for_iso8601(inserted_at) do + {:ok, week_start} -> + if MapSet.member?(week_start_set, week_start) do + Map.update(acc, week_start, 1, &(&1 + 1)) + else + acc + end + + :error -> + acc + end + end) + + {:ok, counts} + end + + close_and_return(result, conn) + end + end + + @doc """ + Returns sanitized public profile blob CIDs from the current actor profile. + """ + def public_profile_blobs(did, %Config{} = config \\ Config.load!()) when is_binary(did) do + with {:ok, conn, _path} <- open_repo(config, did) do + result = + with {:ok, rows} <- + fetch_all( + conn, + """ + SELECT record_json + FROM records + WHERE collection = 'app.bsky.actor.profile' AND rkey = 'self' + LIMIT 1 + """, + [] + ) do + case rows do + [[record_json]] -> + with {:ok, record} <- Jason.decode(record_json) do + {:ok, %{avatar_cid: blob_cid(record["avatar"]), banner_cid: blob_cid(record["banner"])}} + else + {:error, reason} -> {:error, {:invalid_profile_record_json, reason}} + end + + [] -> + {:ok, %{avatar_cid: nil, banner_cid: nil}} + end + end + + close_and_return(result, conn) + end + end + @doc """ Lists blob CIDs referenced by current records. """ @@ -838,6 +961,27 @@ defmodule Tempest.RepoStorage do |> Enum.max(fn -> nil end) end + defp week_start_for_iso8601(value) when is_binary(value) do + with {:ok, datetime, _offset} <- DateTime.from_iso8601(value) do + date = DateTime.to_date(datetime) + {:ok, Date.add(date, 1 - Date.day_of_week(date))} + else + _error -> :error + end + end + + defp week_start_for_iso8601(_value), do: :error + + defp blob_cid(%{"ref" => %{"$link" => cid}}) when is_binary(cid) do + if Cid.valid?(cid), do: cid + end + + defp blob_cid(%{"cid" => cid}) when is_binary(cid) do + if Cid.valid?(cid), do: cid + end + + defp blob_cid(_value), do: nil + defp referenced_blob_cids(record_json) do case Jason.decode(record_json) do {:ok, record} -> collect_blob_cids(record, []) diff --git a/lib/tempest_web/live/home_live.ex b/lib/tempest_web/live/home_live.ex index 7f88a15..2581333 100644 --- a/lib/tempest_web/live/home_live.ex +++ b/lib/tempest_web/live/home_live.ex @@ -110,6 +110,99 @@ defmodule TempestWeb.HomeLive do
{user["did"]}
+