- 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
311 lines
13 KiB
Text
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>
|