diff --git a/assets/css/components/shell.css b/assets/css/components/shell.css index 2375d6f..0731604 100644 --- a/assets/css/components/shell.css +++ b/assets/css/components/shell.css @@ -260,6 +260,13 @@ transform: translate(-1px, -1px); } +.status-card__value { + margin-block-end: var(--space-2); + font-family: var(--font-mono); + font-size: 0.82rem; + font-weight: 700; +} + .status-card__label { margin-block-end: var(--space-2); font-size: 0.75rem; diff --git a/conf/Dockerfile b/conf/Dockerfile index 0e1a4af..635e521 100644 --- a/conf/Dockerfile +++ b/conf/Dockerfile @@ -23,8 +23,8 @@ COPY assets ./assets COPY lib ./lib COPY priv ./priv -RUN mix assets.deploy RUN mix compile +RUN mix assets.deploy RUN mix release FROM alpine:${ALPINE_VERSION} AS runtime diff --git a/lib/tempest_web/live/home_live.ex b/lib/tempest_web/live/home_live.ex new file mode 100644 index 0000000..5aabe89 --- /dev/null +++ b/lib/tempest_web/live/home_live.ex @@ -0,0 +1,439 @@ +defmodule TempestWeb.HomeLive do + use TempestWeb, :live_view + + alias Tempest.{Config, PublicStats} + alias Tempest.Xrpc.{Method, Registry} + + @refresh_interval :timer.seconds(15) + + @service_routes [ + %{verb: "GET", name: "/.well-known/atproto-did", href: "/.well-known/atproto-did", badge: "public", badge_class: "badge-success"}, + %{verb: "GET", name: "/.well-known/did.json", href: "/.well-known/did.json", badge: "public", badge_class: "badge-success"}, + %{verb: "GET", name: "/.well-known/oauth-protected-resource", href: "/.well-known/oauth-protected-resource", badge: "meta", badge_class: "badge-info"}, + %{verb: "GET", name: "/.well-known/oauth-authorization-server", href: "/.well-known/oauth-authorization-server", badge: "meta", badge_class: "badge-info"}, + %{verb: "GET", name: "/oauth/jwks", href: "/oauth/jwks", badge: "meta", badge_class: "badge-info"}, + %{verb: "GET", name: "/xrpc/_health", href: "/xrpc/_health", badge: "ops", badge_class: "badge-info"}, + %{verb: "GET", name: "/xrpc/_stats", href: "/xrpc/_stats", badge: "public", badge_class: "badge-success"}, + %{verb: "GET", name: "/xrpc/_admin/status", href: "/xrpc/_admin/status", badge: "ops", badge_class: "badge-ghost"} + ] + + @impl true + def mount(_params, _session, socket) do + if connected?(socket), do: schedule_refresh() + + {:ok, assign_dashboard(socket)} + end + + @impl true + def handle_params(_params, _uri, socket) do + page_title = + case socket.assigns.live_action do + :stats -> "Tempest Public Stats" + _ -> "Tempest PDS" + end + + {:noreply, assign(socket, :page_title, page_title)} + end + + @impl true + def handle_info(:refresh_dashboard, socket) do + schedule_refresh() + {:noreply, assign_dashboard(socket)} + end + + @impl true + def render(assigns) do + ~H""" + +
+
+
+ + +
+
+
+
+ LIVE_STATS.EXE + +
+ +
+
+ +

Personal Data Server

+

Live node snapshot for {@host}

+

Refreshed {@rendered_at} · every 15s while connected

+
+ + +
+
+ +
+
+ Live Status +
+
+
+
+

{card.label}

+

+ + {card.state} +

+

{card.value}

+

{card.note}

+
+
+
+
+
+ +
+
+
+ Public Metrics +
+
+
+
+

{metric.label}

+

+ {metric.value} +

+

{metric.note}

+
+
+
+
+ +
+
+ Health Checks + + {@health_status} + +
+
+

+ Public checks cover storage, databases, directories, and scan consistency. +

+
+

+ {label} + {inspect(value)} +

+
+
+
+
+ +
+
+ Endpoint Surface +
+
+ <%= for endpoint <- @endpoint_rows do %> + <.link + :if={endpoint.href} + navigate={endpoint.href} + class="endpoint-list__row" + > + {endpoint.verb} + + {endpoint.family} + {endpoint.name} + + {endpoint.badge} + +
+ {endpoint.verb} + + {endpoint.family} + {endpoint.name} + + {endpoint.badge} +
+ <% end %> +
+
+ +
+

Internet Shortcuts

+ +
+
+
+ + + + +
+
+
+ """ + end + + defp assign_dashboard(socket) do + config = Config.load!() + summary = PublicStats.summary(config: config) + health = summary["health"] || %{} + checks = health["checks"] || %{} + metrics = summary["metrics"] || %{} + methods = Registry.all() + endpoint_rows = build_endpoint_rows(methods, config.hostname) + + socket + |> assign(:host, config.hostname) + |> assign(:app_version, Application.spec(:tempest, :vsn)) + |> assign(:summary, summary) + |> assign(:health, health) + |> assign(:checks, checks) + |> assign(:metrics, metrics) + |> assign(:health_status, health["status"] || "unknown") + |> assign(:rendered_at, summary["generatedAt"] || rendered_at()) + |> assign(:status_cards, build_status_cards(metrics, health, methods)) + |> assign(:metric_cards, build_metric_cards(metrics)) + |> assign(:endpoint_rows, endpoint_rows) + |> assign(:endpoint_count, length(endpoint_rows)) + |> assign(:public_endpoint_count, Enum.count(endpoint_rows, &(&1.badge == "public"))) + end + + defp build_status_cards(metrics, health, methods) do + hosted = metrics["hostedAccountCount"] || 0 + total = metrics["totalAccountCount"] || 0 + record_count = metrics["recordCount"] || 0 + collection_count = metrics["collectionCount"] || 0 + commit_count = metrics["commitCount"] || 0 + health_status = health["status"] || "unknown" + sync_count = Enum.count(methods, &String.starts_with?(&1.nsid, "com.atproto.sync.")) + repo_count = Enum.count(methods, &String.starts_with?(&1.nsid, "com.atproto.repo.")) + public_methods = Enum.count(methods, &(&1.auth == :none)) + protected_methods = length(methods) - public_methods + + [ + %{ + id: "status-accounts", + label: "Accounts", + state: "#{hosted} active", + value: "#{total} known account rows", + note: "Live account totals come from the public stats pipeline, not placeholder copy.", + light_class: "status-light--ok" + }, + %{ + id: "status-repos", + label: "Repo Data", + state: "#{record_count} records", + value: "#{collection_count} collections · #{commit_count} commits", + note: "Repository totals are scanned from hosted repos and sequencer activity.", + light_class: if(record_count > 0, do: "status-light--ready", else: nil) + }, + %{ + id: "status-surface", + label: "Protocol Surface", + state: "#{length(methods)} XRPC methods", + value: "#{public_methods} public · #{protected_methods} protected", + note: "#{repo_count} repo methods and #{sync_count} sync methods are registered right now.", + light_class: "status-light--ready" + }, + %{ + id: "status-health", + label: "Node Health", + state: health_status, + value: "#{health["checks"]["statsScanErrorCount"] || 0} scan errors", + note: "Status derives from storage, database, directory, and sequencer checks.", + light_class: health_light_class(health_status) + } + ] + end + + defp build_metric_cards(metrics) do + [ + %{ + id: "hosted-account-count", + label: "Hosted Accounts", + value: metrics["hostedAccountCount"] || 0, + note: "Active accounts currently hosted on this node.", + mono?: false + }, + %{ + id: "total-account-count", + label: "Total Accounts", + value: metrics["totalAccountCount"] || 0, + note: "All account rows, including inactive records.", + mono?: false + }, + %{ + id: "commit-count", + label: "Commits", + value: metrics["commitCount"] || 0, + note: "Repository commit rows accumulated across hosted repos.", + mono?: false + }, + %{ + id: "collection-count", + label: "Collections", + value: metrics["collectionCount"] || 0, + note: "Collections present in currently hosted repos.", + mono?: false + }, + %{ + id: "record-count", + label: "Records", + value: metrics["recordCount"] || 0, + note: "Visible records counted by the public stats scanner.", + mono?: false + }, + %{ + id: "last-indexed-at", + label: "Last Indexed", + value: metrics["lastIndexedAt"] || "n/a", + note: "Latest local repo or sequencer activity observed by this node.", + mono?: true + } + ] + end + + defp build_endpoint_rows(methods, host) do + @service_routes + |> Enum.map(&Map.merge(&1, %{family: "service/", muted?: &1.badge != "public"})) + |> Kernel.++( + methods + |> Enum.sort_by(& &1.nsid) + |> Enum.map(&method_row(&1, host)) + ) + end + + defp method_row(%Method{} = method, host) do + %{ + verb: verb_for(method.kind), + family: family_for(method.nsid), + name: method.nsid, + href: public_href(method, host), + badge: badge_for(method), + badge_class: badge_class_for(method), + muted?: method.auth != :none + } + end + + defp family_for(nsid) do + case String.split(nsid, ".") do + [_, _, family | _] -> family <> "/" + _ -> "" + end + end + + defp verb_for(:query), do: "GET" + defp verb_for(:procedure), do: "POST" + defp verb_for(:subscription), do: "STREAM" + + defp badge_for(%Method{kind: :subscription}), do: "stream" + defp badge_for(%Method{auth: :none}), do: "public" + defp badge_for(%Method{auth: :bearer}), do: "token" + defp badge_for(%Method{auth: :admin}), do: "admin" + + defp badge_class_for(%Method{kind: :subscription}), do: "badge-ghost" + defp badge_class_for(%Method{auth: :none}), do: "badge-success" + defp badge_class_for(%Method{auth: :bearer}), do: "badge-info" + defp badge_class_for(%Method{auth: :admin}), do: "badge-ghost" + + defp public_href(%Method{auth: :none, nsid: "com.atproto.server.describeServer"}), do: "/xrpc/com.atproto.server.describeServer" + + defp public_href(%Method{auth: :none, nsid: "com.atproto.identity.resolveHandle"}, host) do + "/xrpc/com.atproto.identity.resolveHandle?handle=#{host}" + end + + defp public_href(%Method{auth: :none, nsid: "com.atproto.sync.listRepos"}), do: "/xrpc/com.atproto.sync.listRepos" + defp public_href(%Method{}), do: nil + + defp health_light_class("ok"), do: "status-light--ok" + defp health_light_class("degraded"), do: "status-light--ready" + defp health_light_class(_status), do: nil + + defp rendered_at do + DateTime.utc_now() + |> Calendar.strftime("%Y-%m-%d %H:%M:%SZ") + end + + defp schedule_refresh do + Process.send_after(self(), :refresh_dashboard, @refresh_interval) + end +end diff --git a/lib/tempest_web/router.ex b/lib/tempest_web/router.ex index 58d6ccc..ff0ea1d 100644 --- a/lib/tempest_web/router.ex +++ b/lib/tempest_web/router.ex @@ -40,8 +40,8 @@ defmodule TempestWeb.Router do scope "/", TempestWeb do pipe_through :browser - get "/", PageController, :home - get "/stats", PageController, :stats + live "/", HomeLive, :home + live "/stats", HomeLive, :stats get "/account", OperatorAccountController, :dashboard get "/account/repo", OperatorAccountController, :repo get "/account/blobs", OperatorAccountController, :blobs diff --git a/test/tempest_web/controllers/page_controller_test.exs b/test/tempest_web/controllers/page_controller_test.exs index 42a3b0d..378d329 100644 --- a/test/tempest_web/controllers/page_controller_test.exs +++ b/test/tempest_web/controllers/page_controller_test.exs @@ -7,19 +7,21 @@ defmodule TempestWeb.PageControllerTest do assert html =~ ~s(id="tempest-home") assert html =~ "Personal Data Server" + assert html =~ "Live Status" + assert html =~ "Public Metrics" assert html =~ ~s(id="api-endpoints") - assert html =~ "Coming soon" + assert html =~ "Endpoint Surface" end test "GET /stats renders a public aggregate dashboard", %{conn: conn} do conn = get(conn, ~p"/stats") html = html_response(conn, 200) - assert html =~ ~s(id="tempest-stats") + assert html =~ ~s(id="tempest-home") assert html =~ "Tempest Public Stats" assert html =~ "Hosted Accounts" assert html =~ "Total Accounts" - assert html =~ "Commit" + assert html =~ "Commits" assert html =~ "Health" assert html =~ "Stats scan errors" end