enable agent debugging
This commit is contained in:
parent
328d4659a7
commit
3bb89670bd
15 changed files with 941 additions and 217 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
70
lib/towerops/snmp/adapter.ex
Normal file
70
lib/towerops/snmp/adapter.ex
Normal 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
|
||||
168
lib/towerops/snmp/adapters/replay.ex
Normal file
168
lib/towerops/snmp/adapters/replay.ex
Normal 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
|
||||
119
lib/towerops/snmp/agent_discovery.ex
Normal file
119
lib/towerops/snmp/agent_discovery.ex
Normal 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
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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) ->
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue