defmodule Bloccs.Web.Panels.Messages do @moduledoc """ Panel 5 — packages moving through the network. A live throughput chart over the per-second `Bloccs.Web.Telemetry.Flow` buckets, plus a scrolling feed of recent message events: each edge a message crossed (`from.port → to.port`), its outcome, the emitting node's latency, and — when `Bloccs.Inspect` capture is enabled (bloccs 0.3+) — a bounded, redacted snapshot of the payload. Click a row to inspect it: the full payload plus the hop it took, highlighted on a mini-topology. Filterable by node and outcome. """ use Bloccs.Web, :html import Bloccs.Web.Components.{Chart, Graph} alias Bloccs.Web.Format attr :network, :any, required: true attr :base_path, :string, required: true attr :flow, :map, default: %{events: [], series: [], rate: 0} attr :filters, :map, default: %{node: nil, outcome: nil} attr :selected, :any, default: nil # The selected message's journey snapshot (lineage hops, oldest-first), taken at # selection time by the live view so it persists as the feed scrolls. Prev/Next # walks THIS list, so navigation follows the message and the position is stable. attr :journey, :list, default: [] attr :paused, :boolean, default: false def render(assigns) do events = filtered(assigns.flow.events, assigns.filters) hop_idx = assigns.selected && Enum.find_index(assigns.journey, &same?(&1, assigns.selected)) assigns = assigns |> assign(:events, events) |> assign(:any_payload, Enum.any?(events, & &1[:payload])) |> assign(:hop_idx, hop_idx) |> assign(:hop_count, length(assigns.journey)) ~H"""

Messages

live · {@flow.rate}/s ⏸ paused
<.throughput series={@flow.series} />
{length(@events)} shown
<%= for {e, idx} <- Enum.with_index(@events) do %> <% end %>
Time From → To Payload Outcome Latency
{time(e.at)} {edge(e)} {payload(e)} <.status_pill state={pill(e.outcome)} label={Atom.to_string(e.outcome)} /> {Format.latency(e.duration_ms)}

No messages yet. Send traffic through the network and it appears here live.

Payload contents are hidden. Enable capture with config :bloccs, :inspect, enabled: true (bloccs 0.3+).

<%!-- message inspector: a right-side drawer, so the live feed never pushes it --%>
""" end @doc "Filter flow events by node and outcome (used by the panel and the live view)." def filtered(events, %{node: node, outcome: outcome}) do events |> reject_blank(:node, node, fn e, v -> to_string(e.node) == v end) |> reject_blank(:outcome, outcome, fn e, v -> outcome_class(e.outcome) == v end) end def filtered(events, _), do: events @doc "Find the journey hop with this `msg_id` (string) and edge `to` token. Used by the live view." def find_hop(events, msgid, to_token) do Enum.find(events, fn e -> to_string(e[:msg_id]) == msgid and hop_token(e) == to_token end) end @doc """ Whether a flow event is the currently-selected one. Identified by its lineage `msg_id` plus the edge `to` (one emit can fan to several edges → same msg_id, distinct rows) and `at` (a node may re-emit the same id is not expected, but `at` keeps it exact). Stable across live feed updates. """ def same?(_e, nil), do: false def same?(e, s), do: e[:msg_id] == s[:msg_id] and e.to == s.to and e.at == s.at and e.node == s.node defp reject_blank(events, _key, v, _match) when v in [nil, ""], do: events defp reject_blank(events, _key, v, match), do: Enum.filter(events, &match.(&1, v)) defp outcome_class(:ok), do: "ok" defp outcome_class(o) when o in [:dropped, :skipped], do: "dropped" defp outcome_class(_), do: "failed" defp node_ids(%{nodes: nodes}), do: nodes |> Enum.map(& &1.id) |> Enum.sort() defp payload(%{payload: p}) when is_binary(p), do: strip_map(p) defp payload(_), do: "—" defp payload_full(%{payload: p}) when is_binary(p), do: p defp payload_full(_), do: nil defp strip_map("%{" <> rest = full) do if String.ends_with?(rest, "}"), do: binary_part(rest, 0, byte_size(rest) - 1), else: full end defp strip_map(other), do: other # Highlight every node + edge the message touched across its whole journey. defp journey_states(journey) when is_list(journey) do Enum.reduce(journey, %{}, fn e, acc -> acc = Map.put(acc, e.node, :running) case e.to do {tn, _tp} -> Map.put(acc, tn, :running) _ -> acc end end) end defp journey_states(_), do: %{} defp journey_edges(journey) when is_list(journey) do for %{to: {tn, _tp}, node: n} <- journey, into: MapSet.new(), do: {n, tn} end defp journey_edges(_), do: MapSet.new() # A stable token identifying a hop's edge, for `inspect_hop` clicks. defp hop_token(%{to: {tn, tp}}), do: "#{tn}.#{tp}" defp hop_token(_), do: "" defp hop_pos(nil, _count), do: "—" defp hop_pos(idx, count), do: "hop #{idx + 1} of #{count}" defp edge(%{out_port: nil, node: node}), do: "#{node}" defp edge(%{node: node, out_port: port, to: nil}), do: "#{node}.#{port} → ·" defp edge(%{node: node, out_port: port, to: {tn, tp}}), do: "#{node}.#{port} → #{tn}.#{tp}" defp from_label(%{node: n, out_port: nil}), do: "#{n}" defp from_label(%{node: n, out_port: p}), do: "#{n}.#{p}" defp to_label(%{to: {tn, tp}}), do: "#{tn}.#{tp}" defp to_label(_), do: "·" defp pill(:ok), do: :ok defp pill(o) when o in [:dropped, :skipped], do: :idle defp pill(_), do: :failed defp time(ms) do ms |> DateTime.from_unix!(:millisecond) |> Calendar.strftime("%H:%M:%S") |> Kernel.<>("." <> (ms |> rem(1000) |> Integer.to_string() |> String.pad_leading(3, "0"))) end end