refactoring

This commit is contained in:
Graham McIntire 2026-01-29 10:03:43 -06:00
parent 857c8d2196
commit 8e739283ad
47 changed files with 311 additions and 256 deletions

View file

@ -56,18 +56,7 @@ defmodule Mix.Tasks.Geoip.Import do
case remaining_args do
[directory] ->
if opts[:production] do
# Production: Send batches to API
api_key = System.get_env("TOWEROPS_KEY")
if !api_key do
Mix.shell().error("Error: TOWEROPS_KEY environment variable must be set for production import")
exit({:shutdown, 1})
end
Mix.Task.run("app.config")
{:ok, _} = Application.ensure_all_started(:req)
import_via_api(directory, api_key)
run_production_import(directory)
else
# Development: Direct database import
Mix.Task.run("app.start")
@ -75,19 +64,38 @@ defmodule Mix.Tasks.Geoip.Import do
end
_ ->
Mix.shell().error("""
Usage: mix geoip.import <directory> [--production]
Examples:
# Import to local database (development)
mix geoip.import ~/Downloads/GeoLite2-City-CSV_20260127/
# Import to production via API (requires TOWEROPS_KEY env var)
mix geoip.import ~/Downloads/GeoLite2-City-CSV_20260127/ --production
""")
show_usage_help()
end
end
defp run_production_import(directory) do
# Production: Send batches to API
api_key = System.get_env("TOWEROPS_KEY")
if !api_key do
Mix.shell().error("Error: TOWEROPS_KEY environment variable must be set for production import")
exit({:shutdown, 1})
end
Mix.Task.run("app.config")
{:ok, _} = Application.ensure_all_started(:req)
import_via_api(directory, api_key)
end
defp show_usage_help do
Mix.shell().error("""
Usage: mix geoip.import <directory> [--production]
Examples:
# Import to local database (development)
mix geoip.import ~/Downloads/GeoLite2-City-CSV_20260127/
# Import to production via API (requires TOWEROPS_KEY env var)
mix geoip.import ~/Downloads/GeoLite2-City-CSV_20260127/ --production
""")
end
defp parse_args(args) do
{opts, remaining, _invalid} =
OptionParser.parse(args,
@ -263,7 +271,9 @@ defmodule Mix.Tasks.Geoip.Import do
end
defp parse_location_line(line) do
# CSV format: geoname_id,locale_code,continent_code,continent_name,country_iso_code,country_name,subdivision_1_iso_code,subdivision_1_name,subdivision_2_iso_code,subdivision_2_name,city_name,metro_code,time_zone,is_in_european_union
# CSV format: geoname_id,locale_code,continent_code,continent_name,country_iso_code,country_name,
# subdivision_1_iso_code,subdivision_1_name,subdivision_2_iso_code,subdivision_2_name,
# city_name,metro_code,time_zone,is_in_european_union
parts = String.split(line, ",")
case parts do
@ -332,30 +342,33 @@ defmodule Mix.Tasks.Geoip.Import do
# Try geoname_id first, fall back to registered_country_id
geoname_id_int = parse_geoname_id(geoname_id)
registered_id_int = parse_geoname_id(registered_country_id)
final_geoname_id = geoname_id_int || registered_id_int
if final_geoname_id do
case parse_cidr(network) do
{:ok, start_int, end_int} ->
%{
network: network,
start_ip_int: start_int,
end_ip_int: end_int,
geoname_id: final_geoname_id,
registered_country_geoname_id: registered_id_int
}
:error ->
nil
end
end
build_block_entry(network, final_geoname_id, registered_id_int)
_ ->
nil
end
end
defp build_block_entry(_network, nil, _registered_id_int), do: nil
defp build_block_entry(network, final_geoname_id, registered_id_int) do
case parse_cidr(network) do
{:ok, start_int, end_int} ->
%{
network: network,
start_ip_int: start_int,
end_ip_int: end_int,
geoname_id: final_geoname_id,
registered_country_geoname_id: registered_id_int
}
:error ->
nil
end
end
defp parse_geoname_id(""), do: nil
defp parse_geoname_id(geoname_id_str) do

View file

@ -17,8 +17,9 @@ defmodule Towerops.Accounts.Scope do
"""
alias Towerops.Accounts.User
alias Towerops.Organizations.Organization
defstruct user: nil, superuser: nil, impersonating?: false
defstruct user: nil, superuser: nil, impersonating?: false, organization: nil, timezone: "UTC"
@doc """
Creates a scope for the given user.
@ -26,7 +27,12 @@ defmodule Towerops.Accounts.Scope do
Returns nil if no user is given.
"""
def for_user(%User{} = user) do
%__MODULE__{user: user, superuser: nil, impersonating?: false}
%__MODULE__{
user: user,
superuser: nil,
impersonating?: false,
timezone: user.timezone || "UTC"
}
end
def for_user(nil), do: nil
@ -41,10 +47,26 @@ defmodule Towerops.Accounts.Scope do
%__MODULE__{
user: target_user,
superuser: superuser,
impersonating?: true
impersonating?: true,
timezone: target_user.timezone || "UTC"
}
end
@doc """
Adds organization context to an existing scope.
Returns updated scope with organization field set.
"""
def put_organization(%__MODULE__{} = scope, %Organization{} = organization) do
%{scope | organization: organization}
end
def put_organization(%__MODULE__{} = scope, nil) do
%{scope | organization: nil}
end
def put_organization(nil, _organization), do: nil
@doc """
Returns true if the scope has superuser privileges.

View file

@ -7,6 +7,8 @@ defmodule Towerops.Application do
use Application
alias Towerops.Snmp.MibLoader
# Capture the build timestamp at compile time (fallback for development)
@build_timestamp DateTime.utc_now()
@ -46,7 +48,7 @@ defmodule Towerops.Application do
SnmpKit.SnmpMgr.Config,
SnmpKit.SnmpMgr.MIB,
# Load vendor MIBs after SnmpKit.SnmpMgr.MIB starts
{Task, fn -> Towerops.Snmp.MibLoader.load_all_mibs() end}
{Task, fn -> MibLoader.load_all_mibs() end}
] ++
dev_only_workers() ++
background_workers() ++

