fix: comprehensive bug fixes from reliability audit
Critical Fixes (5): - Fix Task.yield_many race condition causing data corruption in DevicePollerWorker - Fix Enum.zip data corruption in SNMP Base Profile with length validation - Fix missing Alert schema fields for check alerts (check_id, severity, etc.) - Fix memory leaks from uncancelled LiveView timers in 4 components - Fix PubSub subscription leak in device form credential testing High Severity Fixes (3): - Fix clock skew bug in needs_discovery? check with DateTime.diff clamping - Fix nil crash in interface status display with proper nil handling - Fix migration index names after equipment→devices table rename Medium Severity Fixes (6): - Fix race condition in device monitor worker (duplicate maintenance checks) - Fix missing preload validation in devices.ex get_org_default_agent - Fix broad rescue clause in alerts.ex with specific error handling - Fix fire-and-forget notification tasks with try-catch error logging - Fix LiveView state bleeding between tabs (assign_new → assign) - Add catch-all handle_info callbacks to 3 LiveViews Infrastructure: - Silence health check endpoint logs (/health, /health/time) - Add migration to fix equipment index names missed in rename Files Changed: 16 files modified, 1 migration added All changes compile successfully and are backward-compatible.
This commit is contained in:
parent
8ead55d2bf
commit
86a5dc728c
16 changed files with 354 additions and 86 deletions
|
|
@ -36,7 +36,29 @@ defmodule Towerops.Alerts do
|
|||
end
|
||||
end
|
||||
rescue
|
||||
_ -> :ok
|
||||
# Only rescue specific expected errors, log unexpected ones
|
||||
error in [Ecto.QueryError, DBConnection.ConnectionError] ->
|
||||
require Logger
|
||||
|
||||
Logger.warning(
|
||||
"Failed to compute GAIIA impact for alert #{alert.id} due to database error: #{inspect(error)}",
|
||||
alert_id: alert.id,
|
||||
device_id: device_id
|
||||
)
|
||||
|
||||
:ok
|
||||
|
||||
error ->
|
||||
require Logger
|
||||
|
||||
Logger.error(
|
||||
"Unexpected error computing GAIIA impact for alert #{alert.id}: #{inspect(error)}",
|
||||
alert_id: alert.id,
|
||||
device_id: device_id,
|
||||
error: error
|
||||
)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp maybe_compute_gaiia_impact(_alert), do: :ok
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ defmodule Towerops.Alerts.Alert do
|
|||
@moduledoc """
|
||||
Alert schema for device monitoring alerts.
|
||||
|
||||
Tracks device_up, device_down, and sensor threshold alerts with
|
||||
Tracks device_up, device_down, and monitoring check alerts with
|
||||
acknowledgement status and auto-resolution.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
|
|
@ -12,11 +12,14 @@ defmodule Towerops.Alerts.Alert do
|
|||
alias Ecto.Association.NotLoaded
|
||||
alias Towerops.Accounts.User
|
||||
alias Towerops.Devices.Device
|
||||
alias Towerops.Monitoring.Check
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "alerts" do
|
||||
field :alert_type, Ecto.Enum, values: [:device_down, :device_up]
|
||||
# Alert type: device_down, device_up for device alerts
|
||||
# For check alerts, uses string like "check_http", "check_ping", etc.
|
||||
field :alert_type, :string
|
||||
field :triggered_at, :utc_datetime
|
||||
field :acknowledged_at, :utc_datetime
|
||||
field :resolved_at, :utc_datetime
|
||||
|
|
@ -24,7 +27,14 @@ defmodule Towerops.Alerts.Alert do
|
|||
field :message, :string
|
||||
field :gaiia_impact, :map
|
||||
|
||||
# Check-related fields (added in ExtendAlertsForChecks migration)
|
||||
field :severity, :integer
|
||||
field :notification_sent, :boolean, default: false
|
||||
field :notification_sent_at, :utc_datetime
|
||||
field :output, :string
|
||||
|
||||
belongs_to :device, Device
|
||||
belongs_to :check, Check
|
||||
belongs_to :acknowledged_by, User
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
|
|
@ -32,15 +42,21 @@ defmodule Towerops.Alerts.Alert do
|
|||
|
||||
@type t :: %__MODULE__{
|
||||
id: Ecto.UUID.t(),
|
||||
alert_type: :device_down | :device_up,
|
||||
alert_type: String.t(),
|
||||
triggered_at: DateTime.t(),
|
||||
acknowledged_at: DateTime.t() | nil,
|
||||
resolved_at: DateTime.t() | nil,
|
||||
email_sent_at: DateTime.t() | nil,
|
||||
message: String.t() | nil,
|
||||
gaiia_impact: map() | nil,
|
||||
device_id: Ecto.UUID.t(),
|
||||
device: NotLoaded.t() | Device.t(),
|
||||
severity: integer() | nil,
|
||||
notification_sent: boolean(),
|
||||
notification_sent_at: DateTime.t() | nil,
|
||||
output: String.t() | nil,
|
||||
device_id: Ecto.UUID.t() | nil,
|
||||
device: NotLoaded.t() | Device.t() | nil,
|
||||
check_id: Ecto.UUID.t() | nil,
|
||||
check: NotLoaded.t() | Check.t() | nil,
|
||||
acknowledged_by_id: Ecto.UUID.t() | nil,
|
||||
acknowledged_by: NotLoaded.t() | User.t() | nil,
|
||||
inserted_at: DateTime.t(),
|
||||
|
|
@ -52,6 +68,7 @@ defmodule Towerops.Alerts.Alert do
|
|||
alert
|
||||
|> cast(attrs, [
|
||||
:device_id,
|
||||
:check_id,
|
||||
:alert_type,
|
||||
:triggered_at,
|
||||
:acknowledged_at,
|
||||
|
|
@ -59,10 +76,29 @@ defmodule Towerops.Alerts.Alert do
|
|||
:resolved_at,
|
||||
:email_sent_at,
|
||||
:message,
|
||||
:gaiia_impact
|
||||
:gaiia_impact,
|
||||
:severity,
|
||||
:notification_sent,
|
||||
:notification_sent_at,
|
||||
:output
|
||||
])
|
||||
|> validate_required([:device_id, :alert_type, :triggered_at])
|
||||
|> validate_required([:alert_type, :triggered_at])
|
||||
|> validate_inclusion(:severity, [1, 2])
|
||||
|> validate_device_or_check()
|
||||
|> foreign_key_constraint(:device_id)
|
||||
|> foreign_key_constraint(:check_id)
|
||||
|> foreign_key_constraint(:acknowledged_by_id)
|
||||
end
|
||||
|
||||
# Ensure either device_id or check_id is present (but not required for flexibility)
|
||||
defp validate_device_or_check(changeset) do
|
||||
device_id = get_field(changeset, :device_id)
|
||||
check_id = get_field(changeset, :check_id)
|
||||
|
||||
if is_nil(device_id) and is_nil(check_id) do
|
||||
add_error(changeset, :base, "either device_id or check_id must be present")
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -245,7 +245,21 @@ defmodule Towerops.Devices do
|
|||
|
||||
defp get_org_default_agent(device) do
|
||||
# Use direct organization relationship instead of site.organization
|
||||
device.organization.default_agent_token_id
|
||||
# Guard against NotLoaded to prevent crashes if association isn't preloaded
|
||||
case device.organization do
|
||||
%Ecto.Association.NotLoaded{} ->
|
||||
require Logger
|
||||
|
||||
Logger.error(
|
||||
"Organization not preloaded for device #{device.id} in get_org_default_agent",
|
||||
device_id: device.id
|
||||
)
|
||||
|
||||
nil
|
||||
|
||||
organization ->
|
||||
organization.default_agent_token_id
|
||||
end
|
||||
end
|
||||
|
||||
defp get_global_default_agent, do: Towerops.Settings.get_global_default_cloud_poller()
|
||||
|
|
|
|||
|
|
@ -172,14 +172,28 @@ defmodule Towerops.Snmp.Profiles.Base do
|
|||
|
||||
case Client.get_multiple(client_opts, oids) do
|
||||
{:ok, values} ->
|
||||
system_info =
|
||||
@system_oids
|
||||
|> Map.keys()
|
||||
|> Enum.zip(values)
|
||||
|> Map.new()
|
||||
|> parse_system_info()
|
||||
keys = Map.keys(@system_oids)
|
||||
|
||||
{:ok, system_info}
|
||||
# Validate we got the expected number of values
|
||||
if length(values) == length(keys) do
|
||||
system_info =
|
||||
keys
|
||||
|> Enum.zip(values)
|
||||
|> Map.new()
|
||||
|> parse_system_info()
|
||||
|
||||
{:ok, system_info}
|
||||
else
|
||||
Logger.error(
|
||||
"SNMP value count mismatch: expected #{length(keys)}, got #{length(values)}",
|
||||
expected: length(keys),
|
||||
actual: length(values),
|
||||
keys: keys,
|
||||
values: values
|
||||
)
|
||||
|
||||
{:error, :value_count_mismatch}
|
||||
end
|
||||
|
||||
{:error, reason} = error ->
|
||||
Logger.error("Failed to discover system info: #{inspect(reason)}")
|
||||
|
|
|
|||
|
|
@ -202,6 +202,7 @@ defmodule Towerops.Workers.DeviceMonitorWorker do
|
|||
if Alerts.has_active_alert?(device.id, :device_down) do
|
||||
:ok
|
||||
else
|
||||
# Single maintenance check (removed duplicate from create_device_down_alert)
|
||||
if Maintenance.device_in_maintenance?(device.id) do
|
||||
Logger.info("Device #{device.id} (#{device.name}) is down but in maintenance window — suppressing alert")
|
||||
:ok
|
||||
|
|
@ -212,30 +213,50 @@ defmodule Towerops.Workers.DeviceMonitorWorker do
|
|||
end
|
||||
|
||||
defp create_device_down_alert(device, now) do
|
||||
if Maintenance.device_in_maintenance?(device.id) do
|
||||
require Logger
|
||||
alert_message = get_down_alert_message(device)
|
||||
|
||||
Logger.info("Device #{device.id} (#{device.name}) alert suppressed by maintenance window (safety net)")
|
||||
:ok
|
||||
else
|
||||
alert_message = get_down_alert_message(device)
|
||||
# Use case instead of pattern match to handle errors gracefully
|
||||
case Alerts.create_alert(%{
|
||||
device_id: device.id,
|
||||
alert_type: :device_down,
|
||||
triggered_at: now,
|
||||
message: alert_message
|
||||
}) do
|
||||
{:ok, alert} ->
|
||||
# Wrap notification in try-catch to prevent silent failures
|
||||
Task.start(fn ->
|
||||
try do
|
||||
Notifier.notify_trigger(alert, device)
|
||||
rescue
|
||||
error ->
|
||||
require Logger
|
||||
|
||||
{:ok, alert} =
|
||||
Alerts.create_alert(%{
|
||||
device_id: device.id,
|
||||
alert_type: :device_down,
|
||||
triggered_at: now,
|
||||
message: alert_message
|
||||
})
|
||||
Logger.error(
|
||||
"Failed to send notification for device down alert: #{inspect(error)}",
|
||||
alert_id: alert.id,
|
||||
device_id: device.id,
|
||||
error: error
|
||||
)
|
||||
end
|
||||
end)
|
||||
|
||||
Task.start(fn -> Notifier.notify_trigger(alert, device) end)
|
||||
_ =
|
||||
Phoenix.PubSub.broadcast(
|
||||
Towerops.PubSub,
|
||||
"alerts:org:#{device.organization_id}:new",
|
||||
{:new_alert, device.id, :device_down}
|
||||
)
|
||||
|
||||
_ =
|
||||
Phoenix.PubSub.broadcast(
|
||||
Towerops.PubSub,
|
||||
"alerts:org:#{device.organization_id}:new",
|
||||
{:new_alert, device.id, :device_down}
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
require Logger
|
||||
|
||||
Logger.error("Failed to create device down alert for #{device.name}: #{inspect(reason)}",
|
||||
device_id: device.id
|
||||
)
|
||||
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -259,25 +280,52 @@ defmodule Towerops.Workers.DeviceMonitorWorker do
|
|||
defp handle_equipment_up(device, now) do
|
||||
recovery_message = get_recovery_message(device)
|
||||
|
||||
{:ok, alert} =
|
||||
Alerts.create_alert(%{
|
||||
device_id: device.id,
|
||||
alert_type: :device_up,
|
||||
triggered_at: now,
|
||||
resolved_at: now,
|
||||
message: recovery_message
|
||||
})
|
||||
# Use case instead of pattern match to handle errors gracefully
|
||||
case Alerts.create_alert(%{
|
||||
device_id: device.id,
|
||||
alert_type: :device_up,
|
||||
triggered_at: now,
|
||||
resolved_at: now,
|
||||
message: recovery_message
|
||||
}) do
|
||||
{:ok, alert} ->
|
||||
# Wrap notification in try-catch to prevent silent failures
|
||||
Task.start(fn ->
|
||||
try do
|
||||
Notifier.notify_trigger(alert, device)
|
||||
rescue
|
||||
error ->
|
||||
require Logger
|
||||
|
||||
Task.start(fn -> Notifier.notify_trigger(alert, device) end)
|
||||
Logger.error(
|
||||
"Failed to send notification for device up alert: #{inspect(error)}",
|
||||
alert_id: alert.id,
|
||||
device_id: device.id,
|
||||
error: error
|
||||
)
|
||||
end
|
||||
end)
|
||||
|
||||
resolve_down_alert(device)
|
||||
resolve_down_alert(device)
|
||||
|
||||
_ =
|
||||
Phoenix.PubSub.broadcast(
|
||||
Towerops.PubSub,
|
||||
"alerts:org:#{device.organization_id}:resolved",
|
||||
{:alert_resolved, device.id, :device_down}
|
||||
)
|
||||
_ =
|
||||
Phoenix.PubSub.broadcast(
|
||||
Towerops.PubSub,
|
||||
"alerts:org:#{device.organization_id}:resolved",
|
||||
{:alert_resolved, device.id, :device_down}
|
||||
)
|
||||
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
require Logger
|
||||
|
||||
Logger.error("Failed to create device up alert for #{device.name}: #{inspect(reason)}",
|
||||
device_id: device.id
|
||||
)
|
||||
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_recovery_message(device) do
|
||||
|
|
|
|||
|
|
@ -210,8 +210,21 @@ defmodule Towerops.Workers.DevicePollerWorker do
|
|||
]
|
||||
|
||||
# Wait for all tasks with timeout, log failures
|
||||
tasks
|
||||
|> Task.yield_many(30_000)
|
||||
# Explicitly pair results with names to handle length mismatches
|
||||
results = Task.yield_many(tasks, 30_000)
|
||||
|
||||
# Validate we got results for all tasks
|
||||
if length(results) != length(task_names) do
|
||||
Logger.error(
|
||||
"Task result count mismatch for #{device.name}: expected #{length(task_names)}, got #{length(results)}",
|
||||
device_id: device.id,
|
||||
expected: length(task_names),
|
||||
actual: length(results)
|
||||
)
|
||||
end
|
||||
|
||||
# Process results with explicit pairing to handle any length mismatch
|
||||
results
|
||||
|> Enum.zip(task_names)
|
||||
|> Enum.each(fn {{task, result}, name} ->
|
||||
case result do
|
||||
|
|
@ -1041,11 +1054,15 @@ defmodule Towerops.Workers.DevicePollerWorker do
|
|||
end
|
||||
|
||||
defp build_oper_status_event(interface, current_attrs, device_id, timestamp) do
|
||||
# Handle nil status values from SNMP failures
|
||||
old_status = interface.if_oper_status || "unknown"
|
||||
new_status = current_attrs.if_oper_status || "unknown"
|
||||
|
||||
message =
|
||||
if interface.if_oper_status do
|
||||
"Interface #{interface.if_name} changed from #{String.upcase(interface.if_oper_status)} to #{String.upcase(current_attrs.if_oper_status)}"
|
||||
"Interface #{interface.if_name} changed from #{String.upcase(old_status)} to #{String.upcase(new_status)}"
|
||||
else
|
||||
"Interface #{interface.if_name} is now #{String.upcase(current_attrs.if_oper_status)}"
|
||||
"Interface #{interface.if_name} is now #{String.upcase(new_status)}"
|
||||
end
|
||||
|
||||
%{
|
||||
|
|
|
|||
|
|
@ -731,9 +731,10 @@ defmodule ToweropsWeb.AgentChannel do
|
|||
|
||||
defp needs_discovery?(device) do
|
||||
# Need discovery if no SNMP device or last discovery was >24 hours ago
|
||||
# Clamp diff to 0 to handle clock skew (future timestamps)
|
||||
is_nil(device.snmp_device) or
|
||||
is_nil(device.last_discovery_at) or
|
||||
DateTime.diff(DateTime.utc_now(), device.last_discovery_at, :hour) > 24
|
||||
max(DateTime.diff(DateTime.utc_now(), device.last_discovery_at, :hour), 0) > 24
|
||||
end
|
||||
|
||||
defp mikrotik_device?(device) do
|
||||
|
|
|
|||
|
|
@ -15,18 +15,20 @@ defmodule ToweropsWeb.ActivityFeedLive do
|
|||
def mount(_params, _session, socket) do
|
||||
organization = socket.assigns.current_scope.organization
|
||||
|
||||
if connected?(socket) do
|
||||
# Subscribe to all relevant PubSub topics for real-time updates
|
||||
Phoenix.PubSub.subscribe(Towerops.PubSub, "alerts:org:#{organization.id}:new")
|
||||
Phoenix.PubSub.subscribe(Towerops.PubSub, "alerts:org:#{organization.id}:resolved")
|
||||
Phoenix.PubSub.subscribe(Towerops.PubSub, "devices:org:#{organization.id}")
|
||||
Phoenix.PubSub.subscribe(Towerops.PubSub, "config:org:#{organization.id}")
|
||||
Phoenix.PubSub.subscribe(Towerops.PubSub, "sync:org:#{organization.id}")
|
||||
Phoenix.PubSub.subscribe(Towerops.PubSub, "device_events:org:#{organization.id}")
|
||||
timer_ref =
|
||||
if connected?(socket) do
|
||||
# Subscribe to all relevant PubSub topics for real-time updates
|
||||
Phoenix.PubSub.subscribe(Towerops.PubSub, "alerts:org:#{organization.id}:new")
|
||||
Phoenix.PubSub.subscribe(Towerops.PubSub, "alerts:org:#{organization.id}:resolved")
|
||||
Phoenix.PubSub.subscribe(Towerops.PubSub, "devices:org:#{organization.id}")
|
||||
Phoenix.PubSub.subscribe(Towerops.PubSub, "config:org:#{organization.id}")
|
||||
Phoenix.PubSub.subscribe(Towerops.PubSub, "sync:org:#{organization.id}")
|
||||
Phoenix.PubSub.subscribe(Towerops.PubSub, "device_events:org:#{organization.id}")
|
||||
|
||||
# Timer for relative timestamp refresh
|
||||
:timer.send_interval(@tick_interval_ms, self(), :tick)
|
||||
end
|
||||
# Timer for relative timestamp refresh
|
||||
{:ok, ref} = :timer.send_interval(@tick_interval_ms, self(), :tick)
|
||||
ref
|
||||
end
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|
|
@ -36,6 +38,7 @@ defmodule ToweropsWeb.ActivityFeedLive do
|
|||
|> assign(:has_more, true)
|
||||
|> assign(:search, "")
|
||||
|> assign(:now, DateTime.utc_now())
|
||||
|> assign(:timer_ref, timer_ref)
|
||||
|> load_activity()
|
||||
|> load_counts()}
|
||||
end
|
||||
|
|
@ -138,6 +141,15 @@ defmodule ToweropsWeb.ActivityFeedLive do
|
|||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def terminate(_reason, socket) do
|
||||
if timer_ref = socket.assigns[:timer_ref] do
|
||||
:timer.cancel(timer_ref)
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
# --- Helpers ---
|
||||
|
||||
defp filter_options do
|
||||
|
|
|
|||
|
|
@ -8,11 +8,13 @@ defmodule ToweropsWeb.Admin.AgentLive.Index do
|
|||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
if connected?(socket) do
|
||||
Phoenix.PubSub.subscribe(Towerops.PubSub, "agents:health")
|
||||
Phoenix.PubSub.subscribe(Towerops.PubSub, "admin:agents")
|
||||
:timer.send_interval(1000, :tick)
|
||||
end
|
||||
timer_ref =
|
||||
if connected?(socket) do
|
||||
Phoenix.PubSub.subscribe(Towerops.PubSub, "agents:health")
|
||||
Phoenix.PubSub.subscribe(Towerops.PubSub, "admin:agents")
|
||||
{:ok, ref} = :timer.send_interval(1000, :tick)
|
||||
ref
|
||||
end
|
||||
|
||||
agent_tokens = Agents.list_all_agent_tokens()
|
||||
{cloud_pollers, org_agents} = Enum.split_with(agent_tokens, & &1.is_cloud_poller)
|
||||
|
|
@ -27,7 +29,8 @@ defmodule ToweropsWeb.Admin.AgentLive.Index do
|
|||
|> assign(:has_cloud_pollers, cloud_pollers != [])
|
||||
|> assign(:has_org_agents, org_agents != [])
|
||||
|> assign(:device_counts, device_counts)
|
||||
|> assign(:now, DateTime.utc_now())}
|
||||
|> assign(:now, DateTime.utc_now())
|
||||
|> assign(:timer_ref, timer_ref)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
@ -104,4 +107,13 @@ defmodule ToweropsWeb.Admin.AgentLive.Index do
|
|||
{t.id, %{direct: direct, total: total}}
|
||||
end)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def terminate(_reason, socket) do
|
||||
if timer_ref = socket.assigns[:timer_ref] do
|
||||
:timer.cancel(timer_ref)
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -19,10 +19,12 @@ defmodule ToweropsWeb.AgentLive.Index do
|
|||
agent_tokens = Agents.list_organization_agent_tokens(organization.id)
|
||||
|
||||
# Subscribe to agent health updates for real-time status changes
|
||||
if connected?(socket) do
|
||||
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agents:health")
|
||||
:timer.send_interval(1000, :tick)
|
||||
end
|
||||
timer_ref =
|
||||
if connected?(socket) do
|
||||
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agents:health")
|
||||
{:ok, ref} = :timer.send_interval(1000, :tick)
|
||||
ref
|
||||
end
|
||||
|
||||
# If superadmin (including when impersonating), also load cloud pollers and global default
|
||||
{cloud_pollers, global_default_cloud_poller_id} =
|
||||
|
|
@ -72,6 +74,7 @@ defmodule ToweropsWeb.AgentLive.Index do
|
|||
|> assign(:new_token, nil)
|
||||
|> assign(:show_token_modal, false)
|
||||
|> assign(:now, DateTime.utc_now())
|
||||
|> assign(:timer_ref, timer_ref)
|
||||
|> assign(:agent_form, to_form(%{"name" => "", "is_cloud_poller" => false}))}
|
||||
end
|
||||
|
||||
|
|
@ -468,4 +471,13 @@ defmodule ToweropsWeb.AgentLive.Index do
|
|||
|> assign(:agent_health_stats, agent_health_stats)
|
||||
|> assign(:offline_agents, offline_agents)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def terminate(_reason, socket) do
|
||||
if timer_ref = socket.assigns[:timer_ref] do
|
||||
:timer.cancel(timer_ref)
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -669,6 +669,11 @@ defmodule ToweropsWeb.DeviceLive.Form do
|
|||
"LiveView received credential test result: test_id=#{result.test_id} success=#{result.success} system_description=#{inspect(result.system_description)} error=#{inspect(result.error_message)}"
|
||||
)
|
||||
|
||||
# Unsubscribe from this specific test topic since we got the result
|
||||
if test_id = socket.assigns[:test_id] do
|
||||
Phoenix.PubSub.unsubscribe(Towerops.PubSub, "credential_test:#{test_id}")
|
||||
end
|
||||
|
||||
test_result =
|
||||
if result.success do
|
||||
%{
|
||||
|
|
@ -684,9 +689,13 @@ defmodule ToweropsWeb.DeviceLive.Form do
|
|||
|
||||
Logger.info("Setting snmp_test_result assign: #{inspect(test_result)}")
|
||||
|
||||
{:noreply, assign(socket, :snmp_test_result, test_result)}
|
||||
{:noreply, socket |> assign(:snmp_test_result, test_result) |> assign(:test_id, nil)}
|
||||
end
|
||||
|
||||
# Catch-all for unexpected messages
|
||||
@impl true
|
||||
def handle_info(_msg, socket), do: {:noreply, socket}
|
||||
|
||||
# Determine effective agent ID considering inheritance chain
|
||||
defp determine_effective_agent_id(device_params, assigns) do
|
||||
# Check device-level agent
|
||||
|
|
@ -839,4 +848,14 @@ defmodule ToweropsWeb.DeviceLive.Form do
|
|||
# 100.64.0.0/10 (RFC6598 CGNAT)
|
||||
defp non_routable_ipv4_range?(100, b) when b >= 64 and b <= 127, do: true
|
||||
defp non_routable_ipv4_range?(_a, _b), do: false
|
||||
|
||||
@impl true
|
||||
def terminate(_reason, socket) do
|
||||
# Unsubscribe from credential test topic if we're still subscribed
|
||||
if test_id = socket.assigns[:test_id] do
|
||||
Phoenix.PubSub.unsubscribe(Towerops.PubSub, "credential_test:#{test_id}")
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -77,8 +77,8 @@ defmodule ToweropsWeb.DeviceLive.Index do
|
|||
|
||||
socket
|
||||
|> assign(:active_tab, "discovered")
|
||||
# Use temporary assign - will be cleared after render
|
||||
|> assign_new(:discovered_devices, fn -> discovered_devices end)
|
||||
# Use regular assign to prevent state bleeding between tab switches
|
||||
|> assign(:discovered_devices, discovered_devices)
|
||||
|> assign(:pagination, %{
|
||||
page: page,
|
||||
per_page: per_page,
|
||||
|
|
|
|||
|
|
@ -790,6 +790,10 @@ defmodule ToweropsWeb.GraphLive.Show do
|
|||
end
|
||||
end
|
||||
|
||||
# Catch-all for unexpected messages
|
||||
@impl true
|
||||
def handle_info(_msg, socket), do: {:noreply, socket}
|
||||
|
||||
# Perform SNMP polling and update buffer
|
||||
defp perform_live_poll(socket) do
|
||||
device_id = socket.assigns.device_id
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ defmodule ToweropsWeb.MobileQRLive do
|
|||
{:ok, qr_token} = MobileSessions.create_qr_login_token(user.id)
|
||||
|
||||
# Start polling to check if token has been completed
|
||||
_ =
|
||||
timer_ref =
|
||||
if connected?(socket) do
|
||||
schedule_check()
|
||||
end
|
||||
|
|
@ -27,6 +27,7 @@ defmodule ToweropsWeb.MobileQRLive do
|
|||
|> assign(:qr_token, qr_token)
|
||||
|> assign(:completed, false)
|
||||
|> assign(:mobile_session, nil)
|
||||
|> assign(:timer_ref, timer_ref)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
|
@ -43,35 +44,57 @@ defmodule ToweropsWeb.MobileQRLive do
|
|||
user = socket.assigns.current_scope.user
|
||||
{:ok, qr_token} = MobileSessions.create_qr_login_token(user.id)
|
||||
|
||||
timer_ref = schedule_check()
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:qr_token, qr_token)
|
||||
|> assign(:timer_ref, timer_ref)
|
||||
|> put_flash(:info, t("QR code expired, generated a new one"))
|
||||
|
||||
schedule_check()
|
||||
{:noreply, socket}
|
||||
else
|
||||
# Still valid, check again
|
||||
schedule_check()
|
||||
{:noreply, socket}
|
||||
timer_ref = schedule_check()
|
||||
{:noreply, assign(socket, :timer_ref, timer_ref)}
|
||||
end
|
||||
|
||||
mobile_session ->
|
||||
# Token was completed!
|
||||
# Token was completed! Cancel timer since we're done
|
||||
cancel_timer(socket.assigns.timer_ref)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:completed, true)
|
||||
|> assign(:mobile_session, mobile_session)
|
||||
|> assign(:timer_ref, nil)
|
||||
|> put_flash(:info, t("Mobile device authenticated successfully!"))
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
# Catch-all for unexpected messages
|
||||
@impl true
|
||||
def handle_info(_msg, socket), do: {:noreply, socket}
|
||||
|
||||
@impl true
|
||||
def terminate(_reason, socket) do
|
||||
cancel_timer(socket.assigns[:timer_ref])
|
||||
:ok
|
||||
end
|
||||
|
||||
defp schedule_check do
|
||||
Process.send_after(self(), :check_completion, 2000)
|
||||
end
|
||||
|
||||
defp cancel_timer(nil), do: :ok
|
||||
|
||||
defp cancel_timer(timer_ref) when is_reference(timer_ref) do
|
||||
Process.cancel_timer(timer_ref)
|
||||
:ok
|
||||
end
|
||||
|
||||
# Generate QR code data URL using qrcode.show API (public service)
|
||||
defp qr_code_url(token) do
|
||||
# Base URL for the mobile app to handle the token
|
||||
|
|
|
|||
|
|
@ -7,22 +7,29 @@ defmodule ToweropsWeb.Plugs.FilterNoisyLogs do
|
|||
|
||||
Currently filters:
|
||||
- GET /health (Kubernetes health checks)
|
||||
- GET /health/time (Kubernetes health checks with time sync verification)
|
||||
- HEAD / (external uptime monitors)
|
||||
"""
|
||||
import Plug.Conn
|
||||
|
||||
require Logger
|
||||
|
||||
def init(opts), do: opts
|
||||
|
||||
def call(conn, _opts) do
|
||||
if should_filter?(conn) do
|
||||
# Setting phoenix_log to false prevents Phoenix.Logger from logging this request
|
||||
put_private(conn, :phoenix_log, false)
|
||||
# Also disable telemetry logging
|
||||
conn
|
||||
|> put_private(:phoenix_log, false)
|
||||
|> put_private(:plug_skip_telemetry, true)
|
||||
else
|
||||
conn
|
||||
end
|
||||
end
|
||||
|
||||
defp should_filter?(%{method: "GET", path_info: ["health"]}), do: true
|
||||
defp should_filter?(%{method: "GET", path_info: ["health", "time"]}), do: true
|
||||
defp should_filter?(%{method: "HEAD", path_info: []}), do: true
|
||||
defp should_filter?(_), do: false
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
defmodule Towerops.Repo.Migrations.FixEquipmentIndexesAfterRename do
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
# The 20260115011502_add_alerts_equipment_indexes migration created indexes
|
||||
# on the equipment table with equipment-prefixed names.
|
||||
# The 20260117190134_rename_equipment_to_devices migration renamed the table
|
||||
# but didn't rename these specific indexes.
|
||||
|
||||
# Rename equipment indexes to device indexes
|
||||
execute "ALTER INDEX IF EXISTS idx_equipment_snmp_poll RENAME TO idx_devices_snmp_poll"
|
||||
|
||||
execute "ALTER INDEX IF EXISTS idx_equipment_snmp_monitoring_site RENAME TO idx_devices_snmp_monitoring_site"
|
||||
|
||||
# The alerts index references device_id now (column was renamed), but name still says equipment
|
||||
execute "ALTER INDEX IF EXISTS idx_alerts_equipment_type_unresolved RENAME TO idx_alerts_device_type_unresolved"
|
||||
end
|
||||
|
||||
def down do
|
||||
# Reverse the renames if rolling back
|
||||
execute "ALTER INDEX IF EXISTS idx_devices_snmp_poll RENAME TO idx_equipment_snmp_poll"
|
||||
|
||||
execute "ALTER INDEX IF EXISTS idx_devices_snmp_monitoring_site RENAME TO idx_equipment_snmp_monitoring_site"
|
||||
|
||||
execute "ALTER INDEX IF EXISTS idx_alerts_device_type_unresolved RENAME TO idx_alerts_equipment_type_unresolved"
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue