neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
CI: a live wall of every running job, tiled to fill the screen
5e0e514 · gmorell · 2026-11-03 15:53
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