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.
324 lines
11 KiB
Elixir
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
|