defmodule PgFlowDashboard.Live.RunsLive.Index do
@moduledoc """
Runs list page with LiveFilter-based filtering.
Uses URL-driven filters for shareable links and Ecto queries via QueryBuilder.
"""
use Phoenix.LiveView
alias LiveFilter.{Pagination, Params.Serializer, QueryBuilder}
alias PgFlowDashboard.Components.{Layouts, ProgressBar, StatusBadge, TypeBadge}
alias PgFlowDashboard.Live.LiveHelpers
alias PgFlowDashboard.Queries.{Crons, Flows, Jobs}
alias PgFlowDashboard.Schemas.Run
defp filter_config(socket) do
[
LiveFilter.select(:flow_type,
label: "Type",
options: [
{"Flow", "flow"},
{"Job", "job"}
],
icon: "hero-squares-2x2",
default_visible: true
),
LiveFilter.select(:flow_slug,
label: "Queue",
options_fn: fn -> queue_options(socket.assigns) end,
icon: "hero-queue-list",
default_visible: true
),
LiveFilter.select(:status,
label: "Status",
options: [
{"Running", "started"},
{"Completed", "completed"},
{"Failed", "failed"}
],
icon: "hero-signal",
default_visible: true
),
LiveFilter.datetime_range(:started_at,
label: "Started",
icon: "hero-calendar-days",
default_visible: true
)
]
end
defp queue_options(assigns) do
flows = Map.get(assigns, :flows, [])
jobs = Map.get(assigns, :jobs, [])
crons = Map.get(assigns, :crons, [])
flow_opts = Enum.map(flows, fn f -> {f.flow_slug, f.flow_slug} end)
job_opts = Enum.map(jobs, fn j -> {j.flow_slug, j.flow_slug} end)
cron_opts = Enum.map(crons, fn c -> {c.flow_slug, c.flow_slug} end)
(flow_opts ++ job_opts ++ cron_opts)
|> Enum.uniq_by(fn {_, slug} -> slug end)
|> Enum.sort_by(fn {label, _} -> label end)
end
@impl true
def mount(_params, session, socket) do
{:cont, socket} = LiveHelpers.on_mount(session, socket)
socket =
socket
|> assign(:page_title, "Runs")
|> assign(:base_path, session["base_path"] || "/pgflow")
|> load_flows_and_jobs()
|> LiveHelpers.subscribe_to_updates()
|> LiveHelpers.schedule_refresh()
{:ok, socket}
end
@impl true
def handle_params(params, _uri, socket) do
config = filter_config(socket)
# Redirect to default date range if no started_at filter
if missing_date_filter?(params) do
default_params = default_date_params()
{:noreply,
push_patch(socket,
to: LiveFilter.to_path("#{socket.assigns.base_path}/runs", default_params)
)}
else
{filters, remaining} = LiveFilter.from_params(params, config)
{pagination, remaining} = LiveFilter.pagination_from_params(remaining, default_limit: 50)
socket =
socket
|> LiveFilter.init(config, filters)
|> assign(:pagination, pagination)
|> assign(:remaining_params, remaining)
|> load_runs()
{:noreply, socket}
end
end
@impl true
def handle_info(
{:livefilter, :updated, params},
%{assigns: %{remaining_params: remaining_params, pagination: %{limit: limit}}} = socket
) do
pagination_params = %{"limit" => to_string(limit), "offset" => "0"}
all_params = Map.merge(remaining_params, params) |> Map.merge(pagination_params)
{:noreply,
push_patch(socket, to: LiveFilter.to_path("#{socket.assigns.base_path}/runs", all_params))}
end
@impl true
def handle_info(
{:livefilter, :page_changed, pagination_params},
%{assigns: %{remaining_params: remaining_params, livefilter: %{filters: filters}}} =
socket
) do
filter_params = Serializer.to_params(filters)
all_params = Map.merge(remaining_params, filter_params) |> Map.merge(pagination_params)
{:noreply,
push_patch(socket, to: LiveFilter.to_path("#{socket.assigns.base_path}/runs", all_params))}
end
@impl true
def handle_info(:refresh, socket) do
socket =
socket
|> load_runs()
|> LiveHelpers.schedule_refresh()
{:noreply, socket}
end
@impl true
def handle_info({:pgflow, _run_id, {:run_started, _}}, socket),
do: {:noreply, load_runs(socket)}
@impl true
def handle_info({:pgflow, _run_id, {:run_completed, _}}, socket),
do: {:noreply, load_runs(socket)}
@impl true
def handle_info({:pgflow, _run_id, {:run_failed, _}}, socket),
do: {:noreply, load_runs(socket)}
@impl true
def handle_info(_, socket), do: {:noreply, socket}
defp load_flows_and_jobs(socket) do
flows = Flows.list_flows(socket.assigns.repo)
jobs = Jobs.list_jobs(socket.assigns.repo)
crons = Crons.list_crons(socket.assigns.repo)
socket
|> assign(:flows, flows)
|> assign(:jobs, jobs)
|> assign(:crons, crons)
end
defp load_runs(%{assigns: %{pagination: pagination, livefilter: %{filters: filters}}} = socket) do
import Ecto.Query
base_query =
Run
|> QueryBuilder.apply(filters,
schema: Run,
allowed_fields: [:flow_type, :flow_slug, :status, :started_at]
)
|> order_by([r], desc: r.started_at)
total_count = QueryBuilder.count(base_query, socket.assigns.repo)
runs =
base_query
|> QueryBuilder.apply_pagination(pagination)
|> socket.assigns.repo.all()
pagination = Pagination.with_total(pagination, total_count)
socket
|> assign(:runs, runs)
|> assign(:pagination, pagination)
end
# Fallback when socket not yet initialized (PubSub messages before handle_params)
defp load_runs(socket), do: socket
defp missing_date_filter?(params) do
has_legacy_range =
Map.has_key?(params, "started_at.gte") || Map.has_key?(params, "started_at.lte")
has_and_range =
case Map.get(params, "and") do
and_param when is_binary(and_param) ->
String.contains?(and_param, "started_at.gte.") ||
String.contains?(and_param, "started_at.lte.")
_ ->
false
end
!(has_legacy_range || has_and_range)
end
defp default_date_params do
today = Date.utc_today()
start_of_day = DateTime.new!(today, ~T[00:00:00], "Etc/UTC")
end_of_day = DateTime.new!(today, ~T[23:59:59], "Etc/UTC")
%{
"and" =>
"(started_at.gte.#{DateTime.to_iso8601(start_of_day)},started_at.lte.#{DateTime.to_iso8601(end_of_day)})",
"limit" => "50",
"offset" => "0"
}
end
@impl true
def render(assigns) do
~H"""
<Layouts.dashboard_layout current_page={:runs} base_path={@base_path}>
<Layouts.page_header title="Runs" subtitle="All workflow executions" />
<div class="mb-6 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-4">
<LiveFilter.bar filter={@livefilter} />
</div>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="px-4 py-2 border-b border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50 flex items-center justify-between">
<span class="text-sm text-slate-500 dark:text-slate-400">
Showing {length(@runs)} of {@pagination.total_count} runs
</span>
</div>
<table class="min-w-full divide-y divide-slate-200 dark:divide-slate-700">
<thead class="bg-slate-50 dark:bg-slate-800/50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Run ID</th>
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Queue</th>
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Status</th>
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Progress</th>
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Duration</th>
<th class="px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-400 uppercase">Started</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
<tr :if={@runs == []} id="runs-empty">
<td colspan="6" class="px-4 py-8 text-center text-slate-500 dark:text-slate-400">
No runs found
</td>
</tr>
<tr
:for={run <- @runs}
id={"run-#{run.run_id}"}
class="hover:bg-slate-50 dark:hover:bg-slate-700/50"
>
<td class="px-4 py-3">
<.link
navigate={"#{@base_path}/runs/#{run.run_id}"}
class="text-sm font-mono text-purple-600 hover:text-purple-700 dark:text-purple-400"
>
{LiveHelpers.short_id(run.run_id)}
</.link>
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<span class="text-sm text-slate-700 dark:text-slate-300">{run.flow_slug}</span>
<TypeBadge.type_badge type={run.flow_type} />
</div>
</td>
<td class="px-4 py-3">
<StatusBadge.status_badge
status={run.status}
size={:sm}
pulse={run.status == "started"}
/>
</td>
<td class="px-4 py-3 w-32">
<%= if run.flow_type in ["job", "cron"] do %>
<span class="text-sm text-slate-400 dark:text-slate-500">—</span>
<% else %>
<ProgressBar.progress_bar
progress={run.progress_percent}
completed={run.completed_steps}
total={run.total_steps}
failed={run.failed_steps}
size={:sm}
/>
<% end %>
</td>
<td class="px-4 py-3 text-sm text-slate-500 dark:text-slate-400">
{LiveHelpers.format_duration(run.duration_ms)}
</td>
<td class="px-4 py-3 text-sm text-slate-500 dark:text-slate-400">
{LiveHelpers.format_timestamp(run.started_at, @time_zone)}
</td>
</tr>
</tbody>
</table>
<div class="px-4 py-3 border-t border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50">
<LiveFilter.paginator pagination={@pagination} />
</div>
</div>
</Layouts.dashboard_layout>
"""
end
end