help update

This commit is contained in:
Graham McIntire 2026-02-04 17:56:48 -06:00
parent 6f6627a54d
commit aa0e7f3d05
No known key found for this signature in database
6 changed files with 820 additions and 56 deletions

View file

@ -0,0 +1,109 @@
defmodule Towerops.Contexts.ConfigChangeTracker do
@moduledoc """
Tracks configuration changes for SNMP and MikroTik settings across
organizations and sites to trigger device propagation.
Provides functions to capture configuration state before updates and
detect which configuration fields changed after updates.
"""
@config_fields [
:snmp_community,
:mikrotik_username,
:mikrotik_password,
:mikrotik_port,
:mikrotik_use_ssl,
:mikrotik_enabled
]
@doc """
Captures the current configuration state from a struct.
Returns a map with all tracked configuration fields.
## Examples
iex> org = %Organization{snmp_community: "public", mikrotik_username: "admin"}
iex> ConfigChangeTracker.capture_config_state(org)
%{
snmp_community: "public",
mikrotik_username: "admin",
mikrotik_password: nil,
mikrotik_port: nil,
mikrotik_use_ssl: nil,
mikrotik_enabled: nil
}
"""
@spec capture_config_state(struct()) :: map()
def capture_config_state(struct) do
Map.new(@config_fields, fn field ->
{field, Map.get(struct, field)}
end)
end
@doc """
Checks if the SNMP community string changed.
## Examples
iex> old_state = %{snmp_community: "public"}
iex> updated = %Organization{snmp_community: "private"}
iex> ConfigChangeTracker.snmp_changed?(updated, old_state)
true
"""
@spec snmp_changed?(struct(), map()) :: boolean()
def snmp_changed?(updated_struct, old_state) do
Map.get(updated_struct, :snmp_community) != old_state.snmp_community
end
@doc """
Checks if any MikroTik configuration field changed.
Compares all MikroTik-related fields (username, password, port, ssl, enabled)
except snmp_community.
## Examples
iex> old_state = %{mikrotik_username: "admin", mikrotik_password: "pass"}
iex> updated = %Organization{mikrotik_username: "root", mikrotik_password: "pass"}
iex> ConfigChangeTracker.mikrotik_changed?(updated, old_state)
true
"""
@spec mikrotik_changed?(struct(), map()) :: boolean()
def mikrotik_changed?(updated_struct, old_state) do
Enum.any?(@config_fields, fn field ->
case field do
:snmp_community -> false
field -> Map.get(updated_struct, field) != old_state[field]
end
end)
end
@doc """
Extracts MikroTik configuration attributes from a struct.
Returns a map with all MikroTik-related fields suitable for device propagation.
## Examples
iex> site = %Site{mikrotik_username: "admin", mikrotik_port: 8729}
iex> ConfigChangeTracker.extract_mikrotik_attrs(site)
%{
mikrotik_username: "admin",
mikrotik_password: nil,
mikrotik_port: 8729,
mikrotik_use_ssl: nil,
mikrotik_enabled: nil
}
"""
@spec extract_mikrotik_attrs(struct()) :: map()
def extract_mikrotik_attrs(struct) do
%{
mikrotik_username: Map.get(struct, :mikrotik_username),
mikrotik_password: Map.get(struct, :mikrotik_password),
mikrotik_port: Map.get(struct, :mikrotik_port),
mikrotik_use_ssl: Map.get(struct, :mikrotik_use_ssl),
mikrotik_enabled: Map.get(struct, :mikrotik_enabled)
}
end
end

View file

