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:
parent
723f475933
commit
8b403db527
2 changed files with 408 additions and 147 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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">·</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">·</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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue