lib/mix/tasks/phoenix_micro.health.ex

defmodule Mix.Tasks.PhoenixMicro.Health do
  use Mix.Task

  @shortdoc "Checks PhoenixMicro health endpoint and prints a status report"

  @moduledoc """
  Hits the PhoenixMicro health endpoint and prints a formatted status table.

  ## Usage

      mix phoenix_micro.health

  ## Options

      --url URL       Health endpoint URL
                      (default: http://localhost:4000/health/microservices)
      --timeout MS    HTTP request timeout in ms (default: 5000)
      --format FORMAT Output format: table|json (default: table)
      --exit-code     Exit with code 1 if status is degraded (useful in CI)

  ## Examples

      # Check local dev server
      mix phoenix_micro.health

      # Check staging
      mix phoenix_micro.health --url https://staging.example.com/health/microservices

      # Use in CI — fails the pipeline if degraded
      mix phoenix_micro.health --exit-code

      # Raw JSON output
      mix phoenix_micro.health --format json

  ## Output

      ┌─────────────────────────────────────────────────┐
      │  PhoenixMicro Health Report                     │
      │  http://localhost:4000/health/microservices     │
      │  2025-01-01T12:00:00Z                           │
      └─────────────────────────────────────────────────┘

      Status      ✓ ok
      Transport   rabbitmq (connected)

      Consumers (2)
        MyApp.Payments.CreatedConsumer  payments.created   broadway  concurrency=10
        MyApp.Orders.PlacedConsumer     orders.placed      broadway  concurrency=5

      Circuit Breakers (3)
        payments_db   ✓ closed
        orders_db     ✗ OPEN    (opened 45s ago)
        inventory_db  ~ half_open

      Pipeline
        Running consumers: 2
  """

  @switches [
    url: :string,
    timeout: :integer,
    format: :string,
    exit_code: :boolean
  ]

  @default_url "http://localhost:4000/health/microservices"
  @default_timeout 5_000

  @spec run([String.t()]) :: any()
  @impl Mix.Task
  def run(argv) do
    {opts, _args, _ignored} = OptionParser.parse(argv, switches: @switches)

    url = Keyword.get(opts, :url, @default_url)
    timeout = Keyword.get(opts, :timeout, @default_timeout)
    format = Keyword.get(opts, :format, "table")
    use_exit_code = Keyword.get(opts, :exit_code, false)

    IO.puts("Checking #{url}...")

    case fetch_health(url, timeout) do
      {:ok, body} ->
        case Jason.decode(body) do
          {:ok, data} ->
            case format do
              "json" -> IO.puts(Jason.encode!(data, pretty: true))
              _fmt -> print_table(data, url)
            end

            if use_exit_code and data["status"] != "ok" do
              exit({:shutdown, 1})
            end

          {:error, _reason} ->
            IO.puts(:stderr, "Could not parse response as JSON:\n#{body}")
            if use_exit_code, do: exit({:shutdown, 1})
        end

      {:error, reason} ->
        IO.puts(:stderr, "Failed to reach health endpoint: #{inspect(reason)}")
        if use_exit_code, do: exit({:shutdown, 1})
    end
  end

  # ---------------------------------------------------------------------------
  # HTTP fetch
  # ---------------------------------------------------------------------------

  defp fetch_health(url, timeout) do
    # :httpc lives in :inets — must be started first. Capture returns to avoid
    # unmatched-return warnings (:already_started is a normal ok case).
    _inets = Application.ensure_started(:inets)
    _ssl = Application.ensure_started(:ssl)

    url_charlist = String.to_charlist(url)
    http_opts = [{:timeout, timeout}, {:connect_timeout, timeout}]
    opts = [{:body_format, :binary}]

    # Use apply/3 so :httpc is resolved at runtime (avoids undefined-function
    # warning when :inets is not in the compile-time dependency list).
    case apply(:httpc, :request, [:get, {url_charlist, []}, http_opts, opts]) do
      {:ok, {{_version, status, _reason}, _headers, body}} ->
        if status in 200..503 do
          {:ok, body}
        else
          {:error, {:unexpected_status, status}}
        end

      {:error, reason} ->
        {:error, reason}
    end
  end

  # ---------------------------------------------------------------------------
  # Table formatter
  # ---------------------------------------------------------------------------

  defp print_table(data, url) do
    timestamp = Map.get(data, "timestamp", "—")
    status = Map.get(data, "status", "unknown")
    transport = Map.get(data, "transport", %{})
    consumers = Map.get(data, "consumers", [])
    cbs = Map.get(data, "circuit_breakers", [])
    pipeline = Map.get(data, "pipeline", %{})

    # Header box
    width = 54
    line = String.duplicate("─", width)
    puts("┌#{line}┐")
    puts("│  #{pad("PhoenixMicro Health Report", width - 2)}│")
    puts("│  #{pad(url, width - 2)}│")
    puts("│  #{pad(timestamp, width - 2)}│")
    puts("└#{line}┘")
    puts("")

    # Status
    status_icon = if status == "ok", do: "✓", else: "✗"
    status_colour = if status == "ok", do: :green, else: :red
    puts("  Status      #{colour(status_icon <> " " <> status, status_colour)}")

    # Transport
    t_name = Map.get(transport, "active", "unknown")
    t_status = Map.get(transport, "status", "unknown")
    t_colour = if t_status == "connected", do: :green, else: :red
    puts("  Transport   #{t_name} (#{colour(t_status, t_colour)})")
    puts("")

    # Consumers
    puts("  Consumers (#{length(consumers)})")

    if consumers == [] do
      puts("    #{colour("none registered", :yellow)}")
    else
      Enum.each(consumers, fn c ->
        mod = Map.get(c, "module", "?")
        topic = Map.get(c, "topic", "?")
        pl = Map.get(c, "pipeline", "broadway")
        concurrency = Map.get(c, "concurrency", 1)
        puts("    #{pad(mod, 40)}  #{pad(topic, 30)}  #{pl}  concurrency=#{concurrency}")
      end)
    end

    puts("")

    # Circuit Breakers
    puts("  Circuit Breakers (#{length(cbs)})")

    if cbs == [] do
      puts("    #{colour("none registered", :yellow)}")
    else
      Enum.each(cbs, fn cb ->
        fuse = Map.get(cb, "fuse", "?")
        state = Map.get(cb, "state", "?")

        {icon, col, extra} =
          case state do
            "closed" -> {"✓", :green, ""}
            "open" -> {"✗", :red, open_age(Map.get(cb, "opened_at_ms"))}
            "half_open" -> {"~", :yellow, "(probing)"}
            _state -> {"?", :default, ""}
          end

        puts("    #{pad(fuse, 30)}  #{colour(icon <> " " <> state, col)}  #{extra}")
      end)
    end

    puts("")

    # Pipeline
    running = get_in(pipeline, ["running"]) || 0
    puts("  Pipeline")
    puts("    Running consumers: #{running}")
    puts("")
  end

  # ---------------------------------------------------------------------------
  # Helpers
  # ---------------------------------------------------------------------------

  defp puts(str), do: IO.puts(str)

  defp pad(str, len) when is_binary(str) do
    str_len = String.length(str)

    if str_len >= len do
      String.slice(str, 0, len)
    else
      str <> String.duplicate(" ", len - str_len)
    end
  end

  defp pad(other, len), do: pad(to_string(other), len)

  defp colour(str, :green), do: IO.ANSI.green() <> str <> IO.ANSI.reset()
  defp colour(str, :red), do: IO.ANSI.red() <> str <> IO.ANSI.reset()
  defp colour(str, :yellow), do: IO.ANSI.yellow() <> str <> IO.ANSI.reset()
  defp colour(str, :default), do: str

  defp open_age(nil), do: ""

  defp open_age(ms) when is_integer(ms) do
    now = System.system_time(:millisecond)
    # ms is a monotonic timestamp from the remote node — use as-is for delta
    ago = abs(now - ms)

    cond do
      ago < 60_000 -> "(opened #{div(ago, 1_000)}s ago)"
      ago < 3_600_000 -> "(opened #{div(ago, 60_000)}m ago)"
      true -> "(opened #{div(ago, 3_600_000)}h ago)"
    end
  end
end