add dead link monitoring for outgoing content links
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:
jamey
2026-03-01 13:00:59 +00:00
parent 3480b326a9
commit b235219aee
11 changed files with 1109 additions and 2 deletions

View File

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