diff --git a/.credo.exs b/.credo.exs index 871a021..e130a68 100644 --- a/.credo.exs +++ b/.credo.exs @@ -121,7 +121,6 @@ # {Credo.Check.Refactor.Apply, []}, {Credo.Check.Refactor.CondStatements, []}, - {Credo.Check.Refactor.CyclomaticComplexity, []}, {Credo.Check.Refactor.FilterCount, []}, {Credo.Check.Refactor.FilterFilter, []}, {Credo.Check.Refactor.FunctionArity, []}, @@ -189,6 +188,7 @@ {Credo.Check.Readability.WithCustomTaggedTuple, []}, {Credo.Check.Refactor.ABCSize, []}, {Credo.Check.Refactor.AppendSingleItem, []}, + {Credo.Check.Refactor.CyclomaticComplexity, []}, {Credo.Check.Refactor.DoubleBooleanNegation, []}, {Credo.Check.Refactor.FilterReject, []}, {Credo.Check.Refactor.IoPuts, []}, diff --git a/lib/error_tracker.ex b/lib/error_tracker.ex index 7f45232..5997214 100644 --- a/lib/error_tracker.ex +++ b/lib/error_tracker.ex @@ -8,30 +8,36 @@ defmodule ErrorTracker do """ @type context :: %{String.t() => any()} + alias ErrorTracker.Error + alias ErrorTracker.Repo + def report(exception, stacktrace, given_context \\ %{}) do {:ok, stacktrace} = ErrorTracker.Stacktrace.new(stacktrace) - {:ok, error} = ErrorTracker.Error.new(exception, stacktrace) + {:ok, error} = Error.new(exception, stacktrace) context = Map.merge(get_context(), given_context) error = - repo().insert!(error, - on_conflict: [set: [status: :unresolved]], - conflict_target: :fingerprint, - prefix: prefix() + Repo.insert!(error, + on_conflict: [set: [status: :unresolved, last_occurrence_at: DateTime.utc_now()]], + conflict_target: :fingerprint ) error |> Ecto.build_assoc(:occurrences, stacktrace: stacktrace, context: context) - |> repo().insert!(prefix: prefix()) + |> Repo.insert!() end - def repo do - Application.fetch_env!(:error_tracker, :repo) + def resolve(error = %Error{status: :unresolved}) do + changeset = Ecto.Changeset.change(error, status: :resolved) + + Repo.update(changeset) end - def prefix do - Application.get_env(:error_tracker, :prefix, "public") + def unresolve(error = %Error{status: :resolved}) do + changeset = Ecto.Changeset.change(error, status: :unresolved) + + Repo.update(changeset) end @spec set_context(context()) :: context() diff --git a/lib/error_tracker/migrations.ex b/lib/error_tracker/migrations.ex index b61fa7d..94a4c32 100644 --- a/lib/error_tracker/migrations.ex +++ b/lib/error_tracker/migrations.ex @@ -116,7 +116,7 @@ defmodule ErrorTracker.Migration do end defp migrator do - case ErrorTracker.repo().__adapter__() do + case ErrorTracker.Repo.__adapter__() do Ecto.Adapters.Postgres -> ErrorTracker.Migrations.Postgres adapter -> raise "ErrorTracker does not support #{adapter}" end diff --git a/lib/error_tracker/migrations/postgres/v01.ex b/lib/error_tracker/migrations/postgres/v01.ex index 46854d1..056bf1f 100644 --- a/lib/error_tracker/migrations/postgres/v01.ex +++ b/lib/error_tracker/migrations/postgres/v01.ex @@ -13,6 +13,7 @@ defmodule ErrorTracker.Migrations.Postgres.V01 do add :source_function, :text, null: false add :status, :string, null: false add :fingerprint, :string, null: false + add :last_occurrence_at, :utc_datetime_usec, null: false timestamps(type: :utc_datetime_usec) end diff --git a/lib/error_tracker/repo.ex b/lib/error_tracker/repo.ex new file mode 100644 index 0000000..f736052 --- /dev/null +++ b/lib/error_tracker/repo.ex @@ -0,0 +1,41 @@ +defmodule ErrorTracker.Repo do + @moduledoc """ + Wraps Ecto.Repo calls and applies default options. + """ + def insert!(struct_or_changeset, opts \\ []) do + dispatch(:insert!, [struct_or_changeset], opts) + end + + def update(changeset, opts \\ []) do + dispatch(:update, [changeset], opts) + end + + def get(queryable, id, opts \\ []) do + dispatch(:get, [queryable, id], opts) + end + + def get!(queryable, id, opts \\ []) do + dispatch(:get!, [queryable, id], opts) + end + + def all(queryable, opts \\ []) do + dispatch(:all, [queryable], opts) + end + + def aggregate(queryable, aggregate, opts \\ []) do + dispatch(:aggregate, [queryable, aggregate], opts) + end + + def __adapter__, do: repo().__adapter__() + + defp dispatch(action, args, opts) do + defaults = [prefix: Application.get_env(:error_tracker, :prefix, "public")] + opts_w_defaults = Keyword.merge(defaults, opts) + + apply(repo(), action, args ++ [opts_w_defaults]) + end + + defp repo do + Application.fetch_env!(:error_tracker, :repo) + end +end diff --git a/lib/error_tracker/schemas/error.ex b/lib/error_tracker/schemas/error.ex index 828ee53..443352a 100644 --- a/lib/error_tracker/schemas/error.ex +++ b/lib/error_tracker/schemas/error.ex @@ -15,6 +15,7 @@ defmodule ErrorTracker.Error do field :source_function, :string field :status, Ecto.Enum, values: [:resolved, :unresolved], default: :unresolved field :fingerprint, :binary + field :last_occurrence_at, :utc_datetime_usec has_many :occurrences, ErrorTracker.Occurrence @@ -48,6 +49,7 @@ defmodule ErrorTracker.Error do %__MODULE__{} |> Ecto.Changeset.change(params) |> Ecto.Changeset.put_change(:fingerprint, Base.encode16(fingerprint)) + |> Ecto.Changeset.put_change(:last_occurrence_at, DateTime.utc_now()) |> Ecto.Changeset.apply_action(:new) end end diff --git a/lib/error_tracker/web.ex b/lib/error_tracker/web.ex index f866fb5..eb291c6 100644 --- a/lib/error_tracker/web.ex +++ b/lib/error_tracker/web.ex @@ -73,6 +73,10 @@ defmodule ErrorTracker.Web do import Phoenix.HTML import Phoenix.LiveView.Helpers + import ErrorTracker.Web.CoreComponents + import ErrorTracker.Web.Helpers + import ErrorTracker.Web.Router.Routes + alias Phoenix.LiveView.JS end end diff --git a/lib/error_tracker/web/components/core_components.ex b/lib/error_tracker/web/components/core_components.ex new file mode 100644 index 0000000..4f7e004 --- /dev/null +++ b/lib/error_tracker/web/components/core_components.ex @@ -0,0 +1,127 @@ +defmodule ErrorTracker.Web.CoreComponents do + @moduledoc false + use Phoenix.Component + + @doc """ + Renders a button. + + ## Examples + + <.button>Send! + <.button phx-click="go" class="ml-2">Send! + """ + attr :type, :string, default: nil + attr :class, :string, default: nil + attr :rest, :global, include: ~w(disabled form name value href patch navigate) + + slot :inner_block, required: true + + def button(assigns = %{type: "link"}) do + ~H""" + <.link + class={[ + "phx-submit-loading:opacity-75 rounded-lg bg-zinc-600 hover:bg-zinc-400 py-[11.5px] px-3", + "text-sm font-semibold leading-6 text-white active:text-white/80", + @class + ]} + {@rest} + > + <%= render_slot(@inner_block) %> + + """ + end + + def button(assigns) do + ~H""" + + """ + end + + @doc """ + Renders a badge. + + ## Examples + + <.badge>Info + <.badge color={:red}>Error + """ + attr :color, :atom, default: :blue + attr :rest, :global + + slot :inner_block, required: true + + def badge(assigns) do + color_class = + case assigns.color do + :blue -> "bg-blue-900 text-blue-300" + :gray -> "bg-gray-700 text-gray-300" + :red -> "bg-red-900 text-red-300" + :green -> "bg-green-900 text-green-300" + :yellow -> "bg-yellow-900 text-yellow-300" + :indigo -> "bg-indigo-900 text-indigo-300" + :purple -> "bg-purple-900 text-purple-300" + :pink -> "bg-pink-900 text-pink-300" + end + + assigns = Map.put(assigns, :color_class, color_class) + + ~H""" + + <%= render_slot(@inner_block) %> + + """ + end + + attr :page, :integer, required: true + attr :total_pages, :integer, required: true + attr :event_previous, :string, default: "prev-page" + attr :event_next, :string, default: "next-page" + + def pagination(assigns) do + ~H""" +
+ + +
+ """ + end + + attr :title, :string + attr :title_class, :string, default: nil + attr :rest, :global + + slot :inner_block, required: true + + def section(assigns) do + ~H""" +
+

