From 0f2fd7f0ae29254bec1e69019f9c66295afa61a4 Mon Sep 17 00:00:00 2001 From: Patricio Cano Date: Mon, 25 Jun 2018 11:48:17 -0500 Subject: [PATCH] Allow build and pipeline status to be updated automatically Use channels to send updated status of each build, when in the build view, or of each pipeline when in the pipeline view. Closes #40 --- assets/js/app.js | 6 ++- assets/js/controllers/build_controller.js | 38 ++++++++++++++++ assets/js/controllers/builds_controller.js | 31 +++---------- assets/js/controllers/pipelines_controller.js | 16 +++++++ lib/alloy_ci/builds/builds.ex | 8 ++-- lib/alloy_ci/lib/artifact_sweeper.ex | 5 +++ lib/alloy_ci/pipelines/pipelines.ex | 11 +++-- lib/alloy_ci/web/channels/builds_channel.ex | 21 +++++++-- .../web/channels/pipelines_channel.ex | 44 +++++++++++++++++++ lib/alloy_ci/web/channels/user_socket.ex | 3 +- .../web/templates/build/show.html.eex | 4 +- .../web/templates/pipeline/build.html.eex | 22 ++++++++++ .../web/templates/pipeline/show.html.eex | 23 ++-------- .../web/templates/project/pipeline.html.eex | 38 ++++++++++++++++ .../web/templates/project/show.html.eex | 41 +++-------------- test/pipelines/pipelines_test.exs | 6 +-- test/web/channels/builds_channel_test.exs | 9 +++- test/web/channels/pipelines_channel_test.exs | 40 +++++++++++++++++ 18 files changed, 267 insertions(+), 99 deletions(-) create mode 100644 assets/js/controllers/build_controller.js create mode 100644 assets/js/controllers/pipelines_controller.js create mode 100644 lib/alloy_ci/web/channels/pipelines_channel.ex create mode 100644 lib/alloy_ci/web/templates/pipeline/build.html.eex create mode 100644 lib/alloy_ci/web/templates/project/pipeline.html.eex create mode 100644 test/web/channels/pipelines_channel_test.exs diff --git a/assets/js/app.js b/assets/js/app.js index dfbcfa06..9d461bc7 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -16,15 +16,19 @@ import "phoenix_html" // Stimulus data import { Application } from "stimulus" +import BuildController from "./controllers/build_controller" import BuildsController from "./controllers/builds_controller" import ChartsController from "./controllers/charts_controller" +import PipelinesController from "./controllers/pipelines_controller" import ProjectsController from "./controllers/projects_controller" import ReposController from "./controllers/repos_controller" import TagsController from "./controllers/tags_controller" const application = Application.start() +application.register("build", BuildController) application.register("builds", BuildsController) application.register("charts", ChartsController) +application.register("pipelines", PipelinesController) application.register("projects", ProjectsController) application.register("repos", ReposController) application.register("tags", TagsController) @@ -61,7 +65,7 @@ if($("#aside").hasClass("page-aside")) { $(".alert").addClass("aci-aside") } -var +const collapsibleSidebarClass = "aci-collapsible-sidebar", collapsibleSidebarCollapsedClass = "aci-collapsible-sidebar-collapsed", sidebar = $(".aci-left-sidebar"), diff --git a/assets/js/controllers/build_controller.js b/assets/js/controllers/build_controller.js new file mode 100644 index 00000000..bfcfd08f --- /dev/null +++ b/assets/js/controllers/build_controller.js @@ -0,0 +1,38 @@ +import { Controller } from "stimulus" +import socket from "../socket" + +export default class extends Controller { + + connect() { + let Ansi = require("ansi-to-html") + const ansi = new Ansi() + + const buildName = this.data.get("name") + const trace = this.data.get("trace") + const id = this.data.get("id") + + if (trace == "") { + var contents = "Build is pending" + } else { + var contents = ansi.toHtml(trace) + } + + $("#output").html(`

${buildName}

