commit 51f741965f244b100cf8d1edc612b58e2f5acad8 Author: Michael Date: Sat Mar 21 17:42:45 2026 +0000 Initial commit diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..e945e12 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,5 @@ +[ + import_deps: [:phoenix], + plugins: [Phoenix.LiveView.HTMLFormatter], + inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}"] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d64362e --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Elixir/Phoenix +/_build/ +/deps/ +/priv/static/assets/ +/priv/static/cache_manifest.json +*.ez +erl_crash.dump + +# Node +/assets/node_modules/ + +# Environment +.env +*.secret + +# Release +/rel/ diff --git a/assets/css/app.css b/assets/css/app.css new file mode 100644 index 0000000..db782d2 --- /dev/null +++ b/assets/css/app.css @@ -0,0 +1 @@ +/* Styles are inline in root.html.heex for simplicity */ diff --git a/assets/js/app.js b/assets/js/app.js new file mode 100644 index 0000000..fa5750d --- /dev/null +++ b/assets/js/app.js @@ -0,0 +1,13 @@ +import "phoenix_html" +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, { + longPollFallbackMs: 2500, + params: {_csrf_token: csrfToken} +}) + +liveSocket.connect() + +window.liveSocket = liveSocket diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js new file mode 100644 index 0000000..206fa98 --- /dev/null +++ b/assets/tailwind.config.js @@ -0,0 +1,11 @@ +module.exports = { + content: [ + "./js/**/*.js", + "../lib/cortex_status_web.ex", + "../lib/cortex_status_web/**/*.*ex" + ], + theme: { + extend: {}, + }, + plugins: [] +} diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..2e2f830 --- /dev/null +++ b/config/config.exs @@ -0,0 +1,50 @@ +import Config + +config :cortex_status, + generators: [timestamp_type: :utc_datetime] + +config :cortex_status, CortexStatusWeb.Endpoint, + url: [host: "localhost"], + adapter: Bandit.PhoenixAdapter, + render_errors: [ + formats: [html: CortexStatusWeb.ErrorHTML, json: CortexStatusWeb.ErrorJSON], + layout: false + ], + pubsub_server: CortexStatus.PubSub, + live_view: [signing_salt: "cortex_lv_salt"] + +config :cortex_status, :services, + symbiont_url: "http://127.0.0.1:8111", + dendrite_url: "http://localhost:3000", + dendrite_api_key: "8dc5e8f7a02745ee8db90c94b2481fd9e1deeea1e2ce74420f54047859ea7edf", + monitored_sites: [ + {"hydrascale.net", "https://hydrascale.net"}, + {"browser.hydrascale.net", "https://browser.hydrascale.net/health"} + ] + +config :esbuild, + version: "0.17.11", + cortex_status: [ + args: ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets), + cd: Path.expand("../assets", __DIR__), + env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} + ] + +config :tailwind, + version: "3.4.3", + cortex_status: [ + args: ~w( + --config=tailwind.config.js + --input=css/app.css + --output=../priv/static/assets/app.css + ), + cd: Path.expand("../assets", __DIR__) + ] + +config :logger, :console, + format: "$time $metadata[$level] $message\n", + metadata: [:request_id] + +config :phoenix, :json_library, Jason + +import_config "#{config_env()}.exs" diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..cfa3b9c --- /dev/null +++ b/config/dev.exs @@ -0,0 +1,22 @@ +import Config + +config :cortex_status, CortexStatusWeb.Endpoint, + http: [ip: {127, 0, 0, 1}, port: 4000], + check_origin: false, + code_reloader: true, + debug_errors: true, + secret_key_base: "dev_secret_key_base_that_is_at_least_64_bytes_long_for_phoenix_to_accept_it_ok", + watchers: [ + esbuild: {Esbuild, :install_and_run, [:cortex_status, ~w(--sourcemap=inline --watch)]}, + tailwind: {Tailwind, :install_and_run, [:cortex_status, ~w(--watch)]} + ] + +config :cortex_status, CortexStatusWeb.Endpoint, + live_reload: [ + patterns: [ + ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$", + ~r"lib/cortex_status_web/(controllers|live|components)/.*(ex|heex)$" + ] + ] + +config :phoenix, :plug_init_mode, :runtime diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 0000000..496a0a7 --- /dev/null +++ b/config/prod.exs @@ -0,0 +1,6 @@ +import Config + +config :cortex_status, CortexStatusWeb.Endpoint, + server: true + +config :logger, level: :info diff --git a/config/runtime.exs b/config/runtime.exs new file mode 100644 index 0000000..4e63924 --- /dev/null +++ b/config/runtime.exs @@ -0,0 +1,33 @@ +import Config + +if System.get_env("PHX_SERVER") do + config :cortex_status, CortexStatusWeb.Endpoint, server: true +end + +if config_env() == :prod do + secret_key_base = + System.get_env("SECRET_KEY_BASE") || + raise """ + environment variable SECRET_KEY_BASE is missing. + You can generate one by calling: mix phx.gen.secret + """ + + host = System.get_env("PHX_HOST") || "status.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 + + # 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 diff --git a/deploy/cortex-status.service b/deploy/cortex-status.service new file mode 100644 index 0000000..d8449ef --- /dev/null +++ b/deploy/cortex-status.service @@ -0,0 +1,22 @@ +[Unit] +Description=Cortex Status Dashboard (Phoenix) +After=network.target symbiont-api.service + +[Service] +Type=exec +User=root +Group=root +WorkingDirectory=/data/cortex_status +Environment=MIX_ENV=prod +Environment=PHX_SERVER=true +Environment=PHX_HOST=status.hydrascale.net +Environment=PORT=4000 +EnvironmentFile=/data/cortex_status/.env +ExecStart=/data/cortex_status/_build/prod/rel/cortex_status/bin/cortex_status start +ExecStop=/data/cortex_status/_build/prod/rel/cortex_status/bin/cortex_status stop +Restart=always +RestartSec=5 +SyslogIdentifier=cortex-status + +[Install] +WantedBy=multi-user.target diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100644 index 0000000..35fb418 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# Deploy script for Cortex Status Dashboard +# Run on cortex.hydrascale.net +set -euo pipefail + +APP_DIR="/data/cortex_status" +cd "$APP_DIR" + +echo "==> Fetching deps..." +mix deps.get --only prod + +echo "==> Compiling..." +MIX_ENV=prod mix compile + +echo "==> Building assets..." +MIX_ENV=prod mix assets.deploy + +echo "==> Building release..." +MIX_ENV=prod mix release --overwrite + +echo "==> Restarting service..." +systemctl restart cortex-status + +echo "==> Checking status..." +sleep 3 +systemctl status cortex-status --no-pager + +echo "" +echo "✓ Deploy complete! Visit https://status.hydrascale.net" diff --git a/deploy/setup.sh b/deploy/setup.sh new file mode 100644 index 0000000..48da33b --- /dev/null +++ b/deploy/setup.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# First-time setup for Cortex Status Dashboard on cortex.hydrascale.net +set -euo pipefail + +echo "==> Installing Erlang & Elixir..." +# Add Erlang Solutions repo for latest OTP +apt-get update -qq +apt-get install -y -qq software-properties-common apt-transport-https +wget -q https://packages.erlang-solutions.com/erlang-solutions_2.0_all.deb +dpkg -i erlang-solutions_2.0_all.deb +rm erlang-solutions_2.0_all.deb +apt-get update -qq +apt-get install -y -qq esl-erlang elixir + +echo "==> Installing hex and rebar..." +mix local.hex --force +mix local.rebar --force + +echo "==> Setting up project directory..." +APP_DIR="/data/cortex_status" +mkdir -p "$APP_DIR" + +echo "==> Generating secret key..." +SECRET=$(mix phx.gen.secret) +cat > "$APP_DIR/.env" << EOF +SECRET_KEY_BASE=$SECRET +PHX_HOST=status.hydrascale.net +PORT=4000 +EOF +chmod 600 "$APP_DIR/.env" + +echo "==> Installing systemd service..." +cp "$APP_DIR/deploy/cortex-status.service" /etc/systemd/system/ +systemctl daemon-reload +systemctl enable cortex-status + +echo "==> Adding Caddy config..." +echo "" +echo "Add this block to /etc/caddy/Caddyfile:" +echo "" +cat << 'CADDY' +status.hydrascale.net { + reverse_proxy localhost:4000 + encode gzip +} +CADDY +echo "" +echo "Then run: caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy" + +echo "" +echo "==> Setup complete!" +echo "Next steps:" +echo " 1. Copy project files to $APP_DIR" +echo " 2. Update Caddyfile (see above)" +echo " 3. Run: cd $APP_DIR && bash deploy/deploy.sh" diff --git a/lib/cortex_status/application.ex b/lib/cortex_status/application.ex new file mode 100644 index 0000000..1ea8e4c --- /dev/null +++ b/lib/cortex_status/application.ex @@ -0,0 +1,23 @@ +defmodule CortexStatus.Application do + @moduledoc false + use Application + + @impl true + def start(_type, _args) do + children = [ + CortexStatusWeb.Telemetry, + {Phoenix.PubSub, name: CortexStatus.PubSub}, + CortexStatus.Services.Monitor, + CortexStatusWeb.Endpoint + ] + + opts = [strategy: :one_for_one, name: CortexStatus.Supervisor] + Supervisor.start_link(children, opts) + end + + @impl true + def config_change(changed, _new, removed) do + CortexStatusWeb.Endpoint.config_change(changed, removed) + :ok + end +end diff --git a/lib/cortex_status/services/monitor.ex b/lib/cortex_status/services/monitor.ex new file mode 100644 index 0000000..43eacf3 --- /dev/null +++ b/lib/cortex_status/services/monitor.ex @@ -0,0 +1,250 @@ +defmodule CortexStatus.Services.Monitor do + @moduledoc """ + GenServer that periodically polls all Cortex services and caches their status. + Broadcasts updates via PubSub so LiveView pages update in real time. + """ + use GenServer + + require Logger + + @poll_interval :timer.seconds(15) + @http_timeout :timer.seconds(5) + + # -- Public API -- + + def start_link(_opts) do + GenServer.start_link(__MODULE__, %{}, name: __MODULE__) + end + + @doc "Get the latest cached status for all services." + def get_status do + GenServer.call(__MODULE__, :get_status) + end + + @doc "Force an immediate refresh." + def refresh do + GenServer.cast(__MODULE__, :refresh) + end + + # -- GenServer callbacks -- + + @impl true + def init(_state) do + state = %{ + server: %{status: :unknown, data: %{}, checked_at: nil}, + symbiont: %{status: :unknown, data: %{}, checked_at: nil}, + dendrite: %{status: :unknown, data: %{}, checked_at: nil}, + websites: %{status: :unknown, data: %{}, checked_at: nil} + } + + # Do first poll after a short delay to let the app boot + Process.send_after(self(), :poll, 1_000) + {:ok, state} + end + + @impl true + def handle_call(:get_status, _from, state) do + {:reply, state, state} + end + + @impl true + def handle_cast(:refresh, state) do + new_state = poll_all(state) + broadcast(new_state) + {:noreply, new_state} + end + + @impl true + def handle_info(:poll, state) do + new_state = poll_all(state) + broadcast(new_state) + Process.send_after(self(), :poll, @poll_interval) + {:noreply, new_state} + end + + # -- Polling logic -- + + defp poll_all(state) do + now = DateTime.utc_now() + + tasks = [ + Task.async(fn -> {:server, check_server()} end), + Task.async(fn -> {:symbiont, check_symbiont()} end), + Task.async(fn -> {:dendrite, check_dendrite()} end), + Task.async(fn -> {:websites, check_websites()} end) + ] + + results = + Task.yield_many(tasks, @http_timeout + 2_000) + |> Enum.map(fn {task, result} -> + case result do + {:ok, value} -> value + nil -> + Task.shutdown(task, :brutal_kill) + {:unknown, %{error: "timeout"}} + end + end) + + Enum.reduce(results, state, fn + {service, {status, data}}, acc -> + Map.put(acc, service, %{status: status, data: data, checked_at: now}) + _, acc -> + acc + end) + end + + defp check_server do + try do + # Use os_mon for local BEAM host metrics + cpu_load = :cpu_sup.avg1() / 256 # normalized to 0-1 + {mem_total, mem_alloc, _} = :memsup.get_memory_data() + disk_data = :disksup.get_disk_data() + + # Also grab system uptime + {uptime_str, 0} = System.cmd("cat", ["/proc/uptime"]) + uptime_seconds = uptime_str |> String.split() |> hd() |> String.to_float() |> trunc() + + {hostname, 0} = System.cmd("hostname", []) + + data = %{ + hostname: String.trim(hostname), + cpu_load_1min: Float.round(cpu_load * 100, 1), + memory_total_mb: div(mem_total, 1_048_576), + memory_used_mb: div(mem_alloc, 1_048_576), + memory_percent: Float.round(mem_alloc / max(mem_total, 1) * 100, 1), + disk: format_disk_data(disk_data), + uptime_seconds: uptime_seconds, + uptime_human: format_uptime(uptime_seconds) + } + + {:ok, data} + rescue + e -> + Logger.warning("Server health check failed: #{inspect(e)}") + {:error, %{error: Exception.message(e)}} + end + end + + defp check_symbiont do + config = Application.get_env(:cortex_status, :services, []) + url = Keyword.get(config, :symbiont_url, "http://127.0.0.1:8111") + + case Req.get("#{url}/status", receive_timeout: @http_timeout) do + {:ok, %{status: 200, body: body}} when is_map(body) -> + {:ok, body} + + {:ok, %{status: 200, body: body}} when is_binary(body) -> + case Jason.decode(body) do + {:ok, parsed} -> {:ok, parsed} + _ -> {:ok, %{"raw" => body}} + end + + {:ok, %{status: code}} -> + {:degraded, %{"http_status" => code}} + + {:error, reason} -> + {:error, %{"error" => inspect(reason)}} + end + rescue + e -> + {:error, %{"error" => Exception.message(e)}} + end + + defp check_dendrite do + config = Application.get_env(:cortex_status, :services, []) + url = Keyword.get(config, :dendrite_url, "http://localhost:3000") + + case Req.get("#{url}/health", receive_timeout: @http_timeout) do + {:ok, %{status: 200, body: body}} -> + {:ok, %{"status" => "healthy", "response" => body}} + + {:ok, %{status: code}} -> + {:degraded, %{"http_status" => code}} + + {:error, reason} -> + {:error, %{"error" => inspect(reason)}} + end + rescue + e -> + {:error, %{"error" => Exception.message(e)}} + end + + defp check_websites do + config = Application.get_env(:cortex_status, :services, []) + sites = Keyword.get(config, :monitored_sites, []) + + results = + sites + |> Enum.map(fn {name, url} -> + Task.async(fn -> + start = System.monotonic_time(:millisecond) + + result = + case Req.get(url, receive_timeout: @http_timeout, redirect: true) do + {:ok, %{status: code}} when code in 200..399 -> + elapsed = System.monotonic_time(:millisecond) - start + %{name: name, url: url, status: :ok, http_code: code, latency_ms: elapsed} + + {:ok, %{status: code}} -> + elapsed = System.monotonic_time(:millisecond) - start + %{name: name, url: url, status: :degraded, http_code: code, latency_ms: elapsed} + + {:error, reason} -> + %{name: name, url: url, status: :error, error: inspect(reason), latency_ms: nil} + end + + result + end) + end) + |> Task.yield_many(@http_timeout + 1_000) + |> Enum.map(fn {task, result} -> + case result do + {:ok, value} -> value + nil -> + Task.shutdown(task, :brutal_kill) + %{status: :error, error: "timeout"} + end + end) + + overall = + cond do + Enum.all?(results, &(&1.status == :ok)) -> :ok + Enum.any?(results, &(&1.status == :error)) -> :error + true -> :degraded + end + + {overall, %{"sites" => results}} + rescue + e -> + {:error, %{"error" => Exception.message(e)}} + end + + # -- Helpers -- + + defp format_disk_data(disk_data) do + disk_data + |> Enum.map(fn {mount, total_kb, percent_used} -> + %{ + mount: to_string(mount), + total_gb: Float.round(total_kb / 1_048_576, 1), + percent_used: percent_used + } + end) + end + + defp format_uptime(seconds) do + days = div(seconds, 86400) + hours = div(rem(seconds, 86400), 3600) + mins = div(rem(seconds, 3600), 60) + + cond do + days > 0 -> "#{days}d #{hours}h #{mins}m" + hours > 0 -> "#{hours}h #{mins}m" + true -> "#{mins}m" + end + end + + defp broadcast(state) do + Phoenix.PubSub.broadcast(CortexStatus.PubSub, "service_status", {:status_update, state}) + end +end diff --git a/lib/cortex_status_web.ex b/lib/cortex_status_web.ex new file mode 100644 index 0000000..a437b20 --- /dev/null +++ b/lib/cortex_status_web.ex @@ -0,0 +1,85 @@ +defmodule CortexStatusWeb do + @moduledoc """ + The entrypoint for defining your web interface. + """ + + def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) + + def router do + quote do + use Phoenix.Router, helpers: false + + import Plug.Conn + import Phoenix.Controller + import Phoenix.LiveView.Router + end + end + + def channel do + quote do + use Phoenix.Channel + end + end + + def controller do + quote do + use Phoenix.Controller, + formats: [:html, :json], + layouts: [html: CortexStatusWeb.Layouts] + + import Plug.Conn + + unquote(verified_routes()) + end + end + + def live_view do + quote do + use Phoenix.LiveView, + layout: {CortexStatusWeb.Layouts, :app} + + unquote(html_helpers()) + end + end + + def live_component do + quote do + use Phoenix.LiveComponent + + unquote(html_helpers()) + end + end + + def html do + quote do + use Phoenix.Component + + import Phoenix.Controller, + only: [get_csrf_token: 0, view_module: 1, view_template: 1] + + unquote(html_helpers()) + end + end + + defp html_helpers do + quote do + import Phoenix.HTML + import CortexStatusWeb.CoreComponents + + unquote(verified_routes()) + end + end + + def verified_routes do + quote do + use Phoenix.VerifiedRoutes, + endpoint: CortexStatusWeb.Endpoint, + router: CortexStatusWeb.Router, + statics: CortexStatusWeb.static_paths() + end + end + + defmacro __using__(which) when is_atom(which) do + apply(__MODULE__, which, []) + end +end diff --git a/lib/cortex_status_web/components/core_components.ex b/lib/cortex_status_web/components/core_components.ex new file mode 100644 index 0000000..ee21cdc --- /dev/null +++ b/lib/cortex_status_web/components/core_components.ex @@ -0,0 +1,19 @@ +defmodule CortexStatusWeb.CoreComponents do + @moduledoc """ + Minimal core components for CortexStatus. + """ + use Phoenix.Component + + attr :flash, :map, required: true + + def flash_group(assigns) do + ~H""" +
+ <%= msg %> +
+
+ <%= msg %> +
+ """ + end +end diff --git a/lib/cortex_status_web/components/layouts.ex b/lib/cortex_status_web/components/layouts.ex new file mode 100644 index 0000000..92cbb36 --- /dev/null +++ b/lib/cortex_status_web/components/layouts.ex @@ -0,0 +1,8 @@ +defmodule CortexStatusWeb.Layouts do + @moduledoc """ + Layouts for CortexStatus. + """ + use CortexStatusWeb, :html + + embed_templates "layouts/*" +end diff --git a/lib/cortex_status_web/components/layouts/app.html.heex b/lib/cortex_status_web/components/layouts/app.html.heex new file mode 100644 index 0000000..a9efe86 --- /dev/null +++ b/lib/cortex_status_web/components/layouts/app.html.heex @@ -0,0 +1,4 @@ +
+ <.flash_group flash={@flash} /> + <%= @inner_content %> +
diff --git a/lib/cortex_status_web/components/layouts/root.html.heex b/lib/cortex_status_web/components/layouts/root.html.heex new file mode 100644 index 0000000..34e4a60 --- /dev/null +++ b/lib/cortex_status_web/components/layouts/root.html.heex @@ -0,0 +1,201 @@ + + + + + + + <%= assigns[:page_title] || "Cortex Status" %> + + + + + <%= @inner_content %> + + diff --git a/lib/cortex_status_web/controllers/error_html.ex b/lib/cortex_status_web/controllers/error_html.ex new file mode 100644 index 0000000..8b416d0 --- /dev/null +++ b/lib/cortex_status_web/controllers/error_html.ex @@ -0,0 +1,7 @@ +defmodule CortexStatusWeb.ErrorHTML do + use CortexStatusWeb, :html + + def render(template, _assigns) do + Phoenix.Controller.status_message_from_template(template) + end +end diff --git a/lib/cortex_status_web/controllers/error_json.ex b/lib/cortex_status_web/controllers/error_json.ex new file mode 100644 index 0000000..68502de --- /dev/null +++ b/lib/cortex_status_web/controllers/error_json.ex @@ -0,0 +1,5 @@ +defmodule CortexStatusWeb.ErrorJSON do + def render(template, _assigns) do + %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} + end +end diff --git a/lib/cortex_status_web/controllers/status_controller.ex b/lib/cortex_status_web/controllers/status_controller.ex new file mode 100644 index 0000000..c5bee8f --- /dev/null +++ b/lib/cortex_status_web/controllers/status_controller.ex @@ -0,0 +1,37 @@ +defmodule CortexStatusWeb.StatusController do + use CortexStatusWeb, :controller + + def status(conn, _params) do + status = CortexStatus.Services.Monitor.get_status() + + json(conn, %{ + overall: overall_status(status), + services: %{ + server: format_service(status.server), + symbiont: format_service(status.symbiont), + dendrite: format_service(status.dendrite), + websites: format_service(status.websites) + }, + checked_at: DateTime.utc_now() |> DateTime.to_iso8601() + }) + end + + defp overall_status(status) do + statuses = [status.server.status, status.symbiont.status, status.dendrite.status, status.websites.status] + + cond do + Enum.all?(statuses, &(&1 == :ok)) -> "operational" + Enum.any?(statuses, &(&1 == :error)) -> "disruption" + Enum.any?(statuses, &(&1 == :degraded)) -> "degraded" + true -> "unknown" + end + end + + defp format_service(%{status: status, data: data, checked_at: checked_at}) do + %{ + status: status, + data: data, + checked_at: if(checked_at, do: DateTime.to_iso8601(checked_at), else: nil) + } + end +end diff --git a/lib/cortex_status_web/endpoint.ex b/lib/cortex_status_web/endpoint.ex new file mode 100644 index 0000000..bcdc626 --- /dev/null +++ b/lib/cortex_status_web/endpoint.ex @@ -0,0 +1,39 @@ +defmodule CortexStatusWeb.Endpoint do + use Phoenix.Endpoint, otp_app: :cortex_status + + @session_options [ + store: :cookie, + key: "_cortex_status_key", + signing_salt: "cortex_ss", + same_site: "Lax" + ] + + socket "/live", Phoenix.LiveView.Socket, + websocket: [connect_info: [session: @session_options]], + longpoll: [connect_info: [session: @session_options]] + + plug Plug.Static, + at: "/", + from: :cortex_status, + gzip: false, + only: CortexStatusWeb.static_paths() + + if code_reloading? do + socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket + plug Phoenix.LiveReloader + plug Phoenix.CodeReloader + end + + plug Plug.RequestId + plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] + + plug Plug.Parsers, + parsers: [:urlencoded, :multipart, :json], + pass: ["*/*"], + json_decoder: Phoenix.json_library() + + plug Plug.MethodOverride + plug Plug.Head + plug Plug.Session, @session_options + plug CortexStatusWeb.Router +end diff --git a/lib/cortex_status_web/live/status_live.ex b/lib/cortex_status_web/live/status_live.ex new file mode 100644 index 0000000..621c422 --- /dev/null +++ b/lib/cortex_status_web/live/status_live.ex @@ -0,0 +1,204 @@ +defmodule CortexStatusWeb.StatusLive do + @moduledoc """ + Public status page — a clean overview of all Cortex services + with real-time updates via PubSub. + """ + use CortexStatusWeb, :live_view + + @impl true + def mount(_params, _session, socket) do + if connected?(socket) do + Phoenix.PubSub.subscribe(CortexStatus.PubSub, "service_status") + end + + status = CortexStatus.Services.Monitor.get_status() + + {:ok, assign(socket, status: status, page_title: "Cortex Status")} + end + + @impl true + def handle_info({:status_update, new_status}, socket) do + {:noreply, assign(socket, status: new_status)} + end + + @impl true + def render(assigns) do + ~H""" +
+
+

