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) |> assign(:timezone, socket.assigns.current_scope.timezone) |> assign(:time_format, socket.assigns.current_scope.time_format) |> assign(:pending_dashboard_reload_ref, 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, t("Insight dismissed"))} {:error, _} -> {:noreply, put_flash(socket, :error, t("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, t("Alert acknowledged"))} {:error, _} -> {:noreply, put_flash(socket, :error, t("Failed to acknowledge alert"))} end {:error, _} -> {:noreply, put_flash(socket, :error, t("Alert not found"))} end end @impl true def handle_info({event, _device_id, _alert_type}, socket) when event in [:new_alert, :alert_resolved] do {:noreply, schedule_debounced_dashboard_reload(socket)} end @impl true def handle_info({:alert_changed, _org_id}, socket) do {:noreply, schedule_debounced_dashboard_reload(socket)} end @impl true def handle_info(:debounced_dashboard_reload, socket) do {:noreply, load_dashboard_data(socket, socket.assigns.current_scope.organization.id)} end @dashboard_debounce_ms 1_500 defp schedule_debounced_dashboard_reload(socket) do if ref = socket.assigns.pending_dashboard_reload_ref do Process.cancel_timer(ref) end ref = Process.send_after(self(), :debounced_dashboard_reload, @dashboard_debounce_ms) assign(socket, :pending_dashboard_reload_ref, ref) 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(:favicon_status, favicon_status(Map.get(status_counts, :down, 0), length(active_alerts))) |> assign(:setup_checklist, build_setup_checklist(organization, device_count)) |> load_insights(organization_id) |> load_recent_config_changes(organization_id) end defp build_setup_checklist(organization, device_count) do integrations = Towerops.Integrations.list_integrations(organization.id) has_billing = Enum.any?(integrations, fn i -> i.provider in ~w(gaiia sonar splynx visp) and i.enabled end) has_escalation_policy = has_escalation_policy?(organization) has_alert_routing = organization.alert_routing != "builtin" or has_escalation_policy schedules = Towerops.OnCall.list_schedules(organization.id) # When using builtin routing without an escalation policy, guide the user # to create one rather than looping back to general settings. {alerting_label, alerting_link} = if organization.alert_routing == "builtin" and not has_escalation_policy do {"Create an escalation policy", ~p"/schedules/escalation-policies/new"} else {"Configure alert routing", ~p"/orgs/#{organization.slug}/settings?tab=general"} end steps = [ %{key: :devices, label: "Add devices to monitor", done: device_count > 0, link: ~p"/devices/new"}, %{ key: :billing, label: "Connect billing platform", done: has_billing, link: ~p"/orgs/#{organization.slug}/settings?tab=integrations", optional: true }, %{ key: :alerting, label: alerting_label, done: has_alert_routing, link: alerting_link, optional: false }, %{ key: :oncall, label: "Create on-call schedule", done: schedules != [], link: ~p"/schedules/new", optional: true } ] completed = Enum.count(steps, & &1.done) %{steps: steps, completed: completed, total: length(steps), all_done: completed == length(steps)} end defp has_escalation_policy?(organization) do case Towerops.OnCall.list_escalation_policies(organization.id) do [] -> false _ -> true end 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 favicon_status(devices_down, _alert_count) when devices_down > 0, do: "red" defp favicon_status(_devices_down, alert_count) when alert_count > 0, do: "yellow" defp favicon_status(_devices_down, _alert_count), do: "green" 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 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_subscriber_count(count) when is_float(count), do: format_subscriber_count(round(count)) defp format_subscriber_count(count) when is_integer(count) and count > 0 do ngettext("%{count} sub", "%{count} subs", count, count: count) end 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 < 86_400 -> "#{div(seconds, 3600)}h #{div(rem(seconds, 3600), 60)}m" true -> "#{div(seconds, 86_400)}d #{div(rem(seconds, 86_400), 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_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 format_qoe(nil), do: "—" defp format_qoe(score), do: :erlang.float_to_binary(score / 1, decimals: 1) defp format_short_datetime(datetime, timezone) do ToweropsWeb.TimeHelpers.format_short_datetime(datetime, timezone) end end