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:
Adrian Gruntkowski 2023-11-02 13:18:11 +01:00 committed by GitHub
parent 3e77621d81
commit 07cab27fef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 1454 additions and 333 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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: [

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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}

View File

@ -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

View File

@ -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]

View 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

View 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

View 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">
&#8203;
</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 &amp; 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

View File

@ -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

View File

@ -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">&#8203;</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 &amp; 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>

View File

@ -1,6 +1,5 @@
defmodule PlausibleWeb.SiteView do
use PlausibleWeb, :view
import Phoenix.Pagination.HTML
use Plausible.Funnel
def plausible_url do

View File

@ -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}
]

View File

@ -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"},

View File

@ -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

View File

@ -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

View 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

View File

@ -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

View 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

View File

@ -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)

View File

@ -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