parent
0f3f16d443
commit
c7a236e504
51 changed files with 3226 additions and 1043 deletions
1296
findings.md
1296
findings.md
File diff suppressed because it is too large
Load diff
1775
findings_librenms.md
Normal file
1775
findings_librenms.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 """
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,19 +386,26 @@ 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} ->
|
||||
|> 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()
|
||||
|
||||
org_id = hd(device_links).organization_id
|
||||
# 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)
|
||||
end
|
||||
end
|
||||
|
||||
defp match_method_priority(%{match_method: "wireless_mac"}), do: 1
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
case %{incident_id: incident.id}
|
||||
|> 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
|
||||
|
||||
defp advance_escalation(incident, policy) do
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
result =
|
||||
case existing do
|
||||
nil ->
|
||||
%FleetProfile{}
|
||||
|> FleetProfile.changeset(full_attrs)
|
||||
|> Repo.insert!()
|
||||
|> Repo.insert()
|
||||
|
||||
profile ->
|
||||
profile
|
||||
|> 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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,13 +864,16 @@ defmodule Towerops.Snmp.Profiles.Base do
|
|||
end
|
||||
|
||||
defp extract_hr_storage_index(oid) do
|
||||
index_str =
|
||||
oid
|
||||
|> String.split(".")
|
||||
|> List.last()
|
||||
|> String.to_integer()
|
||||
rescue
|
||||
|
||||
case Integer.parse(index_str || "0") do
|
||||
{n, _} when n >= 0 -> n
|
||||
_ -> 0
|
||||
end
|
||||
end
|
||||
|
||||
# Map hrStorageType OID to memory pool type (RAM, swap)
|
||||
defp hr_storage_type_to_memory_type(type_oid) when is_list(type_oid) do
|
||||
|
|
@ -943,13 +958,16 @@ defmodule Towerops.Snmp.Profiles.Base do
|
|||
end
|
||||
|
||||
defp extract_entity_index(oid) do
|
||||
index_str =
|
||||
oid
|
||||
|> String.split(".")
|
||||
|> List.last()
|
||||
|> String.to_integer()
|
||||
rescue
|
||||
|
||||
case Integer.parse(index_str || "0") do
|
||||
{n, _} when n >= 0 -> n
|
||||
_ -> 0
|
||||
end
|
||||
end
|
||||
|
||||
# ENTITY-MIB PhysicalClass values mapping
|
||||
@entity_class_map %{
|
||||
|
|
@ -1139,13 +1157,16 @@ defmodule Towerops.Snmp.Profiles.Base do
|
|||
end
|
||||
|
||||
defp extract_processor_index(oid) when is_binary(oid) do
|
||||
index_str =
|
||||
oid
|
||||
|> String.split(".")
|
||||
|> List.last()
|
||||
|> String.to_integer()
|
||||
rescue
|
||||
|
||||
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
|
||||
index_str =
|
||||
oid_string
|
||||
|> String.split(".")
|
||||
|> List.last()
|
||||
|> String.to_integer()
|
||||
|
||||
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,13 +1636,16 @@ defmodule Towerops.Snmp.Profiles.Base do
|
|||
end
|
||||
|
||||
defp extract_vlan_id_from_oid(oid) when is_binary(oid) do
|
||||
index_str =
|
||||
oid
|
||||
|> String.split(".")
|
||||
|> List.last()
|
||||
|> String.to_integer()
|
||||
rescue
|
||||
|
||||
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
|
||||
end
|
||||
rescue
|
||||
[a, b, c, d] -> parse_subnet_octets([a, b, c, d])
|
||||
_ -> nil
|
||||
end
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -32,9 +32,16 @@ defmodule Towerops.Workers.AlertDigestWorker do
|
|||
)
|
||||
|
||||
Enum.each(pending, fn user_id ->
|
||||
%{user_id: user_id}
|
||||
case %{user_id: user_id}
|
||||
|> 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)
|
||||
|
||||
: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}
|
||||
case %{user_id: 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
|
||||
|
||||
defp send_digest_notification(alerts) do
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -20,15 +20,19 @@ defmodule Towerops.Workers.SystemInsightWorker 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 ->
|
||||
# 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(offline_agents, &generate_agent_offline_insight/1)
|
||||
auto_resolve_recovered_agents(org_id, offline_ids)
|
||||
end)
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
device ->
|
||||
if device.organization_id == organization_id do
|
||||
{:ok, device}
|
||||
else
|
||||
{:error, :forbidden}
|
||||
end
|
||||
rescue
|
||||
Ecto.NoResultsError -> {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_int(nil, default), do: default
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,61 +319,14 @@ 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]
|
||||
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
|
||||
)
|
||||
|
||||
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}%")
|
||||
)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,8 +370,13 @@ 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]])
|
||||
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
|
||||
|
||||
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)
|
||||
|
|
@ -388,6 +390,7 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
|> 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.
|
||||
# Neighbors, ARP, MAC, VLANs, and IP addresses determine tab visibility.
|
||||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,14 @@ defmodule ToweropsWeb.MikrotikBackupLive.Compare do
|
|||
organization = socket.assigns.current_scope.organization
|
||||
|
||||
# Verify device access
|
||||
device = Devices.get_device!(device_id)
|
||||
case Devices.get_device(device_id) do
|
||||
nil ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, t("Device not found"))
|
||||
|> push_navigate(to: ~p"/devices")}
|
||||
|
||||
device ->
|
||||
device_with_site = Repo.preload(device, site: :organization)
|
||||
|
||||
if device_with_site.site.organization_id == organization.id do
|
||||
|
|
@ -29,6 +36,7 @@ defmodule ToweropsWeb.MikrotikBackupLive.Compare do
|
|||
|> push_navigate(to: ~p"/devices")}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(_params, _uri, socket) do
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue