This guide walks you through setting up LotusWeb in your Phoenix application.

Requirements

  • Elixir 1.18+ and OTP 26+
  • Phoenix 1.7+ for LiveView compatibility
  • Lotus 1.0 configured in your application

Version Compatibility: LotusWeb 1.0 requires Lotus 1.0. The v1 contract renamed several config keys (:ecto_repo:storage_repo, :data_repos:data_sources, :default_repo:default_source). There is no compatibility shim — Lotus raises at boot if it finds an old key. See the Lotus Upgrading to v1.0 guide if you're coming from 0.x, and the lotus_web Upgrading to v1.0 guide for what changes in the dashboard.

Step 1: Add Dependency

Add lotus and lotus_web to your dependencies in mix.exs:

def deps do
  [
    {:lotus, "~> 1.0"},
    {:lotus_web, "~> 1.0"}
  ]
end

Run mix deps.get to fetch the dependencies.

Step 2: Configure Lotus (if not already done)

LotusWeb requires Lotus to be configured. Add to your config/config.exs:

config :lotus,
  storage_repo: MyApp.Repo,
  default_source: "main",       # Default source for query execution
  data_sources: %{
    "main" => MyApp.Repo,
    "analytics" => MyApp.AnalyticsRepo  # Optional: multiple data sources
    # A non-Ecto source is a config map instead of a repo module, e.g.
    # "logs" => %{adapter: :elasticsearch, url: "http://localhost:9200"}
  },
  # Recommended: Enable caching for better dashboard performance
  cache: %{
    adapter: Lotus.Cache.ETS,
    namespace: "myapp_lotus"
    # Lotus includes built-in profiles that work great with LotusWeb:
    # - :results (60s TTL) - User query results
    # - :schema (1h TTL) - Table introspection (used by dashboard)
    # - :options (5m TTL) - Dropdown options and reference data
  }

Step 3: Run Lotus Migration (if not already done)

mix ecto.gen.migration create_lotus_tables

Add the migration content:

defmodule MyApp.Repo.Migrations.CreateLotusTables do
  use Ecto.Migration

  def up do
    Lotus.Migrations.up()
  end

  def down do
    Lotus.Migrations.down()
  end
end

Run the migration:

mix ecto.migrate

Step 4: Add Lotus to Supervision Tree (Required for Caching)

For caching to work, Lotus must be started as part of your application's supervision tree. Add Lotus to your application supervisor:

# lib/my_app/application.ex
def start(_type, _args) do
  children = [
    MyApp.Repo,
    # Add Lotus for caching support (required for optimal dashboard performance)
    Lotus,
    MyAppWeb.Endpoint
  ]
  
  opts = [strategy: :one_for_one, name: MyApp.Supervisor]
  Supervisor.start_link(children, opts)
end

Note: Without this step, caching will be disabled and dashboard performance may be slower due to repeated database introspection queries.

Step 5: Mount LotusWeb Dashboard

Add to your router:

defmodule MyAppWeb.Router do
  use MyAppWeb, :router
  import Lotus.Web.Router

  # ... other routes

  scope "/", MyAppWeb do
    pipe_through [:browser, :require_authenticated_user] # ⚠️ Add auth!

    lotus_dashboard "/lotus"
  end
end

⚠️ Security Warning: Always mount behind authentication in production.

Note: Your :browser pipeline must include fetch_session and fetch_flash plugs for LotusWeb to work correctly. Most Phoenix apps have these by default.

Router options

lotus_dashboard/2 accepts these options. Any other option raises an ArgumentError at compile time.

OptionDefaultDescription
:as:lotus_dashboardRoute and live session name. Change it if you mount the dashboard more than once.
:on_mount[]Extra on_mount hooks, run before Lotus's own Lotus.Web.Locale and Lotus.Web.Authentication hooks.
:socket_path"/live"Path of the Phoenix LiveView socket the dashboard connects to. Set it if your endpoint mounts the socket elsewhere.
:transport"websocket"LiveView transport, either "websocket" or "longpoll".
:resolverLotus.Web.ResolverModule implementing Lotus.Web.Resolver for authentication, access control and the actor. See below.
:csp_nonce_assign_keynilAssign key (or a %{script: key, style: key} map) holding the CSP nonce. See Content Security Policy.
:features[]Optional feature flags. See below.
:card_concurrency4Maximum number of query cards a dashboard runs at the same time, per page load, in the dashboard view and the public view. The other cards wait and start as results arrive.

The macro mounts more than the main LiveViews: it also adds the two asset routes, the CSV export route, and a second live session for public dashboards at <prefix>/public/:token that skips the resolver entirely. Every one of them is declared inside your scope, so they all go through the same pipeline — mounting behind :require_authenticated_user protects the assets and the export route too.