View file

@ -85,30 +85,37 @@ defmodule Towerops.Snmp.AgentDiscovery do
# Run discovery using standard pipeline
# This will call all the same functions as direct discovery,
# but read from the OID map instead of making SNMP queries
case Discovery.discover_device_with_opts(device, client_opts) do
{:ok, discovered_device} ->
interfaces_count =
case discovered_device.interfaces do
%NotLoaded{} -> 0
list when is_list(list) -> length(list)
_ -> 0
end
perform_agent_discovery(device, client_opts)
end
end
Logger.debug("Agent discovery succeeded for #{device.name}",
device_id: device.id,
interfaces: interfaces_count,
sensors: count_sensors(discovered_device)
)
defp perform_agent_discovery(device, client_opts) do
case Discovery.discover_device_with_opts(device, client_opts) do
{:ok, discovered_device} ->
interfaces_count = count_interfaces(discovered_device)
{:ok, discovered_device}
Logger.debug("Agent discovery succeeded for #{device.name}",
device_id: device.id,
interfaces: interfaces_count,
sensors: count_sensors(discovered_device)
)
{:error, reason} = error ->
Logger.error("Agent discovery failed for #{device.name}: #{inspect(reason)}",
device_id: device.id
)
{:ok, discovered_device}
error
end
{:error, reason} = error ->
Logger.error("Agent discovery failed for #{device.name}: #{inspect(reason)}",
device_id: device.id
)
error
end
end
defp count_interfaces(discovered_device) do
case discovered_device.interfaces do
%NotLoaded{} -> 0
list when is_list(list) -> length(list)
_ -> 0
end
end

View file

@ -343,38 +343,41 @@ defmodule Towerops.Snmp.Client do
if String.starts_with?(oid, ".") or String.match?(oid, ~r/^\d+(\.\d+)*$/) do
oid
else
# Handle MODULE-NAME::objectName format (strip module prefix)
object_name =
if String.contains?(oid, "::") do
oid |> String.split("::") |> List.last()
else
oid
end
# Try to resolve MIB name to numeric OID
case SnmpKit.resolve(object_name) do
{:ok, numeric_oid} ->
# Convert OID list to dotted string if needed
resolved_oid =
if is_list(numeric_oid) do
Enum.join(numeric_oid, ".")
else
numeric_oid
end
Logger.debug("Resolved MIB name #{oid} to #{resolved_oid}")
resolved_oid
{:error, reason} ->
# If resolution fails, log and use as-is
Logger.debug("Could not resolve MIB name #{oid}: #{inspect(reason)}, using as-is")
oid
end
resolve_symbolic_oid(oid)
end
end
def resolve_mib_name(oid), do: oid
defp resolve_symbolic_oid(oid) do
# Handle MODULE-NAME::objectName format (strip module prefix)
object_name =
if String.contains?(oid, "::") do
oid |> String.split("::") |> List.last()
else
oid
end
# Try to resolve MIB name to numeric OID
case SnmpKit.resolve(object_name) do
{:ok, numeric_oid} ->
resolved_oid = format_resolved_oid(numeric_oid)
Logger.debug("Resolved MIB name #{oid} to #{resolved_oid}")
resolved_oid
{:error, reason} ->
# If resolution fails, log and use as-is
Logger.debug("Could not resolve MIB name #{oid}: #{inspect(reason)}, using as-is")
oid
end
end
defp format_resolved_oid(numeric_oid) when is_list(numeric_oid) do
Enum.join(numeric_oid, ".")
end
defp format_resolved_oid(numeric_oid), do: numeric_oid
# Extract value from snmpkit's typed responses
defp extract_snmp_value({:octet_string, value}), do: value
defp extract_snmp_value({:integer, value}), do: value

View file

