Integrate consolidated view UI (#5798)

* Create static consolidated view UI on `/sites` page

- Improve existing site card layout
- Add static UI for the consolidated view
- Add dismissable upgrade card UI
- Extract favicon fetching logic to function
- Configure configurable fallback icon per route
- Add `/favicon/sources_globe/` route with different icon than `/favicon/sources/` to use on `/sites` page

* Improve the mobile view of the `/sites` page

* Minor query interface UX extension (#5713)

* Minor query interface extension

* !fixup

* !fixup

* Initial implementation of consolidated views on /sites

* Improve loading state

* no need to handle nil in main interface

* Juggle `can_manage_consolidated_view?`

* Require team setup in order to enable consolidated view

* nil catcher

* Fixup test fixture

* Don't show Consolidated View tab in CS if team is not setup

* Reorganize + test

* Remove comment

* Only show consolidated views to superadmins for now

* Remove temporary sleep

* CE unused bindings

* Clean up seeds

* EE

* Fixup test

* Test non-superadmin scenario

* Add a test guarding parity between small plots (consolidated vs individual)

* Move private function so CE won't complain

* See if the graphs are now similar at least :)

* sort

* Map keys are unsorted

* Ensure engagement events aren't counted as visitors on smol graphs

* just try and revert

* Revert "just try and revert"

This reverts commit 7584f59816.

* Simplify globe icon handling

* Remove unnecessary @rest

* Split tests into more focused cases

* Address jumpiness on furious refresh cycle

* Revert "Address jumpiness on furious refresh cycle"

This reverts commit 5c03b36918.

* Another attempt at jumpiness

* Enforce less noticeable lag applying the diff from loading to loaded

* Reduce flashing on stats load

---------

Co-authored-by: Sanne de Vries <sannedv@protonmail.com>
This commit is contained in:
Adam Rutkowski 2025-10-22 12:58:26 +02:00 committed by GitHub
parent f01ce1b1b6
commit bce08903e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 910 additions and 184 deletions

View File

@ -91,7 +91,7 @@
}
.button {
@apply inline-flex justify-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md leading-5 transition hover:bg-indigo-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500;
@apply inline-flex justify-center px-3.5 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md leading-5 transition hover:bg-indigo-700 focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500;
}
.button[disabled] {
@ -99,11 +99,7 @@
}
.button-outline {
@apply text-indigo-600 bg-transparent border border-indigo-600;
}
.button-outline:hover {
@apply text-white;
@apply text-indigo-600 bg-transparent border border-gray-300 dark:border-gray-600 dark:text-gray-100 hover:bg-transparent hover:text-indigo-700 dark:hover:text-white hover:border-gray-400/70 dark:hover:border-gray-500 transition-all duration-150;
}
.button-sm {

View File

@ -12,7 +12,7 @@ defmodule Plausible.ConsolidatedView do
alias Plausible.Teams
alias Plausible.Teams.Team
alias Plausible.{Repo, Site}
alias Plausible.{Repo, Site, Auth.User}
import Ecto.Query
@ -41,7 +41,7 @@ defmodule Plausible.ConsolidatedView do
from(s in q, where: s.consolidated == true)
end
@spec enable(Team.t()) :: {:ok, Site.t()} | {:error, :no_sites}
@spec enable(Team.t()) :: {:ok, Site.t()} | {:error, :no_sites | :team_not_setup}
def enable(%Team{} = team) do
with :ok <- ensure_eligible(team), do: do_enable(team)
end
@ -99,6 +99,17 @@ defmodule Plausible.ConsolidatedView do
end
end
@spec can_manage?(User.t(), Team.t()) :: boolean()
def can_manage?(user, team) do
case Plausible.Teams.Memberships.team_role(team, user) do
{:ok, role} when role not in [:viewer, :guest] ->
true
_ ->
false
end
end
defp bump_updated_at(struct_or_changeset) do
Ecto.Changeset.change(struct_or_changeset, updated_at: NaiveDateTime.utc_now(:second))
end
@ -128,7 +139,11 @@ defmodule Plausible.ConsolidatedView do
# TODO: Only active trials and business subscriptions should be eligible.
# This function should also call a new underlying feature module.
defp ensure_eligible(%Team{} = team) do
if has_sites_to_consolidate?(team), do: :ok, else: {:error, :no_sites}
cond do
not Teams.setup?(team) -> {:error, :team_not_setup}
not has_sites_to_consolidate?(team) -> {:error, :no_sites}
true -> :ok
end
end
defp native_stats_start_at(%Team{} = team) do

View File

@ -0,0 +1,138 @@
defmodule Plausible.Stats.ConsolidatedView do
alias Plausible.{Site, Stats}
require Logger
@spec overview_24h(Site.t(), NaiveDateTime.t()) :: map()
def overview_24h(%Site{consolidated: true} = view, now \\ NaiveDateTime.utc_now()) do
stats = query_24h_stats(view, now)
intervals = query_24h_intervals(view, now)
Map.merge(stats, intervals)
end
@spec safe_overview_24h(Site.t()) ::
{:ok, map()} | {:error, :inaccessible} | {:error, :not_found}
def safe_overview_24h(nil), do: {:error, :not_found}
def safe_overview_24h(%Site{} = view) do
try do
{:ok, overview_24h(view)}
catch
kind, value ->
Logger.error(
"Could not render 24h consolidated view stats: #{inspect(kind)} #{inspect(value)}"
)
{:error, :inaccessible}
end
end
defp empty_24h_intervals(now) do
first = NaiveDateTime.add(now, -24, :hour)
{:ok, time} = Time.new(first.hour, 0, 0)
first = NaiveDateTime.new!(NaiveDateTime.to_date(first), time)
for offset <- 0..24, into: %{} do
{NaiveDateTime.add(first, offset, :hour), 0}
end
end
defp query_24h_stats(view, now) do
from =
NaiveDateTime.shift(now, hour: -24)
|> DateTime.from_naive!("Etc/UTC")
|> DateTime.to_iso8601()
to = now |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_iso8601()
c_from =
NaiveDateTime.shift(now, hour: -48)
|> DateTime.from_naive!("Etc/UTC")
|> DateTime.to_iso8601()
c_to =
NaiveDateTime.shift(now, hour: -24)
|> DateTime.from_naive!("Etc/UTC")
|> DateTime.to_iso8601()
stats_query =
Stats.Query.build!(view, :internal, %{
"site_id" => view.domain,
"metrics" => ["visitors", "visits", "pageviews", "views_per_visit"],
"include" => %{"comparisons" => %{"mode" => "custom", "date_range" => [c_from, c_to]}},
"date_range" => [
from,
to
]
})
%Stats.QueryResult{
results: [
%{
metrics: [visitors, visits, pageviews, views_per_visit],
comparison: %{
change: [visitors_change, visits_change, pageviews_change, views_per_visit_change]
}
}
]
} = Stats.query(view, stats_query)
%{
visitors: visitors,
visits: visits,
pageviews: pageviews,
views_per_visit: views_per_visit,
visitors_change: visitors_change,
visits_change: visits_change,
pageviews_change: pageviews_change,
views_per_visit_change: views_per_visit_change
}
end
defp query_24h_intervals(view, now) do
graph_query =
Stats.Query.build!(
view,
:internal,
%{
"site_id" => view.domain,
"metrics" => ["visitors"],
"date_range" => [
NaiveDateTime.shift(now, hour: -24)
|> DateTime.from_naive!("Etc/UTC")
|> DateTime.to_iso8601(),
now
|> DateTime.from_naive!("Etc/UTC")
|> DateTime.to_iso8601()
],
"dimensions" => ["time:hour"],
"order_by" => [["time:hour", "asc"]]
}
)
%Stats.QueryResult{results: results} = Stats.query(view, graph_query)
placeholder =
empty_24h_intervals(now)
results =
Enum.map(
results,
fn %{metrics: [visitors], dimensions: [timestamp]} ->
{NaiveDateTime.from_iso8601!(timestamp), visitors}
end
)
|> Enum.into(%{})
graph_data =
placeholder
|> Enum.reduce([], fn {interval, 0}, acc ->
[{interval, results[interval] || 0} | acc]
end)
|> Enum.sort_by(fn {interval, _} -> interval end, NaiveDateTime)
%{
intervals: Enum.map(graph_data, fn {k, v} -> %{interval: k, visitors: v} end)
}
end
end

View File

@ -140,7 +140,7 @@ defmodule PlausibleWeb.Live.CustomerSupport.Team do
<.tab to="sites" tab={@tab}>
Sites ({number_format(@usage.sites)}/{number_format(@limits.sites)})
</.tab>
<.tab to="consolidated_views" tab={@tab}>
<.tab :if={Plausible.Teams.setup?(@team)} to="consolidated_views" tab={@tab}>
Consolidated Views
</.tab>
<.tab :if={has_sso_integration?(@team)} to="sso" tab={@tab}>SSO</.tab>

View File

@ -349,16 +349,15 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
steps
)
{:ok, query} =
Plausible.Stats.Query.build(
query =
Plausible.Stats.Query.build!(
site,
:internal,
%{
"site_id" => site.domain,
"date_range" => "month",
"metrics" => ["pageviews"]
},
%{}
}
)
{:ok, {definition, query}}

