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"""

Topology

{@flow.rate}/s · {length( @network.nodes )} nodes · {length(@network.edges)} edges
<.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} />
""" 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"""
<.hex_glyph glyph={@node.glyph} state={(@m && @m.state) || :idle} />
{@node.id}
{@node.kind} · {@node.glyph}
<.link patch={@topo} class="bloccs-ins__x" title="Back to network">×

{@node.doc.intent}

<.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} />
<.section title="Ports">
in {p.name} {p.schema}
out {p.name} {p.schema}
<.section title="Effects"> {fx} pure — no declared effects <.section :if={@contract} title="Code"> <.coderef label="pure core" ref={@contract[:pure_core]} /> <.coderef label="effect shell" ref={@contract[:effect_shell]} />
timeout{@contract.timeout_ms}ms
retry{retry(@contract.retry)}
idempotencykey: {@contract.idempotency[:key]}
{label}{val}
concurrency {@node.concurrency} <.link navigate={Paths.messages(@base, @network_id) <> "?node=#{@node.id}"} class="bloccs-link"> View messages →
""" 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"""
{@network.id}
v{@network.version}
<.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)} />
<.section title="Supervision">
strategy{@network.supervision[:strategy]}
restart limit {@network.supervision[:max_restarts]} / {@network.supervision[:max_seconds]}s
<.section title="Inputs">
{name} {endpoint(ep)}

none exposed

<.section title="Outputs">
{name} {endpoint(ep)}

none exposed

Click a node to inspect its primitive and code.

""" end # ---- small building blocks ---- attr :label, :string, required: true attr :value, :any, required: true attr :bad, :any, default: false defp tile(assigns) do ~H"""
{@value}
{@label}
""" end attr :label, :string, required: true attr :ref, :any, default: nil defp coderef(assigns) do ~H"""
{@label}
{@ref}
""" end attr :title, :string, required: true attr :rest, :global slot :inner_block, required: true defp section(assigns) do ~H"""

{@title}

{render_slot(@inner_block)}
""" 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 """ 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""" """ end defp distinct_glyphs(network) do network.nodes |> Enum.map(& &1.glyph) |> Enum.uniq() |> Enum.sort() end end