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"""
<div>
<!-- Connection Bar -->
<div style="display: flex; gap: 24px; align-items: center; margin-bottom: 16px; padding: 12px; background: #f8f9fa; border-radius: 6px;">
<div>
<strong>Status:</strong>
<span style={"color: #{status_color(@connection_info.status)}"}>
<%= @connection_info.status %>
</span>
</div>
<div><strong>URL:</strong> <%= @connection_info.url || "—" %></div>
<div><strong>Device:</strong> <code><%= @connection_info.device_id || "—" %></code></div>
<div :if={@connection_info.uptime_seconds}>
<strong>Uptime:</strong> <%= format_uptime(@connection_info.uptime_seconds) %>
</div>
</div>
<!-- Stores Table -->
<h3 style="margin-bottom: 8px;">Stores</h3>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 24px;">
<thead>
<tr style="border-bottom: 2px solid #dee2e6;">
<th style="text-align: left; padding: 8px;">Store</th>
<th style="text-align: right; padding: 8px;">Entries</th>
<th style="text-align: right; padding: 8px;">Last Seq</th>
<th style="text-align: right; padding: 8px;">Pending</th>
<th style="text-align: center; padding: 8px;">Status</th>
</tr>
</thead>
<tbody>
<tr :for={store <- @stores}
style={"cursor: pointer; #{if @selected_store == store.store, do: "background: #e8f4fd;", else: ""}"}
phx-click="select_store"
phx-value-store={store.store}>
<td style="padding: 8px;"><code><%= store.store %></code></td>
<td style="text-align: right; padding: 8px;"><%= store.entry_count || "—" %></td>
<td style="text-align: right; padding: 8px;"><%= store.last_store_seq %></td>
<td style="text-align: right; padding: 8px;"><%= store.pending_ops %></td>
<td style="text-align: center; padding: 8px;">
<span style={"color: #{status_color(store.connection)}"}>
<%= store.connection %>
</span>
</td>
</tr>
</tbody>
</table>
<!-- Bottom panels (entries + activity) -->
<div :if={@selected_store} style="display: grid; grid-template-columns: 1fr 1fr; gap: 24px;">
<!-- Entries Browser -->
<div>
<h3 style="margin-bottom: 8px;">Entries — <code><%= @selected_store %></code></h3>
<form phx-change="filter_entries" style="margin-bottom: 8px;">
<input name="pattern" value={@entries_filter} placeholder="Filter by glob pattern..." style="width: 100%; padding: 6px; border: 1px solid #ced4da; border-radius: 4px;" />
</form>
<table style="width: 100%; border-collapse: collapse; font-size: 13px;">
<thead>
<tr style="border-bottom: 1px solid #dee2e6;">
<th style="text-align: left; padding: 4px;">Path</th>
<th style="text-align: left; padding: 4px;">Value</th>
<th style="text-align: left; padding: 4px;">Type</th>
<th style="text-align: right; padding: 4px;">Seq</th>
</tr>
</thead>
<tbody>
<tr :for={{path, value, type, seq} <- @entries} style="border-bottom: 1px solid #f0f0f0;">
<td style="padding: 4px;"><code><%= path %></code></td>
<td style="padding: 4px; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
<%= truncate_value(value) %>
</td>
<td style="padding: 4px;"><%= type %></td>
<td style="text-align: right; padding: 4px;"><%= seq %></td>
</tr>
</tbody>
</table>
<div :if={@entries_cursor} style="margin-top: 8px;">
<button phx-click="next_page" style="padding: 4px 12px; border: 1px solid #ced4da; border-radius: 4px; background: white; cursor: pointer;">
Next page →
</button>
</div>
</div>
<!-- Activity Feed -->
<div>
<h3 style="margin-bottom: 8px;">Activity</h3>
<table style="width: 100%; border-collapse: collapse; font-size: 13px;">
<thead>
<tr style="border-bottom: 1px solid #dee2e6;">
<th style="text-align: left; padding: 4px;">Time</th>
<th style="text-align: left; padding: 4px;">Path</th>
<th style="text-align: left; padding: 4px;">Op</th>
<th style="text-align: left; padding: 4px;">Source</th>
<th style="text-align: right; padding: 4px;">Seq</th>
</tr>
</thead>
<tbody>
<tr :for={entry <- @activity} style="border-bottom: 1px solid #f0f0f0;">
<td style="padding: 4px;"><%= format_time(entry.timestamp) %></td>
<td style="padding: 4px;"><code><%= entry.path %></code></td>
<td style="padding: 4px;"><%= entry.op %></td>
<td style="padding: 4px;"><%= entry.source %></td>
<td style="text-align: right; padding: 4px;"><%= entry.seq %></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
"""
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(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