View File

@ -7,6 +7,7 @@ defmodule Plausible.Stats.Clickhouse do
import Ecto.Query, only: [from: 2, dynamic: 1, dynamic: 2]
alias Plausible.Timezones
alias Plausible.Stats
@spec pageview_start_date_local(Plausible.Site.t()) :: Date.t() | nil
def pageview_start_date_local(site) do
@ -76,11 +77,11 @@ defmodule Plausible.Stats.Clickhouse do
def usage_breakdown([], _date_range), do: {0, 0}
def current_visitors(site) do
Plausible.Stats.current_visitors(site)
Stats.current_visitors(site)
end
def current_visitors_12h(site) do
Plausible.Stats.current_visitors(site, Duration.new!(hour: -12))
Stats.current_visitors(site, Duration.new!(hour: -12))
end
def has_pageviews?(site) do
@ -211,12 +212,13 @@ defmodule Plausible.Stats.Clickhouse do
end)
from e in "events_v2",
where: e.name != "engagement",
where: e.site_id in ^Enum.map(sites, & &1.id),
where: ^cutoff_times_condition
end
defp empty_24h_intervals(now) do
first = NaiveDateTime.add(now, -23, :hour)
first = NaiveDateTime.add(now, -24, :hour)
{:ok, time} = Time.new(first.hour, 0, 0)
first = NaiveDateTime.new!(NaiveDateTime.to_date(first), time)

View File

@ -40,8 +40,8 @@ defmodule Plausible.Stats.GoalSuggestions do
to_date = Date.utc_today()
from_date = Date.shift(to_date, month: -6)
{:ok, query} =
Plausible.Stats.Query.build(
query =
Plausible.Stats.Query.build!(
site,
:internal,
%{
@ -49,8 +49,7 @@ defmodule Plausible.Stats.GoalSuggestions do
"date_range" => [Date.to_iso8601(from_date), Date.to_iso8601(to_date)],
"metrics" => ["pageviews"],
"include" => %{"imports" => true}
},
%{}
}
)
native_q =

View File

