defmodule GitGudWeb.AuditLive.Page do
@moduledoc """
What the audit views share: the sudo check and canonical paths in
`handle_params`, a page of entries, and the page body — category
tabs, the actor and repository filter, the entries, and Older/Newer
links.
New entries arrive live: once connected, a view subscribes to its
scope's topic (`GitGud.Audit.topic/1`), and `live/3` puts each one
that matches the filters at the top of the first page. Older pages
stay as they were.
An actor filter names a handle: the account's entries if it exists,
and otherwise the ones recorded under that label, so a deleted
account's history can still be picked out. A repository filter does
the same with the org's `handle/name`.
Each view keeps its own permission check (run on every
`handle_params`, before `params/5`) and its own chrome around
`body/1`.
"""
use GitGudWeb, :html
import GitGudWeb.AuditComponents
import Phoenix.LiveView,
only: [put_flash: 3, redirect: 2, push_patch: 2, stream: 4, stream_insert: 4, connected?: 1]
alias GitGud.Accounts
alias GitGud.Audit
alias GitGudWeb.AuditLive.Filters
@per_page 50
@doc """
The `handle_params` of a view whose user may see `scope`'s entries
under `base`: sends them to re-authenticate outside sudo mode,
patches an unknown or differently spelled filter to its canonical
path, and otherwise loads the page.
"""
def params(socket, params, uri, base, scope) do
path = URI.parse(uri).path
with true <- Accounts.sudo_mode?(socket.assigns.current_scope.user) || :reauth,
{:ok, filters} <-
Filters.parse(params, socket.assigns.categories, socket.assigns.refines) do
canonical = Filters.to_path(base, filters)
if path == canonical,
do: {:noreply, socket |> subscribe(scope) |> load(base, scope, filters)},
else: {:noreply, push_patch(socket, to: canonical, replace: true)}
else
:reauth ->
{:noreply,
socket
|> put_flash(:error, "You must re-authenticate to access this page.")
|> redirect(to: ~p"/users/log-in?#{[return_to: path]}")}
:error ->
{:noreply,
socket
|> put_flash(:error, "That filter isn't one this page knows.")
|> push_patch(to: base)}
end
end
@doc "The categories, of `wanted`, that some catalog action belongs to."
def categories(wanted), do: Enum.filter(wanted, &(&1 in GitGud.Audit.Catalog.categories()))
@doc """
Ready a mounted socket for `params/5`: the categories, which
refinements the view offers, and the stream. Options:
* `refines:` — of `:actor`, `:org`, `:repo`, `:scope` (default
`[:actor]`)
* `repos:` — the repository names the filter offers, with `:repo`
* `show_request:` — show each entry's address and user agent; the
instance view's alone
* `own_requests:` — a user id: show the address and user agent of
that user's own entries only
"""
def setup(socket, categories, opts \\ []) do
socket
|> assign(:categories, categories(categories))
|> assign(:refines, Keyword.get(opts, :refines, [:actor]))
|> assign(:repo_options, Keyword.get(opts, :repos))
|> assign(:show_request, Keyword.get(opts, :show_request, false))
|> assign(:own_requests, Keyword.get(opts, :own_requests))
|> Phoenix.LiveView.stream_configure(:entries, dom_id: &"audit-entry-#{&1.id}")
end
@doc """
A `{:audit, id}` broadcast arrived. `allowed?` is the view's own
permission check, asked again — a page left open mustn't keep
showing new entries to someone who has since lost the right, or
whose sudo window closed. Then, on the first page only, the entry
goes at the top if it matches the filters.
"""
def live(socket, id, allowed?) do
user = socket.assigns.current_scope.user
cond do
not (allowed? and Accounts.sudo_mode?(user)) ->
{:noreply, redirect(socket, to: socket.assigns.base)}
socket.assigns.filters.cursor != nil ->
{:noreply, socket}
true ->
%{scope: scope, filters: filters} = socket.assigns
matching = Map.put(list_filters(scope, filters), :id, id)
case Audit.list(scope, matching, nil, 1) do
{[entry], _} ->
{:noreply, stream_insert(socket, :entries, entry, at: 0, limit: @per_page)}
_ ->
{:noreply, socket}
end
end
end
defp subscribe(%{assigns: %{subscribed: true}} = socket, _scope), do: socket
defp subscribe(socket, scope) do
if connected?(socket) do
Phoenix.PubSub.subscribe(GitGud.PubSub, Audit.topic(scope))
assign(socket, :subscribed, true)
else
socket
end
end
@doc "The filter form's submit: patch to the path for what it says."
def filter(socket, params) do
case Filters.from_form(socket.assigns.filters, params) do
{:ok, filters} ->
{:noreply, push_patch(socket, to: Filters.to_path(socket.assigns.base, filters))}
:error ->
{:noreply, put_flash(socket, :error, "That isn't a handle or repository name.")}
end
end
defp load(socket, base, scope, filters) do
{entries, cursors} =
Audit.list(scope, list_filters(scope, filters), filters.cursor, @per_page)
socket
|> assign(:base, base)
|> assign(:scope, scope)
|> assign(:filters, filters)
|> assign(
:filter_form,
to_form(
%{
"actor" => filters.actor || "",
"org" => filters.org || "",
"repo" => filters.repo || "",
"scope" => filters.scope == "instance"
},
as: :filter
)
)
|> assign(
:older,
cursors.older && Filters.to_path(base, %{filters | cursor: {:before, cursors.older}})
)
|> assign(:newer, newer_path(base, filters, cursors.newer))
|> stream(:entries, entries, reset: true)
end
defp list_filters(scope, filters) do
%{category: filters.category}
|> Map.merge(Audit.actor_filter(filters.actor))
|> Map.merge(Audit.organization_filter(filters.org))
|> Map.merge(repo_filter(scope, filters.repo))
|> Map.merge(if filters.scope == "instance", do: %{instance_only: true}, else: %{})
end
defp repo_filter({:org, org}, name), do: Audit.repository_filter(org, name)
defp repo_filter(_scope, _name), do: %{}
# Back to the top when the newer page would be the first one anyway.
defp newer_path(_base, %Filters{cursor: nil}, _), do: nil
defp newer_path(base, filters, nil), do: Filters.to_path(base, %{filters | cursor: nil})
defp newer_path(base, filters, id), do: Filters.to_path(base, %{filters | cursor: {:after, id}})
@doc "Category tabs, the filter, the entries, and the pager."
attr :base, :string, required: true
attr :filters, Filters, required: true
attr :filter_form, Phoenix.HTML.Form, required: true
attr :categories, :list, required: true
attr :refines, :list, default: [:actor]
attr :repo_options, :list, default: nil
attr :show_request, :boolean, default: false
attr :own_requests, :integer, default: nil
attr :entries, :any, required: true
attr :older, :string, default: nil
attr :newer, :string, default: nil
def body(assigns) do
~H"""
<nav id="audit-categories" class="flex flex-wrap gap-1" aria-label="Category">
<.link
id="audit-category-all"
patch={tab(@base, @filters, nil)}
class={["btn btn-xs", if(@filters.category == nil, do: "btn-primary", else: "btn-ghost")]}
>
All
</.link>
<.link
:for={c <- @categories}
id={"audit-category-#{c}"}
patch={tab(@base, @filters, c)}
class={["btn btn-xs", if(@filters.category == c, do: "btn-primary", else: "btn-ghost")]}
>
{category_label(c)}
</.link>
</nav>
<.form
:if={@refines != []}
for={@filter_form}
id="audit-filter"
phx-submit="filter"
class="flex flex-wrap items-end gap-2"
>
<div class="w-48">
<.input field={@filter_form[:actor]} label="Who" placeholder="handle" />
</div>
<div :if={:org in @refines} class="w-48">
<.input field={@filter_form[:org]} label="Organization" placeholder="handle" />
</div>
<div :if={:repo in @refines and @repo_options} class="w-48">
<.input
field={@filter_form[:repo]}
type="select"
label="Repository"
prompt="Any"
options={@repo_options}
/>
</div>
<div :if={:scope in @refines} class="mb-2">
<.input
field={@filter_form[:scope]}
type="checkbox"
label="Instance-wide only"
/>
</div>
<button type="submit" class="btn btn-sm mb-2">Filter</button>
<.link
:if={refined?(@filters)}
id="audit-filter-clear"
patch={
Filters.to_path(@base, %{
@filters
| actor: nil,
org: nil,
repo: nil,
scope: nil,
cursor: nil
})
}
class="btn btn-sm btn-ghost mb-2"
>
Clear
</.link>
</.form>
<ul id="audit-entries" phx-update="stream" class="divide-y divide-base-300">
<li id="audit-empty" class="hidden only:block py-8 text-center text-sm opacity-60">
{if refined?(@filters),
do: "Nothing matches that filter.",
else: "Nothing recorded here yet."}
</li>
<.entry_row
:for={{id, entry} <- @entries}
id={id}
entry={entry}
show_request={@show_request}
own_requests={@own_requests}
/>
</ul>
<nav :if={@newer || @older} class="flex justify-between text-sm" aria-label="Pages">
<.link :if={@newer} id="audit-newer" patch={@newer} class="btn btn-sm btn-ghost">
<.icon name="hero-arrow-left-micro" class="size-4" /> Newer
</.link>
<span :if={!@newer}></span>
<.link :if={@older} id="audit-older" patch={@older} class="btn btn-sm btn-ghost">
Older <.icon name="hero-arrow-right-micro" class="size-4" />
</.link>
</nav>
"""
end
defp refined?(filters), do: !!(filters.actor || filters.org || filters.repo || filters.scope)
# A category tab keeps the actor and repository, and starts over at
# the newest entry.
defp tab(base, filters, category),
do: Filters.to_path(base, %{filters | category: category, cursor: nil})
end
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
10.2 KiB · text
History
b868907