From 824bf31f0c5308881f77e08c9a15b94961d098e4 Mon Sep 17 00:00:00 2001 From: Muse Date: Mon, 23 Mar 2026 13:02:16 +0000 Subject: [PATCH] =?UTF-8?q?Add=20Engram=20section=20to=20status=20page=20?= =?UTF-8?q?=E2=80=94=20session=20summaries=20+=20stats?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/config.exs | 3 +- config/runtime.exs | 16 +- elixir_notes.md | 473 ++++++++ lib/cortex_status/services/monitor.ex | 42 +- .../components/layouts/root.html.heex | 1046 ++++++++++++++++- .../live/dashboard_pages/tasks_page.ex | 81 ++ lib/cortex_status_web/live/status_live.ex | 142 ++- lib/cortex_status_web/live/task_live.ex | 559 +++++++++ lib/cortex_status_web/router.ex | 12 +- mix.lock | 2 + 10 files changed, 2347 insertions(+), 29 deletions(-) create mode 100644 elixir_notes.md create mode 100644 lib/cortex_status_web/live/dashboard_pages/tasks_page.ex create mode 100644 lib/cortex_status_web/live/task_live.ex diff --git a/config/config.exs b/config/config.exs index 2e2f830..63aeb27 100644 --- a/config/config.exs +++ b/config/config.exs @@ -19,6 +19,7 @@ config :cortex_status, :services, dendrite_api_key: "8dc5e8f7a02745ee8db90c94b2481fd9e1deeea1e2ce74420f54047859ea7edf", monitored_sites: [ {"hydrascale.net", "https://hydrascale.net"}, + {"git.hydrascale.net", "https://git.hydrascale.net"}, {"browser.hydrascale.net", "https://browser.hydrascale.net/health"} ] @@ -47,4 +48,4 @@ config :logger, :console, config :phoenix, :json_library, Jason -import_config "#{config_env()}.exs" +import_config "#{config_env()}.exs" \ No newline at end of file diff --git a/config/runtime.exs b/config/runtime.exs index 4e63924..4981fd2 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -12,22 +12,22 @@ if config_env() == :prod do You can generate one by calling: mix phx.gen.secret """ - host = System.get_env("PHX_HOST") || "status.hydrascale.net" + host = System.get_env("PHX_HOST") || "cortex.hydrascale.net" port = String.to_integer(System.get_env("PORT") || "4000") config :cortex_status, CortexStatusWeb.Endpoint, url: [host: host, port: 443, scheme: "https"], http: [ip: {127, 0, 0, 1}, port: port], - secret_key_base: secret_key_base + secret_key_base: secret_key_base, + check_origin: ["https://cortex.hydrascale.net", "https://status.hydrascale.net"] - # Override service URLs in prod if env vars are set config :cortex_status, :services, symbiont_url: System.get_env("SYMBIONT_URL") || "http://127.0.0.1:8111", dendrite_url: System.get_env("DENDRITE_URL") || "http://localhost:3000", dendrite_api_key: System.get_env("DENDRITE_API_KEY") || "8dc5e8f7a02745ee8db90c94b2481fd9e1deeea1e2ce74420f54047859ea7edf", monitored_sites: [ - {"hydrascale.net", "https://hydrascale.net"}, - {"browser.hydrascale.net", "https://browser.hydrascale.net/health"}, - {"cortex.hydrascale.net", "https://cortex.hydrascale.net"} - ] -end + {"hydrascale.net", "https://hydrascale.net"}, + {"git.hydrascale.net", "https://git.hydrascale.net"}, + {"browser.hydrascale.net", "https://browser.hydrascale.net/health"} + ] +end \ No newline at end of file diff --git a/elixir_notes.md b/elixir_notes.md new file mode 100644 index 0000000..64b6303 --- /dev/null +++ b/elixir_notes.md @@ -0,0 +1,473 @@ +# Elixir / Phoenix Learnings — Cortex Status Dashboard + +Patterns, gotchas, and reference notes from building the cortex_status Phoenix app. + +## Project: cortex_status + +- **Location on cortex**: `/data/cortex_status/` +- **Service**: `symbiont-ex-api.service` (systemd) +- **Port**: 4000 (behind Caddy at status.hydrascale.net) +- **Framework**: Phoenix 1.7.14, LiveView 1.0.0, LiveDashboard 0.8.4 + +--- + +## LiveView Patterns + +### PubSub for Real-Time Updates +The status page subscribes to PubSub topics on mount and receives broadcast updates: +```elixir +def mount(_params, _session, socket) do + if connected?(socket) do + Phoenix.PubSub.subscribe(CortexStatus.PubSub, "service_status") + end + {:ok, assign(socket, ...)} +end + +def handle_info({:status_update, new_status}, socket) do + {:noreply, assign(socket, status: new_status)} +end +``` + +### Polling Pattern (Process.send_after) +For polling an external API from a LiveView (e.g., task progress): +```elixir +@poll_interval 1_000 + +# Start polling +timer = Process.send_after(self(), :poll_task, @poll_interval) +{:noreply, assign(socket, poll_timer: timer)} + +# Handle poll +def handle_info(:poll_task, socket) do + case fetch_progress(socket.assigns.task_id) do + {:ok, data} when is_map(data) -> + terminal = data["status"] in ["completed", "failed"] + timer = if terminal, do: nil, else: Process.send_after(self(), :poll_task, @poll_interval) + {:noreply, assign(socket, data: data, poll_timer: timer)} + _ -> + # Don't crash on bad data — keep polling + timer = Process.send_after(self(), :poll_task, @poll_interval) + {:noreply, assign(socket, poll_timer: timer)} + end +end +``` + +**Gotcha**: Always cancel timers on unmount/logout: +```elixir +if socket.assigns.poll_timer, do: Process.cancel_timer(socket.assigns.poll_timer) +``` + +### Component Attrs and Passing Assigns +When defining function components with `attr`, use `assigns` directly. For passing whole assigns bundles to sub-components, use a named assign: +```elixir +# Don't try to pass @assigns directly — use a named prop +<.auth_gate socket_assigns={assigns} /> + +defp auth_gate(assigns) do + ~H""" + <%= @socket_assigns.prompt %> + """ +end +``` + +### phx-change Goes on the Form, Not Individual Inputs +```elixir +# WRONG: phx-change on textarea alone won't fire + + +# RIGHT: phx-change on the form, inputs trigger it +
+ +
+``` + +### Enum.with_index Returns {element, index} +```elixir +# WRONG — destructuring is backwards: +for {index, element} <- Enum.with_index(list) + +# RIGHT: +for {element, index} <- Enum.with_index(list) +``` + +--- + +## LiveDashboard Custom Pages + +### Basic Structure +```elixir +defmodule MyApp.DashboardPages.MyPage do + use Phoenix.LiveDashboard.PageBuilder + + @impl true + def menu_link(_, _), do: {:ok, "Page Title"} + + @impl true + def render_page(_assigns) do + {:ok, row(components: [card(value: "Hello", inner_title: "Title")])} + end +end +``` + +### Registration in Router +```elixir +live_dashboard "/dashboard", + metrics: MyAppWeb.Telemetry, + additional_pages: [ + my_page: MyApp.DashboardPages.MyPage + ] +``` + +### Available Components +- `card(value:, inner_title:)` — simple KV display +- `table(columns:, rows:, id:, title:, row_attrs:)` — data table +- `row(components:)` — horizontal layout +- `columns(columns:)` — multi-column layout + +**Gotcha**: `row_attrs` must be a function: `fn row -> [{"data-id", row.id}] end` + +--- + +## DateTime Gotchas + +### DateTime.from_iso8601 Returns {:error, reason}, Not :error +```elixir +# WRONG: +case DateTime.from_iso8601(str) do + {:ok, dt, _} -> dt + :error -> str # This clause never matches! +end + +# RIGHT: +case DateTime.from_iso8601(str) do + {:ok, dt, _} -> dt + {:error, _} -> str # Correct error tuple +end +``` + +--- + +## HTTP Calls from LiveView/GenServer + +### Using Req Library +```elixir +# GET +case Req.get("http://localhost:8111/status", receive_timeout: 5000) do + {:ok, %{status: 200, body: body}} when is_map(body) -> {:ok, body} + {:ok, %{status: code}} -> {:error, "HTTP #{code}"} + {:error, reason} -> {:error, reason} +end + +# POST with JSON +case Req.post(url, json: %{"prompt" => prompt}) do + {:ok, %{status: 200, body: body}} -> {:ok, body} + ... +end +``` + +**Gotcha**: Always handle when `body` might be a string (not auto-parsed JSON). Req parses JSON automatically when content-type is application/json. + +--- + +## Application Configuration + +### Reading Config at Runtime (Not Compile-Time) +Use function calls instead of module attributes for config that may change: +```elixir +# WRONG — baked in at compile time: +@symbiont_url Application.get_env(:cortex_status, :services)[:symbiont_url] + +# RIGHT — reads at runtime: +defp symbiont_url do + config = Application.get_env(:cortex_status, :services, []) + Keyword.get(config, :symbiont_url, "http://127.0.0.1:8111") +end +``` + +--- + +## os_mon for System Metrics + +The app uses Erlang's `os_mon` for host-level metrics (requires `:os_mon` in `extra_applications`): +```elixir +cpu_load = :cpu_sup.avg1() / 256 # normalized 0-1 +{mem_total, mem_alloc, _} = :memsup.get_memory_data() +disk_data = :disksup.get_disk_data() # [{mount, total_kb, percent_used}] +``` + +--- + +## Release & Deployment + +### Build Commands +```bash +cd /data/cortex_status +MIX_ENV=prod mix deps.get +MIX_ENV=prod mix compile +MIX_ENV=prod mix assets.deploy # tailwind + esbuild +MIX_ENV=prod mix release --overwrite +systemctl restart symbiont-ex-api +``` + +### Environment Variables (runtime.exs) +- `SECRET_KEY_BASE` — required in prod +- `PHX_HOST` — defaults to cortex.hydrascale.net +- `PORT` — defaults to 4000 +- `SYMBIONT_URL` — override Symbiont API base URL + +--- + +## check_origin: The LiveView Connection Killer + +When LiveView connections silently fail (`liveSocket.isConnected()` returns `false`, +`_mount_attempts` climbs into the thousands), the most likely culprit is a `check_origin` +mismatch. Phoenix checks the HTTP `Origin` header against its configured URL. + +**Symptom**: Phoenix logs show: +``` +[error] Could not check origin for Phoenix.Socket transport. +Origin of the request: https://cortex.hydrascale.net +``` + +**Root cause**: Setting `url: [host: h, port: 443, scheme: "https"]` in `runtime.exs` +causes Phoenix to expect an origin of `https://cortex.hydrascale.net:443`, but browsers +send `https://cortex.hydrascale.net` (no port — 443 is implicit for HTTPS). String +comparison fails. + +**Fix**: Explicitly set `check_origin` in the endpoint config in `runtime.exs`: +```elixir +config :cortex_status, CortexStatusWeb.Endpoint, + url: [host: host, port: 443, scheme: "https"], + http: [ip: {127, 0, 0, 1}, port: port], + secret_key_base: secret_key_base, + check_origin: ["https://cortex.hydrascale.net"] # ← explicit, no port +``` + +**Note**: LiveView uses `longpoll` as its transport (WebSocket upgrades may not work +through all Caddy configs). `longpoll` is functionally identical for most purposes — +slightly more latency but fully supported. + +--- + +## LiveView Auth Gate Pattern + +### Password-Protected LiveView Pages +For pages that need a simple password gate (like Mission Control), use assigns to track +auth state and conditionally render: +```elixir +def mount(_params, _session, socket) do + {:ok, assign(socket, authenticated: false, error: nil, prompt: "")} +end + +def handle_event("authenticate", %{"password" => pw}, socket) do + if pw == Application.get_env(:my_app, :task_password) do + {:noreply, assign(socket, authenticated: true, error: nil)} + else + {:noreply, assign(socket, error: "Invalid password")} + end +end +``` + +In the template, wrap content with `<%= if @authenticated do %>`. + +**Gotcha**: The password check happens server-side in the LiveView process, so it's +secure even though the HTML is rendered client-side. But remember: the initial static +render (before WebSocket connects) will show the unauthenticated state, so don't put +sensitive data in the assigns until after authentication. + +--- + +## LiveView Silent Failures — Always Show Error Feedback + +### The Problem +When a LiveView event handler hits an error (API call fails, validation error, etc.) +and you just return `{:noreply, socket}` without updating assigns, the user sees +*nothing happen*. The button appears to do nothing. This is extremely confusing. + +### The Fix Pattern +Always maintain an `error` assign and display it: +```elixir +def handle_event("submit_task", %{"prompt" => prompt}, socket) do + case submit_to_api(prompt) do + {:ok, task_id} -> + {:noreply, assign(socket, task_id: task_id, error: nil)} + {:error, reason} -> + {:noreply, assign(socket, error: "Task failed: #{reason}")} + end +end +``` + +In the template: +```elixir +<%= if @error do %> +
<%= @error %>
+<% end %> +``` + +**Lesson learned the hard way**: The Mission Control "Execute" button did nothing +for a while because the Symbiont API was rejecting the auth token, but the error +was swallowed silently. Always surface errors to the UI. + +--- + +## API Authentication from LiveView + +### Bearer Token vs Query Param vs JSON Body +When calling external APIs from LiveView, be careful about where auth tokens go. +FastAPI's `Depends()` for auth reads from specific locations — if the API expects +a query param and you send it in the JSON body, auth silently fails. + +**Preferred pattern**: Use `Authorization: Bearer ` header — it's unambiguous: +```elixir +headers = [{"authorization", "Bearer #{token}"}, {"content-type", "application/json"}] +case Req.post(url, json: payload, headers: headers) do + {:ok, %{status: 200, body: body}} -> {:ok, body} + {:ok, %{status: code, body: body}} -> {:error, "HTTP #{code}: #{inspect(body)}"} + {:error, reason} -> {:error, inspect(reason)} +end +``` + +**Gotcha**: Always match on the status code, not just `{:ok, _}`. A 401 or 500 +response is still `{:ok, %Req.Response{}}` — it's only `:error` if the HTTP +request itself fails (timeout, DNS, connection refused). + +--- + +## LiveView Form Gotchas (Expanded) + +### phx-submit Doesn't Fire Without a Submit Button +If your form has `phx-submit="do_thing"` but no ` + + + + + + """ + end + + attr :prompt, :string, default: "" + attr :submitting, :boolean, default: false + attr :active_task, :any, default: nil + attr :task_history, :list, default: [] + attr :submit_error, :string, default: nil + + defp task_interface(assigns) do + ~H""" +
+
+

Mission Control

+ +
+ +
+
+
+ + + <%= if @submit_error do %> +
<%= @submit_error %>
+ <% end %> + +
+
+ + <%= if @active_task do %> + <.task_visualization task={@active_task} /> + <% else %> + <.task_history history={@task_history} /> + <% end %> +
+
+ """ + end + + defp task_visualization(assigns) do + ~H""" +
+ + +
+
Original Prompt
+
<%= @task["prompt"] %>
+
+ + <%= if @task["reasoning"] do %> +
+
Decomposition Reasoning
+
<%= @task["reasoning"] %>
+
+ <% end %> + +
+
Task Flow Visualization
+ <.task_flow_graph subtasks={@task["subtasks"] || []} /> +
+ +
+
Subtask Details
+
+ <%= for subtask <- @task["subtasks"] || [] do %> + <.subtask_detail subtask={subtask} /> + <% end %> +
+
+
+ """ + end + + defp task_flow_graph(assigns) do + # Group subtasks by wave (dependency level) + subtasks = assigns.subtasks || [] + waves = compute_waves(subtasks) + + ~H""" +
+ <%= for {wave_tasks, wave_num} <- Enum.with_index(waves) do %> +
+
Wave <%= wave_num %>
+
+ <%= for subtask <- wave_tasks do %> + <.subtask_card subtask={subtask} /> + <% end %> +
+
+ <% end %> +
+ """ + end + + defp subtask_card(assigns) do + ~H""" +
+
+
+ <%= case @subtask["status"] do %> + <% "completed" -> %> + ✓ + <% "failed" -> %> + ✗ + <% "executing" -> %> + ◆ + <% _ -> %> + ◇ + <% end %> +
+
+ <%= String.slice(@subtask["description"], 0..40) %> +
+
+ +
+ <%= if @subtask["model"] do %> + + <%= @subtask["model"] %> + + <% end %> +
+ + <%= if @subtask["status"] == "executing" do %> +
+ + Executing... +
+ <% end %> + + <%= if @subtask["result"] do %> +
+ <%= String.slice(@subtask["result"], 0..60) %> +
+ <% end %> + +
+ $<%= format_cost(@subtask["cost"]) %> +
+
+ """ + end + + defp subtask_detail(assigns) do + ~H""" +
+
+ + Subtask <%= @subtask["index"] %> + + + <%= String.upcase(@subtask["status"]) %> + +
+ +
+ <%= @subtask["description"] %> +
+ +
+ + Model: <%= @subtask["model"] %> + + + Tier: <%= @subtask["tier_assigned"] %> + + + Cost: $<%= format_cost(@subtask["cost"]) %> + +
+ + <%= if @subtask["started_at"] do %> +
+ + Started: <%= format_datetime(@subtask["started_at"]) %> + + <%= if @subtask["completed_at"] do %> + + Completed: <%= format_datetime(@subtask["completed_at"]) %> + + <% end %> +
+ <% end %> + + <%= if @subtask["result"] do %> +
+ Result: +
<%= @subtask["result"] %>
+
+ <% end %> + + <%= if @subtask["depends_on"] && length(@subtask["depends_on"]) > 0 do %> +
+ Depends on: <%= Enum.join(@subtask["depends_on"], ", ") %> +
+ <% end %> +
+ """ + end + + defp task_history(assigns) do + ~H""" +
+
Recent Tasks
+ + <%= if length(@history) == 0 do %> +
+

