diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3498c83d..173daa95 120000 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1 +1 @@ -/nix/store/4xlkmdd947h5qlhj2rmbyavq08grkz27-pre-commit-config.json \ No newline at end of file +/nix/store/zx636v118rzzqzwafgrw2aybh7l0szrl-pre-commit-config.json \ No newline at end of file diff --git a/lib/towerops/gaiia.ex b/lib/towerops/gaiia.ex index 07ae732c..a8801bda 100644 --- a/lib/towerops/gaiia.ex +++ b/lib/towerops/gaiia.ex @@ -289,7 +289,9 @@ defmodule Towerops.Gaiia do mrr = BillingSubscription - |> join(:inner, [bs], a in Account, on: bs.account_gaiia_id == a.gaiia_id and bs.organization_id == a.organization_id) + |> join(:inner, [bs], a in Account, + on: bs.account_gaiia_id == a.gaiia_id and bs.organization_id == a.organization_id + ) |> where([bs, a], a.id in ^account_ids) |> where([bs], bs.status == "ACTIVE") |> Repo.aggregate(:sum, :mrr_amount) || Decimal.new(0) @@ -333,8 +335,6 @@ defmodule Towerops.Gaiia do Returns `%{subscriber_count: integer, mrr: Decimal.t(), devices: [%{device_id, name, count, mrr}]}`. """ def get_site_impact(site_id) do - alias Towerops.Devices.Device - device_ids = Device |> where(site_id: ^site_id) @@ -365,7 +365,9 @@ defmodule Towerops.Gaiia do Decimal.new(0) else BillingSubscription - |> join(:inner, [bs], a in Account, on: bs.account_gaiia_id == a.gaiia_id and bs.organization_id == a.organization_id) + |> join(:inner, [bs], a in Account, + on: bs.account_gaiia_id == a.gaiia_id and bs.organization_id == a.organization_id + ) |> where([bs, a], a.id in ^total_account_ids) |> where([bs], bs.status == "ACTIVE") |> Repo.aggregate(:sum, :mrr_amount) || Decimal.new(0) diff --git a/lib/towerops/gaiia/subscriber_matching.ex b/lib/towerops/gaiia/subscriber_matching.ex index 3b0bba57..2d98bb27 100644 --- a/lib/towerops/gaiia/subscriber_matching.ex +++ b/lib/towerops/gaiia/subscriber_matching.ex @@ -26,11 +26,11 @@ defmodule Towerops.Gaiia.SubscriberMatching do Deletes existing links and re-computes from ARP + inventory data. """ def refresh_device_links(organization_id) do - unless has_gaiia_integration?(organization_id) do + if has_gaiia_integration?(organization_id) do + do_refresh(organization_id) + else Logger.debug("Skipping subscriber matching for org #{organization_id} — no Gaiia integration") :ok - else - do_refresh(organization_id) end end @@ -42,10 +42,10 @@ defmodule Towerops.Gaiia.SubscriberMatching do device = Repo.get(Device, device_id) if device do - unless has_gaiia_integration?(device.organization_id) do - :ok - else + if has_gaiia_integration?(device.organization_id) do do_refresh_for_device(device) + else + :ok end end end @@ -101,13 +101,11 @@ defmodule Towerops.Gaiia.SubscriberMatching do # Deduplicate by {device_id, gaiia_account_id} — first match wins (highest priority) unique_links = - all_links - |> Enum.uniq_by(fn l -> {l.device_id, l.gaiia_account_id} end) + Enum.uniq_by(all_links, fn l -> {l.device_id, l.gaiia_account_id} end) # Transaction: delete old + insert new Repo.transaction(fn -> - from(d in DeviceSubscriberLink, where: d.organization_id == ^organization_id) - |> Repo.delete_all() + Repo.delete_all(from(d in DeviceSubscriberLink, where: d.organization_id == ^organization_id)) if unique_links != [] do Repo.insert_all(DeviceSubscriberLink, unique_links) @@ -128,62 +126,17 @@ defmodule Towerops.Gaiia.SubscriberMatching do now = DateTime.truncate(DateTime.utc_now(), :second) org_id = device.organization_id - items = load_assigned_items(org_id) - arp_entries = load_arp_entries_for_device(device.id) - wireless_clients = load_wireless_clients_for_device(device.id) - - arp_by_ip = Map.new(arp_entries, fn e -> {e.ip_address, e} end) - arp_by_mac = Map.new(arp_entries, fn e -> {normalize_mac(e.mac_address), e} end) - wc_by_mac = Map.new(wireless_clients, fn w -> {normalize_mac(w.mac_address), w} end) - wc_by_ip = wireless_clients |> Enum.filter(& &1.ip_address) |> Map.new(fn w -> {w.ip_address, w} end) - - # Resolve account IDs from gaiia_id + lookups = build_device_lookups(device) account_lookup = load_account_id_lookup(org_id) + items = load_assigned_items(org_id) links = - Enum.flat_map(items, fn item -> - cond do - # Wireless MAC match (highest confidence) - item.mac_address && Map.has_key?(wc_by_mac, normalize_mac(item.mac_address)) -> - case Map.get(account_lookup, item.assigned_account_gaiia_id) do - nil -> [] - account_id -> - [build_link(org_id, device.id, account_id, item, "wireless_mac", "high", nil, normalize_mac(item.mac_address), now)] - end - - # Wireless IP match (high confidence) - item.ip_address && Map.has_key?(wc_by_ip, item.ip_address) -> - case Map.get(account_lookup, item.assigned_account_gaiia_id) do - nil -> [] - account_id -> - [build_link(org_id, device.id, account_id, item, "wireless_ip", "high", item.ip_address, nil, now)] - end - - # ARP IP match (medium confidence) - item.ip_address && Map.has_key?(arp_by_ip, item.ip_address) -> - case Map.get(account_lookup, item.assigned_account_gaiia_id) do - nil -> [] - account_id -> - [build_link(org_id, device.id, account_id, item, "arp_ip", "medium", item.ip_address, nil, now)] - end - - # ARP MAC match (medium confidence) - item.mac_address && Map.has_key?(arp_by_mac, normalize_mac(item.mac_address)) -> - case Map.get(account_lookup, item.assigned_account_gaiia_id) do - nil -> [] - account_id -> - [build_link(org_id, device.id, account_id, item, "arp_mac", "medium", nil, normalize_mac(item.mac_address), now)] - end - - true -> - [] - end - end) + items + |> Enum.flat_map(&match_item_for_device(&1, device.id, org_id, lookups, account_lookup, now)) |> Enum.uniq_by(fn l -> {l.device_id, l.gaiia_account_id} end) Repo.transaction(fn -> - from(d in DeviceSubscriberLink, where: d.device_id == ^device.id) - |> Repo.delete_all() + Repo.delete_all(from(d in DeviceSubscriberLink, where: d.device_id == ^device.id)) if links != [] do Repo.insert_all(DeviceSubscriberLink, links) @@ -193,6 +146,79 @@ defmodule Towerops.Gaiia.SubscriberMatching do :ok end + defp build_device_lookups(device) do + arp_entries = load_arp_entries_for_device(device.id) + wireless_clients = load_wireless_clients_for_device(device.id) + + %{ + arp_by_ip: Map.new(arp_entries, fn e -> {e.ip_address, e} end), + arp_by_mac: Map.new(arp_entries, fn e -> {normalize_mac(e.mac_address), e} end), + wc_by_mac: Map.new(wireless_clients, fn w -> {normalize_mac(w.mac_address), w} end), + wc_by_ip: wireless_clients |> Enum.filter(& &1.ip_address) |> Map.new(fn w -> {w.ip_address, w} end) + } + end + + defp match_item_for_device(item, device_id, org_id, lookups, account_lookup, now) do + case detect_device_match(item, lookups) do + nil -> + [] + + {method, confidence, ip, mac} -> + maybe_build_link(account_lookup, item, %{ + org_id: org_id, + device_id: device_id, + method: method, + confidence: confidence, + ip: ip, + mac: mac, + now: now + }) + end + end + + defp detect_device_match(item, lookups) do + normalized_mac = normalize_mac(item.mac_address) + + match_wireless_mac(normalized_mac, lookups) || + match_wireless_ip(item, lookups) || + match_arp_ip(item, lookups) || + match_arp_mac(normalized_mac, lookups) + end + + defp match_wireless_mac(nil, _lookups), do: nil + + defp match_wireless_mac(mac, lookups) do + if Map.has_key?(lookups.wc_by_mac, mac), do: {"wireless_mac", "high", nil, mac} + end + + defp match_wireless_ip(item, lookups) do + if item.ip_address && Map.has_key?(lookups.wc_by_ip, item.ip_address), + do: {"wireless_ip", "high", item.ip_address, nil} + end + + defp match_arp_ip(item, lookups) do + if item.ip_address && Map.has_key?(lookups.arp_by_ip, item.ip_address), + do: {"arp_ip", "medium", item.ip_address, nil} + end + + defp match_arp_mac(nil, _lookups), do: nil + + defp match_arp_mac(mac, lookups) do + if Map.has_key?(lookups.arp_by_mac, mac), do: {"arp_mac", "medium", nil, mac} + end + + defp maybe_build_link(account_lookup, item, params) do + case resolve_account(item, account_lookup) do + nil -> [] + account_id -> [build_link(Map.merge(params, %{account_id: account_id, item: item}))] + end + end + + # Resolves the local account ID for an inventory item, returning nil if not found. + defp resolve_account(item, account_lookup) do + Map.get(account_lookup, item.assigned_account_gaiia_id) + end + defp load_assigned_items(organization_id) do InventoryItem |> where(organization_id: ^organization_id) @@ -239,17 +265,27 @@ defmodule Towerops.Gaiia.SubscriberMatching do {matched_ids, links} = Enum.reduce(items, {MapSet.new(), []}, fn item, {matched, acc} -> normalized = normalize_mac(item.mac_address) - if normalized && Map.has_key?(wc_by_mac, normalized) do - wc = Map.get(wc_by_mac, normalized) - case Map.get(account_lookup, item.assigned_account_gaiia_id) do - nil -> - {matched, acc} - account_id -> - link = build_link(org_id, wc.device_id, account_id, item, "wireless_mac", "high", nil, normalized, now) - {MapSet.put(matched, item.id), [link | acc]} - end - else - {matched, acc} + wc = if normalized, do: Map.get(wc_by_mac, normalized) + + case resolve_match(wc, item, account_lookup) do + nil -> + {matched, acc} + + account_id -> + link = + build_link(%{ + org_id: org_id, + device_id: wc.device_id, + account_id: account_id, + item: item, + method: "wireless_mac", + confidence: "high", + ip: nil, + mac: normalized, + now: now + }) + + {MapSet.put(matched, item.id), [link | acc]} end end) @@ -261,17 +297,27 @@ defmodule Towerops.Gaiia.SubscriberMatching do {matched_ids, links} = Enum.reduce(items, {MapSet.new(), []}, fn item, {matched, acc} -> - if item.ip_address && Map.has_key?(wc_by_ip, item.ip_address) do - wc = Map.get(wc_by_ip, item.ip_address) - case Map.get(account_lookup, item.assigned_account_gaiia_id) do - nil -> - {matched, acc} - account_id -> - link = build_link(org_id, wc.device_id, account_id, item, "wireless_ip", "high", item.ip_address, nil, now) - {MapSet.put(matched, item.id), [link | acc]} - end - else - {matched, acc} + wc = if item.ip_address, do: Map.get(wc_by_ip, item.ip_address) + + case resolve_match(wc, item, account_lookup) do + nil -> + {matched, acc} + + account_id -> + link = + build_link(%{ + org_id: org_id, + device_id: wc.device_id, + account_id: account_id, + item: item, + method: "wireless_ip", + confidence: "high", + ip: item.ip_address, + mac: nil, + now: now + }) + + {MapSet.put(matched, item.id), [link | acc]} end end) @@ -283,17 +329,27 @@ defmodule Towerops.Gaiia.SubscriberMatching do {matched_ids, links} = Enum.reduce(items, {MapSet.new(), []}, fn item, {matched, acc} -> - if item.ip_address && Map.has_key?(arp_by_ip, item.ip_address) do - arp = Map.get(arp_by_ip, item.ip_address) - case Map.get(account_lookup, item.assigned_account_gaiia_id) do - nil -> - {matched, acc} - account_id -> - link = build_link(org_id, arp.device_id, account_id, item, "arp_ip", "medium", item.ip_address, nil, now) - {MapSet.put(matched, item.id), [link | acc]} - end - else - {matched, acc} + arp = if item.ip_address, do: Map.get(arp_by_ip, item.ip_address) + + case resolve_match(arp, item, account_lookup) do + nil -> + {matched, acc} + + account_id -> + link = + build_link(%{ + org_id: org_id, + device_id: arp.device_id, + account_id: account_id, + item: item, + method: "arp_ip", + confidence: "medium", + ip: item.ip_address, + mac: nil, + now: now + }) + + {MapSet.put(matched, item.id), [link | acc]} end end) @@ -306,17 +362,27 @@ defmodule Towerops.Gaiia.SubscriberMatching do {matched_ids, links} = Enum.reduce(items, {MapSet.new(), []}, fn item, {matched, acc} -> normalized = normalize_mac(item.mac_address) - if normalized && Map.has_key?(arp_by_mac, normalized) do - arp = Map.get(arp_by_mac, normalized) - case Map.get(account_lookup, item.assigned_account_gaiia_id) do - nil -> - {matched, acc} - account_id -> - link = build_link(org_id, arp.device_id, account_id, item, "arp_mac", "medium", nil, normalized, now) - {MapSet.put(matched, item.id), [link | acc]} - end - else - {matched, acc} + arp = if normalized, do: Map.get(arp_by_mac, normalized) + + case resolve_match(arp, item, account_lookup) do + nil -> + {matched, acc} + + account_id -> + link = + build_link(%{ + org_id: org_id, + device_id: arp.device_id, + account_id: account_id, + item: item, + method: "arp_mac", + confidence: "medium", + ip: nil, + mac: normalized, + now: now + }) + + {MapSet.put(matched, item.id), [link | acc]} end end) @@ -349,28 +415,50 @@ defmodule Towerops.Gaiia.SubscriberMatching do site_id when not is_nil(site_id) <- Map.get(site_mappings, gaiia_site_id), device_ids when device_ids != nil <- Map.get(devices_by_site, site_id), account_id when not is_nil(account_id) <- Map.get(account_lookup, item.assigned_account_gaiia_id) do - Enum.map(device_ids, fn device_id -> - build_link(org_id, device_id, account_id, item, "site_fallback", "low", nil, nil, now) - end) + build_site_links(device_ids, org_id, account_id, item, now) else _ -> [] end end) end - defp build_link(org_id, device_id, account_id, item, method, confidence, ip, mac, now) do + defp build_site_links(device_ids, org_id, account_id, item, now) do + Enum.map(device_ids, fn device_id -> + build_link(%{ + org_id: org_id, + device_id: device_id, + account_id: account_id, + item: item, + method: "site_fallback", + confidence: "low", + ip: nil, + mac: nil, + now: now + }) + end) + end + + # Returns the account_id if the record and account exist, nil otherwise. + # Used by match_by_* functions to flatten the if/case nesting. + defp resolve_match(nil, _item, _account_lookup), do: nil + + defp resolve_match(_record, item, account_lookup) do + Map.get(account_lookup, item.assigned_account_gaiia_id) + end + + defp build_link(%{} = params) do %{ id: Ecto.UUID.generate(), - organization_id: org_id, - device_id: device_id, - gaiia_account_id: account_id, - gaiia_inventory_item_id: item.id, - match_method: method, - confidence: confidence, - subscriber_ip: ip, - subscriber_mac: mac, - inserted_at: now, - updated_at: now + organization_id: params.org_id, + device_id: params.device_id, + gaiia_account_id: params.account_id, + gaiia_inventory_item_id: params.item.id, + match_method: params.method, + confidence: params.confidence, + subscriber_ip: params.ip, + subscriber_mac: params.mac, + inserted_at: params.now, + updated_at: params.now } end @@ -383,6 +471,7 @@ defmodule Towerops.Gaiia.SubscriberMatching do |> case do <> -> "#{a}:#{b}:#{c}:#{d}:#{e}:#{f}" + _ -> nil end diff --git a/lib/towerops/gaiia/sync.ex b/lib/towerops/gaiia/sync.ex index 6b5d44ba..593ebb88 100644 --- a/lib/towerops/gaiia/sync.ex +++ b/lib/towerops/gaiia/sync.ex @@ -9,6 +9,7 @@ defmodule Towerops.Gaiia.Sync do alias Towerops.Gaiia alias Towerops.Gaiia.Client + alias Towerops.Gaiia.SubscriberMatching alias Towerops.Integrations require Logger @@ -39,7 +40,7 @@ defmodule Towerops.Gaiia.Sync do Integrations.update_sync_status(integration, "success", message) persist_billing_totals(integration) Gaiia.SiteAggregation.compute_and_store(org_id) - Towerops.Gaiia.SubscriberMatching.refresh_device_links(org_id) + SubscriberMatching.refresh_device_links(org_id) {:ok, %{ diff --git a/lib/towerops/maintenance.ex b/lib/towerops/maintenance.ex index f411b1a9..7ee25f34 100644 --- a/lib/towerops/maintenance.ex +++ b/lib/towerops/maintenance.ex @@ -118,8 +118,7 @@ defmodule Towerops.Maintenance do site_results = Repo.all(site_query) org_results = Repo.all(org_wide) - (direct_results ++ site_results ++ org_results) - |> Enum.uniq_by(& &1.id) + Enum.uniq_by(direct_results ++ site_results ++ org_results, & &1.id) end @doc """ diff --git a/lib/towerops/organizations/policy.ex b/lib/towerops/organizations/policy.ex index 1fb795a2..210ba5da 100644 --- a/lib/towerops/organizations/policy.ex +++ b/lib/towerops/organizations/policy.ex @@ -72,16 +72,13 @@ defmodule Towerops.Organizations.Policy do defp check_permission(:technician, action, :integration) when action in [:create, :edit, :delete], do: false defp check_permission(:technician, action, resource) - when action in [:view, :list, :create, :edit] and - resource in [:site, :device, :alert], - do: true + when action in [:view, :list, :create, :edit] and resource in [:site, :device, :alert], do: true defp check_permission(:technician, :acknowledge, :alert), do: true defp check_permission(:technician, _action, _resource), do: false # ── Member (legacy, same as technician) ──────────────────────────────── - defp check_permission(:member, action, resource), - do: check_permission(:technician, action, resource) + defp check_permission(:member, action, resource), do: check_permission(:technician, action, resource) # ── Viewer (read-only, no financials) ────────────────────────────────── defp check_permission(:viewer, action, _resource) when action in [:view, :list], do: true diff --git a/lib/towerops/sites.ex b/lib/towerops/sites.ex index eb1c140c..215325bc 100644 --- a/lib/towerops/sites.ex +++ b/lib/towerops/sites.ex @@ -16,11 +16,7 @@ defmodule Towerops.Sites do Used by the weather sync worker to fetch weather data. """ def list_sites_with_coordinates do - from(s in Site, - where: not is_nil(s.latitude) and not is_nil(s.longitude), - select: s - ) - |> Repo.all() + Repo.all(from(s in Site, where: not is_nil(s.latitude) and not is_nil(s.longitude), select: s)) end @doc """ diff --git a/lib/towerops/snmp.ex b/lib/towerops/snmp.ex index 52d89d56..fbdc8e87 100644 --- a/lib/towerops/snmp.ex +++ b/lib/towerops/snmp.ex @@ -13,7 +13,6 @@ defmodule Towerops.Snmp do alias Towerops.Snmp.Client alias Towerops.Snmp.Device alias Towerops.Snmp.Discovery - alias Towerops.Snmp.WirelessClient alias Towerops.Snmp.Interface alias Towerops.Snmp.InterfaceStat alias Towerops.Snmp.IpAddress @@ -27,6 +26,7 @@ defmodule Towerops.Snmp do alias Towerops.Snmp.Storage alias Towerops.Snmp.StorageReading alias Towerops.Snmp.Vlan + alias Towerops.Snmp.WirelessClient require Logger @@ -2327,8 +2327,7 @@ defmodule Towerops.Snmp do Returns `{success_count, error_count}`. """ def upsert_wireless_clients(device_id, organization_id, clients) do - clients - |> Enum.reduce({0, 0}, fn client, {s, e} -> + Enum.reduce(clients, {0, 0}, fn client, {s, e} -> attrs = client |> Map.put(:device_id, device_id) diff --git a/lib/towerops/snmp/wireless_client_discovery.ex b/lib/towerops/snmp/wireless_client_discovery.ex index f5bfb1a2..1cbf253f 100644 --- a/lib/towerops/snmp/wireless_client_discovery.ex +++ b/lib/towerops/snmp/wireless_client_discovery.ex @@ -30,10 +30,17 @@ defmodule Towerops.Snmp.WirelessClientDiscovery do @spec discover_wireless_clients(Client.connection_opts(), String.t()) :: {:ok, [map()]} def discover_wireless_clients(client_opts, vendor_profile) do cond do - vendor_profile in ["epmp", "cambium", "pmp"] -> discover_epmp_clients(client_opts) - vendor_profile in ["airos", "airfiber", "airos-af", "airos-af60", "airos-af-ltu"] -> discover_ubnt_clients(client_opts) - vendor_profile in ["routeros", "mikrotik"] -> discover_mikrotik_clients(client_opts) - true -> {:ok, []} + vendor_profile in ["epmp", "cambium", "pmp"] -> + discover_epmp_clients(client_opts) + + vendor_profile in ["airos", "airfiber", "airos-af", "airos-af60", "airos-af-ltu"] -> + discover_ubnt_clients(client_opts) + + vendor_profile in ["routeros", "mikrotik"] -> + discover_mikrotik_clients(client_opts) + + true -> + {:ok, []} end end @@ -45,12 +52,24 @@ defmodule Towerops.Snmp.WirelessClientDiscovery do sys_oid = snmp_device.sys_object_id || "" manufacturer = String.downcase(snmp_device.manufacturer || "") - cond do - String.starts_with?(sys_oid, "1.3.6.1.4.1.17713") or String.contains?(manufacturer, "cambium") -> "epmp" - String.starts_with?(sys_oid, "1.3.6.1.4.1.41112") or String.contains?(manufacturer, "ubiquiti") -> "airos" - String.starts_with?(sys_oid, "1.3.6.1.4.1.14988") or String.contains?(manufacturer, "mikrotik") -> "routeros" - true -> nil - end + detect_cambium(sys_oid, manufacturer) || + detect_ubiquiti(sys_oid, manufacturer) || + detect_mikrotik(sys_oid, manufacturer) + end + + defp detect_cambium(sys_oid, manufacturer) do + if String.starts_with?(sys_oid, "1.3.6.1.4.1.17713") or String.contains?(manufacturer, "cambium"), + do: "epmp" + end + + defp detect_ubiquiti(sys_oid, manufacturer) do + if String.starts_with?(sys_oid, "1.3.6.1.4.1.41112") or String.contains?(manufacturer, "ubiquiti"), + do: "airos" + end + + defp detect_mikrotik(sys_oid, manufacturer) do + if String.starts_with?(sys_oid, "1.3.6.1.4.1.14988") or String.contains?(manufacturer, "mikrotik"), + do: "routeros" end # --- Cambium ePMP --- @@ -75,15 +94,17 @@ defmodule Towerops.Snmp.WirelessClientDiscovery do |> Enum.map(fn {idx, mac_raw} -> %{ mac_address: format_mac(mac_raw), - ip_address: get_indexed_value(ips, "#{@epmp_base}.10", idx) |> format_ip(), - signal_strength: best_value( - get_indexed_value(dl_rssi, "#{@epmp_base}.5", idx), - get_indexed_value(ul_rssi, "#{@epmp_base}.4", idx) - ), - snr: best_value( - get_indexed_value(dl_snr, "#{@epmp_base}.7", idx), - get_indexed_value(ul_snr, "#{@epmp_base}.6", idx) - ), + ip_address: ips |> get_indexed_value("#{@epmp_base}.10", idx) |> format_ip(), + signal_strength: + best_value( + get_indexed_value(dl_rssi, "#{@epmp_base}.5", idx), + get_indexed_value(ul_rssi, "#{@epmp_base}.4", idx) + ), + snr: + best_value( + get_indexed_value(dl_snr, "#{@epmp_base}.7", idx), + get_indexed_value(ul_snr, "#{@epmp_base}.6", idx) + ), distance: get_indexed_int(distances, "#{@epmp_base}.29", idx), tx_rate: nil, rx_rate: nil, @@ -125,7 +146,7 @@ defmodule Towerops.Snmp.WirelessClientDiscovery do |> Enum.map(fn {idx, mac_raw} -> %{ mac_address: format_mac(mac_raw), - ip_address: get_indexed_value(ips, "#{@ubnt_base}.10", idx) |> format_ip(), + ip_address: ips |> get_indexed_value("#{@ubnt_base}.10", idx) |> format_ip(), hostname: get_indexed_string(names, "#{@ubnt_base}.2", idx), signal_strength: get_indexed_int(signals, "#{@ubnt_base}.3", idx), snr: nil, @@ -255,15 +276,13 @@ defmodule Towerops.Snmp.WirelessClientDiscovery do defp format_ip(value) when is_binary(value) do if String.match?(value, ~r/^\d+\.\d+\.\d+\.\d+$/) do value - else - nil end end defp format_ip(_), do: nil # Pick the best (non-nil) of two values, preferring the first - defp best_value(nil, b), do: if(is_integer(b), do: b, else: nil) + defp best_value(nil, b), do: if(is_integer(b), do: b) defp best_value(a, _) when is_integer(a), do: a defp best_value(_, b) when is_integer(b), do: b defp best_value(_, _), do: nil diff --git a/lib/towerops/weather.ex b/lib/towerops/weather.ex index 2e2e1fa2..25e0f133 100644 --- a/lib/towerops/weather.ex +++ b/lib/towerops/weather.ex @@ -82,9 +82,7 @@ defmodule Towerops.Weather do def cleanup_old_observations(keep_days \\ 30) do cutoff = DateTime.add(DateTime.utc_now(), -keep_days, :day) - {count, _} = - from(o in Observation, where: o.observed_at < ^cutoff) - |> Repo.delete_all() + {count, _} = Repo.delete_all(from(o in Observation, where: o.observed_at < ^cutoff)) Logger.info("Cleaned up #{count} old weather observations") {:ok, count} @@ -120,14 +118,15 @@ defmodule Towerops.Weather do def active_alerts_for_org(organization_id) do now = DateTime.utc_now() - from(a in WeatherAlert, - join: s in assoc(a, :site), - where: s.organization_id == ^organization_id, - where: is_nil(a.ends_at) or a.ends_at > ^now, - order_by: [desc: a.inserted_at], - preload: [:site] + Repo.all( + from(a in WeatherAlert, + join: s in assoc(a, :site), + where: s.organization_id == ^organization_id, + where: is_nil(a.ends_at) or a.ends_at > ^now, + order_by: [desc: a.inserted_at], + preload: [:site] + ) ) - |> Repo.all() end # ── Fetching ───────────────────────────────────────────────────────── @@ -200,14 +199,8 @@ defmodule Towerops.Weather do - Snow > 5mm/h — ice loading on equipment """ def severe?(%Observation{} = obs) do - cond do - (obs.wind_speed_ms || 0) > 15 -> true - (obs.wind_gust_ms || 0) > 20 -> true - (obs.rain_1h_mm || 0) > 10 -> true - (obs.snow_1h_mm || 0) > 5 -> true - obs.condition in ["Thunderstorm"] -> true - true -> false - end + high_wind?(obs) or strong_gust?(obs) or heavy_rain?(obs) or + heavy_snow?(obs) or thunderstorm?(obs) end def severe?(_), do: false @@ -218,18 +211,30 @@ defmodule Towerops.Weather do """ def severity(%Observation{} = obs) do cond do - (obs.wind_gust_ms || 0) > 25 -> :severe - obs.condition in ["Thunderstorm"] -> :severe - (obs.rain_1h_mm || 0) > 20 -> :severe - (obs.wind_speed_ms || 0) > 15 -> :moderate - (obs.wind_gust_ms || 0) > 20 -> :moderate - (obs.rain_1h_mm || 0) > 10 -> :moderate - (obs.snow_1h_mm || 0) > 5 -> :moderate - obs.condition in ["Rain", "Drizzle", "Snow"] -> :mild - (obs.clouds_pct || 0) > 80 -> :mild + severe_severity?(obs) -> :severe + moderate_severity?(obs) -> :moderate + mild_severity?(obs) -> :mild true -> :clear end end def severity(_), do: :clear + + defp high_wind?(obs), do: (obs.wind_speed_ms || 0) > 15 + defp strong_gust?(obs), do: (obs.wind_gust_ms || 0) > 20 + defp heavy_rain?(obs), do: (obs.rain_1h_mm || 0) > 10 + defp heavy_snow?(obs), do: (obs.snow_1h_mm || 0) > 5 + defp thunderstorm?(obs), do: obs.condition in ["Thunderstorm"] + + defp severe_severity?(obs) do + (obs.wind_gust_ms || 0) > 25 or thunderstorm?(obs) or (obs.rain_1h_mm || 0) > 20 + end + + defp moderate_severity?(obs) do + high_wind?(obs) or strong_gust?(obs) or heavy_rain?(obs) or heavy_snow?(obs) + end + + defp mild_severity?(obs) do + obs.condition in ["Rain", "Drizzle", "Snow"] or (obs.clouds_pct || 0) > 80 + end end diff --git a/lib/towerops/weather/client.ex b/lib/towerops/weather/client.ex index cfc02ede..deb4b8dd 100644 --- a/lib/towerops/weather/client.ex +++ b/lib/towerops/weather/client.ex @@ -23,16 +23,7 @@ defmodule Towerops.Weather.Client do else url = "#{@base_url}/weather" - req_opts = - [ - url: url, - params: [ - lat: lat, - lon: lon, - appid: api_key - ] - ] - |> maybe_add_test_plug(opts) + req_opts = maybe_add_test_plug([url: url, params: [lat: lat, lon: lon, appid: api_key]], opts) case Req.get(req_opts) do {:ok, %Req.Response{status: 200, body: body}} -> diff --git a/lib/towerops/weather/observation.ex b/lib/towerops/weather/observation.ex index e95a53f9..db26b16c 100644 --- a/lib/towerops/weather/observation.ex +++ b/lib/towerops/weather/observation.ex @@ -73,35 +73,50 @@ defmodule Towerops.Weather.Observation do def from_owm(site_id, %{} = data) do weather = List.first(data["weather"] || []) || %{} main = data["main"] || %{} - wind = data["wind"] || %{} - rain = data["rain"] || %{} - snow = data["snow"] || %{} - clouds = data["clouds"] || %{} - - observed_at = - case data["dt"] do - ts when is_integer(ts) -> DateTime.from_unix!(ts) - _ -> DateTime.utc_now(:second) - end %{ site_id: site_id, condition: weather["main"], condition_detail: weather["description"], icon: weather["icon"], + visibility_m: data["visibility"], + raw: data, + observed_at: parse_observed_at(data["dt"]) + } + |> Map.merge(parse_main(main)) + |> Map.merge(parse_wind(data["wind"] || %{})) + |> Map.merge(parse_precipitation(data)) + end + + defp parse_observed_at(ts) when is_integer(ts), do: DateTime.from_unix!(ts) + defp parse_observed_at(_), do: DateTime.utc_now(:second) + + defp parse_main(main) do + %{ temperature_c: kelvin_to_celsius(main["temp"]), feels_like_c: kelvin_to_celsius(main["feels_like"]), humidity: main["humidity"], - pressure_hpa: main["pressure"], - visibility_m: data["visibility"], + pressure_hpa: main["pressure"] + } + end + + defp parse_wind(wind) do + %{ wind_speed_ms: wind["speed"], wind_gust_ms: wind["gust"], - wind_deg: wind["deg"], + wind_deg: wind["deg"] + } + end + + defp parse_precipitation(data) do + rain = data["rain"] || %{} + snow = data["snow"] || %{} + clouds = data["clouds"] || %{} + + %{ rain_1h_mm: rain["1h"], snow_1h_mm: snow["1h"], - clouds_pct: clouds["all"], - raw: data, - observed_at: observed_at + clouds_pct: clouds["all"] } end diff --git a/lib/towerops/workers/device_monitor_worker.ex b/lib/towerops/workers/device_monitor_worker.ex index 5422b813..01b0b41e 100644 --- a/lib/towerops/workers/device_monitor_worker.ex +++ b/lib/towerops/workers/device_monitor_worker.ex @@ -8,10 +8,15 @@ defmodule Towerops.Workers.DeviceMonitorWorker do """ use Oban.Worker, queue: :monitors, + max_attempts: 1, unique: [ - period: 60, + period: :infinity, keys: [:device_id], - states: [:available, :scheduled, :executing, :retryable] + states: [:available, :scheduled, :retryable] + ], + replace: [ + scheduled: [:scheduled_at], + available: [:scheduled_at] ] alias Towerops.Agents @@ -209,27 +214,28 @@ defmodule Towerops.Workers.DeviceMonitorWorker do defp create_device_down_alert(device, now) do if Maintenance.device_in_maintenance?(device.id) do require Logger + Logger.info("Device #{device.id} (#{device.name}) alert suppressed by maintenance window (safety net)") :ok else - alert_message = get_down_alert_message(device) + alert_message = get_down_alert_message(device) - {:ok, alert} = - Alerts.create_alert(%{ - device_id: device.id, - alert_type: :device_down, - triggered_at: now, - message: alert_message - }) + {:ok, alert} = + Alerts.create_alert(%{ + device_id: device.id, + alert_type: :device_down, + triggered_at: now, + message: alert_message + }) - Task.start(fn -> Notifier.notify_trigger(alert, device) end) + Task.start(fn -> Notifier.notify_trigger(alert, device) end) - _ = - Phoenix.PubSub.broadcast( - Towerops.PubSub, - "alerts:org:#{device.organization_id}:new", - {:new_alert, device.id, :device_down} - ) + _ = + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "alerts:org:#{device.organization_id}:new", + {:new_alert, device.id, :device_down} + ) end end diff --git a/lib/towerops/workers/device_poller_worker.ex b/lib/towerops/workers/device_poller_worker.ex index 93eb566e..e05a4970 100644 --- a/lib/towerops/workers/device_poller_worker.ex +++ b/lib/towerops/workers/device_poller_worker.ex @@ -14,14 +14,20 @@ defmodule Towerops.Workers.DevicePollerWorker do """ use Oban.Worker, queue: :pollers, + max_attempts: 1, unique: [ - period: 60, + period: :infinity, keys: [:device_id], - states: [:available, :scheduled, :executing, :retryable] + states: [:available, :scheduled, :retryable] + ], + replace: [ + scheduled: [:scheduled_at], + available: [:scheduled_at] ] alias Towerops.Agents alias Towerops.Devices + alias Towerops.Gaiia.SubscriberMatching alias Towerops.JobMonitoring.Events alias Towerops.Snmp alias Towerops.Snmp.ArpDiscovery @@ -361,7 +367,7 @@ defmodule Towerops.Workers.DevicePollerWorker do ) # Refresh subscriber-to-device links based on new ARP data - Towerops.Gaiia.SubscriberMatching.refresh_links_for_device(device.id) + SubscriberMatching.refresh_links_for_device(device.id) rescue error -> Logger.error("Error polling ARP table for #{device.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}") @@ -406,12 +412,14 @@ defmodule Towerops.Workers.DevicePollerWorker do ) # Refresh subscriber-to-device links with new wireless client data - Towerops.Gaiia.SubscriberMatching.refresh_links_for_device(device.id) + SubscriberMatching.refresh_links_for_device(device.id) end end rescue error -> - Logger.error("Error polling wireless clients for #{device.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}") + Logger.error( + "Error polling wireless clients for #{device.name}: #{inspect(error)}\n#{Exception.format_stacktrace()}" + ) end defp poll_device_processors(device, snmp_device, client_opts, now) do diff --git a/lib/towerops/workers/weather_sync_worker.ex b/lib/towerops/workers/weather_sync_worker.ex index 783f7abe..ea128d15 100644 --- a/lib/towerops/workers/weather_sync_worker.ex +++ b/lib/towerops/workers/weather_sync_worker.ex @@ -37,29 +37,32 @@ defmodule Towerops.Workers.WeatherSyncWorker do if total > 0 do Logger.info("Weather sync: fetching weather for #{total} sites") - - results = - sites - |> Enum.with_index(1) - |> Enum.map(fn {site, idx} -> - # Throttle: ~1 req/sec to stay under 60/min limit - if idx > 1, do: Process.sleep(1_100) - - case Weather.fetch_and_store(site) do - {:ok, _obs} -> - :ok - - {:error, reason} -> - Logger.warning("Weather fetch failed for site #{site.name}: #{inspect(reason)}") - :error - end - end) - + results = fetch_all_sites(sites) ok_count = Enum.count(results, &(&1 == :ok)) Logger.info("Weather sync complete: #{ok_count}/#{total} sites updated") end end + defp fetch_all_sites(sites) do + sites + |> Enum.with_index(1) + |> Enum.map(fn {site, idx} -> + if idx > 1, do: Process.sleep(1_100) + fetch_site_weather(site) + end) + end + + defp fetch_site_weather(site) do + case Weather.fetch_and_store(site) do + {:ok, _obs} -> + :ok + + {:error, reason} -> + Logger.warning("Weather fetch failed for site #{site.name}: #{inspect(reason)}") + :error + end + end + defp schedule_next do # Schedule next run in 15 minutes %{} |> new(schedule_in: 900) |> Oban.insert() diff --git a/lib/towerops_web/components/consent_prompt.ex b/lib/towerops_web/components/consent_prompt.ex index 0fb05279..29c8a7eb 100644 --- a/lib/towerops_web/components/consent_prompt.ex +++ b/lib/towerops_web/components/consent_prompt.ex @@ -25,7 +25,8 @@ defmodule ToweropsWeb.Components.ConsentPrompt do >
-
+
+
diff --git a/lib/towerops_web/live/check_live/form_component.ex b/lib/towerops_web/live/check_live/form_component.ex index 4f22e081..febb31bc 100644 --- a/lib/towerops_web/live/check_live/form_component.ex +++ b/lib/towerops_web/live/check_live/form_component.ex @@ -8,7 +8,8 @@ defmodule ToweropsWeb.CheckLive.FormComponent do def render(assigns) do ~H"""
-
+
+
diff --git a/lib/towerops_web/live/dashboard_live.html.heex b/lib/towerops_web/live/dashboard_live.html.heex index 92f5bc66..948ddbc4 100644 --- a/lib/towerops_web/live/dashboard_live.html.heex +++ b/lib/towerops_web/live/dashboard_live.html.heex @@ -11,7 +11,9 @@

{@current_scope.organization.name}

- NOC + + NOC +
<%= if assigns[:last_updated] do %>
@@ -282,7 +284,10 @@ :for={incident <- Enum.take(@active_incidents, 10)} class={[ "group", - if(@can_view_financials, do: incident_severity_classes(incident.mrr_at_risk), else: "") + if(@can_view_financials, + do: incident_severity_classes(incident.mrr_at_risk), + else: "" + ) ]} > @@ -754,7 +759,10 @@ @@ -1807,7 +1814,9 @@ {t("Primary")} <% else %> - - + + - + <% end %> @@ -2463,14 +2472,18 @@ <%= if check.last_check_at do %> {get_latest_value(check)} <% else %> - Pending + + Pending + <% end %> <%= if check.last_check_at do %> {format_relative_time(check.last_check_at)} <% else %> - Never + + Never + <% end %> diff --git a/lib/towerops_web/live/help_live/index.ex b/lib/towerops_web/live/help_live/index.ex index ff3c139d..87eac4af 100644 --- a/lib/towerops_web/live/help_live/index.ex +++ b/lib/towerops_web/live/help_live/index.ex @@ -253,21 +253,21 @@ defmodule ToweropsWeb.HelpLive.Index do <%= if false do %> -
  • - <.link - patch={~p"/help?section=mikrotik"} - class={[ - "block px-3 py-2 rounded-md text-sm font-medium transition-colors", - if @active_section == "mikrotik" do - "bg-blue-50 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300" - else - "text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800" - end - ]} - > - MikroTik - -
  • +
  • + <.link + patch={~p"/help?section=mikrotik"} + class={[ + "block px-3 py-2 rounded-md text-sm font-medium transition-colors", + if @active_section == "mikrotik" do + "bg-blue-50 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300" + else + "text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800" + end + ]} + > + MikroTik + +
  • <% end %>
  • <.link diff --git a/lib/towerops_web/live/maintenance_live/index.ex b/lib/towerops_web/live/maintenance_live/index.ex index 8c31a17b..a9e5e107 100644 --- a/lib/towerops_web/live/maintenance_live/index.ex +++ b/lib/towerops_web/live/maintenance_live/index.ex @@ -1,4 +1,5 @@ defmodule ToweropsWeb.MaintenanceLive.Index do + @moduledoc false use ToweropsWeb, :live_view alias Towerops.Maintenance @@ -43,8 +44,8 @@ defmodule ToweropsWeb.MaintenanceLive.Index do now = DateTime.utc_now() cond do - DateTime.compare(window.starts_at, now) == :gt -> :upcoming - DateTime.compare(window.ends_at, now) == :lt -> :past + DateTime.after?(window.starts_at, now) -> :upcoming + DateTime.before?(window.ends_at, now) -> :past true -> :active end end diff --git a/lib/towerops_web/live/maintenance_live/index.html.heex b/lib/towerops_web/live/maintenance_live/index.html.heex index 34331381..6d6bc869 100644 --- a/lib/towerops_web/live/maintenance_live/index.html.heex +++ b/lib/towerops_web/live/maintenance_live/index.html.heex @@ -21,7 +21,8 @@ class={[ "inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium transition-colors", @filter == "all" && "bg-gray-900 text-white dark:bg-white dark:text-gray-900", - @filter != "all" && "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700" + @filter != "all" && + "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700" ]} > {t("All")} @@ -31,7 +32,8 @@ class={[ "inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium transition-colors", @filter == "active" && "bg-gray-900 text-white dark:bg-white dark:text-gray-900", - @filter != "active" && "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700" + @filter != "active" && + "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700" ]} > {t("Active")} @@ -41,7 +43,8 @@ class={[ "inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium transition-colors", @filter == "upcoming" && "bg-gray-900 text-white dark:bg-white dark:text-gray-900", - @filter != "upcoming" && "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700" + @filter != "upcoming" && + "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700" ]} > {t("Upcoming")} @@ -51,7 +54,8 @@ class={[ "inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium transition-colors", @filter == "past" && "bg-gray-900 text-white dark:bg-white dark:text-gray-900", - @filter != "past" && "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700" + @filter != "past" && + "bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700" ]} > {t("Past")} @@ -61,8 +65,13 @@ <%= if Enum.empty?(@windows) do %>
    - <.icon name="hero-wrench-screwdriver" class="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-4" /> -

    {t("No maintenance windows")}

    + <.icon + name="hero-wrench-screwdriver" + class="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-4" + /> +

    + {t("No maintenance windows")} +

    {t("Schedule maintenance to suppress alerts during planned work.")}

    @@ -73,11 +82,21 @@ - - - - - + + + + + @@ -93,15 +112,21 @@ {scope_label(window)}
    {t("Name")}{t("Scope")}{t("Time Range")}{t("Status")}{t("Created By")} + {t("Name")} + + {t("Scope")} + + {t("Time Range")} + + {t("Status")} + + {t("Created By")} +
    - {format_datetime(window.starts_at, @timezone)} — {format_datetime(window.ends_at, @timezone)} + {format_datetime(window.starts_at, @timezone)} — {format_datetime( + window.ends_at, + @timezone + )} <% status = status_for(window) %> {status |> to_string() |> String.capitalize()} diff --git a/lib/towerops_web/live/maintenance_live/show.ex b/lib/towerops_web/live/maintenance_live/show.ex index 59b869bc..3a914987 100644 --- a/lib/towerops_web/live/maintenance_live/show.ex +++ b/lib/towerops_web/live/maintenance_live/show.ex @@ -1,4 +1,5 @@ defmodule ToweropsWeb.MaintenanceLive.Show do + @moduledoc false use ToweropsWeb, :live_view alias Towerops.Maintenance @@ -33,8 +34,8 @@ defmodule ToweropsWeb.MaintenanceLive.Show do now = DateTime.utc_now() cond do - DateTime.compare(window.starts_at, now) == :gt -> :upcoming - DateTime.compare(window.ends_at, now) == :lt -> :past + DateTime.after?(window.starts_at, now) -> :upcoming + DateTime.before?(window.ends_at, now) -> :past true -> :active end end diff --git a/lib/towerops_web/live/maintenance_live/show.html.heex b/lib/towerops_web/live/maintenance_live/show.html.heex index 22491e9e..b8cf950b 100644 --- a/lib/towerops_web/live/maintenance_live/show.html.heex +++ b/lib/towerops_web/live/maintenance_live/show.html.heex @@ -21,8 +21,12 @@
    <.icon name="hero-wrench-screwdriver" class="h-6 w-6 text-green-600 dark:text-green-400" />
    -

    {t("Currently Active")}

    -

    {t("This maintenance window is currently in effect. Alerts are being suppressed.")}

    +

    + {t("Currently Active")} +

    +

    + {t("This maintenance window is currently in effect. Alerts are being suppressed.")} +

    @@ -33,7 +37,8 @@

    {@window.name}

    @@ -64,28 +69,41 @@ <%!-- Reason --%> <%= if @window.reason do %>
    -
    {t("Reason")}
    -
    {@window.reason}
    +
    + {t("Reason")} +
    +
    + {@window.reason} +
    <% end %> <%!-- Scope --%>
    -
    {t("Scope")}
    +
    + {t("Scope")} +
    {scope_label(@window)}
    <%!-- Time Range --%>
    -
    {t("Time Range")}
    +
    + {t("Time Range")} +
    - {format_datetime(@window.starts_at, @timezone)} — {format_datetime(@window.ends_at, @timezone)} + {format_datetime(@window.starts_at, @timezone)} — {format_datetime( + @window.ends_at, + @timezone + )}
    <%!-- Recurring --%>
    -
    {t("Recurring")}
    +
    + {t("Recurring")} +
    <%= if @window.recurring do %> {t("Yes")} — {@window.recurrence_rule || t("No rule specified")} @@ -97,7 +115,9 @@ <%!-- Suppress Alerts --%>
    -
    {t("Suppress Alerts")}
    +
    + {t("Suppress Alerts")} +
    <%= if @window.suppress_alerts do %> @@ -113,7 +133,9 @@ <%!-- Created By --%>
    -
    {t("Created By")}
    +
    + {t("Created By")} +
    {if @window.created_by, do: @window.created_by.email, else: "—"}
    diff --git a/lib/towerops_web/live/onboarding_live.ex b/lib/towerops_web/live/onboarding_live.ex index 26909c1c..8d107068 100644 --- a/lib/towerops_web/live/onboarding_live.ex +++ b/lib/towerops_web/live/onboarding_live.ex @@ -7,6 +7,7 @@ defmodule ToweropsWeb.OnboardingLive do alias Towerops.Integrations.Integration alias Towerops.Organizations alias Towerops.Sites + alias Towerops.Sites.Site @steps [:snmp, :site, :billing, :agent] @@ -22,7 +23,7 @@ defmodule ToweropsWeb.OnboardingLive do organization = socket.assigns.current_scope.organization changeset = Organizations.change_organization(organization) - site_changeset = Sites.change_site(%Towerops.Sites.Site{}, %{}) + site_changeset = Sites.change_site(%Site{}, %{}) {:ok, socket @@ -76,7 +77,7 @@ defmodule ToweropsWeb.OnboardingLive do @impl true def handle_event("validate_site", %{"site" => params}, socket) do changeset = - %Towerops.Sites.Site{} + %Site{} |> Sites.change_site(params) |> Map.put(:action, :validate) @@ -91,7 +92,7 @@ defmodule ToweropsWeb.OnboardingLive do case Sites.create_site(attrs) do {:ok, _site} -> # Enable sites on the org if not already - unless org.use_sites do + if !org.use_sites do Organizations.update_organization(org, %{use_sites: true}) end @@ -134,7 +135,7 @@ defmodule ToweropsWeb.OnboardingLive do def handle_event("save_integration", %{"integration" => params}, socket) do org = socket.assigns.organization provider = socket.assigns.selected_provider - attrs = normalize_integration_params(params, provider) |> Map.put(:provider, provider) |> Map.put(:enabled, true) + attrs = params |> normalize_integration_params(provider) |> Map.put(:provider, provider) |> Map.put(:enabled, true) case Integrations.create_integration(org.id, attrs) do {:ok, _integration} -> @@ -231,18 +232,22 @@ defmodule ToweropsWeb.OnboardingLive do end defp normalize_integration_params(params, "sonar") do - %{credentials: %{ - "instance_url" => String.trim(Map.get(params, "instance_url", "")), - "api_token" => Map.get(params, "api_token", "") - }} + %{ + credentials: %{ + "instance_url" => String.trim(Map.get(params, "instance_url", "")), + "api_token" => Map.get(params, "api_token", "") + } + } end defp normalize_integration_params(params, "splynx") do - %{credentials: %{ - "instance_url" => String.trim(Map.get(params, "instance_url", "")), - "api_key" => Map.get(params, "api_key", ""), - "api_secret" => Map.get(params, "api_secret", "") - }} + %{ + credentials: %{ + "instance_url" => String.trim(Map.get(params, "instance_url", "")), + "api_key" => Map.get(params, "api_key", ""), + "api_secret" => Map.get(params, "api_secret", "") + } + } end defp normalize_integration_params(params, _provider) do diff --git a/lib/towerops_web/live/onboarding_live.html.heex b/lib/towerops_web/live/onboarding_live.html.heex index d9df6bee..c9bc8340 100644 --- a/lib/towerops_web/live/onboarding_live.html.heex +++ b/lib/towerops_web/live/onboarding_live.html.heex @@ -15,10 +15,12 @@ "flex items-center gap-1.5 text-sm font-medium px-2 py-1 rounded transition", if(step == @step, do: "text-indigo-600 dark:text-indigo-400 bg-indigo-50 dark:bg-indigo-900/30", - else: if(step_index(step) < step_index(@step), - do: "text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white", - else: "text-gray-400 dark:text-gray-500" - ) + else: + if(step_index(step) < step_index(@step), + do: + "text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white", + else: "text-gray-400 dark:text-gray-500" + ) ) ]} > @@ -26,10 +28,11 @@ "flex items-center justify-center w-5 h-5 rounded-full text-xs font-bold", if(step == @step, do: "bg-indigo-600 text-white dark:bg-indigo-500", - else: if(step_index(step) < step_index(@step), - do: "bg-gray-400 dark:bg-gray-500 text-white", - else: "bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400" - ) + else: + if(step_index(step) < step_index(@step), + do: "bg-gray-400 dark:bg-gray-500 text-white", + else: "bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400" + ) ) ]}> <%= if step_index(step) < step_index(@step) do %> @@ -41,7 +44,10 @@ {step_label(step)} <%= if step != List.last(@steps) do %> - <.icon name="hero-chevron-right-mini" class="h-4 w-4 text-gray-300 dark:text-gray-600" /> + <.icon + name="hero-chevron-right-mini" + class="h-4 w-4 text-gray-300 dark:text-gray-600" + /> <% end %> <% end %> @@ -126,7 +132,6 @@
    - <% :site -> %>

    {t("Add Your First Site")} @@ -137,11 +142,34 @@ <.form for={@site_form} phx-change="validate_site" phx-submit="save_site">
    - <.input field={@site_form[:name]} type="text" label={t("Name")} placeholder="e.g. Main Tower" required /> - <.input field={@site_form[:address]} type="text" label={t("Address")} placeholder="123 Tower Rd" /> + <.input + field={@site_form[:name]} + type="text" + label={t("Name")} + placeholder="e.g. Main Tower" + required + /> + <.input + field={@site_form[:address]} + type="text" + label={t("Address")} + placeholder="123 Tower Rd" + />
    - <.input field={@site_form[:latitude]} type="number" label={t("Latitude")} step="any" placeholder="38.8977" /> - <.input field={@site_form[:longitude]} type="number" label={t("Longitude")} step="any" placeholder="-77.0365" /> + <.input + field={@site_form[:latitude]} + type="number" + label={t("Latitude")} + step="any" + placeholder="38.8977" + /> + <.input + field={@site_form[:longitude]} + type="number" + label={t("Longitude")} + step="any" + placeholder="-77.0365" + />
    @@ -161,13 +189,14 @@
    - <% :billing -> %>

    {t("Connect a Billing Platform")}

    - {t("Optional. Sync subscriber data for outage impact analysis and inventory reconciliation.")} + {t( + "Optional. Sync subscriber data for outage impact analysis and inventory reconciliation." + )}

    <%= if @selected_provider == nil do %> @@ -180,7 +209,9 @@ class="flex items-center gap-3 p-4 rounded-lg border border-gray-200 dark:border-white/10 hover:border-indigo-400 dark:hover:border-indigo-500 hover:bg-gray-50 dark:hover:bg-white/5 transition text-left" > <.icon name={provider.icon} class="h-6 w-6 text-gray-400 dark:text-gray-500" /> - {provider.name} + + {provider.name} + <% end %> @@ -196,18 +227,49 @@ - <.form for={@integration_form} phx-change="validate_integration" phx-submit="save_integration"> + <.form + for={@integration_form} + phx-change="validate_integration" + phx-submit="save_integration" + >
    <%= case @selected_provider do %> <% "sonar" -> %> - <.input name="integration[instance_url]" type="text" label={t("Instance URL")} placeholder="https://myisp.sonar.software" value="" /> - <.input name="integration[api_token]" type="password" label={t("API Token")} value="" /> + <.input + name="integration[instance_url]" + type="text" + label={t("Instance URL")} + placeholder="https://myisp.sonar.software" + value="" + /> + <.input + name="integration[api_token]" + type="password" + label={t("API Token")} + value="" + /> <% "splynx" -> %> - <.input name="integration[instance_url]" type="text" label={t("Instance URL")} placeholder="https://myisp.splynx.app" value="" /> + <.input + name="integration[instance_url]" + type="text" + label={t("Instance URL")} + placeholder="https://myisp.splynx.app" + value="" + /> <.input name="integration[api_key]" type="text" label={t("API Key")} value="" /> - <.input name="integration[api_secret]" type="password" label={t("API Secret")} value="" /> + <.input + name="integration[api_secret]" + type="password" + label={t("API Secret")} + value="" + /> <% _ -> %> - <.input name="integration[api_key]" type="password" label={t("API Key")} value="" /> + <.input + name="integration[api_key]" + type="password" + label={t("API Key")} + value="" + /> <% end %>
    @@ -240,13 +302,14 @@
    <% end %> - <% :agent -> %>

    {t("Deploy Agent")}

    - {t("The TowerOps agent polls your network devices via SNMP. Deploy it on a host with network access to your infrastructure.")} + {t( + "The TowerOps agent polls your network devices via SNMP. Deploy it on a host with network access to your infrastructure." + )}

    <%= if @agent_token do %> @@ -264,13 +327,18 @@ <% else %>

    - {t("Agent:")} {@agent_token.name} + {t("Agent:")} + + {@agent_token.name} +

    <% end %>
    -

    {t("Docker")}

    +

    + {t("Docker")} +

    docker run -d \
       --name towerops-agent \
       --restart unless-stopped \
    @@ -281,7 +349,9 @@
                   
    -

    {t("Systemd")}

    +

    + {t("Systemd")} +

    curl -sSL https://install.towerops.net | \
       TOWEROPS_TOKEN=<%= if @agent_token_value, do: @agent_token_value, else: "" %> \
       TOWEROPS_SERVER=<%= ToweropsWeb.Endpoint.url() %> \
    diff --git a/lib/towerops_web/live/org/integrations_live.html.heex b/lib/towerops_web/live/org/integrations_live.html.heex
    index 6deb7ca0..cbd402aa 100644
    --- a/lib/towerops_web/live/org/integrations_live.html.heex
    +++ b/lib/towerops_web/live/org/integrations_live.html.heex
    @@ -39,8 +39,7 @@
             
    - <.icon name="hero-arrow-path" class="h-3.5 w-3.5" /> - Choose one + <.icon name="hero-arrow-path" class="h-3.5 w-3.5" /> Choose one — only one billing platform can be active at a time diff --git a/lib/towerops_web/live/org/settings_live.html.heex b/lib/towerops_web/live/org/settings_live.html.heex index 2db1ea94..c04c683a 100644 --- a/lib/towerops_web/live/org/settings_live.html.heex +++ b/lib/towerops_web/live/org/settings_live.html.heex @@ -45,16 +45,18 @@ <%= if @current_scope.user.is_superuser do %> <%= if false do %> -
  • - <.link - patch={~p"/orgs/#{@organization.slug}/settings?tab=mikrotik"} - class={ - if @active_tab == "mikrotik", do: "text-indigo-600 dark:text-indigo-400", else: "" - } - > - {t("MikroTik")} - -
  • +
  • + <.link + patch={~p"/orgs/#{@organization.slug}/settings?tab=mikrotik"} + class={ + if @active_tab == "mikrotik", + do: "text-indigo-600 dark:text-indigo-400", + else: "" + } + > + {t("MikroTik")} + +
  • <% end %> <% end %>
  • @@ -625,7 +627,12 @@ field={@invite_form[:role]} type="select" label={t("Role")} - options={[{"Admin", "admin"}, {"Executive", "executive"}, {"Technician", "technician"}, {"Viewer", "viewer"}]} + options={[ + {"Admin", "admin"}, + {"Executive", "executive"}, + {"Technician", "technician"}, + {"Viewer", "viewer"} + ]} />
  • <% end %> -
    @@ -874,672 +883,290 @@ true -> "border-gray-200 dark:border-white/10" end - ]} - > - <%!-- Left status accent bar --%> -
    - "bg-red-500" + ]}> + <%!-- Left status accent bar --%> +
    + "bg-red-500" - @integrations[provider.id] && @integrations[provider.id].enabled -> - "bg-green-500" + @integrations[provider.id] && @integrations[provider.id].enabled -> + "bg-green-500" - @integrations[provider.id] -> - "bg-gray-300 dark:bg-gray-600" + @integrations[provider.id] -> + "bg-gray-300 dark:bg-gray-600" - true -> - "bg-gray-200 dark:bg-gray-700" - end - ]} /> + true -> + "bg-gray-200 dark:bg-gray-700" + end + ]} /> -
    - <%!-- === Header Row === --%> -
    -
    -
    "bg-blue-100 dark:bg-blue-900/40" - "gaiia" -> "bg-purple-100 dark:bg-purple-900/40" - "pagerduty" -> "bg-emerald-100 dark:bg-emerald-900/40" - "netbox" -> "bg-orange-100 dark:bg-orange-900/40" - "sonar" -> "bg-violet-100 dark:bg-violet-900/40" - "splynx" -> "bg-blue-100 dark:bg-blue-900/40" - "visp" -> "bg-green-100 dark:bg-green-900/40" - _ -> "bg-indigo-100 dark:bg-indigo-900/40" - end - ]}> - <.icon - name={provider.icon} - class={[ - "h-5 w-5", - case provider.id do - "preseem" -> "text-blue-600 dark:text-blue-400" - "gaiia" -> "text-purple-600 dark:text-purple-400" - "pagerduty" -> "text-emerald-600 dark:text-emerald-400" - "netbox" -> "text-orange-600 dark:text-orange-400" - "sonar" -> "text-violet-600 dark:text-violet-400" - "splynx" -> "text-blue-600 dark:text-blue-400" - "visp" -> "text-green-600 dark:text-green-400" - _ -> "text-indigo-600 dark:text-indigo-400" - end - ]} - /> +
    + <%!-- === Header Row === --%> +
    +
    +
    "bg-blue-100 dark:bg-blue-900/40" + "gaiia" -> "bg-purple-100 dark:bg-purple-900/40" + "pagerduty" -> "bg-emerald-100 dark:bg-emerald-900/40" + "netbox" -> "bg-orange-100 dark:bg-orange-900/40" + "sonar" -> "bg-violet-100 dark:bg-violet-900/40" + "splynx" -> "bg-blue-100 dark:bg-blue-900/40" + "visp" -> "bg-green-100 dark:bg-green-900/40" + _ -> "bg-indigo-100 dark:bg-indigo-900/40" + end + ]}> + <.icon + name={provider.icon} + class={[ + "h-5 w-5", + case provider.id do + "preseem" -> "text-blue-600 dark:text-blue-400" + "gaiia" -> "text-purple-600 dark:text-purple-400" + "pagerduty" -> "text-emerald-600 dark:text-emerald-400" + "netbox" -> "text-orange-600 dark:text-orange-400" + "sonar" -> "text-violet-600 dark:text-violet-400" + "splynx" -> "text-blue-600 dark:text-blue-400" + "visp" -> "text-green-600 dark:text-green-400" + _ -> "text-indigo-600 dark:text-indigo-400" + end + ]} + /> +
    +
    +

    + {provider.name} +

    +

    + {provider.description} +

    +
    -
    -

    - {provider.name} -

    -

    - {provider.description} -

    + + <%!-- === Actions (top right) === --%> +
    + <%= if integration = @integrations[provider.id] do %> +
    + + {if integration.enabled, do: "Enabled", else: "Disabled"} + + +
    + <% end %> + +
    - <%!-- === Actions (top right) === --%> -
    - <%= if integration = @integrations[provider.id] do %> -
    - - {if integration.enabled, do: "Enabled", else: "Disabled"} - - +
    + + <%!-- Last synced --%> + <%= if integration.last_synced_at do %> +
    + <.icon name="hero-clock" class="h-3.5 w-3.5" /> + + {t("Synced")} + <.timestamp + datetime={integration.last_synced_at} + timezone={@timezone} + format="absolute" + /> + +
    + <% else %> + + {t("Never synced")} + + <% end %> + + <%!-- Sync status badge (success/partial) --%> + <%= if integration.last_sync_status && integration.last_sync_status not in ["never", "failed"] do %> + + "bg-green-50 text-green-700 ring-1 ring-inset ring-green-600/20 dark:bg-green-500/10 dark:text-green-400 dark:ring-green-500/20" + + "partial" -> + "bg-yellow-50 text-yellow-700 ring-1 ring-inset ring-yellow-600/20 dark:bg-yellow-500/10 dark:text-yellow-400 dark:ring-yellow-500/20" + + _ -> + "bg-gray-50 text-gray-600 ring-1 ring-inset ring-gray-500/10 dark:bg-gray-500/10 dark:text-gray-400 dark:ring-gray-500/20" + end + ]}> + <%= if integration.last_sync_status == "success" do %> + <.icon name="hero-check-circle-mini" class="h-3.5 w-3.5" /> + <% end %> + {String.capitalize(integration.last_sync_status)} + + <% end %> + + <%!-- Sync message (success/partial) --%> + <%= if integration.last_sync_message && integration.last_sync_message != "" && integration.last_sync_status != "failed" do %> + + {integration.last_sync_message} + + <% end %> + + <%!-- Next sync --%> + <%= if integration.last_synced_at && integration.enabled do %> + + Next in ~{next_sync_minutes( + integration.last_synced_at, + integration.sync_interval_minutes + )}m + + <% end %> + + <%!-- Sync schedule note --%> + <%= if integration.enabled && provider.id == "pagerduty" do %> + + · Event-driven (real-time) + + <% end %>
    <% end %> - -
    + <%!-- === Provider Links Row === --%> + <%= if provider.id == "preseem" || provider.id == "gaiia" do %> +
    + <%= if provider.id == "preseem" do %> + <.link + navigate={ + ~p"/orgs/#{@organization.slug}/settings/integrations/preseem/devices" + } + class="inline-flex items-center gap-1.5 rounded-md bg-gray-50 px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 transition-colors hover:bg-gray-100 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10" + > + <.icon name="hero-cpu-chip" class="h-3.5 w-3.5" /> Manage Devices + + <.link + navigate={~p"/insights?source=preseem"} + class="inline-flex items-center gap-1.5 rounded-md bg-gray-50 px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 transition-colors hover:bg-gray-100 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10" + > + <.icon name="hero-chart-bar" class="h-3.5 w-3.5" /> Network Insights + + <% end %> + <%= if provider.id == "gaiia" do %> + <.link + navigate={ + ~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/mapping" + } + class="inline-flex items-center gap-1.5 rounded-md bg-gray-50 px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 transition-colors hover:bg-gray-100 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10" + > + <.icon name="hero-arrows-right-left" class="h-3.5 w-3.5" /> Entity Mapping + + <.link + navigate={ + ~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/reconciliation" + } + class="inline-flex items-center gap-1.5 rounded-md bg-gray-50 px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 transition-colors hover:bg-gray-100 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10" + > + <.icon name="hero-scale" class="h-3.5 w-3.5" /> Reconciliation + + <% end %> +
    + <% end %> + <% end %>
    - <%!-- === Status Row (when configured) === --%> - <%= if integration = @integrations[provider.id] do %> - <%!-- Failed sync banner --%> - <%= if integration.last_sync_status == "failed" do %> -
    - <.icon - name="hero-exclamation-triangle" - class="h-5 w-5 shrink-0 text-red-500 dark:text-red-400 mt-0.5" - /> -
    -

    - {t("Last sync failed")} -

    - <%= if integration.last_sync_message && integration.last_sync_message != "" do %> -

    - {integration.last_sync_message} -

    - <% end %> - <%= if integration.last_synced_at do %> -

    - <.timestamp - datetime={integration.last_synced_at} - timezone={@timezone} - format="absolute" - /> -

    - <% end %> -
    -
    - <% else %> -
    - <%!-- Status dot + label --%> -
    - - - {if integration.enabled, do: "Connected", else: "Paused"} - -
    - - <%!-- Last synced --%> - <%= if integration.last_synced_at do %> -
    - <.icon name="hero-clock" class="h-3.5 w-3.5" /> - - {t("Synced")} - <.timestamp - datetime={integration.last_synced_at} - timezone={@timezone} - format="absolute" - /> - -
    - <% else %> - - {t("Never synced")} - - <% end %> - - <%!-- Sync status badge (success/partial) --%> - <%= if integration.last_sync_status && integration.last_sync_status not in ["never", "failed"] do %> - - "bg-green-50 text-green-700 ring-1 ring-inset ring-green-600/20 dark:bg-green-500/10 dark:text-green-400 dark:ring-green-500/20" - - "partial" -> - "bg-yellow-50 text-yellow-700 ring-1 ring-inset ring-yellow-600/20 dark:bg-yellow-500/10 dark:text-yellow-400 dark:ring-yellow-500/20" - - _ -> - "bg-gray-50 text-gray-600 ring-1 ring-inset ring-gray-500/10 dark:bg-gray-500/10 dark:text-gray-400 dark:ring-gray-500/20" - end - ]}> - <%= if integration.last_sync_status == "success" do %> - <.icon name="hero-check-circle-mini" class="h-3.5 w-3.5" /> - <% end %> - {String.capitalize(integration.last_sync_status)} - - <% end %> - - <%!-- Sync message (success/partial) --%> - <%= if integration.last_sync_message && integration.last_sync_message != "" && integration.last_sync_status != "failed" do %> - - {integration.last_sync_message} - - <% end %> - - <%!-- Next sync --%> - <%= if integration.last_synced_at && integration.enabled do %> - - Next in ~{next_sync_minutes( - integration.last_synced_at, - integration.sync_interval_minutes - )}m - - <% end %> - - <%!-- Sync schedule note --%> - <%= if integration.enabled && provider.id == "pagerduty" do %> - - · Event-driven (real-time) - - <% end %> -
    - <% end %> - - <%!-- === Provider Links Row === --%> - <%= if provider.id == "preseem" || provider.id == "gaiia" do %> -
    - <%= if provider.id == "preseem" do %> - <.link - navigate={ - ~p"/orgs/#{@organization.slug}/settings/integrations/preseem/devices" - } - class="inline-flex items-center gap-1.5 rounded-md bg-gray-50 px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 transition-colors hover:bg-gray-100 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10" - > - <.icon name="hero-cpu-chip" class="h-3.5 w-3.5" /> Manage Devices - - <.link - navigate={~p"/insights?source=preseem"} - class="inline-flex items-center gap-1.5 rounded-md bg-gray-50 px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 transition-colors hover:bg-gray-100 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10" - > - <.icon name="hero-chart-bar" class="h-3.5 w-3.5" /> Network Insights - - <% end %> - <%= if provider.id == "gaiia" do %> - <.link - navigate={~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/mapping"} - class="inline-flex items-center gap-1.5 rounded-md bg-gray-50 px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 transition-colors hover:bg-gray-100 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10" - > - <.icon name="hero-arrows-right-left" class="h-3.5 w-3.5" /> Entity Mapping - - <.link - navigate={ - ~p"/orgs/#{@organization.slug}/settings/integrations/gaiia/reconciliation" - } - class="inline-flex items-center gap-1.5 rounded-md bg-gray-50 px-3 py-1.5 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-200 transition-colors hover:bg-gray-100 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10" - > - <.icon name="hero-scale" class="h-3.5 w-3.5" /> Reconciliation - - <% end %> -
    - <% end %> - <% end %> -
    - - <%!-- === Configure Panel === --%> - <%= if @configuring == provider.id do %> -
    - <%= if provider.id == "netbox" do %> - <.form - for={@integration_form} - id="netbox-form" - phx-change="validate_integration" - phx-submit="save_integration" - > -
    - <%!-- Connection Section --%> -
    -
    - <.icon name="hero-link" class="h-4 w-4 text-gray-400" /> -

    - {t("Connection")} -

    -
    -

    - {t( - "Your NetBox instance URL and API token. The token needs read permission at minimum." - )} - {t("Write permission is required if you want to push data to NetBox.")} -

    -
    -
    - - -
    -
    - - -
    -
    -
    - - <%!-- Sync Direction Section --%> -
    -
    - <.icon name="hero-arrows-right-left" class="h-4 w-4 text-gray-400" /> -

    - {t("Sync Direction")} -

    -
    -

    - {t( - "Choose how data flows between TowerOps and NetBox. You can change this later." - )} -

    - -
    -
    -
    - - <.icon name="hero-arrow-down-tray" class="h-4 w-4 text-indigo-600" /> - NetBox → TowerOps - - - {t( - "NetBox is the source of truth. Import devices, sites, and IPs from NetBox into TowerOps." - )} - -
    -
    -
    -
    - - <.icon name="hero-arrow-up-tray" class="h-4 w-4 text-orange-600" /> - TowerOps → NetBox - - - {t( - "TowerOps is the source of truth. Push discovered devices and monitoring data to NetBox." - )} - -
    -
    -
    -
    - - <.icon - name="hero-arrows-right-left" - class="h-4 w-4 text-green-600" - /> Bidirectional - - - {t( - "Merge data from both systems. Conflicts resolved by most-recently-updated. Requires write token." - )} - -
    -
    -
    -
    - - <%!-- What to Sync Section --%> -
    -
    - <.icon name="hero-circle-stack" class="h-4 w-4 text-gray-400" /> -

    - {t("What to Sync")} -

    -
    -

    - {t( - "Choose which NetBox objects participate in sync. Devices and sites are recommended at minimum." - )} -

    -
    - - - - -
    -
    - - <%!-- Filtering Section --%> -
    -
    - <.icon name="hero-funnel" class="h-4 w-4 text-gray-400" /> -

    - {t("Filters")} -

    - - (optional) - -
    -

    - {t( - "Narrow the sync scope. Leave blank to sync everything. Comma-separated for multiple values." - )} -

    -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    -
    - - <%!-- Sync Interval --%> -
    -
    - <.input - field={@integration_form[:sync_interval_minutes]} - type="number" - label={t("Sync interval (minutes)")} - min="5" - value={ - if(@integrations["netbox"], - do: @integrations["netbox"].sync_interval_minutes, - else: 30 - ) - } - /> -

    - {t( - "How often TowerOps checks NetBox for changes. 30 minutes is recommended for most setups." - )} -

    -
    -
    - - <%!-- Test & Save --%> - <%= if @test_result do %> -
    "bg-green-50 dark:bg-green-900/20" - {:error, _} -> "bg-red-50 dark:bg-red-900/20" - end - ]}> - <%= case @test_result do %> - <% {:ok, msg} -> %> - <.icon - name="hero-check-circle" - class="h-5 w-5 shrink-0 text-green-500 dark:text-green-400" - /> -

    - {msg} -

    - <% {:error, msg} -> %> - <.icon - name="hero-x-circle" - class="h-5 w-5 shrink-0 text-red-500 dark:text-red-400" - /> -

    {msg}

    - <% end %> -
    - <% end %> - -
    - - -
    -
    - - <% else %> - <%= if provider.id == "sonar" do %> + <%!-- === Configure Panel === --%> + <%= if @configuring == provider.id do %> +
    + <%= if provider.id == "netbox" do %> <.form for={@integration_form} - id="sonar-form" + id="netbox-form" phx-change="validate_integration" phx-submit="save_integration" >
    + <%!-- Connection Section --%>
    <.icon name="hero-link" class="h-4 w-4 text-gray-400" /> @@ -1548,18 +1175,21 @@

    - {t("Your Sonar instance URL and API token from the Sonar admin panel.")} + {t( + "Your NetBox instance URL and API token. The token needs read permission at minimum." + )} + {t("Write permission is required if you want to push data to NetBox.")}

    @@ -1571,8 +1201,8 @@ @@ -1580,7 +1210,271 @@
    -
    + <%!-- Sync Direction Section --%> +
    +
    + <.icon name="hero-arrows-right-left" class="h-4 w-4 text-gray-400" /> +

    + {t("Sync Direction")} +

    +
    +

    + {t( + "Choose how data flows between TowerOps and NetBox. You can change this later." + )} +

    + +
    +
    +
    + + <.icon name="hero-arrow-down-tray" class="h-4 w-4 text-indigo-600" /> + NetBox → TowerOps + + + {t( + "NetBox is the source of truth. Import devices, sites, and IPs from NetBox into TowerOps." + )} + +
    +
    +
    +
    + + <.icon name="hero-arrow-up-tray" class="h-4 w-4 text-orange-600" /> + TowerOps → NetBox + + + {t( + "TowerOps is the source of truth. Push discovered devices and monitoring data to NetBox." + )} + +
    +
    +
    +
    + + <.icon + name="hero-arrows-right-left" + class="h-4 w-4 text-green-600" + /> Bidirectional + + + {t( + "Merge data from both systems. Conflicts resolved by most-recently-updated. Requires write token." + )} + +
    +
    +
    +
    + + <%!-- What to Sync Section --%> +
    +
    + <.icon name="hero-circle-stack" class="h-4 w-4 text-gray-400" /> +

    + {t("What to Sync")} +

    +
    +

    + {t( + "Choose which NetBox objects participate in sync. Devices and sites are recommended at minimum." + )} +

    +
    + + + + +
    +
    + + <%!-- Filtering Section --%> +
    +
    + <.icon name="hero-funnel" class="h-4 w-4 text-gray-400" /> +

    + {t("Filters")} +

    + + (optional) + +
    +

    + {t( + "Narrow the sync scope. Leave blank to sync everything. Comma-separated for multiple values." + )} +

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + <%!-- Sync Interval --%> +
    <.input field={@integration_form[:sync_interval_minutes]} @@ -1588,15 +1482,21 @@ label={t("Sync interval (minutes)")} min="5" value={ - if(@integrations["sonar"], - do: @integrations["sonar"].sync_interval_minutes, - else: 10 + if(@integrations["netbox"], + do: @integrations["netbox"].sync_interval_minutes, + else: 30 ) } /> +

    + {t( + "How often TowerOps checks NetBox for changes. 30 minutes is recommended for most setups." + )} +

    + <%!-- Test & Save --%> <%= if @test_result do %>
    "bg-red-50 dark:bg-red-900/20" end ]}> -

    "text-green-800 dark:text-green-300" - {:error, _} -> "text-red-800 dark:text-red-300" - end - ]}> - {elem(@test_result, 1)} -

    + <%= case @test_result do %> + <% {:ok, msg} -> %> + <.icon + name="hero-check-circle" + class="h-5 w-5 shrink-0 text-green-500 dark:text-green-400" + /> +

    + {msg} +

    + <% {:error, msg} -> %> + <.icon + name="hero-x-circle" + class="h-5 w-5 shrink-0 text-red-500 dark:text-red-400" + /> +

    + {msg} +

    + <% end %>
    <% end %> @@ -1637,10 +1546,10 @@
    <% else %> - <%= if provider.id == "splynx" do %> + <%= if provider.id == "sonar" do %> <.form for={@integration_form} - id="splynx-form" + id="sonar-form" phx-change="validate_integration" phx-submit="save_integration" > @@ -1653,11 +1562,9 @@

    - {t( - "Your Splynx instance URL, API key, and API secret. Find these in Splynx under Administration → API Keys." - )} + {t("Your Sonar instance URL and API token from the Sonar admin panel.")}

    -
    +
    -
    -
    - - -
    -
    - - -
    +
    + +
    @@ -1710,8 +1602,8 @@ label={t("Sync interval (minutes)")} min="5" value={ - if(@integrations["splynx"], - do: @integrations["splynx"].sync_interval_minutes, + if(@integrations["sonar"], + do: @integrations["sonar"].sync_interval_minutes, else: 10 ) } @@ -1759,139 +1651,72 @@
    <% else %> - <.form - for={@integration_form} - id={"#{provider.id}-form"} - phx-change="validate_integration" - phx-submit="save_integration" - > -
    -
    -
    - <.icon name="hero-key" class="h-4 w-4 text-gray-400" /> -

    - {t("Connection Settings")} -

    -
    - + <%= if provider.id == "splynx" do %> + <.form + for={@integration_form} + id="splynx-form" + phx-change="validate_integration" + phx-submit="save_integration" + > +
    - - -
    -
    - - <%= if provider.id == "pagerduty" do %> -
    -

    - <.icon name="hero-information-circle" class="h-4 w-4" /> - {t("Where to find your Integration Key")} -

    -
      -
    1. - In PagerDuty, go to {t("Services")} - → select your service (or create one) -
    2. -
    3. Click the {t("Integrations")} tab
    4. -
    5. - Click {t("Add Integration")} - → select {t("Events API v2")} -
    6. -
    7. - Copy the {t("Integration Key")} and paste it above -
    8. -
    -

    - {t( - "When connected, TowerOps will automatically trigger, acknowledge, and resolve PagerDuty incidents in sync with your alerts." - )} -

    -
    - -
    -
    - <.icon name="hero-arrow-path" class="h-4 w-4 text-gray-400" /> +
    + <.icon name="hero-link" class="h-4 w-4 text-gray-400" />

    - {t("Two-Way Sync (Webhooks)")} + {t("Connection")}

    -

    +

    {t( - "When someone resolves or acknowledges an incident directly in PagerDuty," + "Your Splynx instance URL, API key, and API secret. Find these in Splynx under Administration → API Keys." )} - {t("TowerOps will automatically update the corresponding alert.")}

    -
    - -
    - - {ToweropsWeb.Endpoint.url()}/api/v1/webhooks/pagerduty/{@current_scope.organization.id} - +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    - <.input - name="integration[webhook_secret]" - type="password" - label={t("Webhook Signing Secret")} - value={ - if(@integrations[provider.id], - do: - get_in(@integrations[provider.id].credentials, [ - "webhook_secret" - ]) || - "", - else: "" - ) - } - placeholder={t("Optional — paste from PagerDuty webhook extension")} - /> -
    -
      -
    1. - In PagerDuty, go to {t("Integrations")} - → Generic Webhooks (v3) -
    2. -
    3. - {t("Add a subscription with the Webhook URL above")} -
    4. -
    5. - Select events: incident.resolved - and incident.acknowledged -
    6. -
    7. - Copy the {t("Signing Secret")} and paste it above -
    8. -
    -
    - <% end %> - <%= if provider.id != "pagerduty" do %>
    -
    - <.icon name="hero-clock" class="h-4 w-4 text-gray-400" /> -

    - {t("Sync Settings")} -

    -
    <.input field={@integration_form[:sync_interval_minutes]} @@ -1899,54 +1724,43 @@ label={t("Sync interval (minutes)")} min="5" value={ - if(@integrations[provider.id], - do: @integrations[provider.id].sync_interval_minutes, - else: if(provider.id == "gaiia", do: 15, else: 10) + if(@integrations["splynx"], + do: @integrations["splynx"].sync_interval_minutes, + else: 10 ) } />
    - <% end %> - <%= if @test_result do %> -
    "bg-green-50 dark:bg-green-900/20" - {:error, _} -> "bg-red-50 dark:bg-red-900/20" - end - ]}> -

    +

    "text-green-800 dark:text-green-300" - {:error, _} -> "text-red-800 dark:text-red-300" + {:ok, _} -> "bg-green-50 dark:bg-green-900/20" + {:error, _} -> "bg-red-50 dark:bg-red-900/20" end ]}> - {elem(@test_result, 1)} -

    -
    - <% end %> +

    "text-green-800 dark:text-green-300" + {:error, _} -> "text-red-800 dark:text-red-300" + end + ]}> + {elem(@test_result, 1)} +

    +
    + <% end %> -
    - - -
    +
    -
    - + + <% else %> + <.form + for={@integration_form} + id={"#{provider.id}-form"} + phx-change="validate_integration" + phx-submit="save_integration" + > +
    +
    +
    + <.icon name="hero-key" class="h-4 w-4 text-gray-400" /> +

    + {t("Connection Settings")} +

    +
    + +
    + + +
    +
    + + <%= if provider.id == "pagerduty" do %> +
    +

    + <.icon name="hero-information-circle" class="h-4 w-4" /> + {t("Where to find your Integration Key")} +

    +
      +
    1. + In PagerDuty, go to {t("Services")} + → select your service (or create one) +
    2. +
    3. Click the {t("Integrations")} tab
    4. +
    5. + Click {t("Add Integration")} + → select {t("Events API v2")} +
    6. +
    7. + Copy the {t("Integration Key")} + and paste it above +
    8. +
    +

    + {t( + "When connected, TowerOps will automatically trigger, acknowledge, and resolve PagerDuty incidents in sync with your alerts." + )} +

    +
    + +
    +
    + <.icon name="hero-arrow-path" class="h-4 w-4 text-gray-400" /> +

    + {t("Two-Way Sync (Webhooks)")} +

    +
    +

    + {t( + "When someone resolves or acknowledges an incident directly in PagerDuty," + )} + {t("TowerOps will automatically update the corresponding alert.")} +

    +
    + +
    + + {ToweropsWeb.Endpoint.url()}/api/v1/webhooks/pagerduty/{@current_scope.organization.id} + +
    +
    + <.input + name="integration[webhook_secret]" + type="password" + label={t("Webhook Signing Secret")} + value={ + if(@integrations[provider.id], + do: + get_in(@integrations[provider.id].credentials, [ + "webhook_secret" + ]) || + "", + else: "" + ) + } + placeholder={t("Optional — paste from PagerDuty webhook extension")} + /> +
    +
      +
    1. + In PagerDuty, go to {t("Integrations")} + → Generic Webhooks (v3) +
    2. +
    3. + {t("Add a subscription with the Webhook URL above")} +
    4. +
    5. + Select events: incident.resolved + and incident.acknowledged +
    6. +
    7. + Copy the {t("Signing Secret")} + and paste it above +
    8. +
    +
    +
    + <% end %> + + <%= if provider.id != "pagerduty" do %> +
    +
    + <.icon name="hero-clock" class="h-4 w-4 text-gray-400" /> +

    + {t("Sync Settings")} +

    +
    +
    + <.input + field={@integration_form[:sync_interval_minutes]} + type="number" + label={t("Sync interval (minutes)")} + min="5" + value={ + if(@integrations[provider.id], + do: @integrations[provider.id].sync_interval_minutes, + else: if(provider.id == "gaiia", do: 15, else: 10) + ) + } + /> +
    +
    + <% end %> + + <%= if @test_result do %> +
    "bg-green-50 dark:bg-green-900/20" + {:error, _} -> "bg-red-50 dark:bg-red-900/20" + end + ]}> +

    "text-green-800 dark:text-green-300" + {:error, _} -> "text-red-800 dark:text-red-300" + end + ]}> + {elem(@test_result, 1)} +

    +
    + <% end %> + +
    + + +
    + + +
    +
    +
    + + <% end %> <% end %> <% end %> - <% end %> - <%!-- Gaiia Webhook Section --%> - <%= if provider.id == "gaiia" && @integrations["gaiia"] do %> -
    -
    - <.icon name="hero-globe-alt" class="h-4 w-4 text-gray-400" /> -

    - {t("Webhook Configuration")} -

    -
    -

    - {t( - "Receive real-time updates from Gaiia when accounts, subscriptions, or inventory items change." - )} -

    + <%!-- Gaiia Webhook Section --%> + <%= if provider.id == "gaiia" && @integrations["gaiia"] do %> +
    +
    + <.icon name="hero-globe-alt" class="h-4 w-4 text-gray-400" /> +

    + {t("Webhook Configuration")} +

    +
    +

    + {t( + "Receive real-time updates from Gaiia when accounts, subscriptions, or inventory items change." + )} +

    -
    -
    - -
    - - +
    +
    + +
    + + +
    +
    + +
    + +

    + {t("Paste the secret key generated by Gaiia when you create the webhook.")} +

    +
    + +
    +
    + +
    +
    + <.icon name="hero-information-circle" class="h-4 w-4" /> + Setup Instructions +
    +
      +
    1. In your Gaiia admin panel, go to Settings → Webhooks
    2. +
    3. Click "Add Webhook"
    4. +
    5. Paste the Webhook URL above
    6. +
    7. Under Events, select "All Events"
    8. +
    9. Gaiia will generate a secret key — copy it
    10. +
    11. Paste the secret key into the Webhook Secret field above
    12. +
    13. Save the webhook
    14. +
    +

    + {t( + "Once configured, Towerops will receive real-time updates when accounts, subscriptions, or inventory items change in Gaiia." + )} +

    - -
    - -

    - {t("Paste the secret key generated by Gaiia when you create the webhook.")} -

    -
    - -
    -
    - -
    -
    - <.icon name="hero-information-circle" class="h-4 w-4" /> Setup Instructions -
    -
      -
    1. In your Gaiia admin panel, go to Settings → Webhooks
    2. -
    3. Click "Add Webhook"
    4. -
    5. Paste the Webhook URL above
    6. -
    7. Under Events, select "All Events"
    8. -
    9. Gaiia will generate a secret key — copy it
    10. -
    11. Paste the secret key into the Webhook Secret field above
    12. -
    13. Save the webhook
    14. -
    -

    - {t( - "Once configured, Towerops will receive real-time updates when accounts, subscriptions, or inventory items change in Gaiia." - )} -

    -
    -
    - <% end %> -
    - <% end %> -
    + <% end %> +
    + <% end %> +
    <% end %>
    <% end %> diff --git a/lib/towerops_web/live/trace_live/index.html.heex b/lib/towerops_web/live/trace_live/index.html.heex index 5b81d2c4..3e148b06 100644 --- a/lib/towerops_web/live/trace_live/index.html.heex +++ b/lib/towerops_web/live/trace_live/index.html.heex @@ -216,7 +216,10 @@
    {@trace.access_point.name}
    -
    +
    {@trace.access_point.model}
    @@ -362,7 +365,9 @@ > {ap.name}
    - {ap.subscriber_count || 0} subs + + {ap.subscriber_count || 0} subs + alias Towerops.Organizations.Policy + scope = socket.assigns.current_scope {:cont, diff --git a/priv/repo/migrations/20260216155500_create_maintenance_windows.exs b/priv/repo/migrations/20260216155500_create_maintenance_windows.exs index d6939d28..550189a7 100644 --- a/priv/repo/migrations/20260216155500_create_maintenance_windows.exs +++ b/priv/repo/migrations/20260216155500_create_maintenance_windows.exs @@ -4,14 +4,20 @@ defmodule Towerops.Repo.Migrations.CreateMaintenanceWindows do def change do create table(:maintenance_windows, primary_key: false) do add :id, :binary_id, primary_key: true - add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all), null: false + + add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all), + null: false + add :site_id, references(:sites, type: :binary_id, on_delete: :delete_all) add :device_id, references(:devices, type: :binary_id, on_delete: :delete_all) add :name, :string, null: false add :reason, :string add :starts_at, :utc_datetime, null: false add :ends_at, :utc_datetime, null: false - add :created_by_id, references(:users, type: :binary_id, on_delete: :nilify_all), null: false + + add :created_by_id, references(:users, type: :binary_id, on_delete: :nilify_all), + null: false + add :recurring, :boolean, default: false add :recurrence_rule, :string add :suppress_alerts, :boolean, default: true @@ -25,6 +31,8 @@ defmodule Towerops.Repo.Migrations.CreateMaintenanceWindows do create index(:maintenance_windows, [:starts_at]) create index(:maintenance_windows, [:ends_at]) - create constraint(:maintenance_windows, :ends_at_after_starts_at, check: "ends_at > starts_at") + create constraint(:maintenance_windows, :ends_at_after_starts_at, + check: "ends_at > starts_at" + ) end end diff --git a/priv/repo/migrations/20260216170000_create_device_subscriber_links.exs b/priv/repo/migrations/20260216170000_create_device_subscriber_links.exs index 23989e1c..5f88b6df 100644 --- a/priv/repo/migrations/20260216170000_create_device_subscriber_links.exs +++ b/priv/repo/migrations/20260216170000_create_device_subscriber_links.exs @@ -4,10 +4,18 @@ defmodule Towerops.Repo.Migrations.CreateDeviceSubscriberLinks do def change do create table(:device_subscriber_links, primary_key: false) do add :id, :binary_id, primary_key: true - add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all), null: false + + add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all), + null: false + add :device_id, references(:devices, type: :binary_id, on_delete: :delete_all), null: false - add :gaiia_account_id, references(:gaiia_accounts, type: :binary_id, on_delete: :delete_all), null: false - add :gaiia_inventory_item_id, references(:gaiia_inventory_items, type: :binary_id, on_delete: :nilify_all) + + add :gaiia_account_id, + references(:gaiia_accounts, type: :binary_id, on_delete: :delete_all), null: false + + add :gaiia_inventory_item_id, + references(:gaiia_inventory_items, type: :binary_id, on_delete: :nilify_all) + add :match_method, :string, null: false add :confidence, :string, null: false add :subscriber_ip, :string diff --git a/priv/repo/migrations/20260216180000_create_wireless_clients.exs b/priv/repo/migrations/20260216180000_create_wireless_clients.exs index 08d76a87..bbd9e219 100644 --- a/priv/repo/migrations/20260216180000_create_wireless_clients.exs +++ b/priv/repo/migrations/20260216180000_create_wireless_clients.exs @@ -5,7 +5,10 @@ defmodule Towerops.Repo.Migrations.CreateWirelessClients do create table(:wireless_clients, 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 :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all), + null: false + add :mac_address, :string, null: false add :ip_address, :string add :hostname, :string diff --git a/test/towerops/splynx/sync_test.exs b/test/towerops/splynx/sync_test.exs index 2631d217..3c742d73 100644 --- a/test/towerops/splynx/sync_test.exs +++ b/test/towerops/splynx/sync_test.exs @@ -147,18 +147,19 @@ defmodule Towerops.Splynx.SyncTest do |> Req.Test.json(auth_success_response()) path -> - data = - cond do - String.contains?(path, "/customer") -> Map.get(routes, "customer", []) - String.contains?(path, "/routers") -> Map.get(routes, "routers", []) - true -> [] - end - - Req.Test.json(conn, data) + Req.Test.json(conn, route_data(path, routes)) end end) end + defp route_data(path, routes) do + cond do + String.contains?(path, "/customer") -> Map.get(routes, "customer", []) + String.contains?(path, "/routers") -> Map.get(routes, "routers", []) + true -> [] + end + end + defp auth_success_response do %{ "access_token" => "test_jwt_token", diff --git a/test/towerops/workers/device_monitor_worker_test.exs b/test/towerops/workers/device_monitor_worker_test.exs index 05c21e62..8f01b8f0 100644 --- a/test/towerops/workers/device_monitor_worker_test.exs +++ b/test/towerops/workers/device_monitor_worker_test.exs @@ -6,6 +6,7 @@ defmodule Towerops.Workers.DeviceMonitorWorkerTest do alias Towerops.Devices alias Towerops.Monitoring.PingMock alias Towerops.Organizations + alias Towerops.Repo alias Towerops.Sites alias Towerops.Workers.DeviceMonitorWorker alias Towerops.Workers.PollingOffset @@ -162,4 +163,124 @@ defmodule Towerops.Workers.DeviceMonitorWorkerTest do assert job.args["device_id"] == device.id end end + + describe "job uniqueness" do + test "only one job per device exists at a time", %{site: site} do + {:ok, device} = + Devices.create_device(%{ + name: "Unique Test", + ip_address: "192.168.1.200", + site_id: site.id, + organization_id: site.organization_id, + monitoring_enabled: true + }) + + # Clear jobs created by create_device + Repo.delete_all(Oban.Job) + + # Insert two jobs for the same device + {:ok, _job1} = DeviceMonitorWorker.start_monitoring(device.id) + {:ok, _job2} = DeviceMonitorWorker.start_monitoring(device.id) + + # Should only have one job in the database + job_count = + Oban.Job + |> Ecto.Query.where(worker: "Towerops.Workers.DeviceMonitorWorker") + |> Repo.aggregate(:count) + + assert job_count == 1 + end + + test "new job replaces scheduled_at of existing job", %{site: site} do + {:ok, device} = + Devices.create_device(%{ + name: "Replace Test", + ip_address: "192.168.1.201", + site_id: site.id, + organization_id: site.organization_id, + monitoring_enabled: true + }) + + # Clear jobs created by create_device + Repo.delete_all(Oban.Job) + + # Insert first job scheduled far in the future + {:ok, job1} = + %{device_id: device.id} + |> DeviceMonitorWorker.new(schedule_in: 3600) + |> Oban.insert() + + original_scheduled_at = job1.scheduled_at + + # Insert second job scheduled sooner + {:ok, _job2} = + %{device_id: device.id} + |> DeviceMonitorWorker.new(schedule_in: 60) + |> Oban.insert() + + # Reload the job from DB - should have updated scheduled_at + updated_job = Repo.get!(Oban.Job, job1.id) + assert DateTime.before?(updated_job.scheduled_at, original_scheduled_at) + end + + test "self-scheduling works while job is executing", %{site: site} do + {:ok, device} = + Devices.create_device(%{ + name: "Executing Test", + ip_address: "192.168.1.202", + site_id: site.id, + organization_id: site.organization_id, + monitoring_enabled: true + }) + + # Clear jobs created by create_device + Repo.delete_all(Oban.Job) + + # Insert a job and manually set it to executing state + {:ok, job1} = + %{device_id: device.id} + |> DeviceMonitorWorker.new() + |> Oban.insert() + + # Transition the job to executing state + Repo.update_all( + Ecto.Query.where(Oban.Job, id: ^job1.id), + set: [state: "executing"] + ) + + # Insert another job for the same device (simulating self-scheduling) + {:ok, job2} = + %{device_id: device.id} + |> DeviceMonitorWorker.new(schedule_in: 60) + |> Oban.insert() + + # Should succeed - the new job should be a different job + assert job2.id != job1.id + + # Should have 2 jobs: one executing, one scheduled + job_count = + Oban.Job + |> Ecto.Query.where(worker: "Towerops.Workers.DeviceMonitorWorker") + |> Repo.aggregate(:count) + + assert job_count == 2 + end + + test "max_attempts is 1", %{site: site} do + {:ok, device} = + Devices.create_device(%{ + name: "Max Attempts Test", + ip_address: "192.168.1.203", + site_id: site.id, + organization_id: site.organization_id, + monitoring_enabled: true + }) + + # Clear jobs created by create_device + Repo.delete_all(Oban.Job) + + {:ok, job} = DeviceMonitorWorker.start_monitoring(device.id) + assert job.max_attempts == 1 + end + end end diff --git a/test/towerops/workers/device_poller_worker_test.exs b/test/towerops/workers/device_poller_worker_test.exs index d11f29a1..a7de1a2e 100644 --- a/test/towerops/workers/device_poller_worker_test.exs +++ b/test/towerops/workers/device_poller_worker_test.exs @@ -18,8 +18,6 @@ defmodule Towerops.Workers.DevicePollerWorkerTest do alias Towerops.Workers.DevicePollerWorker alias Towerops.Workers.PollingOffset - @moduletag :skip - setup :verify_on_exit! setup do @@ -58,6 +56,8 @@ defmodule Towerops.Workers.DevicePollerWorkerTest do end describe "perform/1" do + @describetag :skip + test "returns :ok when device does not exist" do assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => Ecto.UUID.generate()}}) end @@ -105,6 +105,8 @@ defmodule Towerops.Workers.DevicePollerWorkerTest do end describe "polling logic" do + @describetag :skip + test "polls sensors and updates values", %{site: site} do # 1. Create Device {:ok, device} = @@ -282,6 +284,8 @@ defmodule Towerops.Workers.DevicePollerWorkerTest do end describe "start_polling/1" do + @describetag :skip + test "schedules initial job with offset", %{site: site} do {:ok, device} = Devices.create_device(%{ @@ -330,6 +334,8 @@ defmodule Towerops.Workers.DevicePollerWorkerTest do end describe "stop_polling/1" do + @describetag :skip + test "cancels all jobs for device", %{site: site} do {:ok, device} = Devices.create_device(%{ @@ -349,6 +355,8 @@ defmodule Towerops.Workers.DevicePollerWorkerTest do end describe "race condition handling" do + @describetag :skip + test "handles device deletion during poll gracefully", %{organization: org, site: site} do {:ok, device} = Devices.create_device(%{ @@ -485,4 +493,127 @@ defmodule Towerops.Workers.DevicePollerWorkerTest do assert :ok = DevicePollerWorker.perform(%Oban.Job{args: %{"device_id" => device.id}}) end end + + describe "job uniqueness" do + test "only one job per device exists at a time", %{site: site} do + {:ok, device} = + Devices.create_device(%{ + name: "Unique Test", + ip_address: "192.168.1.200", + site_id: site.id, + organization_id: site.organization_id, + snmp_enabled: true, + check_interval_seconds: 300 + }) + + # Clear jobs created by create_device + Repo.delete_all(Oban.Job) + + # Insert two jobs for the same device + {:ok, _job1} = DevicePollerWorker.start_polling(device.id) + {:ok, _job2} = DevicePollerWorker.start_polling(device.id) + + # Should only have one job in the database + job_count = + Oban.Job + |> Ecto.Query.where(worker: "Towerops.Workers.DevicePollerWorker") + |> Repo.aggregate(:count) + + assert job_count == 1 + end + + test "new job replaces scheduled_at of existing job", %{site: site} do + {:ok, device} = + Devices.create_device(%{ + name: "Replace Test", + ip_address: "192.168.1.201", + site_id: site.id, + organization_id: site.organization_id, + snmp_enabled: true, + check_interval_seconds: 300 + }) + + # Clear jobs created by create_device + Repo.delete_all(Oban.Job) + + # Insert first job scheduled far in the future + {:ok, job1} = + %{device_id: device.id} + |> DevicePollerWorker.new(schedule_in: 3600) + |> Oban.insert() + + original_scheduled_at = job1.scheduled_at + + # Insert second job scheduled sooner + {:ok, _job2} = + %{device_id: device.id} + |> DevicePollerWorker.new(schedule_in: 60) + |> Oban.insert() + + # Reload the job from DB - should have updated scheduled_at + updated_job = Repo.get!(Oban.Job, job1.id) + assert DateTime.before?(updated_job.scheduled_at, original_scheduled_at) + end + + test "self-scheduling works while job is executing", %{site: site} do + {:ok, device} = + Devices.create_device(%{ + name: "Executing Test", + ip_address: "192.168.1.202", + site_id: site.id, + organization_id: site.organization_id, + snmp_enabled: true, + check_interval_seconds: 300 + }) + + # Clear jobs created by create_device + Repo.delete_all(Oban.Job) + + # Insert a job and manually set it to executing state + {:ok, job1} = + %{device_id: device.id} + |> DevicePollerWorker.new() + |> Oban.insert() + + # Transition the job to executing state + Repo.update_all( + Ecto.Query.where(Oban.Job, id: ^job1.id), + set: [state: "executing"] + ) + + # Insert another job for the same device (simulating self-scheduling) + {:ok, job2} = + %{device_id: device.id} + |> DevicePollerWorker.new(schedule_in: 300) + |> Oban.insert() + + # Should succeed - the new job should be a different job + assert job2.id != job1.id + + # Should have 2 jobs: one executing, one scheduled + job_count = + Oban.Job + |> Ecto.Query.where(worker: "Towerops.Workers.DevicePollerWorker") + |> Repo.aggregate(:count) + + assert job_count == 2 + end + + test "max_attempts is 1", %{site: site} do + {:ok, device} = + Devices.create_device(%{ + name: "Max Attempts Test", + ip_address: "192.168.1.203", + site_id: site.id, + organization_id: site.organization_id, + snmp_enabled: true + }) + + # Clear jobs created by create_device + Repo.delete_all(Oban.Job) + + {:ok, job} = DevicePollerWorker.start_polling(device.id) + assert job.max_attempts == 1 + end + end end