defmodule ChoreRunnerUI.ChoreLive do
@moduledoc """
A LiveView used in conjunction with ChoreRunner's regular functionality.
Features logging and data value display of all running chores locally and on all connected nodes.
Allows the running of chores (with autogenerated forms based on chore inputs), as well as
stopping running chores.
## Usage
Make sure that `Chore.Supervisor` is added in your application
```
children = [
{Phoenix.PubSub, name: MyApp.PubSub},
{ChoreRunner, pubsub: MyApp.PubSub}
]
```
A pubsub MUST BE RUNNING and configured for both the Chore supervisor and the Chore LiveView for the Chore UI to function.
Make the Chore LiveView accessible in your Phoenix web app, either in a template or in the router.
### Template
`<%= live_render(@conn, ChoreRunnerUI.ChoreLive, session: %{"otp_app" => :my_app, "chore_root" => MyApp.Chores, "pubsub" => MyApp.PubSub}) %>`
### Router
```
@chore_session %{
"otp_app" => :my_app,
"chore_root" => MyApp.Chores,
"pubsub" => MyApp.PubSub
}
scope "/" do
pipe_through :browser
live_session :chores, session: @chore_session do
live "/", ChoreRunnerUI.ChoreLive
end
end
```
The `"chore_root"` key in the session should be the module root that all of your chore modules use.
For example: if your root is `MyApp.Chores` your chore modules should be named like `MyApp.Chores.MyChore`
Now you can visit the speficied url and start running chores!
"""
use ChoreRunnerUI, :live
alias ChoreRunnerUI.ChoreView
require Logger
def mount(params, session, socket) do
subscribe_to_pubsub(session)
chores = list_chores(session)
{selected_chore_name, selected_chore} =
case Map.to_list(chores) do
[{selected_chore_name, selected_chore} | _] -> {selected_chore_name, selected_chore}
_ -> {nil, nil}
end
socket =
assign(socket,
chores: chores,
form_chores: Map.keys(chores),
running_chores: ChoreRunner.list_running_chores(),
params: params,
session: session,
inputs: [],
file_inputs: [],
currently_selected_chore: selected_chore,
form_selected_chore: selected_chore_name,
chore_errors: %{},
is_chore_valid: true,
selected_chore: nil
)
|> set_inputs(selected_chore && selected_chore.inputs())
{:ok, socket}
end
def render(assigns) do
ChoreView.render("index.html", assigns)
end
def handle_event(
"form_changed",
%{"run_chore" => %{"chore" => chore_name} = attrs},
%{assigns: %{chores: chores, currently_selected_chore: currently_selected_chore}} = socket
) do
selected_chore = chores[chore_name]
if(currently_selected_chore == selected_chore) do
chore_attrs = Map.get(attrs, "chore_attrs", %{})
errors =
case selected_chore.validate_input(chore_attrs) do
{:ok, _} -> []
{:error, errors} -> errors
end
{:noreply, assign_errors(socket, errors)}
else
socket =
socket
|> assign(
currently_selected_chore: selected_chore,
form_selected_chore: chore_name,
chore_errors: %{},
is_chore_valid: true
)
|> set_inputs(selected_chore.inputs())
{:noreply, socket}
end
end
def handle_event("run_chore", %{"run_chore" => %{"chore" => chore_name} = attrs}, socket) do
file_attrs =
Enum.map(socket.assigns.file_inputs, fn file_input ->
uploaded_file =
consume_uploaded_entries(socket, file_input, fn %{path: path}, _entry ->
tmp_dir = System.tmp_dir!()
File.mkdir(Path.join(tmp_dir, "chore_files"))
dest = Path.join([System.tmp_dir!(), "chore_files", Path.basename(path)])
File.cp!(path, dest)
dest
end)
|> List.first()
{file_input, uploaded_file}
end)
|> Enum.into(%{})
chore = socket.assigns.chores[chore_name]
chore_attrs = Map.get(attrs, "chore_attrs", %{}) |> Map.merge(file_attrs)
errors =
case ChoreRunner.run_chore(chore, chore_attrs) do
{:ok, _} -> []
{:error, errors} -> errors
end
{:noreply, assign_errors(socket, errors)}
end
def handle_event("stop_chore", _, %{assigns: %{running_chores: []}} = socket),
do: {:noreply, socket}
def handle_event("stop_chore", %{"id" => id}, %{assigns: %{running_chores: chores}} = socket) do
chores
|> Enum.find(&(&1.id == id))
|> ChoreRunner.stop_chore()
{:noreply, socket}
end
def handle_event("dismiss_chore", %{"id" => id}, socket) do
{:noreply, remove_running_chore(socket, id)}
end
def handle_event("select_chore", %{"chore" => id}, socket) do
selected_chore = Enum.find(socket.assigns.running_chores, &(&1.id == id))
{:noreply, assign(socket, :selected_chore, selected_chore)}
end
def handle_event("deselect_chore", _, socket) do
{:noreply, assign(socket, :selected_chore, nil)}
end
def handle_event(event, _attrs, socket) do
Logger.debug("Unhandled event #{inspect(event)} in ChoreRunnerUI.ChoreLive")
{:noreply, socket}
end
def handle_info({:chore_started, chore}, socket) do
{:noreply,
assign(
socket,
:running_chores,
[chore | socket.assigns.running_chores]
)}
end
def handle_info({:chore_update, chore}, socket) do
socket =
if socket.assigns.selected_chore && socket.assigns.selected_chore.id == chore.id do
assign(
socket,
:selected_chore,
%{
socket.assigns.selected_chore
| logs: chore.logs ++ socket.assigns.selected_chore.logs
}
)
else
socket
end
{:noreply,
assign(
socket,
:running_chores,
update_running_chore(socket.assigns.running_chores, chore)
)}
end
def handle_info({final_message, chore}, socket)
when final_message in [:chore_finished, :chore_failed] do
{:noreply,
assign(
socket,
:running_chores,
update_running_chore(socket.assigns.running_chores, chore)
)}
end
def handle_info(unhandled, socket) do
Logger.debug("Unhandled message #{inspect(unhandled)} sent to ChoreRunnerUI.ChoreLive")
{:noreply, socket}
end
defp subscribe_to_pubsub(%{"pubsub" => pubsub}) do
Phoenix.PubSub.subscribe(pubsub, ChoreRunner.chore_pubsub_topic(:all))
end
defp subscribe_to_pubsub(_), do: :noop
defp list_chores(%{"otp_app" => app, "chore_root" => root}) do
split_root = Module.split(root) |> Enum.reverse()
{:ok, modules} = :application.get_key(app, :modules)
modules
|> Enum.map(fn module ->
module
|> Module.split()
|> Enum.reverse()
|> case do
[trimmed_module | ^split_root] ->
{trimmed_module, module}
_ ->
nil
end
end)
|> Enum.reject(&is_nil/1)
|> Enum.into(%{})
end
defp list_chores(_), do: %{}
defp update_running_chore(running_chores, %{id: id} = chore) do
Enum.map(running_chores, fn
%{id: ^id, logs: logs, finished_at: nil} ->
%{chore | logs: chore.logs ++ logs, task: chore.task}
chore ->
chore
end)
end
defp remove_running_chore(%{assigns: %{running_chores: running_chores}} = socket, id) do
assign(socket, :running_chores, Enum.reject(running_chores, &(&1.id == id)))
end
defp set_inputs(socket, nil), do: socket
defp set_inputs(socket, inputs) do
socket
|> disable_previous_file_inputs()
|> assign(inputs: inputs)
|> assign(
:file_inputs,
inputs |> Enum.filter(&(elem(&1, 0) == :file)) |> Enum.map(&elem(&1, 1))
)
|> enable_file_inputs()
end
defp disable_previous_file_inputs(%{assigns: %{inputs: nil}} = socket), do: socket
defp disable_previous_file_inputs(%{assigns: %{inputs: inputs}} = socket) do
inputs
|> Enum.filter(fn {type, _key, _opts} -> type == :file end)
|> Enum.reduce(socket, fn {:file, key, _opts}, socket ->
disallow_upload(socket, key)
end)
end
defp enable_file_inputs(%{assigns: %{inputs: nil}} = socket), do: socket
defp enable_file_inputs(%{assigns: %{inputs: inputs}} = socket) do
inputs
|> Enum.filter(fn {type, _key, _opts} -> type == :file end)
|> Enum.reduce(socket, fn {:file, key, _opts}, socket ->
allow_upload(socket, key, accept: :any, max_entries: 1)
end)
end
defp assign_errors(socket, []) do
assign(socket, chore_errors: %{}, is_chore_valid: true)
end
defp assign_errors(socket, errors) do
assign(socket, chore_errors: Enum.into(errors, %{}), is_chore_valid: false)
end
end