Skip to main content

lib/bloccs/web/panels/topology.ex

defmodule Bloccs.Web.Panels.Topology do
  @moduledoc """
  Panel 2 — the live network graph paired with an inspector.

  The graph (`Bloccs.Web.Components.Graph`) shows node state, throughput, and
  packets moving along active edges. The side panel inspects either the whole
  network (setup + live totals) or a clicked node — its primitive (kind, ports,
  effects), live metrics, and the **code** that implements it (the author's
  `pure_core` / `effect_shell` plus any retry/idempotency/window policy). The
  contract fields are read defensively, so this works against any `bloccs` that
  predates them. Selection is the `?node=` URL param (shareable).
  """

  use Bloccs.Web, :html

  import Bloccs.Web.Components.Graph

  alias Bloccs.Web.{Format, Paths}

  attr :network, :any, required: true
  attr :base_path, :string, required: true
  attr :states, :map, default: %{}
  attr :frame, :map, default: %{nodes: %{}, updated_at: nil}
  attr :flow, :map, default: %{events: [], series: [], rate: 0}
  attr :selected, :any, default: nil

  def render(assigns) do
    nodes = Map.get(assigns.frame, :nodes, %{})
    selected_node = Enum.find(assigns.network.nodes, &(to_string(&1.id) == assigns.selected))

    assigns =
      assigns
      |> assign(:rates, Map.new(nodes, fn {id, v} -> {id, Map.get(v, :throughput, 0)} end))
      |> assign(:titles, Map.new(nodes, fn {id, v} -> {id, title_for(id, v)} end))
      |> assign(:active_edges, active_edges(assigns.flow))
      |> assign(:live?, assigns.flow.rate > 0)
      |> assign(:selected_node, selected_node)
      |> assign(:topo_path, Paths.topology(assigns.base_path, assigns.network.id))

    ~H"""
    <section class="bloccs-topology">
      <header class="bloccs-panel__header">
        <h1>Topology</h1>
        <span class="bloccs-muted">
          <span :if={@live?}><span class="bloccs-live"></span> {@flow.rate}/s · </span>{length(
            @network.nodes
          )} nodes · {length(@network.edges)} edges
        </span>
      </header>

      <div class="bloccs-topo">
        <div class="bloccs-topo__graph">
          <.graph
            network={@network}
            states={@states}
            rates={@rates}
            titles={@titles}
            active_edges={@active_edges}
            link_base={@topo_path}
            selected={@selected_node && @selected_node.id}
          />
          <.legend network={@network} />
        </div>

        <aside class="bloccs-ins">
          <%= if @selected_node do %>
            <.node_inspect
              node={@selected_node}
              m={Map.get(@frame.nodes, @selected_node.id)}
              base={@base_path}
              network_id={@network.id}
              topo={@topo_path}
            />
          <% else %>
            <.network_inspect network={@network} nodes={@frame.nodes} />
          <% end %>
        </aside>
      </div>
    </section>
    """
  end

  # ---- inspector: a selected node ----

  attr :node, :map, required: true
  attr :m, :any, default: nil
  attr :base, :string, required: true
  attr :network_id, :any, required: true
  attr :topo, :string, required: true

  defp node_inspect(assigns) do
    assigns =
      assigns
      |> assign(:contract, Map.get(assigns.node, :contract))
      |> assign(:config, Map.get(assigns.node, :config, %{}))

    ~H"""
    <header class="bloccs-ins__head">
      <span class={["bloccs-ins__glyph", "hex-glyph--#{(@m && @m.state) || :idle}"]}>
        <svg viewBox="-60 -62 120 120" width="40" height="40">
          <.hex_glyph glyph={@node.glyph} state={(@m && @m.state) || :idle} />
        </svg>
      </span>
      <div class="bloccs-ins__id">
        <div class="bloccs-ins__name">
          {@node.id}
          <span
            :if={@node[:reply]}
            class="bloccs-chip bloccs-chip--reply"
            title="Replies to Bloccs.call/4 · cast/4"
          >
            reply
          </span>
        </div>
        <div class="bloccs-ins__kind">{@node.kind} · {@node.glyph}</div>
      </div>
      <.link patch={@topo} class="bloccs-ins__x" title="Back to network">×</.link>
    </header>

    <p :if={@node.doc[:intent]} class="bloccs-ins__intent">{@node.doc.intent}</p>

    <div class="bloccs-ins__tiles">
      <.tile label="throughput" value={Format.rate(@m && @m.throughput)} />
      <.tile label="p95" value={lat(@m, :p95)} />
      <.tile label="completed" value={Format.count(@m && @m.completed)} />
      <.tile label="errors" value={(@m && @m.errors) || 0} bad={@m && @m.errors > 0} />
    </div>

    <.section title="Ports">
      <div class="bloccs-ports">
        <div :for={p <- @node.ports_in} class="bloccs-portrow">
          <span class="bloccs-portrow__dir">in</span>
          <span class="bloccs-portrow__name">{p.name}</span>
          <span class="bloccs-chip">{p.schema}</span>
        </div>
        <div :for={p <- @node.ports_out} class="bloccs-portrow">
          <span class="bloccs-portrow__dir bloccs-portrow__dir--out">out</span>
          <span class="bloccs-portrow__name">{p.name}</span>
          <span class="bloccs-chip">{p.schema}</span>
        </div>
      </div>
    </.section>

    <.section title="Effects">
      <div :for={fx <- @node.effects} class="bloccs-fxrow">
        <span class="bloccs-chip bloccs-chip--fx">{fx}</span>
        <span :for={scope <- effect_scopes(@node, fx)} class="bloccs-chip bloccs-chip--scope">
          {scope}
        </span>
      </div>
      <span :if={@node.effects == []} class="bloccs-muted bloccs-ins__pure">
        pure — no declared effects
      </span>
    </.section>

    <.section :if={@contract} title="Code">
      <.coderef label="pure core" ref={@contract[:pure_core]} />
      <.coderef label="effect shell" ref={@contract[:effect_shell]} />
      <div :if={@contract[:timeout_ms]} class="bloccs-kv">
        <span class="bloccs-muted">timeout</span><code>{@contract.timeout_ms}ms</code>
      </div>
      <div :if={@contract[:retry]} class="bloccs-kv">
        <span class="bloccs-muted">retry</span><code>{retry(@contract.retry)}</code>
      </div>
      <div :if={@contract[:idempotency]} class="bloccs-kv">
        <span class="bloccs-muted">idempotency</span><code>key: {@contract.idempotency[:key]}</code>
      </div>
      <div :for={{label, val} <- prim_config(@config)} class="bloccs-kv">
        <span class="bloccs-muted">{label}</span><code>{val}</code>
      </div>
    </.section>

    <div class="bloccs-ins__foot">
      <span class="bloccs-muted">concurrency {@node.concurrency}</span>
      <.link navigate={Paths.messages(@base, @network_id) <> "?node=#{@node.id}"} class="bloccs-link">
        View messages →
      </.link>
    </div>
    """
  end

  # ---- inspector: the network setup ----

  attr :network, :any, required: true
  attr :nodes, :map, default: %{}

  defp network_inspect(assigns) do
    assigns =
      assigns
      |> assign(:total, assigns.nodes |> Map.values() |> Enum.map(& &1.throughput) |> Enum.sum())
      |> assign(:errors, assigns.nodes |> Map.values() |> Enum.map(& &1.errors) |> Enum.sum())

    ~H"""
    <header class="bloccs-ins__head">
      <div class="bloccs-ins__id">
        <div class="bloccs-ins__name">{@network.id}</div>
        <div class="bloccs-ins__kind">v{@network.version}</div>
      </div>
    </header>

    <div class="bloccs-ins__tiles">
      <.tile label="throughput" value={Format.rate(@total)} />
      <.tile label="errors" value={@errors} bad={@errors > 0} />
      <.tile label="nodes" value={length(@network.nodes)} />
      <.tile label="edges" value={length(@network.edges)} />
    </div>

    <.section title="Supervision">
      <div class="bloccs-kv">
        <span class="bloccs-muted">strategy</span><code>{@network.supervision[:strategy]}</code>
      </div>
      <div class="bloccs-kv">
        <span class="bloccs-muted">restart limit</span>
        <code>{@network.supervision[:max_restarts]} / {@network.supervision[:max_seconds]}s</code>
      </div>
    </.section>

    <.section title="Inputs">
      <div :for={{name, ep} <- Map.to_list(@network.expose.in)} class="bloccs-portrow">
        <span class="bloccs-portrow__name">{name}</span>
        <span class="bloccs-chip">{endpoint(ep)}</span>
      </div>
      <p :if={@network.expose.in == %{}} class="bloccs-muted">none exposed</p>
    </.section>

    <.section title="Outputs">
      <div :for={{name, ep} <- Map.to_list(@network.expose.out)} class="bloccs-portrow">
        <span class="bloccs-portrow__name">{name}</span>
        <span class="bloccs-chip">{endpoint(ep)}</span>
      </div>
      <p :if={@network.expose.out == %{}} class="bloccs-muted">none exposed</p>
    </.section>

    <p class="bloccs-muted bloccs-ins__hint">Click a node to inspect its primitive and code.</p>
    """
  end

  # ---- small building blocks ----

  attr :label, :string, required: true
  attr :value, :any, required: true
  attr :bad, :any, default: false

  defp tile(assigns) do
    ~H"""
    <div class="bloccs-tile">
      <div class={["bloccs-tile__v", @bad && "bloccs-num--error"]}>{@value}</div>
      <div class="bloccs-tile__l">{@label}</div>
    </div>
    """
  end

  attr :label, :string, required: true
  attr :ref, :any, default: nil

  defp coderef(assigns) do
    ~H"""
    <div :if={@ref} class="bloccs-coderef">
      <div class="bloccs-coderef__l">{@label}</div>
      <code class="bloccs-coderef__v">{@ref}</code>
    </div>
    """
  end

  attr :title, :string, required: true
  attr :rest, :global
  slot :inner_block, required: true

  defp section(assigns) do
    ~H"""
    <div class="bloccs-ins__section" {@rest}>
      <h3>{@title}</h3>
      {render_slot(@inner_block)}
    </div>
    """
  end

  defp retry(r) when is_map(r) do
    parts = ["#{r[:strategy]}", "max #{r[:max]}"]
    parts = if r[:base_ms], do: parts ++ ["base #{r[:base_ms]}ms"], else: parts
    Enum.join(parts, ", ")
  end

  defp retry(_), do: "—"

  @doc """
  The declared scopes/detail for one effect axis, from the node view's
  `effect_detail` (bloccs ≥ 0.8). `db` → its `"table:action"` scopes (read /
  insert / update / delete); `http` → allowed hosts + methods; `time`/`random`
  → their mode. Empty for older introspect views (graceful: just the axis chip).
  """
  def effect_scopes(node, axis) do
    case node |> Map.get(:effect_detail, %{}) |> Map.get(axis) do
      %{allow: allow, methods: methods} -> List.wrap(allow) ++ List.wrap(methods)
      %{allow: allow} -> List.wrap(allow)
      mode when is_binary(mode) -> [mode]
      _ -> []
    end
  end

  @doc """
  Human one-liners for whichever primitive blocks a node declares (`batch`,
  `join`, `rate`, `delay`). Values may be `Bloccs.Manifest.*` structs, which do
  not implement `Access`, so they're read with `Map.get/2`, not bracket syntax.
  """
  def prim_config(config) when is_map(config) do
    []
    |> add_if(config[:batch], "batch", fn b ->
      "size #{Map.get(b, :size)} · #{Map.get(b, :timeout_ms)}ms"
    end)
    |> add_if(config[:join], "join", fn j ->
      "on #{Map.get(j, :on)} · #{Map.get(j, :timeout_ms)}ms"
    end)
    |> add_if(config[:rate], "rate", fn r ->
      "#{Map.get(r, :allowed)} / #{Map.get(r, :interval_ms)}ms"
    end)
    |> add_if(config[:delay_ms], "delay", fn ms -> "#{ms}ms" end)
  end

  def prim_config(_), do: []

  defp add_if(acc, nil, _label, _fmt), do: acc
  defp add_if(acc, val, label, fmt), do: acc ++ [{label, fmt.(val)}]

  defp lat(nil, _k), do: "—"
  defp lat(m, k), do: Format.latency(Map.get(m, k))

  defp endpoint({n, p}), do: "#{n}.#{p}"
  defp endpoint(other), do: to_string(other)

  defp title_for(id, v) do
    base = "#{id} · #{Format.rate(v.throughput)} · #{Format.count(v.completed)} done"
    p95 = if v.p95, do: " · p95 #{Format.latency(v.p95)}", else: ""
    errs = if v.errors > 0, do: " · #{v.errors} err", else: ""
    base <> p95 <> errs
  end

  defp active_edges(%{events: events}) when is_list(events) do
    for %{node: n, to: {tn, _tp}} <- events, reduce: MapSet.new() do
      acc -> MapSet.put(acc, {n, tn})
    end
  end

  defp active_edges(_), do: MapSet.new()

  attr :network, :any, required: true

  defp legend(assigns) do
    assigns = assign(assigns, :glyphs, distinct_glyphs(assigns.network))

    ~H"""
    <footer class="bloccs-legend">
      <span :for={g <- @glyphs} class="bloccs-legend__item">
        <svg viewBox="-60 -62 120 120" width="22" height="22"><.hex_glyph glyph={g} /></svg>
        {g}
      </span>
    </footer>
    """
  end

  defp distinct_glyphs(network) do
    network.nodes |> Enum.map(& &1.glyph) |> Enum.uniq() |> Enum.sort()
  end
end