mirror of
https://github.com/plausible/analytics.git
synced 2024-12-22 17:11:36 +03:00
Implement new sites view (#3463)
* Implement complete basics of LV sites
* Reimplement everything in LV except pagination
* Implement basic search capability
* PoC: plot visitors on sites index
* Add rudimentary clipped gradient in minicharts
* Fix clipping gradient, define once
* Format
* Add moduledoc to visitors component
* Move paginator helpers to the top core namespace
* Fix typespec of `Plausible.Sites.list`
* Split sites component into subcomponents
* Add function to uniformly calculate 24h intervals
and visitor totals across multiple sites.
* Integrate batch 24h interval query with plots on sites view
* Don't confuse heex compiler with alpine @ shorthands
* Make linear gradient svg definition truly invisible
* Implement basic pagination
* Extract `site_stats` from site and invitation cards
* Improve pagination
* Tweak css
* Improve filtering on pagination and make WSS fail graceful
* Test `last_24h_visitors_hourly_intervals/2`
* Replace /sites with LV implementation
* Add debounce to search filter
* Fix typespecs
* Fix styling
* Fix mini graph scaling factor calculation
* Fix search consuming itself
* Minimal tweaks to the plots
* Fixup
* Remove magic numbers from the plot
* Create `site_pins` table
* Add `SitePin` schema
* Implement listing invitations, sites and pins in a single query
* Add FIXME note
* Remove site pins for now
* Add tests for `Plausible.Sites.list/3`
* Add a couple more tests to sites dead view
* Remove unnecessary FIXME
* Add LV tests for Sites
* Calculate and display 24h visitors change
* Render the change in bold
* Add clarfying comment on virtual field in `Site` schema
* Remove unnecessary function from Invitations API
* Remove unused list opt from type definition in `Sites`
* Improve joins in list query slightly
* Add comment on manually computing sites list total
* Start searching from a singly character in domain field
* Add typespec to `last_24h_visitors_hourly_intervals`
* Extend moduledoc in visitors component
* Simplify loading sites in LV
* Simplify assigns in LV
* Add missing group for shadow under site card
* Make invitation modal render
* Make HTML in sites LV semantically correct
* Remove autofocus and focus search on `/`
* Remove shadow from search input
* Make search cancel on escape
* Fix tests relying on outdated HTML structure
* Make visitor chart color scheme consistent with dashboard chart
* Update styling of trend labels
* Fix empty state and improve search blur/focus handling
* Use live navigation for pagination
* Implement spinner on load from search
* Remove unused `Plausible.Stats.Clickhouse.last_24h_visitors/1`
* Calculate uniques correctly across hour boundaries
* Swap inlined svg for Heroicons component in invitation modal
* Add order by to base query in 24h hourly intervals
* Revert "Add order by to base query in 24h hourly intervals"
This reverts commit a6be5e3026
.
* Query clickhouse 24h visitors only on second mount
* Remove redundant sign from percentage change when negative
* Switch to offset-based pagination
- offset seems easier to deal with for when actions on
paginated list will be performed such as site pinning;
tracking cursor data makes some entries disappear in
edge cases. The data set is still fairly small and
static, even for large customers.
- we're removing Phoenix.Pagination as it doesn't really
fir any use case, and it was only used to limit the number
of sites in the site picker
- site picker is now limited to 9 sites (future: pinned
sites will be prioritized there)
- no need to re-query for total count any more
- BTW, the old /sites template was removed
* Refine the plot queries; Tests pass snapshot
* Add PromEx plugin for LiveView
* Fix tiny plot cut-off at the top
---------
Co-authored-by: Adam Rutkowski <hq@mtod.org>
This commit is contained in:
parent
3e77621d81
commit
07cab27fef
@ -1,7 +1,6 @@
|
||||
defmodule Plausible.Plugins.API.Pagination do
|
||||
defmodule Plausible.Pagination do
|
||||
@moduledoc """
|
||||
Cursor-based pagination for the Plugins API.
|
||||
Can be moved to another namespace in case used elsewhere.
|
||||
Cursor-based pagination.
|
||||
"""
|
||||
|
||||
@limit 10
|
||||
@ -35,6 +34,9 @@ defmodule Plausible.Plugins.API.Pagination do
|
||||
else
|
||||
acc
|
||||
end
|
||||
|
||||
_, acc ->
|
||||
acc
|
||||
end)
|
||||
end
|
||||
|
@ -5,7 +5,7 @@ defmodule Plausible.Plugins.API.Goals do
|
||||
"""
|
||||
|
||||
import Ecto.Query
|
||||
import Plausible.Plugins.API.Pagination
|
||||
import Plausible.Pagination
|
||||
|
||||
alias Plausible.Repo
|
||||
alias PlausibleWeb.Plugins.API.Schemas.Goal.CreateRequest
|
||||
|
@ -4,7 +4,7 @@ defmodule Plausible.Plugins.API.SharedLinks do
|
||||
All high level Shared Links operations should be implemented here.
|
||||
"""
|
||||
import Ecto.Query
|
||||
import Plausible.Plugins.API.Pagination
|
||||
import Plausible.Pagination
|
||||
|
||||
alias Plausible.Repo
|
||||
|
||||
|
@ -8,6 +8,7 @@ defmodule Plausible.PromEx do
|
||||
[
|
||||
Plugins.Application,
|
||||
Plugins.Beam,
|
||||
Plugins.PhoenixLiveView,
|
||||
{Plugins.Phoenix, router: PlausibleWeb.Router, endpoint: PlausibleWeb.Endpoint},
|
||||
{Plugins.Ecto,
|
||||
repos: [
|
||||
|
@ -3,7 +3,7 @@ defmodule Plausible.Repo do
|
||||
otp_app: :plausible,
|
||||
adapter: Ecto.Adapters.Postgres
|
||||
|
||||
use Phoenix.Pagination, per_page: 18
|
||||
use Scrivener, page_size: 24
|
||||
|
||||
defmacro __using__(_) do
|
||||
quote do
|
||||
|
@ -49,6 +49,11 @@ defmodule Plausible.Site do
|
||||
# strictly necessary.
|
||||
field :from_cache?, :boolean, virtual: true, default: false
|
||||
|
||||
# Used in the context of paginated sites list to order in relation to
|
||||
# user's membership state. Currently it can be either "invitation"
|
||||
# or "site", where invitations are first.
|
||||
field :list_type, :string, virtual: true
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
|
@ -29,12 +29,27 @@ defmodule Plausible.Site.Memberships do
|
||||
|> Repo.exists?()
|
||||
end
|
||||
|
||||
@spec has_any_invitations?(String.t()) :: boolean()
|
||||
def has_any_invitations?(email) do
|
||||
@spec pending?(String.t()) :: boolean()
|
||||
def pending?(email) do
|
||||
Repo.exists?(
|
||||
from(i in Plausible.Auth.Invitation,
|
||||
where: i.email == ^email
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
@spec any_or_pending?(Plausible.Auth.User.t()) :: boolean()
|
||||
def any_or_pending?(user) do
|
||||
invitation_query =
|
||||
from(i in Plausible.Auth.Invitation,
|
||||
where: i.email == ^user.email,
|
||||
select: 1
|
||||
)
|
||||
|
||||
from(sm in Plausible.Site.Membership,
|
||||
where: sm.user_id == ^user.id or exists(invitation_query),
|
||||
select: 1
|
||||
)
|
||||
|> Repo.exists?()
|
||||
end
|
||||
end
|
||||
|
@ -2,6 +2,8 @@ defmodule Plausible.Sites do
|
||||
alias Plausible.{Repo, Site, Site.SharedLink, Billing.Quota}
|
||||
import Ecto.Query
|
||||
|
||||
@type list_opt() :: {:filter_by_domain, String.t()}
|
||||
|
||||
def get_by_domain(domain) do
|
||||
Repo.get_by(Site, domain: domain)
|
||||
end
|
||||
@ -10,6 +12,71 @@ defmodule Plausible.Sites do
|
||||
Repo.get_by!(Site, domain: domain)
|
||||
end
|
||||
|
||||
@spec list(Plausible.Auth.User.t(), map(), [list_opt()]) :: Scrivener.Page.t()
|
||||
def list(user, pagination_params, opts \\ []) do
|
||||
domain_filter = Keyword.get(opts, :filter_by_domain)
|
||||
|
||||
base_query =
|
||||
from(s in Plausible.Site,
|
||||
left_join: sm in assoc(s, :memberships),
|
||||
left_join: i in assoc(s, :invitations),
|
||||
where: sm.user_id == ^user.id or i.email == ^user.email,
|
||||
select: %{
|
||||
s
|
||||
| list_type:
|
||||
fragment(
|
||||
"""
|
||||
CASE WHEN ? IS NOT NULL THEN 'invitation'
|
||||
ELSE 'site'
|
||||
END
|
||||
""",
|
||||
i.id
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
memberships_query =
|
||||
from sm in Plausible.Site.Membership,
|
||||
where: sm.user_id == ^user.id
|
||||
|
||||
invitations_query =
|
||||
from i in Plausible.Auth.Invitation,
|
||||
where: i.email == ^user.email
|
||||
|
||||
sites_query =
|
||||
from(s in subquery(base_query),
|
||||
order_by: [asc: s.list_type, asc: s.domain],
|
||||
preload: [
|
||||
memberships: ^memberships_query,
|
||||
invitations: ^invitations_query
|
||||
]
|
||||
)
|
||||
|> maybe_filter_by_domain(domain_filter)
|
||||
|
||||
result =
|
||||
Repo.paginate(sites_query, pagination_params)
|
||||
|
||||
entries =
|
||||
Enum.map(result.entries, fn
|
||||
%{invitations: [invitation]} = site ->
|
||||
site = %{site | invitations: [], memberships: []}
|
||||
invitation = %{invitation | site: site}
|
||||
%{site | invitations: [invitation]}
|
||||
|
||||
site ->
|
||||
site
|
||||
end)
|
||||
|
||||
%{result | entries: entries}
|
||||
end
|
||||
|
||||
defp maybe_filter_by_domain(query, domain)
|
||||
when byte_size(domain) >= 1 and byte_size(domain) <= 64 do
|
||||
where(query, [s], ilike(s.domain, ^"%#{domain}%"))
|
||||
end
|
||||
|
||||
defp maybe_filter_by_domain(query, _), do: query
|
||||
|
||||
def create(user, params) do
|
||||
site_changeset = Site.changeset(%Site{}, params)
|
||||
|
||||
|
@ -1,8 +1,12 @@
|
||||
defmodule Plausible.Stats.Clickhouse do
|
||||
use Plausible.Repo
|
||||
use Plausible.ClickhouseRepo
|
||||
alias Plausible.Stats.Query
|
||||
use Plausible.Stats.Fragments
|
||||
|
||||
import Ecto.Query, only: [from: 2]
|
||||
|
||||
alias Plausible.Stats.Query
|
||||
|
||||
@no_ref "Direct / None"
|
||||
|
||||
@spec pageview_start_date_local(Plausible.Site.t()) :: Date.t() | nil
|
||||
@ -194,23 +198,108 @@ defmodule Plausible.Stats.Clickhouse do
|
||||
)
|
||||
end
|
||||
|
||||
def last_24h_visitors([]), do: %{}
|
||||
|
||||
def last_24h_visitors(sites) do
|
||||
site_id_to_domain_mapping = for site <- sites, do: {site.id, site.domain}, into: %{}
|
||||
|
||||
ClickhouseRepo.all(
|
||||
from(e in "events_v2",
|
||||
group_by: e.site_id,
|
||||
where: e.site_id in ^Map.keys(site_id_to_domain_mapping),
|
||||
where: e.timestamp > fragment("now() - INTERVAL 24 HOUR"),
|
||||
select: {e.site_id, fragment("uniq(user_id)")}
|
||||
)
|
||||
)
|
||||
|> Enum.map(fn {site_id, user_id} ->
|
||||
{site_id_to_domain_mapping[site_id], user_id}
|
||||
@spec empty_24h_visitors_hourly_intervals([Plausible.Site.t()], NaiveDateTime.t()) :: map()
|
||||
def empty_24h_visitors_hourly_intervals(sites, now \\ NaiveDateTime.utc_now()) do
|
||||
sites
|
||||
|> Enum.map(fn site ->
|
||||
{site.domain,
|
||||
%{
|
||||
intervals: empty_24h_intervals(now),
|
||||
visitors: 0,
|
||||
change: 0
|
||||
}}
|
||||
end)
|
||||
|> Enum.into(%{})
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
@spec last_24h_visitors_hourly_intervals([Plausible.Site.t()], NaiveDateTime.t()) :: map()
|
||||
def last_24h_visitors_hourly_intervals(sites, now \\ NaiveDateTime.utc_now())
|
||||
def last_24h_visitors_hourly_intervals([], _), do: %{}
|
||||
|
||||
def last_24h_visitors_hourly_intervals(sites, now) do
|
||||
site_id_to_domain_mapping = for site <- sites, do: {site.id, site.domain}, into: %{}
|
||||
now = now |> NaiveDateTime.truncate(:second)
|
||||
|
||||
placeholder = empty_24h_visitors_hourly_intervals(sites, now)
|
||||
|
||||
previous_query = visitors_24h_total(now, -48, -24, site_id_to_domain_mapping)
|
||||
|
||||
previous_result =
|
||||
previous_query
|
||||
|> ClickhouseRepo.all()
|
||||
|> Enum.reduce(%{}, fn
|
||||
%{total_visitors: total, site_id: site_id}, acc -> Map.put_new(acc, site_id, total)
|
||||
end)
|
||||
|
||||
total_q =
|
||||
visitors_24h_total(now, -24, 0, site_id_to_domain_mapping)
|
||||
|
||||
current_q =
|
||||
from(
|
||||
e in "events_v2",
|
||||
hints: [sample: 20_000_000],
|
||||
join: total_q in subquery(total_q),
|
||||
on: e.site_id == total_q.site_id,
|
||||
where: e.site_id in ^Map.keys(site_id_to_domain_mapping),
|
||||
where: e.timestamp >= ^NaiveDateTime.add(now, -24, :hour),
|
||||
where: e.timestamp <= ^now,
|
||||
select: %{
|
||||
site_id: e.site_id,
|
||||
interval: fragment("toStartOfHour(timestamp)"),
|
||||
visitors: uniq(e.user_id),
|
||||
total: fragment("any(total_visitors)")
|
||||
},
|
||||
group_by: [e.site_id, fragment("toStartOfHour(timestamp)")],
|
||||
order_by: [e.site_id, fragment("toStartOfHour(timestamp)")]
|
||||
)
|
||||
|
||||
result =
|
||||
current_q
|
||||
|> ClickhouseRepo.all()
|
||||
|> Enum.group_by(& &1.site_id)
|
||||
|> Enum.map(fn {site_id, entries} ->
|
||||
%{total: visitors} = List.first(entries)
|
||||
|
||||
full_entries =
|
||||
(entries ++ empty_24h_intervals(now))
|
||||
|> Enum.uniq_by(& &1.interval)
|
||||
|> Enum.sort_by(& &1.interval, NaiveDateTime)
|
||||
|
||||
change = Plausible.Stats.Compare.percent_change(previous_result[site_id], visitors) || 100
|
||||
|
||||
{site_id_to_domain_mapping[site_id],
|
||||
%{intervals: full_entries, visitors: visitors, change: change}}
|
||||
end)
|
||||
|> Map.new()
|
||||
|
||||
Map.merge(placeholder, result)
|
||||
end
|
||||
|
||||
defp visitors_24h_total(now, offset1, offset2, site_id_to_domain_mapping) do
|
||||
from(e in "events_v2",
|
||||
hints: [sample: 20_000_000],
|
||||
where: e.site_id in ^Map.keys(site_id_to_domain_mapping),
|
||||
where: e.timestamp >= ^NaiveDateTime.add(now, offset1, :hour),
|
||||
where: e.timestamp <= ^NaiveDateTime.add(now, offset2, :hour),
|
||||
select: %{
|
||||
site_id: e.site_id,
|
||||
total_visitors: fragment("toUInt64(round(uniq(user_id) * any(_sample_factor)))")
|
||||
},
|
||||
group_by: [e.site_id]
|
||||
)
|
||||
end
|
||||
|
||||
defp empty_24h_intervals(now) do
|
||||
first = NaiveDateTime.add(now, -23, :hour)
|
||||
{:ok, time} = Time.new(first.hour, 0, 0)
|
||||
first = NaiveDateTime.new!(NaiveDateTime.to_date(first), time)
|
||||
|
||||
for offset <- 0..24 do
|
||||
%{
|
||||
interval: NaiveDateTime.add(first, offset, :hour),
|
||||
visitors: 0
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
|
||||
|
@ -13,7 +13,15 @@ defmodule Plausible.Stats.Compare do
|
||||
percent_change(old_count, new_count)
|
||||
end
|
||||
|
||||
defp percent_change(old_count, new_count) do
|
||||
def percent_change(nil, _new_count), do: nil
|
||||
|
||||
def percent_change(%Money{} = old_count, %Money{} = new_count) do
|
||||
old_count = old_count |> Money.to_decimal() |> Decimal.to_float()
|
||||
new_count = new_count |> Money.to_decimal() |> Decimal.to_float()
|
||||
percent_change(old_count, new_count)
|
||||
end
|
||||
|
||||
def percent_change(old_count, new_count) do
|
||||
cond do
|
||||
old_count == 0 and new_count > 0 ->
|
||||
100
|
||||
|
@ -17,15 +17,13 @@ defmodule PlausibleWeb.Api.InternalController do
|
||||
end
|
||||
end
|
||||
|
||||
def sites(conn, params) do
|
||||
def sites(conn, _params) do
|
||||
current_user = conn.assigns[:current_user]
|
||||
|
||||
if current_user do
|
||||
sites =
|
||||
sites_for(current_user, params)
|
||||
|> buildResponse(conn)
|
||||
sites = sites_for(current_user)
|
||||
|
||||
json(conn, sites)
|
||||
json(conn, %{data: sites})
|
||||
else
|
||||
PlausibleWeb.Api.Helpers.unauthorized(
|
||||
conn,
|
||||
@ -67,23 +65,18 @@ defmodule PlausibleWeb.Api.InternalController do
|
||||
end
|
||||
end
|
||||
|
||||
defp sites_for(user, params) do
|
||||
Repo.paginate(
|
||||
defp sites_for(user) do
|
||||
Repo.all(
|
||||
from(
|
||||
s in Site,
|
||||
join: sm in Site.Membership,
|
||||
on: sm.site_id == s.id,
|
||||
where: sm.user_id == ^user.id,
|
||||
order_by: s.domain
|
||||
),
|
||||
params
|
||||
order_by: s.domain,
|
||||
select: %{domain: s.domain},
|
||||
# there are keyboard shortcuts for switching between sites, hence 9
|
||||
limit: 9
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
defp buildResponse({sites, pagination}, conn) do
|
||||
%{
|
||||
data: Enum.map(sites, &%{domain: &1.domain}),
|
||||
pagination: Phoenix.Pagination.JSON.paginate(conn, pagination)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
@ -454,33 +454,12 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
end
|
||||
end
|
||||
|
||||
defp calculate_change(:bounce_rate, old_count, new_count) do
|
||||
def calculate_change(:bounce_rate, old_count, new_count) do
|
||||
if old_count > 0, do: new_count - old_count
|
||||
end
|
||||
|
||||
defp calculate_change(_metric, old_count, new_count) do
|
||||
percent_change(old_count, new_count)
|
||||
end
|
||||
|
||||
defp percent_change(nil, _new_count), do: nil
|
||||
|
||||
defp percent_change(%Money{} = old_count, %Money{} = new_count) do
|
||||
old_count = old_count |> Money.to_decimal() |> Decimal.to_float()
|
||||
new_count = new_count |> Money.to_decimal() |> Decimal.to_float()
|
||||
percent_change(old_count, new_count)
|
||||
end
|
||||
|
||||
defp percent_change(old_count, new_count) do
|
||||
cond do
|
||||
old_count == 0 and new_count > 0 ->
|
||||
100
|
||||
|
||||
old_count == 0 and new_count == 0 ->
|
||||
0
|
||||
|
||||
true ->
|
||||
round((new_count - old_count) / old_count * 100)
|
||||
end
|
||||
def calculate_change(_metric, old_count, new_count) do
|
||||
Stats.Compare.percent_change(old_count, new_count)
|
||||
end
|
||||
|
||||
def sources(conn, params) do
|
||||
@ -1361,8 +1340,8 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
|> Query.put_filter(filter_name, {:member, items})
|
||||
|> Query.remove_event_filters([:goal, :props])
|
||||
|
||||
# Here, we're always only interested in the first page of results
|
||||
# - the :member filter makes sure that the results always match with
|
||||
# Here, we're always only interested in the first page of results
|
||||
# - the :member filter makes sure that the results always match with
|
||||
# the items in the given breakdown_results list
|
||||
pagination = {elem(pagination, 0), 1}
|
||||
|
||||
|
@ -68,7 +68,7 @@ defmodule PlausibleWeb.AuthController do
|
||||
|
||||
render(conn, "activate.html",
|
||||
has_email_code?: Plausible.Users.has_email_code?(user),
|
||||
has_any_invitations?: Plausible.Site.Memberships.has_any_invitations?(user.email),
|
||||
has_any_invitations?: Plausible.Site.Memberships.pending?(user.email),
|
||||
has_any_memberships?: Plausible.Site.Memberships.any?(user),
|
||||
layout: {PlausibleWeb.LayoutView, "focus.html"}
|
||||
)
|
||||
@ -77,7 +77,7 @@ defmodule PlausibleWeb.AuthController do
|
||||
def activate(conn, %{"code" => code}) do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
has_any_invitations? = Plausible.Site.Memberships.has_any_invitations?(user.email)
|
||||
has_any_invitations? = Plausible.Site.Memberships.pending?(user.email)
|
||||
has_any_memberships? = Plausible.Site.Memberships.any?(user)
|
||||
|
||||
case Auth.EmailVerification.verify_code(user, code) do
|
||||
|
@ -6,48 +6,7 @@ defmodule PlausibleWeb.SiteController do
|
||||
plug PlausibleWeb.RequireAccountPlug
|
||||
|
||||
plug PlausibleWeb.AuthorizeSiteAccess,
|
||||
[:owner, :admin, :super_admin] when action not in [:index, :new, :create_site]
|
||||
|
||||
def index(conn, params) do
|
||||
user = conn.assigns[:current_user]
|
||||
|
||||
invitations =
|
||||
Repo.all(
|
||||
from i in Plausible.Auth.Invitation,
|
||||
where: i.email == ^user.email
|
||||
)
|
||||
|> Repo.preload(:site)
|
||||
|
||||
invitation_site_ids = Enum.map(invitations, & &1.site.id)
|
||||
|
||||
{sites, pagination} =
|
||||
Repo.paginate(
|
||||
from(s in Plausible.Site,
|
||||
join: sm in Plausible.Site.Membership,
|
||||
on: sm.site_id == s.id,
|
||||
where: sm.user_id == ^user.id,
|
||||
where: s.id not in ^invitation_site_ids,
|
||||
order_by: s.domain,
|
||||
preload: [memberships: sm]
|
||||
),
|
||||
params
|
||||
)
|
||||
|
||||
user_owns_sites =
|
||||
Enum.any?(sites, fn site -> List.first(site.memberships).role == :owner end) ||
|
||||
Plausible.Auth.user_owns_sites?(user)
|
||||
|
||||
visitors =
|
||||
Plausible.Stats.Clickhouse.last_24h_visitors(sites ++ Enum.map(invitations, & &1.site))
|
||||
|
||||
render(conn, "index.html",
|
||||
invitations: invitations,
|
||||
sites: sites,
|
||||
visitors: visitors,
|
||||
pagination: pagination,
|
||||
needs_to_upgrade: user_owns_sites && Plausible.Billing.check_needs_to_upgrade(user)
|
||||
)
|
||||
end
|
||||
[:owner, :admin, :super_admin] when action not in [:new, :create_site]
|
||||
|
||||
def new(conn, _params) do
|
||||
current_user = conn.assigns[:current_user]
|
||||
|
78
lib/plausible_web/live/components/pagination.ex
Normal file
78
lib/plausible_web/live/components/pagination.ex
Normal file
@ -0,0 +1,78 @@
|
||||
defmodule PlausibleWeb.Live.Components.Pagination do
|
||||
@moduledoc """
|
||||
Pagination components for LiveViews.
|
||||
"""
|
||||
use Phoenix.Component
|
||||
|
||||
def pagination(assigns) do
|
||||
~H"""
|
||||
<nav
|
||||
class="px-4 py-3 flex items-center justify-between border-t border-gray-200 dark:border-gray-500 sm:px-6"
|
||||
aria-label="Pagination"
|
||||
>
|
||||
<div class="hidden sm:block">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
<%= render_slot(@inner_block) %>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-1 flex justify-between sm:justify-end">
|
||||
<.pagination_link
|
||||
page_number={@page_number}
|
||||
total_pages={@total_pages}
|
||||
uri={@uri}
|
||||
type={:prev}
|
||||
/>
|
||||
<.pagination_link
|
||||
page_number={@page_number}
|
||||
total_pages={@total_pages}
|
||||
class="ml-4"
|
||||
uri={@uri}
|
||||
type={:next}
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :class, :string, default: nil
|
||||
attr :uri, URI, required: true
|
||||
attr :type, :atom, required: true
|
||||
attr :page_number, :integer, required: true
|
||||
attr :total_pages, :integer, required: true
|
||||
|
||||
defp pagination_link(assigns) do
|
||||
{active?, uri} =
|
||||
case {assigns.type, assigns.page_number, assigns.total_pages} do
|
||||
{:next, n, total} when n < total ->
|
||||
params = URI.decode_query(assigns.uri.query, %{"page" => n + 1})
|
||||
{true, %{assigns.uri | query: URI.encode_query(params)}}
|
||||
|
||||
{:prev, n, _total} when n > 1 ->
|
||||
params = URI.decode_query(assigns.uri.query, %{"page" => n - 1})
|
||||
{true, %{assigns.uri | query: URI.encode_query(params)}}
|
||||
|
||||
{_, _, _} ->
|
||||
{false, nil}
|
||||
end
|
||||
|
||||
assigns = assign(assigns, uri: active? && URI.to_string(uri), active?: active?)
|
||||
|
||||
~H"""
|
||||
<.link
|
||||
navigate={@uri}
|
||||
class={[
|
||||
"pagination-link relative inline-flex items-center px-4 py-2 border text-sm font-medium rounded-md",
|
||||
if @active? do
|
||||
"active button "
|
||||
else
|
||||
"inactive border-gray-300 text-gray-300 dark:border-gray-500 dark:bg-gray-800 dark:text-gray-600 hover:shadow-none hover:bg-gray-300 cursor-not-allowed"
|
||||
end,
|
||||
@class
|
||||
]}
|
||||
>
|
||||
<span :if={@type == :prev}>← Previous</span>
|
||||
<span :if={@type == :next}>Next →</span>
|
||||
</.link>
|
||||
"""
|
||||
end
|
||||
end
|
83
lib/plausible_web/live/components/visitors.ex
Normal file
83
lib/plausible_web/live/components/visitors.ex
Normal file
@ -0,0 +1,83 @@
|
||||
defmodule PlausibleWeb.Live.Components.Visitors do
|
||||
@moduledoc """
|
||||
Component rendering mini-graph of site's visitors over the last 24 hours.
|
||||
|
||||
The `gradient_defs` component should be rendered once before using `chart`
|
||||
one or more times.
|
||||
|
||||
Accepts input generated via `Plausible.Stats.Clickhouse.last_24h_visitors_hourly_intervals/2`.
|
||||
"""
|
||||
|
||||
use Phoenix.Component
|
||||
|
||||
attr :intervals, :list, required: true
|
||||
attr :height, :integer, default: 50
|
||||
attr :tick, :integer, default: 20
|
||||
|
||||
def chart(assigns) do
|
||||
points =
|
||||
assigns.intervals
|
||||
|> scale(assigns.height)
|
||||
|> Enum.with_index(fn scaled_value, index ->
|
||||
"#{index * assigns.tick},#{scaled_value}"
|
||||
end)
|
||||
|
||||
clip_points =
|
||||
List.flatten([
|
||||
"0,#{assigns.height + 1}",
|
||||
points,
|
||||
"#{(length(points) - 1) * assigns.tick},#{assigns.height + 1}",
|
||||
"0,#{assigns.height + 1}"
|
||||
])
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:points_len, length(points))
|
||||
|> assign(:points, Enum.join(points, " "))
|
||||
|> assign(:clip_points, Enum.join(clip_points, " "))
|
||||
|> assign(:id, Ecto.UUID.generate())
|
||||
|
||||
~H"""
|
||||
<svg viewBox={"0 -1 #{(@points_len - 1) * @tick} #{@height + 1}"} class="chart w-full mb-2">
|
||||
<defs>
|
||||
<clipPath id={"gradient-cut-off-#{@id}"}>
|
||||
<polyline points={@clip_points} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<rect
|
||||
x="0"
|
||||
y="1"
|
||||
width={@points_len * @tick}
|
||||
height={@height}
|
||||
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} />
|
||||
</svg>
|
||||
"""
|
||||
end
|
||||
|
||||
def gradient_defs(assigns) do
|
||||
~H"""
|
||||
<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)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
"""
|
||||
end
|
||||
|
||||
defp scale(data, target_range) do
|
||||
max_value = Enum.max_by(data, fn %{visitors: visitors} -> visitors end)
|
||||
|
||||
scaling_factor =
|
||||
if max_value.visitors > 0, do: target_range / max_value.visitors, else: 0
|
||||
|
||||
Enum.map(data, fn %{visitors: visitors} ->
|
||||
round(target_range - visitors * scaling_factor)
|
||||
end)
|
||||
end
|
||||
end
|
531
lib/plausible_web/live/sites.ex
Normal file
531
lib/plausible_web/live/sites.ex
Normal file
@ -0,0 +1,531 @@
|
||||
defmodule PlausibleWeb.Live.Sites do
|
||||
@moduledoc """
|
||||
LiveView for sites index.
|
||||
"""
|
||||
|
||||
use Phoenix.LiveView
|
||||
use Phoenix.HTML
|
||||
|
||||
import PlausibleWeb.Components.Generic
|
||||
import PlausibleWeb.Live.Components.Pagination
|
||||
|
||||
alias Plausible.Auth
|
||||
alias Plausible.Repo
|
||||
alias Plausible.Site
|
||||
alias Plausible.Sites
|
||||
|
||||
def mount(params, %{"current_user_id" => user_id}, socket) do
|
||||
uri =
|
||||
("/sites?" <> URI.encode_query(Map.take(params, ["filter_text"])))
|
||||
|> URI.new!()
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:uri, uri)
|
||||
|> assign(:filter_text, params["filter_text"] || "")
|
||||
|> assign(:user, Repo.get!(Auth.User, user_id))
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
def handle_params(params, _uri, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:params, params)
|
||||
|> load_sites()
|
||||
|> assign_new(:has_sites?, fn %{user: user} ->
|
||||
Site.Memberships.any_or_pending?(user)
|
||||
end)
|
||||
|> assign_new(:needs_to_upgrade, fn %{user: user, sites: sites} ->
|
||||
user_owns_sites =
|
||||
Enum.any?(sites.entries, fn site ->
|
||||
List.first(site.memberships ++ site.invitations).role == :owner
|
||||
end) ||
|
||||
Auth.user_owns_sites?(user)
|
||||
|
||||
user_owns_sites && Plausible.Billing.check_needs_to_upgrade(user)
|
||||
end)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def render(assigns) do
|
||||
invitations =
|
||||
assigns.sites.entries
|
||||
|> Enum.filter(&(&1.list_type == "invitation"))
|
||||
|> Enum.flat_map(& &1.invitations)
|
||||
|
||||
assigns = assign(assigns, :invitations, invitations)
|
||||
|
||||
~H"""
|
||||
<div
|
||||
x-data={"{selectedInvitation: null, invitationOpen: false, invitations: #{Enum.map(@invitations, &({&1.invitation_id, &1})) |> Enum.into(%{}) |> Jason.encode!}}"}
|
||||
x-on:keydown.escape.window="invitationOpen = false"
|
||||
class="container pt-6"
|
||||
>
|
||||
<PlausibleWeb.Live.Components.Visitors.gradient_defs />
|
||||
<.upgrade_nag_screen :if={@needs_to_upgrade == {:needs_to_upgrade, :no_active_subscription}} />
|
||||
|
||||
<div class="mt-6 pb-5 border-b border-gray-200 dark:border-gray-500 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 flex-shrink-0">
|
||||
My Sites
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 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" class="button">
|
||||
+ Add Website
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
:if={String.trim(@filter_text) != "" and @sites.entries == []}
|
||||
class="mt-4 dark:text-gray-100"
|
||||
>
|
||||
No sites found. Please search for something else.
|
||||
</p>
|
||||
|
||||
<div :if={@has_sites?}>
|
||||
<ul class="my-6 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<%= for site <- @sites.entries do %>
|
||||
<.site
|
||||
:if={site.list_type == "site"}
|
||||
site={site}
|
||||
hourly_stats={@hourly_stats[site.domain]}
|
||||
/>
|
||||
<.invitation
|
||||
:if={site.list_type == "invitation"}
|
||||
site={site}
|
||||
invitation={hd(site.invitations)}
|
||||
hourly_stats={@hourly_stats[site.domain]}
|
||||
/>
|
||||
<% end %>
|
||||
</ul>
|
||||
|
||||
<.pagination
|
||||
:if={@sites.total_pages > 1}
|
||||
id="sites-pagination"
|
||||
uri={@uri}
|
||||
page_number={@sites.page_number}
|
||||
total_pages={@sites.total_pages}
|
||||
>
|
||||
Total of <span class="font-medium"><%= @sites.total_entries %></span> sites
|
||||
</.pagination>
|
||||
<.invitation_modal
|
||||
:if={Enum.any?(@sites.entries, &(&1.list_type == "invitation"))}
|
||||
user={@user}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def upgrade_nag_screen(assigns) do
|
||||
~H"""
|
||||
<div class="rounded-md bg-yellow-100 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg
|
||||
class="h-5 w-5 text-yellow-400"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">
|
||||
Payment required
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700">
|
||||
<p>
|
||||
To access the sites you own, you need to subscribe to a monthly or yearly payment plan. <%= link(
|
||||
"Upgrade now →",
|
||||
to: "/settings",
|
||||
class: "text-sm font-medium text-yellow-800"
|
||||
) %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :site, Plausible.Site, required: true
|
||||
attr :invitation, Plausible.Auth.Invitation, required: true
|
||||
attr :hourly_stats, :map, required: true
|
||||
|
||||
def invitation(assigns) do
|
||||
~H"""
|
||||
<li
|
||||
class="group cursor-pointer"
|
||||
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-lg shadow p-4 group-hover:shadow-lg cursor-pointer">
|
||||
<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="w-4 h-4 flex-shrink-0 mt-px"
|
||||
/>
|
||||
<div class="flex-1 truncate -mt-px">
|
||||
<h3 class="text-gray-900 font-medium text-lg truncate dark:text-gray-100">
|
||||
<%= @site.domain %>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
|
||||
Pending invitation
|
||||
</span>
|
||||
</div>
|
||||
<.site_stats hourly_stats={@hourly_stats} />
|
||||
</div>
|
||||
</li>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :site, Plausible.Site, required: true
|
||||
attr :hourly_stats, :map, required: true
|
||||
|
||||
def site(assigns) do
|
||||
~H"""
|
||||
<li class="group relative" data-domain={@site.domain}>
|
||||
<.unstyled_link href={"/#{URI.encode_www_form(@site.domain)}"}>
|
||||
<div class="col-span-1 bg-white dark:bg-gray-800 rounded-lg shadow p-4 group-hover:shadow-lg cursor-pointer">
|
||||
<div class="w-full flex items-center justify-between space-x-4">
|
||||
<.favicon domain={@site.domain} />
|
||||
<div class="flex-1 -mt-px w-full">
|
||||
<h3
|
||||
class="text-gray-900 font-medium text-lg truncate dark:text-gray-100"
|
||||
style="width: calc(100% - 4rem)"
|
||||
>
|
||||
<%= @site.domain %>
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<.site_stats hourly_stats={@hourly_stats} />
|
||||
</div>
|
||||
</.unstyled_link>
|
||||
<%= if List.first(@site.memberships).role != :viewer do %>
|
||||
<.unstyled_link
|
||||
href={"/#{URI.encode_www_form(@site.domain)}/settings"}
|
||||
class="absolute top-0 right-0 p-4 mt-1"
|
||||
>
|
||||
<Heroicons.cog_8_tooth class="w-4 h-4 text-gray-800 dark:text-gray-400" />
|
||||
</.unstyled_link>
|
||||
<% end %>
|
||||
</li>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :hourly_stats, :map, required: true
|
||||
|
||||
def site_stats(assigns) do
|
||||
~H"""
|
||||
<div class="pl-8 mt-2 flex items-center justify-between">
|
||||
<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>
|
||||
</span>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :change, :integer, required: true
|
||||
|
||||
def percentage_change(assigns) do
|
||||
~H"""
|
||||
<p :if={@change != 0} class="dark:text-gray-100">
|
||||
<span :if={@change > 0} class="font-semibold text-green-500">↑</span>
|
||||
<span :if={@change < 0} class="font-semibold text-red-400">↓</span>
|
||||
<%= abs(@change) %>%
|
||||
</p>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :user, Plausible.Auth.User, required: true
|
||||
|
||||
def invitation_modal(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
x-cloak
|
||||
x-show="invitationOpen"
|
||||
class="fixed z-10 inset-0 overflow-y-auto"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div
|
||||
x-show="invitationOpen"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 bg-gray-500 dark:bg-gray-800 bg-opacity-75 dark:bg-opacity-75 transition-opacity"
|
||||
aria-hidden="true"
|
||||
x-on:click="invitationOpen = false"
|
||||
>
|
||||
</div>
|
||||
<!-- This element is to trick the browser into centering the modal contents. -->
|
||||
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
|
||||
<div
|
||||
x-show="invitationOpen"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
class="inline-block align-bottom bg-white dark:bg-gray-900 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"
|
||||
>
|
||||
<div class="bg-white dark:bg-gray-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div class="hidden sm:block absolute top-0 right-0 pt-4 pr-4">
|
||||
<button
|
||||
x-on:click="invitationOpen = false"
|
||||
class="bg-white dark:bg-gray-800 rounded-md text-gray-400 dark:text-gray-500 hover:text-gray-500 dark:hover:text-gray-400 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
<span class="sr-only">Close</span>
|
||||
<Heroicons.x_mark class="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="sm:flex sm:items-start">
|
||||
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-green-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<Heroicons.user_group class="h-6 w-6" />
|
||||
</div>
|
||||
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3
|
||||
class="text-lg leading-6 font-medium text-gray-900 dark:text-gray-100"
|
||||
id="modal-title"
|
||||
>
|
||||
Invitation for
|
||||
<span x-text="selectedInvitation && selectedInvitation.site.domain"></span>
|
||||
</h3>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-200">
|
||||
You've been invited to the
|
||||
<span x-text="selectedInvitation && selectedInvitation.site.domain"></span>
|
||||
analytics dashboard as <b
|
||||
class="capitalize"
|
||||
x-text="selectedInvitation && selectedInvitation.role"
|
||||
>Admin</b>.
|
||||
</p>
|
||||
<p
|
||||
x-show="selectedInvitation && selectedInvitation.role === 'owner'"
|
||||
class="mt-2 text-sm text-gray-500 dark:text-gray-200"
|
||||
>
|
||||
If you accept the ownership transfer, you will be responsible for billing going forward.
|
||||
<div
|
||||
:if={is_nil(@user.trial_expiry_date) and is_nil(@user.subscription)}
|
||||
class="mt-4"
|
||||
>
|
||||
You will have to enter your card details immediately with no 30-day trial.
|
||||
</div>
|
||||
<div :if={Plausible.Billing.on_trial?(@user)} class="mt-4">
|
||||
<Heroicons.exclamation_triangle class="w-4 h-4 inline-block text-red-500" />
|
||||
Your 30-day free trial will end immediately and
|
||||
<strong>you will have to enter your card details</strong>
|
||||
to keep using Plausible.
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-gray-850 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button
|
||||
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-700 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
data-method="post"
|
||||
data-csrf={Plug.CSRFProtection.get_csrf_token()}
|
||||
x-bind:data-to="selectedInvitation && ('/sites/invitations/' + selectedInvitation.invitation_id + '/accept')"
|
||||
>
|
||||
Accept & Continue
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-500 shadow-sm px-4 py-2 bg-white dark:bg-gray-800 text-base font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-850 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
data-method="post"
|
||||
data-csrf={Plug.CSRFProtection.get_csrf_token()}
|
||||
x-bind:data-to="selectedInvitation && ('/sites/invitations/' + selectedInvitation.invitation_id + '/reject')"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :filter_text, :string, default: ""
|
||||
attr :uri, URI, required: true
|
||||
|
||||
def search_form(assigns) do
|
||||
~H"""
|
||||
<form id="filter-form" phx-change="filter" action={@uri} method="GET">
|
||||
<div class="text-gray-800 text-sm inline-flex items-center">
|
||||
<div class="relative rounded-md flex">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<Heroicons.magnifying_glass class="feather mr-1 dark:text-gray-300" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="filter_text"
|
||||
id="filter-text"
|
||||
phx-debounce={200}
|
||||
class="pl-8 dark:bg-gray-900 dark:text-gray-300 focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:bg-gray-800"
|
||||
placeholder="Press / to search sites"
|
||||
autocomplete="off"
|
||||
value={@filter_text}
|
||||
x-ref="filter_text"
|
||||
x-on:keydown.escape="$refs.filter_text.blur(); $refs.reset_filter?.dispatchEvent(new Event('click', {bubbles: true, cancelable: true}));"
|
||||
x-on:keydown.prevent.slash.window="$refs.filter_text.focus(); $refs.filter_text.select();"
|
||||
x-on:blur="$refs.filter_text.placeholder = 'Press / to search sites';"
|
||||
x-on:focus="$refs.filter_text.placeholder = 'Search sites';"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
:if={String.trim(@filter_text) != ""}
|
||||
class="phx-change-loading:hidden ml-2"
|
||||
phx-click="reset-filter-text"
|
||||
id="reset-filter"
|
||||
x-ref="reset_filter"
|
||||
type="button"
|
||||
>
|
||||
<Heroicons.backspace class="feather hover:text-red-500 dark:text-gray-300 dark:hover:text-red-500" />
|
||||
</button>
|
||||
|
||||
<.spinner class="hidden phx-change-loading:inline ml-2" />
|
||||
</div>
|
||||
</form>
|
||||
"""
|
||||
end
|
||||
|
||||
def favicon(assigns) do
|
||||
src = "/favicon/sources/#{assigns.domain}"
|
||||
assigns = assign(assigns, :src, src)
|
||||
|
||||
~H"""
|
||||
<img src={@src} class="w-4 h-4 flex-shrink-0 mt-px" />
|
||||
"""
|
||||
end
|
||||
|
||||
attr :class, :any, default: ""
|
||||
|
||||
def spinner(assigns) do
|
||||
~H"""
|
||||
<svg
|
||||
class={["animate-spin h-4 w-4 text-indigo-500", @class]}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4">
|
||||
</circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
>
|
||||
</path>
|
||||
</svg>
|
||||
"""
|
||||
end
|
||||
|
||||
def handle_event("filter", %{"filter_text" => filter_text}, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> reset_pagination()
|
||||
|> set_filter_text(filter_text)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("reset-filter-text", _params, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> reset_pagination()
|
||||
|> set_filter_text("")
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp load_sites(%{assigns: assigns} = socket) do
|
||||
sites = Sites.list(assigns.user, assigns.params, filter_by_domain: assigns.filter_text)
|
||||
|
||||
hourly_stats =
|
||||
if connected?(socket) do
|
||||
Plausible.Stats.Clickhouse.last_24h_visitors_hourly_intervals(sites.entries)
|
||||
else
|
||||
Plausible.Stats.Clickhouse.empty_24h_visitors_hourly_intervals(sites.entries)
|
||||
end
|
||||
|
||||
assign(
|
||||
socket,
|
||||
sites: sites,
|
||||
hourly_stats: hourly_stats
|
||||
)
|
||||
end
|
||||
|
||||
defp set_filter_text(socket, filter_text) do
|
||||
uri = socket.assigns.uri
|
||||
|
||||
uri_params =
|
||||
uri.query
|
||||
|> URI.decode_query()
|
||||
|> Map.put("filter_text", filter_text)
|
||||
|> URI.encode_query()
|
||||
|
||||
uri = %{uri | query: uri_params}
|
||||
|
||||
socket
|
||||
|> assign(:filter_text, filter_text)
|
||||
|> assign(:uri, uri)
|
||||
|> push_patch(to: URI.to_string(uri), replace: true)
|
||||
end
|
||||
|
||||
defp reset_pagination(socket) do
|
||||
pagination_fields = ["page"]
|
||||
uri = socket.assigns.uri
|
||||
|
||||
uri_params =
|
||||
uri.query
|
||||
|> URI.decode_query()
|
||||
|> Map.drop(pagination_fields)
|
||||
|> URI.encode_query()
|
||||
|
||||
assign(socket,
|
||||
uri: %{uri | query: uri_params},
|
||||
params: Map.drop(socket.assigns.params, pagination_fields)
|
||||
)
|
||||
end
|
||||
end
|
@ -29,6 +29,10 @@ defmodule PlausibleWeb.Router do
|
||||
plug :put_root_layout, html: {PlausibleWeb.LayoutView, :focus}
|
||||
end
|
||||
|
||||
pipeline :app_layout do
|
||||
plug :put_root_layout, html: {PlausibleWeb.LayoutView, :app}
|
||||
end
|
||||
|
||||
pipeline :api do
|
||||
plug :accepts, ["json"]
|
||||
plug :fetch_session
|
||||
@ -210,7 +214,12 @@ defmodule PlausibleWeb.Router do
|
||||
get "/billing/upgrade-success", BillingController, :upgrade_success
|
||||
get "/billing/subscription/ping", BillingController, :ping_subscription
|
||||
|
||||
get "/sites", SiteController, :index
|
||||
scope alias: Live, assigns: %{connect_live_socket: true} do
|
||||
pipe_through [:app_layout, PlausibleWeb.RequireAccountPlug]
|
||||
|
||||
live "/sites", Sites, :index, as: :site
|
||||
end
|
||||
|
||||
get "/sites/new", SiteController, :new
|
||||
post "/sites", SiteController, :create_site
|
||||
get "/sites/:website/change-domain", SiteController, :change_domain
|
||||
|
@ -1,188 +0,0 @@
|
||||
<div x-data="{selectedInvitation: null, invitationOpen: false, invitations: <%= Enum.map(@invitations, &({&1.invitation_id, &1})) |> Enum.into(%{}) |> Jason.encode! %>}" @keydown.escape.window="invitationOpen = false" class="container pt-6">
|
||||
|
||||
<%= if @needs_to_upgrade == {:needs_to_upgrade, :no_active_subscription} do %>
|
||||
<div class="rounded-md bg-yellow-100 p-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-yellow-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-yellow-800">
|
||||
Payment required
|
||||
</h3>
|
||||
<div class="mt-2 text-sm text-yellow-700">
|
||||
<p>
|
||||
To access the sites you own, you need to subscribe to a monthly or yearly payment plan.
|
||||
<%= link("Upgrade now →", to: "/settings", class: "text-sm font-medium text-yellow-800") %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-6 pb-5 border-b border-gray-200 dark:border-gray-500 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 flex-shrink-0">
|
||||
My Sites
|
||||
</h2>
|
||||
<a href="/sites/new" class="button my-2 sm:my-0 w-auto">+ Add Website</a>
|
||||
</div>
|
||||
|
||||
<ul class="my-6 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<%= if Enum.empty?(@sites ++ @invitations) do %>
|
||||
<p class="dark:text-gray-100">You don't have any sites yet</p>
|
||||
<% end %>
|
||||
|
||||
<%= for invitation <- @invitations do %>
|
||||
<div class="group cursor-pointer" @click="invitationOpen = true; selectedInvitation = invitations['<%= invitation.invitation_id %>']">
|
||||
<li class="col-span-1 bg-white dark:bg-gray-800 rounded-lg shadow p-4 group-hover:shadow-lg cursor-pointer">
|
||||
<div class="w-full flex items-center justify-between space-x-4">
|
||||
<img src="/favicon/sources/<%= invitation.site.domain %>" onerror="this.onerror=null; this.src='/favicon/sources/placeholder';" class="w-4 h-4 flex-shrink-0 mt-px">
|
||||
<div class="flex-1 truncate -mt-px">
|
||||
<h3 class="text-gray-900 font-medium text-lg truncate dark:text-gray-100"><%= invitation.site.domain %></h3>
|
||||
</div>
|
||||
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">
|
||||
Pending invitation
|
||||
</span>
|
||||
</div>
|
||||
<div class="pl-8 mt-2 flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400 text-sm truncate">
|
||||
<span class="text-gray-800 dark:text-gray-200">
|
||||
<b><%= PlausibleWeb.StatsView.large_number_format(Map.get(@visitors, invitation.site.domain, 0)) %></b> visitor<%= if Map.get(@visitors, invitation.site.domain, 0) != 1 do %>s<% end %> in last 24h
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= for site <- @sites do %>
|
||||
<div class="relative">
|
||||
<%= link(to: "/" <> URI.encode_www_form(site.domain)) do %>
|
||||
<li class="col-span-1 bg-white dark:bg-gray-800 rounded-lg shadow p-4 group-hover:shadow-lg cursor-pointer">
|
||||
<div class="w-full flex items-center justify-between space-x-4">
|
||||
<img src="/favicon/sources/<%= site.domain %>" class="w-4 h-4 flex-shrink-0 mt-px">
|
||||
<div class="flex-1 -mt-px w-full">
|
||||
<h3 class="text-gray-900 font-medium text-lg truncate dark:text-gray-100" style="width: calc(100% - 4rem)"><%= site.domain %></h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pl-8 mt-2 flex items-center justify-between">
|
||||
<span class="text-gray-600 dark:text-gray-400 text-sm truncate">
|
||||
<span class="text-gray-800 dark:text-gray-200">
|
||||
<b><%= PlausibleWeb.StatsView.large_number_format(Map.get(@visitors, site.domain, 0)) %></b> visitor<%= if Map.get(@visitors, site.domain, 0) != 1 do %>s<% end %> in last 24h
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
<% end %>
|
||||
<%= if List.first(site.memberships).role != :viewer do %>
|
||||
<%= link(to: "/" <> URI.encode_www_form(site.domain) <> "/settings", class: "absolute top-0 right-0 p-4 mt-1") do %>
|
||||
<svg class="w-5 h-5 text-gray-600 dark:text-gray-400 transition hover:text-gray-900 dark:hover:text-gray-100" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"></path></svg>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</ul>
|
||||
|
||||
<%= if @pagination.total_pages > 1 do %>
|
||||
<%= pagination @conn, @pagination, [current_class: "is-current"], fn p -> %>
|
||||
<nav class="px-4 py-3 flex items-center justify-between border-t border-gray-200 dark:border-gray-500 sm:px-6" aria-label="Pagination">
|
||||
<div class="hidden sm:block">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Showing page
|
||||
<span class="font-medium"><%= @pagination.page %></span>
|
||||
of
|
||||
<span class="font-medium"><%= @pagination.total_pages %></span>
|
||||
total
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-1 flex justify-between sm:justify-end">
|
||||
<%= pagination_link(p, :previous, label: "← Previous", class: "pagination-link relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white dark:bg-gray-100 hover:bg-gray-50", force_show: true) %>
|
||||
<%= pagination_link(p, :next, label: "Next →", class: "pagination-link ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white dark:bg-gray-100 hover:bg-gray-50", force_show: true) %>
|
||||
</div>
|
||||
</nav>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= if Enum.any?(@invitations) do %>
|
||||
<div x-cloak x-show="invitationOpen" class="fixed z-10 inset-0 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div
|
||||
x-show="invitationOpen"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 bg-gray-500 dark:bg-gray-800 bg-opacity-75 dark:bg-opacity-75 transition-opacity"
|
||||
aria-hidden="true"
|
||||
@click="invitationOpen = false"
|
||||
></div>
|
||||
|
||||
<!-- This element is to trick the browser into centering the modal contents. -->
|
||||
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">​</span>
|
||||
|
||||
<div
|
||||
x-show="invitationOpen"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
|
||||
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
class="inline-block align-bottom bg-white dark:bg-gray-900 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full"
|
||||
>
|
||||
<div class="bg-white dark:bg-gray-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div class="hidden sm:block absolute top-0 right-0 pt-4 pr-4">
|
||||
<button @click="invitationOpen = false" class="bg-white dark:bg-gray-800 rounded-md text-gray-400 dark:text-gray-500 hover:text-gray-500 dark:hover:text-gray-400 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
<span class="sr-only">Close</span>
|
||||
<!-- Heroicon name: outline/x -->
|
||||
<svg class="h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="sm:flex sm:items-start">
|
||||
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-green-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path></svg>
|
||||
</div>
|
||||
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-gray-100" id="modal-title">
|
||||
Invitation for <span x-text="selectedInvitation && selectedInvitation.site.domain"></span>
|
||||
</h3>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-200">
|
||||
You've been invited to the <span x-text="selectedInvitation && selectedInvitation.site.domain"></span> analytics dashboard as <b class="capitalize" x-text="selectedInvitation && selectedInvitation.role">Admin</b>.
|
||||
</p>
|
||||
<p x-show="selectedInvitation && selectedInvitation.role === 'owner'" class="mt-2 text-sm text-gray-500 dark:text-gray-200">
|
||||
If you accept the ownership transfer, you will be responsible for billing going forward.
|
||||
<%= if is_nil(@current_user.trial_expiry_date) && is_nil(@current_user.subscription) do %>
|
||||
<br/><br />
|
||||
You will have to enter your card details immediately with no 30-day trial.
|
||||
<% end %>
|
||||
<%= if Plausible.Billing.on_trial?(@current_user) do %>
|
||||
<br/><br />
|
||||
NB: Your 30-day free trial will end immediately and <strong>you will have to enter your card details</strong> to keep using Plausible.
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-gray-850 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-700 sm:ml-3 sm:w-auto sm:text-sm" data-method="post" data-csrf="<%= Plug.CSRFProtection.get_csrf_token() %>" x-bind:data-to="selectedInvitation && ('/sites/invitations/' + selectedInvitation.invitation_id + '/accept')">
|
||||
Accept & Continue
|
||||
</button>
|
||||
<button type="button" class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-500 shadow-sm px-4 py-2 bg-white dark:bg-gray-800 text-base font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-850 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm" data-method="post" data-csrf="<%= Plug.CSRFProtection.get_csrf_token() %>" x-bind:data-to="selectedInvitation && ('/sites/invitations/' + selectedInvitation.invitation_id + '/reject')">
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
@ -1,6 +1,5 @@
|
||||
defmodule PlausibleWeb.SiteView do
|
||||
use PlausibleWeb, :view
|
||||
import Phoenix.Pagination.HTML
|
||||
use Plausible.Funnel
|
||||
|
||||
def plausible_url do
|
||||
|
2
mix.exs
2
mix.exs
@ -102,7 +102,6 @@ defmodule Plausible.MixProject do
|
||||
{:phoenix_ecto, "~> 4.0"},
|
||||
{:phoenix_html, "~> 3.3", override: true},
|
||||
{:phoenix_live_reload, "~> 1.2", only: :dev},
|
||||
{:phoenix_pagination, "~> 0.7.0"},
|
||||
{:phoenix_pubsub, "~> 2.0"},
|
||||
{:phoenix_live_view, "~> 0.18"},
|
||||
{:php_serializer, "~> 2.0"},
|
||||
@ -125,6 +124,7 @@ defmodule Plausible.MixProject do
|
||||
{:open_api_spex, "~> 3.18"},
|
||||
{:joken, "~> 2.5"},
|
||||
{:paginator, git: "https://github.com/duffelhq/paginator.git"},
|
||||
{:scrivener_ecto, "~> 2.0"},
|
||||
{:esbuild, "~> 0.7", runtime: Mix.env() == :dev},
|
||||
{:tailwind, "~> 0.2.0", runtime: Mix.env() == :dev}
|
||||
]
|
||||
|
3
mix.lock
3
mix.lock
@ -111,7 +111,6 @@
|
||||
"phoenix_html": {:hex, :phoenix_html, "3.3.1", "4788757e804a30baac6b3fc9695bf5562465dd3f1da8eb8460ad5b404d9a2178", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "bed1906edd4906a15fd7b412b85b05e521e1f67c9a85418c55999277e553d0d3"},
|
||||
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.4.1", "2aff698f5e47369decde4357ba91fc9c37c6487a512b41732818f2204a8ef1d3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "9bffb834e7ddf08467fe54ae58b5785507aaba6255568ae22b4d46e2bb3615ab"},
|
||||
"phoenix_live_view": {:hex, :phoenix_live_view, "0.19.4", "dd9ffe3ca0683bdef4f340bcdd2c35a6ee0d581a2696033fc25f52e742618bdc", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fd2c666d227476d63af7b8c20e6e61d16f07eb49f924cf4198fca7668156f15b"},
|
||||
"phoenix_pagination": {:hex, :phoenix_pagination, "0.7.0", "e8503270da3c41f4ac4fea5ae90503f51287e9cd72b3a6abb0c547fe84d9639b", [:mix], [{:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.12", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.11.1", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "27055338e9824bb302bb0d72e14972a9a2fb916bf435545f04f361671d6d827f"},
|
||||
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"},
|
||||
"phoenix_template": {:hex, :phoenix_template, "1.0.2", "a3dd349493d7c0b8f58da8175f805963a5b809ffc7d8c1b8dd46ba5b199ef58f", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "ab78ebc964685b9eeba102344049eb32d69e582c497d5a0ae6f25909db00c67b"},
|
||||
"phoenix_view": {:hex, :phoenix_view, "2.0.2", "6bd4d2fd595ef80d33b439ede6a19326b78f0f1d8d62b9a318e3d9c1af351098", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "a929e7230ea5c7ee0e149ffcf44ce7cf7f4b6d2bfe1752dd7c084cdff152d36f"},
|
||||
@ -128,6 +127,8 @@
|
||||
"ref_inspector": {:hex, :ref_inspector, "1.3.1", "bb0489a4c4299dcd633f2b7a60c41a01f5590789d0b28225a60be484e1fbe777", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:yamerl, "~> 0.7", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "3172eb1b08e5c69966f796e3fe0e691257546fa143a5eb0ecc18a6e39b233854"},
|
||||
"referrer_blocklist": {:git, "https://github.com/plausible/referrer-blocklist.git", "d6f52c225cccb4f04b80e3a5d588868ec234139d", []},
|
||||
"rustler_precompiled": {:hex, :rustler_precompiled, "0.6.2", "d2218ba08a43fa331957f30481d00b666664d7e3861431b02bd3f4f30eec8e5b", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "b9048eaed8d7d14a53f758c91865cc616608a438d2595f621f6a4b32a5511709"},
|
||||
"scrivener": {:hex, :scrivener, "2.7.2", "1d913c965ec352650a7f864ad7fd8d80462f76a32f33d57d1e48bc5e9d40aba2", [:mix], [], "hexpm", "7866a0ec4d40274efbee1db8bead13a995ea4926ecd8203345af8f90d2b620d9"},
|
||||
"scrivener_ecto": {:hex, :scrivener_ecto, "2.7.0", "cf64b8cb8a96cd131cdbcecf64e7fd395e21aaa1cb0236c42a7c2e34b0dca580", [:mix], [{:ecto, "~> 3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:scrivener, "~> 2.4", [hex: :scrivener, repo: "hexpm", optional: false]}], "hexpm", "e809f171687806b0031129034352f5ae44849720c48dd839200adeaf0ac3e260"},
|
||||
"sentry": {:hex, :sentry, "8.1.0", "8d235b62fce5f8e067ea1644e30939405b71a5e1599d9529ff82899d11d03f2b", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, "~> 2.3", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm", "f9fc7641ef61e885510f5e5963c2948b9de1de597c63f781e9d3d6c9c8681ab4"},
|
||||
"siphash": {:hex, :siphash, "3.2.0", "ec03fd4066259218c85e2a4b8eec4bb9663bc02b127ea8a0836db376ba73f2ed", [:make, :mix], [], "hexpm", "ba3810701c6e95637a745e186e8a4899087c3b079ba88fb8f33df054c3b0b7c3"},
|
||||
"sleeplocks": {:hex, :sleeplocks, "1.1.1", "3d462a0639a6ef36cc75d6038b7393ae537ab394641beb59830a1b8271faeed3", [:rebar3], [], "hexpm", "84ee37aeff4d0d92b290fff986d6a95ac5eedf9b383fadfd1d88e9b84a1c02e1"},
|
||||
|
@ -1,7 +1,7 @@
|
||||
defmodule Plausible.Plugins.API.PaginationTest do
|
||||
defmodule Plausible.PaginationTest do
|
||||
use Plausible.DataCase, async: true
|
||||
|
||||
alias Plausible.Plugins.API.Pagination
|
||||
alias Plausible.Pagination
|
||||
import Ecto.Query
|
||||
|
||||
setup do
|
@ -81,4 +81,111 @@ defmodule Plausible.SitesTest do
|
||||
assert %{id: ^site_id} = Sites.get_for_user(user2.id, domain, [:super_admin])
|
||||
end
|
||||
end
|
||||
|
||||
describe "list/3" do
|
||||
test "returns empty when there are no sites" do
|
||||
user = insert(:user)
|
||||
_rogue_site = insert(:site)
|
||||
|
||||
assert %{
|
||||
entries: [],
|
||||
page_size: 24,
|
||||
page_number: 1,
|
||||
total_entries: 0,
|
||||
total_pages: 1
|
||||
} =
|
||||
Sites.list(user, %{})
|
||||
end
|
||||
|
||||
test "returns invitations and sites" do
|
||||
user = insert(:user)
|
||||
|
||||
%{id: site_id1} = insert(:site, members: [user])
|
||||
%{id: site_id2} = insert(:site, members: [user])
|
||||
_rogue_site = insert(:site)
|
||||
|
||||
%{id: site_id3} =
|
||||
insert(:site,
|
||||
invitations: [
|
||||
build(:invitation, email: user.email, inviter: build(:user), role: :viewer)
|
||||
]
|
||||
)
|
||||
|
||||
assert %{
|
||||
entries: [
|
||||
%{id: ^site_id3},
|
||||
%{id: ^site_id1},
|
||||
%{id: ^site_id2}
|
||||
]
|
||||
} = Sites.list(user, %{})
|
||||
end
|
||||
|
||||
test "filters by domain" do
|
||||
user = insert(:user)
|
||||
%{id: site_id1} = insert(:site, domain: "first.example.com", members: [user])
|
||||
%{id: _site_id2} = insert(:site, domain: "second.example.com", members: [user])
|
||||
_rogue_site = insert(:site)
|
||||
|
||||
%{id: site_id3} =
|
||||
insert(:site,
|
||||
domain: "first-another.example.com",
|
||||
invitations: [
|
||||
build(:invitation, email: user.email, inviter: build(:user), role: :viewer)
|
||||
]
|
||||
)
|
||||
|
||||
assert %{
|
||||
entries: [
|
||||
%{id: ^site_id3},
|
||||
%{id: ^site_id1}
|
||||
]
|
||||
} = Sites.list(user, %{}, filter_by_domain: "first")
|
||||
end
|
||||
|
||||
test "handles pagination correctly" do
|
||||
user = insert(:user)
|
||||
%{id: site_id1} = insert(:site, members: [user])
|
||||
%{id: site_id2} = insert(:site, members: [user])
|
||||
_rogue_site = insert(:site)
|
||||
|
||||
%{id: site_id3} =
|
||||
insert(:site,
|
||||
invitations: [
|
||||
build(:invitation, email: user.email, inviter: build(:user), role: :viewer)
|
||||
]
|
||||
)
|
||||
|
||||
assert %{
|
||||
entries: [
|
||||
%{id: ^site_id3},
|
||||
%{id: ^site_id1}
|
||||
],
|
||||
page_number: 1,
|
||||
page_size: 2,
|
||||
total_entries: 3,
|
||||
total_pages: 2
|
||||
} = Sites.list(user, %{"page_size" => 2})
|
||||
|
||||
assert %{
|
||||
entries: [
|
||||
%{id: ^site_id2}
|
||||
],
|
||||
page_number: 2,
|
||||
page_size: 2,
|
||||
total_entries: 3,
|
||||
total_pages: 2
|
||||
} = Sites.list(user, %{"page" => 2, "page_size" => 2})
|
||||
|
||||
assert %{
|
||||
entries: [
|
||||
%{id: ^site_id3},
|
||||
%{id: ^site_id1}
|
||||
],
|
||||
page_number: 1,
|
||||
page_size: 2,
|
||||
total_entries: 3,
|
||||
total_pages: 2
|
||||
} = Sites.list(user, %{"page" => 1, "page_size" => 2})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
222
test/plausible/stats/clickhouse_test.exs
Normal file
222
test/plausible/stats/clickhouse_test.exs
Normal file
@ -0,0 +1,222 @@
|
||||
defmodule Plausible.Stats.ClickhouseTest do
|
||||
use Plausible.DataCase, async: true
|
||||
import Plausible.TestUtils
|
||||
alias Plausible.Stats.Clickhouse
|
||||
|
||||
describe "last_24_visitors_hourly_intervals/1" do
|
||||
test "returns no data on no sites" do
|
||||
assert Clickhouse.last_24h_visitors_hourly_intervals([]) == %{}
|
||||
end
|
||||
|
||||
test "returns empty intervals placeholder on no clickhouse stats" do
|
||||
fixed_now = ~N[2023-10-26 10:00:15]
|
||||
site = insert(:site)
|
||||
domain = site.domain
|
||||
|
||||
assert Clickhouse.last_24h_visitors_hourly_intervals(
|
||||
[site],
|
||||
fixed_now
|
||||
) ==
|
||||
%{
|
||||
domain => %{
|
||||
change: 0,
|
||||
visitors: 0,
|
||||
intervals: [
|
||||
%{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},
|
||||
%{interval: ~N[2023-10-25 14:00:00], visitors: 0},
|
||||
%{interval: ~N[2023-10-25 15:00:00], visitors: 0},
|
||||
%{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: 0},
|
||||
%{interval: ~N[2023-10-26 11:00:00], visitors: 0}
|
||||
]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
test "returns clickhouse data merged with placeholder" do
|
||||
fixed_now = ~N[2023-10-26 10:00:15]
|
||||
site = insert(:site)
|
||||
|
||||
user_id = 111
|
||||
|
||||
populate_stats(site, [
|
||||
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])
|
||||
])
|
||||
|
||||
assert %{
|
||||
change: 100,
|
||||
visitors: 3,
|
||||
intervals: [
|
||||
%{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},
|
||||
%{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: 0},
|
||||
%{interval: ~N[2023-10-26 11:00:00], visitors: 0}
|
||||
]
|
||||
} = Clickhouse.last_24h_visitors_hourly_intervals([site], fixed_now)[site.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)
|
||||
|
||||
user_id = 111
|
||||
|
||||
populate_stats(site1, [
|
||||
build(:pageview, user_id: user_id, timestamp: ~N[2023-10-25 13:00:00]),
|
||||
build(:pageview, user_id: user_id, timestamp: ~N[2023-10-25 13:01:00]),
|
||||
build(:pageview, timestamp: ~N[2023-10-25 15:59:00]),
|
||||
build(:pageview, timestamp: ~N[2023-10-25 15:58:00])
|
||||
])
|
||||
|
||||
populate_stats(site2, [
|
||||
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])
|
||||
])
|
||||
|
||||
assert result =
|
||||
Clickhouse.last_24h_visitors_hourly_intervals([site1, site2, site3], fixed_now)
|
||||
|
||||
assert result[site1.domain].visitors == 3
|
||||
assert result[site1.domain].change == 100
|
||||
assert result[site2.domain].visitors == 3
|
||||
assert result[site2.domain].change == 100
|
||||
assert result[site3.domain].visitors == 0
|
||||
assert result[site3.domain].change == 0
|
||||
|
||||
find_interval = fn result, domain, interval ->
|
||||
Enum.find(result[domain].intervals, &(&1.interval == interval))
|
||||
end
|
||||
|
||||
assert find_interval.(result, site1.domain, ~N[2023-10-25 13:00:00]).visitors == 1
|
||||
assert find_interval.(result, site1.domain, ~N[2023-10-25 15:00:00]).visitors == 2
|
||||
assert find_interval.(result, site2.domain, ~N[2023-10-25 13:00:00]).visitors == 2
|
||||
assert find_interval.(result, site2.domain, ~N[2023-10-25 15:00:00]).visitors == 1
|
||||
end
|
||||
|
||||
test "returns calculated change" do
|
||||
fixed_now = ~N[2023-10-26 10:00:15]
|
||||
site = insert(:site)
|
||||
|
||||
populate_stats(site, [
|
||||
build(:pageview, timestamp: ~N[2023-10-24 11:58:00]),
|
||||
build(:pageview, timestamp: ~N[2023-10-24 12:59:00]),
|
||||
build(:pageview, timestamp: ~N[2023-10-25 13:59:00]),
|
||||
build(:pageview, timestamp: ~N[2023-10-25 13:58:00]),
|
||||
build(:pageview, timestamp: ~N[2023-10-25 13:59:00])
|
||||
])
|
||||
|
||||
assert %{
|
||||
change: 50,
|
||||
visitors: 3
|
||||
} = Clickhouse.last_24h_visitors_hourly_intervals([site], fixed_now)[site.domain]
|
||||
end
|
||||
|
||||
test "calculates uniques correctly across hour boundaries" do
|
||||
fixed_now = ~N[2023-10-26 10:00:15]
|
||||
site = insert(:site)
|
||||
|
||||
user_id = 111
|
||||
|
||||
populate_stats(site, [
|
||||
build(:pageview, user_id: user_id, timestamp: ~N[2023-10-25 15:59:00]),
|
||||
build(:pageview, user_id: user_id, timestamp: ~N[2023-10-25 16:00:00])
|
||||
])
|
||||
|
||||
result = Clickhouse.last_24h_visitors_hourly_intervals([site], fixed_now)[site.domain]
|
||||
assert result[:visitors] == 1
|
||||
end
|
||||
|
||||
test "another one" do
|
||||
fixed_now = ~N[2023-10-26 10:00:15]
|
||||
site = insert(:site)
|
||||
|
||||
user_id = 111
|
||||
|
||||
populate_stats(site, [
|
||||
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 16:00:00])
|
||||
])
|
||||
|
||||
assert %{
|
||||
change: 100,
|
||||
visitors: 3,
|
||||
intervals: [
|
||||
%{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},
|
||||
%{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: 1},
|
||||
%{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: 0},
|
||||
%{interval: ~N[2023-10-26 11:00:00], visitors: 0}
|
||||
]
|
||||
} = Clickhouse.last_24h_visitors_hourly_intervals([site], fixed_now)[site.domain]
|
||||
end
|
||||
end
|
||||
end
|
@ -42,14 +42,22 @@ defmodule PlausibleWeb.SiteControllerTest do
|
||||
assert html_response(conn, 200) =~ "You don't have any sites yet"
|
||||
end
|
||||
|
||||
test "lists all of your sites with last 24h visitors", %{conn: conn, user: user} do
|
||||
test "lists all of your sites with last 24h visitors (defaulting to 0 on first mount)", %{
|
||||
conn: conn,
|
||||
user: user
|
||||
} do
|
||||
site = insert(:site, members: [user])
|
||||
|
||||
populate_stats(site, [build(:pageview), build(:pageview), build(:pageview)])
|
||||
# will be skipped
|
||||
populate_stats(site, [build(:pageview)])
|
||||
conn = get(conn, "/sites")
|
||||
|
||||
assert html_response(conn, 200) =~ site.domain
|
||||
assert html_response(conn, 200) =~ "<b>3</b> visitors in last 24h"
|
||||
assert resp = html_response(conn, 200)
|
||||
|
||||
site_card = text_of_element(resp, "li[data-domain=\"#{site.domain}\"]")
|
||||
|
||||
assert site_card =~ "0 visitors in last 24h"
|
||||
assert site_card =~ site.domain
|
||||
end
|
||||
|
||||
test "shows invitations for user by email address", %{conn: conn, user: user} do
|
||||
@ -74,25 +82,105 @@ defmodule PlausibleWeb.SiteControllerTest do
|
||||
assert html_response(conn, 200) =~ site.domain
|
||||
end
|
||||
|
||||
test "paginates sites", %{conn: conn, user: user} do
|
||||
insert(:site, members: [user], domain: "test-site1.com")
|
||||
insert(:site, members: [user], domain: "test-site2.com")
|
||||
insert(:site, members: [user], domain: "test-site3.com")
|
||||
insert(:site, members: [user], domain: "test-site4.com")
|
||||
test "paginates sites", %{conn: initial_conn, user: user} do
|
||||
for i <- 1..25 do
|
||||
insert(:site,
|
||||
members: [user],
|
||||
domain: "paginated-site#{String.pad_leading("#{i}", 2, "0")}.example.com"
|
||||
)
|
||||
end
|
||||
|
||||
conn = get(conn, "/sites?per_page=2")
|
||||
conn = get(initial_conn, "/sites")
|
||||
resp = html_response(conn, 200)
|
||||
|
||||
assert html_response(conn, 200) =~ "test-site1.com"
|
||||
assert html_response(conn, 200) =~ "test-site2.com"
|
||||
refute html_response(conn, 200) =~ "test-site3.com"
|
||||
refute html_response(conn, 200) =~ "test-site4.com"
|
||||
for i <- 1..24 do
|
||||
assert element_exists?(
|
||||
resp,
|
||||
"li[data-domain=\"paginated-site#{String.pad_leading("#{i}", 2, "0")}.example.com\"]"
|
||||
)
|
||||
end
|
||||
|
||||
conn = get(conn, "/sites?per_page=2&page=2")
|
||||
refute resp =~ "paginated-site25.com"
|
||||
|
||||
refute html_response(conn, 200) =~ "test-site1.com"
|
||||
refute html_response(conn, 200) =~ "test-site2.com"
|
||||
assert html_response(conn, 200) =~ "test-site3.com"
|
||||
assert html_response(conn, 200) =~ "test-site4.com"
|
||||
next_page_link = text_of_attr(resp, ".pagination-link.active", "href")
|
||||
next_page = initial_conn |> get(next_page_link) |> html_response(200)
|
||||
|
||||
assert element_exists?(
|
||||
next_page,
|
||||
"li[data-domain=\"paginated-site25.example.com\"]"
|
||||
)
|
||||
|
||||
prev_page_link = text_of_attr(next_page, ".pagination-link.active", "href")
|
||||
prev_page = initial_conn |> get(prev_page_link) |> html_response(200)
|
||||
|
||||
assert element_exists?(
|
||||
prev_page,
|
||||
"li[data-domain=\"paginated-site04.example.com\"]"
|
||||
)
|
||||
|
||||
refute element_exists?(
|
||||
prev_page,
|
||||
"li[data-domain=\"paginated-site25.example.com\"]"
|
||||
)
|
||||
end
|
||||
|
||||
test "shows upgrade nag message to expired trial user without subscription", %{
|
||||
conn: initial_conn,
|
||||
user: user
|
||||
} do
|
||||
insert(:site, members: [user])
|
||||
|
||||
conn = get(initial_conn, "/sites")
|
||||
resp = html_response(conn, 200)
|
||||
|
||||
nag_message =
|
||||
"To access the sites you own, you need to subscribe to a monthly or yearly payment plan."
|
||||
|
||||
refute resp =~ nag_message
|
||||
|
||||
user
|
||||
|> Plausible.Auth.User.end_trial()
|
||||
|> Repo.update!()
|
||||
|
||||
conn = get(initial_conn, "/sites")
|
||||
resp = html_response(conn, 200)
|
||||
|
||||
assert resp =~ nag_message
|
||||
end
|
||||
|
||||
test "filters by domain", %{conn: conn, user: user} do
|
||||
_site1 = insert(:site, domain: "first.example.com", members: [user])
|
||||
_site2 = insert(:site, domain: "second.example.com", members: [user])
|
||||
_rogue_site = insert(:site)
|
||||
|
||||
_site3 =
|
||||
insert(:site,
|
||||
domain: "first-another.example.com",
|
||||
invitations: [
|
||||
build(:invitation, email: user.email, inviter: build(:user), role: :viewer)
|
||||
]
|
||||
)
|
||||
|
||||
conn = get(conn, "/sites", filter_text: "first")
|
||||
resp = html_response(conn, 200)
|
||||
|
||||
assert resp =~ "first.example.com"
|
||||
assert resp =~ "first-another.example.com"
|
||||
refute resp =~ "second.example.com"
|
||||
end
|
||||
|
||||
test "does not show empty state when filter returns empty but there are sites", %{
|
||||
conn: conn,
|
||||
user: user
|
||||
} do
|
||||
_site1 = insert(:site, domain: "example.com", members: [user])
|
||||
|
||||
conn = get(conn, "/sites", filter_text: "none")
|
||||
resp = html_response(conn, 200)
|
||||
|
||||
refute resp =~ "second.example.com"
|
||||
assert html_response(conn, 200) =~ "No sites found. Please search for something else."
|
||||
refute html_response(conn, 200) =~ "You don't have any sites yet."
|
||||
end
|
||||
end
|
||||
|
||||
|
72
test/plausible_web/live/sites_test.exs
Normal file
72
test/plausible_web/live/sites_test.exs
Normal file
@ -0,0 +1,72 @@
|
||||
defmodule PlausibleWeb.Live.SitesTest do
|
||||
use PlausibleWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import Plausible.Test.Support.HTML
|
||||
|
||||
setup [:create_user, :log_in]
|
||||
|
||||
describe "/sites" do
|
||||
test "renders empty sites page", %{conn: conn} do
|
||||
{:ok, _lv, html} = live(conn, "/sites")
|
||||
|
||||
assert text(html) =~ "You don't have any sites yet"
|
||||
end
|
||||
|
||||
test "renders 24h visitors correctly", %{conn: conn, user: user} do
|
||||
site = insert(:site, members: [user])
|
||||
|
||||
populate_stats(site, [build(:pageview), build(:pageview), build(:pageview)])
|
||||
|
||||
{:ok, _lv, html} = live(conn, "/sites")
|
||||
|
||||
site_card = text_of_element(html, "li[data-domain=\"#{site.domain}\"]")
|
||||
assert site_card =~ "3 visitors in last 24h"
|
||||
assert site_card =~ site.domain
|
||||
end
|
||||
|
||||
test "filters by domain", %{conn: conn, user: user} do
|
||||
_site1 = insert(:site, domain: "first.example.com", members: [user])
|
||||
_site2 = insert(:site, domain: "second.example.com", members: [user])
|
||||
_site3 = insert(:site, domain: "first-another.example.com", members: [user])
|
||||
|
||||
{:ok, lv, _html} = live(conn, "/sites")
|
||||
|
||||
type_into_input(lv, "filter_text", "firs")
|
||||
html = render(lv)
|
||||
|
||||
assert html =~ "first.example.com"
|
||||
assert html =~ "first-another.example.com"
|
||||
refute html =~ "second.example.com"
|
||||
end
|
||||
|
||||
test "filtering plays well with pagination", %{conn: conn, user: user} do
|
||||
_site1 = insert(:site, domain: "first.another.example.com", members: [user])
|
||||
_site2 = insert(:site, domain: "second.example.com", members: [user])
|
||||
_site3 = insert(:site, domain: "third.another.example.com", members: [user])
|
||||
|
||||
{:ok, lv, html} = live(conn, "/sites?page_size=2")
|
||||
|
||||
assert html =~ "first.another.example.com"
|
||||
assert html =~ "second.example.com"
|
||||
refute html =~ "third.another.example.com"
|
||||
assert html =~ "page=2"
|
||||
refute html =~ "page=1"
|
||||
|
||||
type_into_input(lv, "filter_text", "anot")
|
||||
html = render(lv)
|
||||
|
||||
assert html =~ "first.another.example.com"
|
||||
refute html =~ "second.example.com"
|
||||
assert html =~ "third.another.example.com"
|
||||
refute html =~ "page=1"
|
||||
refute html =~ "page=2"
|
||||
end
|
||||
end
|
||||
|
||||
defp type_into_input(lv, id, text) do
|
||||
lv
|
||||
|> element("form")
|
||||
|> render_change(%{id => text})
|
||||
end
|
||||
end
|
@ -29,6 +29,7 @@ defmodule Plausible.Test.Support.HTML do
|
||||
|> find(element)
|
||||
|> Floki.text()
|
||||
|> String.trim()
|
||||
|> String.replace(~r/\s+/, " ")
|
||||
end
|
||||
|
||||
def text(element) do
|
||||
@ -43,6 +44,12 @@ defmodule Plausible.Test.Support.HTML do
|
||||
|> text_of_attr("class")
|
||||
end
|
||||
|
||||
def text_of_attr(html, element, attr) do
|
||||
html
|
||||
|> find(element)
|
||||
|> text_of_attr(attr)
|
||||
end
|
||||
|
||||
def text_of_attr(element, attr) do
|
||||
element
|
||||
|> Floki.attribute(attr)
|
||||
|
@ -147,13 +147,7 @@ defmodule Plausible.TestUtils do
|
||||
|
||||
def populate_stats(site, events) do
|
||||
Enum.map(events, fn event ->
|
||||
case event do
|
||||
%Plausible.ClickhouseEventV2{} ->
|
||||
Map.put(event, :site_id, site.id)
|
||||
|
||||
_ ->
|
||||
Map.put(event, :site_id, site.id)
|
||||
end
|
||||
Map.put(event, :site_id, site.id)
|
||||
end)
|
||||
|> populate_stats
|
||||
end
|
||||
|
Loading…
Reference in New Issue
Block a user