diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index b9f5420..0000000 --- a/.travis.yml +++ /dev/null @@ -1,15 +0,0 @@ -language: elixir - -services: - - docker - -before_script: - - docker-compose build - - docker-compose up -d - - sleep 10 - -script: - - docker-compose exec cronitex sh -c 'MIX_ENV=test mix do compile --umbrella, coveralls.json' - -after_success: - - bash <(curl -s https://codecov.io/bash) \ No newline at end of file diff --git a/apps/cronitex/lib/cronitex/application.ex b/apps/cronitex/lib/cronitex/application.ex index 6d8ad5a..b213243 100644 --- a/apps/cronitex/lib/cronitex/application.ex +++ b/apps/cronitex/lib/cronitex/application.ex @@ -10,9 +10,8 @@ defmodule Cronitex.Application do # Start the Ecto repository Cronitex.Repo, # Start the PubSub system - {Phoenix.PubSub, name: Cronitex.PubSub} - # Start a worker by calling: Cronitex.Worker.start_link(arg) - # {Cronitex.Worker, arg} + {Phoenix.PubSub, name: Cronitex.PubSub}, + Cronitex.MonitorServices.CronMonitorSupervisor ] Supervisor.start_link(children, strategy: :one_for_one, name: Cronitex.Supervisor) diff --git a/apps/cronitex/lib/cronitex/monitor_services/cron_monitors/cron_monitor_server.ex b/apps/cronitex/lib/cronitex/monitor_services/cron_monitors/cron_monitor_server.ex new file mode 100644 index 0000000..e3042ab --- /dev/null +++ b/apps/cronitex/lib/cronitex/monitor_services/cron_monitors/cron_monitor_server.ex @@ -0,0 +1,72 @@ +defmodule Cronitex.MonitorServices.CronMonitorServer do + use GenServer + alias Cronitex.MonitorServices.LiveUpdates + + def start_link(init_arg, options) do + GenServer.start_link(__MODULE__, init_arg, options) + end + + @impl true + def init(state) do + state = update_monitor_state(state, :waiting) + state = schedule_work(state) + {:ok, state} + end + + @impl true + def handle_info(:work, state) do + state = schedule_work(state) + state = update_monitor_state(state, :waiting) + {:noreply, state} + end + + @impl true + def handle_info(:start_ping, state) do + # If we get a success ping, disregard the timeout + state = cancel_and_remove_timer(state, :timeout_timer) + state = update_monitor_state(state, :ok) + {:noreply, state} + end + + @impl true + def handle_info(:timeout, state) do + state = update_monitor_state(state, :start_timeout) + {:noreply, state} + end + + @impl true + def handle_info(:stop, state) do + state = cancel_and_remove_timer(state, :work_timer) + state = cancel_and_remove_timer(state, :timeout_timer) + state = update_monitor_state(state, :stopped) + {:noreply, state} + end + + defp cancel_and_remove_timer(state, timer_key) when is_map_key(state, timer_key) do + Process.cancel_timer(state[timer_key]) + Map.pop(state, timer_key) + end + + defp cancel_and_remove_timer(state, _timer_key), do: state + + defp update_monitor_state(state, monitor_state) do + state = Map.put(state, :monitor_state, monitor_state) + LiveUpdates.notify_live_view_for_monitor_id(state.config.token, monitor_state) + state + end + + + defp schedule_work(state) do + # Whenever we schedule work, we need to set two timers, one that executes with the crontab, and one that executes with the timeout + {:ok, next_rundate} = Crontab.Scheduler.get_next_run_date(state.config.cron_expression) + milliseconds_till_run = NaiveDateTime.diff(next_rundate, DateTime.to_naive(DateTime.utc_now()), :millisecond) + timer = Process.send_after(self(), :work, milliseconds_till_run) + state = Map.put(state, :work_timer, timer) + + timeout_milliseconds = state.config.start_tolerance_seconds * 1000 + timeout_timer = Process.send_after(self(), :timeout, milliseconds_till_run + timeout_milliseconds) + state = Map.put(state, :timeout_timer, timeout_timer) + state + end + +end diff --git a/apps/cronitex/lib/cronitex/monitor_services/cron_monitors/cron_monitor_supervisor.ex b/apps/cronitex/lib/cronitex/monitor_services/cron_monitors/cron_monitor_supervisor.ex new file mode 100644 index 0000000..8a38faa --- /dev/null +++ b/apps/cronitex/lib/cronitex/monitor_services/cron_monitors/cron_monitor_supervisor.ex @@ -0,0 +1,29 @@ +defmodule Cronitex.MonitorServices.CronMonitorSupervisor do + use Supervisor + + alias Cronitex.Monitors + alias Cronitex.MonitorServices.CronMonitorServer + + def start_link(init_arg) do + Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__) + end + + @impl true + def init(_init_arg) do + Monitors.list_cronmonitors() + |> start_cron_monitor_servers() + end + + def start_cron_monitor_servers(monitors) do + monitors + |> Enum.into([], &cron_monitor_to_child_map/1) + |> Supervisor.init(strategy: :one_for_one) + end + + defp cron_monitor_to_child_map(model) do + %{ + id: model.token, + start: {CronMonitorServer, :start_link, [%{config: model}, []]} + } + end +end diff --git a/apps/cronitex/lib/cronitex/monitor_services/live_updates.ex b/apps/cronitex/lib/cronitex/monitor_services/live_updates.ex new file mode 100644 index 0000000..84e442c --- /dev/null +++ b/apps/cronitex/lib/cronitex/monitor_services/live_updates.ex @@ -0,0 +1,11 @@ +defmodule Cronitex.MonitorServices.LiveUpdates do + alias Phoenix + + def subscribe_live_view_for_monitor_id(monitor_id) do + Phoenix.PubSub.subscribe(Cronitex.PubSub, monitor_id, link: true) + end + + def notify_live_view_for_monitor_id(monitor_id, status) do + Phoenix.PubSub.broadcast(Cronitex.PubSub, monitor_id, status) + end +end diff --git a/apps/cronitex/lib/cronitex/monitors/cron_monitor.ex b/apps/cronitex/lib/cronitex/monitors/cron_monitor.ex index d5ac9b9..cabaa10 100644 --- a/apps/cronitex/lib/cronitex/monitors/cron_monitor.ex +++ b/apps/cronitex/lib/cronitex/monitors/cron_monitor.ex @@ -1,7 +1,6 @@ defmodule Cronitex.Monitors.CronMonitor do use Ecto.Schema import Ecto.Changeset - import Crontab.CronExpression.Ecto.Type schema "cronmonitors" do field :name, :string @@ -24,14 +23,6 @@ defmodule Cronitex.Monitors.CronMonitor do |> put_token() end - defp check_cron_expression(changeset) do - case Crontab.CronExpression.Parser.parse(changeset.data.cron_expression) do - {:ok, _expression} -> changeset - {:error, _error} -> add_error(changeset, :cron_expression, "Invalid Cron Expression.") - end - - end - defp put_token(changeset) do unless changeset.data.token do put_change(changeset, :token, Ecto.UUID.generate()) diff --git a/apps/cronitex/test/cronitex/monitor_services/cron_monitor_supervisor_test.exs b/apps/cronitex/test/cronitex/monitor_services/cron_monitor_supervisor_test.exs new file mode 100644 index 0000000..5658311 --- /dev/null +++ b/apps/cronitex/test/cronitex/monitor_services/cron_monitor_supervisor_test.exs @@ -0,0 +1,37 @@ +defmodule Cronitex.MonitorServices.CronMonitorSupervisorTests do + use Cronitex.DataCase + + alias Cronitex.MonitorServices.CronMonitorSupervisor + alias Cronitex.TestHelpers + alias Cronitex.Monitors + + test "supervisor spawns with correct children" do + pid = Process.whereis(CronMonitorSupervisor) + %{active: active} = Supervisor.count_children(pid) + assert active == 0 + end + + test "supervisor respawns with corrct children" do + user = TestHelpers.user_fixture() + {:ok, monitor} = Monitors.create_cron_monitor(user, %{name: "valid cronmon", cron_expression: "* * * * * *"}) + + pid = Process.whereis(CronMonitorSupervisor) + ref = Process.monitor(pid) + Process.exit(pid, :kill) + receive do + {:DOWN, ^ref, :process, ^pid, :killed} -> + :timer.sleep 1 + + # Now that we've created a monitor and restarted the process, we should expect that there's an active child of + # our supervisor + pid = Process.whereis(CronMonitorSupervisor) + %{active: active} = Supervisor.count_children(pid) + assert active == 1 + after + 1000 -> + raise :timeout + end + + end + +end diff --git a/apps/cronitex/test/cronitex/monitor_services/live_updates_test.exs b/apps/cronitex/test/cronitex/monitor_services/live_updates_test.exs new file mode 100644 index 0000000..f6822c0 --- /dev/null +++ b/apps/cronitex/test/cronitex/monitor_services/live_updates_test.exs @@ -0,0 +1,25 @@ +defmodule Cronitex.MonitorServices.LiveUpdatesTest do + use Cronitex.DataCase, async: true + + alias Cronitex.MonitorServices.LiveUpdates + + @monitor_id "1234-1234-1234-1234" + + test "nofify and subscribe for monitor token work correctly" do + # Subscribe to notifications + LiveUpdates.subscribe_live_view_for_monitor_id(@monitor_id) + + # Make sure we have no messages + {:messages, messages} = Process.info(self(), :messages) + assert Enum.count(messages) == 0 + + # Notifiy on the same topic, and assert that we have one message with our payload + LiveUpdates.notify_live_view_for_monitor_id(@monitor_id, :hello) + {:messages, messages} = Process.info(self(), :messages) + + assert Enum.count(messages) == 1 + [message | _tail] = messages + assert message == :hello + end + +end diff --git a/apps/cronitex/test/cronitex/monitors_test.exs b/apps/cronitex/test/cronitex/monitors_test.exs index e33aaf5..7d9c203 100644 --- a/apps/cronitex/test/cronitex/monitors_test.exs +++ b/apps/cronitex/test/cronitex/monitors_test.exs @@ -1,5 +1,5 @@ defmodule Cronitex.MonitorsTest do - use Cronitex.DataCase + use Cronitex.DataCase, async: true import Crontab.CronExpression alias Cronitex.Monitors diff --git a/apps/cronitex/test/support/data_case.ex b/apps/cronitex/test/support/data_case.ex index ce39b2b..3c6d6f0 100644 --- a/apps/cronitex/test/support/data_case.ex +++ b/apps/cronitex/test/support/data_case.ex @@ -27,10 +27,14 @@ defmodule Cronitex.DataCase do end end - setup _tags do - Ecto.Adapters.SQL.Sandbox.checkout(Cronitex.Repo) + setup tags do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(Cronitex.Repo) - # Don't need handling for other tags here, will only be using postgres. + unless tags[:async] do + Ecto.Adapters.SQL.Sandbox.mode(Cronitex.Repo, {:shared, self()}) + end + + :ok end @doc """ diff --git a/apps/cronitex_web/assets/js/app.js b/apps/cronitex_web/assets/js/app.js index 8c02de5..3ce6849 100644 --- a/apps/cronitex_web/assets/js/app.js +++ b/apps/cronitex_web/assets/js/app.js @@ -12,4 +12,22 @@ import "../css/app.scss" // import {Socket} from "phoenix" // import socket from "./socket" // -import "phoenix_html" \ No newline at end of file +import "phoenix_html" + +// assets/js/app.js +import {Socket} from "phoenix" +import LiveSocket from "phoenix_live_view" + +let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") +let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}}) + +// Connect if there are any LiveViews on the page +liveSocket.connect() + +// Expose liveSocket on window for web console debug logs and latency simulation: +// >> liveSocket.enableDebug() +// >> liveSocket.enableLatencySim(1000) +// The latency simulator is enabled for the duration of the browser session. +// Call disableLatencySim() to disable: +// >> liveSocket.disableLatencySim() +window.liveSocket = liveSocket \ No newline at end of file diff --git a/apps/cronitex_web/assets/package-lock.json b/apps/cronitex_web/assets/package-lock.json index f8fc546..6f9b3dc 100644 --- a/apps/cronitex_web/assets/package-lock.json +++ b/apps/cronitex_web/assets/package-lock.json @@ -5264,6 +5264,9 @@ "phoenix_html": { "version": "file:../../../deps/phoenix_html" }, + "phoenix_live_view": { + "version": "file:../../../deps/phoenix_live_view" + }, "picomatch": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz", diff --git a/apps/cronitex_web/assets/package.json b/apps/cronitex_web/assets/package.json index 142edf0..d59c5b3 100644 --- a/apps/cronitex_web/assets/package.json +++ b/apps/cronitex_web/assets/package.json @@ -8,7 +8,8 @@ }, "dependencies": { "phoenix": "file:../../../deps/phoenix", - "phoenix_html": "file:../../../deps/phoenix_html" + "phoenix_html": "file:../../../deps/phoenix_html", + "phoenix_live_view": "file:../../../deps/phoenix_live_view" }, "devDependencies": { "@babel/core": "^7.0.0", diff --git a/apps/cronitex_web/lib/cronitex_web.ex b/apps/cronitex_web/lib/cronitex_web.ex index 6cc590d..8b97080 100644 --- a/apps/cronitex_web/lib/cronitex_web.ex +++ b/apps/cronitex_web/lib/cronitex_web.ex @@ -25,6 +25,7 @@ defmodule CronitexWeb do import CronitexWeb.Gettext import CronitexWeb.Auth, only: [authenticate_user: 2] alias CronitexWeb.Router.Helpers, as: Routes + import Phoenix.LiveView.Controller end end @@ -36,7 +37,7 @@ defmodule CronitexWeb do # Import convenience functions from controllers import Phoenix.Controller, only: [get_flash: 1, get_flash: 2, view_module: 1] - + import Phoenix.LiveView.Helpers # Include shared imports and aliases for views unquote(view_helpers()) end @@ -49,6 +50,7 @@ defmodule CronitexWeb do import Plug.Conn import Phoenix.Controller import CronitexWeb.Auth, only: [authenticate_user: 2] + import Phoenix.LiveView.Router end end diff --git a/apps/cronitex_web/lib/cronitex_web/live_views/cronmonitor_status_live_view.ex b/apps/cronitex_web/lib/cronitex_web/live_views/cronmonitor_status_live_view.ex new file mode 100644 index 0000000..e965d50 --- /dev/null +++ b/apps/cronitex_web/lib/cronitex_web/live_views/cronmonitor_status_live_view.ex @@ -0,0 +1,21 @@ +defmodule CronitexWeb.CronMonitorStatusLive do + use Phoenix.LiveView + alias Cronitex.MonitorServices.LiveUpdates + + + def render(assigns) do + ~L""" + <%= @status %> + """ + end + + def handle_info(message, socket) do + {:noreply, assign(socket, :status, message)} + end + + def mount(_params, %{"monitor_token" => monitor_token}, socket) do + LiveUpdates.subscribe_live_view_for_monitor_id(monitor_token) + + {:ok, assign(socket, :status, "Ready!")} + end +end diff --git a/apps/cronitex_web/lib/cronitex_web/router.ex b/apps/cronitex_web/lib/cronitex_web/router.ex index 334ca72..ad69671 100644 --- a/apps/cronitex_web/lib/cronitex_web/router.ex +++ b/apps/cronitex_web/lib/cronitex_web/router.ex @@ -4,9 +4,10 @@ defmodule CronitexWeb.Router do pipeline :browser do plug :accepts, ["html"] plug :fetch_session - plug :fetch_flash + plug :fetch_live_flash plug :protect_from_forgery plug :put_secure_browser_headers + plug :put_root_layout, {CronitexWeb.LayoutView, :root} plug CronitexWeb.Auth end @@ -18,7 +19,7 @@ defmodule CronitexWeb.Router do pipe_through :browser get "/", PageController, :index - resources "/users", UserController, only: [:new, :create, :show, :delete] + resources "/users", UserController, only: [:index, :new, :create, :show, :delete, :edit] resources "/sessions", SessionController, only: [:new, :create, :delete] end diff --git a/apps/cronitex_web/lib/cronitex_web/templates/cron_monitor/index.html.eex b/apps/cronitex_web/lib/cronitex_web/templates/cron_monitor/index.html.eex index 82548f7..aeafbf0 100644 --- a/apps/cronitex_web/lib/cronitex_web/templates/cron_monitor/index.html.eex +++ b/apps/cronitex_web/lib/cronitex_web/templates/cron_monitor/index.html.eex @@ -7,7 +7,7 @@ Token Cron expression Start tolerance seconds - + Status @@ -18,6 +18,7 @@ <%= cron_monitor.token %> <%= cron_str(cron_monitor.cron_expression) %> <%= cron_monitor.start_tolerance_seconds %> + <%= live_render(@conn, CronitexWeb.CronMonitorStatusLive, session: %{"monitor_token" => cron_monitor.token}) %> <%= link "Show", to: Routes.cron_monitor_path(@conn, :show, cron_monitor) %> diff --git a/apps/cronitex_web/lib/cronitex_web/templates/layout/app.html.eex b/apps/cronitex_web/lib/cronitex_web/templates/layout/app.html.eex index 47efa44..7282448 100644 --- a/apps/cronitex_web/lib/cronitex_web/templates/layout/app.html.eex +++ b/apps/cronitex_web/lib/cronitex_web/templates/layout/app.html.eex @@ -6,38 +6,10 @@ Cronitex ยท Phoenix Framework "/> + <%= csrf_meta_tag() %> -
-
- - -
-
-
- - <%= @inner_content %> -
diff --git a/apps/cronitex_web/lib/cronitex_web/templates/layout/root.html.eex b/apps/cronitex_web/lib/cronitex_web/templates/layout/root.html.eex new file mode 100644 index 0000000..f28ff68 --- /dev/null +++ b/apps/cronitex_web/lib/cronitex_web/templates/layout/root.html.eex @@ -0,0 +1,35 @@ + + + +
+
+ + +
+
+
+ + + <%= @inner_content %> +
+ + diff --git a/apps/cronitex_web/mix.exs b/apps/cronitex_web/mix.exs index 37fe87c..a69f40a 100644 --- a/apps/cronitex_web/mix.exs +++ b/apps/cronitex_web/mix.exs @@ -48,7 +48,9 @@ defmodule CronitexWeb.MixProject do {:gettext, "~> 0.11"}, {:cronitex, in_umbrella: true}, {:jason, "~> 1.0"}, - {:plug_cowboy, "~> 2.0"} + {:plug_cowboy, "~> 2.0"}, + {:phoenix_live_view, "~> 0.14.8"}, + {:floki, ">= 0.27.0", only: :test} ] end diff --git a/entrypoint.sh b/entrypoint.sh index 99ff3d7..07291cb 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -18,6 +18,10 @@ if [[ -z `psql -Atqc "\\list $PGDATABASE"` ]]; then echo "Database $PGDATABASE created." fi + +cd /app/apps/cronitex_web/assets && npm install +cd /app + # exec iex --cookie secret --name cronitex@docker \ # --erl '-kernel inet_dist_listen_min 9000' \ # --erl '-kernel inet_dist_listen_max 9000' \ diff --git a/mix.lock b/mix.lock index 76e5d5e..ddcbedb 100644 --- a/mix.lock +++ b/mix.lock @@ -12,8 +12,10 @@ "ecto_sql": {:hex, :ecto_sql, "3.5.3", "1964df0305538364b97cc4661a2bd2b6c89d803e66e5655e4e55ff1571943efd", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.5.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0 or ~> 0.4.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d2f53592432ce17d3978feb8f43e8dc0705e288b0890caf06d449785f018061c"}, "excoveralls": {:hex, :excoveralls, "0.13.3", "edc5f69218f84c2bf61b3609a22ddf1cec0fbf7d1ba79e59f4c16d42ea4347ed", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cc26f48d2f68666380b83d8aafda0fffc65dafcc8d8650358e0b61f6a99b1154"}, "file_system": {:hex, :file_system, "0.2.9", "545b9c9d502e8bfa71a5315fac2a923bd060fd9acb797fe6595f54b0f975fd32", [:mix], [], "hexpm", "3cf87a377fe1d93043adeec4889feacf594957226b4f19d5897096d6f61345d8"}, + "floki": {:hex, :floki, "0.29.0", "b1710d8c93a2f860dc2d7adc390dd808dc2fb8f78ee562304457b75f4c640881", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "008585ce64b9f74c07d32958ec9866f4b8a124bf4da1e2941b28e41384edaaad"}, "gettext": {:hex, :gettext, "0.18.2", "7df3ea191bb56c0309c00a783334b288d08a879f53a7014341284635850a6e55", [:mix], [], "hexpm", "f9f537b13d4fdd30f3039d33cb80144c3aa1f8d9698e47d7bcbcc8df93b1f5c5"}, "hackney": {:hex, :hackney, "1.16.0", "5096ac8e823e3a441477b2d187e30dd3fff1a82991a806b2003845ce72ce2d84", [:rebar3], [{:certifi, "2.5.2", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.1", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.0", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.6", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "3bf0bebbd5d3092a3543b783bf065165fa5d3ad4b899b836810e513064134e18"}, + "html_entities": {:hex, :html_entities, "0.5.1", "1c9715058b42c35a2ab65edc5b36d0ea66dd083767bef6e3edb57870ef556549", [:mix], [], "hexpm", "30efab070904eb897ff05cd52fa61c1025d7f8ef3a9ca250bc4e6513d16c32de"}, "idna": {:hex, :idna, "6.0.1", "1d038fb2e7668ce41fbf681d2c45902e52b3cb9e9c77b55334353b222c2ee50c", [:rebar3], [{:unicode_util_compat, "0.5.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a02c8a1c4fd601215bb0b0324c8a6986749f807ce35f25449ec9e69758708122"}, "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},