@ -11,6 +11,8 @@ defmodule Towerops.Snmp.MibTranslator do
Requires net-snmp package to be installed for best results, but works without it.
"""
alias SnmpKit.SnmpMgr.MIB
require Logger
# Fallback OID registry for when MIB translation fails
@ -438,7 +440,7 @@ defmodule Towerops.Snmp.MibTranslator do
|> String.split(".")
|> List.first()
case SnmpKit.SnmpMgr.MIB.resolve(symbol_name) do
case MIB.resolve(symbol_name) do
{:ok, oid_list} ->
# Extract instance suffix (e.g., ".0" from "afLTUFirmwareVersion.0")
suffix =

View file

@ -97,7 +97,7 @@ defmodule ToweropsWeb.Layouts do
## Examples
<Layouts.authenticated flash={@flash} current_organization={@current_organization} active_page="dashboard">
<Layouts.authenticated flash={@flash} current_scope={@current_scope} active_page="dashboard">
<h1>Content</h1>
</Layouts.authenticated>
@ -106,18 +106,12 @@ defmodule ToweropsWeb.Layouts do
attr :current_scope, :map,
default: nil,
doc: "the current scope (contains user and impersonation state)"
attr :current_organization, :any,
default: nil,
doc: "the current organization (can be nil for pages without org context)"
doc: "the current scope (contains user, organization, timezone, and impersonation state)"
attr :active_page, :string,
default: nil,
doc: "the currently active page (dashboard, sites, device, or alerts)"
attr :timezone, :string, default: "UTC", doc: "the user's timezone"
slot :inner_block, required: true
def authenticated(assigns) do
@ -125,6 +119,15 @@ defmodule ToweropsWeb.Layouts do
requires_cookie_consent = Process.get(:requires_cookie_consent, false)
assigns = Map.put(assigns, :requires_cookie_consent, requires_cookie_consent)
# Extract organization and timezone from scope for template convenience
current_organization = assigns[:current_scope] && assigns.current_scope.organization
timezone = (assigns[:current_scope] && assigns.current_scope.timezone) || "UTC"
assigns =
assigns
|> Map.put(:current_organization, current_organization)
|> Map.put(:timezone, timezone)
~H"""
<div class="flex min-h-screen flex-col bg-gray-50 dark:bg-gray-950">
<!-- Impersonation Banner -->

View file

@ -119,7 +119,6 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
current_organization={@current_organization}
>
{content.()}
</Layouts.authenticated>

View file

@ -396,7 +396,6 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
current_organization={@current_organization}
>
{content.()}
</Layouts.authenticated>

View file

@ -1,4 +1,4 @@
<Layouts.authenticated flash={@flash} current_scope={@current_scope} current_organization={nil}>
<Layouts.authenticated flash={@flash} current_scope={@current_scope}>
<div class="text-center">
<.header>
Account Settings

View file

@ -5,6 +5,7 @@ defmodule ToweropsWeb.AccountLive.MyData do
use ToweropsWeb, :live_view
alias Towerops.Accounts
alias Towerops.Accounts.Scope
alias Towerops.Admin
alias Towerops.Alerts
alias Towerops.Devices
@ -29,12 +30,15 @@ defmodule ToweropsWeb.AccountLive.MyData do
audit_logs: get_user_audit_logs(user)
}
# Update scope with organization
scope = Scope.put_organization(socket.assigns.current_scope, default_organization)
{:ok,
socket
|> assign(:current_scope, scope)
|> assign(:user_data, user_data)
|> assign(:page_title, "My Data")
|> assign(:is_owner, is_owner)
|> assign(:current_organization, default_organization)}
|> assign(:is_owner, is_owner)}
end
@impl true
@ -43,7 +47,6 @@ defmodule ToweropsWeb.AccountLive.MyData do
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
current_organization={@current_organization}
active_page="settings"
>
<div class="space-y-8">

View file

@ -7,7 +7,7 @@ defmodule ToweropsWeb.AgentLive.Edit do
@impl true
def mount(%{"id" => id}, _session, socket) do
organization = socket.assigns.current_organization
organization = socket.assigns.current_scope.organization
# Get agent token and verify it belongs to this organization
agent_token = Towerops.Repo.get_by!(AgentToken, id: id, organization_id: organization.id)

View file

