neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Chart how long a repository's runs have been taking
b55f15e · Gabriel Morell · 2026-09-26 03:46
Message
{commit_body(@commit)}
Files changed
modified
lib/git_gud/repositories.ex
+17
−0
@@ -51,6 +51,23 @@ defmodule GitGud.Repositories do
| 51 | 51 | |> Repo.all() |
| 52 | 52 | end |
| 53 | 53 | |
| 54 | + @doc """ | |
| 55 | + Repositories by id, with the namespace preloaded, as `%{id => repo}`. | |
| 56 | + | |
| 57 | + For a page that already knows which repositories a viewer may see — | |
| 58 | + the org CI dashboard computes that set once — and needs to put names | |
| 59 | + to them without asking per row. | |
| 60 | + """ | |
| 61 | + def list_repositories_by_ids([]), do: %{} | |
| 62 | + | |
| 63 | + def list_repositories_by_ids(ids) when is_list(ids) do | |
| 64 | + Repository | |
| 65 | + |> where([r], r.id in ^ids) | |
| 66 | + |> Repo.all() | |
| 67 | + |> Repo.preload([:owner, :organization]) | |
| 68 | + |> Map.new(&{&1.id, &1}) | |
| 69 | + end | |
| 70 | + | |
| 54 | 71 | # A repo is the same-name profile repo when its `name` matches its |
| 55 | 72 | # namespace's handle. For org-owned repos that's the org's handle; |
| 56 | 73 | # for user-owned (no org) that's the user's handle. The COALESCE |
modified
lib/git_gud/workflows.ex
+92
−0
@@ -290,6 +290,98 @@ defmodule GitGud.Workflows do
| 290 | 290 | |> Repo.all() |
| 291 | 291 | end |
| 292 | 292 | |
| 293 | + @doc """ | |
| 294 | + How long a repository's runs have been taking, oldest first. | |
| 295 | + | |
| 296 | + Only runs that both started and finished: a queued run has no | |
| 297 | + duration, and a running one's is still changing. `:limit` caps how | |
| 298 | + far back it goes (default 40). | |
| 299 | + | |
| 300 | + Oldest first because the chart reads left to right in time, and a | |
| 301 | + caller wanting "the last N" already got them — reversing a 40-element | |
| 302 | + list in the view would be the same work in a worse place. | |
| 303 | + """ | |
| 304 | + def run_durations(%Repository{id: rid}, opts \\ []) do | |
| 305 | + limit = Keyword.get(opts, :limit, 40) | |
| 306 | + | |
| 307 | + duration_fields() | |
| 308 | + |> where([r], r.repository_id == ^rid) | |
| 309 | + |> order_by([r], desc: r.started_at) | |
| 310 | + |> limit(^limit) | |
| 311 | + |> Repo.all() | |
| 312 | + |> Enum.reverse() | |
| 313 | + end | |
| 314 | + | |
| 315 | + @doc """ | |
| 316 | + The same, for several repositories at once, as `%{repository_id => [point]}`. | |
| 317 | + | |
| 318 | + One query with a window rather than one per repository: an org | |
| 319 | + dashboard draws a chart per repo, and a page that issues a query per | |
| 320 | + row is a page that gets slower every time someone creates a | |
| 321 | + repository. | |
| 322 | + | |
| 323 | + `:per_repo` caps each repository's series (default 20). | |
| 324 | + """ | |
| 325 | + def run_durations_for_repos(repo_ids, opts \\ []) when is_list(repo_ids) do | |
| 326 | + if repo_ids == [] do | |
| 327 | + %{} | |
| 328 | + else | |
| 329 | + per_repo = Keyword.get(opts, :per_repo, 20) | |
| 330 | + | |
| 331 | + ranked = | |
| 332 | + duration_fields() | |
| 333 | + |> where([r], r.repository_id in ^repo_ids) | |
| 334 | + |> windows([r], w: [partition_by: r.repository_id, order_by: [desc: r.started_at]]) | |
| 335 | + |> select_merge([r], %{rank: over(row_number(), :w)}) | |
| 336 | + | |
| 337 | + from(p in subquery(ranked), where: p.rank <= ^per_repo) | |
| 338 | + |> Repo.all() | |
| 339 | + |> Enum.group_by(& &1.repository_id) | |
| 340 | + |> Map.new(fn {rid, points} -> {rid, Enum.reverse(points)} end) | |
| 341 | + end | |
| 342 | + end | |
| 343 | + | |
| 344 | + # `seconds` is computed in the database rather than from the two | |
| 345 | + # timestamps in Elixir, so a caller can sort or filter on it later | |
| 346 | + # without re-deriving it, and so the numbers a chart draws are the | |
| 347 | + # numbers the database has. | |
| 348 | + defp duration_fields do | |
| 349 | + from(r in WorkflowRun, | |
| 350 | + where: not is_nil(r.started_at) and not is_nil(r.completed_at), | |
| 351 | + select: %{ | |
| 352 | + repository_id: r.repository_id, | |
| 353 | + number: r.number, | |
| 354 | + workflow_name: r.workflow_name, | |
| 355 | + status: r.status, | |
| 356 | + conclusion: r.conclusion, | |
| 357 | + started_at: r.started_at, | |
| 358 | + seconds: | |
| 359 | + fragment("greatest(0, extract(epoch from (? - ?))::int)", r.completed_at, r.started_at) | |
| 360 | + } | |
| 361 | + ) | |
| 362 | + end | |
| 363 | + | |
| 364 | + @doc """ | |
| 365 | + Middle duration of a series, in seconds, or nil for an empty one. | |
| 366 | + | |
| 367 | + The median rather than the mean: one pathological ten-minute run | |
| 368 | + shouldn't move the number a person reads as "how long this usually | |
| 369 | + takes". | |
| 370 | + """ | |
| 371 | + def median_duration([]), do: nil | |
| 372 | + | |
| 373 | + def median_duration(points) do | |
| 374 | + sorted = points |> Enum.map(& &1.seconds) |> Enum.sort() | |
| 375 | + count = length(sorted) | |
| 376 | + middle = div(count, 2) | |
| 377 | + | |
| 378 | + if rem(count, 2) == 1 do | |
| 379 | + Enum.at(sorted, middle) | |
| 380 | + else | |
| 381 | + div(Enum.at(sorted, middle - 1) + Enum.at(sorted, middle), 2) | |
| 382 | + end | |
| 383 | + end | |
| 384 | + | |
| 293 | 385 | @active_statuses ~w(queued running) |
| 294 | 386 | |
| 295 | 387 | @doc "Statuses considered 'in flight' for the dashboards." |
modified
lib/git_gud_web/live/ci_components.ex
+163
−0
@@ -10,6 +10,19 @@ defmodule GitGudWeb.CiComponents do
| 10 | 10 | alias GitGud.Git |
| 11 | 11 | alias GitGud.Repositories.Storage |
| 12 | 12 | |
| 13 | + # Drawn on a fixed grid and stretched to fit, rather than a grid sized | |
| 14 | + # to the number of runs: a viewBox only as wide as its bars gets | |
| 15 | + # stretched several times over by `preserveAspectRatio="none"`, which | |
| 16 | + # turns 4px marks into fat ones and rounded ends into ellipses. At a | |
| 17 | + # constant 400 the stretch is under 2x at any realistic width, so a | |
| 18 | + # bar looks like a bar. | |
| 19 | + @viewbox_width 400 | |
| 20 | + | |
| 21 | + # Capped, so a repository with four runs draws four thin bars at the | |
| 22 | + # left rather than four columns spread across the page. An empty | |
| 23 | + # right-hand side is the truth: there is no more history yet. | |
| 24 | + @max_pitch 24 | |
| 25 | + | |
| 13 | 26 | @doc """ |
| 14 | 27 | A status-filter tab strip. `current` is `:active | :all` and posts a |
| 15 | 28 | `"filter"` event with `phx-value-scope`. |
@@ -105,6 +118,156 @@ defmodule GitGudWeb.CiComponents do
| 105 | 118 | defp jobs_loaded?(%{jobs: jobs}) when is_list(jobs), do: true |
| 106 | 119 | defp jobs_loaded?(_), do: false |
| 107 | 120 | |
| 121 | + @doc """ | |
| 122 | + How long a repository's runs have been taking, oldest left. | |
| 123 | + | |
| 124 | + One bar per finished run, height by duration, with a dashed line at | |
| 125 | + the median so "slower lately" is something you can see rather than | |
| 126 | + infer. Bars rather than a line: runs are discrete events at irregular | |
| 127 | + times, and joining them would draw a trend through hours when nothing | |
| 128 | + ran. | |
| 129 | + | |
| 130 | + Colour is status, not identity — the two chromatic states are the | |
| 131 | + theme's own `success` and `error`, which is why this reads correctly | |
| 132 | + in all four themes and why dark mode is each theme's chosen step | |
| 133 | + rather than a flip of the light one. The pair is ΔE 11.6 (dark) and | |
| 134 | + 14.6 (light) apart under deuteranopia, and neither carries meaning | |
| 135 | + alone: every bar names its outcome in a tooltip, the legend spells | |
| 136 | + the states out, and the run list below is the same data as text. | |
| 137 | + | |
| 138 | + `compact` drops the axis, the median line and the legend, for the | |
| 139 | + sparkline an org dashboard draws per repository. | |
| 140 | + """ | |
| 141 | + attr :points, :list, required: true, doc: "from `Workflows.run_durations/2`, oldest first" | |
| 142 | + attr :median, :integer, default: nil | |
| 143 | + attr :compact, :boolean, default: false | |
| 144 | + attr :height, :integer, default: 96 | |
| 145 | + attr :class, :string, default: nil | |
| 146 | + | |
| 147 | + def duration_chart(assigns) do | |
| 148 | + count = length(assigns.points) | |
| 149 | + pitch = pitch(count) | |
| 150 | + | |
| 151 | + assigns = | |
| 152 | + assigns | |
| 153 | + |> assign(:count, count) | |
| 154 | + |> assign(:pitch, pitch) | |
| 155 | + |> assign(:bar_width, bar_width(pitch)) | |
| 156 | + |> assign(:peak, peak_seconds(assigns.points)) | |
| 157 | + |> assign(:viewbox_width, @viewbox_width) | |
| 158 | + | |
| 159 | + ~H""" | |
| 160 | + <div class={@class}> | |
| 161 | + <svg | |
| 162 | + :if={@count > 0} | |
| 163 | + viewBox={"0 0 #{@viewbox_width} #{@height}"} | |
| 164 | + preserveAspectRatio="none" | |
| 165 | + class="w-full" | |
| 166 | + style={"height: #{@height}px"} | |
| 167 | + role="img" | |
| 168 | + aria-label={"Run durations, #{@count} finished runs, median #{humanize_secs(@median)}"} | |
| 169 | + > | |
| 170 | + <line | |
| 171 | + :if={!@compact && @median} | |
| 172 | + x1="0" | |
| 173 | + x2={@count * @pitch} | |
| 174 | + y1={baseline(@median, @peak, @height)} | |
| 175 | + y2={baseline(@median, @peak, @height)} | |
| 176 | + stroke="currentColor" | |
| 177 | + stroke-width="1" | |
| 178 | + stroke-dasharray="3 3" | |
| 179 | + class="opacity-30" | |
| 180 | + /> | |
| 181 | + <g :for={{point, index} <- Enum.with_index(@points)}> | |
| 182 | + <rect | |
| 183 | + x={index * @pitch} | |
| 184 | + y={@height - bar_height(point.seconds, @peak, @height)} | |
| 185 | + width={@bar_width} | |
| 186 | + height={bar_height(point.seconds, @peak, @height)} | |
| 187 | + rx="2" | |
| 188 | + fill={outcome_fill(point)} | |
| 189 | + > | |
| 190 | + <title> | |
| 191 | + #{point.number} {point.workflow_name} · {humanize_secs(point.seconds)} · {outcome(point)} | |
| 192 | + </title> | |
| 193 | + </rect> | |
| 194 | + </g> | |
| 195 | + </svg> | |
| 196 | + | |
| 197 | + <p :if={@count == 0} class="text-xs opacity-60">No finished runs yet.</p> | |
| 198 | + | |
| 199 | + <div :if={!@compact && @count > 0} class="flex flex-wrap items-center gap-x-4 gap-y-1 mt-1"> | |
| 200 | + <span class="text-xs opacity-70"> | |
| 201 | + median {humanize_secs(@median)} · peak {humanize_secs(@peak)} over {@count} run{if @count == | |
| 202 | + 1, | |
| 203 | + do: "", | |
| 204 | + else: "s"} | |
| 205 | + </span> | |
| 206 | + <span class="text-xs opacity-70 flex items-center gap-3"> | |
| 207 | + <span :for={{label, fill} <- legend()} class="flex items-center gap-1"> | |
| 208 | + <span class="inline-block size-2 rounded-sm" style={"background: #{fill}"} />{label} | |
| 209 | + </span> | |
| 210 | + </span> | |
| 211 | + </div> | |
| 212 | + </div> | |
| 213 | + """ | |
| 214 | + end | |
| 215 | + | |
| 216 | + defp pitch(0), do: @max_pitch | |
| 217 | + defp pitch(count), do: min(@viewbox_width / count, @max_pitch) | |
| 218 | + | |
| 219 | + defp bar_width(pitch), do: pitch |> Kernel.*(0.55) |> min(6.0) |> max(2.0) | |
| 220 | + | |
| 221 | + defp legend do | |
| 222 | + [ | |
| 223 | + {"succeeded", "var(--color-success)"}, | |
| 224 | + {"failed", "var(--color-error)"}, | |
| 225 | + {"other", "var(--color-base-content)"} | |
| 226 | + ] | |
| 227 | + end | |
| 228 | + | |
| 229 | + # `conclusion` once a run has finished, else its raw status — the two | |
| 230 | + # columns say different things and a finished run's verdict is in the | |
| 231 | + # second one. | |
| 232 | + defp outcome(%{status: status, conclusion: conclusion}) do | |
| 233 | + if status in ~w(completed finished) and is_binary(conclusion), do: conclusion, else: status | |
| 234 | + end | |
| 235 | + | |
| 236 | + defp outcome_fill(point) do | |
| 237 | + case outcome(point) do | |
| 238 | + "success" -> "var(--color-success)" | |
| 239 | + "failure" -> "var(--color-error)" | |
| 240 | + "timed_out" -> "var(--color-error)" | |
| 241 | + # Cancelled, skipped, and anything a newer server invents: a | |
| 242 | + # state, but not one anybody needs to pick out of the row. | |
| 243 | + _ -> "color-mix(in oklab, var(--color-base-content) 35%, transparent)" | |
| 244 | + end | |
| 245 | + end | |
| 246 | + | |
| 247 | + # Named in the summary as well as drawn: without a y-axis, the peak is | |
| 248 | + # what tells a reader how tall the tallest bar is. | |
| 249 | + # | |
| 250 | + # The tallest bar is the longest run, so a repository whose runs take | |
| 251 | + # nine seconds gets a readable chart and so does one whose runs take | |
| 252 | + # an hour. A floor of one second keeps a series of instant runs from | |
| 253 | + # dividing by zero. | |
| 254 | + defp peak_seconds([]), do: 1 | |
| 255 | + defp peak_seconds(points), do: points |> Enum.map(& &1.seconds) |> Enum.max() |> max(1) | |
| 256 | + | |
| 257 | + # A finished run always draws something: a zero-height bar reads as | |
| 258 | + # "no run", which is a different fact from "a run that took no time". | |
| 259 | + defp bar_height(seconds, peak, height), do: max(2, round(seconds / peak * (height - 4))) | |
| 260 | + | |
| 261 | + defp baseline(seconds, peak, height), do: height - bar_height(seconds, peak, height) | |
| 262 | + | |
| 263 | + defp humanize_secs(nil), do: "—" | |
| 264 | + defp humanize_secs(s) when s < 60, do: "#{s}s" | |
| 265 | + defp humanize_secs(s) when s < 3600, do: "#{div(s, 60)}m#{pad(rem(s, 60))}s" | |
| 266 | + defp humanize_secs(s), do: "#{div(s, 3600)}h#{pad(div(rem(s, 3600), 60))}m" | |
| 267 | + | |
| 268 | + defp pad(n) when n < 10, do: "0#{n}" | |
| 269 | + defp pad(n), do: "#{n}" | |
| 270 | + | |
| 108 | 271 | def status_class("success"), do: "badge-success" |
| 109 | 272 | def status_class("running"), do: "badge-info" |
| 110 | 273 | def status_class("queued"), do: "badge-warning" |
modified
lib/git_gud_web/live/org_live/actions.ex
+83
−2
@@ -34,7 +34,8 @@ defmodule GitGudWeb.OrgLive.Actions do
| 34 | 34 | |> assign(:repo_ids, repo_ids) |
| 35 | 35 | |> assign(:filter, :active) |
| 36 | 36 | |> assign(:page_title, "CI — @#{org.handle}") |
| 37 | − |> load_runs()} | |
| 37 | + |> load_runs() | |
| 38 | + |> load_durations()} | |
| 38 | 39 | else |
| 39 | 40 | {:ok, |
| 40 | 41 | socket |
@@ -43,6 +44,19 @@ defmodule GitGudWeb.OrgLive.Actions do
| 43 | 44 | end |
| 44 | 45 | end |
| 45 | 46 | |
| 47 | + defp repo_handle(repo), do: GitGud.Repositories.Storage.repo_handle(repo) | |
| 48 | + | |
| 49 | + # Minutes *and* seconds under an hour: three repositories whose runs | |
| 50 | + # take 70, 95 and 110 seconds all read as "1m" otherwise, which is | |
| 51 | + # the column saying nothing. | |
| 52 | + defp humanize(nil), do: "—" | |
| 53 | + defp humanize(s) when s < 60, do: "#{s}s" | |
| 54 | + defp humanize(s) when s < 3600, do: "#{div(s, 60)}m#{pad(rem(s, 60))}s" | |
| 55 | + defp humanize(s), do: "#{div(s, 3600)}h#{pad(div(rem(s, 3600), 60))}m" | |
| 56 | + | |
| 57 | + defp pad(n) when n < 10, do: "0#{n}" | |
| 58 | + defp pad(n), do: "#{n}" | |
| 59 | + | |
| 46 | 60 | defp can_view?(%{visibility: "public"}, _user), do: true |
| 47 | 61 | defp can_view?(%{visibility: "internal"}, %{} = _user), do: true |
| 48 | 62 | defp can_view?(%{visibility: "internal"}, nil), do: false |
@@ -72,7 +86,39 @@ defmodule GitGudWeb.OrgLive.Actions do
| 72 | 86 | |
| 73 | 87 | @impl true |
| 74 | 88 | def handle_info({:ci_run_changed, _run_id}, socket) do |
| 75 | − {:noreply, load_runs(socket)} | |
| 89 | + {:noreply, socket |> load_runs() |> load_durations()} | |
| 90 | + end | |
| 91 | + | |
| 92 | + # Small multiples: one series per repository, all from one query, so | |
| 93 | + # the page costs the same whether the org has three repositories with | |
| 94 | + # CI or thirty. | |
| 95 | + # | |
| 96 | + # Ordered by the repository whose last run is newest — an org | |
| 97 | + # dashboard is read to see what has been happening — and capped, | |
| 98 | + # because past a screenful of sparklines nobody is reading them. | |
| 99 | + @chart_repos 12 | |
| 100 | + @per_repo 20 | |
| 101 | + | |
| 102 | + defp load_durations(socket) do | |
| 103 | + histories = Workflows.run_durations_for_repos(socket.assigns.repo_ids, per_repo: @per_repo) | |
| 104 | + repos = Repositories.list_repositories_by_ids(Map.keys(histories)) | |
| 105 | + | |
| 106 | + series = | |
| 107 | + histories | |
| 108 | + |> Enum.flat_map(fn {rid, points} -> | |
| 109 | + case Map.fetch(repos, rid) do | |
| 110 | + {:ok, repo} -> | |
| 111 | + [%{repo: repo, points: points, median: Workflows.median_duration(points)}] | |
| 112 | + | |
| 113 | + :error -> | |
| 114 | + [] | |
| 115 | + end | |
| 116 | + end) | |
| 117 | + |> Enum.sort_by(&(&1.points |> List.last() |> Map.fetch!(:started_at)), {:desc, DateTime}) | |
| 118 | + | |
| 119 | + socket | |
| 120 | + |> assign(:duration_total, length(series)) | |
| 121 | + |> assign(:duration_series, Enum.take(series, @chart_repos)) | |
| 76 | 122 | end |
| 77 | 123 | |
| 78 | 124 | @impl true |
@@ -90,6 +136,41 @@ defmodule GitGudWeb.OrgLive.Actions do
| 90 | 136 | <.filter_tabs current={@filter} active_count={@active_count} /> |
| 91 | 137 | </header> |
| 92 | 138 | |
| 139 | + <section :if={@duration_series != []} class="rounded-box border border-base-300 p-3"> | |
| 140 | + <header class="flex items-baseline justify-between mb-2"> | |
| 141 | + <h2 class="text-sm font-medium">Run times</h2> | |
| 142 | + <span :if={@duration_total > length(@duration_series)} class="text-xs opacity-60"> | |
| 143 | + {length(@duration_series)} of {@duration_total} repositories | |
| 144 | + </span> | |
| 145 | + </header> | |
| 146 | + | |
| 147 | + <ul class="space-y-2"> | |
| 148 | + <li | |
| 149 | + :for={series <- @duration_series} | |
| 150 | + class="grid grid-cols-[minmax(6rem,12rem)_1fr_auto] items-center gap-3" | |
| 151 | + > | |
| 152 | + <.link | |
| 153 | + navigate={~p"/r/#{repo_handle(series.repo)}/#{series.repo.name}/actions"} | |
| 154 | + class="link link-hover text-xs font-mono truncate" | |
| 155 | + > | |
| 156 | + {series.repo.name} | |
| 157 | + </.link> | |
| 158 | + <GitGudWeb.CiComponents.duration_chart | |
| 159 | + points={series.points} | |
| 160 | + median={series.median} | |
| 161 | + compact={true} | |
| 162 | + height={28} | |
| 163 | + /> | |
| 164 | + <span class="text-xs opacity-70 tabular-nums">{humanize(series.median)}</span> | |
| 165 | + </li> | |
| 166 | + </ul> | |
| 167 | + | |
| 168 | + <p class="text-xs opacity-60 mt-2"> | |
| 169 | + Each bar is one finished run, oldest left, scaled to that repository's own | |
| 170 | + longest run; the number is its median. | |
| 171 | + </p> | |
| 172 | + </section> | |
| 173 | + | |
| 93 | 174 | <ul class="divide-y divide-base-300"> |
| 94 | 175 | <li :if={@runs == []} class="py-6 opacity-60 text-sm"> |
| 95 | 176 | {if @filter == :active, do: "No runs in flight.", else: "No workflow runs yet."} |
modified
lib/git_gud_web/live/workflow_live/index.ex
+21
−1
@@ -25,6 +25,7 @@ defmodule GitGudWeb.WorkflowLive.Index do
| 25 | 25 | |> assign(:handle, Storage.repo_handle(repo)) |
| 26 | 26 | |> GitGudWeb.RepoLive.Header.assign_chrome(repo) |
| 27 | 27 | |> stream(:runs, runs) |
| 28 | + |> assign_durations() | |
| 28 | 29 | |> assign(:page_title, "Actions — #{owner}/#{name}")} |
| 29 | 30 | end |
| 30 | 31 |
@@ -35,11 +36,22 @@ defmodule GitGudWeb.WorkflowLive.Index do
| 35 | 36 | {:noreply, |
| 36 | 37 | socket |
| 37 | 38 | |> stream(:runs, Workflows.list_runs(repo), reset: true) |
| 38 | − |> assign(:latest_run, Workflows.latest_run(repo))} | |
| 39 | + |> assign(:latest_run, Workflows.latest_run(repo)) | |
| 40 | + |> assign_durations()} | |
| 39 | 41 | end |
| 40 | 42 | |
| 41 | 43 | def handle_info(_msg, socket), do: {:noreply, socket} |
| 42 | 44 | |
| 45 | + # A run finishing is what adds a bar, so the chart is recomputed on | |
| 46 | + # the same event that redraws the list. | |
| 47 | + defp assign_durations(socket) do | |
| 48 | + durations = Workflows.run_durations(socket.assigns.repo) | |
| 49 | + | |
| 50 | + socket | |
| 51 | + |> assign(:durations, durations) | |
| 52 | + |> assign(:median_duration, Workflows.median_duration(durations)) | |
| 53 | + end | |
| 54 | + | |
| 43 | 55 | @impl true |
| 44 | 56 | def render(assigns) do |
| 45 | 57 | ~H""" |
@@ -56,6 +68,14 @@ defmodule GitGudWeb.WorkflowLive.Index do
| 56 | 68 | /> |
| 57 | 69 | <h1 class="text-xl font-semibold">Actions</h1> |
| 58 | 70 | |
| 71 | + <section :if={@durations != []} class="rounded-box border border-base-300 p-3"> | |
| 72 | + <h2 class="text-sm font-medium mb-2">Run times</h2> | |
| 73 | + <GitGudWeb.CiComponents.duration_chart | |
| 74 | + points={@durations} | |
| 75 | + median={@median_duration} | |
| 76 | + /> | |
| 77 | + </section> | |
| 78 | + | |
| 59 | 79 | <ul id="runs" phx-update="stream" class="divide-y divide-base-300"> |
| 60 | 80 | <li id="runs-empty" class="hidden only:block py-6 opacity-60 text-sm"> |
| 61 | 81 | No workflow runs yet. |
added
test/git_gud/workflows_durations_test.exs
+141
−0
@@ -0,0 +1,141 @@
| 1 | +defmodule GitGud.WorkflowsDurationsTest do | |
| 2 | + @moduledoc """ | |
| 3 | + The run-duration history behind the CI charts. | |
| 4 | + | |
| 5 | + What the charts need and the run list doesn't: only finished runs, | |
| 6 | + oldest first, with the duration computed once in the database rather | |
| 7 | + than re-derived per view. | |
| 8 | + """ | |
| 9 | + use GitGud.DataCase, async: false | |
| 10 | + | |
| 11 | + import GitGud.ForgeFixtures | |
| 12 | + | |
| 13 | + alias GitGud.Workflows | |
| 14 | + alias GitGud.Workflows.WorkflowRun | |
| 15 | + | |
| 16 | + setup do | |
| 17 | + {user, repo} = repository_fixture() | |
| 18 | + %{user: user, repo: repo} | |
| 19 | + end | |
| 20 | + | |
| 21 | + defp run(repo, number, opts) do | |
| 22 | + # `:utc_datetime` columns reject microseconds, and `utc_now/0` has | |
| 23 | + # them. | |
| 24 | + default_start = DateTime.add(DateTime.utc_now(:second), -number * 60, :second) | |
| 25 | + started = Keyword.get(opts, :started, default_start) | |
| 26 | + seconds = Keyword.get(opts, :seconds) | |
| 27 | + | |
| 28 | + %WorkflowRun{repository_id: repo.id} | |
| 29 | + |> WorkflowRun.create_changeset(%{ | |
| 30 | + number: number, | |
| 31 | + workflow_path: ".forgejo/workflows/build.yml", | |
| 32 | + trigger: "push", | |
| 33 | + head_sha: :crypto.strong_rand_bytes(20) | |
| 34 | + }) | |
| 35 | + |> Ecto.Changeset.change( | |
| 36 | + status: Keyword.get(opts, :status, "success"), | |
| 37 | + conclusion: Keyword.get(opts, :conclusion, "success"), | |
| 38 | + started_at: started, | |
| 39 | + completed_at: seconds && DateTime.add(started, seconds, :second) | |
| 40 | + ) | |
| 41 | + |> GitGud.Repo.insert!() | |
| 42 | + end | |
| 43 | + | |
| 44 | + describe "run_durations/2" do | |
| 45 | + test "returns finished runs oldest first, with their duration", %{repo: repo} do | |
| 46 | + run(repo, 1, seconds: 30, started: ~U[2026-09-01 10:00:00Z]) | |
| 47 | + run(repo, 2, seconds: 90, started: ~U[2026-09-02 10:00:00Z]) | |
| 48 | + | |
| 49 | + assert [first, second] = Workflows.run_durations(repo) | |
| 50 | + | |
| 51 | + assert first.number == 1 | |
| 52 | + assert first.seconds == 30 | |
| 53 | + assert second.number == 2 | |
| 54 | + assert second.seconds == 90 | |
| 55 | + end | |
| 56 | + | |
| 57 | + test "skips runs that haven't finished", %{repo: repo} do | |
| 58 | + run(repo, 1, seconds: 30) | |
| 59 | + run(repo, 2, seconds: nil, status: "running", conclusion: nil) | |
| 60 | + | |
| 61 | + assert [only] = Workflows.run_durations(repo) | |
| 62 | + assert only.number == 1 | |
| 63 | + end | |
| 64 | + | |
| 65 | + test "keeps failures — a slow failing run is the interesting one", %{repo: repo} do | |
| 66 | + run(repo, 1, seconds: 10, status: "failure", conclusion: "failure") | |
| 67 | + | |
| 68 | + assert [point] = Workflows.run_durations(repo) | |
| 69 | + assert point.conclusion == "failure" | |
| 70 | + end | |
| 71 | + | |
| 72 | + test "honours the limit, keeping the most recent", %{repo: repo} do | |
| 73 | + for n <- 1..5, | |
| 74 | + do: | |
| 75 | + run(repo, n, | |
| 76 | + seconds: n, | |
| 77 | + started: DateTime.add(~U[2026-09-01 00:00:00Z], n * 3600, :second) | |
| 78 | + ) | |
| 79 | + | |
| 80 | + points = Workflows.run_durations(repo, limit: 2) | |
| 81 | + | |
| 82 | + assert Enum.map(points, & &1.number) == [4, 5] | |
| 83 | + end | |
| 84 | + | |
| 85 | + test "a clock that went backwards doesn't make a negative bar", %{repo: repo} do | |
| 86 | + run(repo, 1, seconds: -30) | |
| 87 | + | |
| 88 | + assert [point] = Workflows.run_durations(repo) | |
| 89 | + assert point.seconds == 0 | |
| 90 | + end | |
| 91 | + end | |
| 92 | + | |
| 93 | + describe "run_durations_for_repos/2" do | |
| 94 | + test "groups by repository in one query", %{repo: repo, user: user} do | |
| 95 | + {_user, other} = repository_fixture(%{owner: user}) | |
| 96 | + run(repo, 1, seconds: 10) | |
| 97 | + run(other, 1, seconds: 20) | |
| 98 | + | |
| 99 | + histories = Workflows.run_durations_for_repos([repo.id, other.id]) | |
| 100 | + | |
| 101 | + assert [%{seconds: 10}] = histories[repo.id] | |
| 102 | + assert [%{seconds: 20}] = histories[other.id] | |
| 103 | + end | |
| 104 | + | |
| 105 | + test "caps each repository's series independently", %{repo: repo} do | |
| 106 | + for n <- 1..5, | |
| 107 | + do: | |
| 108 | + run(repo, n, | |
| 109 | + seconds: n, | |
| 110 | + started: DateTime.add(~U[2026-09-01 00:00:00Z], n * 3600, :second) | |
| 111 | + ) | |
| 112 | + | |
| 113 | + assert [%{number: 4}, %{number: 5}] = | |
| 114 | + Workflows.run_durations_for_repos([repo.id], per_repo: 2)[repo.id] | |
| 115 | + end | |
| 116 | + | |
| 117 | + test "is empty for no repositories, rather than querying for none" do | |
| 118 | + assert Workflows.run_durations_for_repos([]) == %{} | |
| 119 | + end | |
| 120 | + end | |
| 121 | + | |
| 122 | + describe "median_duration/1" do | |
| 123 | + test "is the middle of an odd series" do | |
| 124 | + assert Workflows.median_duration([%{seconds: 10}, %{seconds: 90}, %{seconds: 20}]) == 20 | |
| 125 | + end | |
| 126 | + | |
| 127 | + test "averages the two middles of an even one" do | |
| 128 | + assert Workflows.median_duration([%{seconds: 10}, %{seconds: 20}]) == 15 | |
| 129 | + end | |
| 130 | + | |
| 131 | + test "resists one pathological run, which a mean would not" do | |
| 132 | + points = Enum.map([10, 10, 10, 10, 6000], &%{seconds: &1}) | |
| 133 | + | |
| 134 | + assert Workflows.median_duration(points) == 10 | |
| 135 | + end | |
| 136 | + | |
| 137 | + test "is nil for nothing" do | |
| 138 | + assert Workflows.median_duration([]) == nil | |
| 139 | + end | |
| 140 | + end | |
| 141 | +end |
added
test/git_gud_web/live/ci_duration_chart_test.exs
+120
−0
@@ -0,0 +1,120 @@
| 1 | +defmodule GitGudWeb.CiDurationChartTest do | |
| 2 | + @moduledoc """ | |
| 3 | + The run-time history on both CI dashboards. | |
| 4 | + | |
| 5 | + Colour is status here, so the things that keep it readable without | |
| 6 | + colour are asserted too: every bar names its outcome in a tooltip, | |
| 7 | + and the legend spells the states out. | |
| 8 | + """ | |
| 9 | + use GitGudWeb.ConnCase, async: false | |
| 10 | + | |
| 11 | + import Phoenix.LiveViewTest | |
| 12 | + import GitGud.ForgeFixtures | |
| 13 | + | |
| 14 | + alias GitGud.Workflows.WorkflowRun | |
| 15 | + | |
| 16 | + setup %{conn: conn} do | |
| 17 | + {user, repo} = repository_fixture(%{visibility: "public"}) | |
| 18 | + %{conn: log_in_user(conn, user), user: user, repo: repo, handle: user.handle} | |
| 19 | + end | |
| 20 | + | |
| 21 | + defp finished_run(repo, number, seconds, conclusion \\ "success") do | |
| 22 | + started = DateTime.add(~U[2026-09-01 00:00:00Z], number * 3600, :second) | |
| 23 | + | |
| 24 | + %WorkflowRun{repository_id: repo.id} | |
| 25 | + |> WorkflowRun.create_changeset(%{ | |
| 26 | + number: number, | |
| 27 | + workflow_path: ".forgejo/workflows/build.yml", | |
| 28 | + workflow_name: "build", | |
| 29 | + trigger: "push", | |
| 30 | + head_sha: :crypto.strong_rand_bytes(20) | |
| 31 | + }) | |
| 32 | + |> Ecto.Changeset.change( | |
| 33 | + status: conclusion, | |
| 34 | + conclusion: conclusion, | |
| 35 | + started_at: started, | |
| 36 | + completed_at: DateTime.add(started, seconds, :second) | |
| 37 | + ) | |
| 38 | + |> GitGud.Repo.insert!() | |
| 39 | + end | |
| 40 | + | |
| 41 | + describe "the repository dashboard" do | |
| 42 | + test "charts finished runs with a median", %{conn: conn, repo: repo, handle: handle} do | |
| 43 | + finished_run(repo, 1, 30) | |
| 44 | + finished_run(repo, 2, 90) | |
| 45 | + finished_run(repo, 3, 600, "failure") | |
| 46 | + | |
| 47 | + {:ok, _view, html} = live(conn, ~p"/r/#{handle}/#{repo.name}/actions") | |
| 48 | + | |
| 49 | + assert html =~ "Run times" | |
| 50 | + # The peak bounds the scale for a chart with no y-axis. | |
| 51 | + assert html =~ "median 1m30s" | |
| 52 | + assert html =~ "peak 10m00s" | |
| 53 | + assert html =~ "over 3 runs" | |
| 54 | + | |
| 55 | + # Status is never colour alone: each bar says what it was. | |
| 56 | + assert html =~ "1m30s" | |
| 57 | + assert html =~ "10m00s" | |
| 58 | + assert html =~ "succeeded" | |
| 59 | + assert html =~ "failed" | |
| 60 | + end | |
| 61 | + | |
| 62 | + test "says nothing at all when no run has finished", %{conn: conn, repo: repo, handle: handle} do | |
| 63 | + {:ok, _view, html} = live(conn, ~p"/r/#{handle}/#{repo.name}/actions") | |
| 64 | + | |
| 65 | + refute html =~ "Run times" | |
| 66 | + end | |
| 67 | + | |
| 68 | + test "a running run isn't charted — its duration is still changing", %{ | |
| 69 | + conn: conn, | |
| 70 | + repo: repo, | |
| 71 | + handle: handle | |
| 72 | + } do | |
| 73 | + finished_run(repo, 1, 45) | |
| 74 | + | |
| 75 | + %WorkflowRun{repository_id: repo.id} | |
| 76 | + |> WorkflowRun.create_changeset(%{ | |
| 77 | + number: 2, | |
| 78 | + workflow_path: ".forgejo/workflows/build.yml", | |
| 79 | + trigger: "push", | |
| 80 | + head_sha: :crypto.strong_rand_bytes(20) | |
| 81 | + }) | |
| 82 | + |> Ecto.Changeset.change(status: "running", started_at: DateTime.utc_now(:second)) | |
| 83 | + |> GitGud.Repo.insert!() | |
| 84 | + | |
| 85 | + {:ok, _view, html} = live(conn, ~p"/r/#{handle}/#{repo.name}/actions") | |
| 86 | + | |
| 87 | + assert html =~ "over 1 run" | |
| 88 | + end | |
| 89 | + end | |
| 90 | + | |
| 91 | + describe "the organization dashboard" do | |
| 92 | + setup %{user: user} do | |
| 93 | + {:ok, org} = | |
| 94 | + GitGud.Organizations.create_organization(user, %{ | |
| 95 | + "handle" => "org#{System.unique_integer([:positive])}", | |
| 96 | + "name" => "Test Org" | |
| 97 | + }) | |
| 98 | + | |
| 99 | + {:ok, repo} = | |
| 100 | + GitGud.Repositories.create_repository_for_org(org, user, %{ | |
| 101 | + "name" => "widgets", | |
| 102 | + "visibility" => "public" | |
| 103 | + }) | |
| 104 | + | |
| 105 | + %{org: org, org_repo: repo} | |
| 106 | + end | |
| 107 | + | |
| 108 | + test "draws one sparkline per repository", %{conn: conn, org: org, org_repo: repo} do | |
| 109 | + finished_run(repo, 1, 120) | |
| 110 | + finished_run(repo, 2, 240) | |
| 111 | + | |
| 112 | + {:ok, _view, html} = live(conn, ~p"/orgs/#{org.handle}/actions") | |
| 113 | + | |
| 114 | + assert html =~ "Run times" | |
| 115 | + assert html =~ "widgets" | |
| 116 | + # The median, in the compact spelling the row uses. | |
| 117 | + assert html =~ "3m" | |
| 118 | + end | |
| 119 | + end | |
| 120 | +end |
Parents: 1003c50