towerops/lib/towerops_web/live/alert_live/index.html.heex
Graham McIntie 9e7ee5099d refactor: fix all credo strict issues, format all code, fix broken routes
- Fix broken route paths in dashboard (/discovery, /subscribers/trace, /config-changes)
- Fix insights empty state settings link (org-scoped route)
- Add underscores to all 86400 literals across 6 files
- Alphabetize aliases in search.ex and agent_channel.ex
- Extract changelog parser helper to reduce nesting
- Extract dashboard impact calculation to reduce nesting
- Refactor agent_channel config change detection (pattern match, extract function)
- Combine double Enum.reject into single call in trace.ex
- Fix line length in trace.ex search query
- Replace length/1 > 0 with != [] in trace_live
- Run mix format on all files
2026-02-13 19:34:40 -06:00

311 lines
13 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="flex items-center justify-center py-16">
<div class="card bg-base-100 shadow-md border border-green-200 dark:border-green-800 max-w-md w-full">
<div class="card-body items-center text-center">
<div class="rounded-full bg-green-100 dark:bg-green-900/40 p-4 mb-2">
<.icon
name="hero-check-circle-solid"
class="h-12 w-12 text-green-500 dark:text-green-400"
/>
</div>
<h3 class="card-title text-green-700 dark:text-green-400">All Clear!</h3>
<p class="text-sm text-gray-600 dark:text-gray-400">
<%= case @filter do %>
<% "unresolved" -> %>
No active alerts. Your network is looking good!
<% "critical" -> %>
No critical alerts. Nice work keeping things healthy!
<% "resolved" -> %>
No resolved alerts yet.
<% _ -> %>
No active alerts. Your network is looking good!
<% end %>
</p>
</div>
</div>
</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>