Redesign moderation analytics and audit workspaces
This commit is contained in:
parent
76e1ab7fd8
commit
e499ed7553
|
|
@ -190,6 +190,9 @@ defmodule WhoNeedHelp.Catalog do
|
||||||
cursor = Pagination.cursor(options)
|
cursor = Pagination.cursor(options)
|
||||||
|
|
||||||
CategoryProposal
|
CategoryProposal
|
||||||
|
|> maybe_proposal_status(Keyword.get(options, :status))
|
||||||
|
|> maybe_proposal_mode(Keyword.get(options, :mode))
|
||||||
|
|> maybe_proposal_search(Keyword.get(options, :search))
|
||||||
|> before_proposal(cursor)
|
|> before_proposal(cursor)
|
||||||
|> order_by([proposal], desc: proposal.inserted_at, desc: proposal.id)
|
|> order_by([proposal], desc: proposal.inserted_at, desc: proposal.id)
|
||||||
|> limit(^(limit + 1))
|
|> limit(^(limit + 1))
|
||||||
|
|
@ -1004,6 +1007,34 @@ defmodule WhoNeedHelp.Catalog do
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp maybe_proposal_status(query, value) when value in [nil, ""], do: query
|
||||||
|
|
||||||
|
defp maybe_proposal_status(query, value),
|
||||||
|
do: where(query, [proposal], proposal.status == ^value)
|
||||||
|
|
||||||
|
defp maybe_proposal_mode(query, value) when value in [nil, ""], do: query
|
||||||
|
defp maybe_proposal_mode(query, value), do: where(query, [proposal], proposal.mode == ^value)
|
||||||
|
|
||||||
|
defp maybe_proposal_search(query, value) when value in [nil, ""], do: query
|
||||||
|
|
||||||
|
defp maybe_proposal_search(query, value) do
|
||||||
|
clean =
|
||||||
|
value
|
||||||
|
|> to_string()
|
||||||
|
|> String.trim()
|
||||||
|
|> String.replace("%", "")
|
||||||
|
|> String.replace("_", "")
|
||||||
|
|
||||||
|
pattern = "%#{clean}%"
|
||||||
|
|
||||||
|
where(
|
||||||
|
query,
|
||||||
|
[proposal],
|
||||||
|
ilike(proposal.proposed_name, ^pattern) or ilike(proposal.reason, ^pattern) or
|
||||||
|
fragment("CAST(? AS text) ILIKE ?", proposal.id, ^pattern)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
defp before_proposal(query, nil), do: query
|
defp before_proposal(query, nil), do: query
|
||||||
|
|
||||||
defp before_proposal(query, {inserted_at, id}) do
|
defp before_proposal(query, {inserted_at, id}) do
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,10 @@ defmodule WhoNeedHelp.ProductAnalytics do
|
||||||
cursor = Pagination.cursor(options)
|
cursor = Pagination.cursor(options)
|
||||||
|
|
||||||
DailyMetric
|
DailyMetric
|
||||||
|
|> maybe_metric(Keyword.get(options, :metric))
|
||||||
|
|> maybe_dimension(Keyword.get(options, :dimension))
|
||||||
|
|> maybe_date_from(Keyword.get(options, :date_from))
|
||||||
|
|> maybe_date_to(Keyword.get(options, :date_to))
|
||||||
|> before(cursor)
|
|> before(cursor)
|
||||||
|> order_by([metric], desc: metric.inserted_at, desc: metric.id)
|
|> order_by([metric], desc: metric.inserted_at, desc: metric.id)
|
||||||
|> limit(^(limit + 1))
|
|> limit(^(limit + 1))
|
||||||
|
|
@ -88,6 +92,51 @@ defmodule WhoNeedHelp.ProductAnalytics do
|
||||||
|
|
||||||
def allowed_metrics, do: @allowed_metrics
|
def allowed_metrics, do: @allowed_metrics
|
||||||
|
|
||||||
|
def overview(%Scope{user: user}, options \\ []) do
|
||||||
|
if Accounts.authorized?(user, :analytics_view) do
|
||||||
|
base_query =
|
||||||
|
DailyMetric
|
||||||
|
|> maybe_date_from(Keyword.get(options, :date_from))
|
||||||
|
|> maybe_date_to(Keyword.get(options, :date_to))
|
||||||
|
|
||||||
|
by_metric =
|
||||||
|
base_query
|
||||||
|
|> group_by([metric], metric.metric)
|
||||||
|
|> select([metric], {metric.metric, sum(metric.count)})
|
||||||
|
|> Repo.all()
|
||||||
|
|> Enum.map(fn {metric, count} -> {metric, normalize_count(count)} end)
|
||||||
|
|> Map.new()
|
||||||
|
|
||||||
|
trend =
|
||||||
|
base_query
|
||||||
|
|> group_by([metric], metric.date)
|
||||||
|
|> order_by([metric], desc: metric.date)
|
||||||
|
|> limit(14)
|
||||||
|
|> select([metric], %{date: metric.date, count: sum(metric.count)})
|
||||||
|
|> Repo.all()
|
||||||
|
|> Enum.map(&Map.update!(&1, :count, fn count -> normalize_count(count) end))
|
||||||
|
|> Enum.reverse()
|
||||||
|
|
||||||
|
%{
|
||||||
|
total: Enum.sum(Map.values(by_metric)),
|
||||||
|
registrations: Map.get(by_metric, "account.registered", 0),
|
||||||
|
requests_created: Map.get(by_metric, "request.created", 0),
|
||||||
|
requests_completed: Map.get(by_metric, "request.completed", 0),
|
||||||
|
by_metric: by_metric,
|
||||||
|
trend: trend
|
||||||
|
}
|
||||||
|
else
|
||||||
|
%{
|
||||||
|
total: 0,
|
||||||
|
registrations: 0,
|
||||||
|
requests_created: 0,
|
||||||
|
requests_completed: 0,
|
||||||
|
by_metric: %{},
|
||||||
|
trend: []
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp synthetic_email?(email) do
|
defp synthetic_email?(email) do
|
||||||
email
|
email
|
||||||
|> String.trim()
|
|> String.trim()
|
||||||
|
|
@ -95,6 +144,22 @@ defmodule WhoNeedHelp.ProductAnalytics do
|
||||||
|> String.ends_with?(@synthetic_email_suffix)
|
|> String.ends_with?(@synthetic_email_suffix)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp maybe_metric(query, value) when value in [nil, ""], do: query
|
||||||
|
defp maybe_metric(query, value), do: where(query, [metric], metric.metric == ^value)
|
||||||
|
|
||||||
|
defp maybe_dimension(query, value) when value in [nil, ""], do: query
|
||||||
|
defp maybe_dimension(query, value), do: where(query, [metric], metric.dimension == ^value)
|
||||||
|
|
||||||
|
defp maybe_date_from(query, %Date{} = value),
|
||||||
|
do: where(query, [metric], metric.date >= ^value)
|
||||||
|
|
||||||
|
defp maybe_date_from(query, _value), do: query
|
||||||
|
|
||||||
|
defp maybe_date_to(query, %Date{} = value),
|
||||||
|
do: where(query, [metric], metric.date <= ^value)
|
||||||
|
|
||||||
|
defp maybe_date_to(query, _value), do: query
|
||||||
|
|
||||||
defp before(query, nil), do: query
|
defp before(query, nil), do: query
|
||||||
|
|
||||||
defp before(query, {inserted_at, id}) do
|
defp before(query, {inserted_at, id}) do
|
||||||
|
|
@ -105,4 +170,7 @@ defmodule WhoNeedHelp.ProductAnalytics do
|
||||||
(metric.inserted_at == ^inserted_at and metric.id < ^id)
|
(metric.inserted_at == ^inserted_at and metric.id < ^id)
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp normalize_count(%Decimal{} = count), do: Decimal.to_integer(count)
|
||||||
|
defp normalize_count(count) when is_integer(count), do: count
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -338,6 +338,8 @@ defmodule WhoNeedHelp.Trust do
|
||||||
|
|
||||||
Report
|
Report
|
||||||
|> maybe_status(status)
|
|> maybe_status(status)
|
||||||
|
|> maybe_report_reason(Keyword.get(options, :reason))
|
||||||
|
|> maybe_report_search(Keyword.get(options, :search))
|
||||||
|> before_report(cursor)
|
|> before_report(cursor)
|
||||||
|> order_by([report], desc: report.inserted_at, desc: report.id)
|
|> order_by([report], desc: report.inserted_at, desc: report.id)
|
||||||
|> limit(^(limit + 1))
|
|> limit(^(limit + 1))
|
||||||
|
|
@ -672,7 +674,9 @@ defmodule WhoNeedHelp.Trust do
|
||||||
cursor = Pagination.cursor(options)
|
cursor = Pagination.cursor(options)
|
||||||
|
|
||||||
AbuseSignal
|
AbuseSignal
|
||||||
|> where([signal], signal.status == ^status)
|
|> maybe_signal_status(status)
|
||||||
|
|> maybe_signal_kind(Keyword.get(options, :kind))
|
||||||
|
|> maybe_signal_search(Keyword.get(options, :search))
|
||||||
|> before_abuse_signal(cursor)
|
|> before_abuse_signal(cursor)
|
||||||
|> order_by([signal], desc: signal.inserted_at, desc: signal.id)
|
|> order_by([signal], desc: signal.inserted_at, desc: signal.id)
|
||||||
|> limit(^(limit + 1))
|
|> limit(^(limit + 1))
|
||||||
|
|
@ -936,6 +940,7 @@ defmodule WhoNeedHelp.Trust do
|
||||||
AuditEvent
|
AuditEvent
|
||||||
|> maybe_audit_action(Keyword.get(options, :action))
|
|> maybe_audit_action(Keyword.get(options, :action))
|
||||||
|> maybe_audit_actor(Keyword.get(options, :actor_id))
|
|> maybe_audit_actor(Keyword.get(options, :actor_id))
|
||||||
|
|> maybe_audit_target_type(Keyword.get(options, :target_type))
|
||||||
|> maybe_audit_search(Keyword.get(options, :search))
|
|> maybe_audit_search(Keyword.get(options, :search))
|
||||||
|> before_audit_event(cursor)
|
|> before_audit_event(cursor)
|
||||||
|> order_by([event], desc: event.inserted_at, desc: event.id)
|
|> order_by([event], desc: event.inserted_at, desc: event.id)
|
||||||
|
|
@ -972,6 +977,11 @@ defmodule WhoNeedHelp.Trust do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp maybe_audit_target_type(query, value) when value in [nil, ""], do: query
|
||||||
|
|
||||||
|
defp maybe_audit_target_type(query, value),
|
||||||
|
do: where(query, [event], event.target_type == ^value)
|
||||||
|
|
||||||
defp maybe_audit_search(query, value) when value in [nil, ""], do: query
|
defp maybe_audit_search(query, value) when value in [nil, ""], do: query
|
||||||
|
|
||||||
defp maybe_audit_search(query, value) do
|
defp maybe_audit_search(query, value) do
|
||||||
|
|
@ -1325,8 +1335,58 @@ defmodule WhoNeedHelp.Trust do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_status(query, nil), do: query
|
defp maybe_status(query, nil), do: query
|
||||||
|
defp maybe_status(query, ""), do: query
|
||||||
defp maybe_status(query, status), do: where(query, [report], report.status == ^status)
|
defp maybe_status(query, status), do: where(query, [report], report.status == ^status)
|
||||||
|
|
||||||
|
defp maybe_report_reason(query, value) when value in [nil, ""], do: query
|
||||||
|
defp maybe_report_reason(query, value), do: where(query, [report], report.reason == ^value)
|
||||||
|
|
||||||
|
defp maybe_report_search(query, value) when value in [nil, ""], do: query
|
||||||
|
|
||||||
|
defp maybe_report_search(query, value) do
|
||||||
|
pattern = search_pattern(value)
|
||||||
|
|
||||||
|
query
|
||||||
|
|> join(:left, [report], reporter in assoc(report, :reporter))
|
||||||
|
|> where(
|
||||||
|
[report, reporter],
|
||||||
|
ilike(report.details, ^pattern) or ilike(reporter.email, ^pattern) or
|
||||||
|
ilike(reporter.display_name, ^pattern) or
|
||||||
|
fragment("CAST(? AS text) ILIKE ?", report.id, ^pattern)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_signal_status(query, value) when value in [nil, ""], do: query
|
||||||
|
defp maybe_signal_status(query, value), do: where(query, [signal], signal.status == ^value)
|
||||||
|
|
||||||
|
defp maybe_signal_kind(query, value) when value in [nil, ""], do: query
|
||||||
|
defp maybe_signal_kind(query, value), do: where(query, [signal], signal.kind == ^value)
|
||||||
|
|
||||||
|
defp maybe_signal_search(query, value) when value in [nil, ""], do: query
|
||||||
|
|
||||||
|
defp maybe_signal_search(query, value) do
|
||||||
|
pattern = search_pattern(value)
|
||||||
|
|
||||||
|
query
|
||||||
|
|> join(:left, [signal], subject in assoc(signal, :subject))
|
||||||
|
|> where(
|
||||||
|
[signal, subject],
|
||||||
|
ilike(subject.email, ^pattern) or ilike(subject.display_name, ^pattern) or
|
||||||
|
fragment("CAST(? AS text) ILIKE ?", signal.id, ^pattern)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp search_pattern(value) do
|
||||||
|
clean =
|
||||||
|
value
|
||||||
|
|> to_string()
|
||||||
|
|> String.trim()
|
||||||
|
|> String.replace("%", "")
|
||||||
|
|> String.replace("_", "")
|
||||||
|
|
||||||
|
"%#{clean}%"
|
||||||
|
end
|
||||||
|
|
||||||
defp clear_pair_tracking(first_user_id, second_user_id) do
|
defp clear_pair_tracking(first_user_id, second_user_id) do
|
||||||
assignments =
|
assignments =
|
||||||
Assignment
|
Assignment
|
||||||
|
|
|
||||||
|
|
@ -4,65 +4,141 @@ defmodule WhoNeedHelpWeb.AdminAuditLive do
|
||||||
alias WhoNeedHelp.Trust
|
alias WhoNeedHelp.Trust
|
||||||
alias WhoNeedHelpWeb.AdminComponents
|
alias WhoNeedHelpWeb.AdminComponents
|
||||||
|
|
||||||
|
@page_sizes ~w(25 50 100)
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
filters = %{"search" => "", "action" => "", "actor_id" => ""}
|
filters = %{
|
||||||
|
"search" => "",
|
||||||
|
"action" => "",
|
||||||
|
"target_type" => "",
|
||||||
|
"actor_id" => "",
|
||||||
|
"limit" => "25"
|
||||||
|
}
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Audit log"))
|
|> assign(:page_title, gettext("Audit log"))
|
||||||
|> assign(:filters, filters)
|
|> assign(:filters, filters)
|
||||||
|> assign(:filter_form, to_form(filters, as: :filters))
|
|> assign(:filter_form, to_form(filters, as: :filters))
|
||||||
|
|> assign(:page_cursor, nil)
|
||||||
|
|> assign(:cursor_history, [])
|
||||||
|
|> assign(:page_number, 1)
|
||||||
|> load_events()}
|
|> load_events()}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("filter", %{"filters" => filters}, socket) do
|
def handle_event("filter", %{"filters" => params}, socket) do
|
||||||
filters = Map.merge(socket.assigns.filters, filters)
|
filters = normalize_filters(params)
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:filters, filters)
|
|> assign(:filters, filters)
|
||||||
|> assign(:filter_form, to_form(filters, as: :filters))
|
|> assign(:filter_form, to_form(filters, as: :filters))
|
||||||
|
|> reset_pagination()
|
||||||
|> load_events()}
|
|> load_events()}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("load-more", _params, socket) do
|
def handle_event("next-page", _params, %{assigns: %{next_cursor: nil}} = socket),
|
||||||
page =
|
do: {:noreply, socket}
|
||||||
Trust.paginate_audit_events(
|
|
||||||
socket.assigns.current_scope,
|
|
||||||
event_options(socket, socket.assigns.events_cursor)
|
|
||||||
)
|
|
||||||
|
|
||||||
existing_ids = MapSet.new(socket.assigns.events, & &1.id)
|
def handle_event("next-page", _params, socket) do
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:cursor_history, [socket.assigns.page_cursor | socket.assigns.cursor_history])
|
||||||
|
|> assign(:page_cursor, socket.assigns.next_cursor)
|
||||||
|
|> assign(:page_number, socket.assigns.page_number + 1)
|
||||||
|
|> load_events()}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("previous-page", _params, %{assigns: %{cursor_history: []}} = socket),
|
||||||
|
do: {:noreply, socket}
|
||||||
|
|
||||||
|
def handle_event("previous-page", _params, socket) do
|
||||||
|
[previous_cursor | history] = socket.assigns.cursor_history
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(
|
|> assign(:cursor_history, history)
|
||||||
:events,
|
|> assign(:page_cursor, previous_cursor)
|
||||||
socket.assigns.events ++ Enum.reject(page.entries, &MapSet.member?(existing_ids, &1.id))
|
|> assign(:page_number, max(socket.assigns.page_number - 1, 1))
|
||||||
)
|
|> load_events()}
|
||||||
|> assign(:events_cursor, page.next_cursor)}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp load_events(socket) do
|
defp load_events(socket) do
|
||||||
page = Trust.paginate_audit_events(socket.assigns.current_scope, event_options(socket))
|
page =
|
||||||
socket |> assign(:events, page.entries) |> assign(:events_cursor, page.next_cursor)
|
Trust.paginate_audit_events(
|
||||||
|
socket.assigns.current_scope,
|
||||||
|
event_options(socket.assigns.filters, socket.assigns.page_cursor)
|
||||||
|
)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:events, page.entries)
|
||||||
|
|> assign(:next_cursor, page.next_cursor)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp event_options(socket, cursor \\ nil) do
|
defp event_options(filters, cursor) do
|
||||||
filters = socket.assigns.filters
|
|
||||||
|
|
||||||
[
|
[
|
||||||
search: filters["search"],
|
search: filters["search"],
|
||||||
action: filters["action"],
|
action: filters["action"],
|
||||||
|
target_type: filters["target_type"],
|
||||||
actor_id: filters["actor_id"],
|
actor_id: filters["actor_id"],
|
||||||
after: cursor
|
after: cursor,
|
||||||
|
limit: page_size(filters["limit"])
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp reset_pagination(socket) do
|
||||||
|
socket
|
||||||
|
|> assign(:page_cursor, nil)
|
||||||
|
|> assign(:cursor_history, [])
|
||||||
|
|> assign(:page_number, 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_filters(params) do
|
||||||
|
%{
|
||||||
|
"search" => normalize_text(params["search"], 160),
|
||||||
|
"action" => normalize_text(params["action"], 120),
|
||||||
|
"target_type" => normalize_text(params["target_type"], 80),
|
||||||
|
"actor_id" => normalize_text(params["actor_id"], 36),
|
||||||
|
"limit" => if(params["limit"] in @page_sizes, do: params["limit"], else: "25")
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_text(value, max),
|
||||||
|
do: value |> to_string() |> String.trim() |> String.slice(0, max)
|
||||||
|
|
||||||
|
defp page_size(value) do
|
||||||
|
case Integer.parse(value || "") do
|
||||||
|
{size, ""} when size in [25, 50, 100] -> size
|
||||||
|
_invalid -> 25
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp range([], _page, _limit), do: {0, 0}
|
||||||
|
|
||||||
|
defp range(entries, page, limit) do
|
||||||
|
first = (page - 1) * limit + 1
|
||||||
|
{first, first + length(entries) - 1}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp actor_label(%{actor: nil}), do: gettext("System")
|
||||||
|
|
||||||
|
defp actor_label(%{actor: actor}),
|
||||||
|
do: actor.display_name || actor.email || gettext("Unknown staff member")
|
||||||
|
|
||||||
|
defp format_datetime(%DateTime{} = value),
|
||||||
|
do: Calendar.strftime(value, "%Y-%m-%d %H:%M:%S UTC")
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
|
limit = page_size(assigns.filters["limit"])
|
||||||
|
|
||||||
|
assigns =
|
||||||
|
assigns
|
||||||
|
|> assign(:range, range(assigns.events, assigns.page_number, limit))
|
||||||
|
|> assign(:page_sizes, @page_sizes)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<AdminComponents.shell
|
<AdminComponents.shell
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
|
|
@ -71,7 +147,7 @@ defmodule WhoNeedHelpWeb.AdminAuditLive do
|
||||||
title={gettext("Audit log")}
|
title={gettext("Audit log")}
|
||||||
description={
|
description={
|
||||||
gettext(
|
gettext(
|
||||||
"Immutable records of sensitive staff and trust actions. Metadata is shown exactly as stored."
|
"Immutable records of sensitive staff and trust actions. Details stay collapsed until an authorised operator needs them."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -79,12 +155,12 @@ defmodule WhoNeedHelpWeb.AdminAuditLive do
|
||||||
for={@filter_form}
|
for={@filter_form}
|
||||||
id="admin-audit-filters"
|
id="admin-audit-filters"
|
||||||
phx-change="filter"
|
phx-change="filter"
|
||||||
class="grid gap-3 rounded-3xl border border-base-300 bg-base-100 p-4 md:grid-cols-3"
|
class="grid gap-3 rounded-2xl border border-base-300 bg-base-100 p-4 sm:grid-cols-2 xl:grid-cols-[minmax(14rem,1fr)_14rem_12rem_15rem_7rem]"
|
||||||
>
|
>
|
||||||
<.input
|
<.input
|
||||||
field={@filter_form[:search]}
|
field={@filter_form[:search]}
|
||||||
type="search"
|
type="search"
|
||||||
label={gettext("Target or action contains")}
|
label={gettext("Search target or action")}
|
||||||
phx-debounce="300"
|
phx-debounce="300"
|
||||||
/>
|
/>
|
||||||
<.input
|
<.input
|
||||||
|
|
@ -93,93 +169,100 @@ defmodule WhoNeedHelpWeb.AdminAuditLive do
|
||||||
placeholder="user.staff_roles_changed"
|
placeholder="user.staff_roles_changed"
|
||||||
phx-debounce="300"
|
phx-debounce="300"
|
||||||
/>
|
/>
|
||||||
|
<.input
|
||||||
|
field={@filter_form[:target_type]}
|
||||||
|
label={gettext("Target type")}
|
||||||
|
placeholder="user"
|
||||||
|
phx-debounce="300"
|
||||||
|
/>
|
||||||
<.input field={@filter_form[:actor_id]} label={gettext("Actor UUID")} phx-debounce="300" />
|
<.input field={@filter_form[:actor_id]} label={gettext("Actor UUID")} phx-debounce="300" />
|
||||||
|
<.input
|
||||||
|
field={@filter_form[:limit]}
|
||||||
|
type="select"
|
||||||
|
label={gettext("Rows")}
|
||||||
|
options={@page_sizes}
|
||||||
|
/>
|
||||||
</.form>
|
</.form>
|
||||||
|
|
||||||
<div class="space-y-3 md:hidden">
|
<section class="rounded-2xl border border-base-300 bg-base-100">
|
||||||
<p
|
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-base-300 p-4">
|
||||||
:if={@events == []}
|
<div>
|
||||||
class="rounded-3xl border border-dashed border-base-300 p-10 text-center text-base-content/60"
|
<h2 class="font-black">{gettext("Recorded events")}</h2>
|
||||||
>
|
<p class="text-sm text-base-content/55">
|
||||||
|
{gettext(
|
||||||
|
"Actor, action, and target remain visible; technical metadata opens on demand."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span :if={@events != []} class="badge badge-outline">
|
||||||
|
{gettext("Rows %{first}–%{last}", first: elem(@range, 0), last: elem(@range, 1))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p :if={@events == []} class="p-10 text-center text-base-content/60">
|
||||||
{gettext("No audit events match these filters.")}
|
{gettext("No audit events match these filters.")}
|
||||||
</p>
|
</p>
|
||||||
<article
|
|
||||||
:for={event <- @events}
|
|
||||||
class="rounded-3xl border border-base-300 bg-base-100 p-4"
|
|
||||||
>
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<code class="break-all text-xs font-bold text-primary">{event.action}</code>
|
|
||||||
<time class="text-xs text-base-content/55">{event.inserted_at}</time>
|
|
||||||
</div>
|
|
||||||
<dl class="mt-4 grid gap-3 text-sm">
|
|
||||||
<div>
|
|
||||||
<dt class="text-xs font-bold uppercase tracking-wide text-base-content/45">
|
|
||||||
{gettext("Actor")}
|
|
||||||
</dt>
|
|
||||||
<dd class="mt-1 break-all">
|
|
||||||
{(event.actor && (event.actor.display_name || event.actor.email)) || gettext("System")}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt class="text-xs font-bold uppercase tracking-wide text-base-content/45">
|
|
||||||
{gettext("Target")}
|
|
||||||
</dt>
|
|
||||||
<dd class="mt-1 break-all font-mono text-xs">
|
|
||||||
{event.target_type}: {event.target_id}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt class="text-xs font-bold uppercase tracking-wide text-base-content/45">
|
|
||||||
{gettext("Metadata")}
|
|
||||||
</dt>
|
|
||||||
<dd>
|
|
||||||
<pre class="mt-1 whitespace-pre-wrap break-words rounded-2xl bg-base-200 p-3 text-xs">{Jason.encode!(event.metadata, pretty: true)}</pre>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="hidden overflow-x-auto rounded-3xl border border-base-300 bg-base-100 md:block">
|
<div :if={@events != []} class="divide-y divide-base-300">
|
||||||
<table class="table">
|
<details :for={event <- @events} id={"audit-event-#{event.id}"} class="group">
|
||||||
<thead>
|
<summary class="grid cursor-pointer list-none gap-3 p-4 marker:hidden lg:grid-cols-[11rem_minmax(10rem,0.75fr)_minmax(13rem,1fr)_minmax(12rem,0.8fr)_auto] lg:items-center">
|
||||||
<tr>
|
<time class="text-xs text-base-content/60">{format_datetime(event.inserted_at)}</time>
|
||||||
<th>{gettext("Time")}</th><th>{gettext("Actor")}</th><th>{gettext("Action")}</th><th>
|
<div class="min-w-0">
|
||||||
{gettext("Target")}
|
<p class="truncate font-semibold">{actor_label(event)}</p>
|
||||||
</th><th>{gettext("Metadata")}</th>
|
<p :if={event.actor_id} class="truncate font-mono text-[0.7rem] text-base-content/45">
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr :if={@events == []}>
|
|
||||||
<td colspan="5" class="py-10 text-center text-base-content/60">
|
|
||||||
{gettext("No audit events match these filters.")}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr :for={event <- @events}>
|
|
||||||
<td class="whitespace-nowrap text-xs">{event.inserted_at}</td>
|
|
||||||
<td>
|
|
||||||
<span class="font-semibold">{(event.actor &&
|
|
||||||
(event.actor.display_name || event.actor.email)) ||
|
|
||||||
gettext("System")}</span><div :if={event.actor} class="text-xs opacity-55">
|
|
||||||
{event.actor_id}
|
{event.actor_id}
|
||||||
</div>
|
</p>
|
||||||
</td>
|
</div>
|
||||||
<td class="font-mono text-xs">{event.action}</td>
|
<code class="break-all text-xs font-bold text-primary">{event.action}</code>
|
||||||
<td>
|
<div class="min-w-0">
|
||||||
<span>{event.target_type}</span><div class="font-mono text-xs opacity-55">
|
<p class="truncate text-sm font-semibold">{event.target_type}</p>
|
||||||
{event.target_id}
|
<p class="truncate font-mono text-[0.7rem] text-base-content/45">{event.target_id}</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
<span class="flex items-center gap-2 text-sm font-semibold text-base-content/65">
|
||||||
<td>
|
{gettext("Details")}
|
||||||
<pre class="max-w-md whitespace-pre-wrap break-words text-xs">{Jason.encode!(event.metadata, pretty: true)}</pre>
|
<.icon name="hero-chevron-down" class="size-4 transition group-open:rotate-180" />
|
||||||
</td>
|
</span>
|
||||||
</tr>
|
</summary>
|
||||||
</tbody>
|
<div class="border-t border-base-300 bg-base-200/50 p-4">
|
||||||
</table>
|
<div class="mb-3 grid gap-3 text-sm sm:grid-cols-2 lg:hidden">
|
||||||
</div>
|
<p><strong>{gettext("Actor")}:</strong> {actor_label(event)}</p>
|
||||||
<button :if={@events_cursor} phx-click="load-more" class="btn btn-outline">{gettext(
|
<p class="break-all">
|
||||||
"Load more events"
|
<strong>{gettext("Target")}:</strong> {event.target_type}: {event.target_id}
|
||||||
)}</button>
|
</p>
|
||||||
|
</div>
|
||||||
|
<p class="mb-2 text-xs font-bold uppercase tracking-wide text-base-content/50">
|
||||||
|
{gettext("Stored metadata")}
|
||||||
|
</p>
|
||||||
|
<pre class="max-h-80 overflow-auto whitespace-pre-wrap break-words rounded-2xl border border-base-300 bg-base-100 p-3 text-xs">{Jason.encode!(event.metadata, pretty: true)}</pre>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
aria-label={gettext("Audit pages")}
|
||||||
|
class="flex items-center justify-between rounded-2xl border border-base-300 bg-base-100 p-3"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="previous-page"
|
||||||
|
disabled={@cursor_history == []}
|
||||||
|
class="btn btn-sm btn-outline"
|
||||||
|
>
|
||||||
|
<.icon name="hero-arrow-left" class="size-4" />
|
||||||
|
<span class="hidden sm:inline">{gettext("Previous")}</span>
|
||||||
|
</button>
|
||||||
|
<span class="text-sm font-bold">{gettext("Page %{page}", page: @page_number)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="next-page"
|
||||||
|
disabled={is_nil(@next_cursor)}
|
||||||
|
class="btn btn-sm btn-outline"
|
||||||
|
>
|
||||||
|
<span class="hidden sm:inline">{gettext("Next")}</span>
|
||||||
|
<.icon name="hero-arrow-right" class="size-4" />
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
</AdminComponents.shell>
|
</AdminComponents.shell>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -4,37 +4,163 @@ defmodule WhoNeedHelpWeb.ProductAnalyticsLive do
|
||||||
alias WhoNeedHelp.ProductAnalytics
|
alias WhoNeedHelp.ProductAnalytics
|
||||||
alias WhoNeedHelpWeb.AdminComponents
|
alias WhoNeedHelpWeb.AdminComponents
|
||||||
|
|
||||||
|
@page_sizes ~w(25 50 100)
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
page = ProductAnalytics.paginate(socket.assigns.current_scope)
|
filters = %{
|
||||||
|
"metric" => "",
|
||||||
|
"dimension" => "",
|
||||||
|
"date_from" => "",
|
||||||
|
"date_to" => "",
|
||||||
|
"limit" => "25"
|
||||||
|
}
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Product analytics"))
|
|> assign(:page_title, gettext("Product analytics"))
|
||||||
|> assign(:metrics, page.entries)
|
|> assign(:filters, filters)
|
||||||
|> assign(:metrics_cursor, page.next_cursor)}
|
|> assign(:filter_form, to_form(filters, as: :filters))
|
||||||
|
|> assign(:page_cursor, nil)
|
||||||
|
|> assign(:cursor_history, [])
|
||||||
|
|> assign(:page_number, 1)
|
||||||
|
|> load_metrics()}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("load-more", _params, socket) do
|
def handle_event("filter", %{"filters" => params}, socket) do
|
||||||
page =
|
filters = normalize_filters(params)
|
||||||
ProductAnalytics.paginate(socket.assigns.current_scope,
|
|
||||||
after: socket.assigns.metrics_cursor
|
|
||||||
)
|
|
||||||
|
|
||||||
existing_ids = MapSet.new(socket.assigns.metrics, & &1.id)
|
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(
|
|> assign(:filters, filters)
|
||||||
:metrics,
|
|> assign(:filter_form, to_form(filters, as: :filters))
|
||||||
socket.assigns.metrics ++ Enum.reject(page.entries, &MapSet.member?(existing_ids, &1.id))
|
|> reset_pagination()
|
||||||
)
|
|> load_metrics()}
|
||||||
|> assign(:metrics_cursor, page.next_cursor)}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("next-page", _params, %{assigns: %{next_cursor: nil}} = socket),
|
||||||
|
do: {:noreply, socket}
|
||||||
|
|
||||||
|
def handle_event("next-page", _params, socket) do
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:cursor_history, [socket.assigns.page_cursor | socket.assigns.cursor_history])
|
||||||
|
|> assign(:page_cursor, socket.assigns.next_cursor)
|
||||||
|
|> assign(:page_number, socket.assigns.page_number + 1)
|
||||||
|
|> load_metrics()}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("previous-page", _params, %{assigns: %{cursor_history: []}} = socket),
|
||||||
|
do: {:noreply, socket}
|
||||||
|
|
||||||
|
def handle_event("previous-page", _params, socket) do
|
||||||
|
[previous_cursor | history] = socket.assigns.cursor_history
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:cursor_history, history)
|
||||||
|
|> assign(:page_cursor, previous_cursor)
|
||||||
|
|> assign(:page_number, max(socket.assigns.page_number - 1, 1))
|
||||||
|
|> load_metrics()}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp load_metrics(socket) do
|
||||||
|
options = metric_options(socket.assigns.filters, socket.assigns.page_cursor)
|
||||||
|
page = ProductAnalytics.paginate(socket.assigns.current_scope, options)
|
||||||
|
|
||||||
|
overview_options =
|
||||||
|
options
|
||||||
|
|> Keyword.take([:date_from, :date_to])
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:metrics, page.entries)
|
||||||
|
|> assign(:next_cursor, page.next_cursor)
|
||||||
|
|> assign(
|
||||||
|
:overview,
|
||||||
|
ProductAnalytics.overview(socket.assigns.current_scope, overview_options)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp reset_pagination(socket) do
|
||||||
|
socket
|
||||||
|
|> assign(:page_cursor, nil)
|
||||||
|
|> assign(:cursor_history, [])
|
||||||
|
|> assign(:page_number, 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp metric_options(filters, cursor) do
|
||||||
|
[
|
||||||
|
metric: filters["metric"],
|
||||||
|
dimension: filters["dimension"],
|
||||||
|
date_from: parse_date(filters["date_from"]),
|
||||||
|
date_to: parse_date(filters["date_to"]),
|
||||||
|
after: cursor,
|
||||||
|
limit: page_size(filters["limit"])
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_filters(params) do
|
||||||
|
%{
|
||||||
|
"metric" => normalize_choice(params["metric"], ProductAnalytics.allowed_metrics()),
|
||||||
|
"dimension" => normalize_text(params["dimension"], 80),
|
||||||
|
"date_from" => normalize_date(params["date_from"]),
|
||||||
|
"date_to" => normalize_date(params["date_to"]),
|
||||||
|
"limit" => normalize_choice(params["limit"], @page_sizes, "25")
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_choice(value, choices, fallback \\ "") do
|
||||||
|
if value in choices, do: value, else: fallback
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_text(value, max),
|
||||||
|
do: value |> to_string() |> String.trim() |> String.slice(0, max)
|
||||||
|
|
||||||
|
defp normalize_date(value) do
|
||||||
|
case Date.from_iso8601(to_string(value)) do
|
||||||
|
{:ok, _date} -> value
|
||||||
|
_invalid -> ""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_date(""), do: nil
|
||||||
|
defp parse_date(value), do: Date.from_iso8601!(value)
|
||||||
|
|
||||||
|
defp page_size(value) do
|
||||||
|
case Integer.parse(value || "") do
|
||||||
|
{size, ""} when size in [25, 50, 100] -> size
|
||||||
|
_invalid -> 25
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp range([], _page, _limit), do: {0, 0}
|
||||||
|
|
||||||
|
defp range(entries, page, limit) do
|
||||||
|
first = (page - 1) * limit + 1
|
||||||
|
{first, first + length(entries) - 1}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp metric_label(metric),
|
||||||
|
do: metric |> to_string() |> String.replace(".", " ") |> String.replace("_", " ")
|
||||||
|
|
||||||
|
defp compact_number(value) when value >= 1_000_000,
|
||||||
|
do: :erlang.float_to_binary(value / 1_000_000, decimals: 1) <> "M"
|
||||||
|
|
||||||
|
defp compact_number(value) when value >= 1_000,
|
||||||
|
do: :erlang.float_to_binary(value / 1_000, decimals: 1) <> "K"
|
||||||
|
|
||||||
|
defp compact_number(value), do: to_string(value)
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
|
limit = page_size(assigns.filters["limit"])
|
||||||
|
|
||||||
|
assigns =
|
||||||
|
assigns
|
||||||
|
|> assign(:range, range(assigns.metrics, assigns.page_number, limit))
|
||||||
|
|> assign(:page_sizes, @page_sizes)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<AdminComponents.shell
|
<AdminComponents.shell
|
||||||
flash={@flash}
|
flash={@flash}
|
||||||
|
|
@ -43,45 +169,155 @@ defmodule WhoNeedHelpWeb.ProductAnalyticsLive do
|
||||||
title={gettext("Product analytics")}
|
title={gettext("Product analytics")}
|
||||||
description={
|
description={
|
||||||
gettext(
|
gettext(
|
||||||
"Daily aggregate counters only. No user ID, email, coordinate, request text, chat text, medicine name, or device credential is stored here."
|
"Privacy-preserving daily totals. This workspace never stores people, email, coordinates, request text, chat content, medicine names, or device credentials."
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div class="overflow-x-auto rounded-3xl border border-base-300 bg-base-100">
|
<section class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
<table class="table">
|
<.summary_card
|
||||||
<thead>
|
label={gettext("All measured actions")}
|
||||||
<tr>
|
value={@overview.total}
|
||||||
<th>{gettext("Date")}</th>
|
icon="hero-chart-bar"
|
||||||
<th>{gettext("Metric")}</th>
|
/>
|
||||||
<th>{gettext("Dimension")}</th>
|
<.summary_card
|
||||||
<th>{gettext("Count")}</th>
|
label={gettext("Registrations")}
|
||||||
</tr>
|
value={@overview.registrations}
|
||||||
</thead>
|
icon="hero-user-plus"
|
||||||
<tbody>
|
/>
|
||||||
<tr :if={@metrics == []}>
|
<.summary_card
|
||||||
<td colspan="4" class="py-10 text-center text-base-content/60">
|
label={gettext("Requests created")}
|
||||||
{gettext("No aggregate events recorded yet.")}
|
value={@overview.requests_created}
|
||||||
</td>
|
icon="hero-hand-raised"
|
||||||
</tr>
|
/>
|
||||||
<tr :for={metric <- @metrics}>
|
<.summary_card
|
||||||
<td>{metric.date}</td>
|
label={gettext("Requests completed")}
|
||||||
<td class="font-mono text-xs">{metric.metric}</td>
|
value={@overview.requests_completed}
|
||||||
<td>{metric.dimension}</td>
|
icon="hero-check-circle"
|
||||||
<td class="font-bold">{metric.count}</td>
|
/>
|
||||||
</tr>
|
</section>
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<.form
|
||||||
:if={@metrics_cursor}
|
for={@filter_form}
|
||||||
type="button"
|
id="analytics-filters"
|
||||||
phx-click="load-more"
|
phx-change="filter"
|
||||||
class="btn btn-outline btn-sm"
|
class="grid gap-3 rounded-2xl border border-base-300 bg-base-100 p-4 sm:grid-cols-2 xl:grid-cols-[minmax(12rem,1fr)_minmax(10rem,0.8fr)_11rem_11rem_7rem]"
|
||||||
>
|
>
|
||||||
{gettext("Load more")}
|
<.input
|
||||||
</button>
|
field={@filter_form[:metric]}
|
||||||
|
type="select"
|
||||||
|
label={gettext("Metric")}
|
||||||
|
options={[
|
||||||
|
{gettext("All metrics"), ""}
|
||||||
|
| Enum.map(ProductAnalytics.allowed_metrics(), &{metric_label(&1), &1})
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<.input
|
||||||
|
field={@filter_form[:dimension]}
|
||||||
|
label={gettext("Dimension")}
|
||||||
|
placeholder={gettext("Any dimension")}
|
||||||
|
phx-debounce="300"
|
||||||
|
/>
|
||||||
|
<.input field={@filter_form[:date_from]} type="date" label={gettext("From")} />
|
||||||
|
<.input field={@filter_form[:date_to]} type="date" label={gettext("To")} />
|
||||||
|
<.input
|
||||||
|
field={@filter_form[:limit]}
|
||||||
|
type="select"
|
||||||
|
label={gettext("Rows")}
|
||||||
|
options={@page_sizes}
|
||||||
|
/>
|
||||||
|
</.form>
|
||||||
|
|
||||||
|
<section class="rounded-2xl border border-base-300 bg-base-100">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-base-300 p-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-black">{gettext("Daily counters")}</h2>
|
||||||
|
<p class="text-sm text-base-content/55">
|
||||||
|
{gettext("One row per date, metric, and non-identifying dimension.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span :if={@metrics != []} class="badge badge-outline">
|
||||||
|
{gettext("Rows %{first}–%{last}", first: elem(@range, 0), last: elem(@range, 1))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p :if={@metrics == []} class="p-10 text-center text-base-content/60">
|
||||||
|
{gettext("No aggregate counters match these filters.")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div :if={@metrics != []} class="divide-y divide-base-300 md:hidden">
|
||||||
|
<article :for={metric <- @metrics} class="grid grid-cols-[1fr_auto] gap-3 p-4">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate font-bold capitalize">{metric_label(metric.metric)}</p>
|
||||||
|
<p class="mt-1 text-sm text-base-content/55">{metric.date} · {metric.dimension}</p>
|
||||||
|
</div>
|
||||||
|
<strong class="text-lg">{compact_number(metric.count)}</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :if={@metrics != []} class="hidden overflow-x-auto md:block">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{gettext("Date")}</th><th>{gettext("Metric")}</th><th>{gettext("Dimension")}</th><th class="text-right">
|
||||||
|
{gettext("Count")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr :for={metric <- @metrics}>
|
||||||
|
<td class="whitespace-nowrap">{metric.date}</td>
|
||||||
|
<td class="font-semibold capitalize">{metric_label(metric.metric)}</td>
|
||||||
|
<td><span class="badge badge-ghost">{metric.dimension}</span></td>
|
||||||
|
<td class="text-right text-lg font-black">{metric.count}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
aria-label={gettext("Analytics pages")}
|
||||||
|
class="flex items-center justify-between rounded-2xl border border-base-300 bg-base-100 p-3"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="previous-page"
|
||||||
|
disabled={@cursor_history == []}
|
||||||
|
class="btn btn-sm btn-outline"
|
||||||
|
>
|
||||||
|
<.icon name="hero-arrow-left" class="size-4" />
|
||||||
|
<span class="hidden sm:inline">{gettext("Previous")}</span>
|
||||||
|
</button>
|
||||||
|
<span class="text-sm font-bold">{gettext("Page %{page}", page: @page_number)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
phx-click="next-page"
|
||||||
|
disabled={is_nil(@next_cursor)}
|
||||||
|
class="btn btn-sm btn-outline"
|
||||||
|
>
|
||||||
|
<span class="hidden sm:inline">{gettext("Next")}</span>
|
||||||
|
<.icon name="hero-arrow-right" class="size-4" />
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
</AdminComponents.shell>
|
</AdminComponents.shell>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
attr :label, :string, required: true
|
||||||
|
attr :value, :integer, required: true
|
||||||
|
attr :icon, :string, required: true
|
||||||
|
|
||||||
|
defp summary_card(assigns) do
|
||||||
|
~H"""
|
||||||
|
<article class="flex items-center gap-4 rounded-2xl border border-base-300 bg-base-100 p-4">
|
||||||
|
<span class="grid size-11 shrink-0 place-items-center rounded-2xl bg-primary/10 text-primary">
|
||||||
|
<.icon name={@icon} class="size-5" />
|
||||||
|
</span>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate text-sm text-base-content/55">{@label}</p>
|
||||||
|
<p class="text-2xl font-black">{compact_number(@value)}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
"""
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -43,4 +43,29 @@ defmodule WhoNeedHelp.ProductAnalyticsTest do
|
||||||
|
|
||||||
refute Repo.get_by(DailyMetric, metric: "request.created", dimension: "now")
|
refute Repo.get_by(DailyMetric, metric: "request.created", dimension: "now")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "filters aggregate rows and returns privacy-safe overview counters" do
|
||||||
|
analyst = [:analyst] |> staff_user_fixture() |> user_scope_fixture()
|
||||||
|
|
||||||
|
assert {:ok, _metric} = ProductAnalytics.increment("account.registered", "google")
|
||||||
|
assert {:ok, _metric} = ProductAnalytics.increment("request.created", "today")
|
||||||
|
assert {:ok, _metric} = ProductAnalytics.increment("request.created", "today")
|
||||||
|
assert {:ok, _metric} = ProductAnalytics.increment("request.completed", "all")
|
||||||
|
|
||||||
|
assert [%DailyMetric{metric: "request.created", dimension: "today", count: 2}] =
|
||||||
|
ProductAnalytics.paginate(analyst,
|
||||||
|
metric: "request.created",
|
||||||
|
dimension: "today"
|
||||||
|
).entries
|
||||||
|
|
||||||
|
today = Date.utc_today()
|
||||||
|
|
||||||
|
assert %{
|
||||||
|
total: 4,
|
||||||
|
registrations: 1,
|
||||||
|
requests_created: 2,
|
||||||
|
requests_completed: 1,
|
||||||
|
trend: [%{date: ^today, count: 4}]
|
||||||
|
} = ProductAnalytics.overview(analyst, date_from: today, date_to: today)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,29 @@ defmodule WhoNeedHelpWeb.AdminLiveTest do
|
||||||
{:ok, _audit_view, audit_html} = live(conn, ~p"/admin/audit")
|
{:ok, _audit_view, audit_html} = live(conn, ~p"/admin/audit")
|
||||||
assert audit_html =~ "user.staff_roles_changed"
|
assert audit_html =~ "user.staff_roles_changed"
|
||||||
assert audit_html =~ "user.moderated"
|
assert audit_html =~ "user.moderated"
|
||||||
|
assert audit_html =~ "Details"
|
||||||
|
assert audit_html =~ ~s(<details id="audit-event-)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "administrator workspaces expose compact filters and paginated queues", %{conn: conn} do
|
||||||
|
admin = staff_user_fixture([:admin], display_name: "Workspace administrator")
|
||||||
|
conn = log_in_user(conn, admin)
|
||||||
|
|
||||||
|
{:ok, moderation, moderation_html} = live(conn, ~p"/moderation")
|
||||||
|
assert moderation_html =~ "Work one review queue at a time"
|
||||||
|
assert has_element?(moderation, "#report-filters")
|
||||||
|
assert has_element?(moderation, ~s(a[href="/moderation?queue=signals"]))
|
||||||
|
assert has_element?(moderation, ~s(a[href="/moderation?queue=proposals"]))
|
||||||
|
|
||||||
|
{:ok, analytics, analytics_html} = live(conn, ~p"/analytics")
|
||||||
|
assert analytics_html =~ "Privacy-preserving daily totals"
|
||||||
|
assert has_element?(analytics, "#analytics-filters")
|
||||||
|
assert analytics_html =~ "Daily counters"
|
||||||
|
|
||||||
|
{:ok, audit, audit_html} = live(conn, ~p"/admin/audit")
|
||||||
|
assert has_element?(audit, "#admin-audit-filters")
|
||||||
|
assert audit_html =~ "Recorded events"
|
||||||
|
assert audit_html =~ "Page 1"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "administrator UI protects the final active administrator", %{conn: conn} do
|
test "administrator UI protects the final active administrator", %{conn: conn} do
|
||||||
|
|
|
||||||
|
|
@ -1532,13 +1532,12 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
||||||
})
|
})
|
||||||
|
|
||||||
{:ok, view, html} = live(conn, ~p"/moderation")
|
{:ok, view, html} = live(conn, ~p"/moderation")
|
||||||
assert html =~ "Moderation"
|
assert html =~ "Trust and safety"
|
||||||
assert html =~ "Reports"
|
assert html =~ "Reports"
|
||||||
assert html =~ "Open abuse signals"
|
assert html =~ "Abuse signals"
|
||||||
assert html =~ "Category proposals"
|
assert html =~ "Category proposals"
|
||||||
refute html =~ "Users & roles"
|
refute html =~ "Users & roles"
|
||||||
assert html =~ "Please review the matched conversation."
|
assert html =~ "Please review the matched conversation."
|
||||||
assert html =~ "Bicycle repair"
|
|
||||||
refute has_element?(view, "#moderation-user-status-#{user.id}")
|
refute has_element?(view, "#moderation-user-status-#{user.id}")
|
||||||
|
|
||||||
assert has_element?(
|
assert has_element?(
|
||||||
|
|
@ -1552,6 +1551,9 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
assert evidence_html =~ "Evidence message"
|
assert evidence_html =~ "Evidence message"
|
||||||
|
|
||||||
|
proposal_html = render_patch(view, ~p"/moderation?queue=proposals")
|
||||||
|
assert proposal_html =~ "Bicycle repair"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "message reports expose parent activity moderation and switch to restore", %{
|
test "message reports expose parent activity moderation and switch to restore", %{
|
||||||
|
|
@ -1615,7 +1617,7 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
||||||
grant_staff_roles(user, [:moderator])
|
grant_staff_roles(user, [:moderator])
|
||||||
subject = user_fixture(display_name: "Signal subject")
|
subject = user_fixture(display_name: "Signal subject")
|
||||||
|
|
||||||
for sequence <- 1..25 do
|
for sequence <- 1..26 do
|
||||||
%AbuseSignal{}
|
%AbuseSignal{}
|
||||||
|> AbuseSignal.changeset(%{
|
|> AbuseSignal.changeset(%{
|
||||||
kind: :manual,
|
kind: :manual,
|
||||||
|
|
@ -1625,29 +1627,23 @@ defmodule WhoNeedHelpWeb.MutualAidLiveTest do
|
||||||
|> Repo.insert!()
|
|> Repo.insert!()
|
||||||
end
|
end
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/moderation")
|
{:ok, view, html} = live(conn, ~p"/moderation?queue=signals")
|
||||||
|
|
||||||
assert has_element?(
|
assert html =~ "Automated signals are review prompts, not proof of abuse."
|
||||||
view,
|
assert length(Regex.scan(~r/id="signal-[0-9a-f-]{36}"/, html)) == 25
|
||||||
"#abuse-signals > button[phx-click='load-more-signals']"
|
assert has_element?(view, ~s|button[phx-click="next-page"]:not([disabled])|)
|
||||||
)
|
|
||||||
|
|
||||||
refute has_element?(view, "#report-evidence button[phx-click='load-more-signals']")
|
refute has_element?(view, "#report-evidence")
|
||||||
|
|
||||||
view
|
view
|
||||||
|> element("#abuse-signals > button[phx-click='load-more-signals']")
|
|> element(~s(button[phx-click="next-page"]))
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
assert view
|
page_two = render(view)
|
||||||
|> render()
|
assert page_two =~ "Page 2"
|
||||||
|> LazyHTML.from_document()
|
assert length(Regex.scan(~r/id="signal-[0-9a-f-]{36}"/, page_two)) == 1
|
||||||
|> LazyHTML.query("#abuse-signals article")
|
|
||||||
|> Enum.count() == 25
|
|
||||||
|
|
||||||
refute has_element?(
|
assert has_element?(view, ~s|button[phx-click="previous-page"]:not([disabled])|)
|
||||||
view,
|
|
||||||
"#abuse-signals > button[phx-click='load-more-signals']"
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "request page exposes report and block controls for another user", %{conn: conn} do
|
test "request page exposes report and block controls for another user", %{conn: conn} do
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user