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
+
+
+ Users +
+
+
No active hosted users yet.
+
+
+ +
+
+ +

{user["handle"]}

+

{user["did"]}

+
+
Status
+
{user["status"] || "unknown"}
+
Records
+
{user["recordCount"] || 0}
+
+
+
+
+
+ +
+
+ Latest Indexed Record +
+
+
No current records indexed yet.
+
+
User
+
{@latest_record["handle"] || @latest_record["did"]}
+
Collection
+
{@latest_record["collection"]}
+
RKey
+
{@latest_record["rkey"]}
+
CID
+
{@latest_record["cid"]}
+
Indexed
+
{@latest_record["indexedAt"]}
+
+
+
+ +
+
+ Weekly Commits +
+
+
+
+
+
+

{week_label(week)}

+ {week["commitCount"] || 0} +
+
+
+
+ +
+
+ Collections +
+
+
No collection records yet.
+
+ {collection["collection"]} + {collection["recordCount"]} + +
+
+
+
Health Checks @@ -179,6 +272,12 @@ defmodule TempestWeb.HomeLive do |> assign(:health, health) |> assign(:checks, checks) |> assign(:metrics, metrics) + |> assign(:users, summary["users"] || []) + |> assign(:latest_record, summary["latestRecord"]) + |> assign(:commit_weeks, summary["commitWeeks"] || []) + |> assign(:collections, summary["collections"] || []) + |> assign(:max_commit_week_count, max_count(summary["commitWeeks"] || [], "commitCount")) + |> assign(:max_collection_record_count, max_count(summary["collections"] || [], "recordCount")) |> assign(:health_status, health["status"] || "unknown") |> assign(:rendered_at, summary["generatedAt"] || rendered_at()) |> assign(:center_title, center_title(socket.assigns.live_action)) @@ -308,6 +407,30 @@ defmodule TempestWeb.HomeLive do defp stats_scan_error_count(%{"checks" => %{} = checks}), do: checks["statsScanErrorCount"] || 0 defp stats_scan_error_count(_health), do: 0 + defp max_count(items, key) do + items + |> Enum.map(&(&1[key] || 0)) + |> Enum.max(fn -> 0 end) + end + + defp commit_bar_height(_week, 0), do: 4 + defp commit_bar_height(%{"commitCount" => count}, max_count), do: max(round(count / max_count * 100), 4) + defp commit_bar_height(_week, _max_count), do: 4 + + defp collection_bar_width(_collection, 0), do: 0 + + defp collection_bar_width(%{"recordCount" => count}, max_count) do + max(round(count / max_count * 100), 4) + end + + defp collection_bar_width(_collection, _max_count), do: 0 + + defp week_label(%{"weekStart" => week_start}) when is_binary(week_start), do: String.slice(week_start, 5, 5) + defp week_label(_week), do: "n/a" + + defp user_initial(%{"handle" => <>}), do: String.upcase(first) + defp user_initial(_user), do: "?" + defp rendered_at do DateTime.utc_now() |> Calendar.strftime("%Y-%m-%d %H:%M:%SZ") diff --git a/test/tempest/public_stats_test.exs b/test/tempest/public_stats_test.exs index 8daefc5..98b2f2f 100644 --- a/test/tempest/public_stats_test.exs +++ b/test/tempest/public_stats_test.exs @@ -3,6 +3,8 @@ defmodule Tempest.PublicStatsTest do alias Tempest.Accounts alias Tempest.Accounts.{Account, AuthContext} + alias Tempest.Identity.KeyStore + alias Tempest.RepoCore.Cid alias Tempest.{Config, PublicStats, Records, Repo, RepoStorage} @password "correct horse battery staple" @@ -47,6 +49,10 @@ defmodule Tempest.PublicStatsTest do assert summary["metrics"]["collectionCount"] == 2 assert summary["metrics"]["recordCount"] == 2 assert is_binary(summary["metrics"]["lastIndexedAt"]) + assert is_list(summary["users"]) + assert is_map(summary["latestRecord"]) + assert length(summary["commitWeeks"]) == 8 + assert is_list(summary["collections"]) encoded = Jason.encode!(summary) refute encoded =~ "email" @@ -57,6 +63,78 @@ defmodule Tempest.PublicStatsTest do refute encoded =~ Config.load!().data_dir end + test "summary returns bounded public users, latest record, commit weeks, and collections" do + account = create_account!("public-stats-detail.test", "public-stats-detail@example.com") + signing_key = KeyStore.active_key_for_account(account) + avatar_cid = Cid.for_raw("public avatar") |> Cid.to_string() + banner_cid = Cid.for_raw("public banner") |> Cid.to_string() + + assert {:ok, _profile} = + RepoStorage.create_record(account, signing_key, %{ + collection: "app.bsky.actor.profile", + rkey: "self", + swap_commit: nil, + record: %{ + "$type" => "app.bsky.actor.profile", + "displayName" => "Public Details", + "avatar" => %{"$type" => "blob", "ref" => %{"$link" => avatar_cid}, "mimeType" => "image/png"}, + "banner" => %{"$type" => "blob", "ref" => %{"$link" => banner_cid}, "mimeType" => "image/png"} + } + }) + + for index <- 1..11 do + assert {:ok, _record} = + RepoStorage.create_record(account, signing_key, %{ + collection: "app.tempest.collection#{index}", + rkey: "one", + swap_commit: nil, + record: %{"$type" => "app.tempest.collection#{index}", "value" => index} + }) + end + + summary = PublicStats.summary() + + assert [ + %{ + "did" => did, + "handle" => "public-stats-detail.test", + "status" => "active", + "recordCount" => 12, + "lastIndexedAt" => last_indexed_at, + "avatarUrl" => avatar_url, + "bannerUrl" => banner_url + } + | _rest + ] = Enum.filter(summary["users"], &(&1["did"] == account.did)) + + assert did == account.did + assert is_binary(last_indexed_at) + assert_blob_url(avatar_url, account.did, avatar_cid) + assert_blob_url(banner_url, account.did, banner_cid) + + assert %{ + "did" => ^did, + "handle" => "public-stats-detail.test", + "collection" => collection, + "rkey" => rkey, + "cid" => cid, + "indexedAt" => indexed_at + } = summary["latestRecord"] + + assert is_binary(collection) + assert is_binary(rkey) + assert is_binary(cid) + assert is_binary(indexed_at) + + assert length(summary["commitWeeks"]) == 8 + assert Enum.all?(summary["commitWeeks"], &match?(%{"weekStart" => _, "weekEnd" => _, "commitCount" => _}, &1)) + assert Enum.any?(summary["commitWeeks"], &(&1["commitCount"] >= 13)) + + assert length(summary["collections"]) == 10 + assert Enum.all?(summary["collections"], &match?(%{"collection" => _, "recordCount" => _}, &1)) + refute Enum.any?(summary["collections"], &(&1["recordCount"] == 0)) + end + test "health is degraded when a repo scan fails" do account = create_account!("public-stats-scan-failure.test", "public-stats-scan-failure@example.com") path = Config.load!() |> Config.repo_db_path(account.did) @@ -99,4 +177,9 @@ defmodule Tempest.PublicStatsTest do Repo.get_by!(Account, did: session["did"]) end + + defp assert_blob_url(url, did, cid) do + assert %URI{path: "/xrpc/com.atproto.sync.getBlob", query: query} = URI.parse(url) + assert %{"did" => ^did, "cid" => ^cid} = URI.decode_query(query) + end end diff --git a/test/tempest/repo_storage_test.exs b/test/tempest/repo_storage_test.exs index 9cea9bf..ae7d63e 100644 --- a/test/tempest/repo_storage_test.exs +++ b/test/tempest/repo_storage_test.exs @@ -145,6 +145,65 @@ defmodule Tempest.RepoStorageTest do assert counts.latest_activity_at == max(counts.latest_commit_at, counts.latest_record_at) end + test "public stats helpers expose latest record, profile blobs, commit weeks, and collection counts" do + assert {:ok, created} = + Accounts.create_account(%{ + "handle" => "repo-public-details.test", + "email" => "repo-public-details@example.com", + "password" => @password + }) + + path = + Config.load!() + |> Config.repo_db_path(created["did"]) + + on_exit(fn -> File.rm(path) end) + + account = Repo.get_by!(Account, did: created["did"]) + signing_key = KeyStore.active_key_for_account(account) + avatar_cid = Cid.for_raw("avatar bytes") |> Cid.to_string() + banner_cid = Cid.for_raw("banner bytes") |> Cid.to_string() + + assert {:ok, _record} = + RepoStorage.create_record(account, signing_key, %{ + collection: "app.bsky.actor.profile", + rkey: "self", + swap_commit: nil, + record: %{ + "$type" => "app.bsky.actor.profile", + "avatar" => %{"$type" => "blob", "ref" => %{"$link" => avatar_cid}, "mimeType" => "image/png"}, + "banner" => %{"$type" => "blob", "ref" => %{"$link" => banner_cid}, "mimeType" => "image/png"} + } + }) + + assert {:ok, _record} = + RepoStorage.create_record(account, signing_key, %{ + collection: "app.bsky.feed.post", + rkey: "one", + swap_commit: nil, + record: %{"$type" => "app.bsky.feed.post", "text" => "hello"} + }) + + assert {:ok, %{avatar_cid: ^avatar_cid, banner_cid: ^banner_cid}} = + RepoStorage.public_profile_blobs(created["did"]) + + assert {:ok, latest} = RepoStorage.latest_public_record(created["did"]) + assert latest.collection in ["app.bsky.actor.profile", "app.bsky.feed.post"] + assert is_binary(latest.rkey) + assert is_binary(latest.cid) + assert is_binary(latest.indexed_at) + + assert {:ok, collections} = RepoStorage.collection_summaries(created["did"]) + assert %{collection: "app.bsky.actor.profile", record_count: 1} in collections + assert %{collection: "app.bsky.feed.post", record_count: 1} in collections + + week_start = + Date.utc_today() + |> Date.add(1 - Date.day_of_week(Date.utc_today())) + + assert {:ok, %{^week_start => 3}} = RepoStorage.commit_week_counts(created["did"], [week_start]) + end + test "imported records normalize DRISL CID links before storing JSON" do assert {:ok, source_created} = Accounts.create_account(%{ diff --git a/test/tempest_web/controllers/public_stats_controller_test.exs b/test/tempest_web/controllers/public_stats_controller_test.exs index 3a05b90..a72eb69 100644 --- a/test/tempest_web/controllers/public_stats_controller_test.exs +++ b/test/tempest_web/controllers/public_stats_controller_test.exs @@ -35,6 +35,10 @@ defmodule TempestWeb.PublicStatsControllerTest do assert html =~ "Tempest Public Stats" assert html =~ "Hosted Accounts" assert html =~ "Last Indexed" + assert html =~ ~s(id="public-users") + assert html =~ "Latest Indexed Record" + assert html =~ ~s(id="commit-weeks") + assert html =~ ~s(id="collection-summaries") stats = conn @@ -46,6 +50,10 @@ defmodule TempestWeb.PublicStatsControllerTest do assert is_binary(stats["generatedAt"]) assert is_integer(stats["uptimeSeconds"]) assert is_map(stats["metrics"]) + assert is_list(stats["users"]) + assert Map.has_key?(stats, "latestRecord") + assert is_list(stats["commitWeeks"]) + assert is_list(stats["collections"]) assert is_map(stats["health"]) rejected = @@ -86,6 +94,14 @@ defmodule TempestWeb.PublicStatsControllerTest do assert response["metrics"]["recordCount"] == 2 assert is_binary(response["metrics"]["lastIndexedAt"]) + assert [%{"handle" => "stats-public.test", "avatarUrl" => nil, "bannerUrl" => nil} | _rest] = + Enum.filter(response["users"], &(&1["did"] == account.did)) + + assert response["latestRecord"]["did"] == account.did + assert length(response["commitWeeks"]) == 8 + assert %{"collection" => "app.bsky.actor.profile", "recordCount" => 1} in response["collections"] + assert %{"collection" => "app.bsky.feed.post", "recordCount" => 1} in response["collections"] + encoded = response |> Jason.encode!() |> String.downcase() for sensitive <- [