CI: a live wall of every running job, tiled to fill the screen

5e0e514 · gmorell · 2026-11-03 15:53

19 files +849 -1
Message
{commit_body(@commit)}

Files changed

modified assets/css/app.css
+55 −0
@@ -521,3 +521,58 @@
521 521 }
522 522
523 523 /* This file is for your main application CSS */
524 +
525 +/* ── Live CI wall (UserLive.CiWall) ─────────────────────────────────── */
526 +
527 +/* The screen below the nav: the navbar is 4rem plus its 1px border. */
528 +.ci-wall { height: calc(100dvh - 4rem - 1px); }
529 +
530 +/* Each card sizes its own contents (container queries below), so a card
531 + with a column to itself shows its log and a card stacked three high
532 + keeps just what fits. */
533 +.ci-wall-card {
534 + container-type: size;
535 + animation: ci-wall-card-in 0.4s cubic-bezier(0.2, 0.8, 0.2, 1) both;
536 +}
537 +
538 +@keyframes ci-wall-card-in {
539 + from { opacity: 0; transform: translateY(10px) scale(0.98); }
540 +}
541 +
542 +/* Running, said in motion: a band of the info colour sweeping across. */
543 +.ci-wall-shimmer {
544 + background: linear-gradient(
545 + 90deg,
546 + transparent 0%,
547 + var(--color-info) 45%,
548 + var(--color-info) 55%,
549 + transparent 100%
550 + ) 0 0 / 40% 100% no-repeat,
551 + color-mix(in oklab, var(--color-info) 25%, transparent);
552 + animation: ci-wall-sweep 1.8s ease-in-out infinite;
553 +}
554 +
555 +@keyframes ci-wall-sweep {
556 + from { background-position: -40% 0, 0 0; }
557 + to { background-position: 140% 0, 0 0; }
558 +}
559 +
560 +/* Older lines fade out at the top of the tail rather than being cut. */
561 +.ci-wall-log { mask-image: linear-gradient(to bottom, transparent, black 2.25rem); }
562 +
563 +@container (max-height: 230px) { .ci-wall-log { display: none; } }
564 +@container (max-height: 150px) { .ci-wall-meta, .ci-wall-runner { display: none; } }
565 +@container (max-height: 105px) { .ci-wall-step { display: none; } }
566 +@container (max-width: 200px) { .ci-wall-elapsed { font-size: 0.95rem; } }
567 +
568 +/* A phone has no room for six columns: one card under another, scrolled. */
569 +@media (max-width: 767px) {
570 + .ci-wall { height: auto; min-height: calc(100dvh - 4rem - 1px); }
571 + .ci-wall-grid { display: flex; flex-direction: column; }
572 + .ci-wall-grid.hidden { display: none; }
573 + .ci-wall-card { flex: none; height: 16rem; }
574 +}
575 +
576 +@media (prefers-reduced-motion: reduce) {
577 + .ci-wall-card, .ci-wall-shimmer { animation: none; }
578 +}
modified lib/git_gud/workflows.ex
+48 −0
@@ -1379,6 +1379,54 @@ defmodule GitGud.Workflows do
1379 1379 |> Repo.all()
1380 1380 end
1381 1381
1382 + @doc """
1383 + The jobs running right now that a user cares about — the same reach as
1384 + `list_runs_for_user/3` — longest-running first. For the live CI wall:
1385 + each comes with its `:runner`, its `:steps` in order, and its
1386 + `:workflow_run` with the run's `:repository` (and its `:owner` +
1387 + `:organization`).
1388 + """
1389 + def list_running_jobs_for_user(user_id, repo_ids)
1390 + when is_integer(user_id) and is_list(repo_ids) do
1391 + steps_query = from(s in WorkflowStep, order_by: s.number)
1392 +
1393 + from(j in WorkflowJob,
1394 + join: r in assoc(j, :workflow_run),
1395 + where:
1396 + j.status == "running" and
1397 + (r.repository_id in ^repo_ids or r.triggered_by_id == ^user_id),
1398 + order_by: [asc_nulls_last: j.started_at, asc: j.id],
1399 + preload: [
1400 + :runner,
1401 + steps: ^steps_query,
1402 + workflow_run: {r, repository: [:owner, :organization]}
1403 + ]
1404 + )
1405 + |> Repo.all()
1406 + end
1407 +
1408 + @doc """
1409 + The last `n` lines of a job's log, oldest first, as `%{seq, body}` —
1410 + the job's whole flat stream, which the runner writes to its first step
1411 + (`ensure_log_step/1`).
1412 + """
1413 + def job_log_tail(%WorkflowJob{id: jid}, n) when is_integer(n) and n > 0 do
1414 + case log_holder_id(jid) do
1415 + nil ->
1416 + []
1417 +
1418 + holder_id ->
1419 + from(l in "workflow_step_logs",
1420 + where: l.workflow_step_id == ^holder_id,
1421 + order_by: [desc: l.seq],
1422 + limit: ^n,
1423 + select: %{seq: l.seq, body: l.body}
1424 + )
1425 + |> Repo.all()
1426 + |> Enum.reverse()
1427 + end
1428 + end
1429 +
1382 1430 defp base_runs_query(opts) do
1383 1431 limit = Keyword.get(opts, :limit, 100)
1384 1432
modified lib/git_gud_web/live/user_live/ci.ex
+10 −1
@@ -66,7 +66,16 @@ defmodule GitGudWeb.UserLive.Ci do
66 66 <div class="space-y-4">
67 67 <header class="flex items-baseline justify-between">
68 68 <h1 class="text-xl font-semibold">My CI</h1>
69 − <.filter_tabs current={@filter} active_count={@active_count} />
69 + <div class="flex items-center gap-3">
70 + <.link
71 + navigate={~p"/users/ci/live"}
72 + id="ci-live-wall-link"
73 + class="btn btn-sm btn-ghost gap-1"
74 + >
75 + <.icon name="hero-squares-2x2-micro" class="size-4" /> Live wall
76 + </.link>
77 + <.filter_tabs current={@filter} active_count={@active_count} />
78 + </div>
70 79 </header>
71 80 <p class="text-sm opacity-70">
72 81 Runs across repositories you own or work on, plus any run you triggered.
added lib/git_gud_web/live/user_live/ci_wall.ex
+498 −0
@@ -0,0 +1,498 @@
1 +defmodule GitGudWeb.UserLive.CiWall do
2 + @moduledoc """
3 + The live CI wall: every job running right now in the repositories a
4 + user cares about (`Workflows.list_running_jobs_for_user/2`), one card
5 + each, tiled to fill the screen below the nav (`CiWallLayout`).
6 +
7 + `/users/ci/live` puts the longest-running job first, at the left, full
8 + height, and the newest stacked at the right; `/users/ci/live/newest`
9 + reverses the order, so the newest job gets the room. `r` flips between
10 + the two.
11 +
12 + Kept live three ways:
13 +
14 + * the instance-wide `Workflows.ci_topic/0` says a job started or
15 + finished somewhere, and the wall re-reads which jobs are running
16 + * each shown job's run topic (`Workflows.topic_for_run/1`) carries
17 + its step changes, which re-read it too, and its log lines, which
18 + go to that card's tail
19 + * the client ticks the elapsed clocks and glides each card from
20 + where it was to where the re-tiling put it (`.CiWall`)
21 +
22 + Re-reads are coalesced (`@reload_ms`) and log lines are flushed to
23 + their cards in batches (`@flush_ms`), so a burst of either is one
24 + render, not hundreds.
25 + """
26 +
27 + use GitGudWeb, :live_view
28 +
29 + alias GitGud.Git
30 + alias GitGud.Repositories
31 + alias GitGud.Repositories.Storage
32 + alias GitGud.Workflows
33 + alias GitGudWeb.UserLive.CiWallLayout
34 +
35 + @max_cols 6
36 + @tail_lines 40
37 + @reload_ms 250
38 + @flush_ms 200
39 +
40 + @impl true
41 + def mount(_params, _session, socket) do
42 + user = socket.assigns.current_scope.user
43 +
44 + if connected?(socket),
45 + do: Phoenix.PubSub.subscribe(GitGud.PubSub, Workflows.ci_topic())
46 +
47 + {:ok,
48 + socket
49 + |> assign(:user, user)
50 + |> assign(:repo_ids, Repositories.relevant_repo_ids_for_user(user))
51 + |> assign(:page_title, "Live CI")
52 + |> assign(:jobs, [])
53 + |> assign(:tails, %{})
54 + |> assign(:step_jobs, %{})
55 + |> assign(:run_topics, MapSet.new())
56 + |> assign(:dirty_tails, MapSet.new())
57 + |> assign(:reload_pending?, false)
58 + |> assign(:flush_pending?, false)
59 + |> assign(:tiling, CiWallLayout.tile(0, @max_cols))
60 + |> assign(:columns, [])
61 + |> stream_configure(:cards, dom_id: &"ci-card-#{&1.id}")
62 + |> stream(:cards, [])}
63 + end
64 +
65 + @impl true
66 + def handle_params(_params, _uri, socket) do
67 + {:noreply, socket |> assign(:order, socket.assigns.live_action) |> load()}
68 + end
69 +
70 + @impl true
71 + def handle_event("key", %{"key" => key}, socket) when key in ["r", "R"],
72 + do: {:noreply, push_patch(socket, to: flipped_path(socket.assigns.order))}
73 +
74 + def handle_event("key", _params, socket), do: {:noreply, socket}
75 +
76 + @impl true
77 + def handle_info({:ci_run_changed, _run_id}, socket), do: {:noreply, schedule_reload(socket)}
78 + def handle_info({:workflow_job_status, _job}, socket), do: {:noreply, schedule_reload(socket)}
79 + def handle_info({:workflow_step_status, _step}, socket), do: {:noreply, schedule_reload(socket)}
80 +
81 + def handle_info(:reload, socket),
82 + do: {:noreply, socket |> assign(:reload_pending?, false) |> load()}
83 +
84 + def handle_info({:workflow_log, step_id, _seq, body}, socket) do
85 + case socket.assigns.step_jobs do
86 + %{^step_id => job_id} ->
87 + tails = Map.update(socket.assigns.tails, job_id, [line(body)], &append_line(&1, body))
88 +
89 + {:noreply,
90 + socket
91 + |> assign(:tails, tails)
92 + |> assign(:dirty_tails, MapSet.put(socket.assigns.dirty_tails, job_id))
93 + |> schedule_flush()}
94 +
95 + _ ->
96 + {:noreply, socket}
97 + end
98 + end
99 +
100 + def handle_info(:flush, socket) do
101 + %{dirty_tails: dirty, jobs: jobs} = socket.assigns
102 +
103 + socket =
104 + jobs
105 + |> Enum.filter(&MapSet.member?(dirty, &1.id))
106 + |> Enum.reduce(socket, &stream_insert(&2, :cards, card(&2, &1)))
107 +
108 + {:noreply, socket |> assign(:dirty_tails, MapSet.new()) |> assign(:flush_pending?, false)}
109 + end
110 +
111 + # Anything else on a run topic (the run's own status) says nothing
112 + # the ci topic doesn't.
113 + def handle_info(_msg, socket), do: {:noreply, socket}
114 +
115 + defp schedule_reload(%{assigns: %{reload_pending?: true}} = socket), do: socket
116 +
117 + defp schedule_reload(socket) do
118 + Process.send_after(self(), :reload, @reload_ms)
119 + assign(socket, :reload_pending?, true)
120 + end
121 +
122 + defp schedule_flush(%{assigns: %{flush_pending?: true}} = socket), do: socket
123 +
124 + defp schedule_flush(socket) do
125 + Process.send_after(self(), :flush, @flush_ms)
126 + assign(socket, :flush_pending?, true)
127 + end
128 +
129 + # Which jobs are running, in the wall's order, tiled; the run topics
130 + # follow the jobs shown, and a job new to the wall starts with its log
131 + # tail as stored.
132 + defp load(socket) do
133 + %{user: user, repo_ids: repo_ids, tails: tails} = socket.assigns
134 +
135 + jobs = Workflows.list_running_jobs_for_user(user.id, repo_ids)
136 + jobs = if socket.assigns.order == :newest, do: Enum.reverse(jobs), else: jobs
137 +
138 + tails =
139 + Map.new(jobs, fn job ->
140 + {job.id, Map.get_lazy(tails, job.id, fn -> stored_tail(job) end)}
141 + end)
142 +
143 + tiling = CiWallLayout.tile(length(jobs), @max_cols)
144 +
145 + socket =
146 + socket
147 + |> follow_runs(jobs)
148 + |> assign(:jobs, jobs)
149 + |> assign(:tails, tails)
150 + |> assign(:step_jobs, for(j <- jobs, s <- j.steps, into: %{}, do: {s.id, j.id}))
151 + |> assign(:tiling, tiling)
152 + |> assign(:columns, CiWallLayout.column_counts(length(jobs), @max_cols))
153 +
154 + cards =
155 + jobs |> Enum.zip(tiling.spans) |> Enum.map(fn {job, span} -> card(socket, job, span) end)
156 +
157 + stream(socket, :cards, cards, reset: true)
158 + end
159 +
160 + defp follow_runs(socket, jobs) do
161 + if connected?(socket) do
162 + wanted = MapSet.new(jobs, &Workflows.topic_for_run(&1.workflow_run_id))
163 + current = socket.assigns.run_topics
164 +
165 + for t <- MapSet.difference(wanted, current), do: Phoenix.PubSub.subscribe(GitGud.PubSub, t)
166 +
167 + for t <- MapSet.difference(current, wanted),
168 + do: Phoenix.PubSub.unsubscribe(GitGud.PubSub, t)
169 +
170 + assign(socket, :run_topics, wanted)
171 + else
172 + socket
173 + end
174 + end
175 +
176 + # A card as the stream holds it: the job, the rows it spans, its tail.
177 + # Re-inserted for a new tail with the span it was tiled with.
178 + defp card(socket, job, span \\ nil) do
179 + span = span || span_of(socket, job)
180 + %{id: job.id, job: job, span: span, tail: Map.get(socket.assigns.tails, job.id, [])}
181 + end
182 +
183 + defp span_of(socket, job) do
184 + index = Enum.find_index(socket.assigns.jobs, &(&1.id == job.id))
185 + Enum.at(socket.assigns.tiling.spans, index, 1)
186 + end
187 +
188 + defp stored_tail(job),
189 + do: job |> Workflows.job_log_tail(@tail_lines) |> Enum.map(&line(&1.body))
190 +
191 + defp append_line(lines, body), do: Enum.take(lines ++ [line(body)], -@tail_lines)
192 +
193 + # Plain text, one screen line: no colour codes, no carriage-return
194 + # progress bars (the last frame wins), nothing a card can't show.
195 + @ansi ~r/\e\[[0-9;?]*[ -\/]*[@-~]|\e\][^\a]*\a/
196 + defp line(body) when is_binary(body) do
197 + body
198 + |> String.replace(@ansi, "")
199 + |> String.split("\r")
200 + |> List.last()
201 + |> String.trim_trailing()
202 + |> String.slice(0, 400)
203 + end
204 +
205 + defp line(_), do: ""
206 +
207 + defp flipped_path(:newest), do: ~p"/users/ci/live"
208 + defp flipped_path(_oldest), do: ~p"/users/ci/live/newest"
209 +
210 + @impl true
211 + def render(assigns) do
212 + ~H"""
213 + <Layouts.app flash={@flash} current_scope={@current_scope} wide={true}>
214 + <div
215 + id="ci-wall"
216 + class="ci-wall flex flex-col gap-3 p-3 sm:p-4"
217 + phx-window-keyup="key"
218 + >
219 + <header class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
220 + <div class="flex items-center gap-3 min-w-0">
221 + <span class="relative flex size-2.5" aria-hidden="true">
222 + <span class={[
223 + "absolute inline-flex size-full rounded-full bg-info opacity-60",
224 + @jobs != [] && "animate-ping"
225 + ]} />
226 + <span class={[
227 + "relative inline-flex size-2.5 rounded-full",
228 + if(@jobs == [], do: "bg-base-content/30", else: "bg-info")
229 + ]} />
230 + </span>
231 + <h1 class="font-semibold tracking-tight">Live CI</h1>
232 + <span id="ci-wall-count" class="text-sm tabular-nums opacity-60">
233 + {running_label(length(@jobs))}
234 + </span>
235 + <span
236 + :if={@columns != []}
237 + id="ci-wall-shape"
238 + class="hidden md:inline-flex items-end gap-0.5 h-4"
239 + title="Cards per column"
240 + aria-hidden="true"
241 + >
242 + <span
243 + :for={count <- @columns}
244 + class="w-1.5 flex flex-col gap-px h-full"
245 + >
246 + <span :for={_ <- 1..count} class="flex-1 rounded-[1px] bg-base-content/40" />
247 + </span>
248 + </span>
249 + </div>
250 +
251 + <div class="flex items-center gap-2">
252 + <div
253 + id="ci-wall-order"
254 + role="group"
255 + aria-label="Order"
256 + class="inline-flex rounded-lg bg-base-200 p-0.5 text-xs font-medium"
257 + >
258 + <.link
259 + id="ci-wall-oldest"
260 + patch={~p"/users/ci/live"}
261 + aria-current={@order == :oldest && "true"}
262 + class={order_class(@order == :oldest)}
263 + >
264 + <.icon name="hero-bars-arrow-down-micro" class="size-3.5" /> Oldest first
265 + </.link>
266 + <.link
267 + id="ci-wall-newest"
268 + patch={~p"/users/ci/live/newest"}
269 + aria-current={@order == :newest && "true"}
270 + class={order_class(@order == :newest)}
271 + >
272 + <.icon name="hero-bars-arrow-up-micro" class="size-3.5" /> Newest first
273 + </.link>
274 + </div>
275 + <kbd class="hidden lg:inline kbd kbd-xs opacity-60" title="Press r to reverse">r</kbd>
276 + <.link
277 + navigate={~p"/users/ci"}
278 + id="ci-wall-list"
279 + class="btn btn-ghost btn-xs gap-1 opacity-70 hover:opacity-100"
280 + >
281 + <.icon name="hero-list-bullet-micro" class="size-3.5" /> List
282 + </.link>
283 + </div>
284 + </header>
285 +
286 + <div
287 + :if={@jobs == []}
288 + id="ci-wall-empty"
289 + class="flex-1 grid place-items-center rounded-2xl border border-dashed border-base-300"
290 + >
291 + <div class="text-center space-y-2 opacity-60">
292 + <.icon name="hero-moon" class="size-8 mx-auto" />
293 + <p class="font-medium">Nothing running</p>
294 + <p class="text-sm">Jobs appear here the moment a runner picks them up.</p>
295 + </div>
296 + </div>
297 +
298 + <div
299 + id="ci-wall-cards"
300 + phx-update="stream"
301 + phx-hook=".CiWall"
302 + class={["ci-wall-grid flex-1 min-h-0 grid grid-flow-col gap-3", @jobs == [] && "hidden"]}
303 + style={"grid-template-columns: repeat(#{@tiling.cols}, minmax(0, 1fr)); grid-template-rows: repeat(#{@tiling.rows}, minmax(0, 1fr));"}
304 + >
305 + <.job_card :for={{dom_id, card} <- @streams.cards} id={dom_id} card={card} />
306 + </div>
307 + </div>
308 +
309 + <script :type={Phoenix.LiveView.ColocatedHook} name=".CiWall">
310 + // Ticks the elapsed clocks, and glides each card from where it was
311 + // to where the server's re-tiling put it (FLIP), so a job joining
312 + // or leaving the wall moves the others rather than jumping them.
313 + const pad = n => String(n).padStart(2, "0")
314 + const fmt = ms => {
315 + const s = Math.max(0, Math.floor(ms / 1000))
316 + if (s < 60) return `${s}s`
317 + if (s < 3600) return `${Math.floor(s / 60)}m ${pad(s % 60)}s`
318 + return `${Math.floor(s / 3600)}h ${pad(Math.floor((s % 3600) / 60))}m`
319 + }
320 +
321 + export default {
322 + mounted() {
323 + this.tick()
324 + this.timer = setInterval(() => this.tick(), 1000)
325 + },
326 + destroyed() { clearInterval(this.timer) },
327 + beforeUpdate() {
328 + this.rects = new Map()
329 + for (const el of this.el.children) {
330 + if (el.id) this.rects.set(el.id, el.getBoundingClientRect())
331 + }
332 + },
333 + updated() {
334 + this.tick()
335 + const rects = this.rects
336 + this.rects = null
337 + if (!rects || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
338 +
339 + for (const el of this.el.children) {
340 + const was = rects.get(el.id)
341 + if (!was) continue
342 + const now = el.getBoundingClientRect()
343 + const dx = was.left - now.left
344 + const dy = was.top - now.top
345 + if (Math.abs(dx) < 1 && Math.abs(dy) < 1) continue
346 + el.animate(
347 + [{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "none" }],
348 + { duration: 450, easing: "cubic-bezier(.2, .8, .2, 1)" }
349 + )
350 + }
351 + },
352 + tick() {
353 + const now = Date.now()
354 + for (const el of this.el.querySelectorAll("[data-elapsed-from]")) {
355 + el.textContent = fmt(now - Date.parse(el.dataset.elapsedFrom))
356 + }
357 + }
358 + }
359 + </script>
360 + </Layouts.app>
361 + """
362 + end
363 +
364 + attr :id, :string, required: true
365 + attr :card, :map, required: true
366 +
367 + defp job_card(assigns) do
368 + %{job: job} = assigns.card
369 + run = job.workflow_run
370 + repo = run.repository
371 + handle = Storage.repo_handle(repo)
372 + done = Enum.count(job.steps, &(&1.status in ~w(success failure skipped cancelled)))
373 +
374 + assigns =
375 + assigns
376 + |> assign(:job, job)
377 + |> assign(:run, run)
378 + |> assign(:repo, repo)
379 + |> assign(:handle, handle)
380 + |> assign(:current, current_step(job.steps))
381 + |> assign(:done, done)
382 + |> assign(:href, ~p"/r/#{handle}/#{repo.name}/actions/#{run.number}" <> "#job-#{job.id}")
383 +
384 + ~H"""
385 + <article
386 + id={@id}
387 + class="ci-wall-card group relative flex flex-col min-h-0 min-w-0 overflow-hidden rounded-xl border border-base-300 bg-base-100 shadow-sm transition-[border-color,box-shadow] duration-200 hover:border-info/50 hover:shadow-md"
388 + style={"grid-row: span #{@card.span};"}
389 + data-job-id={@job.id}
390 + >
391 + <div class="ci-wall-shimmer h-0.5 shrink-0" aria-hidden="true" />
392 +
393 + <.link
394 + navigate={@href}
395 + class="flex flex-col gap-2 p-3 min-h-0 flex-1 outline-none focus-visible:bg-base-200/60"
396 + >
397 + <div class="flex items-start justify-between gap-2 min-w-0">
398 + <div class="min-w-0">
399 + <p class="font-mono text-[11px] truncate opacity-60">{@handle}/{@repo.name}</p>
400 + <h2 class="font-semibold leading-tight truncate" title={@job.name || @job.job_id}>
401 + {@job.name || @job.job_id}
402 + </h2>
403 + </div>
404 + <span
405 + class="ci-wall-elapsed shrink-0 font-mono text-lg font-semibold tabular-nums text-info"
406 + data-elapsed-from={@job.started_at && DateTime.to_iso8601(@job.started_at)}
407 + >
408 + {elapsed(@job.started_at)}
409 + </span>
410 + </div>
411 +
412 + <p class="ci-wall-meta text-xs opacity-60 truncate">
413 + #{@run.number} · {@run.workflow_name || Path.basename(@run.workflow_path)}
414 + <span :if={@run.head_ref} class="font-mono">· {short_ref(@run.head_ref)}</span>
415 + <span :if={@run.head_sha} class="font-mono">@{Git.short(@run.head_sha)}</span>
416 + </p>
417 +
418 + <div :if={@job.steps != []} class="space-y-1">
419 + <div class="flex gap-0.5 h-1.5" aria-hidden="true">
420 + <span
421 + :for={step <- @job.steps}
422 + class={["flex-1 rounded-full transition-colors duration-300", step_class(step.status)]}
423 + title={"#{step.number}. #{step.name} — #{step.status}"}
424 + />
425 + </div>
426 + <p class="ci-wall-step text-xs truncate">
427 + <span class="tabular-nums opacity-60">{@done}/{length(@job.steps)}</span>
428 + <span :if={@current} class="ml-1">{@current.name || "Step #{@current.number}"}</span>
429 + </p>
430 + </div>
431 +
432 + <div class="ci-wall-log flex-1 min-h-0 flex flex-col justify-end overflow-hidden rounded-lg bg-base-200/70 px-2 py-1.5">
433 + <pre class="font-mono text-[11px] leading-snug opacity-80"><span
434 + :for={line <- @card.tail}
435 + class="block overflow-hidden text-ellipsis whitespace-pre"
436 + >{line}</span></pre>
437 + <p :if={@card.tail == []} class="text-[11px] italic opacity-40">Waiting for output…</p>
438 + </div>
439 +
440 + <p class="ci-wall-runner flex items-center gap-1 text-[11px] opacity-50 truncate">
441 + <.icon name="hero-cpu-chip-micro" class="size-3.5 shrink-0" />
442 + <span class="truncate">
443 + {(@job.runner && @job.runner.name) || "runner"}
444 + <span :if={@job.runner}>· {@job.runner.architecture}</span>
445 + </span>
446 + </p>
447 + </.link>
448 + </article>
449 + """
450 + end
451 +
452 + defp order_class(active?) do
453 + [
454 + "inline-flex items-center gap-1 rounded-md px-2.5 py-1 transition-all duration-150",
455 + if(active?,
456 + do: "bg-base-100 shadow-sm",
457 + else: "opacity-60 hover:opacity-100"
458 + )
459 + ]
460 + end
461 +
462 + defp running_label(1), do: "1 job running"
463 + defp running_label(n), do: "#{n} jobs running"
464 +
465 + # The step running now, else the last one that started (between two
466 + # steps), else none yet.
467 + defp current_step(steps) do
468 + Enum.find(steps, &(&1.status == "running")) ||
469 + steps |> Enum.reject(&(&1.status == "pending")) |> List.last()
470 + end
471 +
472 + defp step_class("success"), do: "bg-success"
473 + defp step_class("failure"), do: "bg-error"
474 + defp step_class("running"), do: "bg-info animate-pulse"
475 + defp step_class("skipped"), do: "bg-base-content/20"
476 + defp step_class("cancelled"), do: "bg-base-content/30"
477 + defp step_class(_pending), do: "bg-base-300"
478 +
479 + defp short_ref("refs/heads/" <> b), do: b
480 + defp short_ref("refs/tags/" <> t), do: t
481 + defp short_ref("refs/pull/" <> rest), do: "PR " <> (rest |> String.split("/") |> hd())
482 + defp short_ref(ref), do: ref
483 +
484 + defp elapsed(nil), do: "—"
485 +
486 + defp elapsed(%DateTime{} = from) do
487 + s = max(DateTime.diff(DateTime.utc_now(), from), 0)
488 +
489 + cond do
490 + s < 60 -> "#{s}s"
491 + s < 3600 -> "#{div(s, 60)}m #{pad(rem(s, 60))}s"
492 + true -> "#{div(s, 3600)}h #{pad(div(rem(s, 3600), 60))}m"
493 + end
494 + end
495 +
496 + defp pad(n) when n < 10, do: "0#{n}"
497 + defp pad(n), do: "#{n}"
498 +end
added lib/git_gud_web/live/user_live/ci_wall_layout.ex
+48 −0
@@ -0,0 +1,48 @@
1 +defmodule GitGudWeb.UserLive.CiWallLayout do
2 + @moduledoc """
3 + How the live CI wall tiles `n` cards across at most `max_cols`
4 + columns.
5 +
6 + Up to `max_cols` cards, each is a column of its own, full height. Past
7 + that the columns fill up from the right: the 7th card (of 6 columns)
8 + pairs the rightmost column, the 8th the next one in, until every
9 + column holds two; then the rightmost becomes three, and so on. So the
10 + first cards in order keep the most room, and the columns to their
11 + right hold one more card than them, or as many.
12 +
13 + Laid out as a CSS grid that flows down its columns: `rows` is a
14 + multiple of every column's card count (k and k + 1 cards need
15 + k·(k + 1) rows), and each card spans `rows / cards in its column`.
16 + """
17 +
18 + @type t :: %{cols: non_neg_integer(), rows: non_neg_integer(), spans: [pos_integer()]}
19 +
20 + @spec tile(non_neg_integer(), pos_integer()) :: t()
21 + def tile(0, _max_cols), do: %{cols: 0, rows: 0, spans: []}
22 +
23 + def tile(n, max_cols) when is_integer(n) and n > 0 and is_integer(max_cols) and max_cols > 0 do
24 + cols = min(n, max_cols)
25 + base = div(n, cols)
26 + # The rightmost `extra` columns hold one card more than the rest.
27 + extra = rem(n, cols)
28 + rows = if extra == 0, do: base, else: base * (base + 1)
29 +
30 + # The leftmost `cols - extra` columns, `base` cards each, come first.
31 + short = (cols - extra) * base
32 +
33 + spans = for i <- 0..(n - 1), do: if(i < short, do: div(rows, base), else: div(rows, base + 1))
34 +
35 + %{cols: cols, rows: rows, spans: spans}
36 + end
37 +
38 + @doc "How many cards each column holds, left to right (for the toolbar)."
39 + @spec column_counts(non_neg_integer(), pos_integer()) :: [pos_integer()]
40 + def column_counts(0, _max_cols), do: []
41 +
42 + def column_counts(n, max_cols) do
43 + cols = min(n, max_cols)
44 + base = div(n, cols)
45 + extra = rem(n, cols)
46 + List.duplicate(base, cols - extra) ++ List.duplicate(base + 1, extra)
47 + end
48 +end
modified lib/git_gud_web/router.ex
+3 −0
@@ -244,6 +244,9 @@ defmodule GitGudWeb.Router do
244 244 live "/orgs/:handle/settings/audit/:category", AuditLive.Org, :index
245 245 live "/orgs/:handle/settings/audit/:category/*refine", AuditLive.Org, :index
246 246 live "/users/ci", UserLive.Ci, :index
247 + # The live wall of running jobs; the second reverses its order.
248 + live "/users/ci/live", UserLive.CiWall, :oldest
249 + live "/users/ci/live/newest", UserLive.CiWall, :newest
247 250 live "/r/:owner/:name/fork", RepoLive.Fork, :new
248 251 live "/orgs", OrgLive.Index, :index
249 252 live "/orgs/new", OrgLive.New, :new
added test/git_gud_web/live/user_live/ci_wall_layout_test.exs
+41 −0
@@ -0,0 +1,41 @@
1 +defmodule GitGudWeb.UserLive.CiWallLayoutTest do
2 + use ExUnit.Case, async: true
3 +
4 + alias GitGudWeb.UserLive.CiWallLayout
5 +
6 + test "no cards, no grid" do
7 + assert CiWallLayout.tile(0, 6) == %{cols: 0, rows: 0, spans: []}
8 + end
9 +
10 + test "up to six cards, each a full-height column" do
11 + for n <- 1..6 do
12 + assert CiWallLayout.tile(n, 6) == %{cols: n, rows: 1, spans: List.duplicate(1, n)}
13 + end
14 + end
15 +
16 + test "the seventh card pairs the rightmost column; the oldest keep full height" do
17 + assert CiWallLayout.tile(7, 6) == %{cols: 6, rows: 2, spans: [2, 2, 2, 2, 2, 1, 1]}
18 + assert CiWallLayout.column_counts(7, 6) == [1, 1, 1, 1, 1, 2]
19 + end
20 +
21 + test "pairs fill in from the right until every column holds two" do
22 + assert CiWallLayout.tile(11, 6).spans == [2] ++ List.duplicate(1, 10)
23 + assert CiWallLayout.tile(12, 6) == %{cols: 6, rows: 2, spans: List.duplicate(1, 12)}
24 + end
25 +
26 + test "past that, threes fill in from the right the same way" do
27 + assert CiWallLayout.tile(13, 6) ==
28 + %{cols: 6, rows: 6, spans: List.duplicate(3, 10) ++ [2, 2, 2]}
29 +
30 + assert CiWallLayout.column_counts(13, 6) == [2, 2, 2, 2, 2, 3]
31 + end
32 +
33 + test "every column is filled exactly" do
34 + for n <- 1..40 do
35 + %{cols: cols, rows: rows, spans: spans} = CiWallLayout.tile(n, 6)
36 + assert length(spans) == n
37 + assert Enum.sum(spans) == cols * rows
38 + assert Enum.sum(CiWallLayout.column_counts(n, 6)) == n
39 + end
40 + end
41 +end
added test/git_gud_web/live/user_live/ci_wall_test.exs
+146 −0
@@ -0,0 +1,146 @@
1 +defmodule GitGudWeb.UserLive.CiWallTest do
2 + @moduledoc """
3 + The live CI wall shows the jobs running in a user's repositories, one
4 + card each, keeps up as jobs start, finish and log, and reverses its
5 + order on request.
6 + """
7 + use GitGudWeb.ConnCase, async: false
8 +
9 + import Phoenix.LiveViewTest
10 + import GitGud.ForgeFixtures
11 +
12 + alias GitGud.Repo
13 + alias GitGud.Workflows
14 + alias GitGud.Workflows.WorkflowJob
15 + alias GitGud.Workflows.WorkflowRun
16 + alias GitGud.Workflows.WorkflowStep
17 +
18 + setup :register_and_log_in_user
19 +
20 + setup %{user: user} do
21 + {_owner, repo} = repository_fixture(%{owner: user})
22 + {:ok, repo: repo, run: run_fixture(repo)}
23 + end
24 +
25 + defp run_fixture(repo) do
26 + %WorkflowRun{repository_id: repo.id}
27 + |> WorkflowRun.create_changeset(%{
28 + number: System.unique_integer([:positive]),
29 + workflow_path: ".forgejo/workflows/ci.yml",
30 + trigger: "push",
31 + head_sha: :crypto.strong_rand_bytes(20)
32 + })
33 + |> Ecto.Changeset.change(status: "running", head_ref: "refs/heads/master")
34 + |> Repo.insert!()
35 + end
36 +
37 + defp job_fixture(run, status, started_seconds_ago) do
38 + job =
39 + %WorkflowJob{workflow_run_id: run.id}
40 + |> WorkflowJob.create_changeset(%{
41 + job_id: "build-#{System.unique_integer([:positive])}",
42 + name: "build",
43 + definition: %{}
44 + })
45 + |> Ecto.Changeset.change(
46 + status: status,
47 + started_at: DateTime.add(DateTime.utc_now(:second), -started_seconds_ago, :second)
48 + )
49 + |> Repo.insert!()
50 +
51 + for n <- 1..2 do
52 + %WorkflowStep{workflow_job_id: job.id}
53 + |> WorkflowStep.create_changeset(%{number: n, name: "step #{n}"})
54 + |> Repo.insert!()
55 + end
56 +
57 + job
58 + end
59 +
60 + defp card_ids(lv) do
61 + lv
62 + |> render()
63 + |> LazyHTML.from_fragment()
64 + |> LazyHTML.query("#ci-wall-cards > article")
65 + |> LazyHTML.attribute("id")
66 + end
67 +
68 + # The wall coalesces what it hears; this is its timer going off now.
69 + defp settle(lv, msg) do
70 + send(lv.pid, msg)
71 + _ = render(lv)
72 + end
73 +
74 + test "nothing running shows the empty wall", %{conn: conn} do
75 + {:ok, lv, _} = live(conn, ~p"/users/ci/live")
76 +
77 + assert has_element?(lv, "#ci-wall-empty")
78 + assert card_ids(lv) == []
79 + end
80 +
81 + test "running jobs only, oldest first; /newest reverses", %{conn: conn, run: run} do
82 + old = job_fixture(run, "running", 600)
83 + new = job_fixture(run, "running", 30)
84 + _queued = job_fixture(run, "queued", 0)
85 +
86 + {:ok, lv, _} = live(conn, ~p"/users/ci/live")
87 + assert card_ids(lv) == ["ci-card-#{old.id}", "ci-card-#{new.id}"]
88 + assert has_element?(lv, "#ci-wall-oldest[aria-current]")
89 +
90 + {:ok, lv, _} = live(conn, ~p"/users/ci/live/newest")
91 + assert card_ids(lv) == ["ci-card-#{new.id}", "ci-card-#{old.id}"]
92 + end
93 +
94 + test "r reverses the order", %{conn: conn, run: run} do
95 + job_fixture(run, "running", 60)
96 + {:ok, lv, _} = live(conn, ~p"/users/ci/live")
97 +
98 + render_keyup(lv, "key", %{"key" => "r"})
99 + assert_patch(lv, ~p"/users/ci/live/newest")
100 +
101 + render_keyup(lv, "key", %{"key" => "r"})
102 + assert_patch(lv, ~p"/users/ci/live")
103 + end
104 +
105 + test "another user's running jobs aren't shown", %{conn: conn} do
106 + {_other, theirs} = repository_fixture()
107 + job_fixture(run_fixture(theirs), "running", 60)
108 +
109 + {:ok, lv, _} = live(conn, ~p"/users/ci/live")
110 + assert card_ids(lv) == []
111 + end
112 +
113 + test "a seventh job stacks the newest two in the rightmost column", %{conn: conn, run: run} do
114 + jobs = for s <- 700..100//-100, do: job_fixture(run, "running", s)
115 + {:ok, lv, _} = live(conn, ~p"/users/ci/live")
116 +
117 + assert has_element?(lv, "#ci-card-#{hd(jobs).id}[style*='span 2']")
118 + assert has_element?(lv, "#ci-card-#{List.last(jobs).id}[style*='span 1']")
119 + end
120 +
121 + test "a job joins when it starts and leaves when it finishes", %{conn: conn, run: run} do
122 + {:ok, lv, _} = live(conn, ~p"/users/ci/live")
123 + job = job_fixture(run, "queued", 0)
124 +
125 + {:ok, job} = job |> Ecto.Changeset.change(status: "running") |> Repo.update()
126 + Workflows.broadcast_ci_change(run.id)
127 + settle(lv, :reload)
128 + assert card_ids(lv) == ["ci-card-#{job.id}"]
129 +
130 + {:ok, _} = Workflows.update_job_status(job, "success")
131 + settle(lv, :reload)
132 + assert card_ids(lv) == []
133 + end
134 +
135 + test "log lines reach the job's card", %{conn: conn, run: run} do
136 + job = job_fixture(run, "running", 60)
137 + {:ok, lv, _} = live(conn, ~p"/users/ci/live")
138 +
139 + step = Repo.get_by!(WorkflowStep, workflow_job_id: job.id, number: 1)
140 + :ok = Workflows.append_log(step, 0, [%{content: "\e[32mcompiling gitgud\e[0m"}])
141 + settle(lv, :flush)
142 +
143 + assert has_element?(lv, "#ci-card-#{job.id} .ci-wall-log", "compiling gitgud")
144 + refute render(lv) =~ "\e["
145 + end
146 +end

Parents: 520cc58