Skip to main content

lib/bloccs/web/panels/metrics.ex

defmodule Bloccs.Web.Panels.Metrics do
  @moduledoc """
  Panel 3 — live per-node metrics. One row per node from the latest collector
  `frame`: a throughput sparkline + rate, a visual p50/p95 latency bar, completed
  count, and error rate, with a state pill. A totals row sums the network. The
  frame arrives on a 1 Hz PubSub tick handled by `Bloccs.Web.DashboardLive`; idle
  nodes show "—" until traffic flows.
  """

  use Bloccs.Web, :html

  import Bloccs.Web.Components.Chart

  alias Bloccs.Web.Format

  attr :network, :any, required: true
  attr :base_path, :string, required: true
  attr :frame, :map, default: %{nodes: %{}, updated_at: nil}

  def render(assigns) do
    rows = rows(assigns.network, assigns.frame)

    assigns =
      assigns
      |> assign(:rows, rows)
      |> assign(:max_p95, max_of(rows, :p95))
      |> assign(:totals, totals(rows))

    ~H"""
    <section class="bloccs-metrics">
      <header class="bloccs-panel__header">
        <h1>Live metrics</h1>
        <span class="bloccs-muted">{header_note(@frame)}</span>
      </header>

      <table class="bloccs-table bloccs-metrics-table">
        <thead>
          <tr>
            <th>Node</th>
            <th>State</th>
            <th class="bloccs-num">Throughput</th>
            <th>Latency (p50 · p95)</th>
            <th class="bloccs-num">Completed</th>
            <th class="bloccs-num">Errors</th>
          </tr>
        </thead>
        <tbody>
          <tr :for={{node, m} <- @rows} class="bloccs-row">
            <td>
              <span class="bloccs-node-id">
                <svg viewBox="-60 -62 120 120" width="20" height="20" class="bloccs-node-id__glyph">
                  <.hex_glyph glyph={node.glyph} state={state(m)} />
                </svg>
                {node.id}
              </span>
            </td>
            <td><.status_pill state={state(m)} /></td>
            <td class="bloccs-num">
              <span class="bloccs-tp">
                <.sparkline values={series(m)} />
                <span class="bloccs-tp__rate">{Format.rate(m && m.throughput)}</span>
              </span>
            </td>
            <td>
              <%= if m && m.p95 do %>
                <div
                  class="bloccs-lat"
                  title={"p50 #{Format.latency(m.p50)} · p95 #{Format.latency(m.p95)}"}
                >
                  <span class="bloccs-lat__track">
                    <span class="bloccs-lat__fill" style={"width:#{pct(m.p95, @max_p95)}%"}></span>
                    <span class="bloccs-lat__p50" style={"left:#{pct(m.p50, @max_p95)}%"}></span>
                  </span>
                  <span class="bloccs-lat__txt">
                    {Format.latency(m.p50)} · {Format.latency(m.p95)}
                  </span>
                </div>
              <% else %>
                <span class="bloccs-muted"></span>
              <% end %>
            </td>
            <td class="bloccs-num">{Format.count(m && m.completed)}</td>
            <td class={["bloccs-num", error_class(m)]}>{errors(m)}</td>
          </tr>
        </tbody>
        <tfoot>
          <tr class="bloccs-metrics-total">
            <td>Total</td>
            <td></td>
            <td class="bloccs-num">{Format.rate(@totals.throughput)}</td>
            <td></td>
            <td class="bloccs-num">{Format.count(@totals.completed)}</td>
            <td class={["bloccs-num", @totals.errors > 0 && "bloccs-num--error"]}>
              {@totals.errors}
            </td>
          </tr>
        </tfoot>
      </table>

      <p class="bloccs-muted bloccs-hint">
        Updates live as messages flow through the network (1 Hz). Sparklines cover the last 10s.
      </p>
    </section>
    """
  end

  defp rows(network, frame) do
    Enum.map(network.nodes, fn node -> {node, Map.get(frame.nodes, node.id)} end)
  end

  defp series(nil), do: []
  defp series(m), do: Map.get(m, :series, [])

  defp max_of(rows, key) do
    rows
    |> Enum.map(fn {_n, m} -> (m && Map.get(m, key)) || 0 end)
    |> Enum.max(fn -> 0 end)
    |> max(1)
    |> Kernel.*(1.0)
  end

  defp totals(rows) do
    Enum.reduce(rows, %{throughput: 0.0, completed: 0, errors: 0}, fn {_n, m}, acc ->
      if m do
        %{
          throughput: acc.throughput + m.throughput,
          completed: acc.completed + m.completed,
          errors: acc.errors + m.errors
        }
      else
        acc
      end
    end)
  end

  defp pct(_v, max) when max in [0, 0.0], do: 0
  defp pct(v, max), do: min(100, round(v / max * 100))

  defp state(nil), do: :idle
  defp state(%{state: state}), do: state

  defp errors(nil), do: "—"
  defp errors(%{errors: 0}), do: "0"
  defp errors(%{errors: n, completed: c}), do: "#{n} (#{Format.percent(safe_rate(n, c))})"

  defp error_class(%{errors: n}) when n > 0, do: "bloccs-num--error"
  defp error_class(_), do: nil

  defp safe_rate(_n, 0), do: 0.0
  defp safe_rate(n, c), do: n / c

  defp header_note(%{updated_at: nil}), do: "waiting for traffic"
  defp header_note(%{nodes: nodes}) when map_size(nodes) == 0, do: "waiting for traffic"
  defp header_note(_), do: "live"
end