@ -38,7 +38,12 @@ defmodule Plausible.Stats.Query do
@type t :: %__MODULE__{}
def build(site, schema_type, params, debug_metadata) do
def build(
%Plausible.Site{domain: domain} = site,
schema_type,
%{"site_id" => domain} = params,
debug_metadata \\ %{}
) do
with {:ok, query_data} <- Filters.QueryParser.parse(site, schema_type, params) do
query =
%__MODULE__{
@ -59,6 +64,13 @@ defmodule Plausible.Stats.Query do
end
end
def build!(site, schema_type, params, debug_metadata \\ %{}) do
case build(site, schema_type, params, debug_metadata) do
{:ok, query} -> query
{:error, reason} -> raise "Failed to build query: #{inspect(reason)}"
end
end
@doc """
Builds query from old-style stats APIv1 params. New code should use `Query.build`.
"""

View File

@ -47,8 +47,11 @@ defmodule Plausible.Stats.SQL.WhereBuilder do
defp filter_site_id(query) do
case query.consolidated_site_ids do
nil -> dynamic([x], x.site_id == ^query.site_id)
[_ | _] = ids -> dynamic([x], fragment("? in ?", x.site_id, ^ids))
nil ->
dynamic([x], x.site_id == ^query.site_id)
[_ | _] = ids ->
dynamic([x], fragment("? in ?", x.site_id, ^ids))
end
end

View File

@ -38,7 +38,7 @@ defmodule PlausibleWeb.Live.Components.Visitors do
|> assign(:id, Ecto.UUID.generate())
~H"""
<svg viewBox={"0 -1 #{(@points_len - 1) * @tick} #{@height + 3}"} class="chart w-full mb-2">
<svg viewBox={"0 -1 #{(@points_len - 1) * @tick} #{@height + 3}"} class="w-full">
<defs>
<clipPath id={"gradient-cut-off-#{@id}"}>
<polyline points={@clip_points} />
@ -52,7 +52,7 @@ defmodule PlausibleWeb.Live.Components.Visitors do
fill="url(#chart-gradient-cut-off)"
clip-path={"url(#gradient-cut-off-#{@id})"}
/>
<polyline fill="none" stroke="rgba(101,116,205)" stroke-width="2.6" points={@points} />
<polyline fill="none" stroke="currentColor" stroke-width="2.6" points={@points} />
</svg>
"""
end
@ -62,8 +62,8 @@ defmodule PlausibleWeb.Live.Components.Visitors do
<svg width="0" height="0">
<defs class="text-white dark:text-indigo-800">
<linearGradient id="chart-gradient-cut-off" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="rgba(101,116,205,0.2)" />
<stop offset="100%" stop-color="rgba(101,116,205,0)" />
<stop offset="0%" stop-color="rgba(97,95,255,0.16)" />
<stop offset="100%" stop-color="rgba(97,95,255,0)" />
</linearGradient>
</defs>
</svg>

View File

@ -559,8 +559,8 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do
end
def suggest_page_paths(input, site) do
{:ok, query} =
Plausible.Stats.Query.build(
query =
Plausible.Stats.Query.build!(
site,
:internal,
%{
@ -568,8 +568,7 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do
"date_range" => "all",
"metrics" => ["pageviews"],
"include" => %{"imports" => true}
},
%{}
}
)
site

View File

@ -227,16 +227,15 @@ defmodule PlausibleWeb.Live.Shields.HostnameRules do
end
def suggest_hostnames(input, _options, site) do
{:ok, query} =
Plausible.Stats.Query.build(
query =
Plausible.Stats.Query.build!(
site,
:internal,
%{
"site_id" => site.domain,
"date_range" => "all",
"metrics" => ["pageviews"]
},
%{}
}
)
site

View File

@ -220,8 +220,8 @@ defmodule PlausibleWeb.Live.Shields.PageRules do
end
def suggest_page_paths(input, _options, site, page_rules) do
{:ok, query} =
Plausible.Stats.Query.build(
query =
Plausible.Stats.Query.build!(
site,
:internal,
%{
@ -229,8 +229,7 @@ defmodule PlausibleWeb.Live.Shields.PageRules do
"date_range" => "all",
"metrics" => ["pageviews"],
"filters" => [["is_not", "event:page", Enum.map(page_rules, & &1.page_path)]]
},
%{}
}
)
site

View File

@ -5,12 +5,16 @@ defmodule PlausibleWeb.Live.Sites do
use PlausibleWeb, :live_view
import PlausibleWeb.Live.Components.Pagination
import PlausibleWeb.StatsView, only: [large_number_format: 1]
require Logger
alias Plausible.Sites
alias Plausible.Teams
def mount(params, _session, socket) do
team = socket.assigns.current_team
user = socket.assigns.current_user
uri =
("/sites?" <> URI.encode_query(Map.take(params, ["filter_text"])))
|> URI.new!()
@ -20,9 +24,11 @@ defmodule PlausibleWeb.Live.Sites do
|> assign(:uri, uri)
|> assign(
:team_invitations,
Teams.Invitations.all(socket.assigns.current_user)
Teams.Invitations.all(user)
)
|> assign(:hourly_stats, %{})
|> assign(:filter_text, String.trim(params["filter_text"] || ""))
|> assign(init_consolidated_view_assigns(user, team))
{:ok, socket}
end
@ -61,7 +67,7 @@ defmodule PlausibleWeb.Live.Sites do
@needs_to_upgrade == {:needs_to_upgrade, :no_active_trial_or_subscription}
} />
<div class="group mt-6 pb-5 border-b border-gray-200 dark:border-gray-500 flex items-center justify-between">
<div class="group mt-6 pb-5 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h2 class="text-2xl font-bold leading-7 text-gray-900 dark:text-gray-100 sm:text-3xl sm:leading-9 sm:truncate shrink-0">
{Teams.name(@current_team)}
<.unstyled_link
@ -76,14 +82,14 @@ defmodule PlausibleWeb.Live.Sites do
<PlausibleWeb.Team.Notice.team_invitations team_invitations={@team_invitations} />
<div class="border-t border-gray-200 pt-4 sm:flex sm:items-center sm:justify-between">
<div class="pt-4 sm:flex sm:items-center sm:justify-between">
<.search_form :if={@has_sites?} filter_text={@filter_text} uri={@uri} />
<p :if={not @has_sites?} class="dark:text-gray-100">
You don't have any sites yet.
</p>
<div class="mt-4 flex sm:ml-4 sm:mt-0">
<a href={"/sites/new?flow=#{PlausibleWeb.Flows.provisioning()}"} class="button">
+ Add Website
+ Add website
</a>
</div>
</div>
@ -107,17 +113,26 @@ defmodule PlausibleWeb.Live.Sites do
<div :if={@has_sites?}>
<ul class="my-6 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<!-- Insert upgrade_card here -->
<.consolidated_view_card
:if={@consolidated_view && Plausible.Auth.is_super_admin?(@current_user)}
can_manage_consolidated_view?={@can_manage_consolidated_view?}
consolidated_view={@consolidated_view}
consolidated_stats={@consolidated_stats}
current_user={@current_user}
current_team={@current_team}
/>
<%= for site <- @sites.entries do %>
<.site
:if={site.entry_type in ["pinned_site", "site"]}
site={site}
hourly_stats={@hourly_stats[site.domain]}
hourly_stats={Map.get(@hourly_stats, site.domain, :loading)}
/>
<.invitation
:if={site.entry_type == "invitation"}
site={site}
invitation={hd(site.invitations)}
hourly_stats={@hourly_stats[site.domain]}
hourly_stats={Map.get(@hourly_stats, site.domain, :loading)}
/>
<% end %>
</ul>
@ -174,6 +189,133 @@ defmodule PlausibleWeb.Live.Sites do
"""
end
def upgrade_card(assigns) do
~H"""
<li class="relative col-span-1 flex flex-col justify-between bg-white p-6 dark:bg-gray-800 rounded-md shadow-lg dark:shadow-xl">
<div class="flex flex-col">
<p class="text-sm text-gray-600 dark:text-gray-400">
Introducing
</p>
<h3 class="text-[1.35rem] font-bold text-gray-900 leading-tighter dark:text-gray-100">
consolidated view
</h3>
</div>
<p class="text-gray-900 dark:text-gray-100 leading-tighter mb-2.5">
See stats for all your sites in one single dashboard.
</p>
<div class="flex gap-x-2">
<a href="#" class="button">
Upgrade
</a>
<a href="#" class="button button-outline">
Learn more
</a>
</div>
<Heroicons.x_mark class="absolute top-6 right-6 size-5 text-gray-400 transition-colors duration-150 cursor-pointer dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" />
</li>
"""
end
def consolidated_view_card(assigns) do
~H"""
<li
data-test-id="consolidated-view-card"
class="relative row-span-2 bg-white p-6 dark:bg-gray-800 rounded-md shadow-sm cursor-pointer hover:shadow-lg transition-shadow duration-150"
>
<.unstyled_link
href={"/#{URI.encode_www_form(@consolidated_view.domain)}"}
class="flex flex-col justify-between gap-6 h-full"
>
<div class="flex flex-col flex-1 justify-between gap-y-5">
<div class="flex flex-col gap-y-2 mb-auto">
<span class="size-8 sm:size-10 bg-indigo-600 text-white p-1.5 sm:p-2 rounded-lg sm:rounded-xl">
<.globe_icon />
</span>
<h3 class="text-gray-900 font-medium text-md sm:text-lg leading-tight dark:text-gray-100">
All sites
</h3>
</div>
<span
:if={is_map(@consolidated_stats)}
class="h-[54px] text-indigo-500 my-auto"
data-test-id="consolidated-view-chart-loaded"
>
<PlausibleWeb.Live.Components.Visitors.chart
intervals={@consolidated_stats.intervals}
height={80}
/>
</span>
</div>
<div
:if={is_map(@consolidated_stats)}
data-test-id="consolidated-view-stats-loaded"
class="flex flex-col flex-1 justify-between gap-y-2.5 sm:gap-y-5"
>
<div class="flex flex-col sm:flex-row justify-between gap-2.5 sm:gap-2 flex-1 w-full">
<.consolidated_view_stat
value={large_number_format(@consolidated_stats.visitors)}
label="Unique visitors"
change={@consolidated_stats.visitors_change}
/>
<.consolidated_view_stat
value={large_number_format(@consolidated_stats.visits)}
label="Total visits"
change={@consolidated_stats.visits_change}
/>
</div>
<div class="flex flex-col sm:flex-row justify-between gap-2.5 sm:gap-2 flex-1 w-full">
<.consolidated_view_stat
value={large_number_format(@consolidated_stats.pageviews)}
label="Total pageviews"
change={@consolidated_stats.pageviews_change}
/>
<.consolidated_view_stat
value={@consolidated_stats.views_per_visit}
label="Views per visit"
change={1}
/>
</div>
</div>
<div
:if={@consolidated_stats == :loading}
class="flex flex-col gap-y-2 min-h-[254px] h-full text-center animate-pulse"
data-test-id="consolidated-viw-stats-loading"
>
<div class="flex-2 dark:bg-gray-700 bg-gray-100 rounded-md"></div>
<div class="flex-1 flex flex-col gap-y-2">
<div class="w-full h-full dark:bg-gray-700 bg-gray-100 rounded-md"></div>
<div class="w-full h-full dark:bg-gray-700 bg-gray-100 rounded-md"></div>
</div>
</div>
</.unstyled_link>
<div :if={@can_manage_consolidated_view?} class="absolute right-1 top-3.5">
<.ellipsis_menu site={@consolidated_view} can_manage?={true} />
</div>
</li>
"""
end
attr(:value, :string, required: true)
attr(:label, :string, required: true)
attr(:change, :integer, required: true)
def consolidated_view_stat(assigns) do
~H"""
<div class="flex flex-col flex-1 sm:gap-y-1.5">
<p class="text-sm text-gray-600 dark:text-gray-400">
{@label}
</p>
<div class="flex w-full justify-between items-baseline sm:flex-col sm:justify-start sm:items-start">
<p class="text-lg sm:text-xl font-bold text-gray-900 dark:text-gray-100">
{@value}
</p>
<.percentage_change change={@change} />
</div>
</div>
"""
end
attr(:site, Plausible.Site, required: true)
attr(:invitation, :map, required: true)
attr(:hourly_stats, :map, required: true)
@ -181,25 +323,20 @@ defmodule PlausibleWeb.Live.Sites do
def invitation(assigns) do
~H"""
<li
class="group cursor-pointer"
class="group relative cursor-pointer"
id={"site-card-#{hash_domain(@site.domain)}"}
data-domain={@site.domain}
x-on:click={"invitationOpen = true; selectedInvitation = invitations['#{@invitation.invitation_id}']"}
>
<div class="col-span-1 bg-white dark:bg-gray-800 rounded-md shadow-sm p-4 group-hover:shadow-lg cursor-pointer transition duration-100">
<div class="w-full flex items-center justify-between space-x-4">
<img
src={"/favicon/sources/#{@site.domain}"}
onerror="this.onerror=null; this.src='/favicon/sources/placeholder';"
class="size-[1.15rem] shrink-0"
/>
<div class="flex-1 truncate -mt-px">
<h3 class="text-gray-900 font-medium text-lg truncate dark:text-gray-100">
<div class="col-span-1 flex flex-col gap-y-5 bg-white dark:bg-gray-800 rounded-md shadow-sm p-6 group-hover:shadow-lg cursor-pointer transition duration-100">
<div class="w-full flex items-center justify-between gap-x-2.5">
<.favicon domain={@site.domain} />
<div class="flex-1 w-full truncate">
<h3 class="text-gray-900 font-medium text-md sm:text-lg leading-[22px] truncate dark:text-gray-100">
{@site.domain}
</h3>
</div>
<span class="inline-flex items-center px-2 py-0.5 rounded-sm text-xs font-medium bg-green-100 text-green-800">
<span class="inline-flex items-center -my-1 px-2 py-1 rounded-sm bg-green-100 text-green-800 text-xs font-medium leading-normal dark:bg-green-900/40 dark:text-green-400">
Pending invitation
</span>
</div>
@ -232,12 +369,12 @@ defmodule PlausibleWeb.Live.Sites do
}
>
<.unstyled_link href={"/#{URI.encode_www_form(@site.domain)}"}>
<div class="col-span-1 bg-white dark:bg-gray-800 rounded-md shadow-sm p-4 group-hover:shadow-lg cursor-pointer transition duration-100">
<div class="w-full flex items-center justify-between space-x-4">
<div class="col-span-1 flex flex-col gap-y-5 bg-white dark:bg-gray-800 rounded-md shadow-sm p-6 group-hover:shadow-lg cursor-pointer transition duration-100">
<div class="w-full flex items-center justify-between gap-x-2.5">
<.favicon domain={@site.domain} />
<div class="flex-1 -mt-px w-full">
<div class="flex-1 w-full">
<h3
class="text-gray-900 font-medium text-lg truncate dark:text-gray-100"
class="text-gray-900 font-medium text-md sm:text-lg leading-[22px] truncate dark:text-gray-100"
style="width: calc(100% - 4rem)"
>
{@site.domain}
@ -248,8 +385,8 @@ defmodule PlausibleWeb.Live.Sites do
</div>
</.unstyled_link>
<div class="absolute right-0 top-2">
<.ellipsis_menu site={@site} />
<div class="absolute right-1 top-3.5">
<.ellipsis_menu site={@site} can_manage?={List.first(@site.memberships).role != :viewer} />
</div>
</li>
"""
@ -264,7 +401,7 @@ defmodule PlausibleWeb.Live.Sites do
<:menu class="!mt-0 mr-4 min-w-40">
<!-- adjust position because click area is much bigger than icon. Default positioning from click area looks weird -->
<.dropdown_item
:if={List.first(@site.memberships).role != :viewer}
:if={@can_manage?}
href={"/#{URI.encode_www_form(@site.domain)}/settings/general"}
class="group/item !flex items-center gap-x-2"
>
@ -273,6 +410,7 @@ defmodule PlausibleWeb.Live.Sites do
</.dropdown_item>
<.dropdown_item
:if={Sites.regular?(@site)}
href="#"
x-on:click.prevent
phx-click={
@ -300,7 +438,7 @@ defmodule PlausibleWeb.Live.Sites do
<span :if={!@site.pinned_at}>Pin site</span>
</.dropdown_item>
<.dropdown_item
:if={Application.get_env(:plausible, :environment) == "dev"}
:if={Application.get_env(:plausible, :environment) == "dev" and Sites.regular?(@site)}
href={Routes.site_path(PlausibleWeb.Endpoint, :delete_site, @site.domain)}
method="delete"
class="group/item !flex items-center gap-x-2"
@ -337,30 +475,34 @@ defmodule PlausibleWeb.Live.Sites do
def site_stats(assigns) do
~H"""
<div class="md:h-[68px] sm:h-[58px] h-20 pl-8 pr-8 pt-2">
<div :if={@hourly_stats == :loading} class="text-center animate-pulse">
<div class="md:h-[34px] sm:h-[30px] h-11 dark:bg-gray-700 bg-gray-100 rounded-md"></div>
<div class="md:h-[26px] sm:h-[18px] h-6 mt-1 dark:bg-gray-700 bg-gray-100 rounded-md"></div>
</div>
<div
:if={is_map(@hourly_stats)}
class="hidden h-50px"
phx-mounted={JS.show(transition: {"ease-in duration-500", "opacity-0", "opacity-100"})}
>
<span class="text-gray-600 dark:text-gray-400 text-sm truncate">
<PlausibleWeb.Live.Components.Visitors.chart intervals={@hourly_stats.intervals} />
<div class="flex justify-between items-center">
<p>
<span class="text-gray-800 dark:text-gray-200">
<b>{PlausibleWeb.StatsView.large_number_format(@hourly_stats.visitors)}</b>
visitor<span :if={@hourly_stats.visitors != 1}>s</span> in last 24h
</span>
</p>
<.percentage_change change={@hourly_stats.change} />
</div>
<div class={[
"flex flex-col gap-y-2 h-[122px] text-center animate-pulse",
is_map(@hourly_stats) && " hidden"
]}>
<div class="flex-2 dark:bg-gray-700 bg-gray-100 rounded-md"></div>
<div class="flex-1 dark:bg-gray-700 bg-gray-100 rounded-md"></div>
</div>
<div :if={is_map(@hourly_stats)}>
<span class="flex flex-col gap-y-5 text-gray-600 dark:text-gray-400 text-sm truncate">
<span class="h-[54px] text-indigo-500">
<PlausibleWeb.Live.Components.Visitors.chart
intervals={@hourly_stats.intervals}
height={80}
/>
</span>
</div>
<div class="flex justify-between items-end">
<div class="flex flex-col">
<p class="text-lg sm:text-xl font-bold text-gray-900 dark:text-gray-100">
{large_number_format(@hourly_stats.visitors)}
</p>
<p class="text-gray-600 dark:text-gray-400">
visitor<span :if={@hourly_stats.visitors != 1}>s</span> in last 24h
</p>
</div>
<.percentage_change change={@hourly_stats.change} />
</div>
</span>
</div>
"""
end
@ -370,8 +512,7 @@ defmodule PlausibleWeb.Live.Sites do
# Related React component: <ChangeArrow />
def percentage_change(assigns) do
~H"""
<p class="dark:text-gray-100">
<span :if={@change == 0} class="font-semibold"></span>
<p class="text-gray-900 dark:text-gray-100">
<svg
:if={@change > 0}
xmlns="http://www.w3.org/2000/svg"
@ -574,7 +715,28 @@ defmodule PlausibleWeb.Live.Sites do
assigns = assign(assigns, :src, src)
~H"""
<img src={@src} class="w-4 h-4 shrink-0 mt-px" />
<img src={@src} class="size-[18px] shrink-0" />
"""
end
def globe_icon(assigns) do
~H"""
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M22 12H2M12 22c5.714-5.442 5.714-14.558 0-20M12 22C6.286 16.558 6.286 7.442 12 2"
/>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Z"
/>
</svg>
"""
end
@ -649,13 +811,6 @@ defmodule PlausibleWeb.Live.Sites do
{:noreply, socket}
end
defp loading(sites) do
sites.entries
|> Enum.into(%{}, fn site ->
{site.domain, :loading}
end)
end
defp load_sites(%{assigns: assigns} = socket) do
sites =
Sites.list_with_invitations(assigns.current_user, assigns.params,
@ -673,19 +828,25 @@ defmodule PlausibleWeb.Live.Sites do
"Could not render 24h visitors hourly intervals: #{inspect(kind)} #{inspect(value)}"
)
loading(sites)
%{}
end
else
loading(sites)
%{}
end
consolidated_stats =
if connected?(socket),
do: load_consolidated_stats(assigns.consolidated_view),
else: :loading
invitations = extract_invitations(sites.entries, assigns.current_team)
assign(
socket,
sites: sites,
invitations: invitations,
hourly_stats: hourly_stats
hourly_stats: hourly_stats,
consolidated_stats: consolidated_stats || Map.get(assigns, :consolidated_stats)
)
end
@ -780,4 +941,41 @@ defmodule PlausibleWeb.Live.Sites do
defp hash_domain(domain) do
:sha |> :crypto.hash(domain) |> Base.encode16()
end
@no_consolidated_view %{
consolidated_view: nil,
can_manage_consolidated_view?: false,
consolidated_stats: nil
}
on_ee do
alias Plausible.ConsolidatedView
defp init_consolidated_view_assigns(_user, nil), do: @no_consolidated_view
defp init_consolidated_view_assigns(user, team) do
if Teams.setup?(team) do
view = ConsolidatedView.get(team)
%{
consolidated_view: view,
can_manage_consolidated_view?: ConsolidatedView.can_manage?(user, team),
consolidated_stats: :loading
}
else
@no_consolidated_view
end
end
defp load_consolidated_stats(consolidated_view) do
case Plausible.Stats.ConsolidatedView.safe_overview_24h(consolidated_view) do
{:ok, stats} -> stats
{:error, :not_found} -> nil
{:error, :inaccessible} -> :loading
end
end
else
defp init_consolidated_view_assigns(_user, _team), do: @no_consolidated_view
defp load_consolidated_stats(_consolidated_view), do: nil
end
end