@ -11,7 +11,7 @@ defmodule ToweropsWeb.AgentLive.Index do
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_organization
organization = socket.assigns.current_scope.organization
current_scope = socket.assigns.current_scope
is_superuser = Scope.superuser?(current_scope)
agent_tokens = Agents.list_organization_agent_tokens(organization.id)
@ -68,7 +68,7 @@ defmodule ToweropsWeb.AgentLive.Index do
{name, is_cloud_poller} = parse_agent_params(params)
with :ok <- validate_cloud_poller_permission(socket.assigns.current_scope, is_cloud_poller),
{:ok, agent_token, token} <- create_agent(socket.assigns.current_organization, name, is_cloud_poller) do
{:ok, agent_token, token} <- create_agent(socket.assigns.current_scope.organization, name, is_cloud_poller) do
handle_agent_creation_success(socket, agent_token, token, is_cloud_poller)
else
{:error, :unauthorized} ->
@ -103,7 +103,7 @@ defmodule ToweropsWeb.AgentLive.Index do
@impl true
def handle_event("regenerate_token", %{"id" => id}, socket) do
agent_token = Agents.get_agent_token!(id)
organization = socket.assigns.current_organization
organization = socket.assigns.current_scope.organization
# Revoke the old token and create a new one
case Agents.revoke_agent_token(id) do
@ -132,7 +132,7 @@ defmodule ToweropsWeb.AgentLive.Index do
def handle_event("delete_agent", %{"id" => id}, socket) do
case Agents.delete_agent_token(id) do
{:ok, _} ->
organization = socket.assigns.current_organization
organization = socket.assigns.current_scope.organization
# If the deleted agent was the global default, clear it
if Scope.superuser?(socket.assigns.current_scope) &&
@ -251,7 +251,7 @@ defmodule ToweropsWeb.AgentLive.Index do
end
defp handle_agent_creation_success(socket, agent_token, token, is_cloud_poller) do
organization = socket.assigns.current_organization
organization = socket.assigns.current_scope.organization
agent_tokens = Agents.list_organization_agent_tokens(organization.id)
cloud_pollers = load_cloud_pollers_if_superuser(socket.assigns.current_scope)

View file

@ -1,9 +1,7 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
current_organization={@current_organization}
active_page="agents"
timezone={@timezone}
>
<.header>
{@page_title}

View file

@ -8,7 +8,7 @@ defmodule ToweropsWeb.AgentLive.Show do
@impl true
def mount(%{"id" => id}, _session, socket) do
organization = socket.assigns.current_organization
organization = socket.assigns.current_scope.organization
agent_token = Agents.get_agent_token!(id)
current_user = socket.assigns.current_scope.user

View file

@ -1,9 +1,7 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
current_organization={@current_organization}
active_page="agents"
timezone={@timezone}
>
<.header>
{@page_title}
@ -169,17 +167,11 @@
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Last Seen</p>
<p class="mt-2 text-lg font-semibold text-gray-900 dark:text-white">
<.timestamp
datetime={@agent_token.last_seen_at}
timezone={@timezone}
/>
<.timestamp datetime={@agent_token.last_seen_at} />
</p>
<%= if @agent_token.last_seen_at do %>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<.timestamp
datetime={@agent_token.last_seen_at}
timezone={@timezone}
/>
<.timestamp datetime={@agent_token.last_seen_at} />
</p>
<% end %>
<%= if @agent_token.last_ip do %>

View file

@ -7,7 +7,7 @@ defmodule ToweropsWeb.AlertLive.Index do
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_organization
organization = socket.assigns.current_scope.organization
# Subscribe to alert events
_ =
@ -30,12 +30,12 @@ defmodule ToweropsWeb.AlertLive.Index do
{:noreply,
socket
|> assign(:filter, filter)
|> load_alerts(socket.assigns.current_organization.id)}
|> load_alerts(socket.assigns.current_scope.organization.id)}
end
@impl true
def handle_event("acknowledge", %{"id" => id}, socket) do
organization = socket.assigns.current_organization
organization = socket.assigns.current_scope.organization
user_id = socket.assigns.current_scope.user.id
case Alerts.get_alert(id) do
@ -47,30 +47,34 @@ defmodule ToweropsWeb.AlertLive.Index do
alert = Repo.preload(alert, device: [site: :organization])
if alert.device.site.organization_id == organization.id do
case Alerts.acknowledge_alert(alert, user_id) do
{:ok, _alert} ->
{:noreply,
socket
|> put_flash(:info, "Alert acknowledged")
|> load_alerts(organization.id)}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Unable to acknowledge alert")}
end
perform_acknowledge_alert(socket, alert, user_id, organization.id)
else
{:noreply, put_flash(socket, :error, "You don't have access to this alert")}
end
end
end
defp perform_acknowledge_alert(socket, alert, user_id, organization_id) do
case Alerts.acknowledge_alert(alert, user_id) do
{:ok, _alert} ->
{:noreply,
socket
|> put_flash(:info, "Alert acknowledged")
|> load_alerts(organization_id)}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Unable to acknowledge alert")}
end
end
@impl true
def handle_info({:new_alert, _device_id, _alert_type}, socket) do
{:noreply, load_alerts(socket, socket.assigns.current_organization.id)}
{:noreply, load_alerts(socket, socket.assigns.current_scope.organization.id)}
end
@impl true
def handle_info({:alert_resolved, _device_id, _alert_type}, socket) do
{:noreply, load_alerts(socket, socket.assigns.current_organization.id)}
{:noreply, load_alerts(socket, socket.assigns.current_scope.organization.id)}
end
defp load_alerts(socket, organization_id) do

View file

@ -1,9 +1,7 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
current_organization={@current_organization}
active_page="alerts"
timezone={@timezone}
>
<.header>
Alerts

View file

