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 commit7584f59816. * 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 commit5c03b36918. * 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:
parent
f01ce1b1b6
commit
bce08903e5
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 496 B After Width: | Height: | Size: 496 B |
|
|
@ -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, %{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue