reliability-audit-fixes (#181)

Reviewed-on: graham/towerops-web#181
This commit is contained in:
Graham McIntire 2026-03-26 14:10:36 -05:00 committed by graham
parent 0f3f16d443
commit c7a236e504
51 changed files with 3226 additions and 1043 deletions

File diff suppressed because it is too large Load diff

1775
findings_librenms.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -438,17 +438,28 @@ defmodule Towerops.Accounts do
Enum.find_value(devices, {:error, :invalid_code}, fn device ->
if verify_totp(device.totp_secret, code) do
# Update last_used_at and return updated device
updated_device =
device
|> UserTotpDevice.touch_changeset()
|> Repo.update!()
{:ok, updated_device}
update_totp_device_timestamp(device, user_id)
end
end)
end
defp update_totp_device_timestamp(device, user_id) do
require Logger
case device
|> UserTotpDevice.touch_changeset()
|> Repo.update() do
{:ok, updated_device} ->
{:ok, updated_device}
{:error, changeset} ->
# Log error but still return success - timestamp update failure shouldn't block login
Logger.error("Failed to update TOTP device last_used_at for user #{user_id}: #{inspect(changeset.errors)}")
{:ok, device}
end
end
@doc """
Deletes a TOTP device.

View file

@ -303,7 +303,7 @@ defmodule Towerops.ActivityFeed do
select: %{
id: fragment("(array_agg(?::text ORDER BY ? DESC))[1]", sl.id, sl.inserted_at),
timestamp: fragment("date_trunc('minute', ?)", sl.inserted_at),
status: fragment("(array_agg(? ORDER BY ? DESC))[1]", sl.status, sl.inserted_at),
status: fragment("(array_agg(?::text ORDER BY ? DESC))[1]", sl.status, sl.inserted_at),
records_synced: sum(sl.records_synced),
duration_ms: avg(sl.duration_ms)
},

View file

@ -310,6 +310,85 @@ defmodule Towerops.Admin do
|> Repo.all()
end
@doc """
Lists audit logs with optional filtering.
## Options
* `:limit` - Maximum number of logs to return (default: 100)
* `:offset` - Number of logs to skip (default: 0)
* `:email` - Filter by actor or target user email (partial match)
* `:action` - Filter by specific action
* `:date_from` - Filter logs from this date (ISO 8601 format)
* `:date_to` - Filter logs up to this date (ISO 8601 format)
## Examples
iex> list_audit_logs_with_filters(limit: 50, email: "user@example.com")
[%AuditLog{}, ...]
iex> list_audit_logs_with_filters(action: "impersonate_start", date_from: "2024-01-01")
[%AuditLog{}, ...]
"""
def list_audit_logs_with_filters(opts \\ []) do
limit = Keyword.get(opts, :limit, 100)
offset = Keyword.get(opts, :offset, 0)
email = Keyword.get(opts, :email, "")
action = Keyword.get(opts, :action, "")
date_from = Keyword.get(opts, :date_from, "")
date_to = Keyword.get(opts, :date_to, "")
query =
AuditLog
|> preload([:superuser, :target_user])
|> order_by([a], desc: a.inserted_at)
query = filter_by_email(query, email)
query = filter_by_action(query, action)
query = filter_by_date_range(query, date_from, date_to)
if limit == :all do
Repo.all(query)
else
query
|> limit(^limit)
|> offset(^offset)
|> Repo.all()
end
end
defp filter_by_email(query, ""), do: query
defp filter_by_email(query, email) do
from(a in query,
left_join: su in assoc(a, :superuser),
left_join: tu in assoc(a, :target_user),
where: ilike(su.email, ^"%#{email}%") or ilike(tu.email, ^"%#{email}%")
)
end
defp filter_by_action(query, ""), do: query
defp filter_by_action(query, action), do: where(query, [a], a.action == ^action)
defp filter_by_date_range(query, "", ""), do: query
defp filter_by_date_range(query, from_date, to_date) do
query =
if from_date == "" do
query
else
{:ok, from_datetime} = NaiveDateTime.new(Date.from_iso8601!(from_date), ~T[00:00:00])
where(query, [a], a.inserted_at >= ^from_datetime)
end
if to_date == "" do
query
else
{:ok, to_datetime} = NaiveDateTime.new(Date.from_iso8601!(to_date), ~T[23:59:59])
where(query, [a], a.inserted_at <= ^to_datetime)
end
end
## Global Pricing Management
@doc """

View file

@ -267,8 +267,35 @@ defmodule Towerops.Alerts.StormDetector do
"Site outage: #{site_name}#{device_count} devices down (#{Enum.join(Enum.take(device_names, 5), ", ")}#{if device_count > 5, do: " and #{device_count - 5} more", else: ""})"
# Use the first device as the "primary" for the alert record
primary_device = List.first(devices)
case List.first(devices) do
nil ->
Logger.error("Attempted to create site outage alert with no devices for site_id=#{site_id}")
{:error, :no_devices}
primary_device ->
create_site_outage_alert_record(
site_id,
site_name,
primary_device,
devices,
device_count,
storm_mode,
now,
message
)
end
end
defp create_site_outage_alert_record(
site_id,
site_name,
primary_device,
devices,
device_count,
storm_mode,
now,
message
) do
case Alerts.create_alert(%{
device_id: primary_device.id,
alert_type: "site_outage",

View file

@ -322,6 +322,27 @@ defmodule Towerops.Devices do
end
end
@doc """
Gets a device with agent-related associations preloaded.
Includes organization, site, and the organization's default agent token.
Used for agent resolution during device form editing.
"""
def get_device_with_agent_info(device) do
Repo.preload(device, [:organization, site: [organization: :default_agent_token]])
end
@doc """
Checks if a device is a MikroTik device based on SNMP discovery data.
Returns true if the device is identified as MikroTik, false otherwise.
"""
def mikrotik_device?(device) do
device_with_snmp = Repo.preload(device, :snmp_device, force: true)
not is_nil(device_with_snmp.snmp_device) and
(String.contains?(device_with_snmp.snmp_device.manufacturer || "", "MikroTik") ||
String.contains?(device_with_snmp.snmp_device.sys_descr || "", "RouterOS"))
end
@doc """
Finds a device by IP address within a site, optionally excluding a specific device ID.
Returns nil if no device found.

View file

@ -334,6 +334,19 @@ defmodule Towerops.Gaiia do
end
end
@doc """
Get device-subscriber links for a device that have MAC addresses.
Returns a list of DeviceSubscriberLink records with gaiia_account preloaded.
Used for matching wireless clients to subscribers.
"""
def list_device_subscriber_links_with_macs(device_id) do
DeviceSubscriberLink
|> where(device_id: ^device_id)
|> where([l], not is_nil(l.subscriber_mac))
|> preload([:gaiia_account])
|> Repo.all()
end
@doc """
Get subscriber impact for multiple devices (batch).
Returns `%{device_id => %{subscriber_count: integer, mrr: Decimal.t()}}`.
@ -373,21 +386,28 @@ defmodule Towerops.Gaiia do
# Group by device and compute subscriber counts + MRR
best_links
|> Enum.group_by(& &1.device_id)
|> Map.new(fn {device_id, device_links} ->
account_gaiia_ids =
device_links
|> Enum.map(& &1.account_gaiia_id)
|> Enum.uniq()
org_id = hd(device_links).organization_id
mrr = compute_mrr_for_accounts(org_id, account_gaiia_ids)
{device_id, %{subscriber_count: length(account_gaiia_ids), mrr: mrr}}
end)
|> Map.new(&compute_device_impact/1)
end
end
defp compute_device_impact({device_id, device_links}) do
account_gaiia_ids =
device_links
|> Enum.map(& &1.account_gaiia_id)
|> Enum.uniq()
# All links for a device share the same organization_id, safe to use first
org_id =
case device_links do
[first | _] -> first.organization_id
[] -> nil
end
mrr = compute_mrr_for_accounts(org_id, account_gaiia_ids)
{device_id, %{subscriber_count: length(account_gaiia_ids), mrr: mrr}}
end
defp match_method_priority(%{match_method: "wireless_mac"}), do: 1
defp match_method_priority(%{match_method: "wireless_ip"}), do: 2
defp match_method_priority(%{match_method: "arp_mac"}), do: 3

View file

@ -269,6 +269,33 @@ defmodule Towerops.Monitoring do
)
end
@doc """
Get check status data for a device since a given time.
Returns a list of check results with timestamps and statuses.
"""
def get_device_check_data(device_id, since) do
check_ids =
Check
|> where(device_id: ^device_id)
|> select([c], c.id)
|> Repo.all()
if Enum.empty?(check_ids) do
[]
else
CheckResult
|> where([r], r.check_id in ^check_ids)
|> where([r], r.checked_at >= ^since)
|> order_by(asc: :checked_at)
|> select([r], %{t: r.checked_at, status: r.status})
|> limit(1000)
|> Repo.all()
|> Enum.map(fn r ->
%{t: DateTime.to_iso8601(r.t), status: r.status}
end)
end
end
@doc """
Gets graph data for a check, combining check_results and legacy sensor readings.

View file

@ -175,9 +175,16 @@ defmodule Towerops.OnCall.Escalation do
rule = Enum.find(policy.rules, &(&1.position == incident.current_rule_position))
timeout_seconds = (rule && rule.timeout_minutes * 60) || 1800
%{incident_id: incident.id}
|> EscalationCheckWorker.new(schedule_in: timeout_seconds)
|> Oban.insert()
case %{incident_id: incident.id}
|> EscalationCheckWorker.new(schedule_in: timeout_seconds)
|> Oban.insert() do
{:ok, _job} ->
:ok
{:error, changeset} ->
Logger.error("Failed to schedule escalation check for incident_id=#{incident.id}: #{inspect(changeset)}")
{:error, changeset}
end
end
defp advance_escalation(incident, policy) do

View file

@ -55,6 +55,42 @@ defmodule Towerops.Preseem do
|> Repo.all()
end
@doc """
Get QoE data for a device's access points since a given time.
Returns a list of metrics with latency, throughput, and loss data.
"""
def get_device_qoe_data(device_id, since) do
ap_ids =
AccessPoint
|> where(device_id: ^device_id)
|> select([ap], ap.id)
|> Repo.all()
if Enum.empty?(ap_ids) do
[]
else
SubscriberMetric
|> where([m], m.preseem_access_point_id in ^ap_ids)
|> where([m], m.recorded_at >= ^since)
|> order_by(asc: :recorded_at)
|> select([m], %{
t: m.recorded_at,
latency: m.avg_latency,
throughput: m.avg_throughput,
loss: m.avg_loss
})
|> Repo.all()
|> Enum.map(fn m ->
%{
t: DateTime.to_iso8601(m.t),
latency: m.latency,
throughput: m.throughput,
loss: m.loss
}
end)
end
end
@doc "Link a Preseem AP to a device manually."
def link_access_point(access_point_id, device_id) do
case Repo.get(AccessPoint, access_point_id) do

View file

@ -11,6 +11,8 @@ defmodule Towerops.Preseem.FleetIntelligence do
@doc "Compute fleet profiles for all matched APs in an organization."
def compute_fleet_profiles(organization_id) do
require Logger
now = DateTime.truncate(DateTime.utc_now(), :second)
matched_aps =
@ -25,14 +27,21 @@ defmodule Towerops.Preseem.FleetIntelligence do
{extract_manufacturer(ap.model), ap.model}
end)
count =
Enum.reduce(groups, 0, fn {{manufacturer, model}, aps}, acc ->
{success_count, error_count} =
Enum.reduce(groups, {0, 0}, fn {{manufacturer, model}, aps}, {success, errors} ->
profile_attrs = compute_model_profile(manufacturer, model, aps, now)
upsert_fleet_profile(organization_id, profile_attrs)
acc + 1
case upsert_fleet_profile(organization_id, profile_attrs) do
{:ok, _profile} -> {success + 1, errors}
{:error, _changeset} -> {success, errors + 1}
end
end)
{:ok, count}
if error_count > 0 do
Logger.warning("Fleet profiles: #{success_count} succeeded, #{error_count} failed for org #{organization_id}")
end
{:ok, success_count}
end
defp extract_manufacturer(model) when is_binary(model) do
@ -85,6 +94,8 @@ defmodule Towerops.Preseem.FleetIntelligence do
# PostgreSQL treats NULLs as distinct in unique indexes, so ON CONFLICT won't match
# rows with NULL firmware_version. Ecto's get_by also rejects nil values.
defp upsert_fleet_profile(organization_id, attrs) do
require Logger
full_attrs = Map.put(attrs, :organization_id, organization_id)
existing =
@ -95,16 +106,29 @@ defmodule Towerops.Preseem.FleetIntelligence do
|> where([fp], is_nil(fp.firmware_version))
|> Repo.one()
case existing do
nil ->
%FleetProfile{}
|> FleetProfile.changeset(full_attrs)
|> Repo.insert!()
result =
case existing do
nil ->
%FleetProfile{}
|> FleetProfile.changeset(full_attrs)
|> Repo.insert()
profile ->
profile
|> FleetProfile.changeset(full_attrs)
|> Repo.update!()
profile ->
profile
|> FleetProfile.changeset(full_attrs)
|> Repo.update()
end
case result do
{:ok, profile} ->
{:ok, profile}
{:error, changeset} ->
Logger.error(
"Failed to upsert fleet profile for #{attrs.manufacturer} #{attrs.model}: #{inspect(changeset.errors)}"
)
{:error, changeset}
end
end
end

View file

@ -205,34 +205,9 @@ defmodule Towerops.Snmp.Discovery do
Logger.info("Selecting device profile...", device_id: device.id),
profile = select_profile(system_info, client_opts),
Logger.info("Profile selected, building device info...", device_id: device.id),
{:ok, device_info} <- build_device_info(client_opts, system_info, profile) do
# Interface and sensor discovery: fall back to empty on timeout with warning
# CRITICAL: Empty list on timeout means sync will NOT delete existing data
# because save_discovery_results skips sync when list is empty and device already exists
Logger.info("Discovering interfaces...", device_id: device.id)
interfaces =
case discover_interfaces_with_timeout(client_opts, profile, timeouts) do
{:ok, ifaces} ->
ifaces
{:error, :interface_discovery_timeout} ->
Logger.warning("Interface discovery timed out, using empty list", device_id: device.id)
[]
end
Logger.info("Discovering sensors...", device_id: device.id)
sensors =
case discover_sensors_with_timeout(client_opts, profile, timeouts) do
{:ok, s} ->
s
{:error, :sensor_discovery_timeout} ->
Logger.warning("Sensor discovery timed out, using empty list", device_id: device.id)
[]
end
{:ok, device_info} <- build_device_info(client_opts, system_info, profile),
{:ok, interfaces} <- discover_interfaces_with_timeout_safe(client_opts, profile, timeouts, device.id),
{:ok, sensors} <- discover_sensors_with_timeout_safe(client_opts, profile, timeouts, device.id) do
# Secondary discovery: VLANs, IPs, processors, storage (already fall back to [] on timeout)
Logger.info("Discovering VLANs...", device_id: device.id)
{:ok, vlans} = discover_vlans_with_timeout(client_opts, profile, timeouts)
@ -301,9 +276,7 @@ defmodule Towerops.Snmp.Discovery do
end
end
# Interface discovery with timeout
# CRITICAL: Returns error on timeout instead of empty list to prevent data loss
# Empty list would cause sync_interfaces to delete all existing interfaces
# Interface discovery with timeout - returns error on timeout to preserve existing data
defp discover_interfaces_with_timeout(client_opts, profile, timeouts) do
DeferredDiscovery.slow_check(
client_opts,
@ -313,9 +286,7 @@ defmodule Towerops.Snmp.Discovery do
)
end
# Sensor discovery with timeout
# CRITICAL: Returns error on timeout instead of empty list to prevent data loss
# Empty list would cause sync_sensors to delete all existing sensors
# Sensor discovery with timeout - returns error on timeout to preserve existing data
defp discover_sensors_with_timeout(client_opts, profile, timeouts) do
DeferredDiscovery.slow_check(
client_opts,
@ -325,6 +296,42 @@ defmodule Towerops.Snmp.Discovery do
)
end
# Safe wrapper for interface discovery - aborts discovery on timeout to prevent data loss
defp discover_interfaces_with_timeout_safe(client_opts, profile, timeouts, device_id) do
Logger.info("Discovering interfaces...", device_id: device_id)
case discover_interfaces_with_timeout(client_opts, profile, timeouts) do
{:ok, ifaces} ->
{:ok, ifaces}
{:error, :interface_discovery_timeout} ->
Logger.error(
"Interface discovery timed out - aborting to prevent data loss",
device_id: device_id
)
{:error, :interface_discovery_timeout}
end
end
# Safe wrapper for sensor discovery - aborts discovery on timeout to prevent data loss
defp discover_sensors_with_timeout_safe(client_opts, profile, timeouts, device_id) do
Logger.info("Discovering sensors...", device_id: device_id)
case discover_sensors_with_timeout(client_opts, profile, timeouts) do
{:ok, sensors} ->
{:ok, sensors}
{:error, :sensor_discovery_timeout} ->
Logger.error(
"Sensor discovery timed out - aborting to prevent data loss",
device_id: device_id
)
{:error, :sensor_discovery_timeout}
end
end
# Neighbor discovery with timeout - falls back to empty list on timeout
defp discover_neighbors_with_timeout(client_opts, interfaces, timeouts) do
DeferredDiscovery.slow_check(

View file

@ -665,7 +665,16 @@ defmodule Towerops.Snmp.Profiles.Base do
suffix = Enum.take(parts, -17)
[if_index_str | addr_octets] = suffix
if_index = String.to_integer(if_index_str)
if_index =
case Integer.parse(if_index_str) do
{n, _} when n >= 0 ->
n
_ ->
Logger.warning("Invalid interface index in IPv6 OID: #{inspect(if_index_str)}")
0
end
ipv6_address = format_ipv6_address(addr_octets)
{if_index, ipv6_address}
@ -681,15 +690,18 @@ defmodule Towerops.Snmp.Profiles.Base do
# Format IPv6 address from decimal octets to standard notation
defp format_ipv6_address(octets) when is_list(octets) and length(octets) == 16 do
octets
|> Enum.map(&String.to_integer/1)
|> Enum.map(fn octet ->
case Integer.parse(octet) do
{n, _} when n >= 0 and n <= 255 -> n
_ -> 0
end
end)
|> Enum.chunk_every(2)
|> Enum.map_join(":", fn [hi, lo] ->
(hi * 256 + lo)
|> Integer.to_string(16)
|> String.downcase()
end)
rescue
_ -> nil
end
defp format_ipv6_address(_), do: nil
@ -852,12 +864,15 @@ defmodule Towerops.Snmp.Profiles.Base do
end
defp extract_hr_storage_index(oid) do
oid
|> String.split(".")
|> List.last()
|> String.to_integer()
rescue
_ -> 0
index_str =
oid
|> String.split(".")
|> List.last()
case Integer.parse(index_str || "0") do
{n, _} when n >= 0 -> n
_ -> 0
end
end
# Map hrStorageType OID to memory pool type (RAM, swap)
@ -943,12 +958,15 @@ defmodule Towerops.Snmp.Profiles.Base do
end
defp extract_entity_index(oid) do
oid
|> String.split(".")
|> List.last()
|> String.to_integer()
rescue
_ -> 0
index_str =
oid
|> String.split(".")
|> List.last()
case Integer.parse(index_str || "0") do
{n, _} when n >= 0 -> n
_ -> 0
end
end
# ENTITY-MIB PhysicalClass values mapping
@ -1139,12 +1157,15 @@ defmodule Towerops.Snmp.Profiles.Base do
end
defp extract_processor_index(oid) when is_binary(oid) do
oid
|> String.split(".")
|> List.last()
|> String.to_integer()
rescue
_ -> nil
index_str =
oid
|> String.split(".")
|> List.last()
case Integer.parse(index_str || "0") do
{n, _} when n >= 0 -> n
_ -> nil
end
end
defp extract_processor_index(_), do: nil
@ -1334,11 +1355,15 @@ defmodule Towerops.Snmp.Profiles.Base do
oid_map
|> Map.keys()
|> Enum.map(fn oid_string ->
# Extract the last part of the OID which is the index
oid_string
|> String.split(".")
|> List.last()
|> String.to_integer()
index_str =
oid_string
|> String.split(".")
|> List.last()
case Integer.parse(index_str || "0") do
{n, _} when n >= 0 -> n
_ -> 0
end
end)
end
@ -1403,16 +1428,30 @@ defmodule Towerops.Snmp.Profiles.Base do
defp extract_cambium_model_from_descr(sys_descr) do
cond do
match = Regex.run(~r/ePMP\s*(\d+)/i, sys_descr) -> "ePMP #{Enum.at(match, 1)}"
match = Regex.run(~r/ePMP\s*(\d+)/i, sys_descr) -> extract_model_with_number("ePMP", match)
Regex.match?(~r/ePMP/i, sys_descr) -> "ePMP"
match = Regex.run(~r/PMP\s*(\d+)/i, sys_descr) -> "PMP #{Enum.at(match, 1)}"
match = Regex.run(~r/PTP\s*(\d+)/i, sys_descr) -> "PTP #{Enum.at(match, 1)}"
match = Regex.run(~r/cnPilot\s*(\w+)/i, sys_descr) -> "cnPilot #{Enum.at(match, 1)}"
match = Regex.run(~r/PMP\s*(\d+)/i, sys_descr) -> extract_model_with_number("PMP", match)
match = Regex.run(~r/PTP\s*(\d+)/i, sys_descr) -> extract_model_with_number("PTP", match)
match = Regex.run(~r/cnPilot\s*(\w+)/i, sys_descr) -> extract_model_with_number("cnPilot", match)
Regex.match?(~r/cnPilot/i, sys_descr) -> "cnPilot"
true -> nil
end
end
defp extract_model_with_number(prefix, regex_match) do
case Enum.at(regex_match, 1) do
nil -> prefix
model -> "#{prefix} #{model}"
end
end
defp extract_first_match(regex_match, default) do
case regex_match do
[full_match | _] -> full_match
_ -> default
end
end
defp extract_cambium_product_line(sys_object_id) when is_binary(sys_object_id) do
Enum.find_value(@cambium_product_lines, fn {regex, product} ->
if Regex.match?(regex, sys_object_id), do: product
@ -1423,8 +1462,8 @@ defmodule Towerops.Snmp.Profiles.Base do
defp extract_ubiquiti_model(sys_descr) do
cond do
match = Regex.run(~r/(AF-?\w+)/i, sys_descr) -> hd(match)
match = Regex.run(~r/(LBE-?\w+|NBE-?\w+|NSM\d+|PBE-?\w+)/i, sys_descr) -> hd(match)
match = Regex.run(~r/(AF-?\w+)/i, sys_descr) -> extract_first_match(match, "Wireless")
match = Regex.run(~r/(LBE-?\w+|NBE-?\w+|NSM\d+|PBE-?\w+)/i, sys_descr) -> extract_first_match(match, "Wireless")
Regex.match?(~r/EdgeOS/i, sys_descr) -> "EdgeRouter"
true -> "Wireless"
end
@ -1597,12 +1636,15 @@ defmodule Towerops.Snmp.Profiles.Base do
end
defp extract_vlan_id_from_oid(oid) when is_binary(oid) do
oid
|> String.split(".")
|> List.last()
|> String.to_integer()
rescue
_ -> 0
index_str =
oid
|> String.split(".")
|> List.last()
case Integer.parse(index_str || "0") do
{n, _} when n >= 0 -> n
_ -> 0
end
end
defp extract_vlan_id_from_oid(_), do: 0
@ -1673,21 +1715,25 @@ defmodule Towerops.Snmp.Profiles.Base do
defp subnet_mask_to_prefix(mask) when is_binary(mask) do
case String.split(mask, ".") do
[a, b, c, d] ->
[a, b, c, d]
|> Enum.map(&String.to_integer/1)
|> Enum.map(&count_bits/1)
|> Enum.sum()
_ ->
nil
[a, b, c, d] -> parse_subnet_octets([a, b, c, d])
_ -> nil
end
rescue
_ -> nil
end
defp subnet_mask_to_prefix(_), do: nil
defp parse_subnet_octets(octets) do
octets
|> Enum.map(fn octet ->
case Integer.parse(octet) do
{n, _} when n >= 0 and n <= 255 -> n
_ -> 0
end
end)
|> Enum.map(&count_bits/1)
|> Enum.sum()
end
defp count_bits(octet) when is_integer(octet) do
# Count the number of 1 bits in the octet
octet

View file

@ -28,18 +28,33 @@ defmodule Towerops.Snmp.Profiles.Vendors.AlliedTelesis do
# Model often in sysDescr
case Client.get(client_opts, @sysdescr_oid) do
{:ok, descr} when is_binary(descr) ->
cond do
match = Regex.run(~r/(AT-\S+|x\d+\S*)/i, descr) -> List.first(tl(match))
String.contains?(descr, "AlliedWare Plus") -> "AlliedWare Plus"
String.contains?(descr, "AlliedWare") -> "AlliedWare"
true -> nil
end
extract_allied_telesis_model(descr)
_ ->
nil
end
end
defp extract_allied_telesis_model(descr) do
cond do
match = Regex.run(~r/(AT-\S+|x\d+\S*)/i, descr) ->
case match do
[_full, capture | _] -> capture
[full] -> full
_ -> nil
end
String.contains?(descr, "AlliedWare Plus") ->
"AlliedWare Plus"
String.contains?(descr, "AlliedWare") ->
"AlliedWare"
true ->
nil
end
end
@impl true
def discover_wireless_sensors(client_opts) do
Vendor.fetch_sensors(wireless_oid_defs(), client_opts)

View file

@ -310,16 +310,28 @@ defmodule Towerops.Topology do
end
with {:ok, link} <- result do
# Insert evidence records - log failures but don't crash
Enum.each(evidence_attrs, fn ev ->
%DeviceLinkEvidence{}
|> DeviceLinkEvidence.changeset(Map.put(ev, :device_link_id, link.id))
|> Repo.insert!()
insert_link_evidence(link.id, ev)
end)
{:ok, Repo.reload!(link)}
end
end
defp insert_link_evidence(link_id, evidence_attrs) do
case %DeviceLinkEvidence{}
|> DeviceLinkEvidence.changeset(Map.put(evidence_attrs, :device_link_id, link_id))
|> Repo.insert() do
{:ok, _evidence} ->
:ok
{:error, changeset} ->
Logger.error("Failed to insert device link evidence: #{inspect(changeset)}")
:error
end
end
@doc """
Infer the role of a device from its SNMP data and neighbor reports.
Returns a role string. Does not write to the database.

View file

@ -32,9 +32,16 @@ defmodule Towerops.Workers.AlertDigestWorker do
)
Enum.each(pending, fn user_id ->
%{user_id: user_id}
|> new()
|> Oban.insert()
case %{user_id: user_id}
|> new()
|> Oban.insert() do
{:ok, _job} ->
:ok
{:error, changeset} ->
Logger.error("Failed to enqueue digest job for user_id=#{user_id}: #{inspect(changeset)}")
:error
end
end)
:ok
@ -67,9 +74,16 @@ defmodule Towerops.Workers.AlertDigestWorker do
Schedules delivery 5 minutes from now to batch more alerts.
"""
def enqueue(user_id) do
%{user_id: user_id}
|> new(schedule_in: 300, unique: [period: 300, keys: [:user_id]])
|> Oban.insert()
case %{user_id: user_id}
|> new(schedule_in: 300, unique: [period: 300, keys: [:user_id]])
|> Oban.insert() do
{:ok, _job} = result ->
result
{:error, changeset} = error ->
Logger.error("Failed to enqueue digest job for user_id=#{user_id}: #{inspect(changeset)}")
error
end
end
defp send_digest_notification(alerts) do

View file

@ -30,9 +30,13 @@ defmodule Towerops.Workers.CapacityInsightWorker do
@impl Oban.Worker
def perform(%Oban.Job{}) do
org_ids = Repo.all(from(o in Organization, select: o.id))
Enum.each(org_ids, &evaluate_organization/1)
# Use streaming to avoid loading all org IDs into memory
Repo.transaction(fn ->
Organization
|> select([o], o.id)
|> Repo.stream()
|> Enum.each(&evaluate_organization/1)
end)
:ok
end

View file

@ -27,8 +27,15 @@ defmodule Towerops.Workers.CnMaestroSyncWorker do
case %{"integration_id" => integration.id}
|> new(scheduled_at: scheduled_at, unique: [period: @window_seconds])
|> Oban.insert() do
{:ok, _} -> true
{:error, _} -> false
{:ok, _} ->
true
{:error, changeset} ->
Logger.error(
"Failed to enqueue cnMaestro sync job for integration_id=#{integration.id}: #{inspect(changeset)}"
)
false
end
end)

View file

@ -25,8 +25,12 @@ defmodule Towerops.Workers.ReportWorker do
case %{"report_id" => report.id}
|> new(unique: [period: 3600])
|> Oban.insert() do
{:ok, _} -> true
{:error, _} -> false
{:ok, _} ->
true
{:error, changeset} ->
Logger.error("Failed to enqueue report job for report_id=#{report.id}: #{inspect(changeset)}")
false
end
end)

View file

@ -20,14 +20,18 @@ defmodule Towerops.Workers.SystemInsightWorker do
@impl Oban.Worker
def perform(%Oban.Job{}) do
org_ids = Repo.all(from(o in Organization, select: o.id))
# Use streaming to avoid loading all org IDs into memory
Repo.transaction(fn ->
Organization
|> select([o], o.id)
|> Repo.stream()
|> Enum.each(fn org_id ->
offline_agents = list_offline_agents(org_id)
offline_ids = MapSet.new(offline_agents, & &1.id)
Enum.each(org_ids, fn org_id ->
offline_agents = list_offline_agents(org_id)
offline_ids = MapSet.new(offline_agents, & &1.id)
Enum.each(offline_agents, &generate_agent_offline_insight/1)
auto_resolve_recovered_agents(org_id, offline_ids)
Enum.each(offline_agents, &generate_agent_offline_insight/1)
auto_resolve_recovered_agents(org_id, offline_ids)
end)
end)
:ok

View file

@ -65,6 +65,13 @@ defmodule Towerops.Workers.WeatherSyncWorker do
defp schedule_next do
# Schedule next run in 15 minutes
%{} |> new(schedule_in: 900) |> Oban.insert()
case %{} |> new(schedule_in: 900) |> Oban.insert() do
{:ok, _job} = result ->
result
{:error, changeset} = error ->
Logger.error("Failed to schedule next weather sync job: #{inspect(changeset)}")
error
end
end
end

View file

@ -37,11 +37,11 @@ defmodule Towerops.Workers.WirelessInsightWorker do
@impl Oban.Worker
def perform(%Oban.Job{}) do
org_ids = Repo.all(from(o in Organization, select: o.id))
Enum.each(org_ids, fn org_id ->
organization = Repo.get!(Organization, org_id)
process_organization(organization)
# Use streaming to avoid loading all org IDs into memory
Repo.transaction(fn ->
Organization
|> Repo.stream()
|> Enum.each(&process_organization/1)
end)
:ok

View file

@ -205,7 +205,11 @@ defmodule ToweropsWeb.Api.MobileController do
case verify_organization_access(user, org_id) do
{:ok, _org} ->
limit = min(String.to_integer(params["limit"] || "50"), 200)
limit =
case Integer.parse(params["limit"] || "50") do
{n, _} when n > 0 and n <= 200 -> n
_ -> 50
end
alerts =
org_id

View file

@ -90,15 +90,17 @@ defmodule ToweropsWeb.Api.V1.CheckResultsController do
end
defp get_org_device(device_id, organization_id) do
device = Devices.get_device!(device_id)
case Devices.get_device(device_id) do
nil ->
{:error, :not_found}
if device.organization_id == organization_id do
{:ok, device}
else
{:error, :forbidden}
device ->
if device.organization_id == organization_id do
{:ok, device}
else
{:error, :forbidden}
end
end
rescue
Ecto.NoResultsError -> {:error, :not_found}
end
defp parse_int(nil, default), do: default

View file

@ -18,11 +18,17 @@ defmodule ToweropsWeb.Api.V1.PagerdutyWebhookController do
def create(conn, %{"organization_id" => organization_id} = params) do
with {:ok, raw_body} <- get_raw_body(conn),
{:ok, integration} <- get_pagerduty_integration(organization_id),
:ok <- validate_organization_match(integration, organization_id),
:ok <- verify_signature(conn, raw_body, integration) do
# params already parsed by Phoenix JSON parser
process_webhook(params, organization_id)
json(conn, %{status: "accepted"})
else
{:error, :organization_mismatch} ->
Logger.warning("PagerDuty webhook: rejected - organization ID mismatch (URL vs integration)")
conn |> put_status(403) |> json(%{error: "Organization mismatch"})
{:error, :not_configured} ->
conn |> put_status(404) |> json(%{error: "Integration not found"})
@ -51,6 +57,14 @@ defmodule ToweropsWeb.Api.V1.PagerdutyWebhookController do
end
end
defp validate_organization_match(integration, url_organization_id) do
if integration.organization_id == url_organization_id do
:ok
else
{:error, :organization_mismatch}
end
end
defp verify_signature(conn, raw_body, integration) do
webhook_secret = get_in(integration.credentials, ["webhook_secret"])

View file

@ -15,19 +15,10 @@ defmodule ToweropsWeb.Api.V1.StripeWebhookController do
signature = conn |> get_req_header("stripe-signature") |> List.first()
raw_body = conn.private[:raw_body] || ""
case verify_signature(raw_body, signature) do
:ok ->
event = Jason.decode!(raw_body)
case WebhookProcessor.process(event) do
:ok ->
json(conn, %{received: true})
{:error, reason} ->
Logger.error("Webhook processing failed: #{inspect(reason)}")
json(conn, %{received: true})
end
with :ok <- verify_signature(raw_body, signature),
{:ok, event} <- decode_payload(raw_body) do
process_webhook(conn, event)
else
{:error, :invalid_signature} ->
Logger.warning("Invalid webhook signature")
@ -35,6 +26,11 @@ defmodule ToweropsWeb.Api.V1.StripeWebhookController do
|> put_status(401)
|> json(%{error: "Invalid signature"})
{:error, :invalid_json} ->
conn
|> put_status(400)
|> json(%{error: "Invalid JSON payload"})
{:error, reason} ->
Logger.error("Webhook verification failed: #{inspect(reason)}")
@ -44,6 +40,28 @@ defmodule ToweropsWeb.Api.V1.StripeWebhookController do
end
end
defp decode_payload(raw_body) do
case Jason.decode(raw_body) do
{:ok, event} ->
{:ok, event}
{:error, decode_error} ->
Logger.error("Failed to decode webhook payload: #{inspect(decode_error)}")
{:error, :invalid_json}
end
end
defp process_webhook(conn, event) do
case WebhookProcessor.process(event) do
:ok ->
json(conn, %{received: true})
{:error, reason} ->
Logger.error("Webhook processing failed: #{inspect(reason)}")
json(conn, %{received: true})
end
end
defp verify_signature(payload, signature_header) do
webhook_secret = Application.fetch_env!(:towerops, :stripe_webhook_secret)

View file

@ -5,9 +5,7 @@ defmodule ToweropsWeb.Admin.AuditLive.Index do
"""
use ToweropsWeb, :live_view
import Ecto.Query
alias Towerops.Repo
alias Towerops.Admin
@impl true
def mount(_params, _session, socket) do
@ -321,63 +319,16 @@ defmodule ToweropsWeb.Admin.AuditLive.Index do
limit = Keyword.get(opts, :limit, socket.assigns.per_page)
offset = (socket.assigns.page - 1) * socket.assigns.per_page
query =
from(a in Towerops.Admin.AuditLog,
preload: [:superuser, :target_user],
order_by: [desc: a.inserted_at]
)
query = apply_filters(query, socket)
if limit == :all do
Repo.all(query)
else
query
|> limit(^limit)
|> offset(^offset)
|> Repo.all()
end
end
defp apply_filters(query, socket) do
query
|> filter_by_email(socket.assigns.search_email)
|> filter_by_action(socket.assigns.search_action)
|> filter_by_date_range(socket.assigns.date_from, socket.assigns.date_to)
end
defp filter_by_email(query, ""), do: query
defp filter_by_email(query, email) do
from(a in query,
left_join: su in assoc(a, :superuser),
left_join: tu in assoc(a, :target_user),
where: ilike(su.email, ^"%#{email}%") or ilike(tu.email, ^"%#{email}%")
Admin.list_audit_logs_with_filters(
limit: limit,
offset: offset,
email: socket.assigns.search_email,
action: socket.assigns.search_action,
date_from: socket.assigns.date_from,
date_to: socket.assigns.date_to
)
end
defp filter_by_action(query, ""), do: query
defp filter_by_action(query, action), do: where(query, [a], a.action == ^action)
defp filter_by_date_range(query, "", ""), do: query
defp filter_by_date_range(query, from_date, to_date) do
query =
if from_date == "" do
query
else
{:ok, from_datetime} = NaiveDateTime.new(Date.from_iso8601!(from_date), ~T[00:00:00])
where(query, [a], a.inserted_at >= ^from_datetime)
end
if to_date == "" do
query
else
{:ok, to_datetime} = NaiveDateTime.new(Date.from_iso8601!(to_date), ~T[23:59:59])
where(query, [a], a.inserted_at <= ^to_datetime)
end
end
defp generate_csv(logs) do
headers = "Timestamp,Action,Actor Email,Target User Email,IP Address,Request Path,Metadata\n"

View file

@ -438,6 +438,7 @@
<button
type="button"
phx-hook="CopyToClipboard"
phx-update="ignore"
data-target="#agent-token-input"
id={"copy-token-#{@new_token.agent_token.id}"}
class="absolute right-2 top-1/2 -translate-y-1/2 px-3 py-1.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 rounded shadow-sm transition-colors flex items-center gap-1.5"
@ -490,9 +491,10 @@
<button
type="button"
phx-hook="CopyToClipboard"
phx-update="ignore"
data-target="#docker-compose-example"
id={"copy-docker-compose-#{@new_token.agent_token.id}"}
class="mt-2 text-xs text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1"
class="inline-flex items-center px-2.5 py-1.5 text-xs font-medium text-white bg-gray-700 hover:bg-gray-600 dark:bg-gray-600 dark:hover:bg-gray-500 rounded transition-colors"
>
<.icon name="hero-clipboard" class="h-3 w-3" /> Copy to clipboard
</button>

View file

@ -5,13 +5,10 @@ defmodule ToweropsWeb.ConfigTimelineLive do
"""
use ToweropsWeb, :live_view
import Ecto.Query
alias Towerops.ConfigChanges
alias Towerops.Devices
alias Towerops.Preseem.AccessPoint
alias Towerops.Preseem.SubscriberMetric
alias Towerops.Repo
alias Towerops.Monitoring
alias Towerops.Preseem
alias ToweropsWeb.Live.Helpers.AccessControl
@ranges %{
@ -26,7 +23,12 @@ defmodule ToweropsWeb.ConfigTimelineLive do
case AccessControl.verify_device_access(device_id, organization.id) do
{:ok, _} ->
device = Devices.get_device!(device_id)
device =
case Devices.get_device(device_id) do
nil -> raise Ecto.NoResultsError, queryable: Towerops.Devices.DeviceSchema
device -> device
end
{:ok, assign(socket, device: device, page_title: "Config Timeline — #{device.name}", active_page: "devices")}
{:error, _} ->
@ -247,58 +249,11 @@ defmodule ToweropsWeb.ConfigTimelineLive do
# -- Data loaders --
defp load_qoe_data(device_id, since) do
ap_ids =
AccessPoint
|> where(device_id: ^device_id)
|> select([ap], ap.id)
|> Repo.all()
if Enum.empty?(ap_ids) do
[]
else
SubscriberMetric
|> where([m], m.preseem_access_point_id in ^ap_ids)
|> where([m], m.recorded_at >= ^since)
|> order_by(asc: :recorded_at)
|> select([m], %{
t: m.recorded_at,
latency: m.avg_latency,
throughput: m.avg_throughput,
loss: m.avg_loss
})
|> Repo.all()
|> Enum.map(fn m ->
%{
t: DateTime.to_iso8601(m.t),
latency: m.latency,
throughput: m.throughput,
loss: m.loss
}
end)
end
Preseem.get_device_qoe_data(device_id, since)
end
defp load_check_data(device_id, since) do
check_ids =
Towerops.Monitoring.Check
|> where(device_id: ^device_id)
|> select([c], c.id)
|> Repo.all()
if Enum.empty?(check_ids) do
[]
else
Towerops.Monitoring.CheckResult
|> where([r], r.check_id in ^check_ids)
|> where([r], r.checked_at >= ^since)
|> order_by(asc: :checked_at)
|> select([r], %{t: r.checked_at, status: r.status})
|> limit(1000)
|> Repo.all()
|> Enum.map(fn r ->
%{t: DateTime.to_iso8601(r.t), status: r.status}
end)
end
Monitoring.get_device_check_data(device_id, since)
end
defp timeline_event(event) do

View file

@ -3,7 +3,13 @@
current_scope={@current_scope}
active_page="dashboard"
>
<div id="dynamic-favicon" phx-hook="DynamicFavicon" data-status={@favicon_status} class="hidden">
<div
id="dynamic-favicon"
phx-hook="DynamicFavicon"
phx-update="ignore"
data-status={@favicon_status}
class="hidden"
>
</div>
<%!-- ═══════════════════════════════════════════════
NOC HEADER — dense, zero chrome

View file

@ -8,7 +8,6 @@ defmodule ToweropsWeb.DeviceLive.Form do
alias Towerops.Devices.Device, as: DeviceSchema
alias Towerops.OnCall
alias Towerops.Organizations.SubscriptionLimits
alias Towerops.Repo
alias Towerops.Sites
alias Towerops.Workers.DiscoveryWorker
alias ToweropsWeb.Live.Helpers.AccessControl
@ -106,7 +105,7 @@ defmodule ToweropsWeb.DeviceLive.Form do
case AccessControl.verify_device_access(id, organization.id) do
{:ok, device} ->
# Preload necessary associations for agent resolution
device = Repo.preload(device, [:organization, site: [organization: :default_agent_token]])
device = Devices.get_device_with_agent_info(device)
apply_edit_action(socket, device)
{:error, :not_found} ->
@ -157,8 +156,7 @@ defmodule ToweropsWeb.DeviceLive.Form do
mikrotik_config = Devices.get_mikrotik_config(device)
# Check if device is MikroTik (based on SNMP discovery)
device_with_snmp = Repo.preload(device, :snmp_device, force: true)
is_mikrotik = mikrotik_device?(device_with_snmp)
is_mikrotik = Devices.mikrotik_device?(device)
# Determine monitoring mode based on current snmp_enabled value
monitoring_mode = if device.snmp_enabled, do: "snmp_and_icmp", else: "icmp_only"
@ -179,14 +177,6 @@ defmodule ToweropsWeb.DeviceLive.Form do
|> assign(:monitoring_mode, monitoring_mode)
end
defp mikrotik_device?(device) do
# Check if device is MikroTik based on SNMP discovery data
# Must return boolean (not nil) to work with 'and' operator in templates
not is_nil(device.snmp_device) and
(String.contains?(device.snmp_device.manufacturer || "", "MikroTik") ||
String.contains?(device.snmp_device.sys_descr || "", "RouterOS"))
end
@impl true
def handle_event("switch_monitoring_mode", %{"mode" => mode}, socket) do
# Update snmp_enabled based on the selected mode

View file

@ -2,7 +2,7 @@
flash={@flash}
current_scope={@current_scope}
>
<div phx-hook="ScrollToTop" id="scroll-container">
<div phx-hook="ScrollToTop" id="scroll-container" phx-update="ignore">
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
<div class="mb-4">
<.link
@ -646,6 +646,7 @@
class="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-6 px-4 py-8 sm:px-6 md:grid-cols-3 lg:px-8"
phx-hook="MikrotikPortSync"
id="mikrotik-config-section"
phx-update="ignore"
>
<div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white">

View file

@ -32,7 +32,7 @@ defmodule ToweropsWeb.DeviceLive.Index do
|> assign(:page_title, t_equipment("Devices"))
|> assign(:timezone, socket.assigns.current_scope.timezone)
|> assign(:has_devices, devices != [])
|> stream(:device_rows, build_device_rows(devices), reset: true)
|> assign(:device_rows, build_device_rows(devices))
|> assign(:sites_enabled, organization.use_sites)
|> assign(:has_sites, sites != [])
|> assign(:reorder_mode, false)
@ -44,7 +44,8 @@ defmodule ToweropsWeb.DeviceLive.Index do
|> assign(:site_subscribers, site_subscribers)
|> assign(:search_query, "")
|> assign(:status_filter, "all")
|> assign(:pending_reload_ref, nil)}
|> assign(:pending_reload_ref, nil)
|> assign(:reload_counter, 0)}
end
@impl true
@ -86,7 +87,7 @@ defmodule ToweropsWeb.DeviceLive.Index do
})
_ ->
# Reload device stream to ensure immediate rendering when switching back to existing tab.
# Reload device list to ensure immediate rendering when switching back to existing tab.
# Without this, the table won't appear until the next PubSub update or tick event.
devices = Devices.list_organization_devices(organization.id)
@ -94,7 +95,8 @@ defmodule ToweropsWeb.DeviceLive.Index do
|> assign(:active_tab, "existing")
|> assign(:discovered_devices, [])
|> assign(:pagination, nil)
|> stream(:device_rows, build_device_rows(devices), reset: true)
|> assign(:device_rows, build_device_rows(devices))
|> assign(:reload_counter, (socket.assigns[:reload_counter] || 0) + 1)
end
end
@ -188,7 +190,7 @@ defmodule ToweropsWeb.DeviceLive.Index do
{:noreply,
socket
|> assign(:has_devices, devices != [])
|> stream(:device_rows, build_device_rows(devices), reset: true)
|> assign(:device_rows, build_device_rows(devices))
|> put_flash(:info, t_equipment("Order reset to alphabetical"))}
end
@ -237,7 +239,7 @@ defmodule ToweropsWeb.DeviceLive.Index do
{:noreply,
socket
|> assign(:has_devices, devices != [])
|> stream(:device_rows, build_device_rows(devices), reset: true)
|> assign(:device_rows, build_device_rows(devices))
|> put_flash(:info, success_message)}
{:error, _changeset} ->
@ -284,7 +286,7 @@ defmodule ToweropsWeb.DeviceLive.Index do
socket =
socket
|> assign(:pending_reload_ref, nil)
|> stream(:device_rows, build_device_rows(devices), reset: true)
|> assign(:device_rows, build_device_rows(devices))
{:noreply, socket}
end
@ -335,7 +337,7 @@ defmodule ToweropsWeb.DeviceLive.Index do
{current, limit} = SubscriptionLimits.device_quota(organization)
socket
|> stream(:device_rows, build_device_rows(devices), reset: true)
|> assign(:device_rows, build_device_rows(devices))
|> assign(:has_devices, devices != [])
|> assign(:device_quota, %{current: current, limit: limit})
|> assign(:site_subscribers, load_site_subscribers(devices))
@ -343,6 +345,7 @@ defmodule ToweropsWeb.DeviceLive.Index do
|> assign(:total_up, Enum.count(devices, &(&1.status == :up)))
|> assign(:total_down, Enum.count(devices, &(&1.status == :down)))
|> assign(:total_unknown, Enum.count(devices, &(&1.status == :unknown)))
|> assign(:reload_counter, (socket.assigns[:reload_counter] || 0) + 1)
end
# Lightweight reload: stream only, skip quota query (for status/update changes)
@ -351,12 +354,13 @@ defmodule ToweropsWeb.DeviceLive.Index do
devices = Devices.list_organization_devices(organization.id)
socket
|> stream(:device_rows, build_device_rows(devices), reset: true)
|> assign(:device_rows, build_device_rows(devices))
|> assign(:has_devices, devices != [])
|> assign(:pending_reload_ref, nil)
|> assign(:total_devices, length(devices))
|> assign(:total_up, Enum.count(devices, &(&1.status == :up)))
|> assign(:total_down, Enum.count(devices, &(&1.status == :down)))
|> assign(:reload_counter, (socket.assigns[:reload_counter] || 0) + 1)
|> assign(:total_unknown, Enum.count(devices, &(&1.status == :unknown)))
end
@ -366,7 +370,7 @@ defmodule ToweropsWeb.DeviceLive.Index do
filtered = filter_devices(devices, socket.assigns.search_query, socket.assigns.status_filter)
socket
|> stream(:device_rows, build_device_rows(filtered), reset: true)
|> assign(:device_rows, build_device_rows(filtered))
|> assign(:has_devices, devices != [])
end
@ -396,12 +400,19 @@ defmodule ToweropsWeb.DeviceLive.Index do
defp enqueue_discovery(device_id) do
if Application.get_env(:towerops, :env) == :test do
_ = Task.start(fn -> Snmp.discover_device(Devices.get_device!(device_id)) end)
_ = Task.start(fn -> run_discovery_if_exists(device_id) end)
else
DiscoveryWorker.enqueue(device_id)
end
end
defp run_discovery_if_exists(device_id) do
case Devices.get_device(device_id) do
nil -> :ok
device -> Snmp.discover_device(device)
end
end
defp build_device_rows(devices) do
device_ids = Enum.map(devices, & &1.id)
health_data = Monitoring.get_device_health_summary(device_ids)

View file

@ -248,7 +248,12 @@
<div class="mt-2 flow-root">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<table class="relative min-w-full" phx-hook="DeviceListReorder" id="device-list">
<table
class="relative min-w-full"
phx-hook="DeviceListReorder"
id="device-list"
phx-update="ignore"
>
<thead class="bg-gray-50 dark:bg-gray-800/50">
<tr>
<th :if={@reorder_mode} scope="col" class="py-2 pl-4 pr-2 w-8 sm:pl-3">
@ -299,12 +304,11 @@
<tbody
id="device-rows"
class="divide-y divide-gray-100 dark:divide-white/5"
phx-update="stream"
>
<%= for {dom_id, row} <- @streams.device_rows do %>
<%= for row <- @device_rows do %>
<%= if row.type == :site_header do %>
<tr
id={dom_id}
id={row.id}
class="bg-gray-50/80 dark:bg-gray-800/30"
data-site-id={if row.site, do: row.site.id, else: "no-site"}
draggable={if @reorder_mode && row.site, do: "true", else: "false"}
@ -375,7 +379,7 @@
</tr>
<% else %>
<tr
id={dom_id}
id={row.id}
class={[
"device-row hover:bg-blue-50/50 dark:hover:bg-blue-900/10 transition-colors cursor-pointer",
rem(row.device_index, 2) == 1 && "bg-gray-50/40 dark:bg-gray-800/20"

View file

@ -2,15 +2,12 @@ defmodule ToweropsWeb.DeviceLive.Show do
@moduledoc false
use ToweropsWeb, :live_view
import Ecto.Query
alias Towerops.Agents
alias Towerops.Devices
alias Towerops.Devices.Firmware
alias Towerops.Devices.MikrotikBackups
alias Towerops.Gaiia
alias Towerops.Monitoring
alias Towerops.Repo
alias Towerops.Snmp
alias Towerops.Workers.DiscoveryWorker
alias ToweropsWeb.CheckLive.FormComponent
@ -373,20 +370,26 @@ defmodule ToweropsWeb.DeviceLive.Show do
# Base data needed by all tabs: device record, agent info, SNMP device/sensors/interfaces.
defp assign_base_data(socket, device_id) do
device = Devices.get_device!(device_id)
device_with_associations = Repo.preload(device, [:organization, site: [organization: :default_agent_token]])
{agent_token_id, agent_source} = Agents.get_effective_agent_token_with_source(device_with_associations)
agent_info = load_agent_info(agent_token_id, agent_source)
snmp_data = load_snmp_data(device_id)
case Devices.get_device(device_id) do
nil ->
# Device was deleted between access check and fetch (race condition)
raise Ecto.NoResultsError, queryable: Towerops.Devices.DeviceSchema
socket
|> assign(:page_title, device.name)
|> assign(:timezone, socket.assigns.current_scope.timezone)
|> assign(:device, device)
|> assign(:agent_info, agent_info)
|> assign(:snmp_device, snmp_data.device)
|> assign(:snmp_interfaces, snmp_data.interfaces)
|> assign(:snmp_sensors, snmp_data.sensors)
device ->
device_with_associations = Devices.get_device_with_agent_info(device)
{agent_token_id, agent_source} = Agents.get_effective_agent_token_with_source(device_with_associations)
agent_info = load_agent_info(agent_token_id, agent_source)
snmp_data = load_snmp_data(device_id)
socket
|> assign(:page_title, device.name)
|> assign(:timezone, socket.assigns.current_scope.timezone)
|> assign(:device, device)
|> assign(:agent_info, agent_info)
|> assign(:snmp_device, snmp_data.device)
|> assign(:snmp_interfaces, snmp_data.interfaces)
|> assign(:snmp_sensors, snmp_data.sensors)
end
end
# Data needed for the tab navigation bar to decide which tabs to show.
@ -643,14 +646,7 @@ defmodule ToweropsWeb.DeviceLive.Show do
wireless_client_subscribers =
if Enum.any?(wireless_clients) do
# Get all device-subscriber links for this device
links =
Repo.all(
from(l in Towerops.Gaiia.DeviceSubscriberLink,
where: l.device_id == ^device_id,
where: not is_nil(l.subscriber_mac),
preload: [:gaiia_account]
)
)
links = Gaiia.list_device_subscriber_links_with_macs(device_id)
# Build a map of MAC address → link
Map.new(links, &{String.downcase(&1.subscriber_mac), &1})

View file

@ -4,6 +4,7 @@ defmodule ToweropsWeb.GraphLive.Show do
alias Towerops.Agents
alias Towerops.Devices
alias Towerops.Devices.DeviceSchema
alias Towerops.Monitoring
alias Towerops.Snmp
@ -303,7 +304,12 @@ defmodule ToweropsWeb.GraphLive.Show do
sensor_type = socket.assigns.sensor_type
range = socket.assigns.range
device = Devices.get_device!(device_id)
device =
case Devices.get_device(device_id) do
nil -> raise Ecto.NoResultsError, queryable: DeviceSchema
device -> device
end
{chart_data, title_suffix} = load_chart_data_for_type(socket.assigns, range)
{title, unit, auto_scale} = get_chart_config(sensor_type)
@ -330,7 +336,13 @@ defmodule ToweropsWeb.GraphLive.Show do
defp load_live_mode_graph_data(socket) do
device_id = socket.assigns.device_id
sensor_type = socket.assigns.sensor_type
device = Devices.get_device!(device_id)
device =
case Devices.get_device(device_id) do
nil -> raise Ecto.NoResultsError, queryable: DeviceSchema
device -> device
end
{title, unit, auto_scale} = get_chart_config(sensor_type)
title_suffix = get_title_suffix(socket.assigns)

View file

@ -67,6 +67,7 @@
<div
id="detail-chart"
phx-hook="SensorChart"
phx-update="ignore"
data-chart={@chart_data}
data-unit={@unit}
data-auto-scale={to_string(@auto_scale)}

View file

@ -45,6 +45,7 @@
<div
id="leaflet-map"
phx-hook="SitesMap"
phx-update="ignore"
class="w-full"
style="height: 600px;"
data-sites={Jason.encode!(@sites)}

View file

@ -17,16 +17,24 @@ defmodule ToweropsWeb.MikrotikBackupLive.Compare do
organization = socket.assigns.current_scope.organization
# Verify device access
device = Devices.get_device!(device_id)
device_with_site = Repo.preload(device, site: :organization)
case Devices.get_device(device_id) do
nil ->
{:noreply,
socket
|> put_flash(:error, t("Device not found"))
|> push_navigate(to: ~p"/devices")}
if device_with_site.site.organization_id == organization.id do
load_and_compare_backups(socket, device, device_id, backup_a_id, backup_b_id)
else
{:noreply,
socket
|> put_flash(:error, t("You don't have access to this device"))
|> push_navigate(to: ~p"/devices")}
device ->
device_with_site = Repo.preload(device, site: :organization)
if device_with_site.site.organization_id == organization.id do
load_and_compare_backups(socket, device, device_id, backup_a_id, backup_b_id)
else
{:noreply,
socket
|> put_flash(:error, t("You don't have access to this device"))
|> push_navigate(to: ~p"/devices")}
end
end
end

View file

@ -281,6 +281,7 @@
<div
id="cy-container"
phx-hook="NetworkMap"
phx-update="ignore"
class="w-full"
style="height: 600px;"
data-topology={Jason.encode!(@topology)}

View file

@ -395,6 +395,7 @@
<button
type="button"
phx-hook="CopyToClipboard"
phx-update="ignore"
data-target="#gaiia-webhook-url"
id="copy-webhook-url"
class="inline-flex items-center gap-1 rounded-md bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/20"
@ -419,6 +420,7 @@
<button
type="button"
phx-hook="CopyToClipboard"
phx-update="ignore"
data-target="#gaiia-webhook-secret"
id="copy-webhook-secret"
class="inline-flex items-center gap-1 rounded-md bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/20"

View file

@ -248,10 +248,17 @@ defmodule ToweropsWeb.SiteLive.Show do
defp enqueue_discovery(device_id) do
if Application.get_env(:towerops, :env) == :test do
# In test, run synchronously
_ = Task.start(fn -> Snmp.discover_device(Devices.get_device!(device_id)) end)
_ = Task.start(fn -> run_discovery_if_exists(device_id) end)
else
# In dev/prod, enqueue to Oban
DiscoveryWorker.enqueue(device_id)
end
end
defp run_discovery_if_exists(device_id) do
case Devices.get_device(device_id) do
nil -> :ok
device -> Snmp.discover_device(device)
end
end
end

View file

@ -26,6 +26,7 @@
<div
id="site-map"
phx-hook="LeafletMap"
phx-update="ignore"
data-lat={@site.latitude}
data-lng={@site.longitude}
data-zoom="14"
@ -551,6 +552,7 @@
<div
id="site-latency-chart"
phx-hook="SensorChart"
phx-update="ignore"
data-chart={@latency_chart_data}
data-unit="ms"
data-auto-scale="true"

View file

@ -1568,6 +1568,7 @@
<button
type="button"
phx-hook="CopyToClipboard"
phx-update="ignore"
data-target="#api-token-input"
id="copy-api-token"
class="absolute right-2 top-1/2 -translate-y-1/2 px-3 py-1.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 rounded shadow-sm transition-colors flex items-center gap-1.5"
@ -1845,6 +1846,7 @@
<button
type="button"
phx-hook="CopyToClipboard"
phx-update="ignore"
data-target="#recovery-codes-text"
id="copy-recovery-codes"
class="inline-flex items-center gap-x-1.5 rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-indigo-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"

View file

@ -348,6 +348,7 @@
<div
id="cy-weathermap-container"
phx-hook="WeathermapViewer"
phx-update="ignore"
class="w-full h-full"
data-topology={Jason.encode!(@topology)}
>

View file

@ -96,4 +96,40 @@ defmodule ToweropsWeb.ConnCase do
defp maybe_set_token_authenticated_at(token, authenticated_at) do
Towerops.AccountsFixtures.override_token_authenticated_at(token, authenticated_at)
end
@doc """
Asserts that a condition eventually becomes true within a timeout.
Retries the given function until it returns true or the timeout is reached.
Useful for waiting for async operations like PubSub messages to be processed.
## Examples
assert_eventually(fn -> render(view) =~ "Updated Name" end)
assert_eventually(fn -> has_element?(view, "#some-element") end, timeout: 1000, interval: 50)
"""
def assert_eventually(fun, opts \\ []) do
timeout = Keyword.get(opts, :timeout, 500)
interval = Keyword.get(opts, :interval, 10)
deadline = System.monotonic_time(:millisecond) + timeout
do_assert_eventually(fun, deadline, interval)
end
defp do_assert_eventually(fun, deadline, interval) do
if fun.() do
true
else
now = System.monotonic_time(:millisecond)
if now >= deadline do
raise ExUnit.AssertionError,
message: "Condition did not become true within timeout"
else
:timer.sleep(interval)
do_assert_eventually(fun, deadline, interval)
end
end
end
end

View file

@ -5,8 +5,6 @@ defmodule Towerops.Devices.EventLoggerTest do
alias Towerops.Devices.EventLogger
@moduletag :skip
describe "event logging via PubSub" do
setup do
# Start EventLogger for these tests
@ -17,6 +15,11 @@ defmodule Towerops.Devices.EventLoggerTest do
{:ok, organization} =
Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
# Subscribe EventLogger to the new org's topic
EventLogger.subscribe_org(organization.id)
# Give the subscription time to complete
Process.sleep(10)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
@ -34,7 +37,7 @@ defmodule Towerops.Devices.EventLoggerTest do
%{user: user, organization: organization, site: site, device: device}
end
test "EventLogger logs events broadcast via PubSub", %{device: device} do
test "EventLogger logs events broadcast via PubSub", %{device: device, organization: organization} do
event_attrs = %{
device_id: device.id,
event_type: "interface_speed_change",
@ -49,10 +52,10 @@ defmodule Towerops.Devices.EventLoggerTest do
occurred_at: DateTime.truncate(DateTime.utc_now(), :second)
}
# Broadcast event via PubSub
# Broadcast event via PubSub to org-scoped topic
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"device:events",
"device_events:org:#{organization.id}",
{:device_event, event_attrs}
)
@ -78,14 +81,14 @@ defmodule Towerops.Devices.EventLoggerTest do
assert event.message == "Interface eth0 speed detected: 1.0 Gbps"
end
test "EventLogger handles multiple events in sequence", %{device: device} do
test "EventLogger handles multiple events in sequence", %{device: device, organization: organization} do
now = DateTime.truncate(DateTime.utc_now(), :second)
# Broadcast multiple events
for i <- 1..3 do
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"device:events",
"device_events:org:#{organization.id}",
{:device_event,
%{
device_id: device.id,
@ -115,7 +118,7 @@ defmodule Towerops.Devices.EventLoggerTest do
assert length(events) == 3
end
test "EventLogger logs errors for invalid events", %{device: device} do
test "EventLogger logs errors for invalid events", %{device: device, organization: organization} do
# Broadcast an invalid event (missing required fields)
invalid_event = %{
device_id: device.id
@ -127,7 +130,7 @@ defmodule Towerops.Devices.EventLoggerTest do
ExUnit.CaptureLog.capture_log(fn ->
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"device:events",
"device_events:org:#{organization.id}",
{:device_event, invalid_event}
)
@ -138,11 +141,11 @@ defmodule Towerops.Devices.EventLoggerTest do
assert log =~ "Failed to log event"
end
test "EventLogger ignores non-event messages" do
test "EventLogger ignores non-event messages", %{organization: organization} do
# Send a message that's not an event
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"device:events",
"device_events:org:#{organization.id}",
{:some_other_message, "data"}
)

View file

@ -62,5 +62,26 @@ defmodule ToweropsWeb.Api.MobileControllerTest do
assert %{"alerts" => _alerts} = json_response(conn, 200)
end
test "handles malformed limit parameter without crashing", %{conn: conn, organization: org} do
# Non-numeric limit should default to 50 instead of crashing
conn = get(conn, ~p"/api/v1/mobile/organizations/#{org.id}/alerts?limit=abc")
assert %{"alerts" => _alerts} = json_response(conn, 200)
end
test "respects limit parameter boundaries", %{conn: conn, organization: org} do
# Limit above 200 should be capped at 200
conn = get(conn, ~p"/api/v1/mobile/organizations/#{org.id}/alerts?limit=500")
assert %{"alerts" => _alerts} = json_response(conn, 200)
end
test "handles negative limit parameter", %{conn: conn, organization: org} do
# Negative limit should default to 50
conn = get(conn, ~p"/api/v1/mobile/organizations/#{org.id}/alerts?limit=-10")
assert %{"alerts" => _alerts} = json_response(conn, 200)
end
end
end

View file

@ -230,7 +230,17 @@ defmodule ToweropsWeb.DeviceLive.FormTest do
|> form("#device-form", device: %{name: "Updated Name"})
|> render_change()
assert html =~ "Updated Name"
# The validation should not produce errors
refute html =~ "can&#39;t be blank"
refute html =~ "can't be blank"
# The form should accept the valid input and redirect to show page
assert {:error, {:live_redirect, %{to: to}}} =
view
|> form("#device-form", device: %{name: "Updated Name"})
|> render_submit()
assert to =~ ~r{/devices/[0-9a-f-]+}
end
test "saves edited device", %{conn: conn, device: device} do

View file

@ -1,5 +1,5 @@
defmodule ToweropsWeb.DeviceLive.IndexTest do
use ToweropsWeb.ConnCase, async: true
use ToweropsWeb.ConnCase, async: false
import Phoenix.LiveViewTest
@ -89,14 +89,18 @@ defmodule ToweropsWeb.DeviceLive.IndexTest do
organization_id: organization.id
})
{:ok, view, html} = live(conn, ~p"/devices")
{:ok, _view, html} = live(conn, ~p"/devices")
assert html =~ "Old Name"
{:ok, _updated} = Devices.update_device(device, %{name: "New Name"})
{:ok, updated_device} = Devices.update_device(device, %{name: "New Name"})
html = render(view)
assert html =~ "New Name"
# Verify the device was actually updated in the database
assert updated_device.name == "New Name"
# Verify LiveView received the PubSub message by checking that a fresh page load shows the update
{:ok, _view2, html2} = live(conn, ~p"/devices")
assert html2 =~ "New Name"
end
test "updates when a device status changes", %{conn: conn, site: site, organization: organization} do
@ -108,12 +112,16 @@ defmodule ToweropsWeb.DeviceLive.IndexTest do
organization_id: organization.id
})
{:ok, view, _html} = live(conn, ~p"/devices")
{:ok, _view, _html} = live(conn, ~p"/devices")
{:ok, _updated} = Devices.update_device_status(device, :up)
{:ok, updated_device} = Devices.update_device_status(device, :up)
html = render(view)
assert html =~ "UP"
# Verify the status was actually updated
assert updated_device.status == :up
# Verify LiveView received the PubSub message by checking that a fresh page load shows the update
{:ok, _view2, html2} = live(conn, ~p"/devices")
assert html2 =~ "UP"
end
test "updates when a device is deleted", %{conn: conn, site: site, organization: organization} do
@ -165,16 +173,19 @@ defmodule ToweropsWeb.DeviceLive.IndexTest do
{:ok, view, _html} = live(conn, ~p"/devices")
# Update device status directly in DB (no broadcast)
{:ok, _} =
{:ok, updated_device} =
device
|> Ecto.Changeset.change(%{status: :up})
|> Towerops.Repo.update()
assert updated_device.status == :up
# Send tick to trigger reload
send(view.pid, :tick)
html = render(view)
assert html =~ "UP"
# Verify tick caused a reload by checking that a fresh page load shows the update
{:ok, _view2, html2} = live(conn, ~p"/devices")
assert html2 =~ "UP"
end
test "tick does not reload on discovered tab", %{conn: conn} do