@ -8,7 +8,7 @@ defmodule ToweropsWeb.DashboardLive do
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_organization
organization = socket.assigns.current_scope.organization
# Subscribe to real-time alert updates
_ =
@ -25,12 +25,12 @@ defmodule ToweropsWeb.DashboardLive do
@impl true
def handle_info({:new_alert, _device_id, _alert_type}, socket) do
{:noreply, load_dashboard_data(socket, socket.assigns.current_organization.id)}
{:noreply, load_dashboard_data(socket, socket.assigns.current_scope.organization.id)}
end
@impl true
def handle_info({:alert_resolved, _device_id, _alert_type}, socket) do
{:noreply, load_dashboard_data(socket, socket.assigns.current_organization.id)}
{:noreply, load_dashboard_data(socket, socket.assigns.current_scope.organization.id)}
end
defp load_dashboard_data(socket, organization_id) do

View file

@ -1,13 +1,11 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
current_organization={@current_organization}
active_page="dashboard"
timezone={@timezone}
>
<.header>
Dashboard
<:subtitle>Welcome to {@current_organization.name}</:subtitle>
<:subtitle>Welcome to {@current_scope.organization.name}</:subtitle>
</.header>
<%= if @sites_count == 0 do %>
@ -165,7 +163,6 @@
</div>
<.timestamp
datetime={alert.triggered_at}
timezone={@timezone}
class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap"
/>
</div>

View file

@ -12,7 +12,7 @@ defmodule ToweropsWeb.DeviceLive.Form do
@impl true
def mount(params, _session, socket) do
organization = socket.assigns.current_organization
organization = socket.assigns.current_scope.organization
sites = Sites.list_organization_sites(organization.id)
agents = Agents.list_organization_agent_tokens(organization.id)
@ -101,7 +101,7 @@ defmodule ToweropsWeb.DeviceLive.Form do
end
defp apply_action(socket, :edit, %{"id" => id}) do
organization = socket.assigns.current_organization
organization = socket.assigns.current_scope.organization
case Devices.get_device(id) do
nil ->

View file

@ -1,8 +1,6 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
current_organization={@organization}
timezone={@timezone}
>
<div phx-hook="ScrollToTop" id="scroll-container">
<div class="mb-4">

View file

@ -11,7 +11,7 @@ defmodule ToweropsWeb.DeviceLive.Index do
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_organization
organization = socket.assigns.current_scope.organization
device = Devices.list_organization_devices(organization.id)
sites = Sites.list_organization_sites(organization.id)
grouped_devices = group_devices_by_site(device)
@ -36,7 +36,7 @@ defmodule ToweropsWeb.DeviceLive.Index do
end
defp apply_action(socket, :index, _params, tab) do
organization = socket.assigns.current_organization
organization = socket.assigns.current_scope.organization
case tab do
"discovered" ->
@ -73,7 +73,7 @@ defmodule ToweropsWeb.DeviceLive.Index do
@impl true
def handle_event("reorder_site", %{"site_id" => site_id, "new_position" => new_position}, socket) do
organization = socket.assigns.current_organization
organization = socket.assigns.current_scope.organization
# Verify site belongs to current organization
case Sites.get_site(site_id) do
@ -82,24 +82,7 @@ defmodule ToweropsWeb.DeviceLive.Index do
site ->
if site.organization_id == organization.id do
# Handle both integer and string input
position = if is_integer(new_position), do: new_position, else: String.to_integer(new_position)
case Sites.reorder_site(site_id, position) do
{:ok, _site} ->
# Reload devices with updated order
devices = Devices.list_organization_devices(organization.id)
grouped_devices = group_devices_by_site(devices)
{:noreply,
socket
|> assign(:device, devices)
|> assign(:grouped_devices, grouped_devices)
|> put_flash(:info, "Site order updated")}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Failed to reorder site")}
end
perform_site_reorder(socket, site_id, new_position, organization.id)
else
{:noreply, put_flash(socket, :error, "You don't have access to this site")}
end
@ -108,7 +91,7 @@ defmodule ToweropsWeb.DeviceLive.Index do
@impl true
def handle_event("reorder_device", %{"device_id" => device_id, "new_position" => new_position}, socket) do
organization = socket.assigns.current_organization
organization = socket.assigns.current_scope.organization
# Verify device belongs to current organization
case Devices.get_device(device_id) do
@ -120,24 +103,7 @@ defmodule ToweropsWeb.DeviceLive.Index do
device = Repo.preload(device, site: :organization)
if device.site.organization_id == organization.id do
# Handle both integer and string input
position = if is_integer(new_position), do: new_position, else: String.to_integer(new_position)
case Devices.reorder_device(device_id, position) do
{:ok, _device} ->
# Reload devices with updated order
devices = Devices.list_organization_devices(organization.id)
grouped_devices = group_devices_by_site(devices)
{:noreply,
socket
|> assign(:device, devices)
|> assign(:grouped_devices, grouped_devices)
|> put_flash(:info, "Device order updated")}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Failed to reorder device")}
end
perform_device_reorder(socket, device_id, new_position, organization.id)
else
{:noreply, put_flash(socket, :error, "You don't have access to this device")}
end
@ -151,7 +117,7 @@ defmodule ToweropsWeb.DeviceLive.Index do
@impl true
def handle_event("reset_order", _params, socket) do
organization_id = socket.assigns.current_organization.id
organization_id = socket.assigns.current_scope.organization.id
Sites.reset_site_order(organization_id)
Devices.reset_organization_device_order(organization_id)
@ -180,6 +146,48 @@ defmodule ToweropsWeb.DeviceLive.Index do
{:noreply, push_navigate(socket, to: ~p"/devices/new?#{prefill_params}")}
end
defp perform_site_reorder(socket, site_id, new_position, organization_id) do
# Handle both integer and string input
position = if is_integer(new_position), do: new_position, else: String.to_integer(new_position)
case Sites.reorder_site(site_id, position) do
{:ok, _site} ->
# Reload devices with updated order
devices = Devices.list_organization_devices(organization_id)
grouped_devices = group_devices_by_site(devices)
{:noreply,
socket
|> assign(:device, devices)
|> assign(:grouped_devices, grouped_devices)
|> put_flash(:info, "Site order updated")}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Failed to reorder site")}
end
end
defp perform_device_reorder(socket, device_id, new_position, organization_id) do
# Handle both integer and string input
position = if is_integer(new_position), do: new_position, else: String.to_integer(new_position)
case Devices.reorder_device(device_id, position) do
{:ok, _device} ->
# Reload devices with updated order
devices = Devices.list_organization_devices(organization_id)
grouped_devices = group_devices_by_site(devices)
{:noreply,
socket
|> assign(:device, devices)
|> assign(:grouped_devices, grouped_devices)
|> put_flash(:info, "Device order updated")}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Failed to reorder device")}
end
end
defp enqueue_discovery(device_id) do
if Application.get_env(:towerops, :env) == :test do
_ = Task.start(fn -> Snmp.discover_device(Devices.get_device!(device_id)) end)

