20.1 KiB · text History b868907
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