feat: smart alert triage with site grouping, severity colors, and resolve

- Group alerts by site (tower/location) with collapsible sections
- Show alert count badges per site, critical count highlighted
- Color-code alerts by severity and age (red >1h, orange >15m, yellow new)
- Add Resolve button with confirmation dialog
- Show subscriber impact from Gaiia inventory at site and alert level
- Filter tabs: Unresolved, Critical, All, Resolved with counts
- Sort options: Severity (default), Oldest First, Subscriber Impact
- Sites with most critical alerts and highest subscriber impact sort first
- Use DaisyUI collapse, badge, tab, and btn components
This commit is contained in:
Graham McIntire 2026-02-13 18:56:37 -06:00
parent 723f475933
commit 8b403db527
2 changed files with 408 additions and 147 deletions

View file

@ -1,5 +1,10 @@
defmodule ToweropsWeb.AlertLive.Index do
@moduledoc false
@moduledoc """
Smart Alert Triage view for WISP operators.
Groups alerts by site, color-codes by severity/age, supports filtering
and sorting, and shows subscriber impact from Gaiia inventory.
"""
use ToweropsWeb, :live_view
alias Towerops.Alerts
@ -10,7 +15,6 @@ defmodule ToweropsWeb.AlertLive.Index do
def mount(_params, _session, socket) do
organization = socket.assigns.current_scope.organization
# Subscribe to alert events
_ =
if connected?(socket) do
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "alerts:org:#{organization.id}:new")
@ -20,17 +24,21 @@ defmodule ToweropsWeb.AlertLive.Index do
{:ok,
socket
|> assign(:page_title, "Alerts")
|> assign(:filter, "active")
|> assign(:filter, "unresolved")
|> assign(:sort_by, "severity")
|> assign(:expanded_sites, MapSet.new())
|> load_alerts(organization.id)}
end
@impl true
def handle_params(params, _url, socket) do
filter = Map.get(params, "filter", "active")
filter = Map.get(params, "filter", "unresolved")
sort_by = Map.get(params, "sort", socket.assigns.sort_by)
{:noreply,
socket
|> assign(:filter, filter)
|> assign(:sort_by, sort_by)
|> load_alerts(socket.assigns.current_scope.organization.id)}
end
@ -51,6 +59,41 @@ defmodule ToweropsWeb.AlertLive.Index do
end
end
def handle_event("resolve", %{"id" => id}, socket) do
organization = socket.assigns.current_scope.organization
case AccessControl.verify_alert_access(id, organization.id) do
{:ok, alert} ->
case Alerts.resolve_alert(alert) do
{:ok, _alert} ->
{:noreply,
socket
|> put_flash(:info, t_equipment("Alert resolved"))
|> load_alerts(organization.id)}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, t_equipment("Unable to resolve alert"))}
end
{:error, :not_found} ->
{:noreply, put_flash(socket, :error, t_equipment("Alert not found"))}
{:error, :unauthorized} ->
{:noreply, put_flash(socket, :error, t_equipment("You don't have access to this alert"))}
end
end
def handle_event("toggle_site", %{"site-id" => site_id}, socket) do
expanded = socket.assigns.expanded_sites
expanded =
if MapSet.member?(expanded, site_id),
do: MapSet.delete(expanded, site_id),
else: MapSet.put(expanded, site_id)
{:noreply, assign(socket, :expanded_sites, expanded)}
end
defp perform_acknowledge_alert(socket, alert, user_id, organization_id) do
case Alerts.acknowledge_alert(alert, user_id) do
{:ok, _alert} ->
@ -75,20 +118,107 @@ defmodule ToweropsWeb.AlertLive.Index do
end
defp load_alerts(socket, organization_id) do
alerts =
case socket.assigns.filter do
"active" ->
Alerts.list_organization_active_alerts(organization_id)
all_alerts = Alerts.list_organization_alerts(organization_id, %{"limit" => 500})
_ ->
Alerts.list_organization_alerts(organization_id, 100)
end
filtered_alerts = filter_alerts(all_alerts, socket.assigns.filter)
site_subscribers = load_site_subscribers(filtered_alerts)
sorted_alerts = sort_alerts(filtered_alerts, socket.assigns.sort_by, site_subscribers)
grouped = group_by_site(sorted_alerts, site_subscribers)
site_subscribers = load_site_subscribers(alerts)
# Compute counts for filter tabs
counts = %{
all: length(all_alerts),
critical: Enum.count(all_alerts, &(&1.alert_type == :device_down and is_nil(&1.resolved_at))),
unresolved: Enum.count(all_alerts, &is_nil(&1.resolved_at)),
resolved: Enum.count(all_alerts, &(not is_nil(&1.resolved_at)))
}
socket
|> assign(:alerts, alerts)
|> assign(:alerts, sorted_alerts)
|> assign(:site_subscribers, site_subscribers)
|> assign(:grouped_alerts, grouped)
|> assign(:counts, counts)
end
defp filter_alerts(alerts, "all"), do: alerts
defp filter_alerts(alerts, "critical"),
do: Enum.filter(alerts, &(&1.alert_type == :device_down and is_nil(&1.resolved_at)))
defp filter_alerts(alerts, "unresolved"),
do: Enum.filter(alerts, &is_nil(&1.resolved_at))
defp filter_alerts(alerts, "resolved"),
do: Enum.filter(alerts, &(not is_nil(&1.resolved_at)))
defp filter_alerts(alerts, _), do: alerts
defp sort_alerts(alerts, "age", _site_subs) do
Enum.sort_by(alerts, & &1.triggered_at, {:asc, DateTime})
end
defp sort_alerts(alerts, "impact", site_subs) do
Enum.sort_by(alerts, fn alert ->
sub_count = get_subscriber_count(alert, site_subs)
-sub_count
end)
end
defp sort_alerts(alerts, _severity, _site_subs) do
# Default: severity sort — device_down unresolved first, then by age
Enum.sort_by(alerts, fn alert ->
severity_weight =
cond do
alert.alert_type == :device_down and is_nil(alert.resolved_at) -> 0
alert.alert_type == :device_down -> 1
true -> 2
end
{severity_weight, alert.triggered_at}
end)
end
defp group_by_site(alerts, site_subscribers) do
alerts
|> Enum.group_by(fn alert ->
if alert.device.site do
{alert.device.site.id, alert.device.site.name}
else
{nil, "No Site"}
end
end)
|> Enum.map(fn {{site_id, site_name}, site_alerts} ->
sub_info = if site_id, do: site_subscribers[site_id], else: nil
critical_count = Enum.count(site_alerts, &(&1.alert_type == :device_down and is_nil(&1.resolved_at)))
%{
site_id: site_id,
site_name: site_name,
alerts: site_alerts,
total_count: length(site_alerts),
critical_count: critical_count,
subscriber_info: sub_info,
total_subscribers: get_site_subscriber_count(sub_info)
}
end)
|> Enum.sort_by(fn group -> {-group.critical_count, -group.total_subscribers, -group.total_count} end)
end
defp get_site_subscriber_count(%{account_count: count}) when is_integer(count), do: count
defp get_site_subscriber_count(_), do: 0
defp get_subscriber_count(alert, site_subs) do
# Use gaiia_impact on alert first, fall back to site-level data
cond do
alert.gaiia_impact && alert.gaiia_impact["total_subscribers"] ->
alert.gaiia_impact["total_subscribers"]
alert.device.site_id && site_subs[alert.device.site_id] ->
get_site_subscriber_count(site_subs[alert.device.site_id])
true ->
0
end
end
defp load_site_subscribers(alerts) do
@ -99,6 +229,38 @@ defmodule ToweropsWeb.AlertLive.Index do
|> Map.new(fn site_id -> {site_id, Gaiia.get_site_subscriber_summary(site_id)} end)
end
@doc false
def severity_color(alert) do
cond do
alert.resolved_at ->
"gray"
alert.alert_type == :device_down and is_nil(alert.resolved_at) ->
age_minutes = DateTime.diff(DateTime.utc_now(), alert.triggered_at, :minute)
cond do
age_minutes > 60 -> "red"
age_minutes > 15 -> "orange"
true -> "yellow"
end
true ->
"green"
end
end
@doc false
def age_text(alert) do
minutes = DateTime.diff(DateTime.utc_now(), alert.triggered_at, :minute)
cond do
minutes < 1 -> "just now"
minutes < 60 -> "#{minutes}m ago"
minutes < 1440 -> "#{div(minutes, 60)}h ago"
true -> "#{div(minutes, 1440)}d ago"
end
end
defp format_number(number) when is_integer(number) do
number
|> Integer.to_string()