Optional Features

You can enable additional features by passing the features option:

lotus_dashboard "/lotus",
  features: [:timeout_options]
FeatureDescription
:timeout_optionsAdds a per-query timeout selector to the query editor toolbar, allowing users to override the default 5-second query timeout for long-running queries.

Access control and the actor

Pass a :resolver to tell the dashboard who is looking at it:

lotus_dashboard "/lotus", resolver: MyAppWeb.LotusResolver
defmodule MyAppWeb.LotusResolver do
  @behaviour Lotus.Web.Resolver

  # Who is here.
  def resolve_user(conn), do: conn.assigns.current_user

  # What they may do in the dashboard.
  def resolve_access(%{admin?: true}), do: :all
  def resolve_access(nil), do: :forbidden
  def resolve_access(_user), do: :read_only

  # Who Lotus core acts for. Reaches middleware and telemetry.
  def resolve_context(%{id: id, roles: roles}), do: %{user_id: id, roles: roles}
  def resolve_context(nil), do: nil

  # What data they may see. Reaches the visibility resolver, and is hashed
  # into the discovery and result cache keys so two tenants never read each
  # other's cached rows. Keep it low-cardinality.
  def resolve_scope(%{tenant_id: tenant_id}), do: %{tenant_id: tenant_id}
  def resolve_scope(nil), do: nil
end

resolve_access/1 gates the dashboard UI. resolve_context/1 and resolve_scope/1 gate the data: every query the dashboard runs, every schema it lists and every table it describes carries them into Lotus core as the :context and :scope options. Without them an access-control plug sees nil for everything a user does in the browser, even though the same plug sees a real actor for calls the host app makes itself.

Every query, chart, dashboard, card, filter and filter mapping the dashboard creates, updates or deletes carries :context too, so a :before_content_change plug knows who made the change. When such a plug halts, the dashboard shows that the change was refused, with the plug's reason when it is a string, and writes nothing. A dashboard save is one transaction.

Every callback is optional. A dashboard with no resolver, or one that implements only resolve_user/1, calls core with no actor at all — the same middleware payloads and the same cache keys as before.

The CSV export route resolves the actor the same way, from the conn.

Fine-grained permissions

resolve_access/1 gives one level for the whole session. To decide per action, implement authorize/3. The dashboard asks it before it renders a control, and does not render the control on a deny. It asks again in the event handler and shows the reason to the user. The CSV export route asks for :query and :export on the source and answers 403 with the reason.

The query editor offers, browses and autocompletes only the sources the user may :discover or :query, so the schemas, tables and columns of a denied source are never listed. Running a query and its dropdown options needs :query. A save that puts a query on a source other than its stored one, or saves a new query, needs :query on that source too.

defmodule MyAppWeb.LotusResolver do
  @behaviour Lotus.Web.Resolver

  def authorize(%{role: :admin}, _action, _resource), do: :allow

  def authorize(%{role: :analyst}, action, _resource)
      when action in [:query, :export, :view_dashboard, :create_query],
      do: :allow

  def authorize(_user, action, _resource),
    do: {:deny, "Your role does not allow #{action}"}
end
ActionResource
:querythe data source name
:exportthe data source name
:ai_generatethe data source name
:discoverthe data source name
:create_querynil
:update_querythe stored %Lotus.Storage.Query{}
:delete_querythe %Lotus.Storage.Query{}
:share_querythe %Lotus.Storage.Query{} (no dashboard control asks it yet)
:share_dashboardthe %Lotus.Storage.Dashboard{} whose public link changes
:view_dashboardthe %Lotus.Storage.Dashboard{}
:manage_dashboardnil for a new dashboard, else the %Lotus.Storage.Dashboard{}
:manage_sourcenil
:manage_cachenil

Without authorize/3, the decision derives from resolve_access/1:

resolve_access/1Decision
:allallow every action
:read_onlyallow :query, :export, :discover and :view_dashboard; deny the rest
:forbiddendeny every action (the dashboard redirects before it asks)

To keep that default for some actions, call Lotus.Web.Authorization.default_decision(resolve_access(user), action) from your callback.

The dashboard calls authorize/3 on every render. Do not do I/O in each call: load the policy once and cache it. A public dashboard never calls it, because it has no user.

A component that asks without the dashboard's :resolver and :access assigns gets a decision from :read_only access, and the error is logged. With strict_actor: true (see below), it raises instead.

Catching an actor that never arrived

A dashboard component holds only the assigns its parent passes it, so a component nobody hands the actor to calls core unscoped, and does it silently: context: nil from a deliberately unscoped dashboard and context never set at all look the same at the call site. Turn on the strict check while you develop:

