towerops/lib/towerops_web/live/alert_live/index.html.heex
Graham McIntire 20f5f48372
feat: command center dashboard with unified insights and contextual enrichment
Transform the dashboard into a single-pane-of-glass command center that
blends operational metrics with business context from Gaiia subscriber data.

- Extend insight schema with multi-source support (snmp, gaiia, system)
- Add Oban workers for automated insight generation (device health, system, gaiia)
- New Dashboard context with health score computation and site summaries
- Rewrite dashboard LiveView with health overview, alert/insight feeds, site grid
- Add source filter pills for insights with URL state management
- Add metrics bar to site detail pages (device count, alerts, subscribers, MRR)
- Add subscriber/MRR context to alert list site links
- Add subscriber badges to device list site headers
- Real-time PubSub updates for alerts on dashboard
2026-02-13 14:52:49 -06:00

184 lines
8.3 KiB
Text

<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="alerts"
>
<.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>
<:subtitle>Monitor and acknowledge system alerts</: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>
</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">
<%= if @filter == "active" do %>
There are no active alerts for this organization.
<% else %>
No alerts have been triggered yet.
<% end %>
</p>
</div>
<% else %>
<div class="space-y-4">
<%= for alert <- @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"
]}>
<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
<% 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 %>
<%= 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
</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">
<.icon name="hero-users" class="h-3.5 w-3.5" />
{alert.gaiia_impact["total_subscribers"]} subscribers affected
</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">
<.icon name="hero-currency-dollar" class="h-3.5 w-3.5" />
${alert.gaiia_impact["total_mrr"]}/mo at risk
</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 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>
<% end %>
</div>
</div>
</div>
<% end %>
</div>
<% end %>
</Layouts.authenticated>