feat: add capacity insight worker, UI, and reporting views

Add CapacityInsightWorker (every 15 min) that generates critical/warning
insights when backhaul utilization exceeds 90%/75% and auto-resolves
when it drops below 70%.

Add capacity and utilization columns to the device ports tab with
set/clear capacity controls. Add organization-level /capacity page
with summary cards and per-site capacity table. Add capacity summary
card to site show page. Add Capacity link to nav menu.
This commit is contained in:
Graham McIntire 2026-03-07 14:42:40 -06:00
parent abcca08f0b
commit 1c222f8ff5
No known key found for this signature in database
16 changed files with 996 additions and 6 deletions

View file

@ -112,6 +112,8 @@ config :towerops, Oban,
{"*/5 * * * *", Towerops.Workers.SystemInsightWorker},
# Gaiia reconciliation insights nightly at 4:30 AM
{"30 4 * * *", Towerops.Workers.GaiiaInsightWorker},
# Backhaul capacity utilization insights every 15 minutes
{"*/15 * * * *", Towerops.Workers.CapacityInsightWorker},
# Sync device usage to Stripe daily at 3 AM UTC
{"0 3 * * *", Towerops.Workers.BillingSyncWorker}
]},

View file

@ -220,6 +220,8 @@ if config_env() == :prod do
{"*/5 * * * *", Towerops.Workers.SystemInsightWorker},
# Gaiia reconciliation insights nightly at 4:30 AM
{"30 4 * * *", Towerops.Workers.GaiiaInsightWorker},
# Backhaul capacity utilization insights every 15 minutes
{"*/15 * * * *", Towerops.Workers.CapacityInsightWorker},
# Sync device usage to Stripe daily at 3 AM UTC
{"0 3 * * *", Towerops.Workers.BillingSyncWorker}
]},

View file

@ -114,6 +114,7 @@ defmodule Towerops.Capacity do
total_throughput_bps: summary.total_throughput_bps,
utilization_pct: summary.utilization_pct,
status: summary.status,
interfaces: summary.interfaces,
headroom_bps: max(0, summary.total_capacity_bps - summary.total_throughput_bps)
}
end)

View file

@ -10,7 +10,7 @@ defmodule Towerops.Preseem.Insight do
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@valid_types ~w(qoe_degradation capacity_saturation firmware_opportunity model_underperforming subscriber_growth config_drift snmp_cpu_high snmp_memory_high snmp_disk_high device_poll_gap agent_offline firmware_mismatch reconciliation_finding subscriber_growth_trend suspect_config_change)
@valid_types ~w(qoe_degradation capacity_saturation firmware_opportunity model_underperforming subscriber_growth config_drift snmp_cpu_high snmp_memory_high snmp_disk_high device_poll_gap agent_offline firmware_mismatch reconciliation_finding subscriber_growth_trend suspect_config_change backhaul_over_capacity backhaul_near_capacity)
@valid_urgencies ~w(critical warning info)
@valid_statuses ~w(active dismissed resolved)
@valid_channels ~w(proactive contextual passive)

View file

