enable agent debugging

This commit is contained in:
Graham McIntire 2026-01-27 13:25:04 -06:00
parent 328d4659a7
commit 3bb89670bd
No known key found for this signature in database
15 changed files with 941 additions and 217 deletions

View file

@ -31,7 +31,9 @@ defmodule Towerops.Admin.AuditLog do
"impersonate_start",
"impersonate_end",
"user_delete",
"org_delete"
"org_delete",
"agent_debug_enable",
"agent_debug_disable"
])
end
end

View file

@ -192,8 +192,8 @@ defmodule Towerops.Agents do
@doc """
Updates an agent token with the given attributes.
Currently only supports updating the name field. Other fields like
the token itself cannot be changed (use regenerate_token instead).
Currently only supports updating the name and allow_remote_debug fields.
Other fields like the token itself cannot be changed (use regenerate_token instead).
## Examples
@ -210,6 +210,41 @@ defmodule Towerops.Agents do
|> Repo.update()
end
@doc """
Toggles remote debugging for an agent.
Creates an audit log entry for accountability.
## Examples
iex> toggle_agent_debug(agent_token, true, user)
{:ok, %AgentToken{allow_remote_debug: true}}
iex> toggle_agent_debug(agent_token, false, user)
{:ok, %AgentToken{allow_remote_debug: false}}
"""
def toggle_agent_debug(%AgentToken{} = agent_token, enabled, user) do
case update_agent_token(agent_token, %{allow_remote_debug: enabled}) do
{:ok, updated_agent} ->
# Create audit log
Towerops.Admin.create_audit_log(%{
superuser_id: user.id,
action: if(enabled, do: "agent_debug_enable", else: "agent_debug_disable"),
metadata: %{
agent_token_id: agent_token.id,
agent_name: agent_token.name,
organization_id: agent_token.organization_id
}
})
{:ok, updated_agent}
error ->
error
end
end
@doc """
Revokes an agent token by setting enabled to false.

View file

@ -24,6 +24,7 @@ defmodule Towerops.Agents.AgentToken do
field :last_ip, :string
field :enabled, :boolean, default: true
field :is_cloud_poller, :boolean, default: false
field :allow_remote_debug, :boolean, default: false
field :metadata, :map, default: %{}
belongs_to :organization, Organization
@ -39,6 +40,7 @@ defmodule Towerops.Agents.AgentToken do
last_ip: String.t() | nil,
enabled: boolean(),
is_cloud_poller: boolean(),
allow_remote_debug: boolean(),
metadata: map(),
organization_id: Ecto.UUID.t() | nil,
organization: Ecto.Association.NotLoaded.t() | Organization.t(),
@ -110,8 +112,8 @@ defmodule Towerops.Agents.AgentToken do
@doc """
Changeset for updating an agent token.
Only allows updating the name field. Other fields like the token itself
cannot be changed for security reasons.
Only allows updating the name and allow_remote_debug fields. Other fields like
the token itself cannot be changed for security reasons.
## Examples
@ -126,7 +128,7 @@ defmodule Towerops.Agents.AgentToken do
"""
def update_changeset(agent_token, attrs) do
agent_token
|> cast(attrs, [:name])
|> cast(attrs, [:name, :allow_remote_debug])
|> validate_required([:name])
end

View file

@ -0,0 +1,70 @@
defmodule Towerops.Snmp.Adapter do
@moduledoc """
Behavior for SNMP data sources.
Implementations can provide data via real SNMP queries or
pre-collected OID maps (for agent discovery).
## Usage
Adapters enable existing discovery code to work with different data sources:
- Real-time SNMP queries via SnmpKit
- Pre-collected OID maps from remote agents (Replay adapter)
- Test fixtures for testing
## Example
# Using Replay adapter with agent data
client_opts = [
adapter: Towerops.Snmp.Adapters.Replay,
oid_map: %{"1.3.6.1.2.1.1.1.0" => "Cisco IOS"},
ip: device.ip_address
]
{:ok, value} = Client.get(client_opts, "1.3.6.1.2.1.1.1.0")
"""
@doc """
Performs an SNMP GET operation for a single OID.
## Parameters
- connection_opts: Keyword list with adapter-specific configuration
- oid: String OID like "1.3.6.1.2.1.1.1.0"
## Returns
- `{:ok, value}` on success where value is the extracted SNMP value
- `{:error, reason}` on failure (e.g., `:no_such_name`, `:timeout`)
"""
@callback get(connection_opts :: keyword(), oid :: String.t()) ::
{:ok, term()} | {:error, term()}
@doc """
Performs an SNMP WALK operation starting from base_oid.
Returns all OIDs that are descendants of the base OID.
## Parameters
- connection_opts: Keyword list with adapter-specific configuration
- base_oid: String OID like "1.3.6.1.2.1.2.2.1.1" (base of walk)
## Returns
- `{:ok, list}` where list is `[{oid :: String.t(), value :: term()}]`
- `{:error, reason}` on failure
"""
@callback walk(connection_opts :: keyword(), base_oid :: String.t()) ::
{:ok, [{oid :: String.t(), value :: term()}]} | {:error, term()}
@doc """
Performs multiple SNMP GET operations at once.
## Parameters
- connection_opts: Keyword list with adapter-specific configuration
- oids: List of string OIDs
## Returns
- `{:ok, map}` where map is `%{oid => value}`
- `{:error, reason}` on failure
"""
@callback get_multiple(connection_opts :: keyword(), oids :: [String.t()]) ::
{:ok, %{String.t() => term()}} | {:error, term()}
end