View File

@ -29,7 +29,7 @@ defmodule PlausibleWeb.Favicon do
import Plug.Conn
alias Plausible.HTTPClient
@placeholder_icon_location "priv/placeholder_favicon.svg"
@placeholder_icon_location "priv/link_favicon.svg"
@placeholder_icon File.read!(@placeholder_icon_location)
@custom_icons %{
"Brave" => "search.brave.com",

View File

@ -80,19 +80,17 @@ defmodule Plausible.Workers.SendEmailReport do
end
defp stats_aggregates(site, date_range) do
{:ok, query} =
Query.build(
query =
Query.build!(
site,
:internal,
%{
# site_id parameter is required, but it doesn't matter what we pass here since the query is executed against a specific site later on
"site_id" => site.domain,
"metrics" => ["pageviews", "visitors", "bounce_rate"],
"date_range" => date_range,
"include" => %{"comparisons" => %{"mode" => "previous_period"}},
"pagination" => %{"limit" => 5}
},
%{}
}
)
%QueryResult{
@ -114,19 +112,17 @@ defmodule Plausible.Workers.SendEmailReport do
end
defp pages(site, date_range) do
{:ok, query} =
Query.build(
query =
Query.build!(
site,
:internal,
%{
# site_id parameter is required, but it doesn't matter what we pass here since the query is executed against a specific site later on
"site_id" => site.domain,
"metrics" => ["visitors"],
"dimensions" => ["event:page"],
"date_range" => date_range,
"pagination" => %{"limit" => 5}
},
%{}
}
)
site
@ -141,20 +137,18 @@ defmodule Plausible.Workers.SendEmailReport do
end
defp sources(site, date_range) do
{:ok, query} =
Query.build(
query =
Query.build!(
site,
:internal,
%{
# site_id parameter is required, but it doesn't matter what we pass here since the query is executed against a specific site later on
"site_id" => site.domain,
"metrics" => ["visitors"],
"filters" => [["is_not", "visit:source", ["Direct / None"]]],
"dimensions" => ["visit:source"],
"date_range" => date_range,
"pagination" => %{"limit" => 5}
},
%{}
}
)
site
@ -169,19 +163,17 @@ defmodule Plausible.Workers.SendEmailReport do
end
defp goals(site, date_range) do
{:ok, query} =
Query.build(
query =
Query.build!(
site,
:internal,
%{
# site_id parameter is required, but it doesn't matter what we pass here since the query is executed against a specific site later on
"site_id" => site.domain,
"metrics" => ["visitors"],
"dimensions" => ["event:goal"],
"date_range" => date_range,
"pagination" => %{"limit" => 5}
},
%{}
}
)
site

View File

@ -131,16 +131,15 @@ defmodule Plausible.Workers.TrafficChangeNotifier do
}
defp put_sources(stats, site) do
{:ok, query} =
Query.build(
query =
Query.build!(
site,
:internal,
Map.merge(@base_query_params, %{
"site_id" => site.domain,
"dimensions" => ["visit:source"],
"filters" => [["is_not", "visit:source", ["Direct / None"]]]
}),
%{}
})
)
%{results: sources} = Plausible.Stats.query(site, query)
@ -149,15 +148,14 @@ defmodule Plausible.Workers.TrafficChangeNotifier do
end
defp put_pages(stats, site) do
{:ok, query} =
Query.build(
query =
Query.build!(
site,
:internal,
Map.merge(@base_query_params, %{
"site_id" => site.domain,
"dimensions" => ["event:page"]
}),
%{}
})
)
%{results: pages} = Plausible.Stats.query(site, query)

