towerops/lib/towerops_web/live/dashboard_live.ex
Graham McIntire 9104ad2948
fix: onboarding checklist alerting step guides to escalation policy
When alert routing is set to "builtin" (TowerOps) but no escalation
policy exists, the checklist item now reads "Create an escalation
policy" and links directly to the new escalation policy page instead
of looping back to general settings where the routing already looks
configured.
2026-03-13 16:41:23 -05:00

324 lines
11 KiB
Elixir

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)}
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({: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(: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_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