berrypod/lib/berrypod_web/live/admin/activity.ex

432 lines
12 KiB
Elixir
Raw Permalink Normal View History

defmodule BerrypodWeb.Admin.Activity do
use BerrypodWeb, :live_view
alias Berrypod.{ActivityLog, Orders}
alias Berrypod.Sync.ProductSyncWorker
@valid_tabs ~w(all attention)
@valid_categories ~w(orders syncs emails carts)
@impl true
def mount(_params, _session, socket) do
if connected?(socket), do: ActivityLog.subscribe()
socket =
socket
|> assign(:page_title, "Activity")
|> assign(:attention_count, ActivityLog.count_needing_attention())
|> assign(:show_system, false)
{:ok, socket}
end
@impl true
def handle_params(params, _uri, socket) do
tab = if params["tab"] in @valid_tabs, do: params["tab"], else: "all"
category = if params["category"] in @valid_categories, do: params["category"], else: nil
search = params["search"]
page_num = Berrypod.Pagination.parse_page(params)
page =
ActivityLog.list_recent(
page: page_num,
tab: tab,
category: category,
search: search,
hide_system: !socket.assigns.show_system
)
socket =
socket
|> assign(:tab, tab)
|> assign(:category, category)
|> assign(:search, search || "")
|> assign(:pagination, page)
|> stream(:entries, page.items, reset: true)
{:noreply, socket}
end
@impl true
def handle_info({:new_activity, entry}, socket) do
socket = assign(socket, :attention_count, ActivityLog.count_needing_attention())
# Only prepend to stream if on page 1 and entry matches current filters
socket =
if socket.assigns.pagination.page == 1 and matches_filters?(entry, socket.assigns) do
stream_insert(socket, :entries, entry, at: 0)
else
socket
end
{:noreply, socket}
end
@impl true
def handle_event("tab", %{"tab" => tab}, socket) do
params = build_params(tab: tab)
{:noreply, push_patch(socket, to: ~p"/admin/activity?#{params}")}
end
def handle_event("category", %{"category" => category}, socket) do
# Toggle: clicking the active category clears it
category = if category == socket.assigns.category, do: nil, else: category
params =
build_params(tab: socket.assigns.tab, category: category, search: socket.assigns.search)
{:noreply, push_patch(socket, to: ~p"/admin/activity?#{params}")}
end
def handle_event("search", %{"search" => %{"query" => query}}, socket) do
search = if query == "", do: nil, else: query
params =
build_params(tab: socket.assigns.tab, category: socket.assigns.category, search: search)
{:noreply, push_patch(socket, to: ~p"/admin/activity?#{params}")}
end
def handle_event("toggle_system", _params, socket) do
socket = assign(socket, :show_system, !socket.assigns.show_system)
{:noreply, refetch_and_reassign(socket)}
end
def handle_event("resolve", %{"id" => id}, socket) do
ActivityLog.resolve(id)
{:noreply, refetch_and_reassign(socket)}
end
def handle_event("retry_submission", %{"order-id" => order_id, "entry-id" => entry_id}, socket) do
ActivityLog.resolve(entry_id)
case Orders.OrderSubmissionWorker.enqueue(order_id) do
{:ok, _job} ->
{:noreply,
socket
|> put_flash(:info, "Submission retry enqueued")
|> refetch_and_reassign()}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, "Failed to enqueue retry")}
end
end
def handle_event(
"retry_sync",
%{"connection-id" => connection_id, "entry-id" => entry_id},
socket
) do
ActivityLog.resolve(entry_id)
case ProductSyncWorker.enqueue(connection_id) do
{:ok, _job} ->
{:noreply,
socket
|> put_flash(:info, "Sync retry enqueued")
|> refetch_and_reassign()}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, "Failed to enqueue sync")}
end
end
@impl true
def render(assigns) do
~H"""
<.header>
Activity
</.header>
<%!-- tabs --%>
<div class="admin-filter-row">
<button
phx-click="tab"
phx-value-tab="all"
class={[
"admin-btn admin-btn-sm",
@tab == "all" && "admin-btn-primary",
@tab != "all" && "admin-btn-ghost"
]}
>
All
</button>
<button
phx-click="tab"
phx-value-tab="attention"
class={[
"admin-btn admin-btn-sm",
@tab == "attention" && "admin-btn-primary",
@tab != "attention" && "admin-btn-ghost"
]}
>
Needs attention
<span
:if={@attention_count > 0}
class="admin-badge admin-badge-sm admin-badge-warning admin-badge-count"
>
{@attention_count}
</span>
</button>
</div>
<%!-- category chips + search --%>
<div class="admin-filter-row admin-filter-row-between">
<div class="admin-cluster">
<.category_chip category={nil} active={@category} label="All" />
<.category_chip category="orders" active={@category} label="Orders" />
<.category_chip category="syncs" active={@category} label="Syncs" />
<.category_chip category="emails" active={@category} label="Emails" />
<.category_chip category="carts" active={@category} label="Carts" />
<label class="admin-toggle-label admin-text-secondary">
<input
type="checkbox"
class="admin-toggle admin-toggle-sm"
checked={@show_system}
phx-click="toggle_system"
/> System events
</label>
</div>
<.form
for={%{}}
phx-submit="search"
as={:search}
class="admin-row"
>
<input
type="text"
name="search[query]"
value={@search}
placeholder="Search by order number"
class="admin-input admin-input-sm"
/>
<button type="submit" class="admin-btn admin-btn-sm admin-btn-ghost">
<.icon name="hero-magnifying-glass-mini" class="size-4" />
</button>
</.form>
</div>
<%!-- entries --%>
<div id="activity-entries" phx-update="stream">
<div id="activity-empty" class="admin-stream-empty">
No activity to show.
</div>
<div :for={{dom_id, entry} <- @streams.entries} id={dom_id} class="admin-activity-row">
<div class={["admin-activity-icon", activity_icon_class(entry.level)]}>
<.icon name={activity_icon(entry.level)} class="size-4" />
</div>
<div class="admin-activity-body">
<span class="admin-activity-type">{format_event_type(entry.event_type)}</span>
<span>{entry.message}</span>
<.link
:if={entry.order_id}
navigate={~p"/admin/orders/#{entry.order_id}"}
class="admin-activity-link"
>
View order &rarr;
</.link>
</div>
<div class="admin-activity-meta">
<time class="admin-activity-time" datetime={DateTime.to_iso8601(entry.occurred_at)}>
{relative_time(entry.occurred_at)}
</time>
<.entry_action entry={entry} />
</div>
</div>
</div>
<.admin_pagination
page={@pagination}
patch={~p"/admin/activity"}
params={build_params(tab: @tab, category: @category, search: @search)}
/>
"""
end
# ── Components ──
defp category_chip(assigns) do
active = assigns.category == assigns.active
assigns = assign(assigns, :active, active)
~H"""
<button
phx-click="category"
phx-value-category={@category || ""}
class={[
"admin-btn admin-btn-xs",
@active && "admin-btn-primary",
!@active && "admin-btn-ghost"
]}
>
{@label}
</button>
"""
end
defp entry_action(
%{entry: %{event_type: "order.submission_failed", order_id: order_id}} = assigns
)
when not is_nil(order_id) do
~H"""
<button
phx-click="retry_submission"
phx-value-order-id={@entry.order_id}
phx-value-entry-id={@entry.id}
class="admin-btn admin-btn-xs admin-btn-primary"
>
Retry submission
</button>
"""
end
defp entry_action(%{entry: %{event_type: "sync.failed", payload: payload}} = assigns) do
connection_id = payload["connection_id"] || payload[:connection_id]
assigns = assign(assigns, :connection_id, connection_id)
~H"""
<button
:if={@connection_id}
phx-click="retry_sync"
phx-value-connection-id={@connection_id}
phx-value-entry-id={@entry.id}
class="admin-btn admin-btn-xs admin-btn-primary"
>
Retry sync
</button>
<button
:if={!@connection_id}
phx-click="resolve"
phx-value-id={@entry.id}
class="admin-btn admin-btn-xs admin-btn-ghost"
>
Dismiss
</button>
"""
end
defp entry_action(%{entry: %{event_type: "job.exhausted", payload: payload}} = assigns) do
# If the exhausted job was an order submission worker and we have an order_id, offer retry
worker = payload["worker"] || payload[:worker] || ""
order_related = String.contains?(worker, "OrderSubmission")
assigns = assign(assigns, :order_related, order_related)
~H"""
<button
:if={@order_related && @entry.order_id}
phx-click="retry_submission"
phx-value-order-id={@entry.order_id}
phx-value-entry-id={@entry.id}
class="admin-btn admin-btn-xs admin-btn-primary"
>
Retry submission
</button>
<button
:if={!@order_related || !@entry.order_id}
phx-click="resolve"
phx-value-id={@entry.id}
class="admin-btn admin-btn-xs admin-btn-ghost"
>
Dismiss
</button>
"""
end
defp entry_action(%{entry: %{level: level, resolved_at: nil}} = assigns)
when level in ["warning", "error"] do
~H"""
<button
phx-click="resolve"
phx-value-id={@entry.id}
class="admin-btn admin-btn-xs admin-btn-ghost"
>
Dismiss
</button>
"""
end
defp entry_action(assigns) do
~H""
end
# ── Helpers ──
defp activity_icon("error"), do: "hero-x-circle-mini"
defp activity_icon("warning"), do: "hero-exclamation-triangle-mini"
defp activity_icon(_), do: "hero-check-circle-mini"
defp activity_icon_class("error"), do: "admin-activity-icon-error"
defp activity_icon_class("warning"), do: "admin-activity-icon-warning"
defp activity_icon_class(_), do: "admin-activity-icon-ok"
defp format_event_type(event_type) do
event_type
|> String.replace(".", " ")
|> String.replace("_", " ")
end
defp relative_time(datetime) do
now = DateTime.utc_now()
diff = DateTime.diff(now, datetime, :second)
cond do
diff < 60 -> "just now"
diff < 3600 -> "#{div(diff, 60)}m ago"
diff < 86400 -> "#{div(diff, 3600)}h ago"
diff < 604_800 -> "#{div(diff, 86400)}d ago"
true -> Calendar.strftime(datetime, "%d %b %Y")
end
end
defp matches_filters?(entry, assigns) do
tab_match =
case assigns.tab do
"attention" -> entry.level in ["warning", "error"] and is_nil(entry.resolved_at)
_ -> true
end
category_match =
case assigns.category do
"orders" -> String.starts_with?(entry.event_type, "order.")
"syncs" -> String.starts_with?(entry.event_type, "sync.")
"emails" -> String.contains?(entry.event_type, ".email.")
"carts" -> String.starts_with?(entry.event_type, "abandoned_cart.")
_ -> true
end
system_match =
if assigns.show_system, do: true, else: !ActivityLog.system_event_type?(entry.event_type)
tab_match and category_match and system_match
end
defp build_params(opts) do
%{}
|> then(fn p ->
if opts[:tab] && opts[:tab] != "all", do: Map.put(p, "tab", opts[:tab]), else: p
end)
|> then(fn p -> if opts[:category], do: Map.put(p, "category", opts[:category]), else: p end)
|> then(fn p -> if opts[:search], do: Map.put(p, "search", opts[:search]), else: p end)
end
defp refetch_page(assigns) do
ActivityLog.list_recent(
page: assigns.pagination.page,
tab: assigns.tab,
category: assigns.category,
search: assigns.search,
hide_system: !assigns.show_system
)
end
defp refetch_and_reassign(socket) do
page = refetch_page(socket.assigns)
socket
|> assign(:pagination, page)
|> assign(:attention_count, ActivityLog.count_needing_attention())
|> stream(:entries, page.items, reset: true)
end
end