add URL redirects with ETS-cached plug, broken URL tracking, and admin UI
All checks were successful
deploy / deploy (push) Successful in 3m30s
All checks were successful
deploy / deploy (push) Successful in 3m30s
Redirects context with redirect/broken_url schemas, chain flattening, ETS cache for fast lookups in the request pipeline. BrokenUrlTracker plug logs 404s. Auto-redirect on product slug change via upsert_product hook. Admin redirects page with active/broken tabs, manual create form. RedirectPrunerWorker cleans up old broken URLs. 1227 tests passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
279
lib/berrypod_web/live/admin/redirects.ex
Normal file
279
lib/berrypod_web/live/admin/redirects.ex
Normal file
@@ -0,0 +1,279 @@
|
||||
defmodule BerrypodWeb.Admin.Redirects do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Redirects
|
||||
|
||||
@valid_tabs ~w(redirects broken create)
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
if connected?(socket), do: Redirects.subscribe()
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, "Redirects")
|
||||
|> assign(:redirects, Redirects.list_redirects())
|
||||
|> assign(:broken_urls, Redirects.list_broken_urls())
|
||||
|> assign(
|
||||
:form,
|
||||
to_form(%{"from_path" => "", "to_path" => "", "status_code" => "301"}, as: :redirect)
|
||||
)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _uri, socket) do
|
||||
tab = if params["tab"] in @valid_tabs, do: params["tab"], else: "redirects"
|
||||
{:noreply, assign(socket, :tab, tab)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:redirects_changed, _action}, socket) do
|
||||
{:noreply, assign(socket, :redirects, Redirects.list_redirects())}
|
||||
end
|
||||
|
||||
def handle_info({:broken_urls_changed, _path}, socket) do
|
||||
{:noreply, assign(socket, :broken_urls, Redirects.list_broken_urls())}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("switch_tab", %{"tab" => tab}, socket) do
|
||||
{:noreply, push_patch(socket, to: ~p"/admin/redirects?#{%{tab: tab}}")}
|
||||
end
|
||||
|
||||
def handle_event("delete_redirect", %{"id" => id}, socket) do
|
||||
redirect = Redirects.get_redirect!(id)
|
||||
{:ok, _} = Redirects.delete_redirect(redirect)
|
||||
|
||||
{:noreply, assign(socket, :redirects, Redirects.list_redirects())}
|
||||
end
|
||||
|
||||
def handle_event("create_redirect", %{"redirect" => params}, socket) do
|
||||
from_path = params["from_path"]
|
||||
|
||||
case Redirects.create_manual(%{
|
||||
from_path: from_path,
|
||||
to_path: params["to_path"],
|
||||
status_code: String.to_integer(params["status_code"])
|
||||
}) do
|
||||
{:ok, _redirect} ->
|
||||
resolve_matching_broken_url(from_path)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(
|
||||
:form,
|
||||
to_form(%{"from_path" => "", "to_path" => "", "status_code" => "301"}, as: :redirect)
|
||||
)
|
||||
|> put_flash(:info, "Redirect created")
|
||||
|> push_patch(to: ~p"/admin/redirects?#{%{tab: "redirects"}}")
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("ignore_broken_url", %{"id" => id}, socket) do
|
||||
broken_url = Redirects.get_broken_url!(id)
|
||||
{:ok, _} = Redirects.ignore_broken_url(broken_url)
|
||||
|
||||
{:noreply, assign(socket, :broken_urls, Redirects.list_broken_urls())}
|
||||
end
|
||||
|
||||
def handle_event("redirect_broken_url", %{"path" => path}, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(
|
||||
:form,
|
||||
to_form(%{"from_path" => path, "to_path" => "", "status_code" => "301"}, as: :redirect)
|
||||
)
|
||||
|> push_patch(to: ~p"/admin/redirects?#{%{tab: "create"}}")
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.header>
|
||||
Redirects
|
||||
</.header>
|
||||
|
||||
<div class="flex gap-2 mt-6 mb-4 flex-wrap">
|
||||
<.tab_button tab="redirects" label="Active" count={length(@redirects)} active={@tab} />
|
||||
<.tab_button tab="broken" label="Broken URLs" count={length(@broken_urls)} active={@tab} />
|
||||
<.tab_button tab="create" label="Create" active={@tab} />
|
||||
</div>
|
||||
|
||||
<%= if @tab == "redirects" do %>
|
||||
<.redirects_table redirects={@redirects} />
|
||||
<% end %>
|
||||
|
||||
<%= if @tab == "broken" do %>
|
||||
<.broken_urls_table broken_urls={@broken_urls} />
|
||||
<% end %>
|
||||
|
||||
<%= if @tab == "create" do %>
|
||||
<.create_form form={@form} />
|
||||
<% end %>
|
||||
"""
|
||||
end
|
||||
|
||||
defp redirects_table(assigns) do
|
||||
~H"""
|
||||
<%= if @redirects == [] do %>
|
||||
<p>No redirects yet.</p>
|
||||
<% else %>
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>From</th>
|
||||
<th>To</th>
|
||||
<th>Source</th>
|
||||
<th>Hits</th>
|
||||
<th>Created</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<%= for redirect <- @redirects do %>
|
||||
<tr>
|
||||
<td><code>{redirect.from_path}</code></td>
|
||||
<td><code>{redirect.to_path}</code></td>
|
||||
<td>
|
||||
<span class={"badge badge-#{source_colour(redirect.source)}"}>
|
||||
{redirect.source}
|
||||
</span>
|
||||
</td>
|
||||
<td>{redirect.hit_count}</td>
|
||||
<td>{Calendar.strftime(redirect.inserted_at, "%d %b %Y")}</td>
|
||||
<td>
|
||||
<button
|
||||
phx-click="delete_redirect"
|
||||
phx-value-id={redirect.id}
|
||||
data-confirm="Delete this redirect?"
|
||||
class="admin-btn admin-btn-sm admin-btn-ghost"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% end %>
|
||||
"""
|
||||
end
|
||||
|
||||
defp broken_urls_table(assigns) do
|
||||
~H"""
|
||||
<%= if @broken_urls == [] do %>
|
||||
<p>No broken URLs detected.</p>
|
||||
<% else %>
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Path</th>
|
||||
<th>Prior traffic</th>
|
||||
<th>404s</th>
|
||||
<th>First seen</th>
|
||||
<th>Last seen</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<%= for broken_url <- @broken_urls do %>
|
||||
<tr>
|
||||
<td><code>{broken_url.path}</code></td>
|
||||
<td>{broken_url.prior_analytics_hits}</td>
|
||||
<td>{broken_url.recent_404_count}</td>
|
||||
<td>{Calendar.strftime(broken_url.first_seen_at, "%d %b %Y")}</td>
|
||||
<td>{Calendar.strftime(broken_url.last_seen_at, "%d %b %Y")}</td>
|
||||
<td class="flex gap-2">
|
||||
<button
|
||||
phx-click="redirect_broken_url"
|
||||
phx-value-path={broken_url.path}
|
||||
class="admin-btn admin-btn-sm admin-btn-primary"
|
||||
>
|
||||
Redirect
|
||||
</button>
|
||||
<button
|
||||
phx-click="ignore_broken_url"
|
||||
phx-value-id={broken_url.id}
|
||||
class="admin-btn admin-btn-sm admin-btn-ghost"
|
||||
>
|
||||
Ignore
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% end %>
|
||||
"""
|
||||
end
|
||||
|
||||
defp create_form(assigns) do
|
||||
~H"""
|
||||
<.form for={@form} phx-submit="create_redirect" style="max-width: 32rem;">
|
||||
<.input
|
||||
field={@form[:from_path]}
|
||||
label="From path"
|
||||
placeholder="/products/old-slug"
|
||||
required
|
||||
/>
|
||||
<.input
|
||||
field={@form[:to_path]}
|
||||
label="To path"
|
||||
placeholder="/products/new-slug"
|
||||
required
|
||||
/>
|
||||
<.input
|
||||
field={@form[:status_code]}
|
||||
type="select"
|
||||
label="Status code"
|
||||
options={[{"301 (permanent)", "301"}, {"302 (temporary)", "302"}]}
|
||||
/>
|
||||
<button type="submit" class="admin-btn admin-btn-primary">Create redirect</button>
|
||||
</.form>
|
||||
"""
|
||||
end
|
||||
|
||||
defp tab_button(assigns) do
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:active, assigns.active == assigns.tab)
|
||||
|> assign_new(:count, fn -> nil end)
|
||||
|
||||
~H"""
|
||||
<button
|
||||
phx-click="switch_tab"
|
||||
phx-value-tab={@tab}
|
||||
class={[
|
||||
"admin-btn admin-btn-sm",
|
||||
@active && "admin-btn-primary",
|
||||
!@active && "admin-btn-ghost"
|
||||
]}
|
||||
>
|
||||
{@label}
|
||||
<span :if={@count && @count > 0} class="admin-badge admin-badge-sm ml-1">{@count}</span>
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
defp resolve_matching_broken_url(from_path) do
|
||||
case Redirects.get_broken_url_by_path(from_path) do
|
||||
nil -> :ok
|
||||
broken_url -> Redirects.mark_broken_url_resolved(broken_url)
|
||||
end
|
||||
end
|
||||
|
||||
defp source_colour("auto_slug_change"), do: "info"
|
||||
defp source_colour("auto_product_deleted"), do: "warning"
|
||||
defp source_colour("analytics_detected"), do: "accent"
|
||||
defp source_colour("admin"), do: "neutral"
|
||||
defp source_colour(_), do: "neutral"
|
||||
end
|
||||
Reference in New Issue
Block a user