${contents.replace(/\n/g, "
")}`) + + let channel = socket.channel(`build:${id}`, {}) + channel.join() + .receive("ok", data => { console.log(`Joined successfully for build ${id}`, data) }) + .receive("error", data => { console.log("Unable to join", data) }) + + channel.on("append_trace", data => { + $("#output").append(ansi.toHtml(data.trace).replace(/\n/g, "
")) + $(window).scrollTop($(document).height()) + }) + + channel.on("replace_trace", data => { + setTimeout(function(){ + window.location.reload(true) + }, 1500) + }) + } +} diff --git a/assets/js/controllers/builds_controller.js b/assets/js/controllers/builds_controller.js index 3f17bdba..c3cd9e77 100644 --- a/assets/js/controllers/builds_controller.js +++ b/assets/js/controllers/builds_controller.js @@ -2,36 +2,17 @@ import { Controller } from "stimulus" import socket from "../socket" export default class extends Controller { - connect() { - let Ansi = require("ansi-to-html") - const ansi = new Ansi() - - const buildName = this.data.get("name") - const trace = this.data.get("trace") + const id = this.data.get("id") + const token = this.data.get("token") + let channel = socket.channel(`build:${id}`, {}) - if (trace == "") { - var contents = "Build is pending" - } else { - var contents = ansi.toHtml(trace) - } - - $("#output").html(`

${buildName}

${contents.replace(/\n/g, "
")}`) - - let channel = socket.channel(`builds:${this.data.get("id")}`, {}) channel.join() - .receive("ok", data => { console.log("Joined successfully", data) }) + .receive("ok", data => { console.log(`Joined successfully for build ${id}`, data) }) .receive("error", data => { console.log("Unable to join", data) }) - channel.on("append_trace", data => { - $("#output").append(ansi.toHtml(data.trace).replace(/\n/g, "
")) - $(window).scrollTop($(document).height()) - }) - - channel.on("replace_trace", data => { - setTimeout(function(){ - window.location.reload(true) - }, 1500) + channel.on("update_status", data => { + $(`#build-${id}`).html(data.content.replace(/data-csrf="{1}.*=="/g, `data-csrf="${token}"`)) }) } } diff --git a/assets/js/controllers/pipelines_controller.js b/assets/js/controllers/pipelines_controller.js new file mode 100644 index 00000000..6c3ecb47 --- /dev/null +++ b/assets/js/controllers/pipelines_controller.js @@ -0,0 +1,16 @@ +import { Controller } from "stimulus" +import socket from "../socket" + +export default class extends Controller { + connect() { + const id = this.data.get("id") + let channel = socket.channel(`pipeline:${id}`, {}) + channel.join() + .receive("ok", data => { console.log(`Joined successfully for pipeline ${id}`, data) }) + .receive("error", data => { console.log("Unable to join", data) }) + + channel.on("update_status", data => { + $(`#pipeline-${id}`).html(data.content) + }) + } +} diff --git a/lib/alloy_ci/builds/builds.ex b/lib/alloy_ci/builds/builds.ex index a58f2907..2f0d6e69 100644 --- a/lib/alloy_ci/builds/builds.ex +++ b/lib/alloy_ci/builds/builds.ex @@ -2,7 +2,7 @@ defmodule AlloyCi.Builds do @moduledoc """ The boundary for the Builds system. """ - alias AlloyCi.{Artifact, Build, Queuer, Pipelines, Projects, Repo, Workers} + alias AlloyCi.{Artifact, Build, Queuer, Pipelines, Projects, Repo, Workers, Web.BuildsChannel} import Ecto.Query, warn: false @github_api Application.get_env(:alloy_ci, :github_api) @@ -60,7 +60,7 @@ defmodule AlloyCi.Builds do end def clean_ref(ref) do - ref |> String.replace(ref |> ref_type() |> cleanup_string(), "") + String.replace(ref, ref |> ref_type() |> cleanup_string(), "") end def create_builds_from_config(content, pipeline) do @@ -160,7 +160,7 @@ defmodule AlloyCi.Builds do end def for_runner(runner) do - # Select builds whose tags are fully contained in the runner's tags + # Select a build whose tags are fully contained in the runner's tags Build |> where([b], b.status == "pending" and is_nil(b.runner_id)) |> where([b], fragment("? <@ ?", b.tags, ^runner.tags)) @@ -290,6 +290,7 @@ defmodule AlloyCi.Builds do "created" -> update_status(build, status || "running") "pending" -> update_status(build, status || "running") "running" -> update_status(build, status || "success") + _ -> {:ok, build} end end @@ -581,6 +582,7 @@ defmodule AlloyCi.Builds do case do_update_status(build, status) do {:ok, build} -> Queuer.push(Workers.ProcessPipelineWorker, build.pipeline_id) + BuildsChannel.update_status(build) build {:error, _} -> diff --git a/lib/alloy_ci/lib/artifact_sweeper.ex b/lib/alloy_ci/lib/artifact_sweeper.ex index d6e3e74a..0918f41a 100644 --- a/lib/alloy_ci/lib/artifact_sweeper.ex +++ b/lib/alloy_ci/lib/artifact_sweeper.ex @@ -31,22 +31,27 @@ defmodule AlloyCi.ArtifactSweeper do GenServer.call(__MODULE__, :sweep) end + @impl true def init(state) do {:ok, schedule_work(self(), state)} end + @impl true def handle_call(:reset_timer, _from, state) do {:reply, :ok, schedule_work(self(), state)} end + @impl true def handle_call(:sweep, _from, state) do {:reply, :ok, sweep(self(), state)} end + @impl true def handle_info(:sweep, state) do {:noreply, sweep(self(), state)} end + @impl true def handle_info(_, state), do: {:noreply, state} defp parse_interval(interval) do diff --git a/lib/alloy_ci/pipelines/pipelines.ex b/lib/alloy_ci/pipelines/pipelines.ex index 25c3bbea..50b2f051 100644 --- a/lib/alloy_ci/pipelines/pipelines.ex +++ b/lib/alloy_ci/pipelines/pipelines.ex @@ -11,6 +11,7 @@ defmodule AlloyCi.Pipelines do Projects, Queuer, Repo, + Web.PipelinesChannel, Workers.CreateBuildsWorker } @@ -172,9 +173,13 @@ defmodule AlloyCi.Pipelines do end def update_pipeline(%Pipeline{} = pipeline, params) do - pipeline - |> Pipeline.changeset(params) - |> Repo.update() + with {:ok, pipeline} <- + pipeline + |> Pipeline.changeset(params) + |> Repo.update() do + PipelinesChannel.update_status(pipeline |> Repo.preload(:project)) + {:ok, pipeline} + end end def update_status(pipeline_id) do diff --git a/lib/alloy_ci/web/channels/builds_channel.ex b/lib/alloy_ci/web/channels/builds_channel.ex index de0df7dc..e9b63d66 100644 --- a/lib/alloy_ci/web/channels/builds_channel.ex +++ b/lib/alloy_ci/web/channels/builds_channel.ex @@ -1,6 +1,6 @@ defmodule AlloyCi.Web.BuildsChannel do @moduledoc false - alias AlloyCi.{Builds, Projects, Web.Endpoint} + alias AlloyCi.{Builds, Projects, Web.Endpoint, Web.PipelineView} use AlloyCi.Web, :channel # Channels can be used in a request/response fashion @@ -16,7 +16,7 @@ defmodule AlloyCi.Web.BuildsChannel do {:noreply, socket} end - def join("builds:" <> build_id, _payload, socket) do + def join("build:" <> build_id, _payload, socket) do build = Builds.get(build_id) if Projects.can_access?(build.project_id, %{id: socket.assigns.user_id}) do @@ -27,12 +27,25 @@ defmodule AlloyCi.Web.BuildsChannel do end def replace_trace(build_id, trace) do - Endpoint.broadcast("builds:#{build_id}", "replace_trace", %{trace: trace}) + Endpoint.broadcast("build:#{build_id}", "replace_trace", %{trace: trace}) end def send_trace(_build_id, ""), do: nil def send_trace(build_id, trace) do - Endpoint.broadcast("builds:#{build_id}", "append_trace", %{trace: trace}) + Endpoint.broadcast("build:#{build_id}", "append_trace", %{trace: trace}) + end + + def update_status(build) do + Endpoint.broadcast("build:#{build.id}", "update_status", %{content: render_build(build)}) + end + + def render_build(build) do + Phoenix.View.render_to_string( + PipelineView, + "build.html", + build: build, + conn: %Plug.Conn{} + ) end end diff --git a/lib/alloy_ci/web/channels/pipelines_channel.ex b/lib/alloy_ci/web/channels/pipelines_channel.ex new file mode 100644 index 00000000..45eeac41 --- /dev/null +++ b/lib/alloy_ci/web/channels/pipelines_channel.ex @@ -0,0 +1,44 @@ +defmodule AlloyCi.Web.PipelinesChannel do + @moduledoc false + alias AlloyCi.{Pipelines, Projects, Web.Endpoint, Web.ProjectView} + use AlloyCi.Web, :channel + + # Channels can be used in a request/response fashion + # by sending replies to requests from the client + def handle_in("ping", payload, socket) do + {:reply, {:ok, payload}, socket} + end + + # It is also common to receive messages from the client and + # broadcast to everyone in the current topic (pipelines:lobby). + def handle_in("shout", payload, socket) do + broadcast(socket, "shout", payload) + {:noreply, socket} + end + + def join("pipeline:" <> pipeline_id, _payload, socket) do + pipeline = Pipelines.get(pipeline_id) + + if Projects.can_access?(pipeline.project_id, %{id: socket.assigns.user_id}) do + {:ok, socket} + else + {:error, %{reason: "Unauthorized"}} + end + end + + def update_status(pipeline) do + Endpoint.broadcast("pipeline:#{pipeline.id}", "update_status", %{ + content: render_pipeline(pipeline) + }) + end + + defp render_pipeline(pipeline) do + Phoenix.View.render_to_string( + ProjectView, + "pipeline.html", + pipeline: pipeline, + project: pipeline.project, + conn: %Plug.Conn{} + ) + end +end diff --git a/lib/alloy_ci/web/channels/user_socket.ex b/lib/alloy_ci/web/channels/user_socket.ex index 9e8b7853..2d815f4f 100644 --- a/lib/alloy_ci/web/channels/user_socket.ex +++ b/lib/alloy_ci/web/channels/user_socket.ex @@ -3,7 +3,8 @@ defmodule AlloyCi.Web.UserSocket do alias AlloyCi.Accounts ## Channels - channel("builds:*", AlloyCi.Web.BuildsChannel) + channel("build:*", AlloyCi.Web.BuildsChannel) + channel("pipeline:*", AlloyCi.Web.PipelinesChannel) channel("repos:*", AlloyCi.Web.ReposChannel) ## Transports diff --git a/lib/alloy_ci/web/templates/build/show.html.eex b/lib/alloy_ci/web/templates/build/show.html.eex index 0a1324bf..b1487f78 100644 --- a/lib/alloy_ci/web/templates/build/show.html.eex +++ b/lib/alloy_ci/web/templates/build/show.html.eex @@ -68,8 +68,8 @@
+ data-controller="build" data-build-id="<%= @build.id %>" data-build-trace="<%= @build.trace %>" + data-build-name="<%= @build.name %>">
diff --git a/lib/alloy_ci/web/templates/pipeline/build.html.eex b/lib/alloy_ci/web/templates/pipeline/build.html.eex new file mode 100644 index 00000000..2a72681d --- /dev/null +++ b/lib/alloy_ci/web/templates/pipeline/build.html.eex @@ -0,0 +1,22 @@ +
+ +
  • +
    + <%= icon("clock-o") %> + <%= build_duration(@build) %> +
    +
    + <%= icon("terminal") %> + <%= link @build.name, to: project_build_path(@conn, :show, @build.project_id, @build.id) %> +
    +
    + + <%= build_status_icon(@build.status) %> + + <%= build_actions(@conn, @build, "fa-lg") %> +
    +
  • +
    +
    diff --git a/lib/alloy_ci/web/templates/pipeline/show.html.eex b/lib/alloy_ci/web/templates/pipeline/show.html.eex index c9b783ab..1c28b726 100644 --- a/lib/alloy_ci/web/templates/pipeline/show.html.eex +++ b/lib/alloy_ci/web/templates/pipeline/show.html.eex @@ -45,26 +45,9 @@
      <%= for build <- builds do %> - -
    • -
      - <%= icon("clock-o") %> - <%= build_duration(build) %> -
      -
      - <%= icon("terminal") %> - <%= link build.name, to: project_build_path(@conn, :show, build.project_id, build.id) %> -
      -
      - - <%= build_status_icon(build.status) %> - - <%= build_actions(@conn, build, "fa-lg") %> -
      -
    • -
      +
      + <%= render "build.html", build: build, conn: @conn %> +
      <% end %>
    diff --git a/lib/alloy_ci/web/templates/project/pipeline.html.eex b/lib/alloy_ci/web/templates/project/pipeline.html.eex new file mode 100644 index 00000000..2f17398a --- /dev/null +++ b/lib/alloy_ci/web/templates/project/pipeline.html.eex @@ -0,0 +1,38 @@ +
    +
    +
    +
    +
    + <%= @pipeline.commit["username"] %> +
    +
    +
    + + <%= pretty_commit(@pipeline.commit["message"]) %> + <%= if @pipeline.commit["pr_commit_message"] do %> + | <%= icon("code-fork") %> <%= pretty_commit(@pipeline.commit["pr_commit_message"]) %> + <% end %> + +
    +
    + <%= icon("user") %> <%= @pipeline.commit["username"] %> + + <%= icon("github") %> <%= sha_link(@pipeline, @project) %> + + <%= ref_icon(@pipeline.ref) %> <%= clean_ref(@pipeline.ref) %> + + <%= icon("hourglass") %> <%= duration(@pipeline.duration) %> + + <%= icon("clock-o") %> <%= pretty_date(@pipeline.inserted_at) %> +
    +
    +
    + <%= link String.capitalize(@pipeline.status), + to: project_pipeline_path(@conn, :show, @project, @pipeline), + class: "btn #{status_btn(@pipeline.status)} btn-lg btn-block" %> +
    +
    +
    +
    +
    +
    diff --git a/lib/alloy_ci/web/templates/project/show.html.eex b/lib/alloy_ci/web/templates/project/show.html.eex index 8d1bfe28..08017a80 100644 --- a/lib/alloy_ci/web/templates/project/show.html.eex +++ b/lib/alloy_ci/web/templates/project/show.html.eex @@ -33,42 +33,13 @@
    <%= for pipeline <- pipelines do %> -
    -
    -
    -
    - <%= pipeline.commit["username"] %> -
    -
    -
    - - <%= pretty_commit(pipeline.commit["message"]) %> - <%= if pipeline.commit["pr_commit_message"] do %> - | <%= icon("code-fork") %> <%= pretty_commit(pipeline.commit["pr_commit_message"]) %> - <% end %> - -
    -
    - <%= icon("user") %> <%= pipeline.commit["username"] %> - - <%= icon("github") %> <%= sha_link(pipeline, @project) %> - - <%= ref_icon(pipeline.ref) %> <%= clean_ref(pipeline.ref) %> - - <%= icon("hourglass") %> <%= duration(pipeline.duration) %> - - <%= icon("clock-o") %> <%= pretty_date(pipeline.inserted_at) %> -
    -
    -
    - <%= link String.capitalize(pipeline.status), - to: project_pipeline_path(@conn, :show, @project, pipeline), - class: "btn #{status_btn(pipeline.status)} btn-lg btn-block" %> -
    -
    -
    + <%= if pipeline.status in ~w(failed success cancelled) do %> + <%= render "pipeline.html", pipeline: pipeline, project: @project, conn: @conn %> + <% else %> +
    + <%= render "pipeline.html", pipeline: pipeline, project: @project, conn: @conn %>
    -
    + <% end %> <% end %>
    <%= paginate @conn, @kerosene, next_label: "»", previous_label: "«" %> diff --git a/test/pipelines/pipelines_test.exs b/test/pipelines/pipelines_test.exs index f90062d1..87e455b2 100644 --- a/test/pipelines/pipelines_test.exs +++ b/test/pipelines/pipelines_test.exs @@ -7,10 +7,10 @@ defmodule AlloyCi.PipelinesTest do @update_attrs %{ before_sha: "some updated before_sha", - commit: %{message: "some new commit_message", email: "some new committer_email"}, + commit: %{"message" => "some new commit_message", "email" => "some new committer_email"}, duration: 43, finished_at: ~N[2011-05-18 15:01:01.000000], - ref: "some updated ref", + ref: "refs/heads/master", sha: "some updated sha", started_at: ~N[2011-05-18 15:01:01.000000], status: "some updated status" @@ -171,7 +171,7 @@ defmodule AlloyCi.PipelinesTest do assert pipeline.before_sha == "some updated before_sha" assert pipeline.duration == 43 assert pipeline.finished_at == ~N[2011-05-18 15:01:01.000000] - assert pipeline.ref == "some updated ref" + assert pipeline.ref == "refs/heads/master" assert pipeline.sha == "some updated sha" assert pipeline.started_at == ~N[2011-05-18 15:01:01.000000] assert pipeline.status == "some updated status" diff --git a/test/web/channels/builds_channel_test.exs b/test/web/channels/builds_channel_test.exs index 7a9ef5c3..258a9173 100644 --- a/test/web/channels/builds_channel_test.exs +++ b/test/web/channels/builds_channel_test.exs @@ -13,9 +13,9 @@ defmodule AlloyCi.Web.BuildsChannelTest do {:ok, _, socket} = "user_id" |> socket(%{user_id: user.id}) - |> subscribe_and_join(BuildsChannel, "builds:#{build.id}") + |> subscribe_and_join(BuildsChannel, "build:#{build.id}") - {:ok, socket: socket} + {:ok, socket: socket, build: build} end test "ping replies with status ok", %{socket: socket} do @@ -32,4 +32,9 @@ defmodule AlloyCi.Web.BuildsChannelTest do broadcast_from!(socket, "broadcast", %{"some" => "data"}) assert_push("broadcast", %{"some" => "data"}) end + + test "update_status sends the correct data", %{build: build} do + BuildsChannel.update_status(build) + assert_push("update_status", %{content: <<_div::binary-size(9), "build-", _rest::binary>>}) + end end diff --git a/test/web/channels/pipelines_channel_test.exs b/test/web/channels/pipelines_channel_test.exs new file mode 100644 index 00000000..a67e7f1b --- /dev/null +++ b/test/web/channels/pipelines_channel_test.exs @@ -0,0 +1,40 @@ +defmodule AlloyCi.Web.PipelinesChannelTest do + use AlloyCi.Web.ChannelCase + import AlloyCi.Factory + + alias AlloyCi.Web.PipelinesChannel + + setup do + pipeline = insert(:pipeline) + + user = + insert(:user, project_permissions: [build(:project_permission, project: pipeline.project)]) + + {:ok, _, socket} = + "user_id" + |> socket(%{user_id: user.id}) + |> subscribe_and_join(PipelinesChannel, "pipeline:#{pipeline.id}") + + {:ok, socket: socket, pipeline: pipeline} + end + + test "ping replies with status ok", %{socket: socket} do + ref = push(socket, "ping", %{"hello" => "there"}) + assert_reply(ref, :ok, %{"hello" => "there"}) + end + + test "shout broadcasts to pipelines:lobby", %{socket: socket} do + push(socket, "shout", %{"hello" => "all"}) + assert_broadcast("shout", %{"hello" => "all"}) + end + + test "broadcasts are pushed to the client", %{socket: socket} do + broadcast_from!(socket, "broadcast", %{"some" => "data"}) + assert_push("broadcast", %{"some" => "data"}) + end + + test "update_status sends the correct data", %{pipeline: pipeline} do + PipelinesChannel.update_status(pipeline) + assert_push("update_status", %{content: <<_div::binary-size(9), "pipeline-", _rest::binary>>}) + end +end