run view: tick elapsed-time counters live while a run is active

5a6d751 · gmorell · 2026-07-02 19:01

1 files +46 -10

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