defmodule PgFlowDashboard.Live.RunsLive.Show do
@moduledoc """
Run detail page with step states and dependency graph.
"""
use Phoenix.LiveView
alias PgFlowDashboard.Components.{
DependencyGraph,
GanttTimeline,
Layouts,
ProgressBar,
StatusBadge
}
alias PgFlowDashboard.Live.LiveHelpers
alias PgFlowDashboard.Queries.{Flows, Runs}
@impl true
def mount(%{"id" => run_id}, session, socket) do
{:cont, socket} = LiveHelpers.on_mount(session, socket)
socket =
socket
|> assign(:page_title, "Run Details")
|> assign(:base_path, session["base_path"] || "/pgflow")
|> assign(:run_id, run_id)
|> assign(:selected_step, nil)
|> assign(:step_tasks, [])
|> load_run()
|> load_step_states()
|> load_flow_steps()
if socket.assigns.run do
socket =
socket
|> LiveHelpers.subscribe_to_run(run_id)
|> LiveHelpers.schedule_refresh()
{:ok, socket}
else
{:ok, push_navigate(socket, to: "#{socket.assigns.base_path}/runs")}
end
end
@impl true
def handle_params(%{"step" => step_slug}, _uri, socket) do
# Pre-select step from query parameter (e.g., from run history grid click)
tasks = Runs.list_step_tasks(socket.assigns.repo, socket.assigns.run_id, step_slug)
socket =
socket
|> assign(:selected_step, step_slug)
|> assign(:step_tasks, tasks)
{:noreply, socket}
end
@impl true
def handle_params(_params, _uri, socket), do: {:noreply, socket}
@impl true
def handle_info(:refresh, socket) do
socket =
socket
|> load_run()
|> load_step_states()
if socket.assigns.run && socket.assigns.run.status == "started" do
{:noreply, LiveHelpers.schedule_refresh(socket)}
else
{:noreply, socket}
end
end
@impl true
def handle_info({:pgflow, _run_id, {:task_started, _}}, socket) do
{:noreply, load_step_states(socket)}
end
@impl true
def handle_info({:pgflow, _run_id, {:task_completed, _}}, socket) do
socket =
socket
|> load_run()
|> load_step_states()
{:noreply, socket}
end
@impl true
def handle_info({:pgflow, _run_id, {:task_failed, _}}, socket) do
socket =
socket
|> load_run()
|> load_step_states()
{:noreply, socket}
end
@impl true
def handle_info({:pgflow, _run_id, {:run_completed, _}}, socket) do
socket =
socket
|> load_run()
|> load_step_states()
{:noreply, socket}
end
@impl true
def handle_info({:pgflow, _run_id, {:run_failed, _}}, socket) do
socket =
socket
|> load_run()
|> load_step_states()
{:noreply, socket}
end
@impl true
def handle_info(_, socket), do: {:noreply, socket}
@impl true
def handle_event("select_step", %{"step" => step_slug}, socket) do
socket =
if socket.assigns.selected_step == step_slug do
# Deselect if clicking the same step
socket
|> assign(:selected_step, nil)
|> assign(:step_tasks, [])
else
# Select the step and load its tasks
tasks = Runs.list_step_tasks(socket.assigns.repo, socket.assigns.run_id, step_slug)
socket
|> assign(:selected_step, step_slug)
|> assign(:step_tasks, tasks)
end
{:noreply, socket}
end
@impl true
def handle_event("clear_selection", _, socket) do
socket =
socket
|> assign(:selected_step, nil)
|> assign(:step_tasks, [])
{:noreply, socket}
end
@impl true
def handle_event("next_step", _, socket) do
{:noreply, navigate_step(socket, :next)}
end
@impl true
def handle_event("prev_step", _, socket) do
{:noreply, navigate_step(socket, :prev)}
end
@impl true
def handle_event("handle_keydown", %{"key" => "j"}, socket) do
{:noreply, navigate_step(socket, :next)}
end
@impl true
def handle_event("handle_keydown", %{"key" => "k"}, socket) do
{:noreply, navigate_step(socket, :prev)}
end
@impl true
def handle_event("handle_keydown", %{"key" => "Escape"}, socket) do
socket =
socket
|> assign(:selected_step, nil)
|> assign(:step_tasks, [])
{:noreply, socket}
end
@impl true
def handle_event("handle_keydown", %{"key" => "]"}, socket) do
{:noreply, navigate_to_adjacent_run(socket, :next)}
end
@impl true
def handle_event("handle_keydown", %{"key" => "["}, socket) do
{:noreply, navigate_to_adjacent_run(socket, :prev)}
end
@impl true
def handle_event("handle_keydown", _, socket), do: {:noreply, socket}
defp navigate_to_adjacent_run(socket, direction) do
case Runs.get_adjacent_run(socket.assigns.repo, socket.assigns.run_id, direction) do
{:ok, adjacent_run_id} ->
push_navigate(socket, to: "#{socket.assigns.base_path}/runs/#{adjacent_run_id}")
{:error, :not_found} ->
socket
end
end
defp navigate_step(socket, direction) do
step_slugs = Enum.map(socket.assigns.step_states, & &1.step_slug)
case {socket.assigns.selected_step, step_slugs} do
{nil, [first | _]} when direction == :next ->
select_step(socket, first)
{nil, slugs} when direction == :prev ->
select_step(socket, List.last(slugs))
{current, slugs} ->
current_idx = Enum.find_index(slugs, &(&1 == current))
new_idx =
case direction do
:next -> min(current_idx + 1, length(slugs) - 1)
:prev -> max(current_idx - 1, 0)
end
new_step = Enum.at(slugs, new_idx)
select_step(socket, new_step)
end
end
defp select_step(socket, step_slug) do
tasks = Runs.list_step_tasks(socket.assigns.repo, socket.assigns.run_id, step_slug)
socket
|> assign(:selected_step, step_slug)
|> assign(:step_tasks, tasks)
end
defp load_run(socket) do
case Runs.get_run(socket.assigns.repo, socket.assigns.run_id) do
{:ok, run} -> assign(socket, :run, run)
{:error, _} -> assign(socket, :run, nil)
end
end
defp load_step_states(socket) do
states = Runs.list_step_states(socket.assigns.repo, socket.assigns.run_id)
state_map = Map.new(states, fn s -> {s.step_slug, s.status} end)
socket
|> assign(:step_states, states)
|> assign(:step_state_map, state_map)
end
defp load_flow_steps(socket) do
if socket.assigns.run do
case Flows.get_flow_with_graph(socket.assigns.repo, socket.assigns.run.flow_slug) do
{:ok, flow} -> assign(socket, :flow_steps, flow.steps)
{:error, _} -> assign(socket, :flow_steps, [])
end
else
assign(socket, :flow_steps, [])
end
end
@impl true
def render(assigns) do
~H"""
<Layouts.dashboard_layout current_page={:runs} base_path={@base_path}>
<div :if={@run} phx-window-keydown="handle_keydown">
<!-- Header -->
<div class="mb-6">
<.link navigate={"#{@base_path}/runs"} class="text-sm text-slate-500 hover:text-slate-700 dark:text-slate-400 mb-2 inline-block">
← Back to runs
</.link>
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-slate-900 dark:text-white flex items-center gap-3">
{@run.flow_slug}
<StatusBadge.status_badge status={@run.status} pulse={@run.status == "started"} />
</h1>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400 font-mono">{@run.run_id}</p>
</div>
</div>
</div>
<!-- Progress and Timing -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-4">
<p class="text-sm text-slate-500 dark:text-slate-400 mb-2">Progress</p>
<ProgressBar.progress_bar
progress={@run.progress_percent}
completed={@run.completed_steps}
total={@run.total_steps}
failed={@run.failed_steps}
/>
</div>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-4">
<p class="text-sm text-slate-500 dark:text-slate-400">Duration</p>
<p class="text-2xl font-semibold text-slate-900 dark:text-white">{LiveHelpers.format_duration(@run.duration_ms)}</p>
</div>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-4">
<p class="text-sm text-slate-500 dark:text-slate-400">Started</p>
<p class="text-sm font-medium text-slate-900 dark:text-white">{LiveHelpers.format_timestamp(@run.started_at, @time_zone)}</p>
</div>
</div>
<!-- Workflow (full width) -->
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 p-4 mb-6">
<h2 class="text-sm font-semibold text-slate-900 dark:text-white mb-4">Workflow</h2>
<p class="text-xs text-slate-500 dark:text-slate-400 mb-3">Click a node to view its output</p>
<DependencyGraph.dependency_graph
steps={@flow_steps}
step_states={@step_state_map}
highlighted_step={@selected_step}
on_click="select_step"
/>
</div>
<!-- Step States + Gantt Timeline (side by side) -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Step States -->
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700">
<div class="px-4 py-3 border-b border-slate-200 dark:border-slate-700">
<h2 class="text-sm font-semibold text-slate-900 dark:text-white">Step States</h2>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1">Click a step to view its output</p>
</div>
<div class="divide-y divide-slate-200 dark:divide-slate-700 max-h-64 overflow-y-auto">
<%= if @step_states == [] do %>
<div class="px-4 py-8 text-center text-slate-500 dark:text-slate-400 text-sm">
No step states yet
</div>
<% else %>
<%= for state <- @step_states do %>
<div
phx-click="select_step"
phx-value-step={state.step_slug}
class={[
"px-4 py-3 cursor-pointer transition-colors",
@selected_step == state.step_slug && "bg-purple-50 dark:bg-purple-900/20 border-l-2 border-l-purple-500 !border-b-transparent",
@selected_step != state.step_slug && "hover:bg-slate-50 dark:hover:bg-slate-700/50"
]}
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<StatusBadge.status_badge status={state.status} size={:sm} pulse={state.status == "started"} />
<span class="text-sm font-medium text-slate-900 dark:text-white">{state.step_slug}</span>
</div>
<span class="text-xs text-slate-500 dark:text-slate-400">
{LiveHelpers.format_duration(state.duration_ms)}
</span>
</div>
<div :if={state.total_tasks > 0} class="mt-2 text-xs text-slate-500 dark:text-slate-400">
Tasks: {state.completed_tasks}/{state.total_tasks}
<span :if={state.failed_tasks > 0} class="text-rose-500">({state.failed_tasks} failed)</span>
</div>
</div>
<% end %>
<% end %>
</div>
</div>
<!-- Gantt Timeline -->
<GanttTimeline.gantt_timeline run={@run} step_states={@step_states} />
</div>
<!-- Input/Output -->
<div class="mt-6">
<!-- Header with context indicator -->
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<h2 class="text-sm font-semibold text-slate-900 dark:text-white">
<%= if @selected_step do %>
Step: {@selected_step}
<% else %>
Run Data
<% end %>
</h2>
<span :if={@selected_step} class="text-xs bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 px-2 py-0.5 rounded">
Step selected
</span>
</div>
<button
:if={@selected_step}
phx-click="clear_selection"
class="text-xs text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
>
Show run data
</button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700">
<div class="px-4 py-3 border-b border-slate-200 dark:border-slate-700">
<h2 class="text-sm font-semibold text-slate-900 dark:text-white">Input</h2>
</div>
<div class="p-4">
<%= if @selected_step do %>
<.step_input_display step_slug={@selected_step} step_states={@step_states} run={@run} />
<% else %>
<pre class="text-xs text-slate-700 dark:text-slate-300 bg-slate-50 dark:bg-slate-900 rounded p-3 overflow-x-auto max-h-96"><%= format_json(@run.input) %></pre>
<% end %>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700">
<div class="px-4 py-3 border-b border-slate-200 dark:border-slate-700">
<h2 class="text-sm font-semibold text-slate-900 dark:text-white">Output</h2>
</div>
<div class="p-4">
<%= if @selected_step && @step_tasks != [] do %>
<div class="space-y-3">
<%= for task <- @step_tasks do %>
<div class="border-l-2 border-slate-300 dark:border-slate-600 pl-3">
<p class="text-xs text-slate-500 dark:text-slate-400 mb-1">Task {task.task_index}</p>
<pre class="text-xs text-slate-700 dark:text-slate-300 bg-slate-50 dark:bg-slate-900 rounded p-3 overflow-x-auto max-h-64"><%= format_json(task.output) %></pre>
<p :if={task.error_message} class="text-xs text-rose-500 mt-2">Error: {task.error_message}</p>
</div>
<% end %>
</div>
<% else %>
<pre class="text-xs text-slate-700 dark:text-slate-300 bg-slate-50 dark:bg-slate-900 rounded p-3 overflow-x-auto max-h-96"><%= format_json(@run.output) %></pre>
<% end %>
</div>
</div>
</div>
</div>
</div>
</Layouts.dashboard_layout>
"""
end
# Component to show step input (based on dependencies)
defp step_input_display(assigns) do
# Find the step's dependencies
step_state = Enum.find(assigns.step_states, fn s -> s.step_slug == assigns.step_slug end)
deps = if step_state, do: step_state[:deps] || [], else: []
assigns = assign(assigns, :deps, deps)
~H"""
<div class="space-y-3">
<%= if @deps == [] do %>
<p class="text-xs text-slate-500 dark:text-slate-400 italic">
This step has no dependencies - it receives the run input directly.
</p>
<pre class="text-xs text-slate-700 dark:text-slate-300 bg-slate-50 dark:bg-slate-900 rounded p-3 overflow-x-auto max-h-64"><%= format_json(@run.input) %></pre>
<% else %>
<p class="text-xs text-slate-500 dark:text-slate-400 italic mb-2">
Input comes from dependencies: {Enum.join(@deps, ", ")}
</p>
<p class="text-xs text-slate-400 dark:text-slate-500">
(Click on a dependency step to see its output)
</p>
<% end %>
</div>
"""
end
defp format_json(nil), do: "-"
defp format_json(data) when is_map(data) or is_list(data) do
Jason.encode!(data, pretty: true)
rescue
_ -> inspect(data)
end
defp format_json(data), do: inspect(data)
end