Initial commit
This commit is contained in:
commit
51f741965f
5
.formatter.exs
Normal file
5
.formatter.exs
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
[
|
||||||
|
import_deps: [:phoenix],
|
||||||
|
plugins: [Phoenix.LiveView.HTMLFormatter],
|
||||||
|
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}"]
|
||||||
|
]
|
||||||
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@ -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/
|
||||||
1
assets/css/app.css
Normal file
1
assets/css/app.css
Normal file
@ -0,0 +1 @@
|
|||||||
|
/* Styles are inline in root.html.heex for simplicity */
|
||||||
13
assets/js/app.js
Normal file
13
assets/js/app.js
Normal file
@ -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
|
||||||
11
assets/tailwind.config.js
Normal file
11
assets/tailwind.config.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
"./js/**/*.js",
|
||||||
|
"../lib/cortex_status_web.ex",
|
||||||
|
"../lib/cortex_status_web/**/*.*ex"
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: []
|
||||||
|
}
|
||||||
50
config/config.exs
Normal file
50
config/config.exs
Normal file
@ -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"
|
||||||
22
config/dev.exs
Normal file
22
config/dev.exs
Normal file
@ -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
|
||||||
6
config/prod.exs
Normal file
6
config/prod.exs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import Config
|
||||||
|
|
||||||
|
config :cortex_status, CortexStatusWeb.Endpoint,
|
||||||
|
server: true
|
||||||
|
|
||||||
|
config :logger, level: :info
|
||||||
33
config/runtime.exs
Normal file
33
config/runtime.exs
Normal file
@ -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
|
||||||
22
deploy/cortex-status.service
Normal file
22
deploy/cortex-status.service
Normal file
@ -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
|
||||||
29
deploy/deploy.sh
Normal file
29
deploy/deploy.sh
Normal file
@ -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"
|
||||||
55
deploy/setup.sh
Normal file
55
deploy/setup.sh
Normal file
@ -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"
|
||||||
23
lib/cortex_status/application.ex
Normal file
23
lib/cortex_status/application.ex
Normal file
@ -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
|
||||||
250
lib/cortex_status/services/monitor.ex
Normal file
250
lib/cortex_status/services/monitor.ex
Normal file
@ -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
|
||||||
85
lib/cortex_status_web.ex
Normal file
85
lib/cortex_status_web.ex
Normal file
@ -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
|
||||||
19
lib/cortex_status_web/components/core_components.ex
Normal file
19
lib/cortex_status_web/components/core_components.ex
Normal file
@ -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"""
|
||||||
|
<div :if={msg = Phoenix.Flash.get(@flash, :info)} class="flash flash-info">
|
||||||
|
<%= msg %>
|
||||||
|
</div>
|
||||||
|
<div :if={msg = Phoenix.Flash.get(@flash, :error)} class="flash flash-error">
|
||||||
|
<%= msg %>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
||||||
8
lib/cortex_status_web/components/layouts.ex
Normal file
8
lib/cortex_status_web/components/layouts.ex
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
defmodule CortexStatusWeb.Layouts do
|
||||||
|
@moduledoc """
|
||||||
|
Layouts for CortexStatus.
|
||||||
|
"""
|
||||||
|
use CortexStatusWeb, :html
|
||||||
|
|
||||||
|
embed_templates "layouts/*"
|
||||||
|
end
|
||||||
4
lib/cortex_status_web/components/layouts/app.html.heex
Normal file
4
lib/cortex_status_web/components/layouts/app.html.heex
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<main>
|
||||||
|
<.flash_group flash={@flash} />
|
||||||
|
<%= @inner_content %>
|
||||||
|
</main>
|
||||||
201
lib/cortex_status_web/components/layouts/root.html.heex
Normal file
201
lib/cortex_status_web/components/layouts/root.html.heex
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="csrf-token" content={get_csrf_token()} />
|
||||||
|
<title><%= assigns[:page_title] || "Cortex Status" %></title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f1117;
|
||||||
|
--surface: #1a1d27;
|
||||||
|
--surface-hover: #222633;
|
||||||
|
--border: #2a2e3b;
|
||||||
|
--text: #e4e7f0;
|
||||||
|
--text-muted: #8890a4;
|
||||||
|
--green: #34d399;
|
||||||
|
--green-bg: rgba(52, 211, 153, 0.1);
|
||||||
|
--yellow: #fbbf24;
|
||||||
|
--yellow-bg: rgba(251, 191, 36, 0.1);
|
||||||
|
--red: #f87171;
|
||||||
|
--red-bg: rgba(248, 113, 113, 0.1);
|
||||||
|
--blue: #60a5fa;
|
||||||
|
--blue-bg: rgba(96, 165, 250, 0.1);
|
||||||
|
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
|
||||||
|
--mono: 'SF Mono', 'Fira Code', 'Fira Mono', Menlo, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.6;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--blue); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* Status page styles */
|
||||||
|
.status-page {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 3rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-header h1 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overall-status {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.4rem 1.2rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overall-status[data-status="ok"] {
|
||||||
|
color: var(--green);
|
||||||
|
background: var(--green-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overall-status[data-status="degraded"] {
|
||||||
|
color: var(--yellow);
|
||||||
|
background: var(--yellow-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overall-status[data-status="error"] {
|
||||||
|
color: var(--red);
|
||||||
|
background: var(--red-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overall-status[data-status="unknown"] {
|
||||||
|
color: var(--blue);
|
||||||
|
background: var(--blue-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.services-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-card:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-card.status-ok { border-left: 3px solid var(--green); }
|
||||||
|
.service-card.status-degraded { border-left: 3px solid var(--yellow); }
|
||||||
|
.service-card.status-error { border-left: 3px solid var(--red); }
|
||||||
|
.service-card.status-unknown { border-left: 3px solid var(--text-muted); }
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-ok .status-indicator { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||||||
|
.status-degraded .status-indicator { background: var(--yellow); box-shadow: 0 0 6px var(--yellow); }
|
||||||
|
.status-error .status-indicator { background: var(--red); box-shadow: 0 0 6px var(--red); animation: pulse 1.5s infinite; }
|
||||||
|
.status-unknown .status-indicator { background: var(--text-muted); }
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-ok .status-label { color: var(--green); }
|
||||||
|
.status-degraded .status-label { color: var(--yellow); }
|
||||||
|
.status-error .status-label { color: var(--red); }
|
||||||
|
.status-unknown .status-label { color: var(--text-muted); }
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 0 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.4rem 0;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label { color: var(--text-muted); }
|
||||||
|
.detail-value { font-family: var(--mono); font-size: 0.8rem; }
|
||||||
|
|
||||||
|
.card-footer {
|
||||||
|
padding: 0.6rem 1.25rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checked-at {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 3rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-footer a {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<script defer phx-track-static type="text/javascript" src={"/assets/app.js"}></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<%= @inner_content %>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
7
lib/cortex_status_web/controllers/error_html.ex
Normal file
7
lib/cortex_status_web/controllers/error_html.ex
Normal file
@ -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
|
||||||
5
lib/cortex_status_web/controllers/error_json.ex
Normal file
5
lib/cortex_status_web/controllers/error_json.ex
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
defmodule CortexStatusWeb.ErrorJSON do
|
||||||
|
def render(template, _assigns) do
|
||||||
|
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
|
||||||
|
end
|
||||||
|
end
|
||||||
37
lib/cortex_status_web/controllers/status_controller.ex
Normal file
37
lib/cortex_status_web/controllers/status_controller.ex
Normal file
@ -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
|
||||||
39
lib/cortex_status_web/endpoint.ex
Normal file
39
lib/cortex_status_web/endpoint.ex
Normal file
@ -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
|
||||||
204
lib/cortex_status_web/live/status_live.ex
Normal file
204
lib/cortex_status_web/live/status_live.ex
Normal file
@ -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"""
|
||||||
|
<div class="status-page">
|
||||||
|
<header class="status-header">
|
||||||
|
<h1>Cortex Status</h1>
|
||||||
|
<p class="overall-status" data-status={overall_status(@status)}>
|
||||||
|
<%= overall_label(@status) %>
|
||||||
|
</p>
|
||||||
|
<p class="subtitle">cortex.hydrascale.net</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="services-grid">
|
||||||
|
<.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)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="status-footer">
|
||||||
|
<p>Checks run every 15 seconds •
|
||||||
|
<a href="/dashboard">LiveDashboard →</a>
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
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"""
|
||||||
|
<div class={"service-card #{status_class(@status)}"}>
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="status-indicator"></span>
|
||||||
|
<h2><%= @name %></h2>
|
||||||
|
<span class="status-label"><%= status_label(@status) %></span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<%= for {label, value} <- @details do %>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label"><%= label %></span>
|
||||||
|
<span class="detail-value"><%= value %></span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<%= if @checked_at do %>
|
||||||
|
<span class="checked-at">Checked <%= format_ago(@checked_at) %></span>
|
||||||
|
<% else %>
|
||||||
|
<span class="checked-at">Pending...</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
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
|
||||||
39
lib/cortex_status_web/router.ex
Normal file
39
lib/cortex_status_web/router.ex
Normal file
@ -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
|
||||||
43
lib/cortex_status_web/telemetry.ex
Normal file
43
lib/cortex_status_web/telemetry.ex
Normal file
@ -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
|
||||||
55
mix.exs
Normal file
55
mix.exs
Normal file
@ -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
|
||||||
28
mix.lock
Normal file
28
mix.lock
Normal file
@ -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"},
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user