Prevent Oban polling/monitoring job stacking per device

When the Oban queue backs up, the 60-second uniqueness window expires
and duplicate jobs stack up per device. Switch to period: :infinity so
only one poll/monitor job exists per device at any time. Add replace
option to supersede stale scheduled jobs with updated scheduled_at.
Remove :executing from unique states so self-scheduling works while
the current job runs. Set max_attempts: 1 since retrying stale polls
is pointless.

Also fix all credo --strict issues across the codebase.
This commit is contained in:
Graham McIntire 2026-02-16 16:37:35 -06:00
parent 09c56e3e9b
commit de986bddf6
No known key found for this signature in database
38 changed files with 1919 additions and 1336 deletions

View file

@ -1 +1 @@
/nix/store/4xlkmdd947h5qlhj2rmbyavq08grkz27-pre-commit-config.json
/nix/store/zx636v118rzzqzwafgrw2aybh7l0szrl-pre-commit-config.json

View file

@ -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)

View file

@ -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::binary-2, b::binary-2, c::binary-2, d::binary-2, e::binary-2, f::binary-2>> ->
"#{a}:#{b}:#{c}:#{d}:#{e}:#{f}"
_ ->
nil
end

View file

@ -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,
%{

View file

@ -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 """

View file

@ -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

View file

@ -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 """

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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}} ->

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -25,7 +25,8 @@ defmodule ToweropsWeb.Components.ConsentPrompt do
>
<div class="flex min-h-screen items-center justify-center p-4">
<!-- Backdrop -->
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity"></div>
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity">
</div>
<!-- Modal panel -->
<div class="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6">

View file

@ -8,7 +8,8 @@ defmodule ToweropsWeb.CheckLive.FormComponent do
def render(assigns) do
~H"""
<div class="relative z-50">
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity"></div>
<div class="fixed inset-0 bg-gray-500 dark:bg-gray-900 bg-opacity-75 dark:bg-opacity-80 transition-opacity">
</div>
<div class="fixed inset-0 z-10 overflow-y-auto">
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">

View file

@ -11,7 +11,9 @@
<h1 class="text-lg font-bold text-gray-900 dark:text-white">
{@current_scope.organization.name}
</h1>
<span class="text-xs text-gray-400 dark:text-gray-500 dark:text-gray-400 font-mono">NOC</span>
<span class="text-xs text-gray-400 dark:text-gray-500 dark:text-gray-400 font-mono">
NOC
</span>
</div>
<%= if assigns[:last_updated] do %>
<div class="flex items-center gap-2 text-xs text-gray-400 dark:text-gray-500 dark:text-gray-400 font-mono">
@ -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: ""
)
]}
>
<td class="w-1 p-0">
@ -754,7 +759,10 @@
<div class="hidden">
<div class="flex items-center justify-between mb-2">
<h2 class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
Config Changes <span class="font-normal text-gray-400 dark:text-gray-500 dark:text-gray-400">(7d)</span>
Config Changes
<span class="font-normal text-gray-400 dark:text-gray-500 dark:text-gray-400">
(7d)
</span>
</h2>
<.link
navigate={~p"/insights"}
@ -839,15 +847,24 @@
</kbd>
<span class="ml-1">to search</span>
<span class="mx-2">·</span>
<.link navigate={~p"/devices/new"} class="hover:text-gray-500 dark:text-gray-400 dark:hover:text-gray-400">
<.link
navigate={~p"/devices/new"}
class="hover:text-gray-500 dark:text-gray-400 dark:hover:text-gray-400"
>
+ device
</.link>
<span class="mx-2">·</span>
<.link navigate={~p"/trace"} class="hover:text-gray-500 dark:text-gray-400 dark:hover:text-gray-400">
<.link
navigate={~p"/trace"}
class="hover:text-gray-500 dark:text-gray-400 dark:hover:text-gray-400"
>
trace
</.link>
<span class="mx-2">·</span>
<.link navigate={~p"/network-map"} class="hover:text-gray-500 dark:text-gray-400 dark:hover:text-gray-400">
<.link
navigate={~p"/network-map"}
class="hover:text-gray-500 dark:text-gray-400 dark:hover:text-gray-400"
>
map
</.link>
</span>

View file

@ -136,7 +136,10 @@
<%= if !@has_devices do %>
<div class="text-center py-16">
<.icon name="hero-server" class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500 dark:text-gray-400" />
<.icon
name="hero-server"
class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500 dark:text-gray-400"
/>
<h3 class="mt-4 text-lg font-semibold text-gray-900 dark:text-white">
{t("No devices")}
</h3>

View file

@ -1640,7 +1640,7 @@ defmodule ToweropsWeb.DeviceLive.Show do
defp format_mrr(nil), do: "$0"
defp format_mrr(%Decimal{} = d) do
"$#{Decimal.round(d, 2) |> Decimal.to_string(:normal)}"
"$#{d |> Decimal.round(2) |> Decimal.to_string(:normal)}"
end
defp format_mrr(n) when is_number(n), do: "$#{:erlang.float_to_binary(n / 1, decimals: 2)}"

View file

@ -105,12 +105,17 @@
<div class="flex items-center gap-2">
<.icon name="hero-users" class="h-5 w-5 text-indigo-600 dark:text-indigo-400" />
<span class="text-sm font-semibold text-indigo-900 dark:text-indigo-200">
{@subscriber_impact.subscriber_count} {if @subscriber_impact.subscriber_count == 1, do: "subscriber", else: "subscribers"}
{@subscriber_impact.subscriber_count} {if @subscriber_impact.subscriber_count == 1,
do: "subscriber",
else: "subscribers"}
</span>
</div>
<%= if @can_view_financials && @subscriber_impact.mrr do %>
<div class="flex items-center gap-2">
<.icon name="hero-currency-dollar" class="h-5 w-5 text-indigo-600 dark:text-indigo-400" />
<.icon
name="hero-currency-dollar"
class="h-5 w-5 text-indigo-600 dark:text-indigo-400"
/>
<span class="text-sm font-semibold text-indigo-900 dark:text-indigo-200">
{format_mrr(@subscriber_impact.mrr)}/mo
</span>
@ -119,8 +124,8 @@
</div>
</div>
<% end %>
<!-- Tabs -->
<!-- Tabs -->
<div class="border-b border-gray-200 dark:border-white/10">
<nav class="-mb-px flex space-x-8 overflow-x-auto">
<.link
@ -675,7 +680,9 @@
{s}
</span>
</div>
<span class="text-gray-500 dark:text-gray-400 text-xs">{event.change_size} lines</span>
<span class="text-gray-500 dark:text-gray-400 text-xs">
{event.change_size} lines
</span>
</div>
<% end %>
</div>
@ -1807,7 +1814,9 @@
{t("Primary")}
</span>
<% else %>
<span class="text-gray-400 dark:text-gray-500 dark:text-gray-400">-</span>
<span class="text-gray-400 dark:text-gray-500 dark:text-gray-400">
-
</span>
<% end %>
</td>
</tr>
@ -2463,14 +2472,18 @@
<%= if check.last_check_at do %>
{get_latest_value(check)}
<% else %>
<span class="text-gray-400 dark:text-gray-500 dark:text-gray-400">Pending</span>
<span class="text-gray-400 dark:text-gray-500 dark:text-gray-400">
Pending
</span>
<% end %>
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
<%= if check.last_check_at do %>
{format_relative_time(check.last_check_at)}
<% else %>
<span class="text-gray-400 dark:text-gray-500 dark:text-gray-400">Never</span>
<span class="text-gray-400 dark:text-gray-500 dark:text-gray-400">
Never
</span>
<% end %>
</td>
<td class="px-4 py-3 whitespace-nowrap text-right text-sm font-medium">

View file

@ -253,21 +253,21 @@ defmodule ToweropsWeb.HelpLive.Index do
</.link>
</li>
<%= if false do %>
<li>
<.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>
</li>
<li>
<.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>
</li>
<% end %>
<li>
<.link

View file

@ -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

View file

@ -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 %>
<div class="flex items-center justify-center py-16">
<div class="text-center">
<.icon name="hero-wrench-screwdriver" class="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">{t("No maintenance windows")}</h3>
<.icon
name="hero-wrench-screwdriver"
class="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-4"
/>
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-300">
{t("No maintenance windows")}
</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
{t("Schedule maintenance to suppress alerts during planned work.")}
</p>
@ -73,11 +82,21 @@
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-gray-800/50">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">{t("Name")}</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">{t("Scope")}</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">{t("Time Range")}</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">{t("Status")}</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">{t("Created By")}</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("Name")}
</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("Scope")}
</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("Time Range")}
</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("Status")}
</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t("Created By")}
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-white/5 bg-white dark:bg-gray-900">
@ -93,15 +112,21 @@
{scope_label(window)}
</td>
<td class="px-4 py-3 text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">
{format_datetime(window.starts_at, @timezone)} — {format_datetime(window.ends_at, @timezone)}
{format_datetime(window.starts_at, @timezone)} — {format_datetime(
window.ends_at,
@timezone
)}
</td>
<td class="px-4 py-3">
<% status = status_for(window) %>
<span class={[
"inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium",
status == :active && "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300",
status == :upcoming && "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
status == :past && "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
status == :active &&
"bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300",
status == :upcoming &&
"bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
status == :past &&
"bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
]}>
{status |> to_string() |> String.capitalize()}
</span>

View file

@ -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

View file

@ -21,8 +21,12 @@
<div class="flex items-center gap-3">
<.icon name="hero-wrench-screwdriver" class="h-6 w-6 text-green-600 dark:text-green-400" />
<div>
<h3 class="text-sm font-semibold text-green-800 dark:text-green-300">{t("Currently Active")}</h3>
<p class="text-sm text-green-700 dark:text-green-400">{t("This maintenance window is currently in effect. Alerts are being suppressed.")}</p>
<h3 class="text-sm font-semibold text-green-800 dark:text-green-300">
{t("Currently Active")}
</h3>
<p class="text-sm text-green-700 dark:text-green-400">
{t("This maintenance window is currently in effect. Alerts are being suppressed.")}
</p>
</div>
</div>
</div>
@ -33,7 +37,8 @@
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{@window.name}</h1>
<span class={[
"inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium mt-2",
status == :active && "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300",
status == :active &&
"bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300",
status == :upcoming && "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300",
status == :past && "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
]}>
@ -64,28 +69,41 @@
<%!-- Reason --%>
<%= if @window.reason do %>
<div class="px-6 py-4">
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">{t("Reason")}</dt>
<dd class="text-sm text-gray-900 dark:text-white whitespace-pre-wrap">{@window.reason}</dd>
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">
{t("Reason")}
</dt>
<dd class="text-sm text-gray-900 dark:text-white whitespace-pre-wrap">
{@window.reason}
</dd>
</div>
<% end %>
<%!-- Scope --%>
<div class="px-6 py-4">
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">{t("Scope")}</dt>
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">
{t("Scope")}
</dt>
<dd class="text-sm text-gray-900 dark:text-white">{scope_label(@window)}</dd>
</div>
<%!-- Time Range --%>
<div class="px-6 py-4">
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">{t("Time Range")}</dt>
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">
{t("Time Range")}
</dt>
<dd class="text-sm text-gray-900 dark:text-white">
{format_datetime(@window.starts_at, @timezone)} — {format_datetime(@window.ends_at, @timezone)}
{format_datetime(@window.starts_at, @timezone)} — {format_datetime(
@window.ends_at,
@timezone
)}
</dd>
</div>
<%!-- Recurring --%>
<div class="px-6 py-4">
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">{t("Recurring")}</dt>
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">
{t("Recurring")}
</dt>
<dd class="text-sm text-gray-900 dark:text-white">
<%= if @window.recurring do %>
{t("Yes")} — {@window.recurrence_rule || t("No rule specified")}
@ -97,7 +115,9 @@
<%!-- Suppress Alerts --%>
<div class="px-6 py-4">
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">{t("Suppress Alerts")}</dt>
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">
{t("Suppress Alerts")}
</dt>
<dd class="text-sm text-gray-900 dark:text-white">
<%= if @window.suppress_alerts do %>
<span class="inline-flex items-center gap-1 text-green-700 dark:text-green-400">
@ -113,7 +133,9 @@
<%!-- Created By --%>
<div class="px-6 py-4">
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">{t("Created By")}</dt>
<dt class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">
{t("Created By")}
</dt>
<dd class="text-sm text-gray-900 dark:text-white">
{if @window.created_by, do: @window.created_by.email, else: "—"}
</dd>

View file

@ -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

View file

@ -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)}
</button>
<%= 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 %>
</li>
<% end %>
@ -126,7 +132,6 @@
</div>
</div>
</.form>
<% :site -> %>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">
{t("Add Your First Site")}
@ -137,11 +142,34 @@
<.form for={@site_form} phx-change="validate_site" phx-submit="save_site">
<div class="space-y-4">
<.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"
/>
<div class="grid grid-cols-2 gap-4">
<.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"
/>
</div>
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-white/10">
@ -161,13 +189,14 @@
</div>
</div>
</.form>
<% :billing -> %>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">
{t("Connect a Billing Platform")}
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
{t("Optional. Sync subscriber data for outage impact analysis and inventory reconciliation.")}
{t(
"Optional. Sync subscriber data for outage impact analysis and inventory reconciliation."
)}
</p>
<%= 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" />
<span class="text-sm font-medium text-gray-900 dark:text-white">{provider.name}</span>
<span class="text-sm font-medium text-gray-900 dark:text-white">
{provider.name}
</span>
</button>
<% end %>
</div>
@ -196,18 +227,49 @@
</button>
</div>
<.form for={@integration_form} phx-change="validate_integration" phx-submit="save_integration">
<.form
for={@integration_form}
phx-change="validate_integration"
phx-submit="save_integration"
>
<div class="space-y-4">
<%= 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 %>
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-white/10">
@ -240,13 +302,14 @@
</button>
</div>
<% end %>
<% :agent -> %>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">
{t("Deploy Agent")}
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
{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."
)}
</p>
<%= if @agent_token do %>
@ -264,13 +327,18 @@
<% else %>
<div class="rounded-md bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 p-3">
<p class="text-xs text-gray-500 dark:text-gray-400">
{t("Agent:")} <span class="font-medium text-gray-900 dark:text-white">{@agent_token.name}</span>
{t("Agent:")}
<span class="font-medium text-gray-900 dark:text-white">
{@agent_token.name}
</span>
</p>
</div>
<% end %>
<div>
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-2">{t("Docker")}</h3>
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-2">
{t("Docker")}
</h3>
<pre class="text-xs font-mono bg-gray-900 text-green-400 rounded-lg p-4 overflow-x-auto select-all"><code>docker run -d \
--name towerops-agent \
--restart unless-stopped \
@ -281,7 +349,9 @@
</div>
<div>
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-2">{t("Systemd")}</h3>
<h3 class="text-sm font-medium text-gray-900 dark:text-white mb-2">
{t("Systemd")}
</h3>
<pre class="text-xs font-mono bg-gray-900 text-green-400 rounded-lg p-4 overflow-x-auto select-all"><code>curl -sSL https://install.towerops.net | \
TOWEROPS_TOKEN=<%= if @agent_token_value, do: @agent_token_value, else: "<your-token>" %> \
TOWEROPS_SERVER=<%= ToweropsWeb.Endpoint.url() %> \

View file

@ -39,8 +39,7 @@
<div class="rounded-lg border-2 border-indigo-200 bg-white p-6 dark:border-indigo-800/50 dark:bg-white/5">
<div class="mb-4 flex items-center gap-2">
<span class="inline-flex items-center gap-1 rounded-full bg-indigo-100 px-3 py-1 text-xs font-semibold text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300">
<.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
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
— only one billing platform can be active at a time

File diff suppressed because it is too large Load diff

View file

@ -216,7 +216,10 @@
<div class="text-sm text-gray-700 dark:text-gray-300">
{@trace.access_point.name}
</div>
<div :if={@trace.access_point.model} class="text-xs text-gray-500 dark:text-gray-400">
<div
:if={@trace.access_point.model}
class="text-xs text-gray-500 dark:text-gray-400"
>
{@trace.access_point.model}
</div>
</div>
@ -362,7 +365,9 @@
>
<span class="text-gray-700 dark:text-gray-300 truncate">{ap.name}</span>
<div class="flex items-center gap-2">
<span class="text-gray-500 dark:text-gray-400">{ap.subscriber_count || 0} subs</span>
<span class="text-gray-500 dark:text-gray-400">
{ap.subscriber_count || 0} subs
</span>
<span
:if={ap.qoe_score}
class={["font-medium", score_color(ap.qoe_score)]}

View file

@ -748,6 +748,7 @@ defmodule ToweropsWeb.UserAuth do
mem ->
alias Towerops.Organizations.Policy
scope = socket.assigns.current_scope
{:cont,

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -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

View file

@ -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