Cortex Status

+

+ <%= overall_label(@status) %> +

+

cortex.hydrascale.net

+
+ +
+ <.service_card + name="Server" + status={@status.server.status} + checked_at={@status.server.checked_at} + details={server_details(@status.server)} + /> + <.service_card + name="Symbiont API" + status={@status.symbiont.status} + checked_at={@status.symbiont.checked_at} + details={symbiont_details(@status.symbiont)} + /> + <.service_card + name="Dendrite Browser" + status={@status.dendrite.status} + checked_at={@status.dendrite.checked_at} + details={dendrite_details(@status.dendrite)} + /> + <.service_card + name="Websites" + status={@status.websites.status} + checked_at={@status.websites.checked_at} + details={websites_details(@status.websites)} + /> +
+ + +
+ """ + end + + attr :name, :string, required: true + attr :status, :atom, required: true + attr :checked_at, :any, default: nil + attr :details, :list, default: [] + + defp service_card(assigns) do + ~H""" +
+
+ +

<%= @name %>

+ <%= status_label(@status) %> +
+
+ <%= for {label, value} <- @details do %> +
+ <%= label %> + <%= value %> +
+ <% end %> +
+ +
+ """ + end + + # -- Helpers -- + + defp overall_status(status) do + statuses = [status.server.status, status.symbiont.status, status.dendrite.status, status.websites.status] + + cond do + Enum.all?(statuses, &(&1 == :ok)) -> "ok" + Enum.any?(statuses, &(&1 == :error)) -> "error" + Enum.any?(statuses, &(&1 == :degraded)) -> "degraded" + true -> "unknown" + end + end + + defp overall_label(status) do + case overall_status(status) do + "ok" -> "All Systems Operational" + "degraded" -> "Partial Degradation" + "error" -> "Service Disruption" + _ -> "Checking..." + end + end + + defp status_class(:ok), do: "status-ok" + defp status_class(:degraded), do: "status-degraded" + defp status_class(:error), do: "status-error" + defp status_class(_), do: "status-unknown" + + defp status_label(:ok), do: "Operational" + defp status_label(:degraded), do: "Degraded" + defp status_label(:error), do: "Down" + defp status_label(_), do: "Checking" + + defp server_details(%{status: :ok, data: data}) do + [ + {"CPU", "#{data[:cpu_load_1min]}%"}, + {"Memory", "#{data[:memory_used_mb]}MB / #{data[:memory_total_mb]}MB (#{data[:memory_percent]}%)"}, + {"Uptime", data[:uptime_human] || "—"} + ] + end + + defp server_details(%{status: :error, data: data}) do + [{"Error", data[:error] || "Check failed"}] + end + + defp server_details(_), do: [] + + defp symbiont_details(%{status: :ok, data: data}) do + [ + {"Queue", "#{data["queue_size"] || 0} tasks"}, + {"Rate Limited", if(data["rate_limited"], do: "Yes", else: "No")}, + {"Last Heartbeat", data["last_heartbeat"] || "—"} + ] + end + + defp symbiont_details(%{status: :error, data: data}) do + [{"Error", data["error"] || "Unreachable"}] + end + + defp symbiont_details(_), do: [] + + defp dendrite_details(%{status: :ok, data: _data}) do + [{"Endpoint", "browser.hydrascale.net"}] + end + + defp dendrite_details(%{status: :error, data: data}) do + [{"Error", data["error"] || "Unreachable"}] + end + + defp dendrite_details(_), do: [] + + defp websites_details(%{data: %{"sites" => sites}}) when is_list(sites) do + Enum.map(sites, fn site -> + status_str = + case site[:status] do + :ok -> "✓ #{site[:latency_ms]}ms" + :degraded -> "⚠ HTTP #{site[:http_code]}" + :error -> "✗ #{site[:error]}" + _ -> "?" + end + + {site[:name] || "?", status_str} + end) + end + + defp websites_details(_), do: [] + + defp format_ago(nil), do: "—" + + defp format_ago(%DateTime{} = dt) do + diff = DateTime.diff(DateTime.utc_now(), dt, :second) + + cond do + diff < 5 -> "just now" + diff < 60 -> "#{diff}s ago" + diff < 3600 -> "#{div(diff, 60)}m ago" + true -> "#{div(diff, 3600)}h ago" + end + end + + defp format_ago(_), do: "—" +end diff --git a/lib/cortex_status_web/router.ex b/lib/cortex_status_web/router.ex new file mode 100644 index 0000000..442b03b --- /dev/null +++ b/lib/cortex_status_web/router.ex @@ -0,0 +1,39 @@ +defmodule CortexStatusWeb.Router do + use CortexStatusWeb, :router + + import Phoenix.LiveDashboard.Router + + pipeline :browser do + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_live_flash + plug :put_root_layout, html: {CortexStatusWeb.Layouts, :root} + plug :protect_from_forgery + plug :put_secure_browser_headers + end + + pipeline :api do + plug :accepts, ["json"] + end + + scope "/", CortexStatusWeb do + pipe_through :browser + + live "/", StatusLive, :index + end + + # Standard Phoenix LiveDashboard for BEAM VM metrics + scope "/" do + pipe_through :browser + + live_dashboard "/dashboard", + metrics: CortexStatusWeb.Telemetry + end + + # JSON API for external consumption + scope "/api", CortexStatusWeb do + pipe_through :api + + get "/status", StatusController, :status + end +end diff --git a/lib/cortex_status_web/telemetry.ex b/lib/cortex_status_web/telemetry.ex new file mode 100644 index 0000000..20574d0 --- /dev/null +++ b/lib/cortex_status_web/telemetry.ex @@ -0,0 +1,43 @@ +defmodule CortexStatusWeb.Telemetry do + use Supervisor + import Telemetry.Metrics + + def start_link(arg) do + Supervisor.start_link(__MODULE__, arg, name: __MODULE__) + end + + @impl true + def init(_arg) do + children = [ + {:telemetry_poller, measurements: periodic_measurements(), period: 10_000} + ] + + Supervisor.init(children, strategy: :one_for_one) + end + + def metrics do + [ + # Phoenix Metrics + summary("phoenix.endpoint.start.system_time", + unit: {:native, :millisecond} + ), + summary("phoenix.endpoint.stop.duration", + unit: {:native, :millisecond} + ), + summary("phoenix.router_dispatch.stop.duration", + unit: {:native, :millisecond}, + tags: [:route] + ), + + # VM Metrics + summary("vm.memory.total", unit: {:byte, :megabyte}), + summary("vm.total_run_queue_lengths.total"), + summary("vm.total_run_queue_lengths.cpu"), + summary("vm.total_run_queue_lengths.io") + ] + end + + defp periodic_measurements do + [] + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..939135c --- /dev/null +++ b/mix.exs @@ -0,0 +1,55 @@ +defmodule CortexStatus.MixProject do + use Mix.Project + + def project do + [ + app: :cortex_status, + version: "0.1.0", + elixir: "~> 1.14", + elixirc_paths: elixirc_paths(Mix.env()), + start_permanent: Mix.env() == :prod, + aliases: aliases(), + deps: deps() + ] + end + + def application do + [ + mod: {CortexStatus.Application, []}, + extra_applications: [:logger, :runtime_tools, :os_mon] + ] + end + + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + defp deps do + [ + {:phoenix, "~> 1.7.14"}, + {:phoenix_html, "~> 4.1"}, + {:phoenix_live_reload, "~> 1.2", only: :dev}, + {:phoenix_live_view, "~> 1.0.0"}, + {:phoenix_live_dashboard, "~> 0.8.4"}, + {:telemetry_metrics, "~> 1.0"}, + {:telemetry_poller, "~> 1.0"}, + {:jason, "~> 1.2"}, + {:bandit, "~> 1.5"}, + {:req, "~> 0.5"}, + {:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, + {:tailwind, "~> 0.2", runtime: Mix.env() == :dev} + ] + end + + defp aliases do + [ + setup: ["deps.get", "assets.setup", "assets.build"], + "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"], + "assets.build": ["tailwind cortex_status", "esbuild cortex_status"], + "assets.deploy": [ + "tailwind cortex_status --minify", + "esbuild cortex_status --minify", + "phx.digest" + ] + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..6b485e1 --- /dev/null +++ b/mix.lock @@ -0,0 +1,28 @@ +%{ + "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"}, + "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"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "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_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"}, + "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, + "tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"}, + "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, + "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, + "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, + "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, + "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, +}