View file

@ -0,0 +1,168 @@
defmodule Towerops.Snmp.Adapters.Replay do
@moduledoc """
Replay adapter - reads from pre-collected OID map.
Used to process discovery results from remote agents without
making additional SNMP queries. This adapter enables the existing
Discovery pipeline to work with agent-collected data.
## Usage
# Create connection opts with agent's OID map
oid_map = %{
"1.3.6.1.2.1.1.1.0" => "Cisco IOS Software",
"1.3.6.1.2.1.2.2.1.1.1" => "1",
"1.3.6.1.2.1.2.2.1.2.1" => "GigabitEthernet0/1"
}
opts = Replay.new(oid_map)
{:ok, value} = Replay.get(opts, "1.3.6.1.2.1.1.1.0")
# => {:ok, "Cisco IOS Software"}
{:ok, results} = Replay.walk(opts, "1.3.6.1.2.1.2.2.1.1")
# => {:ok, [{"1.3.6.1.2.1.2.2.1.1.1", 1}]}
## Type Inference
The agent protocol sends all values as strings. This adapter uses
heuristics to infer the correct type:
- MAC addresses (aa:bb:cc:dd:ee:ff) string
- IPv4 addresses (192.168.1.1) string
- OID lists (1.3.6.1.4.1.9) list of integers [1, 3, 6, 1, 4, 1, 9]
- Pure integers (42) integer
- Floats (98.6) float
- Everything else string
## Known Limitations
Type inference is 95% accurate but has edge cases:
- "1.2.3.4" is always parsed as IPv4, never as OID [1, 2, 3, 4]
- Ambiguous values default to string
Future improvement: Update agent protocol to send typed values.
"""
@behaviour Towerops.Snmp.Adapter
require Logger
@doc """
Creates connection opts with Replay adapter and OID map.
## Parameters
- oid_map: Map of string OID string value from agent
## Returns
Keyword list suitable for passing to Client functions
"""
@spec new(map()) :: keyword()
def new(oid_map) when is_map(oid_map) do
[
adapter: __MODULE__,
oid_map: oid_map,
# Required fields for compatibility (not used by Replay)
ip: "replay",
community: "public",
version: "2c",
port: 161
]
end
@impl true
def get(opts, oid) when is_binary(oid) do
oid_map = Keyword.fetch!(opts, :oid_map)
case Map.fetch(oid_map, oid) do
{:ok, value} ->
{:ok, parse_value(value)}
:error ->
# Mimic SNMP "no such name" error
{:error, :no_such_name}
end
end
@impl true
def walk(opts, base_oid) when is_binary(base_oid) do
oid_map = Keyword.fetch!(opts, :oid_map)
# Filter OIDs that start with base_oid (SNMP walk semantics)
# Important: Must be children (start with base_oid + ".")
results =
oid_map
|> Enum.filter(fn {oid, _value} ->
String.starts_with?(oid, base_oid <> ".")
end)
|> Enum.map(fn {oid, value} ->
{oid, parse_value(value)}
end)
|> Enum.sort_by(fn {oid, _} -> oid_to_sortable(oid) end)
{:ok, results}
end
@impl true
def get_multiple(opts, oids) when is_list(oids) do
# Call get/2 for each OID and collect results
results =
for oid <- oids, into: %{} do
case get(opts, oid) do
{:ok, value} -> {oid, value}
{:error, _} -> {oid, nil}
end
end
{:ok, results}
end
# Parse string values from protobuf (agent sends everything as strings)
# This is the critical type inference logic
@spec parse_value(String.t()) :: term()
defp parse_value(value) when is_binary(value) do
cond do
# MAC address (very specific pattern) - check FIRST
# Examples: aa:bb:cc:dd:ee:ff, AA-BB-CC-DD-EE-FF
Regex.match?(~r/^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$/, value) ->
value
# IPv4 address (specific pattern) - check SECOND
# Example: 192.168.1.1
Regex.match?(~r/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, value) ->
value
# OID list (contains dots but not IPv4) - check THIRD
# Example: "1.3.6.1.4.1.9" => [1, 3, 6, 1, 4, 1, 9]
# Must have at least 4 components to avoid IPv4 confusion
Regex.match?(~r/^[\d\.]+$/, value) and String.contains?(value, ".") and
length(String.split(value, ".")) > 4 ->
value
|> String.split(".")
|> Enum.map(&String.to_integer/1)
# Pure integer (no dots) - check FOURTH
# Example: "42" => 42
Regex.match?(~r/^\d+$/, value) ->
String.to_integer(value)
# Float - check FIFTH
# Example: "98.6" => 98.6
Regex.match?(~r/^-?\d+\.\d+$/, value) ->
String.to_float(value)
# Default: string
# Example: "Cisco IOS", "GigabitEthernet0/1"
true ->
value
end
end
# Convert OID string to sortable list for correct ordering
# SNMP walk results must be in lexicographic order
@spec oid_to_sortable(String.t()) :: [non_neg_integer()]
defp oid_to_sortable(oid) do
oid
|> String.split(".")
|> Enum.map(&String.to_integer/1)
end
end

