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 -> Enum.find_value(devices, {:error, :invalid_code}, fn device ->
if verify_totp(device.totp_secret, code) do if verify_totp(device.totp_secret, code) do
# Update last_used_at and return updated device update_totp_device_timestamp(device, user_id)
updated_device =
device
|> UserTotpDevice.touch_changeset()
|> Repo.update!()
{:ok, updated_device}
end end
end) 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 """ @doc """
Deletes a TOTP device. Deletes a TOTP device.

View file

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

View file

@ -310,6 +310,85 @@ defmodule Towerops.Admin do
|> Repo.all() |> Repo.all()
end 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 ## Global Pricing Management
@doc """ @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: ""})" "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 # 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(%{ case Alerts.create_alert(%{
device_id: primary_device.id, device_id: primary_device.id,
alert_type: "site_outage", alert_type: "site_outage",

View file

@ -322,6 +322,27 @@ defmodule Towerops.Devices do
end end
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 """ @doc """
Finds a device by IP address within a site, optionally excluding a specific device ID. Finds a device by IP address within a site, optionally excluding a specific device ID.
Returns nil if no device found. Returns nil if no device found.

View file

@ -334,6 +334,19 @@ defmodule Towerops.Gaiia do
end end
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 """ @doc """
Get subscriber impact for multiple devices (batch). Get subscriber impact for multiple devices (batch).
Returns `%{device_id => %{subscriber_count: integer, mrr: Decimal.t()}}`. 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 # Group by device and compute subscriber counts + MRR
best_links best_links
|> Enum.group_by(& &1.device_id) |> Enum.group_by(& &1.device_id)
|> Map.new(fn {device_id, device_links} -> |> Map.new(&compute_device_impact/1)
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)
end end
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_mac"}), do: 1
defp match_method_priority(%{match_method: "wireless_ip"}), do: 2 defp match_method_priority(%{match_method: "wireless_ip"}), do: 2
defp match_method_priority(%{match_method: "arp_mac"}), do: 3 defp match_method_priority(%{match_method: "arp_mac"}), do: 3

View file

@ -269,6 +269,33 @@ defmodule Towerops.Monitoring do
) )
end 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 """ @doc """
Gets graph data for a check, combining check_results and legacy sensor readings. 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)) rule = Enum.find(policy.rules, &(&1.position == incident.current_rule_position))
timeout_seconds = (rule && rule.timeout_minutes * 60) || 1800 timeout_seconds = (rule && rule.timeout_minutes * 60) || 1800
%{incident_id: incident.id} case %{incident_id: incident.id}
|> EscalationCheckWorker.new(schedule_in: timeout_seconds) |> EscalationCheckWorker.new(schedule_in: timeout_seconds)
|> Oban.insert() |> 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 end
defp advance_escalation(incident, policy) do defp advance_escalation(incident, policy) do

View file

@ -55,6 +55,42 @@ defmodule Towerops.Preseem do
|> Repo.all() |> Repo.all()
end 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." @doc "Link a Preseem AP to a device manually."
def link_access_point(access_point_id, device_id) do def link_access_point(access_point_id, device_id) do
case Repo.get(AccessPoint, access_point_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." @doc "Compute fleet profiles for all matched APs in an organization."
def compute_fleet_profiles(organization_id) do def compute_fleet_profiles(organization_id) do
require Logger
now = DateTime.truncate(DateTime.utc_now(), :second) now = DateTime.truncate(DateTime.utc_now(), :second)
matched_aps = matched_aps =
@ -25,14 +27,21 @@ defmodule Towerops.Preseem.FleetIntelligence do
{extract_manufacturer(ap.model), ap.model} {extract_manufacturer(ap.model), ap.model}
end) end)
count = {success_count, error_count} =
Enum.reduce(groups, 0, fn {{manufacturer, model}, aps}, acc -> Enum.reduce(groups, {0, 0}, fn {{manufacturer, model}, aps}, {success, errors} ->
profile_attrs = compute_model_profile(manufacturer, model, aps, now) 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) 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 end
defp extract_manufacturer(model) when is_binary(model) do 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 # 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. # rows with NULL firmware_version. Ecto's get_by also rejects nil values.
defp upsert_fleet_profile(organization_id, attrs) do defp upsert_fleet_profile(organization_id, attrs) do
require Logger
full_attrs = Map.put(attrs, :organization_id, organization_id) full_attrs = Map.put(attrs, :organization_id, organization_id)
existing = existing =
@ -95,16 +106,29 @@ defmodule Towerops.Preseem.FleetIntelligence do
|> where([fp], is_nil(fp.firmware_version)) |> where([fp], is_nil(fp.firmware_version))
|> Repo.one() |> Repo.one()
case existing do result =
nil -> case existing do
%FleetProfile{} nil ->
|> FleetProfile.changeset(full_attrs) %FleetProfile{}
|> Repo.insert!() |> FleetProfile.changeset(full_attrs)
|> Repo.insert()
profile -> profile ->
profile profile
|> FleetProfile.changeset(full_attrs) |> FleetProfile.changeset(full_attrs)
|> Repo.update!() |> 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 end
end end

View file

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

View file

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

View file

@ -28,18 +28,33 @@ defmodule Towerops.Snmp.Profiles.Vendors.AlliedTelesis do
# Model often in sysDescr # Model often in sysDescr
case Client.get(client_opts, @sysdescr_oid) do case Client.get(client_opts, @sysdescr_oid) do
{:ok, descr} when is_binary(descr) -> {:ok, descr} when is_binary(descr) ->
cond do extract_allied_telesis_model(descr)
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
_ -> _ ->
nil nil
end end
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 @impl true
def discover_wireless_sensors(client_opts) do def discover_wireless_sensors(client_opts) do
Vendor.fetch_sensors(wireless_oid_defs(), client_opts) Vendor.fetch_sensors(wireless_oid_defs(), client_opts)

View file

@ -310,16 +310,28 @@ defmodule Towerops.Topology do
end end
with {:ok, link} <- result do with {:ok, link} <- result do
# Insert evidence records - log failures but don't crash
Enum.each(evidence_attrs, fn ev -> Enum.each(evidence_attrs, fn ev ->
%DeviceLinkEvidence{} insert_link_evidence(link.id, ev)
|> DeviceLinkEvidence.changeset(Map.put(ev, :device_link_id, link.id))
|> Repo.insert!()
end) end)
{:ok, Repo.reload!(link)} {:ok, Repo.reload!(link)}
end end
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 """ @doc """
Infer the role of a device from its SNMP data and neighbor reports. Infer the role of a device from its SNMP data and neighbor reports.
Returns a role string. Does not write to the database. 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 -> Enum.each(pending, fn user_id ->
%{user_id: user_id} case %{user_id: user_id}
|> new() |> new()
|> Oban.insert() |> 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) end)
:ok :ok
@ -67,9 +74,16 @@ defmodule Towerops.Workers.AlertDigestWorker do
Schedules delivery 5 minutes from now to batch more alerts. Schedules delivery 5 minutes from now to batch more alerts.
""" """
def enqueue(user_id) do def enqueue(user_id) do
%{user_id: user_id} case %{user_id: user_id}
|> new(schedule_in: 300, unique: [period: 300, keys: [:user_id]]) |> new(schedule_in: 300, unique: [period: 300, keys: [:user_id]])
|> Oban.insert() |> 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 end
defp send_digest_notification(alerts) do defp send_digest_notification(alerts) do

View file

@ -30,9 +30,13 @@ defmodule Towerops.Workers.CapacityInsightWorker do
@impl Oban.Worker @impl Oban.Worker
def perform(%Oban.Job{}) do 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 ->
Enum.each(org_ids, &evaluate_organization/1) Organization
|> select([o], o.id)
|> Repo.stream()
|> Enum.each(&evaluate_organization/1)
end)
:ok :ok
end end

View file

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

View file

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

View file

@ -20,14 +20,18 @@ defmodule Towerops.Workers.SystemInsightWorker do
@impl Oban.Worker @impl Oban.Worker
def perform(%Oban.Job{}) do 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 -> Enum.each(offline_agents, &generate_agent_offline_insight/1)
offline_agents = list_offline_agents(org_id) auto_resolve_recovered_agents(org_id, offline_ids)
offline_ids = MapSet.new(offline_agents, & &1.id) end)
Enum.each(offline_agents, &generate_agent_offline_insight/1)
auto_resolve_recovered_agents(org_id, offline_ids)
end) end)
:ok :ok

View file

@ -65,6 +65,13 @@ defmodule Towerops.Workers.WeatherSyncWorker do
defp schedule_next do defp schedule_next do
# Schedule next run in 15 minutes # 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
end end

View file

@ -37,11 +37,11 @@ defmodule Towerops.Workers.WirelessInsightWorker do
@impl Oban.Worker @impl Oban.Worker
def perform(%Oban.Job{}) do 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 ->
Enum.each(org_ids, fn org_id -> Organization
organization = Repo.get!(Organization, org_id) |> Repo.stream()
process_organization(organization) |> Enum.each(&process_organization/1)
end) end)
:ok :ok

View file

@ -205,7 +205,11 @@ defmodule ToweropsWeb.Api.MobileController do
case verify_organization_access(user, org_id) do case verify_organization_access(user, org_id) do
{:ok, _org} -> {: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 = alerts =
org_id org_id

View file

@ -90,15 +90,17 @@ defmodule ToweropsWeb.Api.V1.CheckResultsController do
end end
defp get_org_device(device_id, organization_id) do 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 device ->
{:ok, device} if device.organization_id == organization_id do
else {:ok, device}
{:error, :forbidden} else
{:error, :forbidden}
end
end end
rescue
Ecto.NoResultsError -> {:error, :not_found}
end end
defp parse_int(nil, default), do: default 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 def create(conn, %{"organization_id" => organization_id} = params) do
with {:ok, raw_body} <- get_raw_body(conn), with {:ok, raw_body} <- get_raw_body(conn),
{:ok, integration} <- get_pagerduty_integration(organization_id), {:ok, integration} <- get_pagerduty_integration(organization_id),
:ok <- validate_organization_match(integration, organization_id),
:ok <- verify_signature(conn, raw_body, integration) do :ok <- verify_signature(conn, raw_body, integration) do
# params already parsed by Phoenix JSON parser # params already parsed by Phoenix JSON parser
process_webhook(params, organization_id) process_webhook(params, organization_id)
json(conn, %{status: "accepted"}) json(conn, %{status: "accepted"})
else 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} -> {:error, :not_configured} ->
conn |> put_status(404) |> json(%{error: "Integration not found"}) conn |> put_status(404) |> json(%{error: "Integration not found"})
@ -51,6 +57,14 @@ defmodule ToweropsWeb.Api.V1.PagerdutyWebhookController do
end end
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 defp verify_signature(conn, raw_body, integration) do
webhook_secret = get_in(integration.credentials, ["webhook_secret"]) 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() signature = conn |> get_req_header("stripe-signature") |> List.first()
raw_body = conn.private[:raw_body] || "" raw_body = conn.private[:raw_body] || ""
case verify_signature(raw_body, signature) do with :ok <- verify_signature(raw_body, signature),
:ok -> {:ok, event} <- decode_payload(raw_body) do
event = Jason.decode!(raw_body) process_webhook(conn, event)
else
case WebhookProcessor.process(event) do
:ok ->
json(conn, %{received: true})
{:error, reason} ->
Logger.error("Webhook processing failed: #{inspect(reason)}")
json(conn, %{received: true})
end
{:error, :invalid_signature} -> {:error, :invalid_signature} ->
Logger.warning("Invalid webhook signature") Logger.warning("Invalid webhook signature")
@ -35,6 +26,11 @@ defmodule ToweropsWeb.Api.V1.StripeWebhookController do
|> put_status(401) |> put_status(401)
|> json(%{error: "Invalid signature"}) |> json(%{error: "Invalid signature"})
{:error, :invalid_json} ->
conn
|> put_status(400)
|> json(%{error: "Invalid JSON payload"})
{:error, reason} -> {:error, reason} ->
Logger.error("Webhook verification failed: #{inspect(reason)}") Logger.error("Webhook verification failed: #{inspect(reason)}")
@ -44,6 +40,28 @@ defmodule ToweropsWeb.Api.V1.StripeWebhookController do
end end
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 defp verify_signature(payload, signature_header) do
webhook_secret = Application.fetch_env!(:towerops, :stripe_webhook_secret) 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 use ToweropsWeb, :live_view
import Ecto.Query alias Towerops.Admin
alias Towerops.Repo
@impl true @impl true
def mount(_params, _session, socket) do 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) limit = Keyword.get(opts, :limit, socket.assigns.per_page)
offset = (socket.assigns.page - 1) * socket.assigns.per_page offset = (socket.assigns.page - 1) * socket.assigns.per_page
query = Admin.list_audit_logs_with_filters(
from(a in Towerops.Admin.AuditLog, limit: limit,
preload: [:superuser, :target_user], offset: offset,
order_by: [desc: a.inserted_at] email: socket.assigns.search_email,
) action: socket.assigns.search_action,
date_from: socket.assigns.date_from,
query = apply_filters(query, socket) date_to: socket.assigns.date_to
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}%")
) )
end 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 defp generate_csv(logs) do
headers = "Timestamp,Action,Actor Email,Target User Email,IP Address,Request Path,Metadata\n" headers = "Timestamp,Action,Actor Email,Target User Email,IP Address,Request Path,Metadata\n"

View file

@ -438,6 +438,7 @@
<button <button
type="button" type="button"
phx-hook="CopyToClipboard" phx-hook="CopyToClipboard"
phx-update="ignore"
data-target="#agent-token-input" data-target="#agent-token-input"
id={"copy-token-#{@new_token.agent_token.id}"} 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" 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 <button
type="button" type="button"
phx-hook="CopyToClipboard" phx-hook="CopyToClipboard"
phx-update="ignore"
data-target="#docker-compose-example" data-target="#docker-compose-example"
id={"copy-docker-compose-#{@new_token.agent_token.id}"} 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 <.icon name="hero-clipboard" class="h-3 w-3" /> Copy to clipboard
</button> </button>

View file

@ -5,13 +5,10 @@ defmodule ToweropsWeb.ConfigTimelineLive do
""" """
use ToweropsWeb, :live_view use ToweropsWeb, :live_view
import Ecto.Query
alias Towerops.ConfigChanges alias Towerops.ConfigChanges
alias Towerops.Devices alias Towerops.Devices
alias Towerops.Preseem.AccessPoint alias Towerops.Monitoring
alias Towerops.Preseem.SubscriberMetric alias Towerops.Preseem
alias Towerops.Repo
alias ToweropsWeb.Live.Helpers.AccessControl alias ToweropsWeb.Live.Helpers.AccessControl
@ranges %{ @ranges %{
@ -26,7 +23,12 @@ defmodule ToweropsWeb.ConfigTimelineLive do
case AccessControl.verify_device_access(device_id, organization.id) do case AccessControl.verify_device_access(device_id, organization.id) do
{:ok, _} -> {: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")} {:ok, assign(socket, device: device, page_title: "Config Timeline — #{device.name}", active_page: "devices")}
{:error, _} -> {:error, _} ->
@ -247,58 +249,11 @@ defmodule ToweropsWeb.ConfigTimelineLive do
# -- Data loaders -- # -- Data loaders --
defp load_qoe_data(device_id, since) do defp load_qoe_data(device_id, since) do
ap_ids = Preseem.get_device_qoe_data(device_id, since)
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 end
defp load_check_data(device_id, since) do defp load_check_data(device_id, since) do
check_ids = Monitoring.get_device_check_data(device_id, since)
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
end end
defp timeline_event(event) do defp timeline_event(event) do

View file

@ -3,7 +3,13 @@
current_scope={@current_scope} current_scope={@current_scope}
active_page="dashboard" 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> </div>
<%!-- ═══════════════════════════════════════════════ <%!-- ═══════════════════════════════════════════════
NOC HEADER — dense, zero chrome NOC HEADER — dense, zero chrome

View file

@ -8,7 +8,6 @@ defmodule ToweropsWeb.DeviceLive.Form do
alias Towerops.Devices.Device, as: DeviceSchema alias Towerops.Devices.Device, as: DeviceSchema
alias Towerops.OnCall alias Towerops.OnCall
alias Towerops.Organizations.SubscriptionLimits alias Towerops.Organizations.SubscriptionLimits
alias Towerops.Repo
alias Towerops.Sites alias Towerops.Sites
alias Towerops.Workers.DiscoveryWorker alias Towerops.Workers.DiscoveryWorker
alias ToweropsWeb.Live.Helpers.AccessControl alias ToweropsWeb.Live.Helpers.AccessControl
@ -106,7 +105,7 @@ defmodule ToweropsWeb.DeviceLive.Form do
case AccessControl.verify_device_access(id, organization.id) do case AccessControl.verify_device_access(id, organization.id) do
{:ok, device} -> {:ok, device} ->
# Preload necessary associations for agent resolution # 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) apply_edit_action(socket, device)
{:error, :not_found} -> {:error, :not_found} ->
@ -157,8 +156,7 @@ defmodule ToweropsWeb.DeviceLive.Form do
mikrotik_config = Devices.get_mikrotik_config(device) mikrotik_config = Devices.get_mikrotik_config(device)
# Check if device is MikroTik (based on SNMP discovery) # Check if device is MikroTik (based on SNMP discovery)
device_with_snmp = Repo.preload(device, :snmp_device, force: true) is_mikrotik = Devices.mikrotik_device?(device)
is_mikrotik = mikrotik_device?(device_with_snmp)
# Determine monitoring mode based on current snmp_enabled value # Determine monitoring mode based on current snmp_enabled value
monitoring_mode = if device.snmp_enabled, do: "snmp_and_icmp", else: "icmp_only" 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) |> assign(:monitoring_mode, monitoring_mode)
end 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 @impl true
def handle_event("switch_monitoring_mode", %{"mode" => mode}, socket) do def handle_event("switch_monitoring_mode", %{"mode" => mode}, socket) do
# Update snmp_enabled based on the selected mode # Update snmp_enabled based on the selected mode

View file

@ -2,7 +2,7 @@
flash={@flash} flash={@flash}
current_scope={@current_scope} 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="border-b border-gray-200 pb-5 dark:border-white/5">
<div class="mb-4"> <div class="mb-4">
<.link <.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" 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" phx-hook="MikrotikPortSync"
id="mikrotik-config-section" id="mikrotik-config-section"
phx-update="ignore"
> >
<div> <div>
<h2 class="text-base/7 font-semibold text-gray-900 dark:text-white"> <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(:page_title, t_equipment("Devices"))
|> assign(:timezone, socket.assigns.current_scope.timezone) |> assign(:timezone, socket.assigns.current_scope.timezone)
|> assign(:has_devices, devices != []) |> 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(:sites_enabled, organization.use_sites)
|> assign(:has_sites, sites != []) |> assign(:has_sites, sites != [])
|> assign(:reorder_mode, false) |> assign(:reorder_mode, false)
@ -44,7 +44,8 @@ defmodule ToweropsWeb.DeviceLive.Index do
|> assign(:site_subscribers, site_subscribers) |> assign(:site_subscribers, site_subscribers)
|> assign(:search_query, "") |> assign(:search_query, "")
|> assign(:status_filter, "all") |> assign(:status_filter, "all")
|> assign(:pending_reload_ref, nil)} |> assign(:pending_reload_ref, nil)
|> assign(:reload_counter, 0)}
end end
@impl true @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. # Without this, the table won't appear until the next PubSub update or tick event.
devices = Devices.list_organization_devices(organization.id) devices = Devices.list_organization_devices(organization.id)
@ -94,7 +95,8 @@ defmodule ToweropsWeb.DeviceLive.Index do
|> assign(:active_tab, "existing") |> assign(:active_tab, "existing")
|> assign(:discovered_devices, []) |> assign(:discovered_devices, [])
|> assign(:pagination, nil) |> 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
end end
@ -188,7 +190,7 @@ defmodule ToweropsWeb.DeviceLive.Index do
{:noreply, {:noreply,
socket socket
|> assign(:has_devices, devices != []) |> 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"))} |> put_flash(:info, t_equipment("Order reset to alphabetical"))}
end end
@ -237,7 +239,7 @@ defmodule ToweropsWeb.DeviceLive.Index do
{:noreply, {:noreply,
socket socket
|> assign(:has_devices, devices != []) |> 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)} |> put_flash(:info, success_message)}
{:error, _changeset} -> {:error, _changeset} ->
@ -284,7 +286,7 @@ defmodule ToweropsWeb.DeviceLive.Index do
socket = socket =
socket socket
|> assign(:pending_reload_ref, nil) |> assign(:pending_reload_ref, nil)
|> stream(:device_rows, build_device_rows(devices), reset: true) |> assign(:device_rows, build_device_rows(devices))
{:noreply, socket} {:noreply, socket}
end end
@ -335,7 +337,7 @@ defmodule ToweropsWeb.DeviceLive.Index do
{current, limit} = SubscriptionLimits.device_quota(organization) {current, limit} = SubscriptionLimits.device_quota(organization)
socket socket
|> stream(:device_rows, build_device_rows(devices), reset: true) |> assign(:device_rows, build_device_rows(devices))
|> assign(:has_devices, devices != []) |> assign(:has_devices, devices != [])
|> assign(:device_quota, %{current: current, limit: limit}) |> assign(:device_quota, %{current: current, limit: limit})
|> assign(:site_subscribers, load_site_subscribers(devices)) |> 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_up, Enum.count(devices, &(&1.status == :up)))
|> assign(:total_down, Enum.count(devices, &(&1.status == :down))) |> assign(:total_down, Enum.count(devices, &(&1.status == :down)))
|> assign(:total_unknown, Enum.count(devices, &(&1.status == :unknown))) |> assign(:total_unknown, Enum.count(devices, &(&1.status == :unknown)))
|> assign(:reload_counter, (socket.assigns[:reload_counter] || 0) + 1)
end end
# Lightweight reload: stream only, skip quota query (for status/update changes) # 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) devices = Devices.list_organization_devices(organization.id)
socket socket
|> stream(:device_rows, build_device_rows(devices), reset: true) |> assign(:device_rows, build_device_rows(devices))
|> assign(:has_devices, devices != []) |> assign(:has_devices, devices != [])
|> assign(:pending_reload_ref, nil) |> assign(:pending_reload_ref, nil)
|> assign(:total_devices, length(devices)) |> assign(:total_devices, length(devices))
|> assign(:total_up, Enum.count(devices, &(&1.status == :up))) |> assign(:total_up, Enum.count(devices, &(&1.status == :up)))
|> assign(:total_down, Enum.count(devices, &(&1.status == :down))) |> 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))) |> assign(:total_unknown, Enum.count(devices, &(&1.status == :unknown)))
end end
@ -366,7 +370,7 @@ defmodule ToweropsWeb.DeviceLive.Index do
filtered = filter_devices(devices, socket.assigns.search_query, socket.assigns.status_filter) filtered = filter_devices(devices, socket.assigns.search_query, socket.assigns.status_filter)
socket socket
|> stream(:device_rows, build_device_rows(filtered), reset: true) |> assign(:device_rows, build_device_rows(filtered))
|> assign(:has_devices, devices != []) |> assign(:has_devices, devices != [])
end end
@ -396,12 +400,19 @@ defmodule ToweropsWeb.DeviceLive.Index do
defp enqueue_discovery(device_id) do defp enqueue_discovery(device_id) do
if Application.get_env(:towerops, :env) == :test 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 else
DiscoveryWorker.enqueue(device_id) DiscoveryWorker.enqueue(device_id)
end end
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 defp build_device_rows(devices) do
device_ids = Enum.map(devices, & &1.id) device_ids = Enum.map(devices, & &1.id)
health_data = Monitoring.get_device_health_summary(device_ids) health_data = Monitoring.get_device_health_summary(device_ids)

View file

@ -248,7 +248,12 @@
<div class="mt-2 flow-root"> <div class="mt-2 flow-root">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"> <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"> <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"> <thead class="bg-gray-50 dark:bg-gray-800/50">
<tr> <tr>
<th :if={@reorder_mode} scope="col" class="py-2 pl-4 pr-2 w-8 sm:pl-3"> <th :if={@reorder_mode} scope="col" class="py-2 pl-4 pr-2 w-8 sm:pl-3">
@ -299,12 +304,11 @@
<tbody <tbody
id="device-rows" id="device-rows"
class="divide-y divide-gray-100 dark:divide-white/5" 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 %> <%= if row.type == :site_header do %>
<tr <tr
id={dom_id} id={row.id}
class="bg-gray-50/80 dark:bg-gray-800/30" class="bg-gray-50/80 dark:bg-gray-800/30"
data-site-id={if row.site, do: row.site.id, else: "no-site"} data-site-id={if row.site, do: row.site.id, else: "no-site"}
draggable={if @reorder_mode && row.site, do: "true", else: "false"} draggable={if @reorder_mode && row.site, do: "true", else: "false"}
@ -375,7 +379,7 @@
</tr> </tr>
<% else %> <% else %>
<tr <tr
id={dom_id} id={row.id}
class={[ class={[
"device-row hover:bg-blue-50/50 dark:hover:bg-blue-900/10 transition-colors cursor-pointer", "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" 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 @moduledoc false
use ToweropsWeb, :live_view use ToweropsWeb, :live_view
import Ecto.Query
alias Towerops.Agents alias Towerops.Agents
alias Towerops.Devices alias Towerops.Devices
alias Towerops.Devices.Firmware alias Towerops.Devices.Firmware
alias Towerops.Devices.MikrotikBackups alias Towerops.Devices.MikrotikBackups
alias Towerops.Gaiia alias Towerops.Gaiia
alias Towerops.Monitoring alias Towerops.Monitoring
alias Towerops.Repo
alias Towerops.Snmp alias Towerops.Snmp
alias Towerops.Workers.DiscoveryWorker alias Towerops.Workers.DiscoveryWorker
alias ToweropsWeb.CheckLive.FormComponent 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. # Base data needed by all tabs: device record, agent info, SNMP device/sensors/interfaces.
defp assign_base_data(socket, device_id) do defp assign_base_data(socket, device_id) do
device = Devices.get_device!(device_id) case Devices.get_device(device_id) do
device_with_associations = Repo.preload(device, [:organization, site: [organization: :default_agent_token]]) nil ->
{agent_token_id, agent_source} = Agents.get_effective_agent_token_with_source(device_with_associations) # Device was deleted between access check and fetch (race condition)
agent_info = load_agent_info(agent_token_id, agent_source) raise Ecto.NoResultsError, queryable: Towerops.Devices.DeviceSchema
snmp_data = load_snmp_data(device_id)
socket device ->
|> assign(:page_title, device.name) device_with_associations = Devices.get_device_with_agent_info(device)
|> assign(:timezone, socket.assigns.current_scope.timezone) {agent_token_id, agent_source} = Agents.get_effective_agent_token_with_source(device_with_associations)
|> assign(:device, device) agent_info = load_agent_info(agent_token_id, agent_source)
|> assign(:agent_info, agent_info) snmp_data = load_snmp_data(device_id)
|> assign(:snmp_device, snmp_data.device)
|> assign(:snmp_interfaces, snmp_data.interfaces) socket
|> assign(:snmp_sensors, snmp_data.sensors) |> 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 end
# Data needed for the tab navigation bar to decide which tabs to show. # 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 = wireless_client_subscribers =
if Enum.any?(wireless_clients) do if Enum.any?(wireless_clients) do
# Get all device-subscriber links for this device # Get all device-subscriber links for this device
links = links = Gaiia.list_device_subscriber_links_with_macs(device_id)
Repo.all(
from(l in Towerops.Gaiia.DeviceSubscriberLink,
where: l.device_id == ^device_id,
where: not is_nil(l.subscriber_mac),
preload: [:gaiia_account]
)
)
# Build a map of MAC address → link # Build a map of MAC address → link
Map.new(links, &{String.downcase(&1.subscriber_mac), &1}) 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.Agents
alias Towerops.Devices alias Towerops.Devices
alias Towerops.Devices.DeviceSchema
alias Towerops.Monitoring alias Towerops.Monitoring
alias Towerops.Snmp alias Towerops.Snmp
@ -303,7 +304,12 @@ defmodule ToweropsWeb.GraphLive.Show do
sensor_type = socket.assigns.sensor_type sensor_type = socket.assigns.sensor_type
range = socket.assigns.range 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) {chart_data, title_suffix} = load_chart_data_for_type(socket.assigns, range)
{title, unit, auto_scale} = get_chart_config(sensor_type) {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 defp load_live_mode_graph_data(socket) do
device_id = socket.assigns.device_id device_id = socket.assigns.device_id
sensor_type = socket.assigns.sensor_type 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, unit, auto_scale} = get_chart_config(sensor_type)
title_suffix = get_title_suffix(socket.assigns) title_suffix = get_title_suffix(socket.assigns)

View file

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

View file

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

View file

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

View file

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

View file

@ -395,6 +395,7 @@
<button <button
type="button" type="button"
phx-hook="CopyToClipboard" phx-hook="CopyToClipboard"
phx-update="ignore"
data-target="#gaiia-webhook-url" data-target="#gaiia-webhook-url"
id="copy-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" 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 <button
type="button" type="button"
phx-hook="CopyToClipboard" phx-hook="CopyToClipboard"
phx-update="ignore"
data-target="#gaiia-webhook-secret" data-target="#gaiia-webhook-secret"
id="copy-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" 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 defp enqueue_discovery(device_id) do
if Application.get_env(:towerops, :env) == :test do if Application.get_env(:towerops, :env) == :test do
# In test, run synchronously # 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 else
# In dev/prod, enqueue to Oban # In dev/prod, enqueue to Oban
DiscoveryWorker.enqueue(device_id) DiscoveryWorker.enqueue(device_id)
end end
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 end

View file

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

View file

@ -1568,6 +1568,7 @@
<button <button
type="button" type="button"
phx-hook="CopyToClipboard" phx-hook="CopyToClipboard"
phx-update="ignore"
data-target="#api-token-input" data-target="#api-token-input"
id="copy-api-token" 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" 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 <button
type="button" type="button"
phx-hook="CopyToClipboard" phx-hook="CopyToClipboard"
phx-update="ignore"
data-target="#recovery-codes-text" data-target="#recovery-codes-text"
id="copy-recovery-codes" 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" 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 <div
id="cy-weathermap-container" id="cy-weathermap-container"
phx-hook="WeathermapViewer" phx-hook="WeathermapViewer"
phx-update="ignore"
class="w-full h-full" class="w-full h-full"
data-topology={Jason.encode!(@topology)} 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 defp maybe_set_token_authenticated_at(token, authenticated_at) do
Towerops.AccountsFixtures.override_token_authenticated_at(token, authenticated_at) Towerops.AccountsFixtures.override_token_authenticated_at(token, authenticated_at)
end 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 end

View file

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

View file

@ -62,5 +62,26 @@ defmodule ToweropsWeb.Api.MobileControllerTest do
assert %{"alerts" => _alerts} = json_response(conn, 200) assert %{"alerts" => _alerts} = json_response(conn, 200)
end 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
end end

View file

@ -230,7 +230,17 @@ defmodule ToweropsWeb.DeviceLive.FormTest do
|> form("#device-form", device: %{name: "Updated Name"}) |> form("#device-form", device: %{name: "Updated Name"})
|> render_change() |> 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 end
test "saves edited device", %{conn: conn, device: device} do test "saves edited device", %{conn: conn, device: device} do

View file

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