Phoenix LiveView Dashboards

Copy Markdown View Source

For long-running realtime displays — process monitors, lab instruments, market tickers, telemetry boards — BLAND ships a function component that renders a %Bland.Figure{} as inline SVG. Combined with LiveView's normal change tracking, a single assign(socket, :figure, fig) push updates the chart in the browser with no JavaScript on your end.

Setup

Add phoenix_live_view to your app's deps alongside bland:

def deps do
  [
    {:bland, "~> 0.4"},
    {:phoenix_live_view, "~> 1.0"}
  ]
end

phoenix_live_view is declared optional: true in BLAND, so it's only pulled in when you explicitly add it.

Minimal LiveView

defmodule MyAppWeb.SensorLive do
  use MyAppWeb, :live_view
  import Bland.Phoenix.Component

  @history_size 120

  def mount(_params, _session, socket) do
    if connected?(socket), do: :timer.send_interval(500, :tick)

    {:ok,
     socket
     |> assign(history: [])
     |> assign_figure()}
  end

  def handle_info(:tick, socket) do
    point = {System.system_time(:second), read_sensor()}
    history = [point | socket.assigns.history] |> Enum.take(@history_size)

    {:noreply,
     socket
     |> assign(history: history)
     |> assign_figure()}
  end

  defp assign_figure(socket) do
    {ts, ys} = Enum.unzip(socket.assigns.history)
    xs = Enum.map(ts, &(&1 - List.last(ts, 0)))

    fig =
      Bland.figure(size: {900, 360}, title: "Live sensor")
      |> Bland.axes(xlabel: "t [s]", ylabel: "reading")
      |> Bland.line(Enum.reverse(xs), Enum.reverse(ys), label: "ch.1")
      |> Bland.legend(position: :top_right)

    assign(socket, :figure, fig)
  end

  def render(assigns) do
    ~H"""
    <div class="grid grid-cols-1 gap-6 p-6">
      <.bland_figure figure={@figure} class="bg-white rounded-lg shadow" />
    </div>
    """
  end
end

Every 500 ms the LiveView pulls a fresh reading, builds a new %Bland.Figure{}, and assigns it. LiveView sees the figure changed, re-renders the template, and pushes the new SVG to the browser. The browser does a morph diff and swaps it in place.

Component attributes

<.bland_figure figure={@figure} />
<.bland_figure svg={@composite_svg} />
<.bland_figure figure={@figure} class="plot" style="max-width: 100%" id="ch1" />
  • :figure — a %Bland.Figure{}. Rendered via Bland.to_svg/1.
  • :svg — pre-rendered SVG binary. Use this for Bland.grid/2 output, which returns SVG directly.
  • :class, :style, :id — applied to the wrapping <div>.

:figure wins if both are provided. The XML prolog is stripped automatically so the SVG embeds cleanly into the HTML response.

Multi-panel dashboards

Compose subplots into one SVG with Bland.grid/2, then pass the result as :svg:

def render(assigns) do
  ~H"""
  <.bland_figure svg={@dashboard_svg} class="dashboard-grid" />
  """
end

defp build_dashboard(state) do
  Bland.grid(
    [
      build_throughput(state),
      build_latency(state),
      build_errors(state),
      build_queue_depth(state)
    ],
    columns: 2,
    cell_width: 480,
    cell_height: 280
  )
end

The composite is a single SVG, so updates push as one diff regardless of how many panels you've packed in.

Patterns

Bounded history with a circular buffer

For continuous streams, keep the in-memory history bounded so the figure stays responsive:

@history_size 500
history = [new_point | socket.assigns.history] |> Enum.take(@history_size)

For high-rate data, decimate on the way in (e.g. only keep every Nth sample, or pre-aggregate windowed means).

Multiple subscribers, one source

If multiple LiveViews want the same data, put a GenServer in front:

defmodule MyApp.SensorBus do
  use GenServer

  def subscribe, do: Phoenix.PubSub.subscribe(MyApp.PubSub, "sensor")

  # ... GenServer that reads the sensor and broadcasts
  # Phoenix.PubSub.broadcast(MyApp.PubSub, "sensor", {:tick, point})
end

Each LiveView calls MyApp.SensorBus.subscribe() in mount/3 and handles {:tick, point} in handle_info/2. One sensor, many viewers.

PubSub-driven updates

def mount(_params, _session, socket) do
  if connected?(socket), do: Phoenix.PubSub.subscribe(MyApp.PubSub, "metrics")
  {:ok, assign(socket, history: [])}
end

def handle_info({:metric, point}, socket) do
  history = [point | socket.assigns.history] |> Enum.take(120)
  {:noreply, assign(socket, history: history, figure: build_figure(history))}
end

Performance notes

Each update re-renders the figure to SVG (typically 5–100 KB) and ships it as part of the LiveView diff. That's comfortable up to ~10 Hz across a normal network. For higher rates:

  • Decimate on input. A 1 kHz sensor with 100 visible bins on the chart only needs 100 points; aggregate the rest on the server.
  • Throttle the assign. Buffer points and call assign_figure on a fixed timer rather than on each datum.
  • Lower-res figures. A 400×200 chart sends much less SVG than a 1200×800 one. Adjust size: and let CSS scale.

Inline SVG vs. iframe

BLAND uses inline SVG (raw <svg> injected into the page), not an <img src="…"/> reference. That means:

  • No extra HTTP request per update.
  • The SVG participates in CSS — you can style the wrapping <div> with max-width, set a background, animate transitions.
  • LiveView's morph diff swaps the SVG cleanly without flicker.

The price is a slightly larger DOM. For a few panels at modest size, that's negligible.