if Code.ensure_loaded?(Phoenix.LiveDashboard.PageBuilder) do defmodule Dust.Dashboard do @moduledoc """ LiveDashboard page for Dust SDK introspection. ## Setup live_dashboard "/dev/dashboard", additional_pages: [ dust: Dust.Dashboard ] """ use Phoenix.LiveDashboard.PageBuilder @impl true def menu_link(_, _) do {:ok, "Dust"} end @impl true def mount(_params, session, socket) do socket = socket |> assign(:connection_info, fetch_connection_info()) |> assign(:stores, fetch_stores()) |> assign(:selected_store, nil) |> assign(:entries, []) |> assign(:entries_cursor, nil) |> assign(:entries_filter, "") |> assign(:activity, []) |> assign(:nav, session["nav"] || :stores) {:ok, socket} end @impl true def handle_refresh(socket) do socket = socket |> assign(:connection_info, fetch_connection_info()) |> assign(:stores, fetch_stores()) socket = if store = socket.assigns.selected_store do assign(socket, :activity, fetch_activity(store)) else socket end {:noreply, socket} end @impl true def render(assigns) do ~H"""
Status: <%= @connection_info.status %>
URL: <%= @connection_info.url || "—" %>
Device: <%= @connection_info.device_id || "—" %>
Uptime: <%= format_uptime(@connection_info.uptime_seconds) %>

Stores

Store Entries Last Seq Pending Status
<%= store.store %> <%= store.entry_count || "—" %> <%= store.last_store_seq %> <%= store.pending_ops %> <%= store.connection %>

Entries — <%= @selected_store %>

Path Value Type Seq
<%= path %> <%= truncate_value(value) %> <%= type %> <%= seq %>

Activity

Time Path Op Source Seq
<%= format_time(entry.timestamp) %> <%= entry.path %> <%= entry.op %> <%= entry.source %> <%= entry.seq %>
""" end @impl true def handle_event("select_store", %{"store" => store}, socket) do {entries, cursor} = browse_store(store, nil, "") socket = socket |> assign(:selected_store, store) |> assign(:entries, entries) |> assign(:entries_cursor, cursor) |> assign(:entries_filter, "") |> assign(:activity, fetch_activity(store)) {:noreply, socket} end @impl true def handle_event("filter_entries", %{"pattern" => pattern}, socket) do store = socket.assigns.selected_store {entries, cursor} = browse_store(store, nil, pattern) socket = socket |> assign(:entries, entries) |> assign(:entries_cursor, cursor) |> assign(:entries_filter, pattern) {:noreply, socket} end @impl true def handle_event("next_page", _, socket) do store = socket.assigns.selected_store cursor = socket.assigns.entries_cursor pattern = socket.assigns.entries_filter {entries, next_cursor} = browse_store(store, cursor, pattern) socket = socket |> assign(:entries, entries) |> assign(:entries_cursor, next_cursor) {:noreply, socket} end # Data fetching defp fetch_connection_info do case GenServer.whereis(Dust.Connection) do nil -> %{status: :not_started, url: nil, device_id: nil, uptime_seconds: nil} pid when is_pid(pid) -> if Process.alive?(pid) do Dust.Connection.info(pid) else %{status: :not_started, url: nil, device_id: nil, uptime_seconds: nil} end end end defp fetch_stores do case Process.whereis(Dust.SyncEngineRegistry) do nil -> [] _pid -> Registry.select(Dust.SyncEngineRegistry, [{{:"$1", :"$2", :_}, [], [{{:"$1", :"$2"}}]}]) |> Enum.flat_map(fn {_store, pid} -> if Process.alive?(pid) do case GenServer.call(pid, :status, 1000) do status when is_map(status) -> [status] _ -> [] end else [] end end) |> Enum.sort_by(& &1.store) end end defp browse_store(store, cursor, pattern) do case Process.whereis(Dust.SyncEngineRegistry) do nil -> {[], nil} _pid -> case Registry.lookup(Dust.SyncEngineRegistry, store) do [{pid, _}] when is_pid(pid) -> if Process.alive?(pid) do {cache_mod, target} = GenServer.call(pid, :cache_info, 1000) if function_exported?(cache_mod, :browse, 3) do opts = [limit: 50, cursor: cursor] opts = if pattern != "", do: Keyword.put(opts, :pattern, pattern), else: opts cache_mod.browse(target, store, opts) else {[], nil} end else {[], nil} end _ -> {[], nil} end end end defp fetch_activity(store) do case :ets.whereis(Dust.ActivityBuffer) do :undefined -> [] _ref -> Dust.ActivityBuffer.recent(Dust.ActivityBuffer, store, 50) end end # Formatting helpers defp status_color(:connected), do: "#28a745" defp status_color(:disconnected), do: "#dc3545" defp status_color(:reconnecting), do: "#ffc107" defp status_color(:not_started), do: "#6c757d" defp status_color(_), do: "#6c757d" defp format_uptime(nil), do: "—" defp format_uptime(seconds) when seconds < 60, do: "#{seconds}s" defp format_uptime(seconds) when seconds < 3600, do: "#{div(seconds, 60)}m #{rem(seconds, 60)}s" defp format_uptime(seconds), do: "#{div(seconds, 3600)}h #{div(rem(seconds, 3600), 60)}m" defp format_time(%DateTime{} = dt) do Calendar.strftime(dt, "%H:%M:%S") end defp format_time(_), do: "—" defp truncate_value(value) when is_binary(value) and byte_size(value) > 80 do String.slice(value, 0, 80) <> "..." end defp truncate_value(value) when is_map(value) or is_list(value) do inspected = inspect(value, limit: 5, printable_limit: 80) if String.length(inspected) > 80, do: String.slice(inspected, 0, 80) <> "...", else: inspected end defp truncate_value(value), do: inspect(value) end end