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"""
<.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"""
{@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}
{scope}
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}
"""
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"""
"""
end
attr :label, :string, required: true
attr :ref, :any, default: nil
defp coderef(assigns) do
~H"""
"""
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 """
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"""
"""
end
defp distinct_glyphs(network) do
network.nodes |> Enum.map(& &1.glyph) |> Enum.uniq() |> Enum.sort()
end
end