View file

@ -6,40 +6,64 @@
<.header>
<span class="flex items-center gap-2">
Alerts
<span class="inline-flex items-center rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10 dark:bg-blue-400/10 dark:text-blue-400 dark:ring-blue-400/30">
Experimental
</span>
<span class="badge badge-info badge-sm">Experimental</span>
</span>
<:subtitle>Monitor and acknowledge system alerts</:subtitle>
<:subtitle>Triage alerts by site impact — fix the biggest problems first</:subtitle>
</.header>
<div class="mb-6">
<div class="inline-flex rounded-lg border border-gray-200 dark:border-white/10">
<.link
patch={~p"/alerts"}
class={[
"px-4 py-2 text-sm font-medium rounded-l-lg",
@filter == "active" &&
"bg-blue-600 text-white dark:bg-blue-500",
@filter != "active" &&
"bg-white text-gray-700 hover:bg-gray-50 dark:bg-gray-800/50 dark:text-gray-300 dark:hover:bg-gray-800"
]}
>
Active Alerts
</.link>
<.link
patch={~p"/alerts?filter=all"}
class={[
"px-4 py-2 text-sm font-medium rounded-r-lg border-l border-gray-200 dark:border-white/10",
@filter == "all" &&
"bg-blue-600 text-white dark:bg-blue-500",
@filter != "all" &&
"bg-white text-gray-700 hover:bg-gray-50 dark:bg-gray-800/50 dark:text-gray-300 dark:hover:bg-gray-800"
]}
>
All Alerts
</.link>
</div>
<%!-- Filter Tabs --%>
<div class="tabs tabs-boxed bg-base-200 mb-4 inline-flex">
<.link
patch={~p"/alerts?filter=unresolved&sort=#{@sort_by}"}
class={["tab", @filter == "unresolved" && "tab-active"]}
>
Unresolved
<span class="badge badge-sm ml-1">{@counts.unresolved}</span>
</.link>
<.link
patch={~p"/alerts?filter=critical&sort=#{@sort_by}"}
class={["tab", @filter == "critical" && "tab-active"]}
>
Critical
<span class="badge badge-error badge-sm ml-1">{@counts.critical}</span>
</.link>
<.link
patch={~p"/alerts?filter=all&sort=#{@sort_by}"}
class={["tab", @filter == "all" && "tab-active"]}
>
All
<span class="badge badge-sm ml-1">{@counts.all}</span>
</.link>
<.link
patch={~p"/alerts?filter=resolved&sort=#{@sort_by}"}
class={["tab", @filter == "resolved" && "tab-active"]}
>
Resolved
<span class="badge badge-ghost badge-sm ml-1">{@counts.resolved}</span>
</.link>
</div>
<%!-- Sort Controls --%>
<div class="flex items-center gap-2 mb-6 text-sm">
<span class="text-gray-500 dark:text-gray-400 font-medium">Sort:</span>
<.link
patch={~p"/alerts?filter=#{@filter}&sort=severity"}
class={["btn btn-xs", @sort_by == "severity" && "btn-primary" || "btn-ghost"]}
>
Severity
</.link>
<.link
patch={~p"/alerts?filter=#{@filter}&sort=age"}
class={["btn btn-xs", @sort_by == "age" && "btn-primary" || "btn-ghost"]}
>
Oldest First
</.link>
<.link
patch={~p"/alerts?filter=#{@filter}&sort=impact"}
class={["btn btn-xs", @sort_by == "impact" && "btn-primary" || "btn-ghost"]}
>
Subscriber Impact
</.link>
</div>
<%= if Enum.empty?(@alerts) do %>
@ -47,133 +71,208 @@
<.icon name="hero-bell-slash" class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
<h3 class="mt-4 text-lg font-semibold text-gray-900 dark:text-white">No alerts</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
<%= if @filter == "active" do %>
There are no active alerts for this organization.
<% else %>
No alerts have been triggered yet.
<%= case @filter do %>
<% "unresolved" -> %>
All clear — no unresolved alerts.
<% "critical" -> %>
No critical alerts. Nice work!
<% "resolved" -> %>
No resolved alerts yet.
<% _ -> %>
No alerts have been triggered yet.
<% end %>
</p>
</div>
<% else %>
<%!-- Grouped by Site --%>
<div class="space-y-4">
<%= for alert <- @alerts do %>
<%= for group <- @grouped_alerts do %>
<div class={[
"rounded-lg border p-6 shadow-sm",
alert.resolved_at && "opacity-60",
alert.alert_type == :device_down && is_nil(alert.resolved_at) &&
"border-l-4 border-red-500 bg-red-50 dark:bg-red-950 dark:border-red-700",
(alert.alert_type != :device_down || alert.resolved_at) &&
"border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50"
"collapse collapse-arrow border rounded-xl shadow-sm",
group.critical_count > 0 && "border-red-300 dark:border-red-800 bg-red-50/50 dark:bg-red-950/30",
group.critical_count == 0 && "border-base-300 bg-base-100"
]}>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-3 flex-wrap">
<span class={[
"inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-medium",
alert.alert_type == :device_down &&
"bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
alert.alert_type == :device_up &&
"bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
]}>
<%= case alert.alert_type do %>
<% :device_down -> %>
<.icon name="hero-exclamation-triangle" class="h-4 w-4" /> Device Down
<% :device_up -> %>
<.icon name="hero-check-circle" class="h-4 w-4" /> Device Recovered
<input
type="checkbox"
checked={MapSet.member?(@expanded_sites, group.site_id || "none") || group.critical_count > 0}
phx-click="toggle_site"
phx-value-site-id={group.site_id || "none"}
/>
<%!-- Site Header --%>
<div class="collapse-title">
<div class="flex items-center justify-between flex-wrap gap-2">
<div class="flex items-center gap-3">
<.icon
name={if group.critical_count > 0, do: "hero-exclamation-triangle", else: "hero-signal"}
class={[
"h-5 w-5",
group.critical_count > 0 && "text-red-500",
group.critical_count == 0 && "text-gray-400"
]}
/>
<span class="font-semibold text-gray-900 dark:text-white text-lg">
<%= if group.site_id do %>
<.link
navigate={~p"/sites/#{group.site_id}"}
class="hover:text-blue-600 dark:hover:text-blue-400"
>
{group.site_name}
</.link>
<% else %>
{group.site_name}
<% end %>
</span>
<%= if alert.resolved_at do %>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-800 dark:bg-gray-800 dark:text-gray-200">
Resolved
</span>
<% end %>
<%!-- Alert count badge --%>
<span class={[
"badge",
group.critical_count > 0 && "badge-error",
group.critical_count == 0 && "badge-ghost"
]}>
{group.total_count} alert{if group.total_count != 1, do: "s"}
</span>
<%= if alert.acknowledged_at do %>
<span class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-200">
Acknowledged
<%= if group.critical_count > 0 do %>
<span class="badge badge-error badge-outline badge-sm">
{group.critical_count} down
</span>
<% end %>
</div>
<h3 class="mt-3 font-semibold text-gray-900 dark:text-white">
<.link
navigate={~p"/devices/#{alert.device.id}"}
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{alert.device.name}
</.link>
</h3>
<p class="mt-1 text-sm text-gray-700 dark:text-gray-300">{alert.message}</p>
<%= if alert.gaiia_impact && alert.gaiia_impact["total_subscribers"] && alert.gaiia_impact["total_subscribers"] > 0 do %>
<div class="mt-2 flex items-center gap-3">
<span class="inline-flex items-center gap-1.5 rounded-md bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-800 ring-1 ring-inset ring-amber-600/20 dark:bg-amber-400/10 dark:text-amber-400 dark:ring-amber-400/20">
<%!-- Subscriber impact for site --%>
<div class="flex items-center gap-2 text-sm">
<%= if group.subscriber_info && group.total_subscribers > 0 do %>
<span class="badge badge-warning badge-outline gap-1">
<.icon name="hero-users" class="h-3.5 w-3.5" />
{alert.gaiia_impact["total_subscribers"]} subscribers affected
{format_number(group.total_subscribers)} subscribers
</span>
<%= if alert.gaiia_impact["total_mrr"] && alert.gaiia_impact["total_mrr"] != "0" do %>
<span class="inline-flex items-center gap-1.5 rounded-md bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-800 ring-1 ring-inset ring-amber-600/20 dark:bg-amber-400/10 dark:text-amber-400 dark:ring-amber-400/20">
<%= if group.subscriber_info.total_mrr do %>
<span class="badge badge-warning badge-outline gap-1">
<.icon name="hero-currency-dollar" class="h-3.5 w-3.5" />
${alert.gaiia_impact["total_mrr"]}/mo at risk
{format_mrr(group.subscriber_info.total_mrr)}/mo
</span>
<% end %>
</div>
<% end %>
<div class="mt-3 space-y-1 text-xs text-gray-600 dark:text-gray-400">
<div>
<strong class="font-medium">Triggered:</strong>
{ToweropsWeb.TimeHelpers.format_iso8601(alert.triggered_at, @timezone)}
</div>
<%= if alert.acknowledged_at do %>
<div>
<strong class="font-medium">Acknowledged:</strong>
{ToweropsWeb.TimeHelpers.format_iso8601(alert.acknowledged_at, @timezone)}
<%= if alert.acknowledged_by do %>
by {alert.acknowledged_by.email}
<% end %>
</div>
<% end %>
<%= if alert.resolved_at do %>
<div>
<strong class="font-medium">Resolved:</strong>
{ToweropsWeb.TimeHelpers.format_iso8601(alert.resolved_at, @timezone)}
</div>
<% end %>
<div :if={alert.device.site}>
<strong class="font-medium">Site:</strong>
<.link
navigate={~p"/sites/#{alert.device.site.id}"}
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{alert.device.site.name}
</.link>
<%= if @site_subscribers[alert.device.site_id] do %>
<span class="text-gray-400 dark:text-gray-600 mx-1">&middot;</span>
<span class="text-amber-700 dark:text-amber-400">
{@site_subscribers[alert.device.site_id].account_count} subscribers
</span>
<%= if @site_subscribers[alert.device.site_id].total_mrr do %>
<span class="text-gray-400 dark:text-gray-600 mx-1">&middot;</span>
<span class="text-amber-700 dark:text-amber-400">
{format_mrr(@site_subscribers[alert.device.site_id].total_mrr)}/mo MRR
</span>
<% end %>
<% end %>
</div>
</div>
</div>
</div>
<div class="ml-4">
<%= if alert.alert_type == :device_down && is_nil(alert.acknowledged_at) && is_nil(alert.resolved_at) do %>
<.button phx-click="acknowledge" phx-value-id={alert.id} variant="primary">
<.icon name="hero-check" class="h-4 w-4" /> Acknowledge
</.button>
<%!-- Alerts within site --%>
<div class="collapse-content">
<div class="space-y-2 pt-2">
<%= for alert <- group.alerts do %>
<% color = severity_color(alert) %>
<div class={[
"rounded-lg border p-4 flex items-start justify-between gap-4",
color == "red" && "border-l-4 border-l-red-500 bg-red-50 dark:bg-red-950/50 border-red-200 dark:border-red-800",
color == "orange" && "border-l-4 border-l-orange-500 bg-orange-50 dark:bg-orange-950/50 border-orange-200 dark:border-orange-800",
color == "yellow" && "border-l-4 border-l-yellow-500 bg-yellow-50 dark:bg-yellow-950/50 border-yellow-200 dark:border-yellow-800",
color == "green" && "border-l-4 border-l-green-500 bg-green-50 dark:bg-green-950/50 border-green-200 dark:border-green-800",
color == "gray" && "border-l-4 border-l-gray-300 bg-base-200/50 border-base-300 opacity-60"
]}>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<%!-- Alert type badge --%>
<span class={[
"badge gap-1",
alert.alert_type == :device_down && is_nil(alert.resolved_at) && "badge-error",
alert.alert_type == :device_down && alert.resolved_at && "badge-ghost",
alert.alert_type == :device_up && "badge-success"
]}>
<%= case alert.alert_type do %>
<% :device_down -> %>
<.icon name="hero-exclamation-triangle" class="h-3.5 w-3.5" /> Down
<% :device_up -> %>
<.icon name="hero-check-circle" class="h-3.5 w-3.5" /> Recovered
<% end %>
</span>
<%!-- Age badge --%>
<span class={[
"badge badge-sm",
color == "red" && "badge-error badge-outline",
color == "orange" && "badge-warning badge-outline",
color not in ["red", "orange"] && "badge-ghost"
]}>
{age_text(alert)}
</span>
<%!-- Status badges --%>
<%= if alert.resolved_at do %>
<span class="badge badge-ghost badge-sm">Resolved</span>
<% end %>
<%= if alert.acknowledged_at && is_nil(alert.resolved_at) do %>
<span class="badge badge-info badge-sm badge-outline">Ack'd</span>
<% end %>
<%!-- Per-alert subscriber impact --%>
<%= if alert.gaiia_impact && alert.gaiia_impact["total_subscribers"] && alert.gaiia_impact["total_subscribers"] > 0 do %>
<span class="badge badge-warning badge-sm gap-1">
<.icon name="hero-users" class="h-3 w-3" />
{alert.gaiia_impact["total_subscribers"]} affected
</span>
<%= if alert.gaiia_impact["total_mrr"] && alert.gaiia_impact["total_mrr"] != "0" do %>
<span class="badge badge-warning badge-sm gap-1">
${alert.gaiia_impact["total_mrr"]}/mo
</span>
<% end %>
<% end %>
</div>
<%!-- Device name + message --%>
<div class="mt-2">
<.link
navigate={~p"/devices/#{alert.device.id}"}
class="font-semibold text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{alert.device.name}
</.link>
<span class="text-sm text-gray-500 dark:text-gray-400 ml-2">{alert.device.ip_address}</span>
</div>
<%= if alert.message do %>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">{alert.message}</p>
<% end %>
<%!-- Timestamps --%>
<div class="text-xs text-gray-500 dark:text-gray-500 mt-2 flex flex-wrap gap-x-4 gap-y-1">
<span>
Triggered: {ToweropsWeb.TimeHelpers.format_iso8601(alert.triggered_at, @timezone)}
</span>
<%= if alert.acknowledged_at do %>
<span>
Ack'd: {ToweropsWeb.TimeHelpers.format_iso8601(alert.acknowledged_at, @timezone)}
<%= if alert.acknowledged_by do %>
by {alert.acknowledged_by.email}
<% end %>
</span>
<% end %>
<%= if alert.resolved_at do %>
<span>
Resolved: {ToweropsWeb.TimeHelpers.format_iso8601(alert.resolved_at, @timezone)}
</span>
<% end %>
</div>
</div>
<%!-- Action Buttons --%>
<div class="flex flex-col gap-2 flex-shrink-0">
<%= if alert.alert_type == :device_down && is_nil(alert.acknowledged_at) && is_nil(alert.resolved_at) do %>
<.button phx-click="acknowledge" phx-value-id={alert.id} variant="primary">
<.icon name="hero-check" class="h-4 w-4" /> Ack
</.button>
<% end %>
<%= if is_nil(alert.resolved_at) do %>
<.button
phx-click="resolve"
phx-value-id={alert.id}
data-confirm="Resolve this alert?"
>
<.icon name="hero-check-circle" class="h-4 w-4" /> Resolve
</.button>
<% end %>
</div>
</div>
<% end %>
</div>
</div>