defmodule GitGudWeb.CiComponents do
@moduledoc """
Shared rendering for the CI dashboards (org "single pane of glass"
and the per-user view). Both list `WorkflowRun`s with `:repository`
(and its `:owner`/`:organization`) + `:triggered_by` preloaded.
"""
use GitGudWeb, :html
alias GitGud.Git
alias GitGud.Repositories.Storage
@doc """
A status-filter tab strip. `current` is `:active | :all` and posts a
`"filter"` event with `phx-value-scope`.
"""
attr :current, :atom, required: true
attr :active_count, :integer, default: 0
def filter_tabs(assigns) do
~H"""
<div role="tablist" class="tabs tabs-bordered">
<button
type="button"
role="tab"
class={["tab", @current == :active && "tab-active"]}
phx-click="filter"
phx-value-scope="active"
>
In flight
<span :if={@active_count > 0} class="badge badge-xs badge-primary ml-1">
{@active_count}
</span>
</button>
<button
type="button"
role="tab"
class={["tab", @current == :all && "tab-active"]}
phx-click="filter"
phx-value-scope="all"
>
Recent
</button>
</div>
"""
end
@doc """
One run row. `show_actor?` toggles the "by @handle" line (handy on the
org dashboard, redundant on the personal one).
"""
attr :run, :map, required: true
attr :show_actor?, :boolean, default: true
def run_row(assigns) do
assigns = assign(assigns, :handle, Storage.repo_handle(assigns.run.repository))
~H"""
<li class="py-3">
<div class="flex items-baseline justify-between gap-3">
<div class="min-w-0">
<.link
navigate={~p"/r/#{@handle}/#{@run.repository.name}"}
class="font-mono text-sm link link-hover opacity-80"
>
{@handle}/{@run.repository.name}
</.link>
<.link
navigate={~p"/r/#{@handle}/#{@run.repository.name}/actions/#{@run.number}"}
class="font-medium link link-hover ml-1"
>
#{@run.number} — {@run.workflow_name || @run.workflow_path}
</.link>
</div>
<span class={["badge badge-sm shrink-0", status_class(@run.status)]}>{@run.status}</span>
</div>
<p class="text-xs opacity-60 mt-1 font-mono">
{@run.trigger} · {@run.head_ref} · {Git.short(@run.head_sha)}
</p>
<p class="text-xs opacity-50 mt-0.5">
<span :if={@show_actor? && @run.triggered_by}>
by {actor_label(@run.triggered_by)} ·
</span>
<span :if={@run.started_at}>started {format_dt(@run.started_at)}</span>
<span :if={@run.started_at} class="ml-1">
· {duration(@run.started_at, @run.completed_at)}
</span>
<span :if={!@run.started_at}>queued {format_dt(@run.inserted_at)}</span>
</p>
<div :if={jobs_loaded?(@run) and @run.jobs != []} class="flex flex-wrap gap-1 mt-1.5">
<span
:for={job <- @run.jobs}
class={["badge badge-xs gap-1", status_class(job.status)]}
title={"#{job.name || job.job_id} — #{job.status}"}
>
{job.name || job.job_id}
</span>
</div>
</li>
"""
end
defp jobs_loaded?(%{jobs: %Ecto.Association.NotLoaded{}}), do: false
defp jobs_loaded?(%{jobs: jobs}) when is_list(jobs), do: true
defp jobs_loaded?(_), do: false
def status_class("success"), do: "badge-success"
def status_class("running"), do: "badge-info"
def status_class("queued"), do: "badge-warning"
def status_class("failure"), do: "badge-error"
def status_class("cancelled"), do: "badge-error"
def status_class("skipped"), do: "badge-ghost"
def status_class("blocked"), do: "badge-ghost"
def status_class(_), do: "badge-neutral"
defp actor_label(%{handle: handle}) when is_binary(handle) and handle != "", do: "@" <> handle
defp actor_label(%{email: email}) when is_binary(email),
do: email |> String.split("@") |> hd()
defp actor_label(_), do: "someone"
defp format_dt(%DateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M")
defp format_dt(_), do: ""
defp duration(nil, _), do: ""
defp duration(start, nil), do: "for #{secs(DateTime.diff(DateTime.utc_now(), start))}"
defp duration(start, %DateTime{} = finish), do: "in #{secs(DateTime.diff(finish, start))}"
defp secs(s) when s < 0, do: "0s"
defp secs(s) when s < 60, do: "#{s}s"
defp secs(s) when s < 3600, do: "#{div(s, 60)}m #{rem(s, 60)}s"
defp secs(s), do: "#{div(s, 3600)}h #{div(rem(s, 3600), 60)}m"
end
4.5 KiB · text
5af55d9