parent
0f3f16d443
commit
c7a236e504
51 changed files with 3226 additions and 1043 deletions
1290
findings.md
1290
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 ->
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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 """
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"])
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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})
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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'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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue