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
177 lines
6.3 KiB
Elixir
177 lines
6.3 KiB
Elixir
defmodule ToweropsWeb.DashboardLive do
|
|
@moduledoc false
|
|
use ToweropsWeb, :live_view
|
|
|
|
alias Towerops.Alerts
|
|
alias Towerops.Dashboard
|
|
alias Towerops.Devices
|
|
alias Towerops.Preseem
|
|
alias Towerops.Preseem.Insights
|
|
alias Towerops.Sites
|
|
alias ToweropsWeb.Live.Helpers.AccessControl
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
organization = socket.assigns.current_scope.organization
|
|
|
|
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
|
|
|
|
status_counts = Devices.get_device_status_counts(organization.id)
|
|
device_count = status_counts |> Map.values() |> Enum.sum()
|
|
|
|
{:ok,
|
|
socket
|
|
|> assign(:page_title, organization.name)
|
|
|> assign(:device_count, device_count)
|
|
|> assign(:insight_source, nil)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_params(params, _url, socket) do
|
|
organization = socket.assigns.current_scope.organization
|
|
insight_source = params["insight_source"]
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:insight_source, insight_source)
|
|
|> load_dashboard_data(organization.id)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("dismiss_insight", %{"id" => insight_id}, socket) do
|
|
case Preseem.dismiss_insight(insight_id) do
|
|
{:ok, _} ->
|
|
{:noreply,
|
|
socket
|
|
|> load_insights(socket.assigns.current_scope.organization.id)
|
|
|> put_flash(:info, "Insight dismissed")}
|
|
|
|
{:error, _} ->
|
|
{:noreply, put_flash(socket, :error, "Failed to dismiss insight")}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("acknowledge_alert", %{"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} ->
|
|
case Alerts.acknowledge_alert(alert, user_id) do
|
|
{:ok, _} ->
|
|
{:noreply,
|
|
socket
|
|
|> load_dashboard_data(organization.id)
|
|
|> put_flash(:info, "Alert acknowledged")}
|
|
|
|
{:error, _} ->
|
|
{:noreply, put_flash(socket, :error, "Failed to acknowledge alert")}
|
|
end
|
|
|
|
{:error, _} ->
|
|
{:noreply, put_flash(socket, :error, "Alert not found")}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:new_alert, _device_id, _alert_type}, socket) do
|
|
{:noreply, load_dashboard_data(socket, socket.assigns.current_scope.organization.id)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:alert_resolved, _device_id, _alert_type}, socket) do
|
|
{:noreply, load_dashboard_data(socket, socket.assigns.current_scope.organization.id)}
|
|
end
|
|
|
|
defp load_dashboard_data(socket, organization_id) do
|
|
organization = socket.assigns.current_scope.organization
|
|
summary = Dashboard.get_dashboard_summary(organization_id)
|
|
active_alerts = Alerts.list_organization_active_alerts(organization_id)
|
|
sites_count = if organization.use_sites, do: Sites.count_organization_sites(organization_id), else: 0
|
|
|
|
status_counts = Devices.get_device_status_counts(organization_id)
|
|
device_count = status_counts |> Map.values() |> Enum.sum()
|
|
|
|
has_subscribers = summary.subscribers.total > 0
|
|
|
|
site_summaries =
|
|
if organization.use_sites do
|
|
Dashboard.list_site_summaries(organization_id)
|
|
else
|
|
[]
|
|
end
|
|
|
|
socket
|
|
|> assign(:summary, summary)
|
|
|> assign(:active_alerts, Enum.take(active_alerts, 20))
|
|
|> assign(:total_alert_count, length(active_alerts))
|
|
|> assign(:sites_count, sites_count)
|
|
|> assign(:device_count, device_count)
|
|
|> assign(:device_up, Map.get(status_counts, :up, 0))
|
|
|> assign(:device_down, Map.get(status_counts, :down, 0))
|
|
|> assign(:device_unknown, Map.get(status_counts, :unknown, 0))
|
|
|> assign(:has_subscribers, has_subscribers)
|
|
|> assign(:site_summaries, site_summaries)
|
|
|> load_insights(organization_id)
|
|
end
|
|
|
|
defp load_insights(socket, organization_id) do
|
|
opts = [status: "active", limit: 15]
|
|
|
|
opts =
|
|
if socket.assigns.insight_source,
|
|
do: Keyword.put(opts, :source, socket.assigns.insight_source),
|
|
else: opts
|
|
|
|
insights = Insights.list_insights(organization_id, opts)
|
|
assign(socket, :insights, insights)
|
|
end
|
|
|
|
defp health_score_color(score) when score > 80, do: "text-green-600 dark:text-green-400"
|
|
defp health_score_color(score) when score > 50, do: "text-yellow-600 dark:text-yellow-400"
|
|
defp health_score_color(_score), do: "text-red-600 dark:text-red-400"
|
|
|
|
defp health_score_bg(score) when score > 80,
|
|
do: "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800"
|
|
|
|
defp health_score_bg(score) when score > 50,
|
|
do: "bg-yellow-50 border-yellow-200 dark:bg-yellow-900/20 dark:border-yellow-800"
|
|
|
|
defp health_score_bg(_score), do: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800"
|
|
|
|
defp urgency_classes("critical"), do: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
|
defp urgency_classes("warning"), do: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
|
|
defp urgency_classes("info"), do: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"
|
|
defp urgency_classes(_), do: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
|
|
|
defp source_classes("preseem"), do: "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400"
|
|
defp source_classes("snmp"), do: "bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-400"
|
|
defp source_classes("gaiia"), do: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400"
|
|
defp source_classes("system"), do: "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300"
|
|
defp source_classes(_), do: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
|
|
|
defp format_mrr(nil), do: "$0"
|
|
|
|
defp format_mrr(%Decimal{} = amount) do
|
|
"$#{format_number(Decimal.to_integer(Decimal.round(amount, 0)))}"
|
|
end
|
|
|
|
defp format_mrr(amount) when is_number(amount), do: "$#{format_number(round(amount))}"
|
|
defp format_mrr(_), do: "$0"
|
|
|
|
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)
|
|
end
|