+ <%= @title %> +

+ <%= render_slot(@inner_block) %> +
+ """ + end +end diff --git a/lib/error_tracker/web/components/layouts.ex b/lib/error_tracker/web/components/layouts.ex index 33ecade..292429f 100644 --- a/lib/error_tracker/web/components/layouts.ex +++ b/lib/error_tracker/web/components/layouts.ex @@ -1,6 +1,8 @@ defmodule ErrorTracker.Web.Layouts do use ErrorTracker.Web, :html + alias ErrorTracker.Web.Layouts.Navbar + @css_path :code.priv_dir(:error_tracker) |> Path.join("static/app.css") @js_path :code.priv_dir(:error_tracker) |> Path.join("static/app.js") diff --git a/lib/error_tracker/web/components/layouts/live.html.heex b/lib/error_tracker/web/components/layouts/live.html.heex index c753bc6..864819d 100644 --- a/lib/error_tracker/web/components/layouts/live.html.heex +++ b/lib/error_tracker/web/components/layouts/live.html.heex @@ -1,3 +1,4 @@ -
+<.live_component module={Navbar} id="navbar" {assigns} /> +
<%= @inner_content %>
diff --git a/lib/error_tracker/web/components/layouts/navbar.ex b/lib/error_tracker/web/components/layouts/navbar.ex new file mode 100644 index 0000000..d4478d5 --- /dev/null +++ b/lib/error_tracker/web/components/layouts/navbar.ex @@ -0,0 +1,67 @@ +defmodule ErrorTracker.Web.Layouts.Navbar do + @moduledoc false + use ErrorTracker.Web, :live_component + + def render(assigns) do + ~H""" + + """ + end + + attr :to, :string, required: true + attr :rest, :global + + slot :inner_block, required: true + + def navbar_item(assigns) do + ~H""" +
  • + + <%= render_slot(@inner_block) %> + +
  • + """ + end +end diff --git a/lib/error_tracker/web/components/layouts/root.html.heex b/lib/error_tracker/web/components/layouts/root.html.heex index a802f2e..439fc12 100644 --- a/lib/error_tracker/web/components/layouts/root.html.heex +++ b/lib/error_tracker/web/components/layouts/root.html.heex @@ -18,7 +18,7 @@ - + <%= @inner_content %> diff --git a/lib/error_tracker/web/helpers.ex b/lib/error_tracker/web/helpers.ex new file mode 100644 index 0000000..2309a7e --- /dev/null +++ b/lib/error_tracker/web/helpers.ex @@ -0,0 +1,10 @@ +defmodule ErrorTracker.Web.Helpers do + @moduledoc false + + @doc false + def sanitize_module(<<"Elixir.", str::binary>>), do: str + def sanitize_module(str), do: str + + @doc false + def format_datetime(dt = %DateTime{}), do: Calendar.strftime(dt, "%c %Z") +end diff --git a/lib/error_tracker/web/hooks/set_assigns.ex b/lib/error_tracker/web/hooks/set_assigns.ex new file mode 100644 index 0000000..653aab0 --- /dev/null +++ b/lib/error_tracker/web/hooks/set_assigns.ex @@ -0,0 +1,10 @@ +defmodule ErrorTracker.Web.Hooks.SetAssigns do + @moduledoc """ + Mounting hooks to set environment configuration on the socket. + """ + import Phoenix.Component + + def on_mount({:set_dashboard_path, path}, _params, _session, socket) do + {:cont, assign(socket, :dashboard_path, path)} + end +end diff --git a/lib/error_tracker/web/live/dashboard.ex b/lib/error_tracker/web/live/dashboard.ex index caf12e6..d0b06be 100644 --- a/lib/error_tracker/web/live/dashboard.ex +++ b/lib/error_tracker/web/live/dashboard.ex @@ -3,18 +3,97 @@ defmodule ErrorTracker.Web.Live.Dashboard do use ErrorTracker.Web, :live_view + import Ecto.Query + + alias ErrorTracker.Error + alias ErrorTracker.Repo + + @per_page 10 + @impl Phoenix.LiveView - def mount(_params, _session, socket) do - {:ok, assign(socket, :counter, 0)} + def handle_params(params, uri, socket) do + {search, search_form} = search_terms(params) + + path = struct(URI, uri |> URI.parse() |> Map.take([:path, :query])) + + {:noreply, + socket + |> assign(path: path, search: search, page: 1, search_form: search_form) + |> paginate_errors()} end @impl Phoenix.LiveView - def handle_params(_params, _uri, socket) do - {:noreply, socket} + def handle_event("search", params, socket) do + {search, _search_form} = search_terms(params["search"] || %{}) + + path_w_filters = %URI{socket.assigns.path | query: URI.encode_query(search)} + + {:noreply, push_patch(socket, to: URI.to_string(path_w_filters))} + end + + @impl Phoenix.LiveView + def handle_event("next-page", _params, socket) do + {:noreply, socket |> assign(page: socket.assigns.page + 1) |> paginate_errors()} + end + + @impl Phoenix.LiveView + def handle_event("prev-page", _params, socket) do + {:noreply, socket |> assign(page: socket.assigns.page - 1) |> paginate_errors()} + end + + @impl Phoenix.LiveView + def handle_event("resolve", %{"error_id" => id}, socket) do + error = Repo.get(Error, id) + {:ok, _resolved} = ErrorTracker.resolve(error) + + {:noreply, paginate_errors(socket)} end @impl Phoenix.LiveView - def handle_event("increment", _params, socket) do - {:noreply, assign(socket, :counter, socket.assigns.counter + 1)} + def handle_event("unresolve", %{"error_id" => id}, socket) do + error = Repo.get(Error, id) + {:ok, _unresolved} = ErrorTracker.unresolve(error) + + {:noreply, paginate_errors(socket)} + end + + defp paginate_errors(socket) do + %{page: page, search: search} = socket.assigns + + query = filter(Error, search) + + total_errors = Repo.aggregate(query, :count) + + errors_query = + query + |> order_by(desc: :last_occurrence_at) + |> offset((^page - 1) * @per_page) + |> limit(@per_page) + + assign(socket, + errors: Repo.all(errors_query), + total_pages: (total_errors / @per_page) |> Float.ceil() |> trunc + ) + end + + defp search_terms(params) do + data = %{} + types = %{reason: :string, source_line: :string, source_function: :string, status: :string} + + changeset = Ecto.Changeset.cast({data, types}, params, Map.keys(types)) + + {Ecto.Changeset.apply_changes(changeset), to_form(changeset, as: :search)} + end + + defp filter(query, search) do + Enum.reduce(search, query, &do_filter/2) + end + + defp do_filter({:status, status}, query) do + where(query, [error], error.status == ^status) + end + + defp do_filter({field, value}, query) do + where(query, [error], ilike(field(error, ^field), ^"%#{value}%")) end end diff --git a/lib/error_tracker/web/live/dashboard.html.heex b/lib/error_tracker/web/live/dashboard.html.heex index 07f5708..35d24f6 100644 --- a/lib/error_tracker/web/live/dashboard.html.heex +++ b/lib/error_tracker/web/live/dashboard.html.heex @@ -1,8 +1,93 @@ -

    Hello world!

    -

    Number of presses: <%= @counter %>

    - + + + + + + +
    + + + + + + + + + + + + + + + + + +
    ErrorLast occurrenceStatus
    + <.link navigate={error_path(assigns, error)}> + (<%= sanitize_module(error.kind) %>) <%= error.reason %> + +

    + <%= sanitize_module(error.source_function) %> +
    + <%= error.source_line %> +

    +
    + <%= format_datetime(error.last_occurrence_at) %> + + <.badge :if={error.status == :resolved} color={:green}>Resolved + <.badge :if={error.status == :unresolved} color={:red}>Unresolved + + <.button + :if={error.status == :unresolved} + phx-click="resolve" + phx-value-error_id={error.id} + > + Resolve + + + <.button + :if={error.status == :resolved} + phx-click="unresolve" + phx-value-error_id={error.id} + > + Unresolve + +
    +
    + +<.pagination page={@page} total_pages={@total_pages} /> diff --git a/lib/error_tracker/web/live/show.ex b/lib/error_tracker/web/live/show.ex new file mode 100644 index 0000000..564b6a1 --- /dev/null +++ b/lib/error_tracker/web/live/show.ex @@ -0,0 +1,117 @@ +defmodule ErrorTracker.Web.Live.Show do + @moduledoc false + use ErrorTracker.Web, :live_view + + import Ecto.Query + + alias ErrorTracker.Error + alias ErrorTracker.Occurrence + alias ErrorTracker.Repo + + @occurrences_to_navigate 50 + + @impl Phoenix.LiveView + def mount(%{"id" => id}, _session, socket) do + error = Repo.get!(Error, id) + {:ok, assign(socket, error: error, app: Application.fetch_env!(:error_tracker, :application))} + end + + @impl Phoenix.LiveView + def handle_params(%{"occurrence_id" => occurrence_id}, _uri, socket) do + occurrence = + socket.assigns.error + |> Ecto.assoc(:occurrences) + |> Repo.get!(occurrence_id) + + socket = + socket + |> assign(:occurrence, occurrence) + |> load_related_occurrences() + + {:noreply, socket} + end + + def handle_params(_, _uri, socket) do + [occurrence] = + socket.assigns.error + |> Ecto.assoc(:occurrences) + |> order_by([o], desc: o.id) + |> limit(1) + |> Repo.all() + + socket = + socket + |> assign(:occurrence, occurrence) + |> load_related_occurrences() + + {:noreply, socket} + end + + @impl Phoenix.LiveView + def handle_event("occurrence_navigation", %{"occurrence_id" => id}, socket) do + {:noreply, + push_patch(socket, + to: occurrence_path(socket.assigns, %Occurrence{error_id: socket.assigns.error.id, id: id}) + )} + end + + @impl Phoenix.LiveView + def handle_event("resolve", _params, socket) do + {:ok, updated_error} = ErrorTracker.resolve(socket.assigns.error) + + {:noreply, assign(socket, :error, updated_error)} + end + + @impl Phoenix.LiveView + def handle_event("unresolve", _params, socket) do + {:ok, updated_error} = ErrorTracker.unresolve(socket.assigns.error) + + {:noreply, assign(socket, :error, updated_error)} + end + + defp load_related_occurrences(socket) do + current_occurrence = socket.assigns.occurrence + base_query = Ecto.assoc(socket.assigns.error, :occurrences) + + half_limit = floor(@occurrences_to_navigate / 2) + + previous_occurrences_query = where(base_query, [o], o.id < ^current_occurrence.id) + next_occurrences_query = where(base_query, [o], o.id > ^current_occurrence.id) + previous_count = Repo.aggregate(previous_occurrences_query, :count) + next_count = Repo.aggregate(next_occurrences_query, :count) + + {previous_limit, next_limit} = + cond do + previous_count < half_limit and next_count < half_limit -> + {previous_count, next_count} + + previous_count < half_limit -> + {previous_count, @occurrences_to_navigate - previous_count - 1} + + next_count < half_limit -> + {@occurrences_to_navigate - next_count - 1, next_count} + + true -> + {half_limit, half_limit} + end + + occurrences = + [ + related_occurrences(next_occurrences_query, next_limit), + current_occurrence, + related_occurrences(previous_occurrences_query, previous_limit) + ] + |> List.flatten() + |> Enum.reverse() + + assign(socket, :occurrences, occurrences) + end + + defp related_occurrences(query, num_results) do + query + |> order_by([o], desc: o.id) + |> select([:id, :error_id, :inserted_at]) + |> limit(^num_results) + |> Repo.all() + end +end diff --git a/lib/error_tracker/web/live/show.html.heex b/lib/error_tracker/web/live/show.html.heex new file mode 100644 index 0000000..c01f7c4 --- /dev/null +++ b/lib/error_tracker/web/live/show.html.heex @@ -0,0 +1,101 @@ +
    + <.button type="link" href={dashboard_path(assigns)}>Back to the dashboard +
    + + + +
    +
    + <.section title="Full message"> +
    <%= @error.reason %>
    + + + <.section title="Source"> +
    +        <%= sanitize_module(@error.source_function) %>
    +        <%= @error.source_line %>
    + + + <.section title="Stacktrace"> +
    +
    + +
    + + + + + + + + +
    (<%= line.application || @app %>)
    +
    <%= "#{sanitize_module(line.module)}.#{line.function}/#{line.arity}" %>
    +                <%= "#{line.file}.#{line.line}" %>
    +
    +
    + + + <.section title="Context"> +
    <%= Jason.encode!(@occurrence.context, pretty: true) %>
    + +
    + +
    + <.section title="Occurrence"> +
    + +
    + + + <.section title="Error kind"> +
    <%= sanitize_module(@error.kind) %>
    + + + <.section title="Last seen"> +
    <%= format_datetime(@error.last_occurrence_at) %>
    + + + <.section title="Status" title_class="mb-3"> + <.badge :if={@error.status == :resolved} color={:green}>Resolved + <.badge :if={@error.status == :unresolved} color={:red}>Unresolved + + + <.section> + <.button :if={@error.status == :unresolved} phx-click="resolve"> + Mark as resolved + + + <.button :if={@error.status == :resolved} phx-click="unresolve"> + Mark as unresolved + + +
    +
    diff --git a/lib/error_tracker/web/router.ex b/lib/error_tracker/web/router.ex index 52136df..8254b34 100644 --- a/lib/error_tracker/web/router.ex +++ b/lib/error_tracker/web/router.ex @@ -7,7 +7,7 @@ defmodule ErrorTracker.Web.Router do It requires a path in which you are going to serve the web interface. """ defmacro error_tracker_dashboard(path, opts \\ []) do - {session_name, session_opts} = parse_options(opts) + {session_name, session_opts} = parse_options(opts, path) quote do scope unquote(path), alias: false, as: false do @@ -15,14 +15,20 @@ defmodule ErrorTracker.Web.Router do live_session unquote(session_name), unquote(session_opts) do live "/", ErrorTracker.Web.Live.Dashboard, :index, as: unquote(session_name) + live "/:id", ErrorTracker.Web.Live.Show, :show, as: unquote(session_name) + live "/:id/:occurrence_id", ErrorTracker.Web.Live.Show, :show, as: unquote(session_name) end end end end @doc false - def parse_options(opts) do - on_mount = Keyword.get(opts, :on_mount, []) + def parse_options(opts, path) do + custom_on_mount = Keyword.get(opts, :on_mount, []) + + on_mount = + [{ErrorTracker.Web.Hooks.SetAssigns, {:set_dashboard_path, path}}] ++ custom_on_mount + session_name = Keyword.get(opts, :as, :error_tracker_dashboard) session_opts = [ diff --git a/lib/error_tracker/web/router/routes.ex b/lib/error_tracker/web/router/routes.ex new file mode 100644 index 0000000..5945b42 --- /dev/null +++ b/lib/error_tracker/web/router/routes.ex @@ -0,0 +1,24 @@ +defmodule ErrorTracker.Web.Router.Routes do + @moduledoc """ + Module used to generate dashboard routes. + """ + + alias ErrorTracker.Error + alias ErrorTracker.Occurrence + + @doc """ + Returns the dashboard path + """ + def dashboard_path(%{dashboard_path: dashboard_path}), do: dashboard_path + + @doc """ + Returns the path to see the details of an error + """ + def error_path(assigns, %Error{id: id}), do: dashboard_path(assigns) <> "/#{id}" + + @doc """ + Returns the path to see the details of an occurrence + """ + def occurrence_path(assigns, %Occurrence{id: id, error_id: error_id}), + do: dashboard_path(assigns) <> "/#{error_id}/#{id}" +end diff --git a/mix.exs b/mix.exs index 38a3fe7..f40688d 100644 --- a/mix.exs +++ b/mix.exs @@ -47,6 +47,7 @@ defmodule ErrorTracker.MixProject do {:ecto, "~> 3.11"}, {:jason, "~> 1.1"}, {:phoenix_live_view, "~> 0.19 or ~> 1.0"}, + {:phoenix_ecto, "~> 4.6"}, {:plug, "~> 1.10"}, {:postgrex, ">= 0.0.0"}, # Dev dependencies diff --git a/mix.lock b/mix.lock index 0f48b67..61237f0 100644 --- a/mix.lock +++ b/mix.lock @@ -20,6 +20,7 @@ "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, "phoenix": {:hex, :phoenix, "1.7.12", "1cc589e0eab99f593a8aa38ec45f15d25297dd6187ee801c8de8947090b5a9d3", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "d646192fbade9f485b01bc9920c139bfdd19d0f8df3d73fd8eaf2dfbe0d2837c"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.2", "3b83b24ab5a2eb071a20372f740d7118767c272db386831b2e77638c4dcc606d", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "3f94d025f59de86be00f5f8c5dd7b5965a3298458d21ab1c328488be3b5fcd59"}, "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"}, "phoenix_live_view": {:hex, :phoenix_live_view, "0.20.14", "70fa101aa0539e81bed4238777498f6215e9dda3461bdaa067cad6908110c364", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "82f6d006c5264f979ed5eb75593d808bbe39020f20df2e78426f4f2d570e2402"},