@ -0,0 +1,143 @@
defmodule Towerops.Workers.CapacityInsightWorker do
@moduledoc """
Evaluates backhaul capacity utilization and generates insights when
interfaces approach or exceed their configured capacity.
Runs every 15 minutes. Generates:
- `backhaul_over_capacity` (critical) when utilization >= 90%
- `backhaul_near_capacity` (warning) when utilization >= 75%
Auto-resolves insights when utilization drops below 70%.
"""
use Oban.Worker, queue: :maintenance
import Ecto.Query
alias Towerops.Capacity
alias Towerops.Devices.Device
alias Towerops.Organizations.Organization
alias Towerops.Preseem.Insight
alias Towerops.Preseem.Insights
alias Towerops.Repo
alias Towerops.Snmp.Interface
require Logger
@critical_threshold 90
@warning_threshold 75
@resolve_threshold 70
@impl Oban.Worker
def perform(%Oban.Job{}) do
org_ids = Repo.all(from(o in Organization, select: o.id))
Enum.each(org_ids, &evaluate_organization/1)
:ok
end
defp evaluate_organization(organization_id) do
interfaces = list_capacity_interfaces(organization_id)
evaluated =
Enum.map(interfaces, fn {interface, device} ->
utilization = Capacity.get_utilization(interface)
{interface, device, utilization}
end)
# Generate insights for high utilization
Enum.each(evaluated, fn {interface, device, utilization} ->
generate_capacity_insight(organization_id, interface, device, utilization)
end)
# Auto-resolve insights for recovered interfaces
evaluated_ids = MapSet.new(evaluated, fn {iface, _, _} -> iface.id end)
auto_resolve_recovered(organization_id, evaluated, evaluated_ids)
end
defp generate_capacity_insight(_org_id, _interface, _device, nil), do: :ok
defp generate_capacity_insight(organization_id, interface, device, utilization) do
pct = utilization.utilization_pct
cond do
pct >= @critical_threshold ->
insert_insight(organization_id, interface, device, utilization, "backhaul_over_capacity", "critical")
pct >= @warning_threshold ->
insert_insight(organization_id, interface, device, utilization, "backhaul_near_capacity", "warning")
true ->
:ok
end
end
defp insert_insight(organization_id, interface, device, utilization, type, urgency) do
Insights.insert_insight_if_new(%{
organization_id: organization_id,
device_id: device.id,
type: type,
urgency: urgency,
channel: "proactive",
source: "snmp",
title: "#{interface.if_name} on #{device.name} #{humanize_type(type)}",
description:
"Interface #{interface.if_name} is at #{Float.round(utilization.utilization_pct, 1)}% of its #{format_capacity(interface.configured_capacity_bps)} capacity.",
dedup_key: interface.id,
metadata: %{
"dedup_key" => interface.id,
"interface_id" => interface.id,
"interface_name" => interface.if_name,
"capacity_bps" => interface.configured_capacity_bps,
"throughput_bps" => utilization.throughput.max_bps,
"utilization_pct" => Float.round(utilization.utilization_pct, 1),
"device_name" => device.name
}
})
end
defp auto_resolve_recovered(organization_id, evaluated, _evaluated_ids) do
active_insights =
Insight
|> where([i], i.organization_id == ^organization_id)
|> where([i], i.type in ["backhaul_over_capacity", "backhaul_near_capacity"])
|> where([i], i.status == "active")
|> Repo.all()
Enum.each(active_insights, fn insight ->
interface_id = insight.metadata["interface_id"]
match = Enum.find(evaluated, fn {iface, _, _} -> iface.id == interface_id end)
maybe_resolve_insight(insight, match)
end)
end
defp maybe_resolve_insight(_insight, nil), do: :ok
defp maybe_resolve_insight(_insight, {_iface, _device, nil}), do: :ok
defp maybe_resolve_insight(insight, {_iface, _device, utilization}) do
if utilization.utilization_pct < @resolve_threshold do
insight
|> Insight.changeset(%{status: "resolved"})
|> Repo.update()
end
end
defp list_capacity_interfaces(organization_id) do
Interface
|> join(:inner, [i], sd in Towerops.Snmp.Device, on: i.snmp_device_id == sd.id)
|> join(:inner, [_i, sd], d in Device, on: sd.device_id == d.id)
|> where([_i, _sd, d], d.organization_id == ^organization_id)
|> where([i], not is_nil(i.configured_capacity_bps))
|> select([i, _sd, d], {i, d})
|> Repo.all()
end
defp humanize_type("backhaul_over_capacity"), do: "over capacity"
defp humanize_type("backhaul_near_capacity"), do: "approaching capacity"
defp format_capacity(bps) when bps >= 1_000_000_000, do: "#{Float.round(bps / 1_000_000_000, 1)} Gbps"
defp format_capacity(bps) when bps >= 1_000_000, do: "#{Float.round(bps / 1_000_000, 1)} Mbps"
defp format_capacity(bps) when bps >= 1_000, do: "#{Float.round(bps / 1_000, 1)} Kbps"
defp format_capacity(bps), do: "#{bps} bps"
end

View file

