run view: parallel-stage job layout + live update on run completion
d41fffb · gmorell · 2026-06-26 14:53
Message
{commit_body(@commit)}
Files changed
modified
lib/git_gud/workflows.ex
+11
−0
@@ -959,6 +959,7 @@ defmodule GitGud.Workflows do
| 959 | 959 | |> Repo.update!() |
| 960 | 960 | |
| 961 | 961 | update_run_commit_status(run) |
| 962 | + broadcast_run_status(run) | |
| 962 | 963 | |
| 963 | 964 | true -> |
| 964 | 965 | run = |
@@ -968,10 +969,20 @@ defmodule GitGud.Workflows do
| 968 | 969 | |
| 969 | 970 | update_run_commit_status(run) |
| 970 | 971 | notify_run_webhook(run) |
| 972 | + broadcast_run_status(run) | |
| 971 | 973 | run |
| 972 | 974 | end |
| 973 | 975 | end |
| 974 | 976 | |
| 977 | + # Notify the run's LiveView that the run reached a terminal state so it | |
| 978 | + # can flip the status badge + show the re-run button without a reload. | |
| 979 | + # (The per-job broadcast races ahead of `maybe_complete_run`, so the | |
| 980 | + # run isn't terminal yet when that fires.) | |
| 981 | + defp broadcast_run_status(%WorkflowRun{id: rid} = run) do | |
| 982 | + Phoenix.PubSub.broadcast(GitGud.PubSub, topic_for_run(rid), {:workflow_run_status, run}) | |
| 983 | + run | |
| 984 | + end | |
| 985 | + | |
| 975 | 986 | # Fire the `workflow_run` webhook for a run that just reached a |
| 976 | 987 | # terminal state. Loads the repo so the fan-out can reach org hooks. |
| 977 | 988 | defp notify_run_webhook(%WorkflowRun{} = run) do |
modified
lib/git_gud_web/live/workflow_live/show.ex
+54
−5
@@ -55,6 +55,34 @@ defmodule GitGudWeb.WorkflowLive.Show do
| 55 | 55 | Enum.find_value(run.jobs, fn job -> Enum.find(job.steps, &(&1.id == id)) end) |
| 56 | 56 | end |
| 57 | 57 | |
| 58 | + # Group jobs into dependency stages from their `needs` DAG: jobs in the | |
| 59 | + # same stage have no dependency between them, so they run in parallel. | |
| 60 | + # Matrix legs share a `job_id` with no `needs`, so they all land in | |
| 61 | + # stage 0 together. | |
| 62 | + defp job_stages(jobs) do | |
| 63 | + needs_by_id = Map.new(jobs, fn j -> {j.job_id, j.needs || []} end) | |
| 64 | + | |
| 65 | + jobs | |
| 66 | + |> Enum.group_by(&job_depth(&1.job_id, needs_by_id, MapSet.new())) | |
| 67 | + |> Enum.sort_by(fn {stage, _} -> stage end) | |
| 68 | + |> Enum.map(fn {_stage, stage_jobs} -> stage_jobs end) | |
| 69 | + end | |
| 70 | + | |
| 71 | + defp job_depth(id, needs_by_id, seen) do | |
| 72 | + if MapSet.member?(seen, id) do | |
| 73 | + 0 | |
| 74 | + else | |
| 75 | + case Map.get(needs_by_id, id, []) do | |
| 76 | + [] -> | |
| 77 | + 0 | |
| 78 | + | |
| 79 | + needs -> | |
| 80 | + seen = MapSet.put(seen, id) | |
| 81 | + 1 + (needs |> Enum.map(&job_depth(&1, needs_by_id, seen)) |> Enum.max(fn -> -1 end)) | |
| 82 | + end | |
| 83 | + end | |
| 84 | + end | |
| 85 | + | |
| 58 | 86 | @impl true |
| 59 | 87 | def handle_info({:workflow_log, step_id, seq, body}, socket) do |
| 60 | 88 | if has_step?(socket, step_id) do |
@@ -83,6 +111,17 @@ defmodule GitGudWeb.WorkflowLive.Show do
| 83 | 111 | {:noreply, assign(socket, :run, run)} |
| 84 | 112 | end |
| 85 | 113 | |
| 114 | + # The run reached a terminal state — re-read so the status badge flips | |
| 115 | + # and the Re-run button appears without a manual reload. | |
| 116 | + def handle_info({:workflow_run_status, _run}, socket) do | |
| 117 | + run = Workflows.get_run!(socket.assigns.repo, socket.assigns.run.number) | |
| 118 | + | |
| 119 | + {:noreply, | |
| 120 | + socket | |
| 121 | + |> assign(:run, run) | |
| 122 | + |> assign(:artifacts, Workflows.list_artifacts(run))} | |
| 123 | + end | |
| 124 | + | |
| 86 | 125 | # Defensive catch-all: ignore any other broadcast on the run topic |
| 87 | 126 | # rather than letting an unhandled message take the whole view down. |
| 88 | 127 | def handle_info(_msg, socket), do: {:noreply, socket} |
@@ -219,10 +258,18 @@ defmodule GitGudWeb.WorkflowLive.Show do
| 219 | 258 | </div> |
| 220 | 259 | </header> |
| 221 | 260 | |
| 222 | − <section class="space-y-4"> | |
| 223 | − <article :for={job <- @run.jobs} class="border border-base-300 rounded p-4"> | |
| 224 | − <header class="flex items-baseline justify-between gap-3"> | |
| 225 | − <h2 class="font-semibold font-mono">{job.job_id}</h2> | |
| 261 | + <section class="space-y-6"> | |
| 262 | + <div :for={stage <- job_stages(@run.jobs)} class="space-y-2"> | |
| 263 | + <div | |
| 264 | + :if={length(stage) > 1} | |
| 265 | + class="text-xs uppercase tracking-wide opacity-50 flex items-center gap-1.5" | |
| 266 | + > | |
| 267 | + <.icon name="hero-bolt" class="size-3" /> {length(stage)} jobs run in parallel | |
| 268 | + </div> | |
| 269 | + <div class={["grid gap-4", length(stage) > 1 && "lg:grid-cols-2"]}> | |
| 270 | + <article :for={job <- stage} class="border border-base-300 rounded p-4"> | |
| 271 | + <header class="flex items-baseline justify-between gap-3"> | |
| 272 | + <h2 class="font-semibold font-mono">{job.name || job.job_id}</h2> | |
| 226 | 273 | <div class="flex items-baseline gap-2 text-xs opacity-60"> |
| 227 | 274 | <span :if={job.started_at}>{duration(job.started_at, job.completed_at)}</span> |
| 228 | 275 | <span class={["badge badge-sm", status_class(job.status)]}>{job.status}</span> |
@@ -260,7 +307,9 @@ defmodule GitGudWeb.WorkflowLive.Show do
| 260 | 307 | ><div :for={{dom_id, chunk} <- @streams[stream_name(step.id)]} id={dom_id}><%= chunk.body %></div></pre> |
| 261 | 308 | </li> |
| 262 | 309 | </ol> |
| 263 | − </article> | |
| 310 | + </article> | |
| 311 | + </div> | |
| 312 | + </div> | |
| 264 | 313 | </section> |
| 265 | 314 | |
| 266 | 315 | <section :if={@artifacts != []} class="space-y-2"> |
Parents: bc2e7a7