defmodule GitGudWeb.CiWallLive do
@moduledoc """
The live CI wall: every job running right now, one card each, tiled to
fill the screen below the nav (`CiWallLive.Layout`). Two scopes, the
same as the two run lists they sit beside:
* `/users/ci/live` — the repositories a user cares about, and runs
they triggered (`Workflows.list_running_jobs_for_user/2`), as on
My CI
* `/orgs/:handle/actions/live` — an organization's repositories the
viewer may see (`Repositories.visible_repo_ids_for_org/2`), as on
its Actions page, and to whoever may see that page
Either puts the longest-running job first, at the left, full height,
and the newest stacked at the right; `…/live/newest` reverses the
order, so the newest job gets the room. `r` flips between the two.
Kept live three ways:
* the instance-wide `Workflows.ci_topic/0` says a job started or
finished somewhere, and the wall re-reads which jobs are running
* each shown job's run topic (`Workflows.topic_for_run/1`) carries
its step changes, which re-read it too, and its log lines, which
go to that card's tail
* the client ticks the elapsed clocks and glides each card from
where it was to where the re-tiling put it (`.CiWall`)
Re-reads are coalesced (`@reload_ms`) and log lines are flushed to
their cards in batches (`@flush_ms`), so a burst of either is one
render, not hundreds.
"""
use GitGudWeb, :live_view
alias GitGud.Git
alias GitGud.Organizations
alias GitGud.Repositories
alias GitGud.Repositories.Storage
alias GitGud.Workflows
alias GitGudWeb.CiWallLive.Layout
@max_cols 6
# Enough log to fill a full-height card on a 4K screen (11px lines),
# kept per job; a card is sent only the share its height can show
# (`visible_lines/2`), so a stacked card doesn't carry the lines a
# full-height one needs.
@tail_lines 160
@min_visible_lines 24
@reload_ms 250
@flush_ms 200
@impl true
def mount(%{"handle" => handle}, _session, socket) do
org = Organizations.get_organization_by_handle!(handle)
user = socket.assigns.current_scope && socket.assigns.current_scope.user
if GitGudWeb.OrgLive.Actions.can_view?(org, user) do
base = ~p"/orgs/#{org.handle}/actions/live"
{:ok,
socket
|> assign(:scope, {:repos, Repositories.visible_repo_ids_for_org(org, user)})
|> assign(:org, org)
|> assign(:page_title, "Live CI — @#{org.handle}")
|> assign(:oldest_path, base)
|> assign(:newest_path, base <> "/newest")
|> assign(:list_path, ~p"/orgs/#{org.handle}/actions")
|> setup()}
else
{:ok,
socket
|> put_flash(:error, "You're not authorized to view this organization.")
|> push_navigate(to: ~p"/")}
end
end
def mount(_params, _session, socket) do
user = socket.assigns.current_scope.user
{:ok,
socket
|> assign(:scope, {:user, user.id, Repositories.relevant_repo_ids_for_user(user)})
|> assign(:org, nil)
|> assign(:page_title, "Live CI")
|> assign(:oldest_path, ~p"/users/ci/live")
|> assign(:newest_path, ~p"/users/ci/live/newest")
|> assign(:list_path, ~p"/users/ci")
|> setup()}
end
defp setup(socket) do
if connected?(socket),
do: Phoenix.PubSub.subscribe(GitGud.PubSub, Workflows.ci_topic())
socket
|> assign(:jobs, [])
|> assign(:tails, %{})
|> assign(:step_jobs, %{})
|> assign(:run_topics, MapSet.new())
|> assign(:dirty_tails, MapSet.new())
|> assign(:reload_pending?, false)
|> assign(:flush_pending?, false)
|> assign(:tiling, Layout.tile(0, @max_cols))
|> assign(:columns, [])
|> stream_configure(:cards, dom_id: &"ci-card-#{&1.id}")
|> stream(:cards, [])
end
@impl true
def handle_params(_params, _uri, %{assigns: %{scope: _}} = socket) do
{:noreply, socket |> assign(:order, socket.assigns.live_action) |> load()}
end
# Turned away in `mount/3`, on its way out.
def handle_params(_params, _uri, socket), do: {:noreply, socket}
@impl true
def handle_event("key", %{"key" => key}, socket) when key in ["r", "R"],
do: {:noreply, push_patch(socket, to: flipped_path(socket.assigns))}
def handle_event("key", _params, socket), do: {:noreply, socket}
@impl true
def handle_info({:ci_run_changed, _run_id}, socket), do: {:noreply, schedule_reload(socket)}
def handle_info({:workflow_job_status, _job}, socket), do: {:noreply, schedule_reload(socket)}
def handle_info({:workflow_step_status, _step}, socket), do: {:noreply, schedule_reload(socket)}
def handle_info(:reload, socket),
do: {:noreply, socket |> assign(:reload_pending?, false) |> load()}
def handle_info({:workflow_log, step_id, _seq, body}, socket) do
case socket.assigns.step_jobs do
%{^step_id => job_id} ->
tails = Map.update(socket.assigns.tails, job_id, [line(body)], &append_line(&1, body))
{:noreply,
socket
|> assign(:tails, tails)
|> assign(:dirty_tails, MapSet.put(socket.assigns.dirty_tails, job_id))
|> schedule_flush()}
_ ->
{:noreply, socket}
end
end
def handle_info(:flush, socket) do
%{dirty_tails: dirty, jobs: jobs} = socket.assigns
socket =
jobs
|> Enum.filter(&MapSet.member?(dirty, &1.id))
|> Enum.reduce(socket, &stream_insert(&2, :cards, card(&2, &1)))
{:noreply, socket |> assign(:dirty_tails, MapSet.new()) |> assign(:flush_pending?, false)}
end
# Anything else on a run topic (the run's own status) says nothing
# the ci topic doesn't.
def handle_info(_msg, socket), do: {:noreply, socket}
defp schedule_reload(%{assigns: %{reload_pending?: true}} = socket), do: socket
defp schedule_reload(socket) do
Process.send_after(self(), :reload, @reload_ms)
assign(socket, :reload_pending?, true)
end
defp schedule_flush(%{assigns: %{flush_pending?: true}} = socket), do: socket
defp schedule_flush(socket) do
Process.send_after(self(), :flush, @flush_ms)
assign(socket, :flush_pending?, true)
end
# Which jobs are running, in the wall's order, tiled; the run topics
# follow the jobs shown, and a job new to the wall starts with its log
# tail as stored.
defp load(socket) do
%{scope: scope, tails: tails} = socket.assigns
jobs = running_jobs(scope)
jobs = if socket.assigns.order == :newest, do: Enum.reverse(jobs), else: jobs
tails =
Map.new(jobs, fn job ->
{job.id, Map.get_lazy(tails, job.id, fn -> stored_tail(job) end)}
end)
tiling = Layout.tile(length(jobs), @max_cols)
socket =
socket
|> follow_runs(jobs)
|> assign(:jobs, jobs)
|> assign(:tails, tails)
|> assign(:step_jobs, for(j <- jobs, s <- j.steps, into: %{}, do: {s.id, j.id}))
|> assign(:tiling, tiling)
|> assign(:columns, Layout.column_counts(length(jobs), @max_cols))
cards =
jobs |> Enum.zip(tiling.spans) |> Enum.map(fn {job, span} -> card(socket, job, span) end)
stream(socket, :cards, cards, reset: true)
end
defp follow_runs(socket, jobs) do
if connected?(socket) do
wanted = MapSet.new(jobs, &Workflows.topic_for_run(&1.workflow_run_id))
current = socket.assigns.run_topics
for t <- MapSet.difference(wanted, current), do: Phoenix.PubSub.subscribe(GitGud.PubSub, t)
for t <- MapSet.difference(current, wanted),
do: Phoenix.PubSub.unsubscribe(GitGud.PubSub, t)
assign(socket, :run_topics, wanted)
else
socket
end
end
# A card as the stream holds it: the job, the rows it spans, its tail.
# Re-inserted for a new tail with the span it was tiled with.
defp card(socket, job, span \\ nil) do
span = span || span_of(socket, job)
tail = Map.get(socket.assigns.tails, job.id, [])
visible = visible_lines(span, socket.assigns.tiling.rows)
%{id: job.id, job: job, span: span, tail: Enum.take(tail, -visible)}
end
defp span_of(socket, job) do
index = Enum.find_index(socket.assigns.jobs, &(&1.id == job.id))
Enum.at(socket.assigns.tiling.spans, index, 1)
end
# A card spanning `span` of `rows` rows is that share of the wall's
# height, so it shows that share of the lines, with a floor for the
# phone layout, where every card is the same height.
defp visible_lines(span, rows) when rows > 0,
do: max(@min_visible_lines, ceil(@tail_lines * span / rows))
defp visible_lines(_span, _rows), do: @tail_lines
defp stored_tail(job),
do: job |> Workflows.job_log_tail(@tail_lines) |> Enum.map(&line(&1.body))
defp append_line(lines, body), do: Enum.take(lines ++ [line(body)], -@tail_lines)
defp line(body), do: GitGud.Workflows.LogLine.plain(body)
defp running_jobs({:user, user_id, repo_ids}),
do: Workflows.list_running_jobs_for_user(user_id, repo_ids)
defp running_jobs({:repos, repo_ids}), do: Workflows.list_running_jobs_for_repos(repo_ids)
defp flipped_path(%{order: :newest, oldest_path: path}), do: path
defp flipped_path(%{newest_path: path}), do: path
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} wide={true}>
<div
id="ci-wall"
class="ci-wall flex flex-col gap-3 p-3 sm:p-4"
phx-window-keyup="key"
>
<header class="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
<div class="flex items-center gap-3 min-w-0">
<span class="relative flex size-2.5" aria-hidden="true">
<span class={[
"absolute inline-flex size-full rounded-full bg-info opacity-60",
@jobs != [] && "animate-ping"
]} />
<span class={[
"relative inline-flex size-2.5 rounded-full",
if(@jobs == [], do: "bg-base-content/30", else: "bg-info")
]} />
</span>
<h1 class="font-semibold tracking-tight">
Live CI
<.link
:if={@org}
navigate={~p"/orgs/#{@org.handle}"}
class="font-mono font-normal opacity-60 link link-hover ml-1"
>
@{@org.handle}
</.link>
</h1>
<span id="ci-wall-count" class="text-sm tabular-nums opacity-60">
{running_label(length(@jobs))}
</span>
<span
:if={@columns != []}
id="ci-wall-shape"
class="hidden md:inline-flex items-end gap-0.5 h-4"
title="Cards per column"
aria-hidden="true"
>
<span
:for={count <- @columns}
class="w-1.5 flex flex-col gap-px h-full"
>
<span :for={_ <- 1..count} class="flex-1 rounded-[1px] bg-base-content/40" />
</span>
</span>
</div>
<div class="flex items-center gap-2">
<div
id="ci-wall-order"
role="group"
aria-label="Order"
class="inline-flex rounded-lg bg-base-200 p-0.5 text-xs font-medium"
>
<.link
id="ci-wall-oldest"
patch={@oldest_path}
aria-current={@order == :oldest && "true"}
class={order_class(@order == :oldest)}
>
<.icon name="hero-bars-arrow-down-micro" class="size-3.5" /> Oldest first
</.link>
<.link
id="ci-wall-newest"
patch={@newest_path}
aria-current={@order == :newest && "true"}
class={order_class(@order == :newest)}
>
<.icon name="hero-bars-arrow-up-micro" class="size-3.5" /> Newest first
</.link>
</div>
<kbd class="hidden lg:inline kbd kbd-xs opacity-60" title="Press r to reverse">r</kbd>
<.link
navigate={@list_path}
id="ci-wall-list"
class="btn btn-ghost btn-xs gap-1 opacity-70 hover:opacity-100"
>
<.icon name="hero-list-bullet-micro" class="size-3.5" /> List
</.link>
</div>
</header>
<div
:if={@jobs == []}
id="ci-wall-empty"
class="flex-1 grid place-items-center rounded-2xl border border-dashed border-base-300"
>
<div class="text-center space-y-2 opacity-60">
<.icon name="hero-moon" class="size-8 mx-auto" />
<p class="font-medium">Nothing running</p>
<p class="text-sm">Jobs appear here the moment a runner picks them up.</p>
</div>
</div>
<div
id="ci-wall-cards"
phx-update="stream"
phx-hook=".CiWall"
class={["ci-wall-grid flex-1 min-h-0 grid grid-flow-col gap-3", @jobs == [] && "hidden"]}
style={"grid-template-columns: repeat(#{@tiling.cols}, minmax(0, 1fr)); grid-template-rows: repeat(#{@tiling.rows}, minmax(0, 1fr));"}
>
<.job_card :for={{dom_id, card} <- @streams.cards} id={dom_id} card={card} />
</div>
</div>
<script :type={Phoenix.LiveView.ColocatedHook} name=".CiWall">
// Ticks the elapsed clocks, and glides each card from where it was
// to where the server's re-tiling put it (FLIP), so a job joining
// or leaving the wall moves the others rather than jumping them.
const pad = n => String(n).padStart(2, "0")
const fmt = ms => {
const s = Math.max(0, Math.floor(ms / 1000))
if (s < 60) return `${s}s`
if (s < 3600) return `${Math.floor(s / 60)}m ${pad(s % 60)}s`
return `${Math.floor(s / 3600)}h ${pad(Math.floor((s % 3600) / 60))}m`
}
export default {
mounted() {
this.tick()
this.timer = setInterval(() => this.tick(), 1000)
},
destroyed() { clearInterval(this.timer) },
beforeUpdate() {
this.rects = new Map()
for (const el of this.el.children) {
if (el.id) this.rects.set(el.id, el.getBoundingClientRect())
}
},
updated() {
this.tick()
const rects = this.rects
this.rects = null
if (!rects || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
for (const el of this.el.children) {
const was = rects.get(el.id)
if (!was) continue
const now = el.getBoundingClientRect()
const dx = was.left - now.left
const dy = was.top - now.top
if (Math.abs(dx) < 1 && Math.abs(dy) < 1) continue
el.animate(
[{ transform: `translate(${dx}px, ${dy}px)` }, { transform: "none" }],
{ duration: 450, easing: "cubic-bezier(.2, .8, .2, 1)" }
)
}
},
tick() {
const now = Date.now()
for (const el of this.el.querySelectorAll("[data-elapsed-from]")) {
el.textContent = fmt(now - Date.parse(el.dataset.elapsedFrom))
}
}
}
</script>
</Layouts.app>
"""
end
attr :id, :string, required: true
attr :card, :map, required: true
defp job_card(assigns) do
%{job: job} = assigns.card
run = job.workflow_run
repo = run.repository
handle = Storage.repo_handle(repo)
done = Enum.count(job.steps, &(&1.status in ~w(success failure skipped cancelled)))
assigns =
assigns
|> assign(:job, job)
|> assign(:run, run)
|> assign(:repo, repo)
|> assign(:handle, handle)
|> assign(:current, current_step(job.steps))
|> assign(:done, done)
|> assign(:href, ~p"/r/#{handle}/#{repo.name}/actions/#{run.number}" <> "#job-#{job.id}")
~H"""
<article
id={@id}
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"
style={"grid-row: span #{@card.span};"}
data-job-id={@job.id}
>
<div class="ci-wall-shimmer h-0.5 shrink-0" aria-hidden="true" />
<.link
navigate={@href}
class="flex flex-col gap-2 p-3 min-h-0 flex-1 outline-none focus-visible:bg-base-200/60"
>
<div class="flex items-start justify-between gap-2 min-w-0">
<div class="min-w-0">
<p class="font-mono text-[11px] truncate opacity-60">{@handle}/{@repo.name}</p>
<h2 class="font-semibold leading-tight truncate" title={@job.name || @job.job_id}>
{@job.name || @job.job_id}
</h2>
</div>
<span
class="ci-wall-elapsed shrink-0 font-mono text-lg font-semibold tabular-nums text-info"
data-elapsed-from={@job.started_at && DateTime.to_iso8601(@job.started_at)}
>
{elapsed(@job.started_at)}
</span>
</div>
<p class="ci-wall-meta text-xs opacity-60 truncate">
#{@run.number} · {@run.workflow_name || Path.basename(@run.workflow_path)}
<span :if={@run.head_ref} class="font-mono">· {short_ref(@run.head_ref)}</span>
<span :if={@run.head_sha} class="font-mono">@{Git.short(@run.head_sha)}</span>
</p>
<div :if={@job.steps != []} class="space-y-1">
<div class="flex gap-0.5 h-1.5" aria-hidden="true">
<span
:for={step <- @job.steps}
class={["flex-1 rounded-full transition-colors duration-300", step_class(step.status)]}
title={"#{step.number}. #{step.name} — #{step.status}"}
/>
</div>
<p class="ci-wall-step text-xs truncate">
<span class="tabular-nums opacity-60">{@done}/{length(@job.steps)}</span>
<span :if={@current} class="ml-1">{@current.name || "Step #{@current.number}"}</span>
</p>
</div>
<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">
<pre class="font-mono text-[11px] leading-snug opacity-80"><span
:for={line <- @card.tail}
class="block overflow-hidden text-ellipsis whitespace-pre"
>{line}</span></pre>
<p :if={@card.tail == []} class="text-[11px] italic opacity-40">Waiting for output…</p>
</div>
<p class="ci-wall-runner flex items-center gap-1 text-[11px] opacity-50 truncate">
<.icon name="hero-cpu-chip-micro" class="size-3.5 shrink-0" />
<span class="truncate">
{(@job.runner && @job.runner.name) || "runner"}
<span :if={@job.runner}>· {@job.runner.architecture}</span>
</span>
</p>
</.link>
</article>
"""
end
defp order_class(active?) do
[
"inline-flex items-center gap-1 rounded-md px-2.5 py-1 transition-all duration-150",
if(active?,
do: "bg-base-100 shadow-sm",
else: "opacity-60 hover:opacity-100"
)
]
end
defp running_label(1), do: "1 job running"
defp running_label(n), do: "#{n} jobs running"
# The step running now, else the last one that started (between two
# steps), else none yet.
defp current_step(steps) do
Enum.find(steps, &(&1.status == "running")) ||
steps |> Enum.reject(&(&1.status == "pending")) |> List.last()
end
defp step_class("success"), do: "bg-success"
defp step_class("failure"), do: "bg-error"
defp step_class("running"), do: "bg-info animate-pulse"
defp step_class("skipped"), do: "bg-base-content/20"
defp step_class("cancelled"), do: "bg-base-content/30"
defp step_class(_pending), do: "bg-base-300"
defp short_ref("refs/heads/" <> b), do: b
defp short_ref("refs/tags/" <> t), do: t
defp short_ref("refs/pull/" <> rest), do: "PR " <> (rest |> String.split("/") |> hd())
defp short_ref(ref), do: ref
defp elapsed(nil), do: "—"
defp elapsed(%DateTime{} = from) do
s = max(DateTime.diff(DateTime.utc_now(), from), 0)
cond do
s < 60 -> "#{s}s"
s < 3600 -> "#{div(s, 60)}m #{pad(rem(s, 60))}s"
true -> "#{div(s, 3600)}h #{pad(div(rem(s, 3600), 60))}m"
end
end
defp pad(n) when n < 10, do: "0#{n}"
defp pad(n), do: "#{n}"
end
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
20.1 KiB · text
History
b868907