diff --git a/lib/towerops/activity_feed.ex b/lib/towerops/activity_feed.ex index 6ffd6ca0..4e21c716 100644 --- a/lib/towerops/activity_feed.ex +++ b/lib/towerops/activity_feed.ex @@ -65,14 +65,14 @@ defmodule Towerops.ActivityFeed do """ def count_by_type(organization_id) do # Count recent items (last 24h) per type for badge display - since = DateTime.add(DateTime.utc_now(), -86400, :second) + since = DateTime.add(DateTime.utc_now(), -86_400, :second) - active_sources(nil) - |> Enum.map(fn source -> + nil + |> active_sources() + |> Map.new(fn source -> items = fetch_source(source, organization_id, since, nil, 1000) {source, length(items)} end) - |> Map.new() end defp maybe_filter_search(items, nil), do: items @@ -101,8 +101,7 @@ defmodule Towerops.ActivityFeed do String.contains?(searchable, search) end - defp active_sources(nil), - do: [:config_change, :alert_fired, :alert_resolved, :device_event, :sync, :device_added] + defp active_sources(nil), do: [:config_change, :alert_fired, :alert_resolved, :device_event, :sync, :device_added] defp active_sources(types) when is_list(types), do: types @@ -319,7 +318,8 @@ defmodule Towerops.ActivityFeed do device_name: nil, site_name: nil, summary: "Preseem sync #{status_label}", - detail: "#{row.records_synced || 0} records synced#{if row.duration_ms, do: " in #{row.duration_ms}ms", else: ""}", + detail: + "#{row.records_synced || 0} records synced#{if row.duration_ms, do: " in #{row.duration_ms}ms", else: ""}", severity: sync_severity(row.status), icon: "hero-arrow-path", link: nil @@ -401,8 +401,7 @@ defmodule Towerops.ActivityFeed do defp format_gaiia_impact(nil), do: nil - defp format_gaiia_impact(%{"total_subscribers" => subs, "total_mrr" => mrr}) - when is_integer(subs) and subs > 0 do + defp format_gaiia_impact(%{"total_subscribers" => subs, "total_mrr" => mrr}) when is_integer(subs) and subs > 0 do "Affects #{subs} subscribers, $#{mrr}/mo MRR at risk" end @@ -417,8 +416,8 @@ defmodule Towerops.ActivityFeed do cond do diff < 60 -> "#{diff}s" diff < 3600 -> "#{div(diff, 60)}m" - diff < 86400 -> "#{div(diff, 3600)}h #{div(rem(diff, 3600), 60)}m" - true -> "#{div(diff, 86400)}d #{div(rem(diff, 86400), 3600)}h" + diff < 86_400 -> "#{div(diff, 3600)}h #{div(rem(diff, 3600), 60)}m" + true -> "#{div(diff, 86_400)}d #{div(rem(diff, 86_400), 3600)}h" end end diff --git a/lib/towerops/config_changes.ex b/lib/towerops/config_changes.ex index 94250d4b..9d412666 100644 --- a/lib/towerops/config_changes.ex +++ b/lib/towerops/config_changes.ex @@ -122,7 +122,8 @@ defmodule Towerops.ConfigChanges do preloads = Keyword.get(opts, :preload, [:device]) device_ids = - Towerops.Devices.list_site_devices(site_id) + site_id + |> Towerops.Devices.list_site_devices() |> Enum.map(& &1.id) if device_ids == [] do diff --git a/lib/towerops/config_changes/config_change_event.ex b/lib/towerops/config_changes/config_change_event.ex index fb6d47fa..d2fbbf66 100644 --- a/lib/towerops/config_changes/config_change_event.ex +++ b/lib/towerops/config_changes/config_change_event.ex @@ -6,6 +6,8 @@ defmodule Towerops.ConfigChanges.ConfigChangeEvent do import Ecto.Changeset + alias Towerops.Devices.MikrotikBackup + @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id @@ -17,8 +19,8 @@ defmodule Towerops.ConfigChanges.ConfigChangeEvent do belongs_to :device, Towerops.Devices.Device belongs_to :organization, Towerops.Organizations.Organization - belongs_to :backup_before, Towerops.Devices.MikrotikBackup - belongs_to :backup_after, Towerops.Devices.MikrotikBackup + belongs_to :backup_before, MikrotikBackup + belongs_to :backup_after, MikrotikBackup timestamps(type: :utc_datetime, updated_at: false) end diff --git a/lib/towerops/dashboard.ex b/lib/towerops/dashboard.ex index c4fb1c75..d910a2bf 100644 --- a/lib/towerops/dashboard.ex +++ b/lib/towerops/dashboard.ex @@ -167,8 +167,6 @@ defmodule Towerops.Dashboard do duration_seconds = if device.last_status_change_at do DateTime.diff(now, device.last_status_change_at, :second) - else - nil end %{ @@ -203,18 +201,7 @@ defmodule Towerops.Dashboard do down_devices = Devices.list_organization_devices(organization_id, %{"status" => "down", "site_id" => site.id}) {subscribers_affected, mrr_at_risk} = - if down_count > 0 do - impacts = - Enum.map(down_devices, fn device -> - ImpactAnalysis.analyze_device_impact(organization_id, device.id) - end) - - subs = impacts |> Enum.map(& &1.total_subscribers) |> Enum.sum() - mrr = impacts |> Enum.map(& &1.total_mrr) |> Enum.reduce(Decimal.new("0"), &Decimal.add/2) - {subs, mrr} - else - {0, Decimal.new("0")} - end + calculate_down_impact(organization_id, down_devices, down_count) # Get average QoE from Preseem for devices at this site site_devices = Devices.list_site_devices(site.id) @@ -269,4 +256,17 @@ defmodule Towerops.Dashboard do mrr: if(gaiia_summary, do: gaiia_summary.total_mrr) } end + + defp calculate_down_impact(_organization_id, _down_devices, 0), do: {0, Decimal.new("0")} + + defp calculate_down_impact(organization_id, down_devices, _down_count) do + impacts = + Enum.map(down_devices, fn device -> + ImpactAnalysis.analyze_device_impact(organization_id, device.id) + end) + + subs = impacts |> Enum.map(& &1.total_subscribers) |> Enum.sum() + mrr = impacts |> Enum.map(& &1.total_mrr) |> Enum.reduce(Decimal.new("0"), &Decimal.add/2) + {subs, mrr} + end end diff --git a/lib/towerops/preseem.ex b/lib/towerops/preseem.ex index be687c75..efe0c8d7 100644 --- a/lib/towerops/preseem.ex +++ b/lib/towerops/preseem.ex @@ -110,14 +110,14 @@ defmodule Towerops.Preseem do capacity_scores = aps |> Enum.map(& &1.capacity_score) |> Enum.reject(&is_nil/1) avg_qoe = - if qoe_scores != [], - do: Float.round(Enum.sum(qoe_scores) / length(qoe_scores), 1), - else: nil + if qoe_scores == [], + do: nil, + else: Float.round(Enum.sum(qoe_scores) / length(qoe_scores), 1) avg_capacity = - if capacity_scores != [], - do: Float.round(Enum.sum(capacity_scores) / length(capacity_scores), 1), - else: nil + if capacity_scores == [], + do: nil, + else: Float.round(Enum.sum(capacity_scores) / length(capacity_scores), 1) total_subscribers = aps |> Enum.map(& &1.subscriber_count) |> Enum.reject(&is_nil/1) |> Enum.sum() diff --git a/lib/towerops/search.ex b/lib/towerops/search.ex index 56a4ffeb..ae0529df 100644 --- a/lib/towerops/search.ex +++ b/lib/towerops/search.ex @@ -4,11 +4,12 @@ defmodule Towerops.Search do """ import Ecto.Query - alias Towerops.Repo - alias Towerops.Devices.Device - alias Towerops.Sites.Site - alias Towerops.Gaiia.Account + alias Towerops.Alerts.Alert + alias Towerops.Devices.Device + alias Towerops.Gaiia.Account + alias Towerops.Repo + alias Towerops.Sites.Site @limit 5 @@ -76,7 +77,7 @@ defmodule Towerops.Search do |> where([a, d], d.organization_id == ^organization_id) |> where([a, _d], ilike(a.message, ^pattern)) |> where([a, _d], is_nil(a.resolved_at)) - |> order_by([a, _d], [desc: a.triggered_at]) + |> order_by([a, _d], desc: a.triggered_at) |> limit(@limit) |> select([a, d], %{id: a.id, message: a.message, device_name: d.name}) |> Repo.all() diff --git a/lib/towerops/trace.ex b/lib/towerops/trace.ex index d427a176..97c0a8c9 100644 --- a/lib/towerops/trace.ex +++ b/lib/towerops/trace.ex @@ -41,8 +41,7 @@ defmodule Towerops.Trace do devices = search_devices(organization_id, pattern) access_points = search_access_points(organization_id, pattern) - (accounts ++ inventory ++ devices ++ access_points) - |> Enum.take(25) + Enum.take(accounts ++ inventory ++ devices ++ access_points, 25) end end @@ -63,7 +62,11 @@ defmodule Towerops.Trace do defp search_inventory(organization_id, pattern) do InventoryItem |> where(organization_id: ^organization_id) - |> where([i], ilike(i.ip_address, ^pattern) or ilike(i.serial_number, ^pattern) or ilike(i.name, ^pattern) or ilike(i.mac_address, ^pattern)) + |> where( + [i], + ilike(i.ip_address, ^pattern) or ilike(i.serial_number, ^pattern) or + ilike(i.name, ^pattern) or ilike(i.mac_address, ^pattern) + ) |> limit(10) |> preload(:device) |> Repo.all() @@ -97,7 +100,8 @@ defmodule Towerops.Trace do |> preload(:device) |> Repo.all() |> Enum.map(fn ap -> - sublabel = Enum.join(Enum.reject([ap.ip_address, ap.model, "#{ap.subscriber_count || 0} subscribers"], &is_nil/1), " · ") + sublabel = + Enum.join(Enum.reject([ap.ip_address, ap.model, "#{ap.subscriber_count || 0} subscribers"], &is_nil/1), " · ") %{type: :access_point, id: ap.id, label: ap.name, sublabel: sublabel, data: ap} end) @@ -123,7 +127,7 @@ defmodule Towerops.Trace do defp trace_from_account(organization_id, account_id) do account = Repo.get_by(Account, id: account_id, organization_id: organization_id) - unless account, do: throw(:not_found) + if !account, do: throw(:not_found) subscriptions = list_account_subscriptions(organization_id, account.gaiia_id) inventory_items = list_account_inventory(organization_id, account.gaiia_id) @@ -143,7 +147,7 @@ defmodule Towerops.Trace do |> preload(:device) |> Repo.one() - unless item, do: throw(:not_found) + if !item, do: throw(:not_found) # Find account if linked account = @@ -154,7 +158,7 @@ defmodule Towerops.Trace do subscriptions = if account, do: list_account_subscriptions(organization_id, account.gaiia_id), else: [] inventory_items = if account, do: list_account_inventory(organization_id, account.gaiia_id), else: [item] - device = if item.device_id, do: Repo.get(Device, item.device_id) |> Repo.preload(:site) + device = if item.device_id, do: Device |> Repo.get(item.device_id) |> Repo.preload(:site) access_point = if device, do: Repo.get_by(AccessPoint, device_id: device.id) build_trace(account, subscriptions, inventory_items, device, access_point, organization_id) @@ -169,7 +173,7 @@ defmodule Towerops.Trace do |> preload(:site) |> Repo.one() - unless device, do: throw(:not_found) + if !device, do: throw(:not_found) access_point = Repo.get_by(AccessPoint, device_id: device.id) @@ -183,7 +187,9 @@ defmodule Towerops.Trace do case inventory_items do [first | _] when not is_nil(first.assigned_account_gaiia_id) -> Repo.get_by(Account, organization_id: organization_id, gaiia_id: first.assigned_account_gaiia_id) - _ -> nil + + _ -> + nil end subscriptions = if account, do: list_account_subscriptions(organization_id, account.gaiia_id), else: [] @@ -200,9 +206,9 @@ defmodule Towerops.Trace do |> preload(:device) |> Repo.one() - unless access_point, do: throw(:not_found) + if !access_point, do: throw(:not_found) - device = if access_point.device_id, do: access_point.device |> Repo.preload(:site) + device = if access_point.device_id, do: Repo.preload(access_point.device, :site) build_trace(nil, [], [], device, access_point, organization_id) catch @@ -321,7 +327,7 @@ defmodule Towerops.Trace do defp load_recent_config_changes(nil), do: [] defp load_recent_config_changes(device_id) do - since = DateTime.utc_now() |> DateTime.add(-24 * 3600, :second) + since = DateTime.add(DateTime.utc_now(), -24 * 3600, :second) ConfigChangeEvent |> where(device_id: ^device_id) @@ -351,7 +357,7 @@ defmodule Towerops.Trace do item_with_device = Enum.find(inventory_items, & &1.device_id) if item_with_device do - device = Repo.get(Device, item_with_device.device_id) |> Repo.preload(:site) + device = Device |> Repo.get(item_with_device.device_id) |> Repo.preload(:site) access_point = Repo.get_by(AccessPoint, device_id: device.id) {device, access_point} else @@ -377,15 +383,16 @@ defmodule Towerops.Trace do defp format_address(addr) when is_map(addr) do parts = - [ - addr["street1"] || addr["line1"], - addr["street2"] || addr["line2"], - addr["city"], - addr["state"] || addr["province"], - addr["zip"] || addr["postal_code"] - ] - |> Enum.reject(&is_nil/1) - |> Enum.reject(&(String.trim(&1) == "")) + Enum.reject( + [ + addr["street1"] || addr["line1"], + addr["street2"] || addr["line2"], + addr["city"], + addr["state"] || addr["province"], + addr["zip"] || addr["postal_code"] + ], + &(is_nil(&1) or String.trim(&1) == "") + ) if parts == [], do: nil, else: Enum.join(parts, ", ") end diff --git a/lib/towerops_web.ex b/lib/towerops_web.ex index b5bdbddd..245232a5 100644 --- a/lib/towerops_web.ex +++ b/lib/towerops_web.ex @@ -83,9 +83,9 @@ defmodule ToweropsWeb do use Gettext, backend: ToweropsWeb.Gettext import Phoenix.HTML - import ToweropsWeb.CoreComponents import ToweropsWeb.Components.Breadcrumbs import ToweropsWeb.Components.Skeletons + import ToweropsWeb.CoreComponents import ToweropsWeb.GettextHelpers # HTML escaping functionality diff --git a/lib/towerops_web/changelog_parser.ex b/lib/towerops_web/changelog_parser.ex index 14361a51..de3d0cb7 100644 --- a/lib/towerops_web/changelog_parser.ex +++ b/lib/towerops_web/changelog_parser.ex @@ -30,14 +30,7 @@ defmodule ToweropsWeb.ChangelogParser do # Bullet point String.starts_with?(String.trim(line), "* ") -> - case acc do - [current | rest] -> - item = String.trim(line) |> String.trim_leading("* ") - [%{current | items: current.items ++ [item]} | rest] - - [] -> - acc - end + add_item_to_current(acc, line |> String.trim() |> String.trim_leading("* ")) true -> acc @@ -45,4 +38,8 @@ defmodule ToweropsWeb.ChangelogParser do end) |> Enum.reverse() end + + defp add_item_to_current([current | rest], item), do: [%{current | items: current.items ++ [item]} | rest] + + defp add_item_to_current([], _item), do: [] end diff --git a/lib/towerops_web/channels/agent_channel.ex b/lib/towerops_web/channels/agent_channel.ex index 2ef6eaef..6ef8d994 100644 --- a/lib/towerops_web/channels/agent_channel.ex +++ b/lib/towerops_web/channels/agent_channel.ex @@ -31,6 +31,7 @@ defmodule ToweropsWeb.AgentChannel do alias Towerops.Agent.Validator alias Towerops.Agents alias Towerops.Alerts + alias Towerops.ConfigChanges alias Towerops.Devices alias Towerops.Devices.BackupRequests alias Towerops.Devices.MikrotikBackups @@ -1821,29 +1822,31 @@ defmodule ToweropsWeb.AgentChannel do case backups do [_new, previous | _] -> - if previous.config_hash_normalized != new_backup.config_hash_normalized do - case Towerops.ConfigChanges.record_change_event(device_id, previous, new_backup) do - {:ok, %Towerops.ConfigChanges.ConfigChangeEvent{} = event} -> - Logger.info("Config change event recorded", device_id: device_id, event_id: event.id) - - # Async correlate with performance metrics - Task.start(fn -> - Towerops.ConfigChanges.Correlator.correlate(event) - end) - - {:ok, :no_changes} -> - :ok - - {:error, reason} -> - Logger.warning("Failed to record config change event: #{inspect(reason)}", device_id: device_id) - end - end + maybe_record_change(device_id, previous, new_backup) _ -> :ok end end + defp maybe_record_change(_device_id, %{config_hash_normalized: hash}, %{config_hash_normalized: hash}), do: :ok + + defp maybe_record_change(device_id, previous, new_backup) do + case ConfigChanges.record_change_event(device_id, previous, new_backup) do + {:ok, %{id: event_id} = event} -> + Logger.info("Config change event recorded", device_id: device_id, event_id: event_id) + Task.start(fn -> ConfigChanges.Correlator.correlate(event) end) + + {:ok, :no_changes} -> + :ok + + {:error, reason} -> + Logger.warning("Failed to record config change event: #{inspect(reason)}", + device_id: device_id + ) + end + end + defp backup_needed?(device_id, config_text) do {:ok, needs_backup?} = MikrotikBackups.needs_backup?(device_id, config_text) needs_backup? diff --git a/lib/towerops_web/components/layouts/root.html.heex b/lib/towerops_web/components/layouts/root.html.heex index eba68bf4..fb40a60f 100644 --- a/lib/towerops_web/components/layouts/root.html.heex +++ b/lib/towerops_web/components/layouts/root.html.heex @@ -4,16 +4,25 @@ - + - - + + - - - - + + + + diff --git a/lib/towerops_web/components/skeletons.ex b/lib/towerops_web/components/skeletons.ex index 7bf03a11..f0525ea2 100644 --- a/lib/towerops_web/components/skeletons.ex +++ b/lib/towerops_web/components/skeletons.ex @@ -11,7 +11,10 @@ defmodule ToweropsWeb.Components.Skeletons do def skeleton_card(assigns) do ~H""" -
All plans include team access, email alerts, and API access. - Need a custom plan for 500+ devices? Talk to us. + Need a custom plan for 500+ devices? Talk to us.
+
<%= if item.link do %> <.link navigate={item.link} class="hover:underline"> {item.summary} @@ -194,19 +217,31 @@ <% end %>
-+
{item.detail}
-@@ -95,12 +93,15 @@ <%= for group <- @grouped_alerts do %>
{alert.message}
@@ -243,11 +256,17 @@ <%!-- Timestamps --%>No results found.
++ No results found. +
<% end %> <%= for {category, items} <- @results do %> @@ -114,14 +124,20 @@ defmodule ToweropsWeb.Live.Components.GlobalSearchComponent do "flex items-center gap-3 rounded-lg px-3 py-2 text-sm cursor-pointer", if(global_idx == @selected_index, do: "bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300", - else: "text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5" + else: + "text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/5" ) ]} > - <.icon name={category_icon(category)} class="h-4 w-4 flex-shrink-0 opacity-60" /> + <.icon + name={category_icon(category)} + class="h-4 w-4 flex-shrink-0 opacity-60" + />{item.label}
-{item.sublabel}
++ {item.sublabel} +
- {if @uptime_percentage == 100.0, do: "100", else: :erlang.float_to_binary(@uptime_percentage, decimals: 1)}% + {if @uptime_percentage == 100.0, + do: "100", + else: :erlang.float_to_binary(@uptime_percentage, decimals: 1)}%
{@device_up}/{@device_count} devices up @@ -205,7 +206,10 @@ ]}> {format_mrr(@impact_summary.mrr_at_risk)}
-0} class="mt-1 text-sm text-red-600 dark:text-red-400"> +
0} + class="mt-1 text-sm text-red-600 dark:text-red-400" + > {format_number(@impact_summary.subscribers_affected)} subscribers affected
@@ -268,8 +272,7 @@- {Calendar.strftime(change.changed_at, "%b %d %H:%M")} · {change.change_size} lines · {Enum.join(Enum.take(change.sections_changed, 2), ", ")} + {Calendar.strftime(change.changed_at, "%b %d %H:%M")} · {change.change_size} lines · {Enum.join( + Enum.take(change.sections_changed, 2), + ", " + )}
Search for a customer by name, IP address, account ID, or device name to see their full network status at a glance. diff --git a/priv/repo/migrations/20260213235031_create_config_change_events.exs b/priv/repo/migrations/20260213235031_create_config_change_events.exs index 23cb867a..225ffe01 100644 --- a/priv/repo/migrations/20260213235031_create_config_change_events.exs +++ b/priv/repo/migrations/20260213235031_create_config_change_events.exs @@ -5,9 +5,16 @@ defmodule Towerops.Repo.Migrations.CreateConfigChangeEvents do create table(:config_change_events, primary_key: false) do add :id, :binary_id, primary_key: true add :device_id, references(:devices, type: :binary_id, on_delete: :delete_all), null: false - add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all), null: false - add :backup_before_id, references(:device_mikrotik_backups, type: :binary_id, on_delete: :nilify_all) - add :backup_after_id, references(:device_mikrotik_backups, type: :binary_id, on_delete: :nilify_all) + + add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all), + null: false + + add :backup_before_id, + references(:device_mikrotik_backups, type: :binary_id, on_delete: :nilify_all) + + add :backup_after_id, + references(:device_mikrotik_backups, type: :binary_id, on_delete: :nilify_all) + add :changed_at, :utc_datetime, null: false add :diff_summary, :text add :sections_changed, {:array, :string}, default: []