towerops/lib/towerops_web/live/alert_live/index.ex
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

119 lines
3.3 KiB
Elixir

defmodule ToweropsWeb.AlertLive.Index do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Alerts
alias Towerops.Gaiia
alias ToweropsWeb.Live.Helpers.AccessControl
@impl true
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")
Phoenix.PubSub.subscribe(Towerops.PubSub, "alerts:org:#{organization.id}:resolved")
end
{:ok,
socket
|> assign(:page_title, "Alerts")
|> assign(:filter, "active")
|> load_alerts(organization.id)}
end
@impl true
def handle_params(params, _url, socket) do
filter = Map.get(params, "filter", "active")
{:noreply,
socket
|> assign(:filter, filter)
|> load_alerts(socket.assigns.current_scope.organization.id)}
end
@impl true
def handle_event("acknowledge", %{"id" => id}, socket) do
organization = socket.assigns.current_scope.organization
user_id = socket.assigns.current_scope.user.id
case AccessControl.verify_alert_access(id, organization.id) do
{:ok, alert} ->
perform_acknowledge_alert(socket, alert, user_id, organization.id)
{: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
defp perform_acknowledge_alert(socket, alert, user_id, organization_id) do
case Alerts.acknowledge_alert(alert, user_id) do
{:ok, _alert} ->
{:noreply,
socket
|> put_flash(:info, t_equipment("Alert acknowledged"))
|> load_alerts(organization_id)}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, t_equipment("Unable to acknowledge alert"))}
end
end
@impl true
def handle_info({:new_alert, _device_id, _alert_type}, socket) do
{:noreply, load_alerts(socket, socket.assigns.current_scope.organization.id)}
end
@impl true
def handle_info({:alert_resolved, _device_id, _alert_type}, socket) do
{:noreply, load_alerts(socket, socket.assigns.current_scope.organization.id)}
end
defp load_alerts(socket, organization_id) do
alerts =
case socket.assigns.filter do
"active" ->
Alerts.list_organization_active_alerts(organization_id)
_ ->
Alerts.list_organization_alerts(organization_id, 100)
end
site_subscribers = load_site_subscribers(alerts)
socket
|> assign(:alerts, alerts)
|> assign(:site_subscribers, site_subscribers)
end
defp load_site_subscribers(alerts) do
alerts
|> Enum.map(& &1.device.site_id)
|> Enum.uniq()
|> Enum.reject(&is_nil/1)
|> Map.new(fn site_id -> {site_id, Gaiia.get_site_subscriber_summary(site_id)} end)
end
defp format_number(number) when is_integer(number) do
number
|> Integer.to_string()
|> String.graphemes()
|> Enum.reverse()
|> Enum.chunk_every(3)
|> Enum.join(",")
|> String.reverse()
end
defp format_number(number), do: to_string(number)
defp format_mrr(%Decimal{} = amount) do
"$#{format_number(Decimal.to_integer(Decimal.round(amount, 0)))}"
end
defp format_mrr(_), do: nil
end