No tasks yet. Create your first compound task above.

+
+ <% else %> +
+ <%= for task <- @history do %> +
+
+ <%= task["id"] %> + + <%= String.upcase(task["status"]) %> + +
+
+ <%= String.slice(task["prompt"], 0..100) %> +
+
+ <%= task["subtask_count"] || length(task["subtasks"] || []) %> subtasks + + $<%= format_cost(task["total_cost"]) %> + +
+
+ <% end %> +
+ <% end %> +
+ """ + end + + # -- Helpers -- + + defp submit_compound_task(prompt) do + case Req.post("#{symbiont_url()}/task/compound", + json: %{"prompt" => prompt}, + headers: [{"authorization", "Bearer #{@auth_token}"}] + ) do + {:ok, response} -> + case response.status do + 200 -> {:ok, response.body} + _ -> {:error, "HTTP #{response.status}"} + end + + {:error, reason} -> + {:error, reason} + end + end + + defp fetch_task_progress(task_id) do + case Req.get("#{symbiont_url()}/task/#{task_id}/progress") do + {:ok, response} -> + case response.status do + 200 -> {:ok, response.body} + _ -> {:error, "HTTP #{response.status}"} + end + + {:error, reason} -> + {:error, reason} + end + end + + defp fetch_recent_tasks do + case Req.get("#{symbiont_url()}/tasks/recent") do + {:ok, response} -> + case response.status do + 200 -> + case response.body do + list when is_list(list) -> list + _ -> [] + end + + _ -> + [] + end + + {:error, _} -> + [] + end + end + + defp compute_waves(subtasks) do + # Group subtasks by dependency level (wave) + # Wave 0: no dependencies, Wave N: max(dep waves) + 1 + # Sort by index first to ensure deps are computed before dependents + sorted = Enum.sort_by(subtasks, & &1["index"]) + + wave_map = + Enum.reduce(sorted, %{}, fn task, acc -> + deps = task["depends_on"] || [] + + wave = + if Enum.empty?(deps) do + 0 + else + deps + |> Enum.map(&Map.get(acc, &1, 0)) + |> Enum.max(fn -> 0 end) + |> Kernel.+(1) + end + + Map.put(acc, task["index"], wave) + end) + + subtasks + |> Enum.group_by(&Map.get(wave_map, &1["index"], 0)) + |> Enum.sort_by(fn {wave, _} -> wave end) + |> Enum.map(fn {_, tasks} -> Enum.sort_by(tasks, &(&1["index"])) end) + end + + defp format_cost(cost) when is_number(cost) do + :io_lib.format("~.4f", [cost]) |> to_string() + end + + defp format_cost(_), do: "0.0000" + + defp format_datetime(iso_string) when is_binary(iso_string) do + case DateTime.from_iso8601(iso_string) do + {:ok, dt, _} -> + dt + |> DateTime.to_naive() + |> NaiveDateTime.to_string() + + {:error, _} -> + iso_string + end + end + + defp format_datetime(nil), do: "—" +end \ No newline at end of file diff --git a/lib/cortex_status_web/router.ex b/lib/cortex_status_web/router.ex index 442b03b..bb50df1 100644 --- a/lib/cortex_status_web/router.ex +++ b/lib/cortex_status_web/router.ex @@ -20,14 +20,22 @@ defmodule CortexStatusWeb.Router do pipe_through :browser live "/", StatusLive, :index + live "/tasks", TaskLive, :index end - # Standard Phoenix LiveDashboard for BEAM VM metrics + # Phoenix LiveDashboard with custom pages scope "/" do pipe_through :browser live_dashboard "/dashboard", - metrics: CortexStatusWeb.Telemetry + metrics: CortexStatusWeb.Telemetry, + additional_pages: [ + cortex_health: CortexStatusWeb.DashboardPages.ServerHealthPage, + symbiont: CortexStatusWeb.DashboardPages.SymbiontPage, + dendrite: CortexStatusWeb.DashboardPages.DendritePage, + websites: CortexStatusWeb.DashboardPages.WebsitesPage, + tasks: CortexStatusWeb.DashboardPages.TasksPage + ] end # JSON API for external consumption diff --git a/mix.lock b/mix.lock index 6b485e1..f751c2b 100644 --- a/mix.lock +++ b/mix.lock @@ -2,6 +2,7 @@ "bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"}, "castore": {:hex, :castore, "1.0.18", "5e43ef0ec7d31195dfa5a65a86e6131db999d074179d2ba5a8de11fe14570f55", [:mix], [], "hexpm", "f393e4fe6317829b158fb74d86eb681f737d2fe326aa61ccf6293c4104957e34"}, "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, @@ -12,6 +13,7 @@ "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [: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", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"}, + "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"}, "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.18", "943431edd0ef8295ffe4949f0897e2cb25c47d3d7ebba2b008d7c68598b887f1", [: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 or ~> 1.8.0-rc", [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", "724934fd0a68ecc57281cee863674454b06163fed7f5b8005b5e201ba4b23316"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},