towerops/lib/towerops_web/live/dashboard_live.ex
Graham McIntire 91e3181bbc dialyzer: fix all unmatched_return warnings (154 → 0)
Prefix fire-and-forget calls (PubSub.broadcast, Oban enqueue, Logger,
update_*, etc.) with `_ =` so dialyzer knows the return value is
intentionally discarded. Wrap a few if/case expressions whose
branches produce mixed types the same way.

No behavior changes — only explicit acknowledgement of discarded
returns.

Warnings: 242 → 88.
2026-04-21 10:03:55 -05:00

349 lines
12 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)
|> 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