- H23: introduce Towerops.Workers.SyncErrors.transient?/1 classifier; uisp/preseem/cn_maestro sync workers now retry transient failures (5xx, timeouts, rate limits) via Oban while still discarding permanent errors (401/403/404) as :ok. Stale monitoring data is the bigger risk than an extra retry on a known-bad credential. - H26: add Monitoring.get_latency_data_for_devices/2 batch counterpart (currently a stub map but called from SiteLive.Show so any future ping implementation lands in a single query, not one-per-device). - H28: Nominatim search popups in the coverage map now bind via a text node instead of an HTML string, so a compromised/poisoned response can't execute as JS in the popup. - H29: yaml_profiles dot-boundary OID prefix match (handles optional trailing dot from YAML profile keys). Stops 1.3.6.1.4.1.9 from incorrectly matching 1.3.6.1.4.1.99.
278 lines
9.3 KiB
Elixir
278 lines
9.3 KiB
Elixir
defmodule ToweropsWeb.SiteLive.Show do
|
|
@moduledoc false
|
|
use ToweropsWeb, :live_view
|
|
|
|
alias Towerops.Alerts
|
|
alias Towerops.Capacity
|
|
alias Towerops.ConfigChanges
|
|
alias Towerops.Dashboard
|
|
alias Towerops.Devices
|
|
alias Towerops.Gaiia
|
|
alias Towerops.Monitoring
|
|
alias Towerops.Preseem
|
|
alias Towerops.Sites
|
|
alias Towerops.Snmp
|
|
alias Towerops.Workers.DiscoveryWorker
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
{:ok, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_params(%{"id" => id}, _, socket) do
|
|
organization = socket.assigns.current_scope.organization
|
|
site = Sites.get_organization_site!(organization.id, id)
|
|
devices = Devices.list_site_devices(site.id)
|
|
device_ids = Enum.map(devices, & &1.id)
|
|
latency_chart_data = load_site_latency_chart_data(devices)
|
|
site_summary = Dashboard.get_site_summary(site.id)
|
|
|
|
# QoE summary from Preseem
|
|
qoe_summary = Preseem.get_site_qoe_summary(site.id)
|
|
|
|
# Config changes in last 30 days
|
|
thirty_days_ago = DateTime.add(DateTime.utc_now(), -30, :day)
|
|
config_changes = ConfigChanges.list_site_changes(site.id, after: thirty_days_ago, limit: 10)
|
|
|
|
# Active alerts
|
|
active_alerts = Alerts.list_site_active_alerts(site.id)
|
|
|
|
# Insights
|
|
insights = Preseem.Insights.list_site_insights(site.id, limit: 10)
|
|
|
|
# Device response times for health grid
|
|
response_times = Monitoring.get_device_latest_response_times(device_ids)
|
|
|
|
# Subscriber impact per device from Gaiia
|
|
site_impact =
|
|
try do
|
|
Gaiia.get_site_impact(site.id)
|
|
rescue
|
|
_ -> %{subscriber_count: 0, mrr: nil, devices: []}
|
|
end
|
|
|
|
# Build per-device impact lookup for device health table
|
|
device_impact = Map.new(site_impact.devices, fn d -> {d.device_id, d} end)
|
|
|
|
# Use site_impact as fallback when network site isn't mapped
|
|
site_summary = merge_impact_into_summary(site_summary, site_impact)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:page_title, site.name)
|
|
|> assign(:site, site)
|
|
|> assign(:device, devices)
|
|
|> assign(:site_impact, site_impact)
|
|
|> assign(:device_impact, device_impact)
|
|
|> assign(:latency_chart_data, latency_chart_data)
|
|
|> assign(:site_summary, site_summary)
|
|
|> assign(:qoe_summary, qoe_summary)
|
|
|> assign(:config_changes, config_changes)
|
|
|> assign(:active_alerts, active_alerts)
|
|
|> assign(:insights, insights)
|
|
|> assign(:response_times, response_times)
|
|
|> assign(:capacity_summary, Capacity.get_site_capacity_summary(site.id))}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("force_rediscover_all", _params, socket) do
|
|
devices = socket.assigns.device
|
|
snmp_devices = Enum.filter(devices, & &1.snmp_enabled)
|
|
|
|
if Enum.empty?(snmp_devices) do
|
|
{:noreply, put_flash(socket, :error, t_equipment("No SNMP-enabled devices at this site"))}
|
|
else
|
|
# Enqueue discovery for all SNMP-enabled devices
|
|
Enum.each(snmp_devices, fn device ->
|
|
enqueue_discovery(device.id)
|
|
end)
|
|
|
|
count = length(snmp_devices)
|
|
|
|
message =
|
|
t_equipment("Discovery started for %{count} device", count: count) <>
|
|
if(count == 1, do: "", else: "s")
|
|
|
|
{:noreply, put_flash(socket, :info, message)}
|
|
end
|
|
end
|
|
|
|
defp load_site_latency_chart_data(devices) do
|
|
if Enum.empty?(devices) do
|
|
nil
|
|
else
|
|
twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour)
|
|
device_ids = Enum.map(devices, & &1.id)
|
|
|
|
latency_by_device =
|
|
Monitoring.get_latency_data_for_devices(device_ids, since: twenty_four_hours_ago, limit: 1000)
|
|
|
|
datasets =
|
|
devices
|
|
|> Enum.map(fn device ->
|
|
checks =
|
|
latency_by_device
|
|
|> Map.get(device.id, [])
|
|
|> Enum.reverse()
|
|
|
|
%{
|
|
label: device.name,
|
|
data: Enum.map(checks, &latency_check_to_data_point/1)
|
|
}
|
|
end)
|
|
|> Enum.reject(fn dataset -> Enum.empty?(dataset.data) end)
|
|
|
|
if Enum.empty?(datasets) do
|
|
nil
|
|
else
|
|
Jason.encode!(%{datasets: datasets})
|
|
end
|
|
end
|
|
end
|
|
|
|
defp latency_check_to_data_point(check) do
|
|
%{
|
|
x: DateTime.to_unix(check.checked_at, :millisecond),
|
|
y: check.response_time_ms
|
|
}
|
|
end
|
|
|
|
defp merge_impact_into_summary(summary, impact) do
|
|
if impact.subscriber_count > 0 do
|
|
%{summary | subscribers: impact.subscriber_count, mrr: impact.mrr}
|
|
else
|
|
summary
|
|
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)
|
|
|
|
@doc false
|
|
def qoe_color(nil), do: "text-gray-400 dark:text-gray-500"
|
|
def qoe_color(score) when score >= 8.0, do: "text-green-600 dark:text-green-400"
|
|
def qoe_color(score) when score >= 6.0, do: "text-yellow-600 dark:text-yellow-400"
|
|
def qoe_color(_), do: "text-red-600 dark:text-red-400"
|
|
|
|
@doc false
|
|
def qoe_bg(nil), do: "bg-gray-50 border-gray-200 dark:bg-gray-800/50 dark:border-white/10"
|
|
def qoe_bg(score) when score >= 8.0, do: "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800/30"
|
|
|
|
def qoe_bg(score) when score >= 6.0,
|
|
do: "bg-yellow-50 border-yellow-200 dark:bg-yellow-900/20 dark:border-yellow-800/30"
|
|
|
|
def qoe_bg(_), do: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800/30"
|
|
|
|
@doc false
|
|
def capacity_bar_color(nil), do: "bg-gray-300"
|
|
def capacity_bar_color(score) when score >= 70, do: "bg-green-500"
|
|
def capacity_bar_color(score) when score >= 40, do: "bg-yellow-500"
|
|
def capacity_bar_color(_), do: "bg-red-500"
|
|
|
|
@doc false
|
|
def status_dot_class(:up), do: "bg-green-500"
|
|
def status_dot_class(:down), do: "bg-red-500"
|
|
def status_dot_class(_), do: "bg-gray-400"
|
|
|
|
@doc false
|
|
def time_ago(nil), do: "—"
|
|
|
|
def time_ago(datetime) do
|
|
seconds = DateTime.diff(DateTime.utc_now(), datetime, :second)
|
|
format_seconds_ago(seconds)
|
|
end
|
|
|
|
defp format_seconds_ago(seconds) when seconds < 60, do: "#{seconds}s ago"
|
|
defp format_seconds_ago(seconds) when seconds < 3600, do: "#{div(seconds, 60)}m ago"
|
|
defp format_seconds_ago(seconds) when seconds < 86_400, do: "#{div(seconds, 3600)}h ago"
|
|
defp format_seconds_ago(seconds), do: "#{div(seconds, 86_400)}d ago"
|
|
|
|
@doc false
|
|
def format_response_time(nil), do: "—"
|
|
def format_response_time(ms) when is_number(ms) and ms < 1, do: "<1ms"
|
|
def format_response_time(ms) when is_number(ms), do: "#{round(ms)}ms"
|
|
def format_response_time(_), do: "—"
|
|
|
|
@doc false
|
|
def change_size_label(size) when size < 100, do: "Small"
|
|
def change_size_label(size) when size < 500, do: "Medium"
|
|
def change_size_label(_), do: "Large"
|
|
|
|
@doc false
|
|
def change_size_color(size) when size < 100, do: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300"
|
|
|
|
def change_size_color(size) when size < 500,
|
|
do: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
|
|
|
|
def change_size_color(_), do: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
|
|
|
@doc false
|
|
def insight_urgency_class("critical"), do: "text-red-600 dark:text-red-400"
|
|
def insight_urgency_class("warning"), do: "text-yellow-600 dark:text-yellow-400"
|
|
def insight_urgency_class(_), do: "text-blue-600 dark:text-blue-400"
|
|
|
|
@doc false
|
|
def insight_urgency_icon("critical"), do: "hero-exclamation-triangle"
|
|
def insight_urgency_icon("warning"), do: "hero-exclamation-circle"
|
|
def insight_urgency_icon(_), do: "hero-information-circle"
|
|
|
|
@doc false
|
|
def alert_severity(:device_down), do: "critical"
|
|
def alert_severity(:device_up), do: "info"
|
|
def alert_severity(_), do: "warning"
|
|
|
|
@doc false
|
|
def format_capacity(bps) when is_number(bps) and bps >= 1_000_000_000, do: "#{Float.round(bps / 1_000_000_000, 1)} Gbps"
|
|
|
|
def format_capacity(bps) when is_number(bps) and bps >= 1_000_000, do: "#{Float.round(bps / 1_000_000, 1)} Mbps"
|
|
def format_capacity(bps) when is_number(bps) and bps >= 1_000, do: "#{Float.round(bps / 1_000, 1)} Kbps"
|
|
def format_capacity(bps) when is_number(bps), do: "#{bps} bps"
|
|
def format_capacity(_), do: "-"
|
|
|
|
@doc false
|
|
def utilization_bar_color(pct) when pct >= 90, do: "bg-red-500"
|
|
def utilization_bar_color(pct) when pct >= 70, do: "bg-yellow-500"
|
|
def utilization_bar_color(_pct), do: "bg-green-500"
|
|
|
|
@doc false
|
|
def utilization_text_color(pct) when pct >= 90, do: "text-red-600 dark:text-red-400"
|
|
def utilization_text_color(pct) when pct >= 70, do: "text-yellow-600 dark:text-yellow-400"
|
|
def utilization_text_color(_pct), do: "text-green-600 dark:text-green-400"
|
|
|
|
# Enqueue discovery job - safe to call in test environment
|
|
defp enqueue_discovery(device_id) do
|
|
if Application.get_env(:towerops, :env) == :test do
|
|
# In test, run synchronously
|
|
_ = Task.start(fn -> run_discovery_if_exists(device_id) end)
|
|
else
|
|
# In dev/prod, enqueue to Oban
|
|
DiscoveryWorker.enqueue(device_id)
|
|
end
|
|
end
|
|
|
|
defp run_discovery_if_exists(device_id) do
|
|
case Devices.get_device(device_id) do
|
|
nil -> :ok
|
|
device -> Snmp.discover_device(device)
|
|
end
|
|
end
|
|
end
|