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