run view: tick elapsed-time counters live while a run is active
5a6d751 · gmorell · 2026-07-02 19:01
Files changed
modified
lib/git_gud_web/live/workflow_live/show.ex
+46
−10
@@ -29,11 +29,29 @@ defmodule GitGudWeb.WorkflowLive.Show do
| 29 | 29 | |> assign(:expanded_step, nil) |
| 30 | 30 | |> assign(:can_admin?, user && admin?(user, repo)) |
| 31 | 31 | |> assign(:page_title, "Run ##{run.number}") |
| 32 | + |> assign(:now, DateTime.utc_now()) | |
| 33 | + |> assign(:ticking?, false) | |
| 32 | 34 | |> seed_step_streams(run) |
| 35 | + |> ensure_ticking() | |
| 33 | 36 | |
| 34 | 37 | {:ok, socket} |
| 35 | 38 | end |
| 36 | 39 | |
| 40 | + # Drive a 1-second @now tick while the run is active so the elapsed | |
| 41 | + # counters on running jobs/steps update live. Self-perpetuates until the | |
| 42 | + # run is terminal; re-armed from the run-updating handlers (e.g. a | |
| 43 | + # per-job re-run flips the run back to `running`). | |
| 44 | + defp ensure_ticking(socket) do | |
| 45 | + if connected?(socket) and run_active?(socket.assigns.run) and not socket.assigns.ticking? do | |
| 46 | + Process.send_after(self(), :tick, 1000) | |
| 47 | + assign(socket, :ticking?, true) | |
| 48 | + else | |
| 49 | + socket | |
| 50 | + end | |
| 51 | + end | |
| 52 | + | |
| 53 | + defp run_active?(%{status: s}), do: s in ~w(queued running) | |
| 54 | + | |
| 37 | 55 | defp admin?(user, %{owner_id: uid, organization_id: nil}), do: user.id == uid |
| 38 | 56 | |
| 39 | 57 | defp admin?(user, %{organization: %{} = org}), |
@@ -98,7 +116,8 @@ defmodule GitGudWeb.WorkflowLive.Show do
| 98 | 116 | {:noreply, |
| 99 | 117 | socket |
| 100 | 118 | |> assign(:run, run) |
| 101 | − |> assign(:artifacts, Workflows.list_artifacts(run))} | |
| 119 | + |> assign(:artifacts, Workflows.list_artifacts(run)) | |
| 120 | + |> ensure_ticking()} | |
| 102 | 121 | end |
| 103 | 122 | |
| 104 | 123 | # A step changed state (runner reported a new StepState). Re-read the |
@@ -108,7 +127,7 @@ defmodule GitGudWeb.WorkflowLive.Show do
| 108 | 127 | # thrash. |
| 109 | 128 | def handle_info({:workflow_step_status, _step}, socket) do |
| 110 | 129 | run = Workflows.get_run!(socket.assigns.repo, socket.assigns.run.number) |
| 111 | − {:noreply, assign(socket, :run, run)} | |
| 130 | + {:noreply, socket |> assign(:run, run) |> ensure_ticking()} | |
| 112 | 131 | end |
| 113 | 132 | |
| 114 | 133 | # The run reached a terminal state — re-read so the status badge flips |
@@ -119,7 +138,19 @@ defmodule GitGudWeb.WorkflowLive.Show do
| 119 | 138 | {:noreply, |
| 120 | 139 | socket |
| 121 | 140 | |> assign(:run, run) |
| 122 | − |> assign(:artifacts, Workflows.list_artifacts(run))} | |
| 141 | + |> assign(:artifacts, Workflows.list_artifacts(run)) | |
| 142 | + |> ensure_ticking()} | |
| 143 | + end | |
| 144 | + | |
| 145 | + def handle_info(:tick, socket) do | |
| 146 | + socket = assign(socket, :now, DateTime.utc_now()) | |
| 147 | + | |
| 148 | + if run_active?(socket.assigns.run) do | |
| 149 | + Process.send_after(self(), :tick, 1000) | |
| 150 | + {:noreply, socket} | |
| 151 | + else | |
| 152 | + {:noreply, assign(socket, :ticking?, false)} | |
| 153 | + end | |
| 123 | 154 | end |
| 124 | 155 | |
| 125 | 156 | # Defensive catch-all: ignore any other broadcast on the run topic |
@@ -191,7 +222,8 @@ defmodule GitGudWeb.WorkflowLive.Show do
| 191 | 222 | {:noreply, |
| 192 | 223 | socket |
| 193 | 224 | |> put_flash(:info, "Re-running job #{job_id}.") |
| 194 | − |> assign(:run, run)} | |
| 225 | + |> assign(:run, run) | |
| 226 | + |> ensure_ticking()} | |
| 195 | 227 | |
| 196 | 228 | {:error, reason} -> |
| 197 | 229 | {:noreply, put_flash(socket, :error, "Could not re-run job: #{inspect(reason)}")} |
@@ -253,7 +285,7 @@ defmodule GitGudWeb.WorkflowLive.Show do
| 253 | 285 | · Finished {format_dt(@run.completed_at)} |
| 254 | 286 | </span> |
| 255 | 287 | <span :if={@run.started_at} class="ml-2"> |
| 256 | − · {duration(@run.started_at, @run.completed_at)} | |
| 288 | + · {duration(@run.started_at, @run.completed_at, @now)} | |
| 257 | 289 | </span> |
| 258 | 290 | </p> |
| 259 | 291 | </div> |
@@ -292,7 +324,7 @@ defmodule GitGudWeb.WorkflowLive.Show do
| 292 | 324 | <header class="flex items-baseline justify-between gap-3"> |
| 293 | 325 | <h2 class="font-semibold font-mono">{job.name || job.job_id}</h2> |
| 294 | 326 | <div class="flex items-baseline gap-2 text-xs opacity-60"> |
| 295 | − <span :if={job.started_at}>{duration(job.started_at, job.completed_at)}</span> | |
| 327 | + <span :if={job.started_at}>{duration(job.started_at, job.completed_at, @now)}</span> | |
| 296 | 328 | <span class={["badge badge-sm", status_class(job.status)]}>{job.status}</span> |
| 297 | 329 | <button |
| 298 | 330 | :if={@can_admin? and job.status in ["failure", "cancelled", "success", "skipped"]} |
@@ -318,7 +350,7 @@ defmodule GitGudWeb.WorkflowLive.Show do
| 318 | 350 | <span class="opacity-60 text-xs w-6">{step.number}</span> |
| 319 | 351 | <span class="flex-1 text-left font-mono">{step.name || "(unnamed)"}</span> |
| 320 | 352 | <span :if={step.started_at} class="text-xs opacity-50"> |
| 321 | − {duration(step.started_at, step.completed_at)} | |
| 353 | + {duration(step.started_at, step.completed_at, @now)} | |
| 322 | 354 | </span> |
| 323 | 355 | <span class={["badge badge-xs", status_class(step.status)]}>{step.status}</span> |
| 324 | 356 | <.icon |
@@ -378,10 +410,14 @@ defmodule GitGudWeb.WorkflowLive.Show do
| 378 | 410 | defp format_size(b) when b < 1_048_576, do: "#{Float.round(b / 1024, 1)} KB" |
| 379 | 411 | defp format_size(b), do: "#{Float.round(b / 1_048_576, 1)} MB" |
| 380 | 412 | |
| 381 | − defp duration(nil, _), do: "" | |
| 382 | − defp duration(start, nil), do: "for #{seconds_to_human(DateTime.diff(DateTime.utc_now(), start))}" | |
| 413 | + # `now` is the ticking @now assign so LiveView re-evaluates the elapsed | |
| 414 | + # counter each second while a job/step is still running. | |
| 415 | + defp duration(nil, _finish, _now), do: "" | |
| 416 | + | |
| 417 | + defp duration(start, nil, now), | |
| 418 | + do: "for #{seconds_to_human(DateTime.diff(now, start))}" | |
| 383 | 419 | |
| 384 | − defp duration(start, %DateTime{} = finish), | |
| 420 | + defp duration(start, %DateTime{} = finish, _now), | |
| 385 | 421 | do: "in #{seconds_to_human(DateTime.diff(finish, start))}" |
| 386 | 422 | |
| 387 | 423 | defp seconds_to_human(secs) when secs < 0, do: "0s" |
Parents: e499575