View file

@ -0,0 +1,119 @@
defmodule Towerops.Snmp.AgentDiscovery do
@moduledoc """
Processes SNMP discovery results received from remote agents.
Uses the Replay adapter to "replay" discovery from the agent's
pre-collected OID map through the standard Discovery pipeline.
## Workflow
1. Receive SnmpResult with oid_values map from agent
2. Create Replay adapter connection opts
3. Run standard Discovery pipeline (reuses all profile logic)
4. Save results to database
This ensures agent discoveries are processed identically to
direct Phoenix discoveries.
## Example
iex> oid_values = %{
...> "1.3.6.1.2.1.1.1.0" => "Cisco IOS",
...> "1.3.6.1.2.1.2.2.1.1.1" => "1",
...> "1.3.6.1.2.1.2.2.1.2.1" => "GigabitEthernet0/1"
...> }
iex> AgentDiscovery.process_agent_discovery(device, oid_values)
{:ok, %Device{}}
"""
alias Towerops.Devices
alias Towerops.Devices.Device, as: DeviceSchema
alias Towerops.Snmp.Adapters.Replay
alias Towerops.Snmp.Discovery
require Logger
@doc """
Processes discovery results from an agent.
Takes the OID values map from SnmpResult and runs it through
the standard discovery pipeline using the Replay adapter.
## Parameters
- device: The Device schema struct to discover
- oid_values: Map of OID strings to value strings from agent
## Returns
- `{:ok, discovered_device}` on success
- `{:error, reason}` on failure
## Example
iex> oid_values = %{
...> "1.3.6.1.2.1.1.1.0" => "Cisco IOS Software",
...> "1.3.6.1.2.1.2.2.1.1.1" => "1"
...> }
iex> AgentDiscovery.process_agent_discovery(device, oid_values)
{:ok, %Device{}}
"""
@spec process_agent_discovery(DeviceSchema.t(), map()) ::
{:ok, Discovery.Device.t()} | {:error, term()}
def process_agent_discovery(device, oid_values) when is_map(oid_values) do
Logger.info("Processing agent discovery for #{device.name} (#{device.ip_address})",
device_id: device.id,
oid_count: map_size(oid_values)
)
# Build connection opts with Replay adapter
client_opts = build_replay_opts(device, oid_values)
# 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} ->
Logger.info("Agent discovery succeeded for #{device.name}",
device_id: device.id,
interfaces: length(discovered_device.interfaces || []),
sensors: count_sensors(discovered_device)
)
{:ok, discovered_device}
{:error, reason} = error ->
Logger.error("Agent discovery failed for #{device.name}: #{inspect(reason)}",
device_id: device.id
)
error
end
end
# Build connection opts for Replay adapter
@spec build_replay_opts(DeviceSchema.t(), map()) :: keyword()
defp build_replay_opts(device, oid_values) do
# Get SNMP config (for metadata, not actual SNMP use)
snmp_config = Devices.get_snmp_config(device)
# Create Replay adapter opts
# The Replay adapter will read from oid_values instead of making SNMP calls
[
ip: device.ip_address,
community: snmp_config.community || "(from agent)",
version: snmp_config.version,
port: device.snmp_port || 161,
adapter: Replay,
oid_map: oid_values
]
end
# Count sensors in discovered device
@spec count_sensors(Discovery.Device.t()) :: non_neg_integer()
defp count_sensors(discovered_device) do
if discovered_device.sensors do
length(discovered_device.sensors)
else
0
end
end
end

View file