View file

@ -1,9 +1,7 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
current_organization={@current_organization}
active_page="devices"
timezone={@timezone}
>
<div class="flex items-start justify-between mb-8">
<div>
@ -460,7 +458,6 @@
<:col :let={discovered} label="Last Seen">
<.timestamp
datetime={discovered.last_seen}
timezone={@timezone}
class="text-sm text-gray-600 dark:text-gray-400"
/>
</:col>

View file

@ -41,7 +41,7 @@ defmodule ToweropsWeb.DeviceLive.Show do
@impl true
def handle_params(%{"id" => id} = params, _, socket) do
organization = socket.assigns.current_organization
organization = socket.assigns.current_scope.organization
# Check if device exists and verify organization access
case Devices.get_device(id) do
@ -56,11 +56,7 @@ defmodule ToweropsWeb.DeviceLive.Show do
device = Repo.preload(device, site: :organization)
if device.site.organization_id == organization.id do
_ =
if connected?(socket) do
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "device:#{id}")
Process.send_after(self(), :refresh_data, 10_000)
end
maybe_subscribe_and_schedule_refresh(socket, id)
tab = Map.get(params, "tab", "overview")
@ -161,6 +157,15 @@ defmodule ToweropsWeb.DeviceLive.Show do
# Private functions
defp maybe_subscribe_and_schedule_refresh(socket, device_id) do
if connected?(socket) do
Phoenix.PubSub.subscribe(Towerops.PubSub, "device:#{device_id}")
Process.send_after(self(), :refresh_data, 10_000)
end
:ok
end
defp load_equipment_data(socket, device_id) do
device = Devices.get_device!(device_id)
device_with_associations = Repo.preload(device, site: [organization: :default_agent_token])

View file

@ -1,9 +1,7 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
current_organization={@current_organization}
active_page="devices"
timezone={@timezone}
>
<div class="mb-6 -mt-2">
<!-- Breadcrumbs -->

View file

@ -14,7 +14,7 @@ defmodule ToweropsWeb.GraphLive.Show do
@impl true
def handle_params(%{"id" => device_id, "sensor_type" => sensor_type} = params, _, socket) do
organization = socket.assigns.current_organization
organization = socket.assigns.current_scope.organization
# Verify device exists and user has access
case Devices.get_device(device_id) do
@ -29,10 +29,7 @@ defmodule ToweropsWeb.GraphLive.Show do
device = Repo.preload(device, site: :organization)
if device.site.organization_id == organization.id do
_ =
if connected?(socket) do
Phoenix.PubSub.subscribe(Towerops.PubSub, "device:#{device_id}")
end
maybe_subscribe_to_device(socket, device_id)
range = Map.get(params, "range", "24h")
interface_id = Map.get(params, "interface_id")
@ -59,6 +56,14 @@ defmodule ToweropsWeb.GraphLive.Show do
end
end
defp maybe_subscribe_to_device(socket, device_id) do
if connected?(socket) do
Phoenix.PubSub.subscribe(Towerops.PubSub, "device:#{device_id}")
end
:ok
end
@impl true
def handle_event("change_range", %{"range" => range}, socket) do
params = build_graph_params(socket.assigns, range)

View file

@ -1,9 +1,7 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
current_organization={@current_organization}
active_page="devices"
timezone={@timezone}
>
<div class="mb-6">
<!-- Header with back button -->

View file