@ -306,7 +306,7 @@ defmodule ToweropsWeb.Layouts do
}
class={[
"inline-flex items-center gap-1 h-14 sm:h-16 px-2 text-sm font-medium border-b-2 transition-colors",
if(@active_page in ["network-map", "insights", "trace", "activity"],
if(@active_page in ["network-map", "insights", "trace", "activity", "capacity"],
do:
"border-gray-900 text-gray-900 font-semibold dark:border-white dark:text-white",
else:
@ -379,6 +379,19 @@ defmodule ToweropsWeb.Layouts do
>
<.icon name="hero-clock" class="h-4 w-4 inline mr-2" />{t("Activity")}
</.link>
<.link
navigate={~p"/capacity"}
role="menuitem"
class={[
"block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-white/5",
if(@active_page == "capacity",
do: "text-gray-900 font-medium dark:text-white",
else: "text-gray-700 dark:text-gray-300"
)
]}
>
<.icon name="hero-signal" class="h-4 w-4 inline mr-2" />{t("Capacity")}
</.link>
</div>
</div>
</div>
@ -588,6 +601,9 @@ defmodule ToweropsWeb.Layouts do
<.mobile_nav_link navigate={~p"/activity"} active={@active_page == "activity"}>
<.icon name="hero-clock" class="size-5" /> {t("Activity")}
</.mobile_nav_link>
<.mobile_nav_link navigate={~p"/capacity"} active={@active_page == "capacity"}>
<.icon name="hero-signal" class="size-5" /> {t("Capacity")}
</.mobile_nav_link>
</div>
<!-- Organization & account -->

View file

@ -0,0 +1,56 @@
defmodule ToweropsWeb.CapacityLive do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Capacity
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_scope.organization
summaries = Capacity.get_organization_capacity_summary(organization.id)
total_capacity = Enum.reduce(summaries, 0, fn s, acc -> acc + s.total_capacity_bps end)
total_throughput = Enum.reduce(summaries, 0.0, fn s, acc -> acc + s.total_throughput_bps end)
overall_utilization =
if total_capacity > 0, do: Float.round(total_throughput / total_capacity * 100, 1), else: 0.0
sites_at_risk = Enum.count(summaries, fn s -> s.utilization_pct >= 75 end)
sites_with_headroom = Enum.count(summaries, fn s -> s.utilization_pct < 70 and s.total_capacity_bps > 0 end)
{:ok,
socket
|> assign(:page_title, "Capacity")
|> assign(:summaries, summaries)
|> assign(:total_capacity, total_capacity)
|> assign(:total_throughput, total_throughput)
|> assign(:overall_utilization, overall_utilization)
|> assign(:sites_at_risk, sites_at_risk)
|> assign(:sites_with_headroom, sites_with_headroom)}
end
defp format_capacity(bps) when is_number(bps) and bps >= 1_000_000_000,
do: "#{Float.round(bps / 1_000_000_000, 1)} Gbps"
defp format_capacity(bps) when is_number(bps) and bps >= 1_000_000, do: "#{Float.round(bps / 1_000_000, 1)} Mbps"
defp format_capacity(bps) when is_number(bps) and bps >= 1_000, do: "#{Float.round(bps / 1_000, 1)} Kbps"
defp format_capacity(bps) when is_number(bps), do: "#{bps} bps"
defp format_capacity(_), do: "-"
defp utilization_color(pct) when pct >= 90, do: "bg-red-500"
defp utilization_color(pct) when pct >= 70, do: "bg-yellow-500"
defp utilization_color(_pct), do: "bg-green-500"
defp utilization_text_color(pct) when pct >= 90, do: "text-red-600 dark:text-red-400"
defp utilization_text_color(pct) when pct >= 70, do: "text-yellow-600 dark:text-yellow-400"
defp utilization_text_color(_pct), do: "text-green-600 dark:text-green-400"
defp status_badge(pct) when pct >= 90, do: {"Critical", "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300"}
defp status_badge(pct) when pct >= 70,
do: {"Warning", "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/50 dark:text-yellow-300"}
defp status_badge(_pct), do: {"Healthy", "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300"}
end

View file

@ -0,0 +1,137 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="capacity"
>
<.breadcrumb items={[
%{label: "Dashboard", navigate: ~p"/dashboard"},
%{label: "Capacity"}
]} />
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{t("Network Capacity")}</h1>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
{t("Backhaul capacity utilization across all sites")}
</p>
</div>
<!-- Summary Cards -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-4">
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t("Total Capacity")}
</div>
<div class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
{format_capacity(@total_capacity)}
</div>
</div>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-4">
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t("Current Throughput")}
</div>
<div class="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
{format_capacity(@total_throughput)}
</div>
</div>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-4">
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t("Sites at Risk")}
</div>
<div class="mt-1 text-2xl font-bold text-yellow-600 dark:text-yellow-400">
{@sites_at_risk}
</div>
</div>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-4">
<div class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t("Sites with Headroom")}
</div>
<div class="mt-1 text-2xl font-bold text-green-600 dark:text-green-400">
{@sites_with_headroom}
</div>
</div>
</div>
<!-- Site Capacity Table -->
<%= if @summaries != [] do %>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
<h2 class="text-sm font-semibold text-gray-900 dark:text-white">
{t("Site Capacity")}
</h2>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-gray-800/75">
<tr class="text-xs text-gray-600 dark:text-gray-400">
<th class="px-4 py-2 text-left font-medium">{t("Site")}</th>
<th class="px-4 py-2 text-left font-medium">{t("Capacity")}</th>
<th class="px-4 py-2 text-left font-medium">{t("Throughput")}</th>
<th class="px-4 py-2 text-left font-medium">{t("Utilization")}</th>
<th class="px-4 py-2 text-left font-medium">{t("Status")}</th>
<th class="px-4 py-2 text-left font-medium">{t("Interfaces")}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
<%= for summary <- Enum.sort_by(@summaries, & &1.utilization_pct, :desc) do %>
<tr class="text-sm hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
<td class="px-4 py-3">
<.link
navigate={~p"/sites/#{summary.site_id}"}
class="font-medium text-gray-900 dark:text-white hover:text-blue-600 dark:hover:text-blue-400 hover:underline"
>
{summary.site_name}
</.link>
</td>
<td class="px-4 py-3 font-mono text-xs text-gray-900 dark:text-white">
{format_capacity(summary.total_capacity_bps)}
</td>
<td class="px-4 py-3 font-mono text-xs text-gray-900 dark:text-white">
{format_capacity(summary.total_throughput_bps)}
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-2 min-w-[120px]">
<div class="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden">
<div
class={[
"h-full rounded-full transition-all",
utilization_color(summary.utilization_pct)
]}
style={"width: #{min(summary.utilization_pct, 100)}%"}
>
</div>
</div>
<span class={[
"font-mono text-xs whitespace-nowrap",
utilization_text_color(summary.utilization_pct)
]}>
{Float.round(summary.utilization_pct, 1)}%
</span>
</div>
</td>
<td class="px-4 py-3">
<% {label, classes} = status_badge(summary.utilization_pct) %>
<span class={["px-2 py-0.5 rounded-full text-xs font-medium", classes]}>
{label}
</span>
</td>
<td class="px-4 py-3 text-xs text-gray-600 dark:text-gray-400">
{length(summary.interfaces)}
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<% else %>
<div class="text-center py-12 bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
<.icon name="hero-signal" class="h-12 w-12 mx-auto text-gray-300 dark:text-gray-600" />
<h3 class="mt-4 text-sm font-medium text-gray-900 dark:text-white">
{t("No capacity data")}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{t("Configure capacity on device interfaces to see utilization data here.")}
</p>
</div>
<% end %>
</Layouts.authenticated>