@ -6,6 +6,7 @@ defmodule Towerops.Organizations do
import Ecto.Query
alias Towerops.Agents.AgentAssignment
alias Towerops.Contexts.ConfigChangeTracker
alias Towerops.Devices.Device
alias Towerops.Organizations.Invitation
alias Towerops.Organizations.Membership
@ -124,30 +125,23 @@ defmodule Towerops.Organizations do
Updates an organization.
"""
def update_organization(%Organization{} = organization, attrs) do
old_values = %{
snmp_community: organization.snmp_community,
mikrotik_username: organization.mikrotik_username,
mikrotik_password: organization.mikrotik_password,
mikrotik_port: organization.mikrotik_port,
mikrotik_use_ssl: organization.mikrotik_use_ssl,
mikrotik_enabled: organization.mikrotik_enabled,
use_sites: organization.use_sites
}
old_config = ConfigChangeTracker.capture_config_state(organization)
old_use_sites = organization.use_sites
with {:ok, updated_organization} <- organization |> Organization.changeset(attrs) |> Repo.update() do
propagate_organization_changes(updated_organization, old_values)
propagate_organization_changes(updated_organization, old_config, old_use_sites)
{:ok, updated_organization}
end
end
defp propagate_organization_changes(updated_organization, old_values) do
propagate_snmp_changes(updated_organization, old_values)
propagate_mikrotik_changes(updated_organization, old_values)
handle_sites_disabled(updated_organization, old_values)
defp propagate_organization_changes(updated_organization, old_config, old_use_sites) do
propagate_snmp_changes(updated_organization, old_config)
propagate_mikrotik_changes(updated_organization, old_config)
handle_sites_disabled(updated_organization, old_use_sites)
end
defp propagate_snmp_changes(updated_organization, %{snmp_community: old_community}) do
if updated_organization.snmp_community != old_community do
defp propagate_snmp_changes(updated_organization, old_config) do
if ConfigChangeTracker.snmp_changed?(updated_organization, old_config) do
Towerops.Devices.propagate_organization_community_change(
updated_organization.id,
updated_organization.snmp_community
@ -155,22 +149,9 @@ defmodule Towerops.Organizations do
end
end
defp propagate_mikrotik_changes(updated_organization, old_values) do
mikrotik_changed =
updated_organization.mikrotik_username != old_values.mikrotik_username ||
updated_organization.mikrotik_password != old_values.mikrotik_password ||
updated_organization.mikrotik_port != old_values.mikrotik_port ||
updated_organization.mikrotik_use_ssl != old_values.mikrotik_use_ssl ||
updated_organization.mikrotik_enabled != old_values.mikrotik_enabled
if mikrotik_changed do
mikrotik_attrs = %{
mikrotik_username: updated_organization.mikrotik_username,
mikrotik_password: updated_organization.mikrotik_password,
mikrotik_port: updated_organization.mikrotik_port,
mikrotik_use_ssl: updated_organization.mikrotik_use_ssl,
mikrotik_enabled: updated_organization.mikrotik_enabled
}
defp propagate_mikrotik_changes(updated_organization, old_config) do
if ConfigChangeTracker.mikrotik_changed?(updated_organization, old_config) do
mikrotik_attrs = ConfigChangeTracker.extract_mikrotik_attrs(updated_organization)
Towerops.Devices.propagate_organization_mikrotik_change(
updated_organization.id,
@ -179,7 +160,7 @@ defmodule Towerops.Organizations do
end
end
defp handle_sites_disabled(updated_organization, %{use_sites: old_use_sites}) do
defp handle_sites_disabled(updated_organization, old_use_sites) do
if old_use_sites && !updated_organization.use_sites do
clear_all_site_assignments(updated_organization.id)
end

View file

@ -6,6 +6,7 @@ defmodule Towerops.Sites do
import Ecto.Query
alias Towerops.Agents.AgentAssignment
alias Towerops.Contexts.ConfigChangeTracker
alias Towerops.Devices.Device
alias Towerops.Repo
alias Towerops.Sites.Site
@ -106,39 +107,20 @@ defmodule Towerops.Sites do
Updates a site.
"""
def update_site(%Site{} = site, attrs) do
old_community = site.snmp_community
old_mikrotik_username = site.mikrotik_username
old_mikrotik_password = site.mikrotik_password
old_mikrotik_port = site.mikrotik_port
old_mikrotik_use_ssl = site.mikrotik_use_ssl
old_mikrotik_enabled = site.mikrotik_enabled
old_config = ConfigChangeTracker.capture_config_state(site)
case site
|> Site.changeset(attrs)
|> Repo.update() do
{:ok, updated_site} = result ->
# If community string changed, propagate to inheriting devices
if updated_site.snmp_community != old_community do
if ConfigChangeTracker.snmp_changed?(updated_site, old_config) do
Towerops.Devices.propagate_site_community_change(updated_site.id, updated_site.snmp_community)
end
# If any MikroTik settings changed, propagate to inheriting devices
mikrotik_changed =
updated_site.mikrotik_username != old_mikrotik_username ||
updated_site.mikrotik_password != old_mikrotik_password ||
updated_site.mikrotik_port != old_mikrotik_port ||
updated_site.mikrotik_use_ssl != old_mikrotik_use_ssl ||
updated_site.mikrotik_enabled != old_mikrotik_enabled
if mikrotik_changed do
mikrotik_attrs = %{
mikrotik_username: updated_site.mikrotik_username,
mikrotik_password: updated_site.mikrotik_password,
mikrotik_port: updated_site.mikrotik_port,
mikrotik_use_ssl: updated_site.mikrotik_use_ssl,
mikrotik_enabled: updated_site.mikrotik_enabled
}
if ConfigChangeTracker.mikrotik_changed?(updated_site, old_config) do
mikrotik_attrs = ConfigChangeTracker.extract_mikrotik_attrs(updated_site)
Towerops.Devices.propagate_site_mikrotik_change(updated_site.id, mikrotik_attrs)
end

View file

@ -76,6 +76,15 @@
Account Data (GDPR)
</a>
</li>
<li>
<a
href="#graphs"
data-nav-link="graphs"
class="nav-link block py-1 pl-4 pr-3 text-sm text-gray-600 transition hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
>
Graphs & Live Polling
</a>
</li>
</ul>
</li>
</ul>

View file

@ -238,6 +238,21 @@ defmodule ToweropsWeb.HelpLive.Index do
MikroTik
</.link>
</li>
<li>
<.link
patch={~p"/help?section=graphs"}
class={[
"block px-3 py-2 rounded-md text-sm font-medium transition-colors",
if @active_section == "graphs" do
"bg-blue-50 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300"
else
"text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800"
end
]}
>
Graphs & Live Polling
</.link>
</li>
</ul>
</nav>
</div>
@ -951,6 +966,361 @@ defmodule ToweropsWeb.HelpLive.Index do
</div>
</div>
</div>
<% "graphs" -> %>
<div class="p-6">
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-4">
Graphs & Live Polling
</h2>
<div class="prose prose-sm dark:prose-invert max-w-none">
<p class="text-gray-600 dark:text-gray-400">
Towerops provides powerful visualization capabilities for all monitored metrics. Every sensor,
interface, and metric can be viewed as a time-series graph with multiple time ranges and a
real-time live polling mode for instant feedback.
</p>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6 mb-3">
Accessing Graphs
</h3>
<p class="text-gray-600 dark:text-gray-400">
Graphs are available throughout Towerops wherever metrics are displayed:
</p>
<ul class="space-y-2 text-gray-600 dark:text-gray-400 list-disc list-inside mt-3">
<li>
<strong class="text-gray-900 dark:text-white">Device Overview:</strong>
Click any metric tile (CPU, Memory, Temperature, etc.) to view its graph
</li>
<li>
<strong class="text-gray-900 dark:text-white">Sensors Tab:</strong>
Click the graph icon next to any sensor reading
</li>
<li>
<strong class="text-gray-900 dark:text-white">Interfaces Tab:</strong>
Click the graph icon next to any interface to view traffic graphs
</li>
<li>
<strong class="text-gray-900 dark:text-white">Storage Tab:</strong>
Click any storage volume to view usage over time
</li>
</ul>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-8 mb-3">
Time Ranges
</h3>
<p class="text-gray-600 dark:text-gray-400">
All graphs support multiple time ranges for analyzing trends at different scales:
</p>
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-1">
1 Hour
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400">
Recent activity with high detail
</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-1">
6 Hours
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400">
Half-day trends and patterns
</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-1">
12 Hours
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400">
Business day overview
</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-1">
24 Hours (Default)
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400">
Full day of activity
</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-1">
7 Days
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400">
Weekly trends and patterns
</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-1">
30 Days
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400">
Monthly overview and capacity planning
</p>
</div>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-8 mb-3">
Live Polling Mode
</h3>
<p class="text-gray-600 dark:text-gray-400">
Live mode provides real-time sensor monitoring with data updating every second. This is perfect for:
</p>
<ul class="space-y-2 text-gray-600 dark:text-gray-400 list-disc list-inside mt-3">
<li>Testing configuration changes and seeing immediate effects</li>
<li>Monitoring system load during maintenance or upgrades</li>
<li>Watching temperature changes during thermal testing</li>
<li>Observing traffic patterns during load testing</li>
<li>Real-time troubleshooting of performance issues</li>
</ul>
<div class="mt-6 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<div class="flex gap-3">
<.icon
name="hero-signal"
class="h-5 w-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5"
/>
<div>
<h4 class="text-sm font-semibold text-green-900 dark:text-green-300 mb-1">
How Live Mode Works
</h4>
<ul class="text-sm text-green-800 dark:text-green-300 space-y-1 list-disc list-inside">
<li>Polls sensors directly via SNMP every 1 second</li>
<li>Displays a rolling 5-minute window (300 data points)</li>
<li>Updates chart in real-time as new data arrives</li>
<li>Automatically stops when you switch to another time range</li>
<li>Works with remote pollers - polling happens on the agent</li>
</ul>
</div>
</div>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-8 mb-3">
Using Live Mode
</h3>
<div class="space-y-4">
<div class="flex gap-4">
<div class="flex-shrink-0">
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 font-semibold">
1
</div>
</div>
<div class="flex-1">
<h4 class="text-base font-semibold text-gray-900 dark:text-white mb-2">
Open Any Graph
</h4>
<p class="text-gray-600 dark:text-gray-400">
Navigate to any device and click on a metric graph (CPU, Memory, Temperature, Traffic, etc.)
</p>
</div>
</div>
<div class="flex gap-4">
<div class="flex-shrink-0">
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 font-semibold">
2
</div>
</div>
<div class="flex-1">
<h4 class="text-base font-semibold text-gray-900 dark:text-white mb-2">
Click the "Live" Button
</h4>
<p class="text-gray-600 dark:text-gray-400">
The Live button has a distinctive green gradient style and will pulse when active
</p>
</div>
</div>
<div class="flex gap-4">
<div class="flex-shrink-0">
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 font-semibold">
3
</div>
</div>
<div class="flex-1">
<h4 class="text-base font-semibold text-gray-900 dark:text-white mb-2">
Watch Real-Time Updates
</h4>
<p class="text-gray-600 dark:text-gray-400">
The graph will start updating every second with fresh data. A pulsing green indicator
shows that live polling is active.
</p>
</div>
</div>
<div class="flex gap-4">
<div class="flex-shrink-0">
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 font-semibold">
4
</div>
</div>
<div class="flex-1">
<h4 class="text-base font-semibold text-gray-900 dark:text-white mb-2">
Switch Back to Historical Data
</h4>
<p class="text-gray-600 dark:text-gray-400">
Click any other time range button to stop live polling and view historical data
</p>
</div>
</div>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-8 mb-3">
Supported Metrics in Live Mode
</h3>
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-1 flex items-center gap-2">
<.icon name="hero-cpu-chip" class="h-4 w-4 text-blue-500" />
CPU / Processors
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400">
Real-time CPU load and utilization
</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-1 flex items-center gap-2">
<.icon name="hero-circle-stack" class="h-4 w-4 text-blue-500" />
Memory Usage
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400">
RAM utilization percentage
</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-1 flex items-center gap-2">
<.icon name="hero-fire" class="h-4 w-4 text-orange-500" /> Temperature
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400">
Device and component temperatures
</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-1 flex items-center gap-2">
<.icon name="hero-bolt" class="h-4 w-4 text-yellow-500" /> Voltage
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400">
Power supply voltages
</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-1 flex items-center gap-2">
<.icon name="hero-server-stack" class="h-4 w-4 text-purple-500" /> Storage
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400">
Disk usage and capacity
</p>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-1 flex items-center gap-2">
<.icon name="hero-hashtag" class="h-4 w-4 text-green-500" /> Custom Metrics
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400">
Sessions, connections, and counts
</p>
</div>
</div>
<div class="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div class="flex gap-3">
<.icon
name="hero-information-circle"
class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
/>
<div>
<h4 class="text-sm font-semibold text-blue-900 dark:text-blue-300 mb-1">
Note About Traffic Graphs
</h4>
<p class="text-sm text-blue-800 dark:text-blue-300">
Live mode is currently only available for sensor metrics (CPU, memory, temperature, etc.).
Interface traffic graphs use historical data only, as traffic calculations require
comparing multiple SNMP polls over time.
</p>
</div>
</div>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-8 mb-3">
Tips for Using Graphs
</h3>
<div class="space-y-3">
<div class="p-4 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">
Multiple Sensors on One Graph
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">
When viewing aggregate metrics (like "Temperature" for all sensors), the graph
automatically displays all sensors of that type with different colors for easy comparison.
</p>
</div>
<div class="p-4 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">
Max and Min Values
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">
Historical graphs (non-live) display the maximum and minimum values for the selected
time range at the bottom of the chart for quick reference.
</p>
</div>
<div class="p-4 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">
Traffic Graph Direction
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">
Interface traffic graphs show outbound traffic as positive values (above zero) and
inbound traffic as negative values (below zero) for easy visualization of bidirectional flow.
</p>
</div>
<div class="p-4 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">
Automatic Unit Scaling
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">
Traffic graphs automatically scale units (bps, Kbps, Mbps, Gbps) based on the data
range for optimal readability. Hover over data points to see exact values.
</p>
</div>
</div>
<div class="mt-8 p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<div class="flex gap-3">
<.icon
name="hero-light-bulb"
class="h-5 w-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5"
/>
<div>
<h4 class="text-sm font-semibold text-yellow-900 dark:text-yellow-300 mb-1">
Performance Tip
</h4>
<p class="text-sm text-yellow-800 dark:text-yellow-300">
Live polling makes direct SNMP requests every second. While this provides instant
feedback, keeping many live graphs open simultaneously may impact device performance.
Use live mode for troubleshooting and testing, then switch back to historical ranges
for routine monitoring.
</p>
</div>
</div>
</div>
</div>
</div>
<% "mikrotik" -> %>
<div class="p-6">
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-4">

View file

@ -0,0 +1,313 @@
defmodule Towerops.Contexts.ConfigChangeTrackerTest do
use Towerops.DataCase, async: true
alias Towerops.Contexts.ConfigChangeTracker
describe "capture_config_state/1" do
test "captures all config fields from organization struct" do
org = %{
snmp_community: "public",
mikrotik_username: "admin",
mikrotik_password: "password",
mikrotik_port: 8729,
mikrotik_use_ssl: true,
mikrotik_enabled: true
}
result = ConfigChangeTracker.capture_config_state(org)
assert result == %{
snmp_community: "public",
mikrotik_username: "admin",
mikrotik_password: "password",
mikrotik_port: 8729,
mikrotik_use_ssl: true,
mikrotik_enabled: true
}
end
test "captures partial config fields with nils" do
site = %{
snmp_community: "private",
mikrotik_username: nil,
mikrotik_password: nil,
mikrotik_port: nil,
mikrotik_use_ssl: nil,
mikrotik_enabled: nil
}
result = ConfigChangeTracker.capture_config_state(site)
assert result.snmp_community == "private"
assert result.mikrotik_username == nil
assert result.mikrotik_password == nil
end
test "handles struct with missing fields gracefully" do
minimal = %{snmp_community: "test"}
result = ConfigChangeTracker.capture_config_state(minimal)
assert result.snmp_community == "test"
assert result.mikrotik_username == nil
assert result.mikrotik_password == nil
assert result.mikrotik_port == nil
assert result.mikrotik_use_ssl == nil
assert result.mikrotik_enabled == nil
end
end
describe "snmp_changed?/2" do
test "returns true when SNMP community changed" do
old_state = %{snmp_community: "public"}
updated = %{snmp_community: "private"}
assert ConfigChangeTracker.snmp_changed?(updated, old_state) == true
end
test "returns false when SNMP community unchanged" do
old_state = %{snmp_community: "public"}
updated = %{snmp_community: "public"}
assert ConfigChangeTracker.snmp_changed?(updated, old_state) == false
end
test "returns true when SNMP community changes from nil to value" do
old_state = %{snmp_community: nil}
updated = %{snmp_community: "public"}
assert ConfigChangeTracker.snmp_changed?(updated, old_state) == true
end
test "returns true when SNMP community changes from value to nil" do
old_state = %{snmp_community: "public"}
updated = %{snmp_community: nil}
assert ConfigChangeTracker.snmp_changed?(updated, old_state) == true
end
end
describe "mikrotik_changed?/2" do
test "returns true when MikroTik username changed" do
old_state = %{
snmp_community: "public",
mikrotik_username: "admin",
mikrotik_password: "pass",
mikrotik_port: 8729,
mikrotik_use_ssl: true,
mikrotik_enabled: true
}
updated = %{
snmp_community: "public",
mikrotik_username: "root",
mikrotik_password: "pass",
mikrotik_port: 8729,
mikrotik_use_ssl: true,
mikrotik_enabled: true
}
assert ConfigChangeTracker.mikrotik_changed?(updated, old_state) == true
end
test "returns true when MikroTik password changed" do
old_state = %{
mikrotik_username: "admin",
mikrotik_password: "oldpass",
mikrotik_port: 8729,
mikrotik_use_ssl: true,
mikrotik_enabled: true
}
updated = %{
mikrotik_username: "admin",
mikrotik_password: "newpass",
mikrotik_port: 8729,
mikrotik_use_ssl: true,
mikrotik_enabled: true
}
assert ConfigChangeTracker.mikrotik_changed?(updated, old_state) == true
end
test "returns true when MikroTik port changed" do
old_state = %{
mikrotik_username: "admin",
mikrotik_password: "pass",
mikrotik_port: 8729,
mikrotik_use_ssl: true,
mikrotik_enabled: true
}
updated = %{
mikrotik_username: "admin",
mikrotik_password: "pass",
mikrotik_port: 8728,
mikrotik_use_ssl: true,
mikrotik_enabled: true
}
assert ConfigChangeTracker.mikrotik_changed?(updated, old_state) == true
end
test "returns true when MikroTik SSL setting changed" do
old_state = %{
mikrotik_username: "admin",
mikrotik_password: "pass",
mikrotik_port: 8729,
mikrotik_use_ssl: true,
mikrotik_enabled: true
}
updated = %{
mikrotik_username: "admin",
mikrotik_password: "pass",
mikrotik_port: 8729,
mikrotik_use_ssl: false,
mikrotik_enabled: true
}
assert ConfigChangeTracker.mikrotik_changed?(updated, old_state) == true
end
test "returns true when MikroTik enabled setting changed" do
old_state = %{
mikrotik_username: "admin",
mikrotik_password: "pass",
mikrotik_port: 8729,
mikrotik_use_ssl: true,
mikrotik_enabled: false
}
updated = %{
mikrotik_username: "admin",
mikrotik_password: "pass",
mikrotik_port: 8729,
mikrotik_use_ssl: true,
mikrotik_enabled: true
}
assert ConfigChangeTracker.mikrotik_changed?(updated, old_state) == true
end
test "returns false when no MikroTik fields changed" do
old_state = %{
snmp_community: "public",
mikrotik_username: "admin",
mikrotik_password: "pass",
mikrotik_port: 8729,
mikrotik_use_ssl: true,
mikrotik_enabled: true
}
updated = %{
snmp_community: "public",
mikrotik_username: "admin",
mikrotik_password: "pass",
mikrotik_port: 8729,
mikrotik_use_ssl: true,
mikrotik_enabled: true
}
assert ConfigChangeTracker.mikrotik_changed?(updated, old_state) == false
end
test "returns false when only SNMP community changed (not MikroTik field)" do
old_state = %{
snmp_community: "public",
mikrotik_username: "admin",
mikrotik_password: "pass",
mikrotik_port: 8729,
mikrotik_use_ssl: true,
mikrotik_enabled: true
}
updated = %{
snmp_community: "private",
mikrotik_username: "admin",
mikrotik_password: "pass",
mikrotik_port: 8729,
mikrotik_use_ssl: true,
mikrotik_enabled: true
}
assert ConfigChangeTracker.mikrotik_changed?(updated, old_state) == false
end
test "returns true when MikroTik field changes from nil to value" do
old_state = %{
mikrotik_username: nil,
mikrotik_password: nil,
mikrotik_port: nil,
mikrotik_use_ssl: nil,
mikrotik_enabled: nil
}
updated = %{
mikrotik_username: "admin",
mikrotik_password: nil,
mikrotik_port: nil,
mikrotik_use_ssl: nil,
mikrotik_enabled: nil
}
assert ConfigChangeTracker.mikrotik_changed?(updated, old_state) == true
end
end
describe "extract_mikrotik_attrs/1" do
test "extracts all MikroTik fields from struct" do
struct = %{
mikrotik_username: "admin",
mikrotik_password: "password",
mikrotik_port: 8729,
mikrotik_use_ssl: true,
mikrotik_enabled: true,
snmp_community: "public"
}
result = ConfigChangeTracker.extract_mikrotik_attrs(struct)
assert result == %{
mikrotik_username: "admin",
mikrotik_password: "password",
mikrotik_port: 8729,
mikrotik_use_ssl: true,
mikrotik_enabled: true
}
end
test "extracts MikroTik fields with nils" do
struct = %{
mikrotik_username: "admin",
mikrotik_password: nil,
mikrotik_port: nil,
mikrotik_use_ssl: nil,
mikrotik_enabled: false
}
result = ConfigChangeTracker.extract_mikrotik_attrs(struct)
assert result.mikrotik_username == "admin"
assert result.mikrotik_password == nil
assert result.mikrotik_port == nil
assert result.mikrotik_use_ssl == nil
assert result.mikrotik_enabled == false
end
test "does not include SNMP community field" do
struct = %{
snmp_community: "public",
mikrotik_username: "admin",
mikrotik_password: "pass",
mikrotik_port: 8729,
mikrotik_use_ssl: true,
mikrotik_enabled: true
}
result = ConfigChangeTracker.extract_mikrotik_attrs(struct)
refute Map.has_key?(result, :snmp_community)
end
end
end