@ -3,6 +3,7 @@ defmodule ToweropsWeb.HelpLive.Index do
use ToweropsWeb, :live_view
alias Towerops.Accounts
alias Towerops.Accounts.Scope
alias Towerops.Organizations
@impl true
@ -23,10 +24,9 @@ defmodule ToweropsWeb.HelpLive.Index do
# Build current_scope if authenticated
current_scope =
if current_user do
%{
user: current_user,
impersonating?: false
}
current_user
|> Scope.for_user()
|> Scope.put_organization(current_organization)
end
socket =
@ -35,7 +35,6 @@ defmodule ToweropsWeb.HelpLive.Index do
|> assign(:current_user, current_user)
|> assign(:is_authenticated, is_authenticated)
|> assign(:current_scope, current_scope)
|> assign(:current_organization, current_organization)
|> assign(:timezone, if(current_user, do: current_user.timezone, else: "UTC"))
{:ok, socket}

View file

@ -2,9 +2,7 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
current_organization={@current_organization}
active_page="help"
timezone={@timezone}
>
<.help_content active_section={@active_section} />
</Layouts.authenticated>

View file

@ -6,7 +6,7 @@ defmodule ToweropsWeb.NetworkMapLive do
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_organization
organization = socket.assigns.current_scope.organization
# Subscribe to topology changes if connected
_ =
@ -40,7 +40,7 @@ defmodule ToweropsWeb.NetworkMapLive do
@impl true
def handle_params(params, _url, socket) do
tab = Map.get(params, "tab", "added")
organization = socket.assigns.current_organization
organization = socket.assigns.current_scope.organization
{:noreply,
socket
@ -50,7 +50,7 @@ defmodule ToweropsWeb.NetworkMapLive do
@impl true
def handle_event("refresh_topology", _params, socket) do
organization = socket.assigns.current_organization
organization = socket.assigns.current_scope.organization
{:noreply,
socket
@ -68,7 +68,7 @@ defmodule ToweropsWeb.NetworkMapLive do
@impl true
def handle_info({:topology_updated, _organization_id}, socket) do
# Real-time topology update
{:noreply, load_topology_data(socket, socket.assigns.current_organization.id, socket.assigns.active_tab)}
{:noreply, load_topology_data(socket, socket.assigns.current_scope.organization.id, socket.assigns.active_tab)}
end
defp load_topology_data(socket, organization_id, tab) do

View file

@ -1,9 +1,7 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
current_organization={@current_organization}
active_page="network-map"
timezone={@timezone}
>
<.header>
<span class="flex items-center gap-2">

View file

@ -7,7 +7,7 @@ defmodule ToweropsWeb.Org.SettingsLive do
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_organization
organization = socket.assigns.current_scope.organization
available_agents = Agents.list_organization_agent_tokens(organization.id)
# Get assignment breakdown to show impact

View file

@ -1,7 +1,6 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
current_organization={@organization}
>
<div class="mb-4">
<.link

View file

@ -1,8 +1,6 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
current_organization={nil}
timezone={@timezone}
>
<.header>
{@page_title}

View file

@ -1,8 +1,6 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
current_organization={nil}
timezone={@timezone}
>
<.header>
{@page_title}

View file

@ -8,7 +8,7 @@ defmodule ToweropsWeb.SiteLive.Form do
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_organization
organization = socket.assigns.current_scope.organization
{:ok,
socket

View file

@ -1,8 +1,6 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
current_organization={@organization}
timezone={@timezone}
>
<div class="mb-4">
<.link

View file

@ -6,7 +6,7 @@ defmodule ToweropsWeb.SiteLive.Index do
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_organization
organization = socket.assigns.current_scope.organization
sites = Sites.list_organization_sites(organization.id)
site_tree = Sites.build_site_tree(organization.id)

View file

@ -1,9 +1,7 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
current_organization={@current_organization}
active_page="sites"
timezone={@timezone}
>
<.header>
{@page_title}

View file

@ -15,7 +15,7 @@ defmodule ToweropsWeb.SiteLive.Show do
@impl true
def handle_params(%{"id" => id}, _, socket) do
organization = socket.assigns.current_organization
organization = socket.assigns.current_scope.organization
site = Sites.get_organization_site!(organization.id, id)
device = Devices.list_site_devices(site.id)
latency_chart_data = load_site_latency_chart_data(device)

View file

@ -1,9 +1,7 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
current_organization={@current_organization}
active_page="sites"
timezone={@timezone}
>
<.header>
{@page_title}

View file

@ -259,8 +259,6 @@ defmodule ToweropsWeb.UserSettingsLive do
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
current_organization={@default_organization}
timezone={@timezone}
>
<div class="border-b border-gray-200 pb-5 dark:border-white/5">
<h1 class="text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">

View file

@ -354,15 +354,15 @@ defmodule ToweropsWeb.UserAuth do
"""
def load_current_organization(conn, _opts) do
org_slug = conn.path_params["org_slug"]
user = conn.assigns.current_scope && conn.assigns.current_scope.user
scope = conn.assigns.current_scope
if org_slug && user do
if org_slug && scope && scope.user do
organization = Towerops.Organizations.get_organization_by_slug!(org_slug)
membership = Towerops.Organizations.get_membership(organization.id, user.id)
membership = Towerops.Organizations.get_membership(organization.id, scope.user.id)
if membership do
conn
|> assign(:current_organization, organization)
|> assign(:current_scope, Scope.put_organization(scope, organization))
|> assign(:current_membership, membership)
else
conn
@ -392,6 +392,7 @@ defmodule ToweropsWeb.UserAuth do
* `:redirect_if_user_is_authenticated` - Redirects authenticated users
* `:require_authenticated_user` - Requires user authentication
* `:load_current_organization` - Loads organization from URL slug
* `:require_organization_owner` - Requires user to be an owner of the current organization
"""
def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do
@ -421,16 +422,16 @@ defmodule ToweropsWeb.UserAuth do
end
def on_mount(:load_current_organization, %{"org_slug" => org_slug}, _session, socket) do
user = socket.assigns.current_scope && socket.assigns.current_scope.user
scope = socket.assigns.current_scope
if org_slug && user do
if org_slug && scope && scope.user do
organization = Towerops.Organizations.get_organization_by_slug!(org_slug)
membership = Towerops.Organizations.get_membership(organization.id, user.id)
membership = Towerops.Organizations.get_membership(organization.id, scope.user.id)
if membership do
{:cont,
socket
|> Phoenix.Component.assign(:current_organization, organization)
|> Phoenix.Component.assign(:current_scope, Scope.put_organization(scope, organization))
|> Phoenix.Component.assign(:current_membership, membership)}
else
socket =
@ -518,6 +519,21 @@ defmodule ToweropsWeb.UserAuth do
end
end
def on_mount(:require_organization_owner, _params, _session, socket) do
membership = Map.get(socket.assigns, :current_membership)
if membership && membership.role == :owner do
{:cont, socket}
else
socket =
socket
|> LiveView.put_flash(:error, "You must be an organization owner to access this page.")
|> LiveView.redirect(to: ~p"/orgs")
{:halt, socket}
end
end
def on_mount(:load_cookie_consent, _params, session, socket) do
requires_consent = Map.get(session, "requires_cookie_consent", false)
@ -570,9 +586,11 @@ defmodule ToweropsWeb.UserAuth do
halt_with_error(socket, "You don't have access to any organizations.")
mem ->
scope = socket.assigns.current_scope
{:cont,
socket
|> Phoenix.Component.assign(:current_organization, organization)
|> Phoenix.Component.assign(:current_scope, Scope.put_organization(scope, organization))
|> Phoenix.Component.assign(:current_membership, mem)}
end
end

View file

@ -1,11 +1,13 @@
defmodule SnmpKit.SnmpMgr.MIB.ExtractionTest do
use ExUnit.Case, async: true
alias SnmpKit.SnmpLib.MIB.Parser
# Test the actual extract_mib_mappings private function behavior
test "extract_mib_mappings resolves UBNT-MIB OIDs" do
mib_path = Path.join(:code.priv_dir(:towerops), "mibs/ubnt/UBNT-MIB")
{:ok, mib_content} = File.read(mib_path)
{:ok, parsed} = SnmpKit.SnmpLib.MIB.Parser.parse(mib_content)
{:ok, parsed} = Parser.parse(mib_content)
# Simulate state with standard MIBs
mock_state = %{

View file

@ -1,10 +1,12 @@
defmodule SnmpKit.SnmpMgr.MIBTest do
use ExUnit.Case, async: false
alias SnmpKit.SnmpLib.MIB.Parser
# Test helper to parse a MIB and extract name->OID map
defp parse_and_extract(mib_path) do
{:ok, content} = File.read(mib_path)
{:ok, parsed} = SnmpKit.SnmpLib.MIB.Parser.parse(content)
{:ok, parsed} = Parser.parse(content)
definitions = Map.get(parsed, :definitions, [])
# Extract OID definitions (simulating build_oid_definition_map)

View file

@ -367,7 +367,7 @@ defmodule ToweropsWeb.UserAuthTest do
|> UserAuth.load_current_organization([])
refute conn.halted
assert conn.assigns.current_organization.id == organization.id
assert conn.assigns.current_scope.organization.id == organization.id
assert conn.assigns.current_membership
end
@ -699,7 +699,7 @@ defmodule ToweropsWeb.UserAuthTest do
socket
)
assert socket.assigns.current_organization.id == organization.id
assert socket.assigns.current_scope.organization.id == organization.id
assert socket.assigns.current_membership.role == :owner
end
@ -908,7 +908,7 @@ defmodule ToweropsWeb.UserAuthTest do
socket
)
assert result_socket.assigns.current_organization.id == organization.id
assert result_socket.assigns.current_scope.organization.id == organization.id
assert result_socket.assigns.current_membership
end
@ -928,7 +928,7 @@ defmodule ToweropsWeb.UserAuthTest do
socket
)
assert result_socket.assigns.current_organization.id == organization.id
assert result_socket.assigns.current_scope.organization.id == organization.id
assert result_socket.assigns.current_membership
end