View file

@ -444,11 +444,21 @@ defmodule ToweropsWeb.DeviceLive.Show do
end
end
# Ports tab: interfaces grouped by type.
# Uses snmp_interfaces already loaded in base data.
# Ports tab: interfaces grouped by type, enriched with utilization data.
defp assign_ports_data(socket) do
interfaces = socket.assigns.snmp_interfaces
interfaces_by_type = group_interfaces_by_type(interfaces)
interfaces_with_utilization =
Enum.map(interfaces, fn interface ->
utilization =
if interface.configured_capacity_bps do
Towerops.Capacity.get_utilization(interface)
end
Map.put(interface, :utilization, utilization)
end)
interfaces_by_type = group_interfaces_by_type(interfaces_with_utilization)
assign(socket, :interfaces_by_type, interfaces_by_type)
end
@ -726,6 +736,26 @@ defmodule ToweropsWeb.DeviceLive.Show do
defp format_speed(_), do: "-"
defp capacity_source_label("manual"), do: "Manual"
defp capacity_source_label("sensor"), do: "Sensor"
defp capacity_source_label("if_speed"), do: "Link"
defp capacity_source_label("peak"), do: "Peak"
defp capacity_source_label(_), do: ""
defp capacity_source_class("manual"), do: "bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300"
defp capacity_source_class("sensor"), do: "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-300"
defp capacity_source_class("if_speed"), do: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300"
defp capacity_source_class("peak"), do: "bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300"
defp capacity_source_class(_), do: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300"
defp utilization_color(pct) when pct >= 90, do: "bg-red-500"
defp utilization_color(pct) when pct >= 70, do: "bg-yellow-500"
defp utilization_color(_pct), do: "bg-green-500"
defp utilization_text_color(pct) when pct >= 90, do: "text-red-600 dark:text-red-400"
defp utilization_text_color(pct) when pct >= 70, do: "text-yellow-600 dark:text-yellow-400"
defp utilization_text_color(_pct), do: "text-green-600 dark:text-green-400"
defp format_bytes(nil), do: "-"
defp format_bytes(0), do: "0 B"
@ -1283,6 +1313,38 @@ defmodule ToweropsWeb.DeviceLive.Show do
]
end
@impl true
def handle_event("set_capacity", %{"interface_id" => id, "capacity_mbps" => mbps_str}, socket) do
case Float.parse(mbps_str) do
{mbps, _} when mbps > 0 ->
bps = round(mbps * 1_000_000)
case Snmp.set_manual_capacity(id, bps) do
{:ok, _} ->
socket = reload_snmp_and_ports(socket)
{:noreply, put_flash(socket, :info, t("Capacity updated"))}
{:error, _} ->
{:noreply, put_flash(socket, :error, t("Failed to update capacity"))}
end
_ ->
{:noreply, put_flash(socket, :error, t("Invalid capacity value"))}
end
end
@impl true
def handle_event("clear_capacity", %{"interface_id" => id}, socket) do
case Snmp.clear_manual_capacity(id) do
{:ok, _} ->
socket = reload_snmp_and_ports(socket)
{:noreply, put_flash(socket, :info, t("Capacity cleared"))}
{:error, _} ->
{:noreply, put_flash(socket, :error, t("Failed to clear capacity"))}
end
end
@impl true
def handle_event("add_check", _params, socket) do
{:noreply, assign(socket, :show_check_form, true)}
@ -1401,6 +1463,15 @@ defmodule ToweropsWeb.DeviceLive.Show do
end
end
defp reload_snmp_and_ports(socket) do
device_id = socket.assigns.device.id
snmp_data = load_snmp_data(device_id)
socket
|> assign(:snmp_interfaces, snmp_data.interfaces)
|> assign_ports_data()
end
defp trigger_manual_backup(socket, device, agent_token_id) do
alias Towerops.Agent.AgentJob
alias Towerops.Agent.MikrotikCommand

View file

@ -1294,6 +1294,8 @@
<th class="px-3 py-2 text-left font-medium">Speed</th>
<th class="px-3 py-2 text-left font-medium">IP Address</th>
<th class="px-3 py-2 text-left font-medium">MAC</th>
<th class="px-3 py-2 text-left font-medium">Capacity</th>
<th class="px-3 py-2 text-left font-medium">Utilization</th>
<th class="px-3 py-2 text-right font-medium">In</th>
<th class="px-3 py-2 text-right font-medium">Out</th>
</tr>
@ -1392,6 +1394,71 @@
</span>
<span :if={!interface.if_phys_address} class="text-gray-400">-</span>
</td>
<td class="px-3 py-2 text-xs">
<%= if interface.configured_capacity_bps do %>
<div class="flex items-center gap-1">
<span class="font-mono text-gray-900 dark:text-white">
{format_speed(interface.configured_capacity_bps)}
</span>
<span class={[
"px-1 py-0.5 rounded text-[10px] font-medium",
capacity_source_class(interface.capacity_source)
]}>
{capacity_source_label(interface.capacity_source)}
</span>
<button
phx-click="clear_capacity"
phx-value-interface_id={interface.id}
class="text-gray-400 hover:text-red-500 transition-colors ml-0.5"
title={t("Clear capacity")}
>
<.icon name="hero-x-mark" class="h-3 w-3" />
</button>
</div>
<% else %>
<button
phx-click={
JS.push("set_capacity",
value: %{
interface_id: interface.id,
capacity_mbps:
if(interface.if_speed,
do: to_string(div(interface.if_speed, 1_000_000)),
else: ""
)
}
)
}
class="text-xs text-blue-600 dark:text-blue-400 hover:underline"
>
{t("Set")}
</button>
<% end %>
</td>
<td class="px-3 py-2 text-xs">
<%= if interface.utilization do %>
<div class="flex items-center gap-2 min-w-[100px]">
<div class="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden">
<div
class={[
"h-full rounded-full transition-all",
utilization_color(interface.utilization.utilization_pct)
]}
style={"width: #{min(interface.utilization.utilization_pct, 100)}%"}
>
</div>
</div>
<span class={[
"font-mono whitespace-nowrap",
utilization_text_color(interface.utilization.utilization_pct)
]}>
{Float.round(interface.utilization.utilization_pct, 1)}%
</span>
</div>
<% else %>
<span class="text-gray-400">-</span>
<% end %>
</td>
<td class="px-3 py-2 text-right font-mono text-xs text-gray-600 dark:text-gray-400">
{if interface.latest_stat && interface.latest_stat.if_in_octets,
do: format_bytes(interface.latest_stat.if_in_octets),

View file

@ -3,6 +3,7 @@ defmodule ToweropsWeb.SiteLive.Show do
use ToweropsWeb, :live_view
alias Towerops.Alerts
alias Towerops.Capacity
alias Towerops.ConfigChanges
alias Towerops.Dashboard
alias Towerops.Devices
@ -70,7 +71,8 @@ defmodule ToweropsWeb.SiteLive.Show do
|> assign(:config_changes, config_changes)
|> assign(:active_alerts, active_alerts)
|> assign(:insights, insights)
|> assign(:response_times, response_times)}
|> assign(:response_times, response_times)
|> assign(:capacity_summary, Capacity.get_site_capacity_summary(site.id))}
end
@impl true
@ -224,6 +226,24 @@ defmodule ToweropsWeb.SiteLive.Show do
defp alert_severity(:device_up), do: "info"
defp alert_severity(_), do: "warning"
defp format_capacity(bps) when is_number(bps) and bps >= 1_000_000_000,
do: "#{Float.round(bps / 1_000_000_000, 1)} Gbps"
defp format_capacity(bps) when is_number(bps) and bps >= 1_000_000, do: "#{Float.round(bps / 1_000_000, 1)} Mbps"
defp format_capacity(bps) when is_number(bps) and bps >= 1_000, do: "#{Float.round(bps / 1_000, 1)} Kbps"
defp format_capacity(bps) when is_number(bps), do: "#{bps} bps"
defp format_capacity(_), do: "-"
defp utilization_bar_color(pct) when pct >= 90, do: "bg-red-500"
defp utilization_bar_color(pct) when pct >= 70, do: "bg-yellow-500"
defp utilization_bar_color(_pct), do: "bg-green-500"
defp utilization_text_color(pct) when pct >= 90, do: "text-red-600 dark:text-red-400"
defp utilization_text_color(pct) when pct >= 70, do: "text-yellow-600 dark:text-yellow-400"
defp 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

View file

@ -128,6 +128,63 @@
</div>
</div>
<%!-- Backhaul Capacity Card --%>
<%= if @capacity_summary.interfaces != [] do %>
<div class="mt-6 rounded-lg border border-gray-200 bg-white dark:border-white/10 dark:bg-gray-800/50 p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">
<.icon name="hero-signal" class="h-4 w-4 inline mr-1" />{t("Backhaul Capacity")}
</h3>
<span class="text-xs text-gray-500 dark:text-gray-400">
{format_capacity(@capacity_summary.total_capacity_bps)} {t("total")}
</span>
</div>
<div class="flex items-center gap-3 mb-3">
<div class="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
<div
class={[
"h-full rounded-full transition-all",
utilization_bar_color(@capacity_summary.utilization_pct)
]}
style={"width: #{min(@capacity_summary.utilization_pct, 100)}%"}
>
</div>
</div>
<span class={[
"text-sm font-bold font-mono whitespace-nowrap",
utilization_text_color(@capacity_summary.utilization_pct)
]}>
{Float.round(@capacity_summary.utilization_pct, 1)}%
</span>
</div>
<div class="grid grid-cols-3 gap-2 text-xs">
<div>
<span class="text-gray-500 dark:text-gray-400">{t("Throughput")}</span>
<p class="font-mono font-medium text-gray-900 dark:text-white">
{format_capacity(@capacity_summary.total_throughput_bps)}
</p>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">{t("Headroom")}</span>
<p class="font-mono font-medium text-gray-900 dark:text-white">
{format_capacity(
max(
0,
@capacity_summary.total_capacity_bps - @capacity_summary.total_throughput_bps
)
)}
</p>
</div>
<div>
<span class="text-gray-500 dark:text-gray-400">{t("Interfaces")}</span>
<p class="font-mono font-medium text-gray-900 dark:text-white">
{length(@capacity_summary.interfaces)}
</p>
</div>
</div>
</div>
<% end %>
<%!-- QoE Summary Card --%>
<%= if @qoe_summary do %>
<div class={["mt-6 rounded-lg border p-4", qoe_bg(@qoe_summary[:score])]}>

View file

@ -400,6 +400,9 @@ defmodule ToweropsWeb.Router do
# Alert routes
live "/alerts", AlertLive.Index, :index
# Capacity route
live "/capacity", CapacityLive, :index
# Agent routes
live "/agents", AgentLive.Index, :index
live "/agents/:id/edit", AgentLive.Edit, :edit

View file

@ -0,0 +1,220 @@
defmodule Towerops.Workers.CapacityInsightWorkerTest do
use Towerops.DataCase
import Towerops.AccountsFixtures
alias Towerops.Preseem.Insight
alias Towerops.Snmp.Device, as: SnmpDevice
alias Towerops.Snmp.Interface
alias Towerops.Snmp.InterfaceStat
alias Towerops.Workers.CapacityInsightWorker
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test ISP"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Tower Alpha",
organization_id: organization.id
})
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Backhaul Radio",
ip_address: "10.0.0.1",
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
site_id: site.id,
organization_id: organization.id
})
snmp_device =
%SnmpDevice{}
|> SnmpDevice.changeset(%{device_id: device.id, sys_name: "radio", sys_descr: "PTP"})
|> Repo.insert!()
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0",
if_speed: 1_000_000_000,
configured_capacity_bps: 100_000_000,
capacity_source: "manual"
})
|> Repo.insert!()
%{
organization: organization,
site: site,
device: device,
snmp_device: snmp_device,
interface: interface
}
end
describe "perform/1" do
test "generates critical insight when utilization >= 90%", %{interface: interface} do
# 100 Mbps capacity, sending at 95 Mbps = 95% utilization
# 95 Mbps = 11_875_000 bytes/sec * 60 sec = 712_500_000 bytes
now = DateTime.utc_now()
insert_stat(interface.id, %{
if_in_octets: 0,
if_out_octets: 0,
checked_at: DateTime.add(now, -120, :second)
})
insert_stat(interface.id, %{
if_in_octets: 0,
if_out_octets: 712_500_000,
checked_at: DateTime.add(now, -60, :second)
})
assert :ok = CapacityInsightWorker.perform(%Oban.Job{})
insights = Repo.all(from(i in Insight, where: i.type == "backhaul_over_capacity"))
assert [insight] = insights
assert insight.urgency == "critical"
assert insight.status == "active"
end
test "generates warning insight when utilization >= 75%", %{interface: interface} do
# 100 Mbps capacity, sending at 80 Mbps = 80% utilization
# 80 Mbps = 10_000_000 bytes/sec * 60 sec = 600_000_000 bytes
now = DateTime.utc_now()
insert_stat(interface.id, %{
if_in_octets: 0,
if_out_octets: 0,
checked_at: DateTime.add(now, -120, :second)
})
insert_stat(interface.id, %{
if_in_octets: 0,
if_out_octets: 600_000_000,
checked_at: DateTime.add(now, -60, :second)
})
assert :ok = CapacityInsightWorker.perform(%Oban.Job{})
insights = Repo.all(from(i in Insight, where: i.type == "backhaul_near_capacity"))
assert [insight] = insights
assert insight.urgency == "warning"
end
test "does not generate insight when utilization < 75%", %{interface: interface} do
# 100 Mbps capacity, sending at 50 Mbps = 50% utilization
now = DateTime.utc_now()
insert_stat(interface.id, %{
if_in_octets: 0,
if_out_octets: 0,
checked_at: DateTime.add(now, -120, :second)
})
insert_stat(interface.id, %{
if_in_octets: 0,
if_out_octets: 375_000_000,
checked_at: DateTime.add(now, -60, :second)
})
assert :ok = CapacityInsightWorker.perform(%Oban.Job{})
assert [] = Repo.all(from(i in Insight, where: i.type in ["backhaul_over_capacity", "backhaul_near_capacity"]))
end
test "skips interfaces without configured capacity", %{snmp_device: snmp_device} do
# Create an interface without capacity
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 99,
if_name: "lo0"
})
|> Repo.insert!()
assert :ok = CapacityInsightWorker.perform(%Oban.Job{})
# Only insights from the interface WITH capacity (if any threshold met)
all_insights =
Repo.all(from(i in Insight, where: i.type in ["backhaul_over_capacity", "backhaul_near_capacity"]))
refute Enum.any?(all_insights, fn i -> i.metadata["interface_name"] == "lo0" end)
end
test "auto-resolves insight when utilization drops below 70%", %{
interface: interface,
device: device,
organization: organization
} do
# First create an active insight
%Insight{}
|> Insight.changeset(%{
organization_id: organization.id,
device_id: device.id,
type: "backhaul_over_capacity",
urgency: "critical",
channel: "proactive",
source: "snmp",
title: "eth0 over capacity",
metadata: %{"dedup_key" => interface.id, "interface_id" => interface.id}
})
|> Repo.insert!()
# Now set up stats showing low utilization (30 Mbps on 100 Mbps = 30%)
now = DateTime.utc_now()
insert_stat(interface.id, %{
if_in_octets: 0,
if_out_octets: 0,
checked_at: DateTime.add(now, -120, :second)
})
insert_stat(interface.id, %{
if_in_octets: 0,
if_out_octets: 225_000_000,
checked_at: DateTime.add(now, -60, :second)
})
assert :ok = CapacityInsightWorker.perform(%Oban.Job{})
# Insight should be resolved
[insight] = Repo.all(from(i in Insight, where: i.type == "backhaul_over_capacity"))
assert insight.status == "resolved"
end
test "deduplicates insights for same interface", %{interface: interface} do
now = DateTime.utc_now()
insert_stat(interface.id, %{
if_in_octets: 0,
if_out_octets: 0,
checked_at: DateTime.add(now, -120, :second)
})
insert_stat(interface.id, %{
if_in_octets: 0,
if_out_octets: 712_500_000,
checked_at: DateTime.add(now, -60, :second)
})
# Run twice
assert :ok = CapacityInsightWorker.perform(%Oban.Job{})
assert :ok = CapacityInsightWorker.perform(%Oban.Job{})
# Should still only have one insight
insights = Repo.all(from(i in Insight, where: i.type == "backhaul_over_capacity"))
assert length(insights) == 1
end
end
defp insert_stat(interface_id, attrs) do
%InterfaceStat{}
|> InterfaceStat.changeset(Map.put(attrs, :interface_id, interface_id))
|> Repo.insert!()
end
end

View file

@ -0,0 +1,97 @@
defmodule ToweropsWeb.CapacityLiveTest do
use ToweropsWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import Towerops.AccountsFixtures
alias Towerops.Snmp.Device, as: SnmpDevice
alias Towerops.Snmp.Interface
alias Towerops.Snmp.InterfaceStat
setup do
user = user_fixture(enable_totp: true)
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test ISP"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Tower Alpha",
organization_id: organization.id
})
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Backhaul Radio",
ip_address: "10.0.0.1",
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
site_id: site.id,
organization_id: organization.id
})
snmp_device =
%SnmpDevice{}
|> SnmpDevice.changeset(%{device_id: device.id, sys_name: "radio", sys_descr: "PTP"})
|> Towerops.Repo.insert!()
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0",
if_speed: 1_000_000_000,
if_type: 6,
configured_capacity_bps: 300_000_000,
capacity_source: "manual"
})
|> Towerops.Repo.insert!()
now = DateTime.utc_now()
%InterfaceStat{}
|> InterfaceStat.changeset(%{
interface_id: interface.id,
if_in_octets: 0,
if_out_octets: 0,
checked_at: DateTime.add(now, -120, :second)
})
|> Towerops.Repo.insert!()
%InterfaceStat{}
|> InterfaceStat.changeset(%{
interface_id: interface.id,
if_in_octets: 0,
if_out_octets: 1_125_000_000,
checked_at: DateTime.add(now, -60, :second)
})
|> Towerops.Repo.insert!()
%{user: user, organization: organization, site: site, device: device, interface: interface}
end
describe "capacity index page" do
test "redirects to login when not authenticated", %{conn: conn} do
assert {:error, redirect} = live(conn, ~p"/capacity")
assert {:redirect, %{to: to}} = redirect
assert to =~ "/users/log-in"
end
test "displays capacity page with site data", %{conn: conn, user: user} do
conn = log_in_user(conn, user)
{:ok, _view, html} = live(conn, ~p"/capacity")
assert html =~ "Capacity"
assert html =~ "Tower Alpha"
assert html =~ "300.0 Mbps"
end
test "shows utilization percentage for site", %{conn: conn, user: user} do
conn = log_in_user(conn, user)
{:ok, _view, html} = live(conn, ~p"/capacity")
# Site has 300 Mbps capacity with ~150 Mbps throughput = ~50%
assert html =~ "%"
end
end
end

View file

@ -280,4 +280,102 @@ defmodule ToweropsWeb.DeviceLive.ShowTest do
assert render(view)
end
end
describe "ports tab capacity" do
setup %{device: device} do
alias Towerops.Snmp.Device, as: SnmpDevice
alias Towerops.Snmp.Interface
alias Towerops.Snmp.InterfaceStat
snmp_device =
%SnmpDevice{}
|> SnmpDevice.changeset(%{device_id: device.id, sys_name: "radio", sys_descr: "PTP"})
|> Towerops.Repo.insert!()
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: snmp_device.id,
if_index: 1,
if_name: "eth0",
if_speed: 1_000_000_000,
if_type: 6,
configured_capacity_bps: 300_000_000,
capacity_source: "manual"
})
|> Towerops.Repo.insert!()
# Insert stats for utilization calculation
now = DateTime.utc_now()
%InterfaceStat{}
|> InterfaceStat.changeset(%{
interface_id: interface.id,
if_in_octets: 0,
if_out_octets: 0,
checked_at: DateTime.add(now, -120, :second)
})
|> Towerops.Repo.insert!()
# ~150 Mbps = 50% of 300 Mbps capacity
%InterfaceStat{}
|> InterfaceStat.changeset(%{
interface_id: interface.id,
if_in_octets: 0,
if_out_octets: 1_125_000_000,
checked_at: DateTime.add(now, -60, :second)
})
|> Towerops.Repo.insert!()
%{snmp_device: snmp_device, interface: interface}
end
test "displays capacity column on ports tab", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}?tab=ports")
assert html =~ "Capacity"
assert html =~ "300.0 Mbps"
end
test "displays utilization bar on ports tab", %{conn: conn, user: user, device: device} do
conn = log_in_user(conn, user)
{:ok, _view, html} = live(conn, ~p"/devices/#{device.id}?tab=ports")
assert html =~ "Utilization"
end
test "set capacity via event handler", %{
conn: conn,
user: user,
device: device,
interface: interface
} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=ports")
html =
render_click(view, "set_capacity", %{
"interface_id" => interface.id,
"capacity_mbps" => "500"
})
assert html =~ "500.0 Mbps"
end
test "clear capacity via event handler", %{
conn: conn,
user: user,
device: device,
interface: interface
} do
conn = log_in_user(conn, user)
{:ok, view, _html} = live(conn, ~p"/devices/#{device.id}?tab=ports")
html = render_click(view, "clear_capacity", %{"interface_id" => interface.id})
# After clearing, the capacity badge should not show 300 Mbps anymore
refute html =~ "300.0 Mbps"
end
end
end