add dead link monitoring for outgoing content links
All checks were successful
deploy / deploy (push) Successful in 3m42s
All checks were successful
deploy / deploy (push) Successful in 3m42s
Scans page blocks and nav items for broken URLs (internal via DB lookup, external via HTTP HEAD). Daily Oban cron at 03:30, plus on-demand checks when pages are saved. Admin UI tab on redirects page with re-check, ignore, and clickable source links. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,8 +2,9 @@ defmodule BerrypodWeb.Admin.Redirects do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Redirects
|
||||
alias Berrypod.Redirects.LinkScanner
|
||||
|
||||
@valid_tabs ~w(redirects broken create)
|
||||
@valid_tabs ~w(redirects broken dead_links create)
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
@@ -11,14 +12,18 @@ defmodule BerrypodWeb.Admin.Redirects do
|
||||
|
||||
redirect_page = Redirects.list_redirects_paginated(page: 1)
|
||||
broken_page = Redirects.list_broken_urls_paginated(page: 1)
|
||||
dead_link_page = Redirects.list_dead_links_paginated(page: 1)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, "Redirects")
|
||||
|> assign(:redirect_pagination, redirect_page)
|
||||
|> assign(:broken_url_pagination, broken_page)
|
||||
|> assign(:dead_link_pagination, dead_link_page)
|
||||
|> assign(:dead_link_count, Redirects.count_dead_links())
|
||||
|> stream(:redirects, redirect_page.items)
|
||||
|> stream(:broken_urls, broken_page.items)
|
||||
|> stream(:dead_links, dead_link_page.items)
|
||||
|> assign(
|
||||
:form,
|
||||
to_form(%{"from_path" => "", "to_path" => "", "status_code" => "301"}, as: :redirect)
|
||||
@@ -50,6 +55,13 @@ defmodule BerrypodWeb.Admin.Redirects do
|
||||
|> assign(:broken_url_pagination, page)
|
||||
|> stream(:broken_urls, page.items, reset: true)
|
||||
|
||||
"dead_links" ->
|
||||
page = Redirects.list_dead_links_paginated(page: page_num)
|
||||
|
||||
socket
|
||||
|> assign(:dead_link_pagination, page)
|
||||
|> stream(:dead_links, page.items, reset: true)
|
||||
|
||||
_ ->
|
||||
socket
|
||||
end
|
||||
@@ -76,6 +88,16 @@ defmodule BerrypodWeb.Admin.Redirects do
|
||||
|> stream(:broken_urls, page.items, reset: true)}
|
||||
end
|
||||
|
||||
def handle_info(:dead_links_changed, socket) do
|
||||
page = Redirects.list_dead_links_paginated(page: socket.assigns.dead_link_pagination.page)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:dead_link_pagination, page)
|
||||
|> assign(:dead_link_count, Redirects.count_dead_links())
|
||||
|> stream(:dead_links, page.items, reset: true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("switch_tab", %{"tab" => tab}, socket) do
|
||||
{:noreply, push_patch(socket, to: ~p"/admin/redirects?#{%{tab: tab}}")}
|
||||
@@ -132,6 +154,32 @@ defmodule BerrypodWeb.Admin.Redirects do
|
||||
|> stream(:broken_urls, page.items, reset: true)}
|
||||
end
|
||||
|
||||
def handle_event("ignore_dead_link", %{"id" => id}, socket) do
|
||||
dead_link = Redirects.get_dead_link!(id)
|
||||
{:ok, _} = Redirects.ignore_dead_link(dead_link)
|
||||
|
||||
page = Redirects.list_dead_links_paginated(page: socket.assigns.dead_link_pagination.page)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:dead_link_pagination, page)
|
||||
|> assign(:dead_link_count, Redirects.count_dead_links())
|
||||
|> stream(:dead_links, page.items, reset: true)}
|
||||
end
|
||||
|
||||
def handle_event("recheck_dead_link", %{"id" => id}, socket) do
|
||||
dead_link = Redirects.get_dead_link!(id)
|
||||
|
||||
Oban.insert(Berrypod.Workers.DeadLinkCheckerWorker.new(%{"check_url" => dead_link.url}))
|
||||
|
||||
{:noreply, put_flash(socket, :info, "Re-checking #{dead_link.url}...")}
|
||||
end
|
||||
|
||||
def handle_event("check_all_links", _params, socket) do
|
||||
Oban.insert(Berrypod.Workers.DeadLinkCheckerWorker.new(%{}))
|
||||
{:noreply, put_flash(socket, :info, "Full link check started...")}
|
||||
end
|
||||
|
||||
def handle_event("redirect_broken_url", %{"path" => path}, socket) do
|
||||
socket =
|
||||
socket
|
||||
@@ -164,6 +212,12 @@ defmodule BerrypodWeb.Admin.Redirects do
|
||||
count={@broken_url_pagination.total_count}
|
||||
active={@tab}
|
||||
/>
|
||||
<.tab_button
|
||||
tab="dead_links"
|
||||
label="Dead links"
|
||||
count={@dead_link_count}
|
||||
active={@tab}
|
||||
/>
|
||||
<.tab_button tab="create" label="Create" active={@tab} />
|
||||
</div>
|
||||
|
||||
@@ -175,6 +229,10 @@ defmodule BerrypodWeb.Admin.Redirects do
|
||||
<.broken_urls_table streams={@streams} pagination={@broken_url_pagination} />
|
||||
<% end %>
|
||||
|
||||
<%= if @tab == "dead_links" do %>
|
||||
<.dead_links_table streams={@streams} pagination={@dead_link_pagination} />
|
||||
<% end %>
|
||||
|
||||
<%= if @tab == "create" do %>
|
||||
<.create_form form={@form} />
|
||||
<% end %>
|
||||
@@ -287,6 +345,71 @@ defmodule BerrypodWeb.Admin.Redirects do
|
||||
"""
|
||||
end
|
||||
|
||||
defp dead_links_table(assigns) do
|
||||
~H"""
|
||||
<div class="flex justify-end mb-4">
|
||||
<button phx-click="check_all_links" class="admin-btn admin-btn-sm admin-btn-ghost">
|
||||
Check all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%= if @pagination.total_count == 0 do %>
|
||||
<p>No dead links detected.</p>
|
||||
<% else %>
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>URL</th>
|
||||
<th>Type</th>
|
||||
<th>Error</th>
|
||||
<th>Used in</th>
|
||||
<th>Last checked</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="dead-links-table" phx-update="stream">
|
||||
<tr :for={{dom_id, dead_link} <- @streams.dead_links} id={dom_id}>
|
||||
<td class="max-w-xs truncate"><code>{dead_link.url}</code></td>
|
||||
<td>
|
||||
<span class={"badge badge-#{dead_link_type_colour(dead_link.url_type)}"}>
|
||||
{dead_link.url_type}
|
||||
</span>
|
||||
</td>
|
||||
<td>{format_dead_link_error(dead_link)}</td>
|
||||
<td><.dead_link_sources url={dead_link.url} /></td>
|
||||
<td>{Calendar.strftime(dead_link.last_checked_at, "%d %b %Y %H:%M")}</td>
|
||||
<td class="flex gap-2">
|
||||
<button
|
||||
phx-click="recheck_dead_link"
|
||||
phx-value-id={dead_link.id}
|
||||
class="admin-btn admin-btn-sm admin-btn-ghost"
|
||||
>
|
||||
Re-check
|
||||
</button>
|
||||
<button
|
||||
phx-click="ignore_dead_link"
|
||||
phx-value-id={dead_link.id}
|
||||
data-confirm="Ignore this dead link?"
|
||||
class="admin-btn admin-btn-sm admin-btn-ghost"
|
||||
>
|
||||
Ignore
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<.admin_pagination
|
||||
page={@pagination}
|
||||
patch={~p"/admin/redirects"}
|
||||
params={%{"tab" => "dead_links"}}
|
||||
/>
|
||||
<% end %>
|
||||
"""
|
||||
end
|
||||
|
||||
defp create_form(assigns) do
|
||||
~H"""
|
||||
<.form for={@form} phx-submit="create_redirect" style="max-width: 32rem;">
|
||||
@@ -335,6 +458,36 @@ defmodule BerrypodWeb.Admin.Redirects do
|
||||
"""
|
||||
end
|
||||
|
||||
defp dead_link_type_colour("internal"), do: "warning"
|
||||
defp dead_link_type_colour("external"), do: "info"
|
||||
defp dead_link_type_colour(_), do: "neutral"
|
||||
|
||||
defp format_dead_link_error(%{http_status: status, error: error}) when not is_nil(status) do
|
||||
"#{status} #{error}"
|
||||
end
|
||||
|
||||
defp format_dead_link_error(%{error: error}) when not is_nil(error), do: error
|
||||
defp format_dead_link_error(_), do: "—"
|
||||
|
||||
defp dead_link_sources(assigns) do
|
||||
assigns = assign(assigns, :sources, LinkScanner.find_sources(assigns.url))
|
||||
|
||||
~H"""
|
||||
<%= case @sources do %>
|
||||
<% [] -> %>
|
||||
<span>—</span>
|
||||
<% [source] -> %>
|
||||
<.link navigate={source.edit_path} class="underline">{source.label}</.link>
|
||||
<% sources -> %>
|
||||
<ul class="list-none p-0 m-0 space-y-1">
|
||||
<li :for={source <- sources}>
|
||||
<.link navigate={source.edit_path} class="underline">{source.label}</.link>
|
||||
</li>
|
||||
</ul>
|
||||
<% end %>
|
||||
"""
|
||||
end
|
||||
|
||||
defp resolve_matching_broken_url(from_path) do
|
||||
case Redirects.get_broken_url_by_path(from_path) do
|
||||
nil -> :ok
|
||||
|
||||
Reference in New Issue
Block a user