# config/dev.exs and config/test.exs
config :lotus_web, strict_actor: true

Lotus then raises instead of quietly dropping the actor. Leave it off in production, where an unscoped call is better than a crashed dashboard.

The public dashboard route (<prefix>/public/:token) is deliberately outside this: it mounts with no resolver, so it has no user, no context and no scope, and it is fixed at :read_only access. A public dashboard therefore runs its cards with whatever visibility your static configuration allows. Publish a dashboard only if every card is safe to show unauthenticated.

Assets

The dashboard's stylesheet and JavaScript bundle ship inside the lotus_web package and are served from two routes under the mount path:

  • <prefix>/css-<hash>
  • <prefix>/js-<hash>

<hash> is the MD5 of the compiled asset, so the URL changes whenever the bundle does. Lotus.Web.Assets answers with cache-control: public, max-age=31536000, immutable, and with content-encoding: gzip when the request's accept-encoding allows it (vary: accept-encoding is always set). A request for a hash this build did not produce gets a 404 with cache-control: no-store, so a stale URL from an old node during a rolling deploy can never poison a cache.

There is nothing to add to your endpoint: the routes come from lotus_dashboard/2 and do not go through Plug.Static or your asset pipeline. The route helper is lotus_asset_path/3 if you need to build the URLs yourself.

A browser tab that stays open across a deploy is handled too. On the connected mount the dashboard compares the phx-track-static URLs the client is tracking against the hashes of the running build, and issues a full-page redirect when they differ, so the tab picks up the new bundle instead of running old JavaScript against new server code.

Content Security Policy (CSP)

If your application sets a Content-Security-Policy header (via :put_secure_browser_headers or a custom plug), you'll need to configure it to allow the resources used by LotusWeb.

Changed in 1.0: the stylesheet used to be inlined in the page and is now an external request to <prefix>/css-<hash>. A policy that allowed it with style-src 'unsafe-inline' alone no longer does — 'unsafe-inline' governs inline style content, not external stylesheets. Use a nonce or 'self'. The same applies to the JavaScript bundle and script-src.

Nonce-based CSP

LotusWeb serves its stylesheet and JavaScript bundle from two routes under the dashboard mount path (<prefix>/css-<hash> and <prefix>/js-<hash>) and loads the TailwindPlus module from a CDN. The <link> and <script> tags carry a nonce when you configure one, so a nonce-based policy covers all of them. To enable nonces:

  1. Generate a nonce in a custom plug and store it in conn.assigns:
defmodule MyAppWeb.CSPPlug do
  import Plug.Conn

  def init(opts), do: opts

  def call(conn, _opts) do
    nonce =
      24
      |> :crypto.strong_rand_bytes()
      |> Base.url_encode64(padding: false)

    conn
    |> assign(:csp_nonce, nonce)
    |> put_resp_header(
      "content-security-policy",
      "default-src 'self'; " <>
        "script-src 'nonce-#{nonce}' https://cdn.jsdelivr.net; " <>
        "style-src 'nonce-#{nonce}'; " <>
        "font-src 'self' data:; " <>
        "img-src 'self' data:; " <>
        "connect-src 'self' ws: wss:;"
    )
  end
end
  1. Add the plug to your browser pipeline, after :put_secure_browser_headers (so it overrides Phoenix's default CSP):
pipeline :browser do
  plug :accepts, ["html"]
  plug :fetch_session
  plug :fetch_live_flash
  plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
  plug :protect_from_forgery
  plug :put_secure_browser_headers
  plug MyAppWeb.CSPPlug
end
  1. Pass the nonce key when mounting the dashboard:
lotus_dashboard "/lotus",
  csp_nonce_assign_key: :csp_nonce

You can also use separate keys for script and style nonces:

lotus_dashboard "/lotus",
  csp_nonce_assign_key: %{script: :script_csp_nonce, style: :style_csp_nonce}

Required CSP directives

LotusWeb uses the following resources that your CSP policy must allow:

DirectiveRequired valueReason
script-src'nonce-<value>' (or 'self') and https://cdn.jsdelivr.netApp JS bundle served from <prefix>/js-<hash>, and the TailwindPlus CDN module
style-src'nonce-<value>' (or 'self')App stylesheet served from <prefix>/css-<hash>. 'unsafe-inline' alone does not allow an external stylesheet
font-srcdata:Embedded Inter font (base64-encoded)
img-srcdata:Data URI images
connect-srcws: or wss:LiveView WebSocket connection

Step 6: Visit the Dashboard

Start your Phoenix server and visit /lotus to access the dashboard.

Next Steps

Continue with the Getting Started guide to learn how to use LotusWeb.