defmodule ToweropsWeb.DashboardLive do @moduledoc false use ToweropsWeb, :live_view alias Towerops.ActivityFeed 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("refresh_dashboard", _params, socket) do organization = socket.assigns.current_scope.organization {:noreply, load_dashboard_data(socket, 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 # Impact data impact_summary = Dashboard.get_impact_summary(organization_id) active_incidents = Dashboard.list_active_incidents(organization_id) site_impact_summaries = if organization.use_sites do Dashboard.get_site_impact_summaries(organization_id) else [] end device_up = Map.get(status_counts, :up, 0) uptime_percentage = if device_count > 0, do: Float.round(device_up / device_count * 100, 1), else: 100.0 recent_activity = try do ActivityFeed.list_org_activity(organization_id, limit: 5) rescue _ -> [] 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, device_up) |> 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) |> assign(:impact_summary, impact_summary) |> assign(:active_incidents, active_incidents) |> assign(:site_impact_summaries, site_impact_summaries) |> assign(:uptime_percentage, uptime_percentage) |> assign(:recent_activity, recent_activity) |> assign(:last_updated, DateTime.utc_now()) |> load_insights(organization_id) |> load_recent_config_changes(organization_id) end defp load_recent_config_changes(socket, organization_id) do alias Towerops.ConfigChanges changes = ConfigChanges.list_org_changes(organization_id, limit: 10, after: DateTime.add(DateTime.utc_now(), -7 * 24 * 3600, :second), preload: [:device] ) assign(socket, :recent_config_changes, changes) 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 config_change_impact_color(change) do cond do change.change_size > 50 -> "bg-red-500" change.change_size > 20 -> "bg-yellow-500" true -> "bg-green-500" end end 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) defp format_duration(nil), do: "Unknown" defp format_duration(seconds) when is_integer(seconds) do cond do seconds < 60 -> "#{seconds}s" seconds < 3600 -> "#{div(seconds, 60)}m" seconds < 86400 -> "#{div(seconds, 3600)}h #{div(rem(seconds, 3600), 60)}m" true -> "#{div(seconds, 86400)}d #{div(rem(seconds, 86400), 3600)}h" end end defp format_duration(_), do: "Unknown" defp incident_severity_classes(mrr) do amount = decimal_to_float(mrr) cond do amount >= 1000 -> "border-l-4 border-red-500 bg-red-50 dark:bg-red-950/30" amount >= 100 -> "border-l-4 border-orange-500 bg-orange-50 dark:bg-orange-950/30" amount > 0 -> "border-l-4 border-yellow-500 bg-yellow-50 dark:bg-yellow-950/30" true -> "border-l-4 border-gray-300 bg-gray-50 dark:bg-gray-800/50" end end defp site_health_border(:red), do: "border-red-300 bg-red-50/50 dark:border-red-800 dark:bg-red-950/20" defp site_health_border(:yellow), do: "border-yellow-300 bg-yellow-50/50 dark:border-yellow-800 dark:bg-yellow-950/20" defp site_health_border(_), do: "border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50" defp site_health_dot(:red), do: "bg-red-500" defp site_health_dot(:yellow), do: "bg-yellow-500" defp site_health_dot(_), do: "bg-green-500" defp decimal_to_float(%Decimal{} = d), do: Decimal.to_float(d) defp decimal_to_float(n) when is_number(n), do: n / 1 defp decimal_to_float(_), do: 0.0 defp mrr_at_risk_positive?(%Decimal{} = d), do: Decimal.gt?(d, Decimal.new("0")) defp mrr_at_risk_positive?(n) when is_number(n), do: n > 0 defp mrr_at_risk_positive?(_), do: false defp uptime_color(pct) when pct >= 99.0, do: "text-green-600 dark:text-green-400" defp uptime_color(pct) when pct >= 95.0, do: "text-yellow-600 dark:text-yellow-400" defp uptime_color(_pct), do: "text-red-600 dark:text-red-400" defp uptime_bg(pct) when pct >= 99.0, do: "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800" defp uptime_bg(pct) when pct >= 95.0, do: "bg-yellow-50 border-yellow-200 dark:bg-yellow-900/20 dark:border-yellow-800" defp uptime_bg(_pct), do: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800" defp format_qoe(nil), do: "—" defp format_qoe(score), do: :erlang.float_to_binary(score / 1, decimals: 1) end