- 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
283 lines
12 KiB
Text
283 lines
12 KiB
Text
<Layouts.authenticated
|
|
flash={@flash}
|
|
current_scope={@current_scope}
|
|
active_page="alerts"
|
|
>
|
|
<.header>
|
|
<span class="flex items-center gap-2">
|
|
Alerts
|
|
<span class="badge badge-info badge-sm">Experimental</span>
|
|
</span>
|
|
<:subtitle>Triage alerts by site impact — fix the biggest problems first</:subtitle>
|
|
</.header>
|
|
|
|
<%!-- 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 %>
|
|
<div class="text-center py-16">
|
|
<.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">
|
|
<%= 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 group <- @grouped_alerts do %>
|
|
<div class={[
|
|
"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"
|
|
]}>
|
|
<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>
|
|
|
|
<%!-- 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 group.critical_count > 0 do %>
|
|
<span class="badge badge-error badge-outline badge-sm">
|
|
{group.critical_count} down
|
|
</span>
|
|
<% end %>
|
|
</div>
|
|
|
|
<%!-- 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" />
|
|
{format_number(group.total_subscribers)} subscribers
|
|
</span>
|
|
<%= 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" />
|
|
{format_mrr(group.subscriber_info.total_mrr)}/mo
|
|
</span>
|
|
<% end %>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<%!-- 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>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
</Layouts.authenticated>
|