6
priv/globe_favicon.svg Normal file
View File

@ -0,0 +1,6 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 11H1" stroke="#90A1B9" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 21C16.7143 15.5578 16.7143 6.44218 11 1" stroke="#90A1B9" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.0006 21C5.28627 15.5578 5.28627 6.44218 11.0006 1" stroke="#90A1B9" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 21C16.5228 21 21 16.5228 21 11C21 5.47715 16.5228 1 11 1C5.47715 1 1 5.47715 1 11C1 16.5228 5.47715 21 11 21Z" stroke="#90A1B9" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 719 B

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 496 B

After

Width:  |  Height:  |  Size: 496 B

View File

@ -25,6 +25,12 @@ native_stats_range =
Date.utc_today()
)
native_stats_range2 =
Date.range(
Date.add(Date.utc_today(), -320),
Date.utc_today()
)
imported_stats_range =
Date.range(
Date.add(native_stats_range.first, -180),
@ -62,6 +68,16 @@ site =
add_guest(site, user: new_user(name: "Arnold Wallaby", password: "plausible"), role: :viewer)
add_guest(site, user: new_user(name: "Lois Lane", password: "plausible"), role: :editor)
another_site =
new_site(
domain: "another.site",
team: [
native_stats_start_at: NaiveDateTime.new!(native_stats_range2.first, ~T[00:00:00]),
stats_start_date: NaiveDateTime.new!(native_stats_range2.first, ~T[00:00:00])
],
owner: user
)
user2 = new_user(name: "Mary Jane", email: "user2@plausible.test", password: "plausible")
site2 = new_site(domain: "computer.example.com", owner: user2)
invite_guest(site2, user, inviter: user2, role: :viewer)
@ -175,12 +191,13 @@ utm_medium = %{
"Twitter" => ["social"]
}
random_event_data = fn ->
random_event_data = fn site ->
domain = site.domain
referrer_source = Enum.random(sources)
[
site_id: site.id,
hostname: Enum.random(["en.dummy.site", "es.dummy.site", "dummy.site"]),
hostname: Enum.random(["en.#{domain}", "es.#{domain}", domain]),
referrer_source: referrer_source,
browser: Enum.random(["Microsoft Edge", "Chrome", "curl", "Safari", "Firefox", "Vivaldi"]),
browser_version: to_string(Enum.random(0..50)),
@ -224,7 +241,7 @@ native_stats_range
user_id = :rand.uniform(clickhouse_max_uint64)
event =
random_event_data.()
random_event_data.(site)
|> Keyword.merge(user_id: user_id)
Enum.reduce(0..Enum.random(0..5), [], fn event_index, events ->
@ -274,6 +291,43 @@ native_stats_range
end)
|> Plausible.TestUtils.populate_stats()
native_stats_range2
|> Enum.flat_map(fn date ->
n_visitors = 10 + :rand.uniform(70)
Enum.flat_map(0..n_visitors, fn _ ->
visit_start_timestamp = with_random_time.(date)
user_id = :rand.uniform(clickhouse_max_uint64)
event =
random_event_data.(another_site)
|> Keyword.merge(user_id: user_id)
Enum.reduce(0..Enum.random(0..5), [], fn _event_index, events ->
timestamp =
case events do
[] -> visit_start_timestamp
[event | _] -> next_event_timestamp.(event.timestamp)
end
event = Keyword.merge(event, timestamp: timestamp)
pageview = Plausible.Factory.build(:pageview, event)
engagement =
Map.merge(pageview, %{
name: "engagement",
engagement_time: Enum.random(300..10000),
scroll_depth: Enum.random(1..100)
})
[engagement, pageview] ++ events
end)
|> Enum.reverse()
end)
end)
|> Plausible.TestUtils.populate_stats()
site_import =
site
|> Plausible.Imported.SiteImport.create_changeset(user, %{

View File

@ -14,8 +14,8 @@ defmodule Plausible.CondolidatedView.CacheTest do
{s3, s4, s5} = {new_site(owner: owner2), new_site(owner: owner2), new_site(owner: owner2)}
team2 = team_of(owner2)
{:ok, consolidated_view1} = ConsolidatedView.enable(team1)
{:ok, consolidated_view2} = ConsolidatedView.enable(team2)
consolidated_view1 = new_consolidated_view(team1)
consolidated_view2 = new_consolidated_view(team2)
start_test_cache(test)
@ -66,7 +66,7 @@ defmodule Plausible.CondolidatedView.CacheTest do
team = team_of(owner)
{:ok, consolidated_view} = ConsolidatedView.enable(team)
consolidated_view = new_consolidated_view(team)
:ok = Cache.refresh_updated_recently(cache_name: test)
@ -78,7 +78,7 @@ defmodule Plausible.CondolidatedView.CacheTest do
new_site(owner: user)
new_site(owner: user)
team = team_of(user)
{:ok, consolidated_view} = ConsolidatedView.enable(team)
consolidated_view = new_consolidated_view(team)
start_test_cache(test)
:ok = Cache.refresh_all(cache_name: test)

View File

@ -4,20 +4,23 @@ defmodule Plausible.ConsolidatedViewTest do
on_ee do
use Plausible.DataCase, async: true
import Ecto.Query
alias Plausible.ConsolidatedView
import Plausible.Teams.Test
alias Plausible.ConsolidatedView
alias Plausible.Teams
describe "enable/1 and enabled?/1" do
setup [:create_user, :create_team]
test "creates and persists a new consolidated site instance", %{team: team} do
new_site(team: team)
team = Teams.complete_setup(team)
assert {:ok, %Plausible.Site{consolidated: true}} = ConsolidatedView.enable(team)
assert ConsolidatedView.enabled?(team)
end
test "is idempotent", %{team: team} do
new_site(team: team)
team = Teams.complete_setup(team)
assert {:ok, s1} = ConsolidatedView.enable(team)
assert {:ok, s2} = ConsolidatedView.enable(team)
@ -29,14 +32,22 @@ defmodule Plausible.ConsolidatedViewTest do
end
test "returns {:error, :no_sites} when the team does not have any sites", %{team: team} do
team = Teams.complete_setup(team)
assert {:error, :no_sites} = ConsolidatedView.enable(team)
refute ConsolidatedView.enabled?(team)
end
test "returns {:error, :team_not_setup} when the team is not set up", %{team: team} do
assert {:error, :team_not_setup} = ConsolidatedView.enable(team)
refute ConsolidatedView.enabled?(team)
end
@tag :skip
test "returns {:error, :upgrade_required} when team ineligible for this feature"
test "creates consolidated view with stats start dates of the oldest site", %{team: team} do
team = Teams.complete_setup(team)
datetimes = [
~N[2024-01-01 12:00:00],
~N[2024-01-01 11:00:00],
@ -53,6 +64,7 @@ defmodule Plausible.ConsolidatedViewTest do
end
test "enable/1 updates cache", %{team: team} do
team = Teams.complete_setup(team)
site = new_site(team: team)
{:ok, _} = ConsolidatedView.enable(team)
@ -66,7 +78,7 @@ defmodule Plausible.ConsolidatedViewTest do
setup [:create_user, :create_team, :create_site]
setup %{team: team} do
ConsolidatedView.enable(team)
new_consolidated_view(team)
:ok
end
@ -86,6 +98,38 @@ defmodule Plausible.ConsolidatedViewTest do
end
end
describe "can_manage?/2" do
test "invalid membership" do
refute ConsolidatedView.can_manage?(%Plausible.Auth.User{id: 1}, %Plausible.Teams.Team{
id: 1
})
end
test "viewer" do
team = new_site().team
viewer = add_member(team, role: :viewer)
refute ConsolidatedView.can_manage?(viewer, team)
end
test "not a viewer" do
team = new_site().team
viewer = add_member(team, role: :editor)
assert ConsolidatedView.can_manage?(viewer, team)
end
test "not a viewer + guest" do
site = new_site()
viewer = add_guest(site, role: :editor)
refute ConsolidatedView.can_manage?(viewer, site.team)
end
test "viewer + guest" do
site = new_site()
viewer = add_guest(site, role: :viewer)
refute ConsolidatedView.can_manage?(viewer, site.team)
end
end
describe "site_ids/1" do
setup [:create_user, :create_team, :create_site]
@ -97,7 +141,7 @@ defmodule Plausible.ConsolidatedViewTest do
team: team,
site: site
} do
ConsolidatedView.enable(team)
new_consolidated_view(team)
assert ConsolidatedView.site_ids(team) == {:ok, [site.id]}
end
end
@ -107,13 +151,13 @@ defmodule Plausible.ConsolidatedViewTest do
test "can get by team", %{team: team} do
assert is_nil(ConsolidatedView.get(team))
ConsolidatedView.enable(team)
new_consolidated_view(team)
assert %Plausible.Site{} = ConsolidatedView.get(team)
end
test "can get by team.identifier", %{team: team} do
assert is_nil(ConsolidatedView.get(team.identifier))
ConsolidatedView.enable(team)
new_consolidated_view(team)
assert %Plausible.Site{} = ConsolidatedView.get(team.identifier)
end
end
@ -131,6 +175,7 @@ defmodule Plausible.ConsolidatedViewTest do
@tag :slow
test "re-enables", %{team: team} do
_site = new_site(team: team, native_stats_start_at: ~N[2024-01-01 12:00:00])
team = Teams.complete_setup(team)
{:ok, first_enable} = ConsolidatedView.enable(team)

View File

@ -59,7 +59,7 @@ defmodule Plausible.Site.SiteRemovalTest do
site = new_site(owner: owner)
team = team_of(owner)
{:ok, _} = Plausible.ConsolidatedView.enable(team)
new_consolidated_view(team)
assert Plausible.ConsolidatedView.enabled?(team)
assert {:ok, _} = Removal.run(site)
@ -76,7 +76,7 @@ defmodule Plausible.Site.SiteRemovalTest do
team = team_of(owner)
{:ok, _} = Plausible.ConsolidatedView.enable(team)
new_consolidated_view(team)
assert Plausible.ConsolidatedView.enabled?(team)
assert {:ok, _} = Removal.run(site)

View File

@ -176,19 +176,17 @@ defmodule Plausible.SitesTest do
end
on_ee do
alias Plausible.ConsolidatedView
test "resets consolidated view stats dates every time" do
owner = new_user()
new_site(owner: owner)
team = team_of(owner)
{:ok, consolidated_view} = ConsolidatedView.enable(team)
consolidated_view = new_consolidated_view(team)
assert consolidated_view.stats_start_date == ~D[2000-01-01]
assert Sites.stats_start_date(consolidated_view) == ~D[2000-01-01]
new_site(owner: owner, native_stats_start_at: ~N[1999-01-01 12:00:00])
new_site(team: team, native_stats_start_at: ~N[1999-01-01 12:00:00])
assert Sites.stats_start_date(consolidated_view) == ~D[1999-01-01]
end

View File

@ -1,5 +1,7 @@
defmodule Plausible.Stats.ClickhouseTest do
use Plausible.DataCase, async: true
import Plausible.Teams.Test
import Plausible.TestUtils
alias Plausible.Stats.Clickhouse
@ -10,7 +12,7 @@ defmodule Plausible.Stats.ClickhouseTest do
test "returns empty intervals placeholder on no clickhouse stats" do
fixed_now = ~N[2023-10-26 10:00:15]
site = insert(:site)
site = new_site()
domain = site.domain
assert Clickhouse.last_24h_visitors_hourly_intervals(
@ -22,6 +24,7 @@ defmodule Plausible.Stats.ClickhouseTest do
change: 0,
visitors: 0,
intervals: [
%{interval: ~N[2023-10-25 10:00:00], visitors: 0},
%{interval: ~N[2023-10-25 11:00:00], visitors: 0},
%{interval: ~N[2023-10-25 12:00:00], visitors: 0},
%{interval: ~N[2023-10-25 13:00:00], visitors: 0},
@ -45,8 +48,7 @@ defmodule Plausible.Stats.ClickhouseTest do
%{interval: ~N[2023-10-26 07:00:00], visitors: 0},
%{interval: ~N[2023-10-26 08:00:00], visitors: 0},
%{interval: ~N[2023-10-26 09:00:00], visitors: 0},
%{interval: ~N[2023-10-26 10:00:00], visitors: 0},
%{interval: ~N[2023-10-26 11:00:00], visitors: 0}
%{interval: ~N[2023-10-26 10:00:00], visitors: 0}
]
}
}
@ -54,22 +56,25 @@ defmodule Plausible.Stats.ClickhouseTest do
test "returns clickhouse data merged with placeholder" do
fixed_now = ~N[2023-10-26 10:00:15]
site = insert(:site)
site = new_site()
user_id = 111
populate_stats(site, [
build(:pageview, timestamp: ~N[2023-10-25 11:01:00]),
build(:pageview, timestamp: ~N[2023-10-25 13:59:00]),
build(:pageview, timestamp: ~N[2023-10-25 13:58:00]),
build(:pageview, user_id: user_id, timestamp: ~N[2023-10-25 15:00:00]),
build(:pageview, user_id: user_id, timestamp: ~N[2023-10-25 15:01:00])
build(:pageview, user_id: user_id, timestamp: ~N[2023-10-25 15:01:00]),
build(:pageview, timestamp: ~N[2023-10-26 10:00:14])
])
assert %{
change: 100,
visitors: 3,
visitors: 5,
intervals: [
%{interval: ~N[2023-10-25 11:00:00], visitors: 0},
%{interval: ~N[2023-10-25 10:00:00], visitors: 0},
%{interval: ~N[2023-10-25 11:00:00], visitors: 1},
%{interval: ~N[2023-10-25 12:00:00], visitors: 0},
%{interval: ~N[2023-10-25 13:00:00], visitors: 2},
%{interval: ~N[2023-10-25 14:00:00], visitors: 0},
@ -92,8 +97,7 @@ defmodule Plausible.Stats.ClickhouseTest do
%{interval: ~N[2023-10-26 07:00:00], visitors: 0},
%{interval: ~N[2023-10-26 08:00:00], visitors: 0},
%{interval: ~N[2023-10-26 09:00:00], visitors: 0},
%{interval: ~N[2023-10-26 10:00:00], visitors: 0},
%{interval: ~N[2023-10-26 11:00:00], visitors: 0}
%{interval: ~N[2023-10-26 10:00:00], visitors: 1}
]
} = Clickhouse.last_24h_visitors_hourly_intervals([site], fixed_now)[site.domain]
end
@ -124,6 +128,7 @@ defmodule Plausible.Stats.ClickhouseTest do
change: 100,
visitors: 1,
intervals: [
%{interval: ~N[2023-10-25 10:00:00], visitors: 0},
%{interval: ~N[2023-10-25 11:00:00], visitors: 0},
%{interval: ~N[2023-10-25 12:00:00], visitors: 0},
%{interval: ~N[2023-10-25 13:00:00], visitors: 0},
@ -147,8 +152,7 @@ defmodule Plausible.Stats.ClickhouseTest do
%{interval: ~N[2023-10-26 07:00:00], visitors: 0},
%{interval: ~N[2023-10-26 08:00:00], visitors: 0},
%{interval: ~N[2023-10-26 09:00:00], visitors: 0},
%{interval: ~N[2023-10-26 10:00:00], visitors: 0},
%{interval: ~N[2023-10-26 11:00:00], visitors: 0}
%{interval: ~N[2023-10-26 10:00:00], visitors: 0}
]
} = Clickhouse.last_24h_visitors_hourly_intervals([site1], fixed_now)[site1.domain]
@ -156,6 +160,7 @@ defmodule Plausible.Stats.ClickhouseTest do
change: 100,
visitors: 3,
intervals: [
%{interval: ~N[2023-10-25 10:00:00], visitors: 0},
%{interval: ~N[2023-10-25 11:00:00], visitors: 0},
%{interval: ~N[2023-10-25 12:00:00], visitors: 0},
%{interval: ~N[2023-10-25 13:00:00], visitors: 2},
@ -179,17 +184,16 @@ defmodule Plausible.Stats.ClickhouseTest do
%{interval: ~N[2023-10-26 07:00:00], visitors: 0},
%{interval: ~N[2023-10-26 08:00:00], visitors: 0},
%{interval: ~N[2023-10-26 09:00:00], visitors: 0},
%{interval: ~N[2023-10-26 10:00:00], visitors: 0},
%{interval: ~N[2023-10-26 11:00:00], visitors: 0}
%{interval: ~N[2023-10-26 10:00:00], visitors: 0}
]
} = Clickhouse.last_24h_visitors_hourly_intervals([site2], fixed_now)[site2.domain]
end
test "returns clickhouse data merged with placeholder for multiple sites" do
fixed_now = ~N[2023-10-26 10:00:15]
site1 = insert(:site)
site2 = insert(:site)
site3 = insert(:site)
site1 = new_site()
site2 = new_site()
site3 = new_site()
user_id = 111
@ -229,7 +233,7 @@ defmodule Plausible.Stats.ClickhouseTest do
test "returns calculated change" do
fixed_now = ~N[2023-10-26 10:00:15]
site = insert(:site)
site = new_site()
populate_stats(site, [
build(:pageview, timestamp: ~N[2023-10-24 11:58:00]),
@ -247,7 +251,7 @@ defmodule Plausible.Stats.ClickhouseTest do
test "calculates uniques correctly across hour boundaries" do
fixed_now = ~N[2023-10-26 10:00:15]
site = insert(:site)
site = new_site()
user_id = 111
@ -262,7 +266,7 @@ defmodule Plausible.Stats.ClickhouseTest do
test "another one" do
fixed_now = ~N[2023-10-26 10:00:15]
site = insert(:site)
site = new_site()
user_id = 111
@ -277,6 +281,7 @@ defmodule Plausible.Stats.ClickhouseTest do
change: 100,
visitors: 3,
intervals: [
%{interval: ~N[2023-10-25 10:00:00], visitors: 0},
%{interval: ~N[2023-10-25 11:00:00], visitors: 0},
%{interval: ~N[2023-10-25 12:00:00], visitors: 0},
%{interval: ~N[2023-10-25 13:00:00], visitors: 2},
@ -300,16 +305,36 @@ defmodule Plausible.Stats.ClickhouseTest do
%{interval: ~N[2023-10-26 07:00:00], visitors: 0},
%{interval: ~N[2023-10-26 08:00:00], visitors: 0},
%{interval: ~N[2023-10-26 09:00:00], visitors: 0},
%{interval: ~N[2023-10-26 10:00:00], visitors: 0},
%{interval: ~N[2023-10-26 11:00:00], visitors: 0}
%{interval: ~N[2023-10-26 10:00:00], visitors: 0}
]
} = Clickhouse.last_24h_visitors_hourly_intervals([site], fixed_now)[site.domain]
end
test "excludes engagement events from visitor counts" do
fixed_now = ~N[2025-10-20 12:49:15]
site = new_site()
populate_stats(site, [
build(:pageview, user_id: 111, timestamp: ~N[2025-10-20 12:00:00]),
build(:pageview, user_id: 222, timestamp: ~N[2025-10-20 10:50:00]),
build(:engagement,
user_id: 222,
pathname: "/blog",
timestamp: ~N[2025-10-20 10:50:01],
scroll_depth: 20,
engagement_time: 50_000
)
])
result = Clickhouse.last_24h_visitors_hourly_intervals([site], fixed_now)[site.domain]
assert %{visitors: 2} = result
end
end
describe "imported_pageview_counts/1" do
test "gets pageview counts for each of sites' imports" do
site = insert(:site)
site = new_site()
import1 = insert(:site_import, site: site)
import2 = insert(:site_import, site: site)

View File

@ -427,8 +427,8 @@ defmodule Plausible.Stats.ComparisonsTest do
end
defp build_comparison_query(site, params) do
{:ok, query} =
Query.build(
query =
Query.build!(
site,
:internal,
Map.merge(
@ -439,8 +439,7 @@ defmodule Plausible.Stats.ComparisonsTest do
"include" => %{"comparisons" => %{"mode" => "previous_period"}}
},
params
),
%{}
)
)
query

View File

@ -0,0 +1,147 @@
defmodule Plausible.Stats.ConsolidatedViewTest do
use Plausible.DataCase, async: true
on_ee do
import Plausible.Teams.Test
test "returns stats for a consolidated view merged with placeholder" do
fixed_now = ~N[2023-10-26 10:00:15]
owner = new_user()
site1 = new_site(owner: owner)
site2 = new_site(owner: owner)
user_id = 111
populate_stats(site1, [
build(:pageview, user_id: user_id, timestamp: ~N[2023-10-25 15:00:00]),
build(:pageview, user_id: user_id, timestamp: ~N[2023-10-25 15:01:00]),
# this one is at the end of the range
build(:pageview, timestamp: ~N[2023-10-26 10:00:14])
])
populate_stats(site2, [
# this one is at the beginning of the range
build(:pageview, timestamp: ~N[2023-10-25 11:01:00]),
build(:pageview, timestamp: ~N[2023-10-25 13:59:00]),
build(:pageview, timestamp: ~N[2023-10-25 13:58:00])
])
view = new_consolidated_view(team_of(owner))
result = Plausible.Stats.ConsolidatedView.overview_24h(view, fixed_now)
assert %{
visitors_change: 100,
pageviews_change: 100,
visits_change: 100,
visitors: 5,
visits: 5,
pageviews: 6,
views_per_visit: 1.2,
intervals: [
%{interval: ~N[2023-10-25 10:00:00], visitors: 0},
%{interval: ~N[2023-10-25 11:00:00], visitors: 1},
%{interval: ~N[2023-10-25 12:00:00], visitors: 0},
%{interval: ~N[2023-10-25 13:00:00], visitors: 2},
%{interval: ~N[2023-10-25 14:00:00], visitors: 0},
%{interval: ~N[2023-10-25 15:00:00], visitors: 1},
%{interval: ~N[2023-10-25 16:00:00], visitors: 0},
%{interval: ~N[2023-10-25 17:00:00], visitors: 0},
%{interval: ~N[2023-10-25 18:00:00], visitors: 0},
%{interval: ~N[2023-10-25 19:00:00], visitors: 0},
%{interval: ~N[2023-10-25 20:00:00], visitors: 0},
%{interval: ~N[2023-10-25 21:00:00], visitors: 0},
%{interval: ~N[2023-10-25 22:00:00], visitors: 0},
%{interval: ~N[2023-10-25 23:00:00], visitors: 0},
%{interval: ~N[2023-10-26 00:00:00], visitors: 0},
%{interval: ~N[2023-10-26 01:00:00], visitors: 0},
%{interval: ~N[2023-10-26 02:00:00], visitors: 0},
%{interval: ~N[2023-10-26 03:00:00], visitors: 0},
%{interval: ~N[2023-10-26 04:00:00], visitors: 0},
%{interval: ~N[2023-10-26 05:00:00], visitors: 0},
%{interval: ~N[2023-10-26 06:00:00], visitors: 0},
%{interval: ~N[2023-10-26 07:00:00], visitors: 0},
%{interval: ~N[2023-10-26 08:00:00], visitors: 0},
%{interval: ~N[2023-10-26 09:00:00], visitors: 0},
%{interval: ~N[2023-10-26 10:00:00], visitors: 1}
]
} = result
end
test "excludes engagement events from visitor counts" do
fixed_now = ~N[2025-10-20 12:49:15]
owner = new_user()
site = new_site(owner: owner)
populate_stats(site, [
build(:pageview, user_id: 111, timestamp: ~N[2025-10-20 12:00:00]),
build(:pageview, user_id: 222, timestamp: ~N[2025-10-20 10:50:00]),
build(:engagement,
user_id: 222,
pathname: "/blog",
timestamp: ~N[2025-10-20 10:50:01],
scroll_depth: 20,
engagement_time: 50_000
)
])
view = new_consolidated_view(team_of(owner))
result = Plausible.Stats.ConsolidatedView.overview_24h(view, fixed_now)
assert %{visitors: 2} = result
end
test "filters out-of-range timeslots correctly" do
fixed_now = ~N[2025-10-20 12:49:15]
owner = new_user()
site = new_site(owner: owner)
populate_stats(site, [
build(:pageview, user_id: 111, timestamp: ~N[2025-10-19 11:00:00]),
build(:pageview, user_id: 111, timestamp: ~N[2025-10-20 12:00:00]),
build(:pageview, user_id: 222, timestamp: ~N[2025-10-19 12:51:00]),
build(:pageview, user_id: 333, timestamp: ~N[2025-10-19 12:48:00])
])
view = new_consolidated_view(team_of(owner))
result = Plausible.Stats.ConsolidatedView.overview_24h(view, fixed_now)
expected_non_zero_intervals = [
{~N[2025-10-19 12:00:00], 1},
{~N[2025-10-20 12:00:00], 1}
]
consolidated_result = filter_only_non_zero_intervals(result.intervals)
assert consolidated_result == expected_non_zero_intervals
assert List.first(result.intervals).interval == ~N[2025-10-19 12:00:00]
assert List.last(result.intervals).interval == ~N[2025-10-20 12:00:00]
end
test "orders timeslots chronologically" do
fixed_now = ~N[2025-10-20 12:49:15]
owner = new_user()
site = new_site(owner: owner)
populate_stats(site, [
build(:pageview, user_id: 111, timestamp: ~N[2025-10-20 12:00:00]),
build(:pageview, user_id: 222, timestamp: ~N[2025-10-19 13:00:00]),
build(:pageview, user_id: 333, timestamp: ~N[2025-10-20 10:00:00])
])
view = new_consolidated_view(team_of(owner))
result = Plausible.Stats.ConsolidatedView.overview_24h(view, fixed_now)
timeslots = Enum.map(result.intervals, & &1.interval)
sorted_timeslots = Enum.sort(timeslots, NaiveDateTime)
assert timeslots == sorted_timeslots
end
defp filter_only_non_zero_intervals(intervals) do
intervals
|> Enum.filter(&(&1.visitors > 0))
|> Enum.map(fn i -> {i.interval, i.visitors} end)
end
end
end

View File

@ -16,9 +16,35 @@ defmodule Plausible.Stats.QueryResultTest do
{:ok, site: site}
end
test "query!/3 raises on error on site_id mismatch", %{site: site} do
assert_raise FunctionClauseError, fn ->
Query.build!(
site,
:public,
%{
"site_id" => "different"
}
)
end
end
test "query!/3 raises on schema validation error", %{site: site} do
assert_raise RuntimeError,
~s/Failed to build query: "#: Required properties metrics, date_range were not present."/,
fn ->
Query.build!(
site,
:public,
%{
"site_id" => site.domain
}
)
end
end
test "serializing query to JSON keeps keys ordered", %{site: site} do
{:ok, query} =
Query.build(
query =
Query.build!(
site,
:public,
%{
@ -26,8 +52,7 @@ defmodule Plausible.Stats.QueryResultTest do
"metrics" => ["pageviews"],
"date_range" => ["2024-01-01", "2024-02-01"],
"include" => %{"imports" => true}
},
%{}
}
)
query = QueryOptimizer.optimize(query)

View File

@ -92,7 +92,7 @@ defmodule Plausible.Teams.Sites.TransferTest do
site = new_site(owner: user)
team = team_of(user)
assert {:ok, _} = ConsolidatedView.enable(team)
new_consolidated_view(team)
assert ConsolidatedView.enabled?(team)
another_owner = new_user()

View File

@ -198,7 +198,7 @@ defmodule PlausibleWeb.Live.CustomerSupport.TeamsTest do
end
test "can create a consolidated view for team", %{conn: conn, user: user} do
team = team_of(user)
team = user |> team_of() |> Plausible.Teams.complete_setup()
{:ok, lv, _html} = live(conn, open_team(team.id, tab: "consolidated_views"))
@ -209,7 +209,7 @@ defmodule PlausibleWeb.Live.CustomerSupport.TeamsTest do
test "renders existing consolidated view", %{conn: conn, user: user} do
team = team_of(user)
Plausible.ConsolidatedView.enable(team)
new_consolidated_view(team)
{:ok, lv, _html} = live(conn, open_team(team.id, tab: "consolidated_views"))
html = render(lv)
@ -224,7 +224,7 @@ defmodule PlausibleWeb.Live.CustomerSupport.TeamsTest do
test "can delete consolidated view", %{conn: conn, user: user} do
team = team_of(user)
Plausible.ConsolidatedView.enable(team)
new_consolidated_view(team)
{:ok, lv, _html} = live(conn, open_team(team.id, tab: "consolidated_views"))

View File

@ -297,6 +297,83 @@ defmodule PlausibleWeb.Live.SitesTest do
end
end
on_ee do
describe "consolidated views appearance" do
setup %{user: user} do
# this is temporary, instead of feature flag we'll only show consolidated views to super admins
patch_env(:super_admin_user_ids, [user.id])
end
test "consolidated view shows up", %{conn: conn, user: user} do
new_site(owner: user)
team = team_of(user)
conn = set_current_team(conn, team)
{:ok, _lv, html} = live(conn, "/sites")
refute element_exists?(html, ~s|[data-test-id="consolidated-view-card"]|)
new_consolidated_view(team)
{:ok, _lv, html} = live(conn, "/sites")
assert element_exists?(html, ~s|[data-test-id="consolidated-view-card"]|)
assert element_exists?(html, ~s|[data-test-id="consolidated-view-stats-loaded"]|)
assert element_exists?(html, ~s|[data-test-id="consolidated-view-chart-loaded"]|)
end
test "consolidated view presents consolidated stats", %{conn: conn, user: user} do
site1 = new_site(owner: user)
site2 = new_site(owner: user)
populate_stats(site1, [
build(:pageview, user_id: 1),
build(:pageview, user_id: 1),
build(:pageview)
])
populate_stats(site2, [
build(:pageview, user_id: 3)
])
team = team_of(user)
conn = set_current_team(conn, team)
new_consolidated_view(team)
{:ok, _lv, html} = live(conn, "/sites")
stats = text_of_element(html, ~s|[data-test-id="consolidated-view-stats-loaded"]|)
assert stats =~ "Unique visitors 3"
assert stats =~ "Total visits 3"
assert stats =~ "Total pageviews 4"
assert stats =~ "Views per visit 1.33"
end
test "consolidated view does not show up for non-superadmin (temp)", %{conn: conn} do
user = new_user()
new_site(owner: user)
team = team_of(user)
conn = set_current_team(conn, team)
{:ok, _lv, html} = live(conn, "/sites")
refute element_exists?(html, ~s|[data-test-id="consolidated-view-card"]|)
new_consolidated_view(team)
{:ok, _lv, html} = live(conn, "/sites")
refute element_exists?(html, ~s|[data-test-id="consolidated-view-card"]|)
refute element_exists?(html, ~s|[data-test-id="consolidated-view-stats-loaded"]|)
refute element_exists?(html, ~s|[data-test-id="consolidated-view-chart-loaded"]|)
end
end
end
describe "pinning" do
test "renders pin site option when site not pinned", %{conn: conn, user: user} do
site = new_site(owner: user)

View File

@ -145,7 +145,7 @@ defmodule PlausibleWeb.FaviconTest do
end
describe "Fallback to placeholder icon" do
@placeholder_icon File.read!("priv/placeholder_favicon.svg")
@placeholder_icon File.read!("priv/link_favicon.svg")
test "falls back to placeholder when DDG returns a non-2xx response", %{plug_opts: plug_opts} do
expect(

View File

@ -25,6 +25,7 @@ defmodule Plausible.Teams.Test do
on_ee do
def new_consolidated_view(team) do
team = Teams.complete_setup(team)
{:ok, site} = Plausible.ConsolidatedView.enable(team)
site
end