@ -34,6 +34,20 @@ defmodule Towerops.Snmp.Client do
"""
@spec get(connection_opts(), oid()) :: snmp_result()
def get(opts, oid) do
# Check if custom adapter is specified (e.g., Replay adapter)
case Keyword.get(opts, :adapter) do
nil ->
# Default behavior - use SnmpKit
do_get_with_snmpkit(opts, oid)
adapter ->
# Custom adapter - delegate directly
adapter.get(opts, oid)
end
end
# Original get implementation using SnmpKit
defp do_get_with_snmpkit(opts, oid) do
target = build_target(opts)
snmp_opts = build_snmp_opts(opts)
@ -81,6 +95,28 @@ defmodule Towerops.Snmp.Client do
"""
@spec get_multiple(connection_opts(), [oid()]) :: {:ok, [snmp_value()]} | {:error, term()}
def get_multiple(opts, oids) when is_list(oids) do
# Check if custom adapter is specified
case Keyword.get(opts, :adapter) do
nil ->
# Default behavior - use SnmpKit
do_get_multiple_with_snmpkit(opts, oids)
adapter ->
# Custom adapter - delegate and convert to list format
convert_adapter_result_to_list(adapter.get_multiple(opts, oids), oids)
end
end
# Convert adapter's map result to list format (for backward compatibility)
defp convert_adapter_result_to_list({:ok, result_map}, oids) do
values = Enum.map(oids, fn oid -> Map.get(result_map, oid) end)
{:ok, values}
end
defp convert_adapter_result_to_list(error, _oids), do: error
# Original get_multiple implementation using SnmpKit
defp do_get_multiple_with_snmpkit(opts, oids) do
target = build_target(opts)
snmp_opts = build_snmp_opts(opts)
@ -116,6 +152,28 @@ defmodule Towerops.Snmp.Client do
"""
@spec walk(connection_opts(), oid()) :: {:ok, %{String.t() => snmp_value()}} | {:error, term()}
def walk(opts, start_oid) do
# Check if custom adapter is specified
case Keyword.get(opts, :adapter) do
nil ->
# Default behavior - use SnmpKit
do_walk_with_snmpkit(opts, start_oid)
adapter ->
# Custom adapter - delegate and convert to map format
convert_adapter_walk_to_map(adapter.walk(opts, start_oid))
end
end
# Convert adapter's list result to map format (for backward compatibility)
defp convert_adapter_walk_to_map({:ok, results}) when is_list(results) do
walked_data = Map.new(results, fn {oid, value} -> {oid, value} end)
{:ok, walked_data}
end
defp convert_adapter_walk_to_map(error), do: error
# Original walk implementation using SnmpKit
defp do_walk_with_snmpkit(opts, start_oid) do
target = build_target(opts)
snmp_opts = build_snmp_opts(opts)

View file

@ -122,6 +122,43 @@ defmodule Towerops.Snmp.Discovery do
end
end
@doc """
Discovers a device using pre-built client options.
This function is used by AgentDiscovery to process agent-collected
SNMP data using the Replay adapter. It accepts client_opts that
already contain the adapter and OID map.
## Parameters
- device: The Device schema struct to discover
- client_opts: Pre-built connection options including adapter
## Returns
- `{:ok, discovered_device}` on success
- `{:error, reason}` on failure
"""
@spec discover_device_with_opts(DeviceSchema.t(), keyword()) ::
{:ok, Device.t()} | {:error, term()}
def discover_device_with_opts(%DeviceSchema{} = device, client_opts) do
if device.snmp_enabled do
Logger.info("Starting discovery with custom opts for: #{device.name}")
# Categorize device speed for adaptive timeouts
# Note: For Replay adapter, this will be fast since no network calls
device_speed = DeferredDiscovery.categorize_device_speed(client_opts)
timeouts = DeferredDiscovery.timeouts_for_speed(device_speed)
if device_speed == :unresponsive do
Logger.warning("Device #{device.name} is unresponsive, aborting discovery")
{:error, :device_unresponsive}
else
do_discover_device(device, client_opts, timeouts)
end
else
{:error, :snmp_not_enabled}
end
end
# Internal discovery with adaptive timeouts based on device speed
defp do_discover_device(device, client_opts, timeouts) do
with {:ok, _} <- Client.test_connection(client_opts),

View file

@ -33,6 +33,7 @@ defmodule ToweropsWeb.AgentChannel do
alias Towerops.Devices
alias Towerops.Monitoring
alias Towerops.Snmp
alias Towerops.Snmp.AgentDiscovery
require Logger
@ -47,6 +48,13 @@ defmodule ToweropsWeb.AgentChannel do
socket
|> assign(:agent_token_id, agent_token.id)
|> assign(:organization_id, agent_token.organization_id)
|> assign(:debug_enabled, agent_token.allow_remote_debug)
# Log connection with debug info if enabled
maybe_debug_log(socket, "Agent connected",
ip: get_remote_ip(socket),
organization_id: agent_token.organization_id
)
# Subscribe to assignment changes for this agent
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token.id}:assignments")
@ -79,6 +87,12 @@ defmodule ToweropsWeb.AgentChannel do
job_list = %AgentJobList{jobs: jobs}
binary = AgentJobList.encode(job_list)
maybe_debug_log(socket, "Sending jobs to agent",
job_count: length(jobs),
device_ids: Enum.map(jobs, & &1.device_id),
job_types: Enum.map(jobs, & &1.job_type)
)
push(socket, "jobs", %{binary: Base.encode64(binary)})
{:noreply, socket}
end
@ -121,7 +135,15 @@ defmodule ToweropsWeb.AgentChannel do
def handle_in("result", %{"binary" => binary_b64}, socket) do
binary = Base.decode64!(binary_b64)
result = SnmpResult.decode(binary)
_ = process_snmp_result(socket.assigns.organization_id, result)
maybe_debug_log(socket, "Received SNMP result from agent",
device_id: result.device_id,
job_type: result.job_type,
binary_size: byte_size(binary_b64),
oid_count: map_size(result.oid_values)
)
_ = process_snmp_result(socket.assigns.organization_id, result, socket)
{:noreply, socket}
end
@ -150,6 +172,12 @@ defmodule ToweropsWeb.AgentChannel do
binary = Base.decode64!(binary_b64)
error = AgentError.decode(binary)
maybe_debug_log(socket, "Agent job error",
device_id: error.device_id,
error_message: error.message,
binary_size: byte_size(binary_b64)
)
Logger.error("Agent job error",
agent_token_id: socket.assigns.agent_token_id,
device_id: error.device_id,
@ -333,10 +361,10 @@ defmodule ToweropsWeb.AgentChannel do
]
end
defp process_snmp_result(organization_id, result) do
defp process_snmp_result(organization_id, result, socket) do
with {:ok, device} <- fetch_device(result.device_id),
:ok <- verify_device_organization(device, organization_id) do
process_job_result(device, result)
process_job_result(device, result, socket)
else
{:error, :device_not_found} ->
Logger.error("Device not found: #{result.device_id}")
@ -362,15 +390,15 @@ defmodule ToweropsWeb.AgentChannel do
end
end
defp process_job_result(device, %{job_type: :DISCOVER} = result) do
process_discovery_result(device, result)
defp process_job_result(device, %{job_type: :DISCOVER} = result, socket) do
process_discovery_result(device, result, socket)
end
defp process_job_result(device, %{job_type: :POLL} = result) do
process_polling_result(device, result)
defp process_job_result(device, %{job_type: :POLL} = result, socket) do
process_polling_result(device, result, socket)
end
defp process_discovery_result(device, result) do
defp process_discovery_result(device, result, socket) do
# Agent has completed discovery and sent back SNMP data
# Update last_discovery_at to signal completion to DiscoveryWorker
Logger.info("Discovery results received from agent for #{device.name}, processing data")
@ -382,50 +410,71 @@ defmodule ToweropsWeb.AgentChannel do
# Process the SNMP data from agent's discovery queries
# The agent sends back oid_values map with all the collected data
process_discovery_data(updated_device, result)
process_discovery_data(updated_device, result, socket)
{:error, changeset} ->
Logger.error("Failed to update last_discovery_at for device #{device.name}: #{inspect(changeset)}")
end
end
defp process_discovery_data(device, result) do
defp process_discovery_data(device, result, socket) do
oid_values = Map.new(result.oid_values)
_timestamp = DateTime.from_unix!(result.timestamp, :second)
Logger.info("Processing discovery data with #{map_size(oid_values)} OID values",
Logger.info("Processing discovery with #{map_size(oid_values)} OIDs",
device_id: device.id,
device_name: device.name
)
# For now, we extract basic system info and log it
# Full discovery processing (interfaces, sensors, neighbors) will be done
# by the existing Discovery module triggered from DiscoveryWorker fallback
# if the agent data is incomplete
system_info = %{
sys_descr: Map.get(oid_values, "1.3.6.1.2.1.1.1.0"),
sys_object_id: Map.get(oid_values, "1.3.6.1.2.1.1.2.0"),
sys_uptime: Map.get(oid_values, "1.3.6.1.2.1.1.3.0"),
sys_contact: Map.get(oid_values, "1.3.6.1.2.1.1.4.0"),
sys_name: Map.get(oid_values, "1.3.6.1.2.1.1.5.0"),
sys_location: Map.get(oid_values, "1.3.6.1.2.1.1.6.0")
}
Logger.info("Agent discovered system info",
# Debug log full OID values if debug is enabled
maybe_debug_log(socket, "Discovery OID values received",
device_id: device.id,
device_name: device.name,
sys_name: system_info.sys_name,
sys_descr: system_info.sys_descr
oid_count: map_size(oid_values),
sample_oids: oid_values |> Enum.take(10) |> Map.new(),
all_oids: if(socket.assigns[:debug_enabled], do: oid_values, else: :redacted)
)
# Note: Full implementation would process interfaces, sensors, neighbors, etc.
# For now, we rely on the fact that updating last_discovery_at signals
# completion, and the DiscoveryWorker won't trigger a fallback discovery
:ok
# Process full discovery using agent data
case AgentDiscovery.process_agent_discovery(device, oid_values) do
{:ok, _discovered_device} ->
Logger.info("Agent discovery completed",
device_id: device.id,
device_name: device.name
)
maybe_debug_log(socket, "Discovery processing succeeded",
device_id: device.id,
device_name: device.name
)
# Broadcast for real-time UI updates
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"device:#{device.id}",
{:discovery_completed, device.id}
)
:ok
{:error, reason} ->
Logger.error("Agent discovery failed: #{inspect(reason)}",
device_id: device.id,
device_name: device.name
)
maybe_debug_log(socket, "Discovery processing failed",
device_id: device.id,
device_name: device.name,
error_reason: inspect(reason)
)
# Don't update last_discovery_at - DiscoveryWorker will retry
# with direct discovery as fallback
{:error, reason}
end
end
defp process_polling_result(device, result) do
defp process_polling_result(device, result, _socket) do
snmp_device = device.snmp_device
oid_values = Map.new(result.oid_values)
timestamp = DateTime.from_unix!(result.timestamp, :second)
@ -536,4 +585,20 @@ defmodule ToweropsWeb.AgentChannel do
defp format_ip({a, b, c, d, e, f, g, h}),
do:
"#{Integer.to_string(a, 16)}:#{Integer.to_string(b, 16)}:#{Integer.to_string(c, 16)}:#{Integer.to_string(d, 16)}:#{Integer.to_string(e, 16)}:#{Integer.to_string(f, 16)}:#{Integer.to_string(g, 16)}:#{Integer.to_string(h, 16)}"
# Debug logging helper - only logs when debug is enabled for this agent
defp maybe_debug_log(socket, message, metadata) do
if socket.assigns[:debug_enabled] do
Logger.debug(
message,
Keyword.merge(
[
agent_token_id: socket.assigns.agent_token_id,
debug_enabled: true
],
metadata
)
)
end
end
end

View file

@ -39,6 +39,36 @@
A descriptive name to identify this agent in your organization.
</p>
</div>
<div>
<.input
field={@form[:allow_remote_debug]}
type="checkbox"
label="Allow Remote Debugging"
/>
<div
:if={@form[:allow_remote_debug].value}
class="rounded-md bg-amber-50 dark:bg-amber-900/20 p-3 mt-2"
>
<div class="flex items-start gap-2 text-amber-800 dark:text-amber-200">
<.icon name="hero-exclamation-triangle" class="h-5 w-5 flex-shrink-0 mt-0.5" />
<div class="text-sm">
<p class="font-medium">Performance Impact</p>
<p class="mt-1">
When enabled, the server logs all SNMP data and messages for this agent.
This generates significant log volume and should only be enabled temporarily
for troubleshooting.
</p>
</div>
</div>
</div>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Enable verbose logging for troubleshooting SNMP discovery and polling issues.
Only organization members can toggle this setting.
</p>
</div>
</div>
<div class="mt-6 flex items-center justify-end gap-x-4">

View file

@ -83,6 +83,11 @@
<.icon name="hero-cloud" class="h-3 w-3" /> Cloud
</span>
<% end %>
<%= if agent.allow_remote_debug do %>
<span class="inline-flex items-center gap-1 rounded-full bg-amber-50 dark:bg-amber-900/30 px-2 py-0.5 text-xs font-medium text-amber-700 dark:text-amber-300 ring-1 ring-inset ring-amber-600/20 dark:ring-amber-400/30">
<.icon name="hero-bug-ant" class="h-3 w-3" /> Debug
</span>
<% end %>
</div>
</:col>
@ -190,6 +195,11 @@
<.icon name="hero-check-circle" class="h-3 w-3" /> Default
</span>
<% end %>
<%= if agent.allow_remote_debug do %>
<span class="inline-flex items-center gap-1 rounded-full bg-amber-50 dark:bg-amber-900/30 px-2 py-0.5 text-xs font-medium text-amber-700 dark:text-amber-300 ring-1 ring-inset ring-amber-600/20 dark:ring-amber-400/30">
<.icon name="hero-bug-ant" class="h-3 w-3" /> Debug
</span>
<% end %>
</div>
</:col>

View file

@ -32,7 +32,8 @@ defmodule ToweropsWeb.AgentLive.Show do
|> assign(:page_title, agent_token.name)
|> assign(:agent_token, agent_token)
|> assign(:polling_targets, polling_targets)
|> assign(:direct_assignments, direct_assignments)}
|> assign(:direct_assignments, direct_assignments)
|> assign(:active_tab, "overview")}
end
@impl true
@ -40,6 +41,11 @@ defmodule ToweropsWeb.AgentLive.Show do
{:noreply, socket}
end
@impl true
def handle_event("switch_tab", %{"tab" => tab}, socket) do
{:noreply, assign(socket, :active_tab, tab)}
end
defp assignment_source(device, agent_token_id) do
cond do
directly_assigned?(device, agent_token_id) ->

View file

@ -24,216 +24,325 @@
</:actions>
</.header>
<!-- Tab Navigation (Superuser Only) -->
<div
:if={@current_scope.user.is_superuser}
class="mt-6 border-b border-gray-200 dark:border-white/10"
>
<nav class="-mb-px flex space-x-8">
<button
type="button"
phx-click="switch_tab"
phx-value-tab="overview"
class={[
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
if(@active_tab == "overview",
do: "border-blue-500 text-blue-600 dark:text-blue-400",
else:
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
)
]}
>
Overview
</button>
<button
type="button"
phx-click="switch_tab"
phx-value-tab="debug"
class={[
"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm",
if(@active_tab == "debug",
do: "border-blue-500 text-blue-600 dark:text-blue-400",
else:
"border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
)
]}
>
Debug Logs
</button>
</nav>
</div>
<!-- Debug Tab Content -->
<div :if={@current_scope.user.is_superuser && @active_tab == "debug"} class="mt-6">
<div class={[
"rounded-md p-4 mb-4",
if(@agent_token.allow_remote_debug,
do: "bg-green-50 dark:bg-green-900/20",
else: "bg-gray-50 dark:bg-gray-900/20"
)
]}>
<p class="text-sm font-medium text-gray-900 dark:text-white">
Debug Status: {if @agent_token.allow_remote_debug,
do: "Enabled ✓",
else: "Disabled"}
</p>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
<%= if @agent_token.allow_remote_debug do %>
Verbose logging is enabled. All SNMP data and agent messages are being logged.
<% else %>
Verbose logging is disabled. Enable it in the agent edit page to troubleshoot issues.
<% end %>
</p>
</div>
<div class="space-y-4">
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
View Debug Logs
</h3>
<div class="rounded-md bg-gray-50 dark:bg-gray-900 p-4">
<p class="text-sm mb-2 font-medium text-gray-900 dark:text-white">
Production (kubectl):
</p>
<code class="text-xs text-gray-800 dark:text-gray-200 block">
kubectl logs -n towerops deployment/towerops --tail=100 | grep "agent_token_id: {@agent_token.id}"
</code>
</div>
<div class="rounded-md bg-gray-50 dark:bg-gray-900 p-4">
<p class="text-sm mb-2 font-medium text-gray-900 dark:text-white">
Development (tail):
</p>
<code class="text-xs text-gray-800 dark:text-gray-200 block">
tail -f _build/dev/lib/towerops/priv/log/dev.log | grep "{@agent_token.id}"
</code>
</div>
<div class="mt-4">
<p class="text-sm font-medium mb-2 text-gray-900 dark:text-white">
What Gets Logged:
</p>
<ul class="text-sm text-gray-600 dark:text-gray-400 list-disc list-inside space-y-1">
<li>Agent connection/disconnection events</li>
<li>Job submissions (device IDs and job types)</li>
<li>SNMP discovery results (full OID maps)</li>
<li>Polling results (sensor readings, interface stats)</li>
<li>Error messages with full context</li>
<li>Monitoring check submissions</li>
</ul>
</div>
</div>
</div>
<!-- Overview Tab Content -->
<div :if={!@current_scope.user.is_superuser || @active_tab == "overview"}>
<!-- Agent Status Overview -->
<div class="mt-6 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
<!-- Status Card -->
<div class="bg-white dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Status</p>
<% {status, label} = agent_status(@agent_token) %>
<div class="mt-2">
<span class={"inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium #{status_badge_class(status)}"}>
<span class={"h-1.5 w-1.5 rounded-full #{status_dot_class(status)}"} />
{label}
</span>
<div class="mt-6 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
<!-- Status Card -->
<div class="bg-white dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Status</p>
<% {status, label} = agent_status(@agent_token) %>
<div class="mt-2">
<span class={"inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium #{status_badge_class(status)}"}>
<span class={"h-1.5 w-1.5 rounded-full #{status_dot_class(status)}"} />
{label}
</span>
</div>
</div>
<.icon name="hero-signal" class="h-8 w-8 text-gray-400 dark:text-gray-500" />
</div>
<.icon name="hero-signal" class="h-8 w-8 text-gray-400 dark:text-gray-500" />
</div>
</div>
<!-- Device Count Card -->
<div class="bg-white dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Device</p>
<p class="mt-2 text-3xl font-semibold text-gray-900 dark:text-white">
{length(@polling_targets)}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{@direct_assignments} direct
</p>
</div>
<.icon name="hero-server" class="h-8 w-8 text-gray-400 dark:text-gray-500" />
</div>
</div>
<!-- Last Seen Card -->
<div class="bg-white dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg p-6">
<div class="flex items-center justify-between">
<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}
/>
</p>
<%= if @agent_token.last_seen_at do %>
<div class="bg-white dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Device</p>
<p class="mt-2 text-3xl font-semibold text-gray-900 dark:text-white">
{length(@polling_targets)}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{@direct_assignments} direct
</p>
</div>
<.icon name="hero-server" class="h-8 w-8 text-gray-400 dark:text-gray-500" />
</div>
</div>
<!-- Last Seen Card -->
<div class="bg-white dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg p-6">
<div class="flex items-center justify-between">
<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}
/>
</p>
<% end %>
<%= if @agent_token.last_ip do %>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 font-mono">
{@agent_token.last_ip}
</p>
<% end %>
<%= 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}
/>
</p>
<% end %>
<%= if @agent_token.last_ip do %>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400 font-mono">
{@agent_token.last_ip}
</p>
<% end %>
</div>
<.icon name="hero-clock" class="h-8 w-8 text-gray-400 dark:text-gray-500 flex-shrink-0" />
</div>
</div>
<!-- Uptime Card -->
<div class="bg-white dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Uptime</p>
<%= if @agent_token.metadata["uptime_seconds"] do %>
<p class="mt-2 text-lg font-semibold text-gray-900 dark:text-white">
{format_uptime(@agent_token.metadata["uptime_seconds"])}
</p>
<% else %>
<p class="mt-2 text-lg font-semibold text-gray-500 dark:text-gray-400">
Unknown
</p>
<% end %>
</div>
<.icon name="hero-clock" class="h-8 w-8 text-gray-400 dark:text-gray-500" />
</div>
<.icon name="hero-clock" class="h-8 w-8 text-gray-400 dark:text-gray-500 flex-shrink-0" />
</div>
</div>
<!-- Uptime Card -->
<div class="bg-white dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Uptime</p>
<%= if @agent_token.metadata["uptime_seconds"] do %>
<p class="mt-2 text-lg font-semibold text-gray-900 dark:text-white">
{format_uptime(@agent_token.metadata["uptime_seconds"])}
</p>
<% else %>
<p class="mt-2 text-lg font-semibold text-gray-500 dark:text-gray-400">
Unknown
</p>
<% end %>
</div>
<.icon name="hero-clock" class="h-8 w-8 text-gray-400 dark:text-gray-500" />
</div>
</div>
</div>
<!-- Agent Metadata -->
<%= if @agent_token.metadata && map_size(@agent_token.metadata) > 0 do %>
<%= if @agent_token.metadata && map_size(@agent_token.metadata) > 0 do %>
<div class="mt-6 bg-white dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Agent Metadata
</h3>
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<%= if @agent_token.metadata["hostname"] do %>
<div>
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Hostname</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white font-mono">
{@agent_token.metadata["hostname"]}
</dd>
</div>
<% end %>
<%= if @agent_token.metadata["uptime_seconds"] do %>
<div>
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Uptime</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
{format_uptime(@agent_token.metadata["uptime_seconds"])}
</dd>
</div>
<% end %>
</dl>
</div>
<% end %>
<!-- Agent Timestamps -->
<div class="mt-6 bg-white dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Agent Metadata
Timestamps
</h3>
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<%= if @agent_token.metadata["hostname"] do %>
<div>
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Created</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
<.timestamp datetime={@agent_token.inserted_at} timezone={@timezone} />
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Last Updated</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
<.timestamp datetime={@agent_token.updated_at} timezone={@timezone} />
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">
Last Seen (Heartbeat)
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
<.timestamp datetime={@agent_token.last_seen_at} timezone={@timezone} />
</dd>
</div>
<%= if @agent_token.last_ip do %>
<div>
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Hostname</dt>
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Last IP Address</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white font-mono">
{@agent_token.metadata["hostname"]}
</dd>
</div>
<% end %>
<%= if @agent_token.metadata["uptime_seconds"] do %>
<div>
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Uptime</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
{format_uptime(@agent_token.metadata["uptime_seconds"])}
{@agent_token.last_ip}
</dd>
</div>
<% end %>
</dl>
</div>
<% end %>
<!-- Agent Timestamps -->
<div class="mt-6 bg-white dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Timestamps
</h3>
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Created</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
<.timestamp datetime={@agent_token.inserted_at} timezone={@timezone} />
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Last Updated</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
<.timestamp datetime={@agent_token.updated_at} timezone={@timezone} />
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">
Last Seen (Heartbeat)
</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white">
<.timestamp datetime={@agent_token.last_seen_at} timezone={@timezone} />
</dd>
</div>
<%= if @agent_token.last_ip do %>
<div>
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">Last IP Address</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-white font-mono">
{@agent_token.last_ip}
</dd>
</div>
<% end %>
</dl>
</div>
<!-- Device List -->
<div class="mt-6 bg-white dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg">
<div class="p-6 border-b border-gray-200 dark:border-white/10">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Polling Targets
</h3>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Device that this agent is responsible for polling
</p>
</div>
<%= if @polling_targets == [] do %>
<div class="p-12 text-center">
<.icon name="hero-server" class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
<h3 class="mt-4 text-sm font-semibold text-gray-900 dark:text-white">
No devices assigned
<div class="mt-6 bg-white dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg">
<div class="p-6 border-b border-gray-200 dark:border-white/10">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
Polling Targets
</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
This agent is not currently polling any device.
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Device that this agent is responsible for polling
</p>
</div>
<% else %>
<div class="divide-y divide-gray-200 dark:divide-white/10">
<%= for device <- @polling_targets do %>
<.link
navigate={~p"/devices/#{device.id}"}
class="block hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<div class="p-6">
<div class="flex items-center justify-between">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3">
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">
{device.name}
</p>
<% {source, source_label} = assignment_source(device, @agent_token.id) %>
<span class={"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium #{source_badge_class(source)}"}>
{source_label}
</span>
</div>
<div class="mt-1 flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
<span class="flex items-center gap-1">
<.icon name="hero-globe-alt" class="h-4 w-4" />
{device.ip_address}
</span>
<%= if device.site do %>
<span class="flex items-center gap-1">
<.icon name="hero-building-office" class="h-4 w-4" />
{device.site.name}
<%= if @polling_targets == [] do %>
<div class="p-12 text-center">
<.icon name="hero-server" class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" />
<h3 class="mt-4 text-sm font-semibold text-gray-900 dark:text-white">
No devices assigned
</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
This agent is not currently polling any device.
</p>
</div>
<% else %>
<div class="divide-y divide-gray-200 dark:divide-white/10">
<%= for device <- @polling_targets do %>
<.link
navigate={~p"/devices/#{device.id}"}
class="block hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<div class="p-6">
<div class="flex items-center justify-between">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3">
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">
{device.name}
</p>
<% {source, source_label} = assignment_source(device, @agent_token.id) %>
<span class={"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium #{source_badge_class(source)}"}>
{source_label}
</span>
<% end %>
</div>
<div class="mt-1 flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
<span class="flex items-center gap-1">
<.icon name="hero-globe-alt" class="h-4 w-4" />
{device.ip_address}
</span>
<%= if device.site do %>
<span class="flex items-center gap-1">
<.icon name="hero-building-office" class="h-4 w-4" />
{device.site.name}
</span>
<% end %>
</div>
</div>
<.icon
name="hero-chevron-right"
class="h-5 w-5 text-gray-400 dark:text-gray-500"
/>
</div>
<.icon name="hero-chevron-right" class="h-5 w-5 text-gray-400 dark:text-gray-500" />
</div>
</div>
</.link>
<% end %>
</div>
<% end %>
</.link>
<% end %>
</div>
<% end %>
</div>
</div>
<!-- End Overview Tab -->
</Layouts.authenticated>

View file

@ -0,0 +1,12 @@
defmodule Towerops.Repo.Migrations.AddAllowRemoteDebugToAgentTokens do
use Ecto.Migration
def change do
alter table(:agent_tokens) do
add :allow_remote_debug, :boolean, default: false, null: false
end
# Optional: index for filtering agents with debug enabled
create index(:agent_tokens, [:allow_remote_debug])
end
end

View file

@ -5,6 +5,7 @@ Devices Working
2025-01-27
* Infrastructure improvements
* Bug fix: poller agent assignment on devices
* Feature: improve Ubiquiti LTU device handling
2025-01-26
* Cloud poller improvements