domain, "url" => url} = session, socket) do
+ user_prefs = get_connect_params(socket)["user_prefs"] || %{}
+ current_user = socket.assigns[:current_user]
+
+ site =
+ current_user
+ |> Plausible.Sites.get_for_user(domain)
+ |> Plausible.Repo.preload(:owners)
+
+ socket =
+ socket
+ |> assign(:site, site)
+ |> assign(:user_prefs, user_prefs)
+ |> assign(:modal, nil)
+
+ params = Map.drop(session, ["domain", "site_id", "url"])
+
+ {:noreply, socket} = handle_params_internal(params, url, socket)
+
+ {:ok, assign(socket, params: params)}
+ end
+
+ def handle_params_internal(params, url, socket) do
+ uri = URI.parse(url)
+
+ filters =
+ (uri.query || "")
+ |> String.split("&")
+ |> Enum.map(&parse_filter/1)
+ |> Enum.filter(&Function.identity/1)
+ |> Jason.encode!()
+
+ params = Map.put(params, "filters", filters)
+
+ query = Query.from(socket.assigns.site, params, %{})
+
+ modal = uri.path |> String.split("/") |> Enum.at(2)
+
+ socket =
+ if modal do
+ socket
+ |> Prima.Modal.push_open("details-#{modal}-breakdown-modal")
+ |> assign(:modal, modal)
+ else
+ socket
+ |> Prima.Modal.push_close("details-#{modal}-breakdown-modal")
+ |> assign(:modal, nil)
+ end
+
+ socket = assign(socket, :query, query)
+
+ {:noreply, socket}
+ end
+
+ def render(assigns) do
+ ~H"""
+
+ <.portal id="pages-breakdown-live-container" target="#pages-breakdown-live">
+ <.live_component
+ module={PlausibleWeb.Live.Dashboard.Pages}
+ id="pages-breakdown-component"
+ params={@params}
+ site={@site}
+ query={@query}
+ user_prefs={@user_prefs}
+ >
+
+
+ <.live_component
+ module={PlausibleWeb.Live.Dashboard.Details.Pages}
+ id="details-pages-breakdown-component"
+ params={@params}
+ site={@site}
+ query={@query}
+ user_prefs={@user_prefs}
+ >
+
+
+ """
+ end
+
+ def handle_event("handle_dashboard_params", %{"url" => url}, socket) do
+ query =
+ url
+ |> URI.parse()
+ |> Map.fetch!(:query)
+
+ params = URI.decode_query(query || "")
+
+ handle_params_internal(params, url, socket)
+ end
+
+ defp parse_filter("f=" <> filter_expr) do
+ case String.split(filter_expr, ",") do
+ ["is", metric, value] when metric in ["page"] ->
+ [:is, "event:#{metric}", [value]]
+
+ ["is", metric, value] ->
+ [:is, "visit:#{metric}", [value]]
+
+ _ ->
+ nil
+ end
+ end
+
+ defp parse_filter(_), do: nil
+end
diff --git a/lib/plausible_web/live/dashboard/details/pages.ex b/lib/plausible_web/live/dashboard/details/pages.ex
new file mode 100644
index 000000000000..1442c41d2502
--- /dev/null
+++ b/lib/plausible_web/live/dashboard/details/pages.ex
@@ -0,0 +1,33 @@
+defmodule PlausibleWeb.Live.Dashboard.Details.Pages do
+ @moduledoc """
+ Component for detailed pages breakdown.
+ """
+
+ use PlausibleWeb, :live_component
+
+ alias PlausibleWeb.Live.Components.PrimaModal
+
+ def update(_assigns, socket) do
+ {:ok, socket}
+ end
+
+ def render(assigns) do
+ ~H"""
+
+
+
+
+ Some content will come here real soon now.
+
+
+
+ """
+ end
+end
diff --git a/lib/plausible_web/live/dashboard/pages.ex b/lib/plausible_web/live/dashboard/pages.ex
new file mode 100644
index 000000000000..300383ba730d
--- /dev/null
+++ b/lib/plausible_web/live/dashboard/pages.ex
@@ -0,0 +1,366 @@
+defmodule PlausibleWeb.Live.Dashboard.Pages do
+ @moduledoc """
+ LV version of pages breakdown.
+ """
+
+ use PlausibleWeb, :live_component
+
+ alias Plausible.Stats
+ alias Plausible.Stats.Filters
+
+ @max_items 9
+ @min_height 380
+ @row_height 32
+ @row_gap_height 4
+ @data_container_height (@row_height + @row_gap_height) * (@max_items - 1) + @row_height
+ @col_min_width 70
+
+ @name_labels %{
+ "pages" => "Page",
+ "entry-pages" => "Entry Page",
+ "exit-pages" => "Exit Page"
+ }
+
+ @metrics %{
+ "pages" => %{
+ visitors: %{
+ width: "w-24",
+ key: :visitors,
+ label: "Visitors",
+ sortable: true,
+ plot: true
+ },
+ conversion_rate: %{
+ width: "w-24",
+ key: :conversion_rate,
+ label: "CR",
+ sortable: true
+ }
+ },
+ "entry-pages" => %{
+ visitors: %{
+ width: "w-24",
+ key: :visitors,
+ label: "Unique Entrances",
+ sortable: true,
+ plot: true
+ },
+ conversion_rate: %{
+ width: "w-24",
+ key: :conversion_rate,
+ label: "CR",
+ sortable: true
+ }
+ },
+ "exit-pages" => %{
+ visitors: %{
+ width: "w-24",
+ key: :visitors,
+ label: "Unique Exits",
+ sortable: true,
+ plot: true
+ },
+ conversion_rate: %{
+ width: "w-24",
+ key: :conversion_rate,
+ label: "CR",
+ sortable: true
+ }
+ }
+ }
+
+ def update(assigns, socket) do
+ active_tab = assigns.user_prefs["page_tab"] || "pages"
+
+ socket =
+ socket
+ |> assign(
+ name_labels: @name_labels,
+ max_items: @max_items,
+ min_height: @min_height,
+ row_height: @row_height,
+ row_gap_height: @row_gap_height,
+ data_container_height: @data_container_height,
+ col_min_width: @col_min_width,
+ site: assigns.site,
+ active_tab: active_tab,
+ query: assigns.query,
+ user_prefs: Map.get(socket.assigns, :user_prefs, assigns.user_prefs)
+ )
+ |> load_metrics()
+
+ {:ok, socket}
+ end
+
+ def render(assigns) do
+ tabs = [
+ %{label: "Top Pages", value: "pages"},
+ %{label: "Entry Pages", value: "entry-pages"},
+ %{label: "Exit Pages", value: "exit-pages"}
+ ]
+
+ assigns = assign(assigns, :tabs, tabs)
+
+ ~H"""
+
+
+
+
+ Top Pages
+
+
+
+ <.tabs>
+ <.tab
+ :for={tab <- @tabs}
+ label={tab.label}
+ value={tab.value}
+ active={@active_tab}
+ myself={@myself}
+ />
+
+
+
+
+
+
+
+
+
+
{@name_labels[@active_tab]}
+
+ {metric.label}
+
+
+
+
+
+
+
+
+
+
+ <.dashboard_link
+ id={"filter-link-#{idx}"}
+ href={"/#{@site.domain}?f=is,page,#{item.name}"}
+ >
+ {trim_name(item.name, @col_min_width)}
+
+
+
+
+
+
+ {item[metric.key]}
+
+
+
+
+
+
+
+
+
+
+ """
+ end
+
+ def handle_event("set-tab", %{"tab" => tab}, socket) do
+ if tab != socket.assigns.active_tab do
+ socket =
+ socket
+ |> assign(:active_tab, tab)
+ |> push_event("update_local_storage", %{
+ key: "pageTab__#{socket.assigns.site.domain}",
+ value: tab
+ })
+ |> load_metrics()
+
+ {:noreply, socket}
+ else
+ {:noreply, socket}
+ end
+ end
+
+ def tabs(assigns) do
+ ~H"""
+
+ {render_slot(@inner_block)}
+
+ """
+ end
+
+ def tab(assigns) do
+ ~H"""
+
+ """
+ end
+
+ defp load_metrics(socket) do
+ %{results: pages, meta: meta, metrics: metrics} =
+ metrics_for_tab(socket.assigns.active_tab, socket.assigns.site, socket.assigns.query)
+
+ assign(
+ socket,
+ metrics: Enum.map(metrics, &Map.fetch!(@metrics[socket.assigns.active_tab], &1)),
+ results: Enum.take(pages, @max_items),
+ meta: Map.merge(meta, Stats.Breakdown.formatted_date_ranges(socket.assigns.query)),
+ skip_imported_reason: meta[:imports_skip_reason]
+ )
+ end
+
+ defp metrics_for_tab("pages", site, query) do
+ query = struct!(query, dimensions: ["event:page"])
+
+ metrics = breakdown_metrics(query)
+ pagination = parse_pagination(%{})
+
+ %{results: results, meta: meta} = Stats.breakdown(site, query, metrics, pagination)
+
+ pages =
+ results
+ |> transform_keys(%{page: :name})
+
+ %{results: pages, meta: meta, metrics: metrics}
+ end
+
+ defp metrics_for_tab("entry-pages", site, query) do
+ query = struct!(query, dimensions: ["visit:entry_page"])
+
+ metrics = breakdown_metrics(query)
+ pagination = parse_pagination(%{})
+
+ %{results: results, meta: meta} = Stats.breakdown(site, query, metrics, pagination)
+
+ pages =
+ results
+ |> transform_keys(%{entry_page: :name})
+
+ %{results: pages, meta: meta, metrics: metrics}
+ end
+
+ defp metrics_for_tab("exit-pages", site, query) do
+ query = struct!(query, dimensions: ["visit:exit_page"])
+
+ metrics = breakdown_metrics(query)
+ pagination = parse_pagination(%{})
+
+ %{results: results, meta: meta} = Stats.breakdown(site, query, metrics, pagination)
+
+ pages =
+ results
+ |> transform_keys(%{exit_page: :name})
+
+ %{results: pages, meta: meta, metrics: metrics}
+ end
+
+ defp trim_name(name, max_length) do
+ if String.length(name) <= max_length do
+ name
+ else
+ left_length = div(max_length, 2)
+ right_length = max_length - left_length
+
+ left_side = String.slice(name, 0..left_length)
+ right_side = String.slice(name, -right_length..-1)
+
+ left_side <> "..." <> right_side
+ end
+ end
+
+ defp parse_pagination(params) do
+ limit = to_int(params["limit"], 9)
+ page = to_int(params["page"], 1)
+ {limit, page}
+ end
+
+ defp breakdown_metrics(query) do
+ if toplevel_goal_filter?(query) do
+ [:visitors, :conversion_rate]
+ else
+ [:visitors]
+ end
+ end
+
+ defp transform_keys(result, keys_to_replace) when is_map(result) do
+ for {key, val} <- result, do: {Map.get(keys_to_replace, key, key), val}, into: %{}
+ end
+
+ defp transform_keys(results, keys_to_replace) when is_list(results) do
+ Enum.map(results, &transform_keys(&1, keys_to_replace))
+ end
+
+ defp to_int(string, default) when is_binary(string) do
+ case Integer.parse(string) do
+ {i, ""} when is_integer(i) ->
+ i
+
+ _ ->
+ default
+ end
+ end
+
+ defp to_int(_, default), do: default
+
+ defp toplevel_goal_filter?(query) do
+ Filters.filtering_on_dimension?(query, "event:goal",
+ max_depth: 0,
+ behavioral_filters: :ignore
+ )
+ end
+end
diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex
index a5046072e320..420f03ae7b7f 100644
--- a/lib/plausible_web/router.ex
+++ b/lib/plausible_web/router.ex
@@ -706,7 +706,10 @@ defmodule PlausibleWeb.Router do
put "/:domain/settings", SiteController, :update_settings
get "/:domain/export", StatsController, :csv_export
- get "/:domain/*path", StatsController, :stats
+
+ scope assigns: %{live_socket_disable_push_state: true} do
+ get "/:domain/*path", StatsController, :stats
+ end
end
end
end
diff --git a/lib/plausible_web/templates/layout/app.html.heex b/lib/plausible_web/templates/layout/app.html.heex
index dc217ba23fa9..99e7c11341ed 100644
--- a/lib/plausible_web/templates/layout/app.html.heex
+++ b/lib/plausible_web/templates/layout/app.html.heex
@@ -12,6 +12,12 @@
<% end %>
+ <%= if assigns[:live_socket_disable_push_state] do %>
+
+ <% end %>
+ <%= if assigns[:site] do %>
+
+ <% end %>
diff --git a/lib/plausible_web/templates/stats/stats.html.heex b/lib/plausible_web/templates/stats/stats.html.heex
index d473e6b5be41..4ca5a170ad3b 100644
--- a/lib/plausible_web/templates/stats/stats.html.heex
+++ b/lib/plausible_web/templates/stats/stats.html.heex
@@ -54,6 +54,15 @@
data-team-identifier={@team_identifier}
>
+ {live_render(@conn, PlausibleWeb.Live.Dashboard,
+ id: "live-dashboard-lv",
+ session:
+ Map.merge(@params, %{
+ "site_id" => @site.id,
+ "domain" => @site.domain,
+ "url" => Plug.Conn.request_url(@conn)
+ })
+ )}
<%= if !@conn.assigns[:current_user] && @conn.assigns[:demo] do %>
diff --git a/mix.exs b/mix.exs
index 6b85b8ff9406..c7c8e584ab80 100644
--- a/mix.exs
+++ b/mix.exs
@@ -111,7 +111,10 @@ defmodule Plausible.MixProject do
{:phoenix_html, "~> 4.1"},
{:phoenix_live_reload, "~> 1.2", only: [:dev, :ce_dev]},
{:phoenix_pubsub, "~> 2.0"},
- {:phoenix_live_view, "~> 1.1"},
+ {:phoenix_live_view,
+ git: "https://github.com/plausible/phoenix_live_view",
+ branch: "disable-push-state",
+ override: true},
{:php_serializer, "~> 2.0"},
{:plug, "~> 1.13", override: true},
{:prima, "~> 0.2.1"},
diff --git a/mix.lock b/mix.lock
index c583d621ce4d..8a686c68ecf8 100644
--- a/mix.lock
+++ b/mix.lock
@@ -118,13 +118,13 @@
"parent": {:hex, :parent, "0.12.1", "495c4386f06de0df492e0a7a7199c10323a55e9e933b27222060dd86dccd6d62", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2ab589ef1f37bfcedbfb5ecfbab93354972fb7391201b8907a866dadd20b39d1"},
"parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
"peep": {:hex, :peep, "3.4.2", "49d4ca116d994779351959dfee971fb2c7d6506f1821374f3cc4fd39a3d3fadb", [:mix], [{:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:plug, "~> 1.16", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "cae224b09e224bf5584f5375108becf71e2288572a099a122e66735289cd33f4"},
- "phoenix": {:hex, :phoenix, "1.8.1", "865473a60a979551a4879db79fbfb4503e41cd809e77c85af79716578b6a456d", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "84d77d2b2e77c3c7e7527099bd01ef5c8560cd149c036d6b3a40745f11cd2fb2"},
+ "phoenix": {:hex, :phoenix, "1.8.2", "75aba5b90081d88a54f2fc6a26453d4e76762ab095ff89be5a3e7cb28bff9300", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "19ea65b4064f17b1ab0515595e4d0ea65742ab068259608d5d7b139a73f47611"},
"phoenix_bakery": {:hex, :phoenix_bakery, "0.1.2", "ca57673caea1a98f1cc763f94032796a015774d27eaa3ce5feef172195470452", [:mix], [{:brotli, "~> 0.3.0", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "45cc8cecc5c3002b922447c16389761718c07c360432328b04680034e893ea5b"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.6.3", "f686701b0499a07f2e3b122d84d52ff8a31f5def386e03706c916f6feddf69ef", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "909502956916a657a197f94cc1206d9a65247538de8a5e186f7537c895d95764"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
"phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"},
- "phoenix_live_view": {:hex, :phoenix_live_view, "1.1.17", "1d782b5901cf13b137c6d8c56542ff6cb618359b2adca7e185b21df728fa0c6c", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fa82307dd9305657a8236d6b48e60ef2e8d9f742ee7ed832de4b8bcb7e0e5ed2"},
+ "phoenix_live_view": {:git, "https://github.com/plausible/phoenix_live_view", "492a8aeebe450c9c73be50f2c5cf6aea1ca7006f", [branch: "disable-push-state"]},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_storybook": {:hex, :phoenix_storybook, "0.9.3", "4f94e731d4c40d4dd7d1eddf7d5c6914366da7d78552dc565b222e4036d0d76f", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:jason, "~> 1.3", [hex: :jason, repo: "hexpm", optional: true]}, {:makeup_eex, "~> 2.0.2", [hex: :makeup_eex, repo: "hexpm", optional: false]}, {:makeup_html, "~> 0.2.0", [hex: :makeup_html, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.8.1", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "4c8658b756fd8238f7e8e4343a0f12bdb91d4eba592b1c4e8118b37b6fd43e4b"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},