defmodule PgFlowDashboard.Router do
@moduledoc """
Router macro for mounting the PgFlow Dashboard.
## Usage
defmodule MyAppWeb.Router do
use MyAppWeb, :router
import PgFlowDashboard.Router
scope "/" do
pipe_through [:browser]
pgflow_dashboard "/pgflow",
repo: MyApp.Repo,
pubsub: MyApp.PubSub
end
end
## Options
See `PgFlowDashboard.Config` for all available options.
Required:
* `:repo` - The Ecto repository module
* `:pubsub` - The Phoenix.PubSub module
Optional:
* `:refresh_interval` - Polling interval (default: 5000ms)
* `:time_zone` - Time zone for timestamps (default: "UTC")
* `:default_time_range` - Default filter (default: :last_24h)
* `:max_grid_runs` - Max runs in history grid (default: 50)
"""
alias PgFlowDashboard.Live.LiveHelpers
@doc """
Generates routes for the PgFlow Dashboard.
## Authentication
In production, you should protect the dashboard with authentication.
Use the `:on_mount` option to add an authentication hook:
pgflow_dashboard "/pgflow",
repo: MyApp.Repo,
pubsub: MyApp.PubSub,
on_mount: [{MyAppWeb.Auth, :ensure_admin}]
The `:on_mount` hooks are added to the LiveView session alongside
the dashboard's own mount hook. See `Phoenix.LiveView.Router` for
more information on `on_mount` hooks.
"""
@spec pgflow_dashboard(String.t(), keyword()) :: Macro.t()
defmacro pgflow_dashboard(path, opts \\ []) do
PgFlowDashboard.Router.ensure_dashboard_dependencies!()
quote bind_quoted: [path: path, opts: opts] do
scope path, alias: false, as: false do
import Phoenix.LiveView.Router, only: [live: 3, live: 4, live_session: 3]
# Extract user-provided on_mount hooks, if any
{user_hooks, config_opts} = Keyword.pop(opts, :on_mount, [])
# Combine dashboard mount with any user-provided hooks
all_hooks = [{PgFlowDashboard.Router, :mount_dashboard} | List.wrap(user_hooks)]
live_session :pgflow_dashboard,
on_mount: all_hooks,
session: {PgFlowDashboard.Router, :session, [config_opts, path]} do
live("/", PgFlowDashboard.Live.OverviewLive, :index)
live("/runs", PgFlowDashboard.Live.RunsLive.Index, :index)
live("/runs/:id", PgFlowDashboard.Live.RunsLive.Show, :show)
live("/flows", PgFlowDashboard.Live.FlowsLive.Index, :index)
live("/flows/:slug", PgFlowDashboard.Live.FlowsLive.Show, :show)
live("/jobs", PgFlowDashboard.Live.JobsLive.Index, :index)
live("/jobs/:id", PgFlowDashboard.Live.JobsLive.Show, :show)
live("/crons", PgFlowDashboard.Live.CronsLive.Index, :index)
live("/crons/:id", PgFlowDashboard.Live.CronsLive.Show, :show)
live("/workers", PgFlowDashboard.Live.WorkersLive, :index)
live("/workers/:id", PgFlowDashboard.Live.WorkersLive.Show, :show)
end
end
end
end
@doc """
Builds the LiveView session map used by PgFlowDashboard routes.
The session contains validated dashboard configuration and the resolved base
path for links inside the dashboard.
"""
@spec session(Plug.Conn.t(), keyword(), String.t()) :: map()
def session(conn, opts, path) do
config = PgFlowDashboard.Config.validate!(opts)
%{
"pgflow_dashboard_config" => Enum.into(config, %{}),
"base_path" => compute_base_path(conn.request_path, path)
}
end
@doc """
Derive the full base path by finding where the dashboard path appears
in the request URL. This handles outer scopes (e.g., "/admin/pgflow"
when `pgflow_dashboard("/pgflow")` is mounted inside `scope "/admin"`).
"""
@spec compute_base_path(String.t(), String.t()) :: String.t()
def compute_base_path(request_path, path) do
suffix = String.trim_trailing(path, "/")
case String.split(request_path, suffix, parts: 2) do
[prefix, _rest] -> prefix <> suffix
_ -> path
end
end
@doc """
Mounts dashboard configuration into each PgFlowDashboard LiveView socket.
"""
@spec on_mount(:mount_dashboard, map(), map(), Phoenix.LiveView.Socket.t()) ::
{:cont, Phoenix.LiveView.Socket.t()}
def on_mount(:mount_dashboard, _params, session, socket) do
LiveHelpers.on_mount(session, socket)
end
@doc """
Ensures optional dashboard dependencies are available before routes are mounted.
PgFlow can be used without PgFlowDashboard. When an application imports and
mounts `pgflow_dashboard/2`, the dashboard requires Phoenix LiveView,
LiveFilter, and Tz to be installed in the host project.
"""
@spec ensure_dashboard_dependencies!() :: :ok
def ensure_dashboard_dependencies! do
check_dashboard_dependencies!([
{:phoenix_live_view, Phoenix.LiveView},
{:livefilter, LiveFilter},
{:tz, Tz}
])
end
@doc false
@spec check_dashboard_dependencies!([{atom(), module()}]) :: :ok
def check_dashboard_dependencies!(deps) when is_list(deps) do
case missing_dependencies(deps) do
[] ->
:ok
missing ->
raise ArgumentError, missing_dependencies_message(missing)
end
end
@doc false
@spec missing_dependencies([{atom(), module()}]) :: [atom()]
def missing_dependencies(deps) when is_list(deps) do
deps
|> Enum.reject(fn {_app, module} -> Code.ensure_loaded?(module) end)
|> Enum.map(fn {app, _module} -> app end)
end
defp missing_dependencies_message(missing) do
deps = Enum.map_join(missing, ", ", &inspect/1)
"PgFlowDashboard requires #{deps}; add the missing dependencies before mounting pgflow_dashboard/2"
end
end