diff --git a/lib/towerops/proto/decoder_macros.ex b/lib/towerops/proto/decoder_macros.ex new file mode 100644 index 00000000..1276cbe6 --- /dev/null +++ b/lib/towerops/proto/decoder_macros.ex @@ -0,0 +1,371 @@ +defmodule Towerops.Proto.DecoderMacros do + @moduledoc """ + Macros for generating protobuf message decoders from declarative field specs. + + Uses pattern-matched function heads for dispatch -- no `case`, `cond`, or `if` + in the generated code. + """ + + @doc """ + Generate a struct-based decoder. + + ## Options + * `:validate` -- optional validation function atom (e.g. `:validate_heartbeat`) + * `:finalize` -- optional finalization specification + - `nil` (default) -- no finalization, return accumulator as-is + - `{:reverse, fields}` -- reverse the specified list fields + * `:defaults` -- keyword list of field default value overrides + + ## Field types + `:string`, `:uint`, `:int64`, `:double`, `:bool`, + `{:enum, conv_fn}`, `{:message, decode_fn}`, + `{:message, decode_fn, validate_fn}`, + `{:repeated_message, decode_fn}`, + `{:repeated_message, decode_fn, validate_fn}`, + `{:repeated, :string}`, `{:map, :string, :string}`, + `{:oneof, decode_fn, tag}` + """ + defmacro defdecoder(mod, fun, fields, opts \\ []) do + # Evaluate opts at compile time (keyword list literal) + validate = Keyword.get(opts, :validate) + finalize = Keyword.get(opts, :finalize) + overrides_map = opts |> Keyword.get(:defaults, []) |> Map.new() + + # Destructure fields AST -- each entry is {:{}, _, [num, name, type]} + defaults = build_defaults_ast(fields, overrides_map) + fields_fn = :"#{fun}_fields" + field_fn = :"#{fun}_field" + validate_fn = :"#{fun}_apply_validate" + + field_handlers = + for field_ast <- fields do + {:{}, _, [field_num, field_name, type_spec]} = field_ast + build_field_handler_ast(fun, field_num, field_name, type_spec) + end + + finalize_clause = build_finalize_clause_ast(finalize) + validate_clause = build_validate_clause_ast(validate_fn, validate) + + quote location: :keep do + def unquote(fun)(data) when is_binary(data) do + acc = struct(unquote(mod), unquote(defaults)) + + with {:ok, result} <- unquote(fields_fn)(data, acc) do + unquote(validate_fn)(result) + end + end + + defp unquote(fields_fn)(<<>>, acc), do: {:ok, unquote(finalize_clause)} + + defp unquote(fields_fn)(data, acc) do + unquote(field_fn)(data, acc, Wire.decode_tag(data)) + end + + unquote(field_handlers) + + defp unquote(field_fn)(_data, acc, {:ok, {_fn, wt, rest}}) do + with {:ok, rest2} <- Wire.skip_field(wt, rest) do + unquote(fields_fn)(rest2, acc) + end + end + + defp unquote(field_fn)(_data, _acc, {:error, _} = error), do: error + + unquote(validate_clause) + end + end + + @doc """ + Generate a decoder that accumulates repeated sub-messages into a list, + then wraps in a struct. + + ## Options + * `:validate_item` -- optional item validation function + * `:max` -- optional maximum list size (creates batch-too-large check) + """ + defmacro deflistdecoder(fun, wrapper_mod, field_name, decode_fn, opts \\ []) do + max_size = Keyword.get(opts, :max) + validate_item = Keyword.get(opts, :validate_item) + fields_fn = :"#{fun}_fields" + field_fn = :"#{fun}_field" + + finalize_clause = build_list_finalize_ast(fun, wrapper_mod, field_name, max_size) + + item_handler = + if validate_item do + quote do + defp unquote(field_fn)(_data, acc, {:ok, {1, wt, rest}}) do + with {:ok, {msg_data, rest2}} <- Wire.decode_bytes(rest), + {:ok, item} <- unquote(decode_fn)(msg_data), + {:ok, item} <- unquote(validate_item)(item) do + unquote(fields_fn)(rest2, [item | acc]) + end + end + end + else + quote do + defp unquote(field_fn)(_data, acc, {:ok, {1, wt, rest}}) do + with {:ok, {msg_data, rest2}} <- Wire.decode_bytes(rest), + {:ok, item} <- unquote(decode_fn)(msg_data) do + unquote(fields_fn)(rest2, [item | acc]) + end + end + end + end + + quote location: :keep do + def unquote(fun)(data) when is_binary(data) do + with {:ok, items} <- unquote(fields_fn)(data, []) do + unquote(:"#{fun}_finalize")(items) + end + end + + defp unquote(fields_fn)(<<>>, acc), do: {:ok, acc} + + defp unquote(fields_fn)(data, acc) do + unquote(field_fn)(data, acc, Wire.decode_tag(data)) + end + + unquote(item_handler) + + defp unquote(field_fn)(_data, acc, {:ok, {_fn, wt, rest}}) do + with {:ok, rest2} <- Wire.skip_field(wt, rest) do + unquote(fields_fn)(rest2, acc) + end + end + + defp unquote(field_fn)(_data, _acc, {:error, _} = error), do: error + + unquote(finalize_clause) + end + end + + # ────────────────────────────────────────────── + # Compile-time helpers (work with AST values) + # ────────────────────────────────────────────── + + defp build_defaults_ast(fields_ast, overrides_map) do + for field_ast <- fields_ast do + {:{}, _, [_num, name, type]} = field_ast + {name, Map.get(overrides_map, name, default_for_type_ast(type))} + end + end + + defp default_for_type_ast(:string), do: "" + defp default_for_type_ast(:uint), do: 0 + defp default_for_type_ast(:int64), do: 0 + defp default_for_type_ast(:double), do: 0.0 + defp default_for_type_ast(:bool), do: false + # Tuple type specs arrive as AST tuples {:{}, meta, [tag | args]} + defp default_for_type_ast({:{}, _, [:enum, _]}), do: nil + defp default_for_type_ast({:{}, _, [:message, _]}), do: nil + defp default_for_type_ast({:{}, _, [:message, _, _]}), do: nil + defp default_for_type_ast({:{}, _, [:repeated_message, _]}), do: [] + defp default_for_type_ast({:{}, _, [:repeated_message, _, _]}), do: [] + defp default_for_type_ast({:{}, _, [:repeated, :string]}), do: [] + defp default_for_type_ast({:{}, _, [:map, :string, :string]}), do: %{} + defp default_for_type_ast({:{}, _, [:oneof, _, _]}), do: nil + + defp build_finalize_clause_ast(nil), do: quote(do: acc) + + defp build_finalize_clause_ast({:reverse, fields}) do + reverses = + Enum.map(fields, fn field -> + quote do + {unquote(field), Enum.reverse(acc.unquote(field))} + end + end) + + quote do + %{acc | unquote_splicing(reverses)} + end + end + + defp build_validate_clause_ast(vfn, nil) do + quote do + defp unquote(vfn)(result), do: {:ok, result} + end + end + + defp build_validate_clause_ast(vfn, validate) do + quote do + defp unquote(vfn)(result), do: unquote(validate)(result) + end + end + + defp build_list_finalize_ast(fun, wrapper_mod, field_name, nil) do + quote do + defp unquote(:"#{fun}_finalize")(items) do + {:ok, %unquote(wrapper_mod){unquote(field_name) => Enum.reverse(items)}} + end + end + end + + defp build_list_finalize_ast(fun, wrapper_mod, field_name, max_size) do + quote do + defp unquote(:"#{fun}_finalize")(items) do + reversed = Enum.reverse(items) + unquote(:"#{fun}_check_size")(reversed) + end + + defp unquote(:"#{fun}_check_size")(result) when length(result) <= unquote(max_size) do + {:ok, %unquote(wrapper_mod){unquote(field_name) => result}} + end + + defp unquote(:"#{fun}_check_size")(_result) do + {:error, {:batch_too_large, "Batch exceeds #{unquote(max_size)} metrics"}} + end + end + end + + # Generate a field handler clause for a single field + defp build_field_handler_ast(fun, field_num, field_name, type_spec) do + fields_fn = :"#{fun}_fields" + + cond do + type_spec == :string -> + quote do + defp unquote(:"#{fun}_field")(_data, acc, {:ok, {unquote(field_num), 2, rest}}) do + with {:ok, {value, rest2}} <- Wire.decode_bytes(rest) do + unquote(fields_fn)(rest2, %{acc | unquote(field_name) => value}) + end + end + end + + type_spec == :uint -> + quote do + defp unquote(:"#{fun}_field")(_data, acc, {:ok, {unquote(field_num), 0, rest}}) do + with {:ok, {value, rest2}} <- Wire.decode_varint(rest) do + unquote(fields_fn)(rest2, %{acc | unquote(field_name) => value}) + end + end + end + + type_spec == :int64 -> + quote do + defp unquote(:"#{fun}_field")(_data, acc, {:ok, {unquote(field_num), 0, rest}}) do + with {:ok, {raw, rest2}} <- Wire.decode_varint(rest) do + unquote(fields_fn)(rest2, %{acc | unquote(field_name) => Wire.decode_int64_value(raw)}) + end + end + end + + type_spec == :double -> + quote do + defp unquote(:"#{fun}_field")(_data, acc, {:ok, {unquote(field_num), 1, rest}}) do + with {:ok, {value, rest2}} <- Wire.decode_double(rest) do + unquote(fields_fn)(rest2, %{acc | unquote(field_name) => value}) + end + end + end + + type_spec == :bool -> + quote do + defp unquote(:"#{fun}_field")(_data, acc, {:ok, {unquote(field_num), 0, rest}}) do + with {:ok, {value, rest2}} <- Wire.decode_varint(rest) do + unquote(fields_fn)(rest2, %{acc | unquote(field_name) => value != 0}) + end + end + end + + # Tuple type specs arrive as {:{}, meta, [tag | args]} + match?({:{}, _, [:enum, _]}, type_spec) -> + {:{}, _, [_, conv_fn]} = type_spec + + quote do + defp unquote(:"#{fun}_field")(_data, acc, {:ok, {unquote(field_num), 0, rest}}) do + with {:ok, {value, rest2}} <- Wire.decode_varint(rest), + {:ok, converted} <- unquote(conv_fn).(value) do + unquote(fields_fn)(rest2, %{acc | unquote(field_name) => converted}) + end + end + end + + match?({:{}, _, [:message, _]}, type_spec) and + not match?({:{}, _, [:message, _, _]}, type_spec) -> + {:{}, _, [_, decode_fn]} = type_spec + + quote do + defp unquote(:"#{fun}_field")(_data, acc, {:ok, {unquote(field_num), 2, rest}}) do + with {:ok, {msg_data, rest2}} <- Wire.decode_bytes(rest), + {:ok, msg} <- unquote(decode_fn)(msg_data) do + unquote(fields_fn)(rest2, %{acc | unquote(field_name) => msg}) + end + end + end + + match?({:{}, _, [:message, _, _]}, type_spec) -> + {:{}, _, [_, decode_fn, validate_fn]} = type_spec + + quote do + defp unquote(:"#{fun}_field")(_data, acc, {:ok, {unquote(field_num), 2, rest}}) do + with {:ok, {msg_data, rest2}} <- Wire.decode_bytes(rest), + {:ok, msg} <- unquote(decode_fn)(msg_data), + {:ok, msg} <- unquote(validate_fn)(msg) do + unquote(fields_fn)(rest2, %{acc | unquote(field_name) => msg}) + end + end + end + + match?({:{}, _, [:repeated_message, _]}, type_spec) and + not match?({:{}, _, [:repeated_message, _, _]}, type_spec) -> + {:{}, _, [_, decode_fn]} = type_spec + + quote do + defp unquote(:"#{fun}_field")(_data, acc, {:ok, {unquote(field_num), 2, rest}}) do + with {:ok, {msg_data, rest2}} <- Wire.decode_bytes(rest), + {:ok, msg} <- unquote(decode_fn)(msg_data) do + unquote(fields_fn)(rest2, %{acc | unquote(field_name) => [msg | acc.unquote(field_name)]}) + end + end + end + + match?({:{}, _, [:repeated_message, _, _]}, type_spec) -> + {:{}, _, [_, decode_fn, validate_fn]} = type_spec + + quote do + defp unquote(:"#{fun}_field")(_data, acc, {:ok, {unquote(field_num), 2, rest}}) do + with {:ok, {msg_data, rest2}} <- Wire.decode_bytes(rest), + {:ok, msg} <- unquote(decode_fn)(msg_data), + {:ok, msg} <- unquote(validate_fn)(msg) do + unquote(fields_fn)(rest2, %{acc | unquote(field_name) => [msg | acc.unquote(field_name)]}) + end + end + end + + match?({:{}, _, [:repeated, :string]}, type_spec) -> + quote do + defp unquote(:"#{fun}_field")(_data, acc, {:ok, {unquote(field_num), 2, rest}}) do + with {:ok, {value, rest2}} <- Wire.decode_bytes(rest) do + unquote(fields_fn)(rest2, %{acc | unquote(field_name) => [value | acc.unquote(field_name)]}) + end + end + end + + match?({:{}, _, [:map, :string, :string]}, type_spec) -> + quote do + defp unquote(:"#{fun}_field")(_data, acc, {:ok, {unquote(field_num), 2, rest}}) do + with {:ok, {entry_data, rest2}} <- Wire.decode_bytes(rest), + {:ok, {key, val}} <- decode_map_entry(entry_data) do + unquote(fields_fn)(rest2, %{acc | unquote(field_name) => Map.put(acc.unquote(field_name), key, val)}) + end + end + end + + match?({:{}, _, [:oneof, _, _]}, type_spec) -> + {:{}, _, [_, decode_fn, tag]} = type_spec + + quote do + defp unquote(:"#{fun}_field")(_data, acc, {:ok, {unquote(field_num), 2, rest}}) do + with {:ok, {msg_data, rest2}} <- Wire.decode_bytes(rest), + {:ok, msg} <- unquote(decode_fn)(msg_data) do + unquote(fields_fn)(rest2, %{acc | unquote(field_name) => {unquote(tag), msg}}) + end + end + end + + true -> + raise "Unknown field type spec: #{inspect(type_spec)}" + end + end +end diff --git a/lib/towerops/proto/field_specs.ex b/lib/towerops/proto/field_specs.ex new file mode 100644 index 00000000..2ff1f273 --- /dev/null +++ b/lib/towerops/proto/field_specs.ex @@ -0,0 +1,226 @@ +defmodule Towerops.Proto.FieldSpecs do + @moduledoc """ + Declarative field specifications for all protobuf message decoders. + + This module documents every message type and its wire format fields. + Each spec tuple: `{struct_module, decode_function_name, fields_list, opts_keyword}` + + Field types: `:string`, `:uint`, `:int64`, `:double`, `:bool`, + `{:enum, conv_fn}`, `{:message, decode_fn}`, `{:oneof, decode_fn, tag}`, + `{:repeated, :string}`, `{:map, :string, :string}`, + `{:repeated_message, decode_fn}`, `{:repeated_message, decode_fn, validate_fn}`. + + Options: `validate`, `finalize`, `defaults`. + """ + + alias Towerops.Proto.Types + + @doc "Return all decoder specifications." + def all do + [ + # ── Agent → Server messages ────────────────────── + + {Types.AgentHeartbeat, :decode_agent_heartbeat, + [ + {1, :version, :string}, + {2, :hostname, :string}, + {3, :uptime_seconds, :uint}, + {4, :ip_address, :string}, + {5, :arch, :string} + ], validate: :validate_heartbeat}, + {Types.HeartbeatMetadata, :decode_heartbeat_metadata, + [{1, :version, :string}, {2, :hostname, :string}, {3, :uptime_seconds, :uint}]}, + {Types.HeartbeatResponse, :decode_heartbeat_response, [{1, :status, :string}]}, + {Types.SnmpResult, :decode_snmp_result, + [ + {1, :device_id, :string}, + {2, :job_type, {:enum, &Types.job_type_from_int/1}}, + {3, :oid_values, {:map, :string, :string}}, + {4, :timestamp, :int64}, + {5, :job_id, :string} + ], validate: :validate_snmp_result, defaults: [job_type: :discover]}, + {Types.AgentError, :decode_agent_error, + [{1, :device_id, :string}, {2, :job_id, :string}, {3, :message, :string}, {4, :timestamp, :int64}], + validate: :validate_agent_error}, + {Types.CredentialTestResult, :decode_credential_test_result, + [ + {1, :test_id, :string}, + {2, :success, :bool}, + {3, :error_message, :string}, + {4, :system_description, :string}, + {5, :timestamp, :int64} + ], validate: :validate_credential_test_result}, + {Types.MikrotikResult, :decode_mikrotik_result, + [ + {1, :device_id, :string}, + {2, :job_id, :string}, + {3, :sentences, {:repeated_message, :decode_mikrotik_sentence}}, + {4, :error, :string}, + {5, :timestamp, :int64} + ], validate: :validate_mikrotik_result, finalize: {:reverse, [:sentences]}}, + {Types.MonitoringCheck, :decode_monitoring_check, + [{1, :device_id, :string}, {2, :status, :string}, {3, :response_time_ms, :double}, {4, :timestamp, :int64}], + validate: :validate_monitoring_check}, + {Types.LldpTopologyResult, :decode_lldp_topology_result, + [ + {1, :device_id, :string}, + {2, :job_id, :string}, + {3, :local_system_name, :string}, + {4, :neighbors, {:repeated_message, :decode_lldp_neighbor}}, + {5, :timestamp, :int64} + ], validate: :validate_lldp_topology_result, finalize: {:reverse, [:neighbors]}}, + {Types.CheckResult, :decode_check_result, + [ + {1, :check_id, :string}, + {2, :status, :uint}, + {3, :output, :string}, + {4, :response_time_ms, :double}, + {5, :timestamp, :int64} + ], validate: :validate_check_result}, + {Types.Sensor, :decode_sensor, + [ + {1, :id, :string}, + {2, :sensor_type, :string}, + {3, :oid, :string}, + {4, :divisor, :double}, + {5, :unit, :string}, + {6, :metadata, {:map, :string, :string}} + ]}, + + # ── Server → Agent messages ────────────────────── + + {Types.AgentJob, :decode_agent_job, + [ + {1, :job_id, :string}, + {2, :job_type, {:enum, &Types.job_type_from_int/1}}, + {3, :device_id, :string}, + {4, :snmp_device, {:message, :decode_snmp_device, :validate_snmp_device}}, + {5, :queries, {:repeated_message, :decode_snmp_query, :validate_snmp_query}}, + {6, :mikrotik_device, {:message, :decode_mikrotik_device, :validate_mikrotik_device}}, + {7, :mikrotik_commands, {:repeated_message, :decode_mikrotik_command, :validate_mikrotik_command}} + ], defaults: [job_type: :discover], finalize: {:reverse, [:queries, :mikrotik_commands]}}, + {Types.AgentConfig, :decode_agent_config, + [ + {1, :version, :string}, + {2, :poll_interval_seconds, :uint}, + {3, :devices, {:repeated_message, :decode_device}}, + {4, :checks, {:repeated_message, :decode_check, :validate_check}} + ], finalize: {:reverse, [:devices, :checks]}}, + + # ── Nested sub-messages ────────────────────────── + + {Types.Device, :decode_device, + [ + {1, :id, :string}, + {2, :name, :string}, + {3, :ip_address, :string}, + {4, :snmp, {:message, :decode_snmp_config}}, + {5, :poll_interval_seconds, :uint}, + {6, :sensors, {:repeated_message, :decode_sensor, :validate_sensor}}, + {7, :interfaces, {:repeated_message, :decode_interface, :validate_interface}}, + {8, :monitoring_enabled, :bool}, + {9, :check_interval_seconds, :uint} + ], finalize: {:reverse, [:sensors, :interfaces]}}, + {Types.SnmpConfig, :decode_snmp_config, + [ + {1, :enabled, :bool}, + {2, :version, :string}, + {3, :community, :string}, + {4, :port, :uint}, + {5, :transport, :string} + ]}, + {Types.SensorReading, :decode_sensor_reading, + [{1, :sensor_id, :string}, {2, :value, :double}, {3, :status, :string}, {4, :timestamp, :int64}]}, + {Types.InterfaceStat, :decode_interface_stat, + [ + {1, :interface_id, :string}, + {2, :if_in_octets, :int64}, + {3, :if_out_octets, :int64}, + {4, :if_in_errors, :int64}, + {5, :if_out_errors, :int64}, + {6, :if_in_discards, :int64}, + {7, :if_out_discards, :int64}, + {8, :timestamp, :int64} + ]}, + {Types.NeighborDiscovery, :decode_neighbor_discovery, + [ + {1, :interface_id, :string}, + {2, :protocol, :string}, + {3, :remote_chassis_id, :string}, + {4, :remote_system_name, :string}, + {5, :remote_system_description, :string}, + {6, :remote_platform, :string}, + {7, :remote_port_id, :string}, + {8, :remote_port_description, :string}, + {9, :remote_address, :string}, + {10, :remote_capabilities, {:repeated, :string}}, + {11, :timestamp, :int64} + ]}, + {Types.Interface, :decode_interface, [{1, :id, :string}, {2, :if_index, :uint}, {3, :if_name, :string}]}, + {Types.Check, :decode_check, + [ + {1, :id, :string}, + {2, :check_type, :string}, + {3, :interval_seconds, :uint}, + {4, :timeout_ms, :uint}, + {5, :config, {:oneof, :decode_http_check_config, :http}}, + {6, :config, {:oneof, :decode_tcp_check_config, :tcp}}, + {7, :config, {:oneof, :decode_dns_check_config, :dns}}, + {8, :config, {:oneof, :decode_ssl_check_config, :ssl}} + ], defaults: [config: :no_config]}, + {Types.HttpCheckConfig, :decode_http_check_config, + [ + {1, :url, :string}, + {2, :method, :string}, + {3, :expected_status, :uint}, + {4, :verify_ssl, :bool}, + {5, :headers, {:map, :string, :string}}, + {6, :body, :string}, + {7, :regex, :string}, + {8, :follow_redirects, :bool} + ]}, + {Types.TcpCheckConfig, :decode_tcp_check_config, + [{1, :host, :string}, {2, :port, :uint}, {3, :send, :string}, {4, :expect, :string}]}, + {Types.DnsCheckConfig, :decode_dns_check_config, + [{1, :hostname, :string}, {2, :server, :string}, {3, :record_type, :string}, {4, :expected, :string}]}, + {Types.SslCheckConfig, :decode_ssl_check_config, + [{1, :host, :string}, {2, :port, :uint}, {3, :warning_days, :uint}]}, + {Types.SnmpDevice, :decode_snmp_device, + [ + {1, :ip, :string}, + {2, :community, :string}, + {3, :version, :string}, + {4, :port, :uint}, + {5, :v3_security_level, :string}, + {6, :v3_username, :string}, + {7, :v3_auth_protocol, :string}, + {8, :v3_auth_password, :string}, + {9, :v3_priv_protocol, :string}, + {10, :v3_priv_password, :string}, + {11, :transport, :string} + ], validate: :validate_snmp_device}, + {Types.SnmpQuery, :decode_snmp_query, + [{1, :query_type, {:enum, &Types.query_type_from_int/1}}, {2, :oids, {:repeated, :string}}], + validate: :validate_snmp_query, defaults: [query_type: :get], finalize: {:reverse, [:oids]}}, + {Types.MikrotikDevice, :decode_mikrotik_device, + [ + {1, :ip, :string}, + {2, :port, :uint}, + {3, :username, :string}, + {4, :password, :string}, + {5, :use_ssl, :bool}, + {6, :ssh_port, :uint} + ], validate: :validate_mikrotik_device}, + {Types.MikrotikCommand, :decode_mikrotik_command, [{1, :command, :string}, {2, :args, {:map, :string, :string}}], + validate: :validate_mikrotik_command}, + {Types.LldpNeighbor, :decode_lldp_neighbor, + [ + {1, :neighbor_name, :string}, + {2, :local_port, :string}, + {3, :remote_port, :string}, + {4, :remote_port_id, :string}, + {5, :management_addresses, {:repeated, :string}} + ], finalize: {:reverse, [:management_addresses]}} + ] + end +end diff --git a/lib/towerops/topology.ex b/lib/towerops/topology.ex index cae68492..ea1f8cc9 100644 --- a/lib/towerops/topology.ex +++ b/lib/towerops/topology.ex @@ -45,9 +45,20 @@ defmodule Towerops.Topology do end defp fetch_lookup_devices(organization_id) do + organization_id + |> fetch_device_ids() + |> Enum.chunk_every(100) + |> Enum.flat_map(&fetch_device_batch/1) + end + + defp fetch_device_ids(organization_id) do + Repo.all(from(d in Device, where: d.organization_id == ^organization_id, select: d.id)) + end + + defp fetch_device_batch(ids) do Repo.all( from(d in Device, - where: d.organization_id == ^organization_id, + where: d.id in ^ids, left_join: sd in assoc(d, :snmp_device), left_join: i in Interface, on: sd.id == i.snmp_device_id, @@ -81,22 +92,20 @@ defmodule Towerops.Topology do defp map_macs_to_ids(devices) do devices - |> Enum.flat_map(fn device -> - case device.snmp_device do - nil -> - [] - - sd -> - sd.interfaces - |> Enum.filter(&(&1.if_phys_address != nil)) - |> Enum.map(&Identifier.normalize_mac(&1.if_phys_address)) - |> Enum.reject(&is_nil/1) - |> Enum.map(&{&1, device.id}) - end - end) + |> Enum.flat_map(&device_mac_entries/1) |> Map.new() end + defp device_mac_entries(%Device{id: _device_id, snmp_device: nil}), do: [] + + defp device_mac_entries(%Device{id: device_id, snmp_device: sd}) do + sd.interfaces + |> Enum.filter(&(&1.if_phys_address != nil)) + |> Enum.map(&Identifier.normalize_mac(&1.if_phys_address)) + |> Enum.reject(&is_nil/1) + |> Enum.map(&{&1, device_id}) + end + @doc """ Finds a device ID by IP address from the lookup. """ @@ -286,34 +295,38 @@ defmodule Towerops.Topology do evidence_attrs = Map.get(attrs, :evidence, []) link_attrs = Map.delete(attrs, :evidence) - existing = find_existing_link(link_attrs) - - result = - case existing do - nil -> - %DeviceLink{} - |> DeviceLink.changeset(link_attrs) - |> Repo.insert() - - link -> - merged_confidence = merge_confidence(link.confidence, link_attrs.confidence) - - link - |> DeviceLink.changeset(%{ - confidence: merged_confidence, - last_confirmed_at: link_attrs.last_confirmed_at, - target_device_id: link_attrs[:target_device_id] || link.target_device_id, - metadata: Map.merge(link.metadata || %{}, link_attrs[:metadata] || %{}) - }) - |> Repo.update() - end - - with {:ok, link} <- result do - _ = insert_link_evidence_batch(link.id, evidence_attrs) - {:ok, Repo.reload!(link)} - end + link_attrs + |> find_existing_link() + |> upsert_or_create_link(link_attrs) + |> insert_evidence(evidence_attrs) end + defp upsert_or_create_link(nil, link_attrs) do + %DeviceLink{} + |> DeviceLink.changeset(link_attrs) + |> Repo.insert() + end + + defp upsert_or_create_link(link, link_attrs) do + merged_confidence = merge_confidence(link.confidence, link_attrs.confidence) + + link + |> DeviceLink.changeset(%{ + confidence: merged_confidence, + last_confirmed_at: link_attrs.last_confirmed_at, + target_device_id: link_attrs[:target_device_id] || link.target_device_id, + metadata: Map.merge(link.metadata || %{}, link_attrs[:metadata] || %{}) + }) + |> Repo.update() + end + + defp insert_evidence({:ok, link}, evidence_attrs) do + _ = insert_link_evidence_batch(link.id, evidence_attrs) + {:ok, Repo.reload!(link)} + end + + defp insert_evidence({:error, _} = error, _evidence_attrs), do: error + @valid_evidence_types ~w(lldp_neighbor cdp_neighbor mac_on_interface arp_entry wireless_registration subnet_match) # Batch-inserts all evidence rows for a link in a single round-trip. @@ -324,35 +337,45 @@ defmodule Towerops.Topology do defp insert_link_evidence_batch(link_id, evidence_attrs) do now = DateTime.truncate(DateTime.utc_now(), :second) - {valid, invalid} = - Enum.split_with(evidence_attrs, fn ev -> - Map.has_key?(ev, :evidence_type) and ev.evidence_type in @valid_evidence_types and - Map.has_key?(ev, :observed_at) - end) + evidence_attrs + |> Enum.split_with(&valid_evidence?/1) + |> log_invalid_evidence() + |> build_evidence_rows(link_id, now) + |> insert_evidence_rows() + end + defp valid_evidence?(ev) do + is_map_key(ev, :evidence_type) and ev.evidence_type in @valid_evidence_types and + is_map_key(ev, :observed_at) + end + + defp log_invalid_evidence({valid, []}), do: {valid, []} + + defp log_invalid_evidence({valid, invalid}) do Enum.each(invalid, fn ev -> Logger.error("Dropping invalid device link evidence: #{inspect(ev)}") end) - rows = - Enum.map(valid, fn ev -> - %{ - id: Ecto.UUID.generate(), - device_link_id: link_id, - evidence_type: ev.evidence_type, - evidence_data: Map.get(ev, :evidence_data, %{}), - observed_at: DateTime.truncate(ev.observed_at, :second), - inserted_at: now, - updated_at: now - } - end) - - case rows do - [] -> :ok - _ -> Repo.insert_all(DeviceLinkEvidence, rows) - end + {valid, invalid} end + defp build_evidence_rows({valid, _invalid}, link_id, now) do + Enum.map(valid, fn ev -> + %{ + id: Ecto.UUID.generate(), + device_link_id: link_id, + evidence_type: ev.evidence_type, + evidence_data: Map.get(ev, :evidence_data, %{}), + observed_at: DateTime.truncate(ev.observed_at, :second), + inserted_at: now, + updated_at: now + } + end) + end + + defp insert_evidence_rows([]), do: :ok + defp insert_evidence_rows(rows), do: Repo.insert_all(DeviceLinkEvidence, rows) + defdelegate infer_device_role(device), to: InferenceEngine @doc """ @@ -372,43 +395,43 @@ defmodule Towerops.Topology do lookup = build_device_lookup(organization_id) now = DateTime.utc_now() - # Collect all evidence - all_evidence = - collect_lldp_evidence(device.id) ++ - collect_cdp_evidence(device.id) ++ - collect_mac_evidence(device.id, lookup) ++ - collect_arp_evidence(device.id, lookup) - - if Enum.empty?(all_evidence) do - {:ok, :unchanged} - else - grouped = group_evidence_by_remote(all_evidence) - - results = - grouped - |> Enum.map(&upsert_grouped_evidence(&1, device.id, lookup, now)) - |> Enum.reject(&(&1 == :skip)) - - has_changes = Enum.any?(results, &Result.ok?/1) - - # Auto-inference disabled - device type is now manual-only - # maybe_update_device_role(device) - - if has_changes do - _ = - Phoenix.PubSub.broadcast( - Towerops.PubSub, - "topology:#{organization_id}", - {:topology_updated, organization_id} - ) - - {:ok, :changed} - else - {:ok, :unchanged} - end - end + device.id + |> collect_all_evidence(lookup) + |> process_collected_evidence(device.id, lookup, now, organization_id) end + defp collect_all_evidence(device_id, lookup) do + collect_lldp_evidence(device_id) ++ + collect_cdp_evidence(device_id) ++ + collect_mac_evidence(device_id, lookup) ++ + collect_arp_evidence(device_id, lookup) + end + + defp process_collected_evidence([], _device_id, _lookup, _now, _org_id), do: {:ok, :unchanged} + + defp process_collected_evidence(all_evidence, device_id, lookup, now, organization_id) do + has_changes = + all_evidence + |> group_evidence_by_remote() + |> Enum.map(&upsert_grouped_evidence(&1, device_id, lookup, now)) + |> Enum.reject(&(&1 == :skip)) + |> Enum.any?(&Result.ok?/1) + + broadcast_if_changed(has_changes, organization_id) + end + + defp broadcast_if_changed(true, organization_id) do + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "topology:#{organization_id}", + {:topology_updated, organization_id} + ) + + {:ok, :changed} + end + + defp broadcast_if_changed(false, _organization_id), do: {:ok, :unchanged} + @doc """ Clean up stale links. Called periodically (e.g. hourly). - Links not confirmed in 24h: set confidence to 0.1 @@ -610,85 +633,99 @@ defmodule Towerops.Topology do Returns nil if node not found or not in organization. """ def get_node_detail("discovered_" <> suffix = node_id, organization_id) do - case find_discovered_link(suffix, organization_id) do - nil -> - nil - - link -> - capabilities = get_in(link.metadata, ["remote_capabilities"]) - - %{ - id: node_id, - name: link.discovered_remote_name || link.discovered_remote_ip || link.discovered_remote_mac, - ip_address: link.discovered_remote_ip, - mac_address: link.discovered_remote_mac, - type: :discovered, - role: infer_role_from_capabilities(capabilities), - status: :unknown, - site_name: nil, - manufacturer: nil, - connections: [ - %{ - device_id: link.source_device_id, - device_name: link.source_device.name, - interface: if(link.source_interface, do: link.source_interface.if_name), - confidence: link.confidence, - link_type: link.link_type - } - ] - } - end + suffix + |> find_discovered_link(organization_id) + |> discovered_node_detail(node_id) end def get_node_detail("site_" <> _rest, _organization_id), do: nil def get_node_detail(device_id, organization_id) do - device = - Repo.one( - from(d in Device, - where: d.id == ^device_id and d.organization_id == ^organization_id, - left_join: s in assoc(d, :site), - left_join: sd in assoc(d, :snmp_device), - preload: [site: s, snmp_device: sd] - ) - ) - - case device do - nil -> - nil - - device -> - connections = - device_id - |> list_connected_devices() - |> Enum.map(&link_to_connection(&1, device_id)) - - # Wireless stats for detail panel - ws = compute_wireless_stats([device_id]) - device_ws = Map.get(ws, device_id, %{client_count: 0, signal_health: nil}) - - rf = compute_rf_link_stats([device_id]) - device_rf = Map.get(rf, device_id) - - %{ - id: device.id, - name: device.name, - ip_address: device.ip_address, - type: :managed, - role: role_to_type_atom(device.device_role), - device_role: device.device_role, - status: device.status, - site_name: if(device.site, do: device.site.name), - manufacturer: if(device.snmp_device, do: device.snmp_device.manufacturer), - connections: connections, - client_count: device_ws.client_count, - signal_health: device_ws.signal_health, - snr: if(device_rf, do: device_rf[:snr]), - is_wireless: device.device_role in ~w(access_point backhaul cpe backhaul_radio) - } - end + device_id + |> query_device_detail(organization_id) + |> managed_node_detail(device_id) end + defp discovered_node_detail(nil, _node_id), do: nil + + defp discovered_node_detail(link, node_id) do + capabilities = get_in(link.metadata, ["remote_capabilities"]) + + %{ + id: node_id, + name: link.discovered_remote_name || link.discovered_remote_ip || link.discovered_remote_mac, + ip_address: link.discovered_remote_ip, + mac_address: link.discovered_remote_mac, + type: :discovered, + role: infer_role_from_capabilities(capabilities), + status: :unknown, + site_name: nil, + manufacturer: nil, + connections: [ + %{ + device_id: link.source_device_id, + device_name: link.source_device.name, + interface: interface_name(link.source_interface), + confidence: link.confidence, + link_type: link.link_type + } + ] + } + end + + defp query_device_detail(device_id, organization_id) do + Repo.one( + from(d in Device, + where: d.id == ^device_id and d.organization_id == ^organization_id, + left_join: s in assoc(d, :site), + left_join: sd in assoc(d, :snmp_device), + preload: [site: s, snmp_device: sd] + ) + ) + end + + defp managed_node_detail(nil, _device_id), do: nil + + defp managed_node_detail(device, device_id) do + connections = + device_id + |> list_connected_devices() + |> Enum.map(&link_to_connection(&1, device_id)) + + # Wireless stats for detail panel + ws = compute_wireless_stats([device_id]) + device_ws = Map.get(ws, device_id, %{client_count: 0, signal_health: nil}) + + rf = compute_rf_link_stats([device_id]) + device_rf = Map.get(rf, device_id) + + %{ + id: device.id, + name: device.name, + ip_address: device.ip_address, + type: :managed, + role: role_to_type_atom(device.device_role), + device_role: device.device_role, + status: device.status, + site_name: extract_field_value(device.site, :name), + manufacturer: extract_field_value(device.snmp_device, :manufacturer), + connections: connections, + client_count: device_ws.client_count, + signal_health: device_ws.signal_health, + snr: extract_rf_snr(device_rf), + is_wireless: device.device_role in ~w(access_point backhaul cpe backhaul_radio) + } + end + + defp extract_field_value(nil, _field), do: nil + defp extract_field_value(struct, field), do: Map.get(struct, field) + + defp extract_rf_snr(nil), do: nil + defp extract_rf_snr(rf), do: rf[:snr] + + defp interface_name(nil), do: nil + defp interface_name(interface), do: interface.if_name + defp find_discovered_link(suffix, organization_id) do link = Repo.one( @@ -708,37 +745,40 @@ defmodule Towerops.Topology do end defp find_discovered_link_by_anonymous_id("anonymous_" <> link_id, organization_id) do - case Ecto.UUID.cast(link_id) do - {:ok, uuid} -> - Repo.one( - from l in DeviceLink, - join: d in Device, - on: l.source_device_id == d.id, - where: d.organization_id == ^organization_id, - where: is_nil(l.target_device_id), - where: l.id == ^uuid, - preload: [:source_device, :source_interface, :target_interface] - ) - - :error -> - nil - end + link_id + |> Ecto.UUID.cast() + |> lookup_anonymous_link(organization_id) end defp find_discovered_link_by_anonymous_id(_suffix, _organization_id), do: nil - defp link_to_connection(link, device_id) do - {connected_device, interface} = - if link.source_device_id == device_id do - {link.target_device, link.target_interface} - else - {link.source_device, link.source_interface} - end + defp lookup_anonymous_link({:ok, uuid}, organization_id) do + Repo.one( + from l in DeviceLink, + join: d in Device, + on: l.source_device_id == d.id, + where: d.organization_id == ^organization_id, + where: is_nil(l.target_device_id), + where: l.id == ^uuid, + preload: [:source_device, :source_interface, :target_interface] + ) + end + defp lookup_anonymous_link(:error, _organization_id), do: nil + + defp link_to_connection(%{source_device_id: device_id} = link, device_id) do + build_connection(link, link.target_device, link.target_interface) + end + + defp link_to_connection(link, _device_id) do + build_connection(link, link.source_device, link.source_interface) + end + + defp build_connection(link, connected_device, interface) do %{ - device_id: if(connected_device, do: connected_device.id), + device_id: extract_device_id(connected_device), device_name: connected_device_name(connected_device, link), - interface: if(interface, do: interface.if_name), + interface: interface_name(interface), confidence: link.confidence, link_type: link.link_type } @@ -806,19 +846,22 @@ defmodule Towerops.Topology do device_role: device.device_role, status: device.status, site_id: device.site_id, - site_name: if(device.site, do: device.site.name), + site_name: extract_field_value(device.site, :name), ip_address: device.ip_address, discovered: false, - manufacturer: if(device.snmp_device, do: device.snmp_device.manufacturer), - parent: if(device.site_id, do: "site_#{device.site_id}"), + manufacturer: extract_field_value(device.snmp_device, :manufacturer), + parent: site_parent_id(device.site_id), client_count: stats.client_count, signal_health: stats.signal_health, - latitude: if(device.site, do: device.site.latitude), - longitude: if(device.site, do: device.site.longitude), + latitude: extract_field_value(device.site, :latitude), + longitude: extract_field_value(device.site, :longitude), has_alerts: device.status == :down } end + defp site_parent_id(nil), do: nil + defp site_parent_id(site_id), do: "site_#{site_id}" + defp build_discovered_nodes(links, "all") do links |> Enum.filter(&is_nil(&1.target_device_id)) @@ -896,31 +939,37 @@ defmodule Towerops.Topology do defp build_edges_from_links(links, node_ids) do links - |> Enum.map(fn link -> - target_id = - if link.target_device_id, - do: link.target_device_id, - else: discovered_node_id(link) - - %{ - id: link.id, - source: link.source_device_id, - target: target_id, - source_interface: if(link.source_interface, do: link.source_interface.if_name), - target_interface: if(link.target_interface, do: link.target_interface.if_name), - source_interface_id: link.source_interface_id, - target_interface_id: if(link.target_interface, do: link.target_interface.id), - link_type: link.link_type, - confidence: link.confidence, - metadata: link.metadata, - if_speed: if(link.source_interface, do: link.source_interface.if_speed) - } - end) + |> Enum.map(&link_to_edge/1) |> Enum.filter(fn edge -> MapSet.member?(node_ids, edge.source) and MapSet.member?(node_ids, edge.target) end) end + defp link_to_edge(link) do + %{ + id: link.id, + source: link.source_device_id, + target: edge_target_id(link), + source_interface: interface_name(link.source_interface), + target_interface: interface_name(link.target_interface), + source_interface_id: link.source_interface_id, + target_interface_id: extract_target_interface_id(link.target_interface), + link_type: link.link_type, + confidence: link.confidence, + metadata: link.metadata, + if_speed: extract_if_speed(link.source_interface) + } + end + + defp edge_target_id(%{target_device_id: nil} = link), do: discovered_node_id(link) + defp edge_target_id(%{target_device_id: target_id}), do: target_id + + defp extract_target_interface_id(nil), do: nil + defp extract_target_interface_id(interface), do: interface.id + + defp extract_if_speed(nil), do: nil + defp extract_if_speed(interface), do: interface.if_speed + # Map device roles to visual types for network topology defp role_to_type_atom("router"), do: :router defp role_to_type_atom("core_router"), do: :router @@ -950,45 +999,45 @@ defmodule Towerops.Topology do name: remote_identity.remote_name }) - if matched_id == device_id do - :skip - else - upsert_link(%{ - source_device_id: device_id, - target_device_id: matched_id, - source_interface_id: best.source_interface_id, - link_type: evidence_type_to_link_type(best.evidence_type), - confidence: combine_evidence_confidence(evidence_list), - discovered_remote_mac: remote_identity.remote_mac, - discovered_remote_ip: remote_identity.remote_ip, - discovered_remote_name: remote_identity.remote_name, - metadata: build_link_metadata(evidence_list), - last_confirmed_at: now, - evidence: - Enum.map(evidence_list, fn ev -> - %{ - evidence_type: ev.evidence_type, - evidence_data: ev.evidence_data, - observed_at: now - } - end) - }) - end + upsert_or_skip(matched_id, device_id, evidence_list, remote_identity, best, now) + end + + defp upsert_or_skip(matched_id, device_id, _evidence_list, _remote_identity, _best, _now) when matched_id == device_id, + do: :skip + + defp upsert_or_skip(matched_id, device_id, evidence_list, remote_identity, best, now) do + upsert_link(%{ + source_device_id: device_id, + target_device_id: matched_id, + source_interface_id: best.source_interface_id, + link_type: evidence_type_to_link_type(best.evidence_type), + confidence: combine_evidence_confidence(evidence_list), + discovered_remote_mac: remote_identity.remote_mac, + discovered_remote_ip: remote_identity.remote_ip, + discovered_remote_name: remote_identity.remote_name, + metadata: build_link_metadata(evidence_list), + last_confirmed_at: now, + evidence: + Enum.map(evidence_list, fn ev -> + %{ + evidence_type: ev.evidence_type, + evidence_data: ev.evidence_data, + observed_at: now + } + end) + }) end defp build_link_metadata(evidence_list) do - capabilities = - evidence_list - |> Enum.flat_map(fn ev -> ev[:remote_capabilities] || [] end) - |> Enum.uniq() - - if Enum.empty?(capabilities) do - %{} - else - %{"remote_capabilities" => capabilities} - end + evidence_list + |> Enum.flat_map(fn ev -> ev[:remote_capabilities] || [] end) + |> Enum.uniq() + |> wrap_capabilities() end + defp wrap_capabilities([]), do: %{} + defp wrap_capabilities(capabilities), do: %{"remote_capabilities" => capabilities} + defp group_evidence_by_remote(evidence_list) do Enum.group_by(evidence_list, &evidence_group_key/1) end @@ -1018,42 +1067,40 @@ defmodule Towerops.Topology do source_device_id = link_attrs.source_device_id source_interface_id = Map.get(link_attrs, :source_interface_id) - base_query = - if is_nil(source_interface_id) do - from(l in DeviceLink, - where: l.source_device_id == ^source_device_id and is_nil(l.source_interface_id) - ) - else - from(l in DeviceLink, - where: l.source_device_id == ^source_device_id and l.source_interface_id == ^source_interface_id - ) - end - link_attrs - |> existing_link_query(base_query) + |> existing_link_query(build_base_query(source_device_id, source_interface_id)) |> Repo.one() end + defp build_base_query(source_device_id, nil) do + from(l in DeviceLink, + where: l.source_device_id == ^source_device_id and is_nil(l.source_interface_id) + ) + end + + defp build_base_query(source_device_id, source_interface_id) do + from(l in DeviceLink, + where: l.source_device_id == ^source_device_id and l.source_interface_id == ^source_interface_id + ) + end + defp existing_link_query(%{discovered_remote_mac: remote_mac}, base_query) when is_binary(remote_mac) do from(l in base_query, where: l.discovered_remote_mac == ^remote_mac) end - defp existing_link_query(%{discovered_remote_ip: remote_ip} = link_attrs, base_query) when is_binary(remote_ip) do - remote_name = Map.get(link_attrs, :discovered_remote_name) + defp existing_link_query(%{discovered_remote_ip: remote_ip, discovered_remote_name: remote_name}, base_query) + when is_binary(remote_ip) and is_binary(remote_name) do + from(l in base_query, + where: + is_nil(l.discovered_remote_mac) and l.discovered_remote_ip == ^remote_ip and + l.discovered_remote_name == ^remote_name + ) + end - case remote_name do - name when is_binary(name) -> - from(l in base_query, - where: - is_nil(l.discovered_remote_mac) and l.discovered_remote_ip == ^remote_ip and - l.discovered_remote_name == ^name - ) - - _ -> - from(l in base_query, - where: is_nil(l.discovered_remote_mac) and l.discovered_remote_ip == ^remote_ip - ) - end + defp existing_link_query(%{discovered_remote_ip: remote_ip}, base_query) when is_binary(remote_ip) do + from(l in base_query, + where: is_nil(l.discovered_remote_mac) and l.discovered_remote_ip == ^remote_ip + ) end defp existing_link_query(%{discovered_remote_name: remote_name}, base_query) when is_binary(remote_name) do @@ -1077,13 +1124,16 @@ defmodule Towerops.Topology do remote_ip = Identifier.normalize_ip(ev.remote_ip) remote_name = Identifier.normalize_name(ev.remote_name) - case remote_mac || remote_ip || remote_name do - nil -> - {"anonymous", ev.source_interface_id, ev.evidence_type, inspect(ev.evidence_data)} + key_identity = remote_mac || remote_ip || remote_name + group_by_identity(key_identity, ev, remote_mac, remote_ip, remote_name) + end - _ -> - {ev.source_interface_id, remote_mac, remote_ip, remote_name} - end + defp group_by_identity(nil, ev, _remote_mac, _remote_ip, _remote_name) do + {"anonymous", ev.source_interface_id, ev.evidence_type, inspect(ev.evidence_data)} + end + + defp group_by_identity(_identity, ev, remote_mac, remote_ip, remote_name) do + {ev.source_interface_id, remote_mac, remote_ip, remote_name} end defp summarize_remote_identity(evidence_list) do @@ -1098,12 +1148,12 @@ defmodule Towerops.Topology do evidence_list |> Enum.filter(&(is_binary(Map.get(&1, field)) and Map.get(&1, field) != "")) |> Enum.max_by(& &1.confidence, fn -> nil end) - |> case do - nil -> nil - ev -> Map.get(ev, field) - end + |> extract_field(field) end + defp extract_field(nil, _field), do: nil + defp extract_field(ev, field), do: Map.get(ev, field) + defp discovered_node_id(link) do suffix = link.discovered_remote_mac || @@ -1123,17 +1173,21 @@ defmodule Towerops.Topology do """ @spec discover_lldp_neighbors(String.t()) :: {:ok, non_neg_integer()} | {:error, :device_not_found} def discover_lldp_neighbors(device_id) do - case Lldp.discover_neighbors(device_id) do - {:ok, %{neighbors: neighbors}} -> - now = DateTime.utc_now() - results = Enum.map(neighbors, &upsert_device_neighbor(device_id, &1, now)) - success_count = Enum.count(results, &Result.ok?/1) - {:ok, success_count} + device_id + |> Lldp.discover_neighbors() + |> handle_discovery_result(device_id) + end - {:error, reason} = error -> - Logger.error("Failed to discover LLDP neighbors for device #{device_id}: #{inspect(reason)}") - error - end + defp handle_discovery_result({:ok, %{neighbors: neighbors}}, device_id) do + now = DateTime.utc_now() + results = Enum.map(neighbors, &upsert_device_neighbor(device_id, &1, now)) + success_count = Enum.count(results, &Result.ok?/1) + {:ok, success_count} + end + + defp handle_discovery_result({:error, _reason} = error, device_id) do + Logger.error("Failed to discover LLDP neighbors for device #{device_id}: #{inspect(error)}") + error end @doc """ @@ -1210,80 +1264,83 @@ defmodule Towerops.Topology do last_seen_at: now } - case Repo.get_by(DeviceNeighbor, - device_id: device_id, - local_port: neighbor.local_port, - neighbor_name: neighbor.neighbor_name - ) do - nil -> - %DeviceNeighbor{} - |> DeviceNeighbor.changeset(attrs) - |> Repo.insert() + DeviceNeighbor + |> Repo.get_by( + device_id: device_id, + local_port: neighbor.local_port, + neighbor_name: neighbor.neighbor_name + ) + |> insert_or_update_neighbor(attrs) + end - existing -> - existing - |> DeviceNeighbor.changeset( - Map.take(attrs, [ - :last_seen_at, - :remote_port, - :remote_port_id, - :management_addresses, - :neighbor_device_id - ]) - ) - |> Repo.update() - end + defp insert_or_update_neighbor(nil, attrs) do + %DeviceNeighbor{} + |> DeviceNeighbor.changeset(attrs) + |> Repo.insert() + end + + defp insert_or_update_neighbor(existing, attrs) do + existing + |> DeviceNeighbor.changeset( + Map.take(attrs, [ + :last_seen_at, + :remote_port, + :remote_port_id, + :management_addresses, + :neighbor_device_id + ]) + ) + |> Repo.update() end # Attempts to find an existing device that matches the LLDP neighbor defp find_device_by_lldp_neighbor(discovering_device_id, neighbor) do discovering_device = Devices.get_device(discovering_device_id) - - if discovering_device do - # Search within the same organization for a device matching: - # 1. Name matches neighbor_name - # 2. IP address matches one of the management_addresses - - query = - from(d in Device, - where: d.organization_id == ^discovering_device.organization_id, - where: - d.name == ^neighbor.neighbor_name or - d.ip_address in ^neighbor.management_addresses - ) - - case Repo.one(query) do - nil -> nil - device -> device.id - end - end + match_device_by_neighbor(discovering_device, neighbor) end + defp match_device_by_neighbor(nil, _neighbor), do: nil + + defp match_device_by_neighbor(discovering_device, neighbor) do + query = + from(d in Device, + where: d.organization_id == ^discovering_device.organization_id, + where: + d.name == ^neighbor.neighbor_name or + d.ip_address in ^neighbor.management_addresses + ) + + query + |> Repo.one() + |> extract_device_id() + end + + defp extract_device_id(nil), do: nil + defp extract_device_id(device), do: device.id + # --- WISP wireless stats helpers --- @doc """ Compute wireless client counts and average signal health per device. Returns a map of device_id => %{client_count: int, signal_health: "good"|"degraded"|"critical"|nil} """ - def compute_wireless_stats(device_ids) when is_list(device_ids) do - if Enum.empty?(device_ids) do - %{} - else - # Count wireless clients and average signal per device - results = - Repo.all( - from(wc in WirelessClient, - where: wc.device_id in ^device_ids, - group_by: wc.device_id, - select: {wc.device_id, count(wc.id), avg(wc.signal_strength)} - ) - ) + def compute_wireless_stats([]), do: %{} - Map.new(results, fn {device_id, count, avg_signal} -> - health = classify_signal_health(avg_signal) - {device_id, %{client_count: count, signal_health: health}} - end) - end + def compute_wireless_stats(device_ids) when is_list(device_ids) do + from(wc in WirelessClient, + where: wc.device_id in ^device_ids, + group_by: wc.device_id, + select: {wc.device_id, count(wc.id), avg(wc.signal_strength)} + ) + |> Repo.all() + |> build_wireless_stats() + end + + defp build_wireless_stats(results) do + Map.new(results, fn {device_id, count, avg_signal} -> + health = classify_signal_health(avg_signal) + {device_id, %{client_count: count, signal_health: health}} + end) end @doc """ @@ -1291,25 +1348,18 @@ defmodule Towerops.Topology do determine signal health for PTP/backhaul links. Returns a map of device_id => %{signal_dbm: float, snr: float, signal_health: string} """ - def compute_rf_link_stats(device_ids) when is_list(device_ids) do - if Enum.empty?(device_ids) do - %{} - else - # Get latest SNR sensor readings per device - results = - Repo.all( - from(s in Sensor, - join: sd in SnmpDevice, - on: s.snmp_device_id == sd.id, - where: sd.device_id in ^device_ids and s.sensor_type == "snr" and not is_nil(s.last_value), - select: {sd.device_id, s.last_value, s.sensor_descr} - ) - ) + def compute_rf_link_stats([]), do: %{} - results - |> Enum.group_by(fn {device_id, _val, _descr} -> device_id end) - |> Map.new(&compute_device_snr_stats/1) - end + def compute_rf_link_stats(device_ids) when is_list(device_ids) do + from(s in Sensor, + join: sd in SnmpDevice, + on: s.snmp_device_id == sd.id, + where: sd.device_id in ^device_ids and s.sensor_type == "snr" and not is_nil(s.last_value), + select: {sd.device_id, s.last_value, s.sensor_descr} + ) + |> Repo.all() + |> Enum.group_by(fn {device_id, _val, _descr} -> device_id end) + |> Map.new(&compute_device_snr_stats/1) end defp compute_device_snr_stats({device_id, entries}) do @@ -1322,15 +1372,18 @@ defmodule Towerops.Topology do defp classify_signal_health(nil), do: nil defp classify_signal_health(avg_signal) do - avg = if is_float(avg_signal), do: avg_signal, else: Decimal.to_float(avg_signal) - - cond do - avg >= -65 -> "good" - avg >= -75 -> "degraded" - true -> "critical" - end + avg_signal + |> signal_to_float() + |> classify_by_signal() end + defp signal_to_float(avg) when is_float(avg), do: avg + defp signal_to_float(avg), do: Decimal.to_float(avg) + + defp classify_by_signal(avg) when avg >= -65, do: "good" + defp classify_by_signal(avg) when avg >= -75, do: "degraded" + defp classify_by_signal(_avg), do: "critical" + defp classify_snr_health(snr) when snr >= 25, do: "good" defp classify_snr_health(snr) when snr >= 15, do: "degraded" defp classify_snr_health(_snr), do: "critical" @@ -1349,29 +1402,30 @@ defmodule Towerops.Topology do Map.merge(edge, %{signal_health: signal_health, snr: snr}) end - defp compute_edge_signal_health(source_rf, target_rf) do - case {source_rf, target_rf} do - {nil, nil} -> nil - {s, nil} -> s.signal_health - {nil, t} -> t.signal_health - {s, t} -> worse_health(s.signal_health, t.signal_health) - end - end + defp compute_edge_signal_health(nil, nil), do: nil + defp compute_edge_signal_health(s, nil), do: s.signal_health + defp compute_edge_signal_health(nil, t), do: t.signal_health + defp compute_edge_signal_health(s, t), do: worse_health(s.signal_health, t.signal_health) - defp compute_edge_snr(source_rf, target_rf) do - case {source_rf, target_rf} do - {nil, nil} -> nil - {s, nil} -> s[:snr] - {nil, t} -> t[:snr] - {s, t} -> min(s[:snr] || 0, t[:snr] || 0) - end - end + defp compute_edge_snr(nil, nil), do: nil + defp compute_edge_snr(s, nil), do: s[:snr] + defp compute_edge_snr(nil, t), do: t[:snr] + defp compute_edge_snr(s, t), do: min(s[:snr] || 0, t[:snr] || 0) defp worse_health(a, b) do priority = %{"good" => 0, "degraded" => 1, "critical" => 2} - if (priority[a] || 0) >= (priority[b] || 0), do: a, else: b + pick_worse(a, b, priority) end + defp pick_worse(a, b, priority) do + a_prio = Map.get(priority, a, 0) + b_prio = Map.get(priority, b, 0) + pick_by_priority(a, b, a_prio, b_prio) + end + + defp pick_by_priority(a, _b, a_prio, b_prio) when a_prio >= b_prio, do: a + defp pick_by_priority(_a, b, _a_prio, _b_prio), do: b + # Enriches edges with bandwidth utilization data by calculating interface throughput # and comparing it to configured capacity. defp enrich_edges_with_utilization(edges, device_ids) do @@ -1421,73 +1475,68 @@ defmodule Towerops.Topology do |> Map.new(fn interface -> {interface.interface_id, interface} end) end - defp get_edge_utilization(edge, direction, interface_utilizations) do - interface_id = - case direction do - :source -> edge[:source_interface_id] - :target -> edge[:target_interface_id] - end - - case interface_id do - nil -> nil - id -> Map.get(interface_utilizations, id) - end + defp get_edge_utilization(edge, :source, interface_utilizations) do + lookup_utilization(edge[:source_interface_id], interface_utilizations) end - defp combine_interface_utilizations(source_util, target_util) do - case {source_util, target_util} do - {nil, nil} -> - %{ - utilization_pct: nil, - utilization_level: nil, - throughput_bps: nil, - capacity_bps: nil, - utilization_text: nil - } - - {util, nil} -> - process_single_utilization(util) - - {nil, util} -> - process_single_utilization(util) - - {source, target} -> - # Use the higher utilization of the two interfaces - source_data = process_single_utilization(source) - target_data = process_single_utilization(target) - - if (source_data.utilization_pct || 0) >= (target_data.utilization_pct || 0) do - source_data - else - target_data - end - end + defp get_edge_utilization(edge, :target, interface_utilizations) do + lookup_utilization(edge[:target_interface_id], interface_utilizations) end - defp process_single_utilization(util_data) do - case util_data.utilization_data do - nil -> - %{ - utilization_pct: nil, - utilization_level: nil, - throughput_bps: nil, - capacity_bps: util_data.capacity_bps, - utilization_text: nil - } + defp lookup_utilization(nil, _interface_utilizations), do: nil + defp lookup_utilization(id, interface_utilizations), do: Map.get(interface_utilizations, id) - util -> - utilization_pct = Float.round(util.utilization_pct, 1) - level = classify_utilization_level(utilization_pct) + defp combine_interface_utilizations(nil, nil) do + %{ + utilization_pct: nil, + utilization_level: nil, + throughput_bps: nil, + capacity_bps: nil, + utilization_text: nil + } + end - %{ - utilization_pct: utilization_pct, - utilization_level: level, - throughput_bps: util.throughput.max_bps, - capacity_bps: util_data.capacity_bps, - utilization_text: format_utilization_text(util.throughput.max_bps, util_data.capacity_bps), - interface_name: util_data.interface_name - } - end + defp combine_interface_utilizations(util, nil), do: process_single_utilization(util) + defp combine_interface_utilizations(nil, util), do: process_single_utilization(util) + + defp combine_interface_utilizations(source, target) do + source_data = process_single_utilization(source) + target_data = process_single_utilization(target) + pick_higher_utilization(source_data, target_data) + end + + defp pick_higher_utilization(source_data, target_data) do + source_pct = source_data.utilization_pct || 0 + target_pct = target_data.utilization_pct || 0 + pick_util_data(source_data, target_data, source_pct, target_pct) + end + + defp pick_util_data(source_data, _target_data, source_pct, target_pct) when source_pct >= target_pct, do: source_data + + defp pick_util_data(_source_data, target_data, _source_pct, _target_pct), do: target_data + + defp process_single_utilization(%{utilization_data: nil} = util_data) do + %{ + utilization_pct: nil, + utilization_level: nil, + throughput_bps: nil, + capacity_bps: util_data.capacity_bps, + utilization_text: nil + } + end + + defp process_single_utilization(%{utilization_data: util} = util_data) do + utilization_pct = Float.round(util.utilization_pct, 1) + level = classify_utilization_level(utilization_pct) + + %{ + utilization_pct: utilization_pct, + utilization_level: level, + throughput_bps: util.throughput.max_bps, + capacity_bps: util_data.capacity_bps, + utilization_text: format_utilization_text(util.throughput.max_bps, util_data.capacity_bps), + interface_name: util_data.interface_name + } end defp classify_utilization_level(pct) when pct < 30, do: :low diff --git a/lib/towerops_web/channels/agent_channel.ex b/lib/towerops_web/channels/agent_channel.ex index de334906..5d8612db 100644 --- a/lib/towerops_web/channels/agent_channel.ex +++ b/lib/towerops_web/channels/agent_channel.ex @@ -25,22 +25,14 @@ defmodule ToweropsWeb.AgentChannel do alias Towerops.Agent.AgentHeartbeat alias Towerops.Agent.AgentJob alias Towerops.Agent.AgentJobList - alias Towerops.Agent.Check, as: CheckProto alias Towerops.Agent.CheckList alias Towerops.Agent.CheckResult, as: CheckResultProto alias Towerops.Agent.CredentialTestResult - alias Towerops.Agent.DnsCheckConfig - alias Towerops.Agent.HttpCheckConfig alias Towerops.Agent.LldpTopologyResult - alias Towerops.Agent.MikrotikCommand - alias Towerops.Agent.MikrotikDevice alias Towerops.Agent.MikrotikResult alias Towerops.Agent.MonitoringCheck, as: MonitoringCheckProto alias Towerops.Agent.SnmpDevice - alias Towerops.Agent.SnmpQuery alias Towerops.Agent.SnmpResult - alias Towerops.Agent.SslCheckConfig - alias Towerops.Agent.TcpCheckConfig alias Towerops.Agents alias Towerops.Alerts alias Towerops.ConfigChanges @@ -58,6 +50,9 @@ defmodule ToweropsWeb.AgentChannel do alias Towerops.Snmp.SensorChangeDetector alias Towerops.Snmp.SensorScale alias Towerops.Topology + alias ToweropsWeb.AgentChannel.Heartbeat + alias ToweropsWeb.AgentChannel.JobBuilder + alias ToweropsWeb.AgentChannel.Subscriptions, as: ChannelSubscriptions alias ToweropsWeb.GraphQL.Subscriptions alias ToweropsWeb.RemoteIp @@ -85,100 +80,54 @@ defmodule ToweropsWeb.AgentChannel do payload_keys: Map.keys(payload) ) - # Verify agent token from join payload - case Agents.verify_agent_token(token) do - {:ok, agent_token} -> - socket = - 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") - - # Subscribe to discovery requests for this agent - _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token.id}:discovery") - - # Subscribe to backup requests for this agent - _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token.id}:backup") - - # Subscribe to credential test requests for this agent - _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token.id}:credential_test") - - # Subscribe to live poll requests for this agent - _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token.id}:live_poll") - - # Subscribe to check changes for this agent (service checks added/removed/updated) - _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "checks:agent:#{agent_token.id}") - - # Subscribe to token lifecycle events (disable/delete triggers disconnect) - _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token.id}:lifecycle") - - # Subscribe to latency probe requests (cloud pollers only) - _ = - if agent_token.is_cloud_poller do - _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token.id}:latency_probe") - end - - # Update last_seen_at and IP on join - now = DateTime.utc_now() - remote_ip = get_remote_ip(socket) - _ = Agents.update_agent_token_heartbeat(agent_token.id, remote_ip, %{}) - - # Broadcast agent connection for real-time UI updates - _ = - Phoenix.PubSub.broadcast( - Towerops.PubSub, - "agents:health", - {:agent_connected, agent_token.id, agent_token.organization_id} - ) - - # Track heartbeat time and schedule periodic check - # Also set last_heartbeat_db_update since we just updated the DB - socket = - socket - |> assign(:last_heartbeat_at, now) - |> assign(:last_heartbeat_db_update, now) - - Process.send_after(self(), :check_heartbeat, @heartbeat_check_interval_ms) - - # Send initial job list after join completes (push/3 cannot be called during join) - send(self(), :send_jobs) - - {:ok, socket} - - {:error, _} -> - {:error, %{reason: "unauthorized"}} - end + do_join(Agents.verify_agent_token(token), socket) end def join("agent:" <> _agent_id, _payload, _socket) do {:error, %{reason: "missing token"}} end - @impl true - def terminate(_reason, socket) do - # Broadcast agent disconnection for real-time UI updates - _ = - if Map.has_key?(socket.assigns, :agent_token_id) do - _ = - Phoenix.PubSub.broadcast( - Towerops.PubSub, - "agents:health", - {:agent_disconnected, socket.assigns.agent_token_id, socket.assigns.organization_id} - ) - end + defp do_join({:ok, agent_token}, socket) do + socket = + socket + |> assign(:agent_token_id, agent_token.id) + |> assign(:organization_id, agent_token.organization_id) + |> assign(:debug_enabled, agent_token.allow_remote_debug) + maybe_debug_log(socket, "Agent connected", + ip: get_remote_ip(socket), + organization_id: agent_token.organization_id + ) + + _ = ChannelSubscriptions.subscribe_all(agent_token) + + now = DateTime.utc_now() + _ = Agents.update_agent_token_heartbeat(agent_token.id, get_remote_ip(socket), %{}) + _ = ChannelSubscriptions.broadcast_connection(agent_token.id, agent_token.organization_id) + + socket = + socket + |> assign(:last_heartbeat_at, now) + |> assign(:last_heartbeat_db_update, now) + + Process.send_after(self(), :check_heartbeat, @heartbeat_check_interval_ms) + send(self(), :send_jobs) + + {:ok, socket} + end + + defp do_join({:error, _}, _socket) do + {:error, %{reason: "unauthorized"}} + end + + @impl true + def terminate(_reason, %{assigns: %{agent_token_id: id, organization_id: org_id}}) do + _ = ChannelSubscriptions.broadcast_disconnection(id, org_id) :ok end + def terminate(_reason, _socket), do: :ok + @impl true @spec handle_info(:send_jobs, Phoenix.Socket.t()) :: {:noreply, Phoenix.Socket.t()} def handle_info(:send_jobs, socket) do @@ -202,11 +151,11 @@ defmodule ToweropsWeb.AgentChannel do Logger.error("Device not found for post-discovery poll", device_id: device_id) device -> - jobs = [build_polling_job(device)] + jobs = [JobBuilder.build_polling_job(device)] jobs = if device.monitoring_enabled do - jobs ++ [build_ping_job(device)] + jobs ++ [JobBuilder.build_ping_job(device)] else jobs end @@ -309,8 +258,7 @@ defmodule ToweropsWeb.AgentChannel do {:noreply, socket} device -> - # Build and send discovery job to agent - job = build_discovery_job(device) + job = JobBuilder.build_discovery_job(device) job_list = %AgentJobList{jobs: [job]} binary = AgentJobList.encode(job_list) @@ -335,7 +283,7 @@ defmodule ToweropsWeb.AgentChannel do ) device -> - job = build_backup_job(device, job_id) + job = JobBuilder.build_backup_job(device, job_id) job_list = %AgentJobList{jobs: [job]} binary = AgentJobList.encode(job_list) push(socket, "backup_job", %{binary: Base.encode64(binary)}) @@ -396,8 +344,7 @@ defmodule ToweropsWeb.AgentChannel do {:noreply, socket} device -> - # Build live polling job with only requested sensor OIDs - job = build_live_polling_job(device, sensor_oids, reply_topic) + job = JobBuilder.build_live_polling_job(device, sensor_oids, reply_topic) job_list = %AgentJobList{jobs: [job]} binary = AgentJobList.encode(job_list) @@ -431,10 +378,13 @@ defmodule ToweropsWeb.AgentChannel do end # Handle latency probe requests — send PING jobs for cloud-polled devices + def handle_info({:latency_probe_jobs, []}, socket) do + {:noreply, socket} + end + def handle_info({:latency_probe_jobs, devices}, socket) do jobs = Enum.flat_map(devices, fn device -> - # 5 pings per device for statistical validity for i <- 1..5 do %AgentJob{ job_id: "probe:#{device.id}:#{i}", @@ -451,306 +401,65 @@ defmodule ToweropsWeb.AgentChannel do end end) - if jobs != [] do - job_list = %AgentJobList{jobs: jobs} - binary = AgentJobList.encode(job_list) + job_list = %AgentJobList{jobs: jobs} + binary = AgentJobList.encode(job_list) - Logger.info("Sending #{length(jobs)} latency probe pings to cloud poller", - agent_token_id: socket.assigns.agent_token_id, - device_count: length(devices) - ) + Logger.info("Sending #{length(jobs)} latency probe pings to cloud poller", + agent_token_id: socket.assigns.agent_token_id, + device_count: length(devices) + ) - push(socket, "jobs", %{binary: Base.encode64(binary)}) - end + push(socket, "jobs", %{binary: Base.encode64(binary)}) {:noreply, socket} end - defp build_backup_job(device, job_id) do - mikrotik_config = Devices.get_mikrotik_config(device) - backup_filename = "towerops-backup-#{DateTime.to_unix(DateTime.utc_now())}" - chunk_size = 32_768 - num_chunks = 10 - - read_commands = - for i <- 0..(num_chunks - 1) do - %MikrotikCommand{ - command: "/file/read", - args: %{ - "file" => "#{backup_filename}.rsc", - "offset" => to_string(i * chunk_size), - "chunk-size" => to_string(chunk_size) - } - } - end - - %AgentJob{ - job_id: job_id, - job_type: :MIKROTIK, - device_id: device.id, - mikrotik_device: %MikrotikDevice{ - ip: device.ip_address || "", - username: mikrotik_config.username || "", - password: mikrotik_config.password || "", - port: mikrotik_config.port || 8729, - ssh_port: mikrotik_config.ssh_port || 22, - use_ssl: mikrotik_config.use_ssl || false - }, - mikrotik_commands: - [ - %MikrotikCommand{ - command: "/export", - args: %{"file" => backup_filename, "compact" => "true"} - } - ] ++ - read_commands ++ - [ - %MikrotikCommand{ - command: "/file/remove", - args: %{"numbers" => "#{backup_filename}.rsc"} - } - ] - } - end - @impl true @spec handle_in(String.t(), map(), socket()) :: {:noreply, socket()} - def handle_in("result", %{"binary" => binary_b64}, socket) when is_binary(binary_b64) do - # Validate message size to prevent DoS attacks (early return pattern) - if byte_size(binary_b64) > @max_message_size do - Logger.warning("Rejected oversized message from agent", - agent_token_id: socket.assigns.agent_token_id, - size: byte_size(binary_b64), - max_size: @max_message_size - ) - {:reply, {:error, %{reason: "Message too large (max #{@max_message_size} bytes)"}}, socket} - else - process_snmp_result_message(binary_b64, socket) - end + def handle_in("result", %{"binary" => b}, socket) when is_binary(b) and byte_size(b) <= @max_message_size do + decode_and_process(b, &SnmpResult.decode/1, &process_snmp_result_message/2, socket) end - @spec handle_in(String.t(), %{required(String.t()) => base64_string()}, socket()) :: - {:noreply, socket()} - def handle_in("heartbeat", %{"binary" => binary_b64}, socket) when is_binary(binary_b64) do - # Validate message size to prevent DoS attacks (early return pattern) - if byte_size(binary_b64) > @max_message_size do - Logger.warning("Rejected oversized heartbeat from agent", - agent_token_id: socket.assigns.agent_token_id, - size: byte_size(binary_b64), - max_size: @max_message_size - ) - - {:reply, {:error, %{reason: "Message too large"}}, socket} - else - process_heartbeat_message(binary_b64, socket) - end + def handle_in("heartbeat", %{"binary" => b}, socket) when is_binary(b) and byte_size(b) <= @max_message_size do + decode_and_process(b, &AgentHeartbeat.decode/1, &Heartbeat.process/2, socket) end - def handle_in("error", %{"binary" => binary_b64}, socket) when is_binary(binary_b64) do - # Validate message size to prevent DoS attacks - if byte_size(binary_b64) > @max_message_size do - Logger.warning("Rejected oversized error message from agent", - agent_token_id: socket.assigns.agent_token_id, - size: byte_size(binary_b64), - max_size: @max_message_size - ) - - {:reply, {:error, %{reason: "Message too large"}}, socket} - else - with {:ok, binary} <- safe_base64_decode(binary_b64), - {:ok, error} <- AgentError.decode(binary) do - 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, - error: error.message - ) - - {:noreply, socket} - else - {:error, {type, message}} -> - Logger.error("Invalid error message from agent", - agent_token_id: socket.assigns.agent_token_id, - error_type: type, - error_message: message - ) - - {:noreply, socket} - end - end + def handle_in("error", %{"binary" => b}, socket) when is_binary(b) and byte_size(b) <= @max_message_size do + decode_and_process(b, &AgentError.decode/1, &handle_agent_error/2, socket) end - def handle_in("mikrotik_result", %{"binary" => binary_b64}, socket) when is_binary(binary_b64) do - with {:ok, binary} <- safe_base64_decode(binary_b64), - {:ok, result} <- MikrotikResult.decode(binary) do - handle_validated_mikrotik_result(result, socket) - else - {:error, {type, message}} -> - Logger.error("Invalid MikroTik result from agent", - agent_token_id: socket.assigns.agent_token_id, - error_type: type, - error_message: message - ) - - {:noreply, socket} - end + def handle_in("mikrotik_result", %{"binary" => b}, socket) when is_binary(b) and byte_size(b) <= @max_message_size do + decode_and_process(b, &MikrotikResult.decode/1, &handle_validated_mikrotik_result/2, socket) end - def handle_in("credential_test_result", %{"binary" => binary_b64}, socket) when is_binary(binary_b64) do - with {:ok, binary} <- safe_base64_decode(binary_b64), - {:ok, result} <- CredentialTestResult.decode(binary) do - Logger.info("AgentChannel received credential test result from agent, broadcasting to LiveView", - agent_token_id: socket.assigns.agent_token_id, - test_id: result.test_id, - success: result.success, - has_error: result.error_message != "", - broadcast_topic: "credential_test:#{result.test_id}" - ) - - # Broadcast result to the requesting LiveView via PubSub - # The test_id contains the device_id, so we can broadcast to that topic - _ = - Phoenix.PubSub.broadcast( - Towerops.PubSub, - "credential_test:#{result.test_id}", - {:credential_test_result, result} - ) - - {:noreply, socket} - else - {:error, {type, message}} -> - Logger.error( - "Invalid credential test result from agent: #{type} - #{message}", - agent_token_id: socket.assigns.agent_token_id, - error_type: type, - error_message: message, - binary_size: byte_size(binary_b64) - ) - - {:noreply, socket} - end + def handle_in("credential_test_result", %{"binary" => b}, socket) + when is_binary(b) and byte_size(b) <= @max_message_size do + decode_and_process(b, &CredentialTestResult.decode/1, &handle_credential_test_result/2, socket) end - @spec handle_in(String.t(), %{required(String.t()) => base64_string()}, socket()) :: - {:noreply, socket()} - def handle_in("monitoring_check", %{"binary" => binary_b64}, socket) when is_binary(binary_b64) do - with {:ok, binary} <- safe_base64_decode(binary_b64), - {:ok, check} <- MonitoringCheckProto.decode(binary) do - maybe_debug_log(socket, "Received ping result from agent", - device_id: check.device_id, - status: check.status, - response_time_ms: check.response_time_ms, - binary_size: byte_size(binary_b64) - ) - - # Store ping result in database - case store_monitoring_check(check, socket) do - :ok -> - :ok - - {:error, reason} -> - Logger.error("Failed to store monitoring check", - device_id: check.device_id, - reason: inspect(reason) - ) - end - - {:noreply, socket} - else - {:error, {type, message}} -> - Logger.error("Invalid ping result from agent: #{type} - #{message}", - agent_token_id: socket.assigns.agent_token_id, - error_type: type, - error_message: message, - binary_size: byte_size(binary_b64) - ) - - {:noreply, socket} - end + def handle_in("monitoring_check", %{"binary" => b}, socket) when is_binary(b) and byte_size(b) <= @max_message_size do + decode_and_process(b, &MonitoringCheckProto.decode/1, &handle_monitoring_check/2, socket) end - @spec handle_in(String.t(), %{required(String.t()) => base64_string()}, socket()) :: - {:noreply, socket()} - def handle_in("check_result", %{"binary" => binary_b64}, socket) when is_binary(binary_b64) do - with {:ok, binary} <- safe_base64_decode(binary_b64), - {:ok, result} <- CheckResultProto.decode(binary) do - maybe_debug_log(socket, "Received check result from agent", - check_id: result.check_id, - status: result.status, - response_time_ms: result.response_time_ms, - binary_size: byte_size(binary_b64) - ) - - case store_check_result(result, socket) do - :ok -> - :ok - - {:error, reason} -> - Logger.error("Failed to store check result", - check_id: result.check_id, - reason: inspect(reason) - ) - end - - {:noreply, socket} - else - {:error, {type, message}} -> - Logger.error("Invalid check result from agent: #{type} - #{message}", - agent_token_id: socket.assigns.agent_token_id, - error_type: type, - error_message: message, - binary_size: byte_size(binary_b64) - ) - - {:noreply, socket} - end + def handle_in("check_result", %{"binary" => b}, socket) when is_binary(b) and byte_size(b) <= @max_message_size do + decode_and_process(b, &CheckResultProto.decode/1, &handle_check_result/2, socket) end - @spec handle_in(String.t(), %{required(String.t()) => base64_string()}, socket()) :: - {:noreply, socket()} - def handle_in("lldp_topology_result", %{"binary" => binary_b64}, socket) when is_binary(binary_b64) do - with {:ok, binary} <- safe_base64_decode(binary_b64), - {:ok, result} <- LldpTopologyResult.decode(binary) do - maybe_debug_log(socket, "Received LLDP topology result from agent", - device_id: result.device_id, - job_id: result.job_id, - neighbor_count: length(result.neighbors), - local_system_name: result.local_system_name - ) + def handle_in("lldp_topology_result", %{"binary" => b}, socket) + when is_binary(b) and byte_size(b) <= @max_message_size do + decode_and_process(b, &LldpTopologyResult.decode/1, &handle_lldp_topology_result/2, socket) + end - # Store LLDP neighbors in database - case store_lldp_neighbors(result) do - {:ok, count} -> - Logger.info("Stored LLDP neighbors for device", - device_id: result.device_id, - neighbor_count: count - ) + # Oversized message handler — catches any binary that exceeds size limits + def handle_in(type, %{"binary" => b}, socket) when is_binary(b) do + Logger.warning("Rejected oversized #{type} from agent", + agent_token_id: socket.assigns.agent_token_id, + size: byte_size(b), + max_size: @max_message_size + ) - {:error, reason} -> - Logger.error("Failed to store LLDP neighbors", - device_id: result.device_id, - reason: inspect(reason) - ) - end - - {:noreply, socket} - else - {:error, {type, message}} -> - Logger.error("Invalid LLDP topology result from agent: #{type} - #{message}", - agent_token_id: socket.assigns.agent_token_id, - error_type: type, - error_message: message, - binary_size: byte_size(binary_b64) - ) - - {:noreply, socket} - end + {:reply, {:error, %{reason: "Message too large (max #{@max_message_size} bytes)"}}, socket} end # Catch-all for unmatched events (diagnostic) @@ -764,33 +473,16 @@ defmodule ToweropsWeb.AgentChannel do {:noreply, socket} end - # Private helpers - - defp process_snmp_result_message(binary_b64, socket) do - Logger.debug("Received SNMP result from agent (binary size: #{byte_size(binary_b64)})") + ## Decode-and-process helper + @spec decode_and_process(base64_string(), function(), function(), socket()) :: {:noreply, socket()} + defp decode_and_process(binary_b64, decode_fn, process_fn, socket) do with {:ok, binary} <- safe_base64_decode(binary_b64), - {:ok, result} <- SnmpResult.decode(binary) do - maybe_debug_log(socket, "Received SNMP result from agent", - device_id: result.device_id, - job_type: result.job_type, - job_id: result.job_id, - binary_size: byte_size(binary_b64), - oid_count: map_size(result.oid_values) - ) - - # Check if this is a live poll result - _ = - if String.starts_with?(result.job_id, "live_poll:") do - handle_live_poll_result(result, socket) - else - process_and_log_snmp_result(socket, result) - end - - {:noreply, socket} + {:ok, decoded} <- decode_fn.(binary) do + process_fn.(decoded, socket) else {:error, {type, message}} -> - Logger.error("Invalid SNMP result from agent: #{type} - #{message}", + Logger.error("Invalid message from agent: #{type} - #{message}", agent_token_id: socket.assigns.agent_token_id, error_type: type, error_message: message, @@ -801,56 +493,199 @@ defmodule ToweropsWeb.AgentChannel do end end - defp process_heartbeat_message(binary_b64, socket) do - with {:ok, binary} <- safe_base64_decode(binary_b64), - {:ok, heartbeat} <- AgentHeartbeat.decode(binary) do - now = DateTime.utc_now() - last_db_update = socket.assigns[:last_heartbeat_db_update] + ## Message handler wrappers (normalize to (decoded, socket) -> {:noreply, socket}) - # Only update database if last update was >30s ago to prevent flooding - # This limits heartbeat DB writes to ~2/minute max per agent - socket = - if is_nil(last_db_update) or DateTime.diff(now, last_db_update) > 30 do - metadata = %{ - "version" => heartbeat.version, - "uptime_seconds" => heartbeat.uptime_seconds, - "arch" => heartbeat.arch - } + defp handle_agent_error(error, socket) do + maybe_debug_log(socket, "Agent job error", + device_id: error.device_id, + error_message: error.message + ) - _ = - Agents.update_agent_token_heartbeat( - socket.assigns.agent_token_id, - get_remote_ip(socket), - metadata - ) + Logger.error("Agent job error", + agent_token_id: socket.assigns.agent_token_id, + device_id: error.device_id, + error: error.message + ) - # Broadcast heartbeat for real-time UI updates (especially important for stale agents coming back online) - _ = - Phoenix.PubSub.broadcast( - Towerops.PubSub, - "agents:health", - {:agent_heartbeat, socket.assigns.agent_token_id, socket.assigns.organization_id} - ) + {:noreply, socket} + end - socket - |> assign(:last_heartbeat_at, now) - |> assign(:last_heartbeat_db_update, now) - else - # Just update in-memory tracking, skip DB write - assign(socket, :last_heartbeat_at, now) - end + defp handle_credential_test_result(result, socket) do + Logger.info( + "AgentChannel received credential test result from agent, broadcasting to LiveView", + agent_token_id: socket.assigns.agent_token_id, + test_id: result.test_id, + success: result.success, + has_error: result.error_message != "", + broadcast_topic: "credential_test:#{result.test_id}" + ) - {:noreply, socket} - else - {:error, {type, message}} -> - Logger.error("Invalid heartbeat from agent", - agent_token_id: socket.assigns.agent_token_id, - error_type: type, - error_message: message + _ = + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "credential_test:#{result.test_id}", + {:credential_test_result, result} + ) + + {:noreply, socket} + end + + defp handle_monitoring_check(check, socket) do + maybe_debug_log(socket, "Received ping result from agent", + device_id: check.device_id, + status: check.status, + response_time_ms: check.response_time_ms + ) + + case store_monitoring_check(check, socket) do + :ok -> + :ok + + {:error, reason} -> + Logger.error("Failed to store monitoring check", + device_id: check.device_id, + reason: inspect(reason) + ) + end + + {:noreply, socket} + end + + defp handle_check_result(result, socket) do + maybe_debug_log(socket, "Received check result from agent", + check_id: result.check_id, + status: result.status, + response_time_ms: result.response_time_ms + ) + + case store_check_result(result, socket) do + :ok -> + :ok + + {:error, reason} -> + Logger.error("Failed to store check result", + check_id: result.check_id, + reason: inspect(reason) + ) + end + + {:noreply, socket} + end + + defp handle_lldp_topology_result(%LldpTopologyResult{} = result, socket) do + maybe_debug_log(socket, "Received LLDP topology result from agent", + device_id: result.device_id, + job_id: result.job_id, + neighbor_count: length(result.neighbors), + local_system_name: result.local_system_name + ) + + case store_lldp_neighbors(result) do + {:ok, count} -> + Logger.info("Stored LLDP neighbors for device", + device_id: result.device_id, + neighbor_count: count ) - {:noreply, socket} + {:error, reason} -> + Logger.error("Failed to store LLDP neighbors", + device_id: result.device_id, + reason: inspect(reason) + ) end + + {:noreply, socket} + end + + ## SNMP result processing + + defp process_snmp_result_message(result, socket) do + maybe_debug_log(socket, "Received SNMP result from agent", + device_id: result.device_id, + job_type: result.job_type, + job_id: result.job_id, + oid_count: map_size(result.oid_values) + ) + + _ = do_process_snmp_result(result.job_id, result, socket) + + {:noreply, socket} + end + + defp do_process_snmp_result("live_poll:" <> _rest, result, socket) do + handle_live_poll_result(result, socket) + end + + defp do_process_snmp_result(_job_id, result, socket) do + process_and_log_snmp_result(socket, result) + end + + # Handle live poll result — broadcast to reply topic instead of saving + defp handle_live_poll_result(result, socket) do + # Extract reply topic from job_id: "live_poll:device_id:reply_topic" + case String.split(result.job_id, ":", parts: 3) do + ["live_poll", _device_id, reply_topic] -> + maybe_debug_log(socket, "Broadcasting live poll result", + reply_topic: reply_topic, + oid_count: map_size(result.oid_values) + ) + + # Broadcast OID values directly to the waiting LiveView + Phoenix.PubSub.broadcast( + Towerops.PubSub, + reply_topic, + {:live_poll_result, result.oid_values} + ) + + _ -> + Logger.warning("Invalid live poll job_id format: #{result.job_id}") + end + end + + # Builds all jobs for the agent and pushes them to the channel. + # Used both during join (synchronous) and for subsequent refreshes. + defp build_and_push_jobs(socket) do + jobs = JobBuilder.build_jobs_for_agent(socket.assigns.agent_token_id) + job_list = %AgentJobList{jobs: jobs} + binary = AgentJobList.encode(job_list) + + # Extract device IDs for better debugging + device_ids = jobs |> Enum.map(& &1.device_id) |> Enum.uniq() |> Enum.sort() + + Logger.info("Sending #{length(jobs)} jobs to agent", + agent_token_id: socket.assigns.agent_token_id, + device_count: length(device_ids), + device_ids: inspect(device_ids), + job_ids: inspect(Enum.map(jobs, & &1.job_id)) + ) + + # Store last job list to detect changes on next refresh + socket = assign(socket, :last_job_device_ids, device_ids) + + push(socket, "jobs", %{binary: Base.encode64(binary)}) + socket + end + + # Builds service check jobs for the agent and pushes them via "check_jobs" event. + defp build_and_push_check_jobs(socket) do + agent_token_id = socket.assigns.agent_token_id + checks = JobBuilder.list_checks_for_agent(agent_token_id) + + do_push_check_jobs(socket, checks) + end + + defp do_push_check_jobs(_socket, []), do: :ok + + defp do_push_check_jobs(socket, checks) do + check_list = JobBuilder.build_check_list(checks) + binary = CheckList.encode(check_list) + + Logger.info("Sending #{length(check_list.checks)} check jobs to agent", + agent_token_id: socket.assigns.agent_token_id, + check_ids: inspect(Enum.map(checks, & &1.id)) + ) + + push(socket, "check_jobs", %{binary: Base.encode64(binary)}) end @spec handle_validated_mikrotik_result(MikrotikResult.t(), socket()) :: {:noreply, socket()} @@ -885,607 +720,6 @@ defmodule ToweropsWeb.AgentChannel do {:noreply, socket} end - # Builds all jobs for the agent and pushes them to the channel. - # Used both during join (synchronous) and for subsequent refreshes. - defp build_and_push_jobs(socket) do - jobs = build_jobs_for_agent(socket.assigns.agent_token_id) - job_list = %AgentJobList{jobs: jobs} - binary = AgentJobList.encode(job_list) - - # Extract device IDs for better debugging - device_ids = jobs |> Enum.map(& &1.device_id) |> Enum.uniq() |> Enum.sort() - - Logger.info("Sending #{length(jobs)} jobs to agent", - agent_token_id: socket.assigns.agent_token_id, - device_count: length(device_ids), - device_ids: inspect(device_ids), - job_ids: inspect(Enum.map(jobs, & &1.job_id)) - ) - - # Store last job list to detect changes on next refresh - socket = assign(socket, :last_job_device_ids, device_ids) - - push(socket, "jobs", %{binary: Base.encode64(binary)}) - socket - end - - # Builds service check jobs for the agent and pushes them via "check_jobs" event. - defp build_and_push_check_jobs(socket) do - agent_token_id = socket.assigns.agent_token_id - checks = Monitoring.list_checks_for_agent(agent_token_id, enabled: true) - - if checks != [] do - proto_checks = Enum.map(checks, &build_check_protobuf/1) - check_list = %CheckList{checks: proto_checks} - binary = CheckList.encode(check_list) - - Logger.info("Sending #{length(proto_checks)} check jobs to agent", - agent_token_id: agent_token_id, - check_ids: inspect(Enum.map(checks, & &1.id)) - ) - - push(socket, "check_jobs", %{binary: Base.encode64(binary)}) - end - end - - defp build_check_protobuf(check) do - config = check.config || %{} - - base_fields = [ - id: check.id, - check_type: check.check_type, - interval_seconds: check.interval_seconds, - timeout_ms: check.timeout_ms - ] - - struct!(CheckProto, base_fields ++ check_type_config(check.check_type, config)) - end - - defp check_type_config("http", config) do - [ - http: %HttpCheckConfig{ - url: config["url"] || "", - method: config["method"] || "GET", - expected_status: config["expected_status"] || 200, - verify_ssl: config["verify_ssl"] != false, - regex: config["regex"] || "", - follow_redirects: config["follow_redirects"] != false - } - ] - end - - defp check_type_config("tcp", config) do - [ - tcp: %TcpCheckConfig{ - host: config["host"] || "", - port: config["port"] || 0, - send: config["send"] || "", - expect: config["expect"] || "" - } - ] - end - - defp check_type_config("dns", config) do - [ - dns: %DnsCheckConfig{ - hostname: config["hostname"] || "", - server: config["server"] || "", - record_type: config["record_type"] || "A", - expected: config["expected"] || "" - } - ] - end - - defp check_type_config("ssl", config) do - [ - ssl: %SslCheckConfig{ - host: config["host"] || "", - port: config["port"] || 443, - warning_days: config["warning_days"] || 30 - } - ] - end - - defp check_type_config(_, _config), do: [] - - @spec build_jobs_for_agent(Ecto.UUID.t()) :: [AgentJob.t()] - defp build_jobs_for_agent(agent_token_id) do - agent_token_id - |> Agents.list_agent_polling_targets() - |> Enum.flat_map(&build_jobs_for_device/1) - end - - defp build_jobs_for_device(device) do - # Build SNMP job (discovery or polling) only if SNMP is enabled - snmp_job = - if device.snmp_enabled do - if needs_discovery?(device) do - build_discovery_job(device) - else - build_polling_job(device) - end - end - - # Build MikroTik job only during discovery (not during regular polling) - # MikroTik commands are discovery-type operations that should run once per 24h - mikrotik_job = - if device.snmp_enabled and needs_discovery?(device) and mikrotik_device?(device) do - build_mikrotik_job(device) - end - - # Build ping job if monitoring is enabled - ping_job = - if device.monitoring_enabled do - build_ping_job(device) - end - - # Return list of jobs (filter out nil) - Enum.reject([snmp_job, mikrotik_job, ping_job], &is_nil/1) - end - - defp needs_discovery?(device) do - # Need discovery if no SNMP device or last discovery was >24 hours ago - # Clamp diff to 0 to handle clock skew (future timestamps) - is_nil(device.snmp_device) or - is_nil(device.last_discovery_at) or - max(DateTime.diff(DateTime.utc_now(), device.last_discovery_at, :hour), 0) > 24 - end - - defp mikrotik_device?(device) do - # Check if device is MikroTik based on SNMP discovery data - device.snmp_device && - (String.contains?(device.snmp_device.manufacturer || "", "MikroTik") || - String.contains?(device.snmp_device.sys_descr || "", "RouterOS")) - end - - # Resolves SNMP credentials for a device. - # For SNMPv3, returns a map with resolved credentials via cascade. - # For v1/v2c, returns a simple map with community string and version. - defp resolve_snmp_credentials(device) do - if device.snmp_version == "3" do - Devices.get_snmpv3_config(device) - else - %{ - community: Devices.resolve_snmp_community(device), - version: device.snmp_version - } - end - end - - # Checks if SNMP credentials are present (not nil/empty). - defp credentials_present?(%{community: community}) when is_binary(community) do - community != "" - end - - defp credentials_present?(%{username: username}) when is_binary(username) do - username != "" - end - - defp credentials_present?(_), do: false - - # Builds the SnmpDevice protobuf message with appropriate credentials. - # For v1/v2c: Uses community string - # For v3: Uses SNMPv3 credentials (security_level, username, auth/priv protocols/passwords) - defp build_snmp_device_message(device, snmp_config) do - if device.snmp_version == "3" do - build_v3_snmp_device(device, snmp_config) - else - build_v2c_snmp_device(device, snmp_config) - end - end - - defp build_v3_snmp_device(device, snmp_config) do - log_v3_device_build(device, snmp_config) - - %SnmpDevice{ - ip: device.ip_address, - version: device.snmp_version, - port: device.snmp_port || 161, - community: "", - v3_security_level: snmp_config.security_level || "", - v3_username: snmp_config.username || "", - v3_auth_protocol: snmp_config.auth_protocol || "", - v3_auth_password: snmp_config.auth_password || "", - v3_priv_protocol: snmp_config.priv_protocol || "", - v3_priv_password: snmp_config.priv_password || "" - } - end - - defp log_v3_device_build(device, snmp_config) do - auth_password_present = !is_nil(snmp_config.auth_password) && snmp_config.auth_password != "" - priv_password_present = !is_nil(snmp_config.priv_password) && snmp_config.priv_password != "" - - Logger.info( - "Building SNMPv3 device message", - device_id: device.id, - device_name: device.name, - device_ip: device.ip_address, - security_level: snmp_config.security_level || "", - username: snmp_config.username || "", - auth_protocol: snmp_config.auth_protocol || "", - auth_password_present: auth_password_present, - priv_protocol: snmp_config.priv_protocol || "", - priv_password_present: priv_password_present - ) - end - - defp build_v2c_snmp_device(device, snmp_config) do - community = snmp_config.community || "" - - # Use v2c when possible for HC (64-bit) counter support, but respect - # the device's configured version — some devices only support v1. - version = - case device.snmp_version do - "1" -> "1" - _ -> "2c" - end - - %SnmpDevice{ - ip: device.ip_address, - version: version, - port: device.snmp_port || 161, - community: community - } - end - - defp build_discovery_job(device) do - snmp_credentials = resolve_snmp_credentials(device) - - maybe_debug_log(%{assigns: %{agent_token_id: "system"}}, "Building discovery job", - device_id: device.id, - device_name: device.name, - snmp_version: device.snmp_version, - credentials_present: credentials_present?(snmp_credentials), - credential_source: - if(device.snmp_version == "3", - do: Map.get(snmp_credentials, :source), - else: device.snmp_community_source - ) - ) - - %AgentJob{ - job_id: "discover:#{device.id}", - job_type: :DISCOVER, - device_id: device.id, - snmp_device: build_snmp_device_message(device, snmp_credentials), - queries: build_discovery_queries() - } - end - - defp build_polling_job(device) do - _snmp_device = device.snmp_device - snmp_credentials = resolve_snmp_credentials(device) - - maybe_debug_log(%{assigns: %{agent_token_id: "system"}}, "Building polling job", - device_id: device.id, - device_name: device.name, - snmp_version: device.snmp_version, - credentials_present: credentials_present?(snmp_credentials), - credential_source: - if(device.snmp_version == "3", - do: Map.get(snmp_credentials, :source), - else: device.snmp_community_source - ), - site_id: device.site_id, - site_loaded: not is_nil(Map.get(device, :site)) - ) - - %AgentJob{ - job_id: "poll:#{device.id}", - job_type: :POLL, - device_id: device.id, - snmp_device: build_snmp_device_message(device, snmp_credentials), - queries: build_polling_queries(device) - } - end - - defp build_ping_job(device) do - %AgentJob{ - job_id: "ping:#{device.id}", - job_type: :PING, - device_id: device.id, - # Ping only needs IP address - use minimal SnmpDevice message - snmp_device: %SnmpDevice{ - ip: to_string(device.ip_address), - port: 0, - version: "", - community: "" - }, - queries: [] - } - end - - defp build_live_polling_job(device, sensor_oids, reply_topic) do - snmp_credentials = resolve_snmp_credentials(device) - - %AgentJob{ - job_id: "live_poll:#{device.id}:#{reply_topic}", - job_type: :POLL, - device_id: device.id, - snmp_device: build_snmp_device_message(device, snmp_credentials), - queries: [ - %SnmpQuery{ - query_type: :GET, - oids: sensor_oids - } - ] - } - end - - # Handle live poll result - broadcast to reply topic instead of saving - defp handle_live_poll_result(result, socket) do - # Extract reply topic from job_id: "live_poll:device_id:reply_topic" - case String.split(result.job_id, ":", parts: 3) do - ["live_poll", _device_id, reply_topic] -> - maybe_debug_log(socket, "Broadcasting live poll result", - reply_topic: reply_topic, - oid_count: map_size(result.oid_values) - ) - - # Broadcast OID values directly to the waiting LiveView - Phoenix.PubSub.broadcast( - Towerops.PubSub, - reply_topic, - {:live_poll_result, result.oid_values} - ) - - _ -> - Logger.warning("Invalid live poll job_id format: #{result.job_id}") - end - end - - defp build_discovery_queries do - [ - # System info (GET) - %SnmpQuery{ - query_type: :GET, - oids: [ - # sysDescr - "1.3.6.1.2.1.1.1.0", - # sysObjectID - "1.3.6.1.2.1.1.2.0", - # sysUpTime - "1.3.6.1.2.1.1.3.0", - # sysContact - "1.3.6.1.2.1.1.4.0", - # sysName - "1.3.6.1.2.1.1.5.0", - # sysLocation - "1.3.6.1.2.1.1.6.0" - ] - }, - # Interface table (WALK) - %SnmpQuery{ - query_type: :WALK, - # ifTable - oids: ["1.3.6.1.2.1.2.2.1"] - }, - # Interface extensions (WALK) - %SnmpQuery{ - query_type: :WALK, - # ifXTable - oids: ["1.3.6.1.2.1.31.1.1.1"] - }, - # Sensor discovery - ENTITY-SENSOR-MIB (WALK) - %SnmpQuery{ - query_type: :WALK, - oids: [ - # ENTITY-SENSOR-MIB - sensor tables - "1.3.6.1.2.1.99.1.1.1", - # ENTITY-MIB - physical entity descriptions (needed for sensor names) - "1.3.6.1.2.1.47.1.1.1.1.2", - # entPhysicalName - "1.3.6.1.2.1.47.1.1.1.1.7", - # entPhysicalClass - "1.3.6.1.2.1.47.1.1.1.1.5" - ] - }, - # State sensors - ENTITY-STATE-MIB (WALK) - %SnmpQuery{ - query_type: :WALK, - oids: ["1.3.6.1.2.1.131.1.1.1.1"] - }, - # HOST-RESOURCES-MIB (WALK) - processors, storage, devices - %SnmpQuery{ - query_type: :WALK, - oids: [ - # hrProcessorTable - "1.3.6.1.2.1.25.3.3", - # hrStorageTable - "1.3.6.1.2.1.25.2.3", - # hrDeviceTable (for processor descriptions) - "1.3.6.1.2.1.25.3.2" - ] - }, - # UCD-SNMP-MIB (GET) - Linux CPU stats fallback - %SnmpQuery{ - query_type: :GET, - oids: [ - # ssCpuUser - "1.3.6.1.4.1.2021.11.9.0", - # ssCpuSystem - "1.3.6.1.4.1.2021.11.10.0", - # ssCpuIdle - "1.3.6.1.4.1.2021.11.11.0" - ] - }, - # Vendor-specific MIBs (WALK) - targeted subtrees only - %SnmpQuery{ - query_type: :WALK, - oids: [ - # Cisco PROCESS-MIB (CPU) - "1.3.6.1.4.1.9.9.109", - # Cisco ENVMON-MIB (temp/voltage/fan/PSU) - "1.3.6.1.4.1.9.9.13", - # Cisco CDP - "1.3.6.1.4.1.9.9.23", - # Cisco WAP - "1.3.6.1.4.1.9.9.618", - # MikroTik - "1.3.6.1.4.1.14988", - # Ubiquiti - "1.3.6.1.4.1.41112", - # Juniper - "1.3.6.1.4.1.2636", - # HP/H3C/Comware - "1.3.6.1.4.1.25506", - # Fortinet - "1.3.6.1.4.1.12356", - # Cambium - "1.3.6.1.4.1.17713" - ] - }, - # Neighbor discovery (WALK) - %SnmpQuery{ - query_type: :WALK, - oids: [ - # LLDP - "1.0.8802.1.1.2.1.4.1.1", - # Cisco CDP - "1.3.6.1.4.1.9.9.23" - ] - }, - # IP address discovery (WALK) - %SnmpQuery{ - query_type: :WALK, - oids: [ - # IP-MIB ipAddrTable (IPv4 - deprecated but widely supported) - "1.3.6.1.2.1.4.20", - # IP-MIB ipAddressTable (RFC 4293 - unified IPv4/IPv6) - "1.3.6.1.2.1.4.34" - ] - } - ] - end - - defp build_polling_queries(device) do - sensor_oids = Enum.map(device.snmp_device.sensors, & &1.sensor_oid) - - interface_oids = - Enum.flat_map(device.snmp_device.interfaces, fn iface -> - idx = iface.if_index - - [ - # ifHCInOctets (64-bit counter, preferred) - "1.3.6.1.2.1.31.1.1.1.6.#{idx}", - # ifHCOutOctets (64-bit counter, preferred) - "1.3.6.1.2.1.31.1.1.1.10.#{idx}", - # ifInOctets (32-bit fallback for devices without HC counter support) - "1.3.6.1.2.1.2.2.1.10.#{idx}", - # ifOutOctets (32-bit fallback for devices without HC counter support) - "1.3.6.1.2.1.2.2.1.16.#{idx}", - # ifInErrors - "1.3.6.1.2.1.2.2.1.14.#{idx}", - # ifOutErrors - "1.3.6.1.2.1.2.2.1.20.#{idx}", - # ifInDiscards - "1.3.6.1.2.1.2.2.1.13.#{idx}", - # ifOutDiscards - "1.3.6.1.2.1.2.2.1.19.#{idx}" - ] - end) - - # Build separate WALK queries to isolate failures - # If one table doesn't exist, others can still succeed - base_queries = [ - %SnmpQuery{ - query_type: :GET, - oids: sensor_oids ++ interface_oids - } - ] - - # Neighbor discovery (LLDP/CDP) - separate query - neighbor_query = %SnmpQuery{ - query_type: :WALK, - oids: [ - # LLDP - "1.0.8802.1.1.2.1.4.1.1", - # Cisco CDP - "1.3.6.1.4.1.9.9.23" - ] - } - - # ARP table - separate query (may not be supported on all devices) - arp_query = %SnmpQuery{ - query_type: :WALK, - oids: [ - # IP-MIB ipNetToMediaTable (IPv4 ARP) - "1.3.6.1.2.1.4.22", - # IP-MIB ipNetToPhysicalTable (RFC 4293 - unified IPv4/IPv6) - "1.3.6.1.2.1.4.35" - ] - } - - # MAC address table - separate query (only on switches) - mac_query = %SnmpQuery{ - query_type: :WALK, - oids: [ - # BRIDGE-MIB dot1dTpFdbTable - "1.3.6.1.2.1.17.4.3" - ] - } - - # IP address tables - separate query - ip_query = %SnmpQuery{ - query_type: :WALK, - oids: [ - # IP-MIB ipAddrTable (IPv4) - "1.3.6.1.2.1.4.20", - # IP-MIB ipAddressTable (IPv6) - "1.3.6.1.2.1.4.34" - ] - } - - # HOST-RESOURCES-MIB - separate query (may not be supported) - host_resources_query = %SnmpQuery{ - query_type: :WALK, - oids: [ - # hrProcessorTable - "1.3.6.1.2.1.25.3.3", - # hrStorageTable - "1.3.6.1.2.1.25.2.3" - ] - } - - base_queries ++ [neighbor_query, arp_query, mac_query, ip_query, host_resources_query] - end - - defp build_mikrotik_job(device) do - mikrotik_config = Devices.get_mikrotik_config(device) - - # Only build job if MikroTik API is enabled and has credentials - if mikrotik_config.enabled && mikrotik_config.username do - %AgentJob{ - job_id: "mikrotik:#{device.id}", - job_type: :MIKROTIK, - device_id: device.id, - mikrotik_device: %MikrotikDevice{ - ip: device.ip_address, - username: mikrotik_config.username, - password: mikrotik_config.password, - port: mikrotik_config.port, - use_ssl: mikrotik_config.use_ssl - }, - mikrotik_commands: build_mikrotik_commands() - } - end - end - - defp build_mikrotik_commands do - [ - # System identity - %MikrotikCommand{ - command: "/system/identity/print", - args: %{} - }, - # System resources - %MikrotikCommand{ - command: "/system/resource/print", - args: %{} - } - ] - end - defp process_and_log_snmp_result(socket, result) do case process_snmp_result(socket.assigns.organization_id, result, socket) do :ok -> @@ -1970,7 +1204,7 @@ defmodule ToweropsWeb.AgentChannel do defp process_polling_result(device, result, _socket) do Devices.update_snmp_poll_time(device) - # Successful SNMP poll means device is up - update status and resolve any stuck alerts + # Successful SNMP poll means device is up — update status and resolve any stuck alerts old_status = device.status new_status = :up @@ -2182,43 +1416,49 @@ defmodule ToweropsWeb.AgentChannel do end defp publish_sensor_readings_to_graphql(device, sensors, oid_values, timestamp) do - readings = - sensors - |> Enum.filter(fn sensor -> - normalized_oid = String.trim_leading(sensor.sensor_oid, ".") - Map.has_key?(oid_values, normalized_oid) - end) - |> Enum.map(fn sensor -> - normalized_oid = String.trim_leading(sensor.sensor_oid, ".") - raw_value = Map.get(oid_values, normalized_oid) - parsed = parse_float(raw_value) + readings = do_build_readings(sensors, oid_values, timestamp) + do_publish_readings(device, readings) + end - value = - if parsed do - divided = parsed / sensor.sensor_divisor - scaled = SensorScale.normalize(sensor.sensor_type, divided) - Float.round(scaled / 1.0, 2) - end + defp do_build_readings(sensors, oid_values, timestamp) do + sensors + |> Enum.filter(fn sensor -> + normalized_oid = String.trim_leading(sensor.sensor_oid, ".") + Map.has_key?(oid_values, normalized_oid) + end) + |> Enum.map(fn sensor -> + normalized_oid = String.trim_leading(sensor.sensor_oid, ".") + raw_value = Map.get(oid_values, normalized_oid) + parsed = parse_float(raw_value) - %{ - sensor_id: sensor.id, - sensor_type: sensor.sensor_type, - sensor_descr: sensor.sensor_descr, - sensor_unit: sensor.sensor_unit, - value: value, - checked_at: to_string(timestamp) - } - end) - |> Enum.reject(fn r -> is_nil(r.value) end) + value = + if parsed do + divided = parsed / sensor.sensor_divisor + scaled = SensorScale.normalize(sensor.sensor_type, divided) + Float.round(scaled / 1.0, 2) + end - if readings != [] do - Subscriptions.publish_sensor_readings( - device.id, - device.organization_id, - device.name, - readings - ) - end + %{ + sensor_id: sensor.id, + sensor_type: sensor.sensor_type, + sensor_descr: sensor.sensor_descr, + sensor_unit: sensor.sensor_unit, + value: value, + checked_at: to_string(timestamp) + } + end) + |> Enum.reject(fn r -> is_nil(r.value) end) + end + + defp do_publish_readings(_device, []), do: :ok + + defp do_publish_readings(device, readings) do + Subscriptions.publish_sensor_readings( + device.id, + device.organization_id, + device.name, + readings + ) end defp resolve_sensor_value(sensor, oid_values) do @@ -2289,7 +1529,7 @@ defmodule ToweropsWeb.AgentChannel do defp get_remote_ip(socket), do: RemoteIp.from_socket(socket) - # Debug logging helper - only logs when debug is enabled for this agent + # Debug logging helper — only logs when debug is enabled for this agent # Uses :info level since production logger filters out :debug defp maybe_debug_log(socket, message, metadata) do if socket.assigns[:debug_enabled] do diff --git a/lib/towerops_web/channels/agent_channel/heartbeat.ex b/lib/towerops_web/channels/agent_channel/heartbeat.ex new file mode 100644 index 00000000..4b28c234 --- /dev/null +++ b/lib/towerops_web/channels/agent_channel/heartbeat.ex @@ -0,0 +1,75 @@ +defmodule ToweropsWeb.AgentChannel.Heartbeat do + @moduledoc """ + Handles heartbeat processing for agent channels. + + Receives a decoded `AgentHeartbeat` protobuf struct and the current socket, + and returns `{:noreply, socket}` with updated heartbeat tracking. + + ## Rate-limiting + + Database writes are throttled to at most one per 30 seconds to prevent + flooding. In-memory heartbeat tracking (`last_heartbeat_at`) is always + updated on every heartbeat. + """ + + alias Towerops.Agent.AgentHeartbeat + alias Towerops.Agents + alias ToweropsWeb.RemoteIp + + @doc """ + Processes a decoded heartbeat message from an agent. + + Updates the `last_seen_at` timestamp in the database (rate-limited), + broadcasts a health event for real-time UI, and updates in-memory + heartbeat tracking on the socket. + """ + @spec process(AgentHeartbeat.t(), Phoenix.Socket.t()) :: {:noreply, Phoenix.Socket.t()} + def process(heartbeat, socket) do + now = DateTime.utc_now() + do_process(heartbeat, socket, now, socket.assigns[:last_heartbeat_db_update]) + end + + # No previous DB update — always persist to database + defp do_process(heartbeat, socket, now, nil) do + {:noreply, persist_heartbeat(heartbeat, socket, now)} + end + + # Previous DB update exists — only persist if stale (>30s) + defp do_process(heartbeat, socket, now, last_db_update) do + socket = do_process_with_staleness(heartbeat, socket, now, last_db_update) + {:noreply, socket} + end + + defp do_process_with_staleness(heartbeat, socket, now, last_db_update) do + do_process_stale(DateTime.diff(now, last_db_update) > 30, heartbeat, socket, now) + end + + defp do_process_stale(true, heartbeat, socket, now), do: persist_heartbeat(heartbeat, socket, now) + defp do_process_stale(false, _heartbeat, socket, now), do: Phoenix.Socket.assign(socket, :last_heartbeat_at, now) + + defp persist_heartbeat(heartbeat, socket, now) do + metadata = %{ + "version" => heartbeat.version, + "uptime_seconds" => heartbeat.uptime_seconds, + "arch" => heartbeat.arch + } + + _ = + Agents.update_agent_token_heartbeat( + socket.assigns.agent_token_id, + RemoteIp.from_socket(socket), + metadata + ) + + _ = + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "agents:health", + {:agent_heartbeat, socket.assigns.agent_token_id, socket.assigns.organization_id} + ) + + socket + |> Phoenix.Socket.assign(:last_heartbeat_at, now) + |> Phoenix.Socket.assign(:last_heartbeat_db_update, now) + end +end diff --git a/lib/towerops_web/channels/agent_channel/job_builder.ex b/lib/towerops_web/channels/agent_channel/job_builder.ex new file mode 100644 index 00000000..5a7d88d6 --- /dev/null +++ b/lib/towerops_web/channels/agent_channel/job_builder.ex @@ -0,0 +1,589 @@ +defmodule ToweropsWeb.AgentChannel.JobBuilder do + @moduledoc """ + Builds protobuf job messages for agent channels. + + Provides functions to construct `AgentJob` and `CheckList` protobuf messages + from device and check data, handling SNMP credential resolution, discovery + vs. polling job selection, and MikroTik backup job construction. + """ + + alias Towerops.Agent.AgentJob + alias Towerops.Agent.Check, as: CheckProto + alias Towerops.Agent.CheckList + alias Towerops.Agent.DnsCheckConfig + alias Towerops.Agent.HttpCheckConfig + alias Towerops.Agent.MikrotikCommand + alias Towerops.Agent.MikrotikDevice + alias Towerops.Agent.SnmpDevice + alias Towerops.Agent.SnmpQuery + alias Towerops.Agent.SslCheckConfig + alias Towerops.Agent.TcpCheckConfig + alias Towerops.Agents + alias Towerops.Devices + alias Towerops.Monitoring + + require Logger + + @doc """ + Builds all jobs for an agent based on assigned polling targets. + """ + @spec build_jobs_for_agent(Ecto.UUID.t()) :: [AgentJob.t()] + def build_jobs_for_agent(agent_token_id) do + agent_token_id + |> Agents.list_agent_polling_targets() + |> Enum.flat_map(&build_jobs_for_device/1) + end + + @doc """ + Builds all jobs for a single device (SNMP discovery/polling, MikroTik, ping). + """ + @spec build_jobs_for_device(map()) :: [AgentJob.t()] + def build_jobs_for_device(device) do + [] + |> maybe_put_snmp_job(device) + |> maybe_put_mikrotik_job(device) + |> maybe_put_ping_job(device) + |> Enum.reject(&is_nil/1) + end + + defp maybe_put_snmp_job(jobs, %{snmp_enabled: true} = device) do + [snmp_job_for_device(device) | jobs] + end + + defp maybe_put_snmp_job(jobs, _device), do: jobs + + defp snmp_job_for_device(device) do + do_snmp_job(needs_discovery?(device), device) + end + + defp do_snmp_job(true, device), do: build_discovery_job(device) + defp do_snmp_job(false, device), do: build_polling_job(device) + + defp maybe_put_mikrotik_job(jobs, %{snmp_enabled: true} = device) do + [mikrotik_conditional_job(device) | jobs] + end + + defp maybe_put_mikrotik_job(jobs, _device), do: jobs + + defp mikrotik_conditional_job(device) do + do_mikrotik_job(needs_discovery?(device), mikrotik_device?(device), device) + end + + defp do_mikrotik_job(true, true, device), do: build_mikrotik_job(device) + defp do_mikrotik_job(_, _, _device), do: nil + + defp maybe_put_ping_job(jobs, %{monitoring_enabled: true} = device) do + [build_ping_job(device) | jobs] + end + + defp maybe_put_ping_job(jobs, _device), do: jobs + + @doc """ + Determines if a device needs SNMP discovery. + Returns `true` if no SNMP device, no previous discovery, or last discovery >24h ago. + """ + @spec needs_discovery?(map()) :: boolean() + def needs_discovery?(device) do + is_nil(device.snmp_device) or + is_nil(device.last_discovery_at) or + max(DateTime.diff(DateTime.utc_now(), device.last_discovery_at, :hour), 0) > 24 + end + + @doc """ + Checks if a device is a MikroTik device based on SNMP discovery data. + """ + @spec mikrotik_device?(map()) :: boolean() + def mikrotik_device?(device) do + device.snmp_device && + (String.contains?(device.snmp_device.manufacturer || "", "MikroTik") || + String.contains?(device.snmp_device.sys_descr || "", "RouterOS")) + end + + @doc """ + Resolves SNMP credentials for a device. + + For SNMPv3, delegates to `Devices.get_snmpv3_config/1` for credential cascade. + For v1/v2c, returns community string and version. + """ + @spec resolve_snmp_credentials(map()) :: map() + def resolve_snmp_credentials(device) do + do_resolve_snmp_credentials(device.snmp_version, device) + end + + defp do_resolve_snmp_credentials("3", device), do: Devices.get_snmpv3_config(device) + + defp do_resolve_snmp_credentials(_version, device) do + %{ + community: Devices.resolve_snmp_community(device), + version: device.snmp_version + } + end + + @doc """ + Checks if SNMP credentials are present (not nil/empty). + """ + @spec credentials_present?(map()) :: boolean() + def credentials_present?(%{community: community}) when is_binary(community), do: community != "" + def credentials_present?(%{username: username}) when is_binary(username), do: username != "" + def credentials_present?(_), do: false + + @doc """ + Builds an `SnmpDevice` protobuf message with appropriate credentials for the given + device and SNMP config. + """ + @spec build_snmp_device_message(map(), map()) :: SnmpDevice.t() + def build_snmp_device_message(device, snmp_config) do + do_build_snmp_device_message(device.snmp_version, device, snmp_config) + end + + defp do_build_snmp_device_message("3", device, snmp_config) do + log_v3_device_build(device, snmp_config) + + %SnmpDevice{ + ip: device.ip_address, + version: device.snmp_version, + port: device.snmp_port || 161, + community: "", + v3_security_level: snmp_config.security_level || "", + v3_username: snmp_config.username || "", + v3_auth_protocol: snmp_config.auth_protocol || "", + v3_auth_password: snmp_config.auth_password || "", + v3_priv_protocol: snmp_config.priv_protocol || "", + v3_priv_password: snmp_config.priv_password || "" + } + end + + defp do_build_snmp_device_message(_version, device, snmp_config) do + community = snmp_config.community || "" + version = effective_snmp_version(device.snmp_version) + + %SnmpDevice{ + ip: device.ip_address, + version: version, + port: device.snmp_port || 161, + community: community + } + end + + defp effective_snmp_version("1"), do: "1" + defp effective_snmp_version(_), do: "2c" + + defp log_v3_device_build(device, snmp_config) do + auth_password_present = + is_binary(snmp_config.auth_password) and snmp_config.auth_password != "" + + priv_password_present = + is_binary(snmp_config.priv_password) and snmp_config.priv_password != "" + + Logger.info( + "Building SNMPv3 device message", + device_id: device.id, + device_name: device.name, + device_ip: device.ip_address, + security_level: snmp_config.security_level || "", + username: snmp_config.username || "", + auth_protocol: snmp_config.auth_protocol || "", + auth_password_present: auth_password_present, + priv_protocol: snmp_config.priv_protocol || "", + priv_password_present: priv_password_present + ) + end + + @doc """ + Builds a discovery job for a device. + """ + @spec build_discovery_job(map()) :: AgentJob.t() + def build_discovery_job(device) do + snmp_credentials = resolve_snmp_credentials(device) + + %AgentJob{ + job_id: "discover:#{device.id}", + job_type: :DISCOVER, + device_id: device.id, + snmp_device: build_snmp_device_message(device, snmp_credentials), + queries: build_discovery_queries() + } + end + + @doc """ + Builds a polling job for a device. + """ + @spec build_polling_job(map()) :: AgentJob.t() + def build_polling_job(device) do + snmp_credentials = resolve_snmp_credentials(device) + + %AgentJob{ + job_id: "poll:#{device.id}", + job_type: :POLL, + device_id: device.id, + snmp_device: build_snmp_device_message(device, snmp_credentials), + queries: build_polling_queries(device) + } + end + + @doc """ + Builds a ping job for a device. + """ + @spec build_ping_job(map()) :: AgentJob.t() + def build_ping_job(device) do + %AgentJob{ + job_id: "ping:#{device.id}", + job_type: :PING, + device_id: device.id, + snmp_device: %SnmpDevice{ + ip: to_string(device.ip_address), + port: 0, + version: "", + community: "" + }, + queries: [] + } + end + + @doc """ + Builds a live polling job with only requested sensor OIDs. + """ + @spec build_live_polling_job(map(), [String.t()], String.t()) :: AgentJob.t() + def build_live_polling_job(device, sensor_oids, reply_topic) do + snmp_credentials = resolve_snmp_credentials(device) + + %AgentJob{ + job_id: "live_poll:#{device.id}:#{reply_topic}", + job_type: :POLL, + device_id: device.id, + snmp_device: build_snmp_device_message(device, snmp_credentials), + queries: [ + %SnmpQuery{ + query_type: :GET, + oids: sensor_oids + } + ] + } + end + + @doc """ + Builds the list of SNMP queries for device discovery. + """ + @spec build_discovery_queries() :: [SnmpQuery.t()] + def build_discovery_queries do + [ + %SnmpQuery{ + query_type: :GET, + oids: [ + "1.3.6.1.2.1.1.1.0", + "1.3.6.1.2.1.1.2.0", + "1.3.6.1.2.1.1.3.0", + "1.3.6.1.2.1.1.4.0", + "1.3.6.1.2.1.1.5.0", + "1.3.6.1.2.1.1.6.0" + ] + }, + %SnmpQuery{query_type: :WALK, oids: ["1.3.6.1.2.1.2.2.1"]}, + %SnmpQuery{query_type: :WALK, oids: ["1.3.6.1.2.1.31.1.1.1"]}, + %SnmpQuery{ + query_type: :WALK, + oids: [ + "1.3.6.1.2.1.99.1.1.1", + "1.3.6.1.2.1.47.1.1.1.1.2", + "1.3.6.1.2.1.47.1.1.1.1.7", + "1.3.6.1.2.1.47.1.1.1.1.5" + ] + }, + %SnmpQuery{query_type: :WALK, oids: ["1.3.6.1.2.1.131.1.1.1.1"]}, + %SnmpQuery{ + query_type: :WALK, + oids: [ + "1.3.6.1.2.1.25.3.3", + "1.3.6.1.2.1.25.2.3", + "1.3.6.1.2.1.25.3.2" + ] + }, + %SnmpQuery{ + query_type: :GET, + oids: [ + "1.3.6.1.4.1.2021.11.9.0", + "1.3.6.1.4.1.2021.11.10.0", + "1.3.6.1.4.1.2021.11.11.0" + ] + }, + %SnmpQuery{ + query_type: :WALK, + oids: [ + "1.3.6.1.4.1.9.9.109", + "1.3.6.1.4.1.9.9.13", + "1.3.6.1.4.1.9.9.23", + "1.3.6.1.4.1.9.9.618", + "1.3.6.1.4.1.14988", + "1.3.6.1.4.1.41112", + "1.3.6.1.4.1.2636", + "1.3.6.1.4.1.25506", + "1.3.6.1.4.1.12356", + "1.3.6.1.4.1.17713" + ] + }, + %SnmpQuery{ + query_type: :WALK, + oids: [ + "1.0.8802.1.1.2.1.4.1.1", + "1.3.6.1.4.1.9.9.23" + ] + }, + %SnmpQuery{ + query_type: :WALK, + oids: [ + "1.3.6.1.2.1.4.20", + "1.3.6.1.2.1.4.34" + ] + } + ] + end + + @doc """ + Builds the list of SNMP queries for regular polling of a device. + """ + @spec build_polling_queries(map()) :: [SnmpQuery.t()] + def build_polling_queries(device) do + sensor_oids = Enum.map(device.snmp_device.sensors, & &1.sensor_oid) + + interface_oids = + Enum.flat_map(device.snmp_device.interfaces, fn iface -> + idx = iface.if_index + + [ + "1.3.6.1.2.1.31.1.1.1.6.#{idx}", + "1.3.6.1.2.1.31.1.1.1.10.#{idx}", + "1.3.6.1.2.1.2.2.1.10.#{idx}", + "1.3.6.1.2.1.2.2.1.16.#{idx}", + "1.3.6.1.2.1.2.2.1.14.#{idx}", + "1.3.6.1.2.1.2.2.1.20.#{idx}", + "1.3.6.1.2.1.2.2.1.13.#{idx}", + "1.3.6.1.2.1.2.2.1.19.#{idx}" + ] + end) + + base_queries = [ + %SnmpQuery{ + query_type: :GET, + oids: sensor_oids ++ interface_oids + } + ] + + neighbor_query = %SnmpQuery{ + query_type: :WALK, + oids: [ + "1.0.8802.1.1.2.1.4.1.1", + "1.3.6.1.4.1.9.9.23" + ] + } + + arp_query = %SnmpQuery{ + query_type: :WALK, + oids: [ + "1.3.6.1.2.1.4.22", + "1.3.6.1.2.1.4.35" + ] + } + + mac_query = %SnmpQuery{ + query_type: :WALK, + oids: [ + "1.3.6.1.2.1.17.4.3" + ] + } + + ip_query = %SnmpQuery{ + query_type: :WALK, + oids: [ + "1.3.6.1.2.1.4.20", + "1.3.6.1.2.1.4.34" + ] + } + + host_resources_query = %SnmpQuery{ + query_type: :WALK, + oids: [ + "1.3.6.1.2.1.25.3.3", + "1.3.6.1.2.1.25.2.3" + ] + } + + base_queries ++ [neighbor_query, arp_query, mac_query, ip_query, host_resources_query] + end + + @doc """ + Builds a MikroTik job for a device (only when enabled and has credentials). + Returns `nil` if MikroTik is not configured. + """ + @spec build_mikrotik_job(map()) :: AgentJob.t() | nil + def build_mikrotik_job(device) do + do_build_mikrotik_job(Devices.get_mikrotik_config(device), device) + end + + defp do_build_mikrotik_job(%{enabled: true, username: username}, device) when is_binary(username) do + config = Devices.get_mikrotik_config(device) + + %AgentJob{ + job_id: "mikrotik:#{device.id}", + job_type: :MIKROTIK, + device_id: device.id, + mikrotik_device: %MikrotikDevice{ + ip: device.ip_address, + username: config.username, + password: config.password, + port: config.port, + use_ssl: config.use_ssl + }, + mikrotik_commands: build_mikrotik_commands() + } + end + + defp do_build_mikrotik_job(_config, _device), do: nil + + @doc """ + Builds the list of MikroTik commands for device interrogation. + """ + @spec build_mikrotik_commands() :: [MikrotikCommand.t()] + def build_mikrotik_commands do + [ + %MikrotikCommand{ + command: "/system/identity/print", + args: %{} + }, + %MikrotikCommand{ + command: "/system/resource/print", + args: %{} + } + ] + end + + @doc """ + Builds a MikroTik backup job for a device. + """ + @spec build_backup_job(map(), String.t()) :: AgentJob.t() + def build_backup_job(device, job_id) do + mikrotik_config = Devices.get_mikrotik_config(device) + backup_filename = "towerops-backup-#{DateTime.to_unix(DateTime.utc_now())}" + chunk_size = 32_768 + num_chunks = 10 + + read_commands = + for i <- 0..(num_chunks - 1) do + %MikrotikCommand{ + command: "/file/read", + args: %{ + "file" => "#{backup_filename}.rsc", + "offset" => to_string(i * chunk_size), + "chunk-size" => to_string(chunk_size) + } + } + end + + %AgentJob{ + job_id: job_id, + job_type: :MIKROTIK, + device_id: device.id, + mikrotik_device: %MikrotikDevice{ + ip: device.ip_address || "", + username: mikrotik_config.username || "", + password: mikrotik_config.password || "", + port: mikrotik_config.port || 8729, + ssh_port: mikrotik_config.ssh_port || 22, + use_ssl: mikrotik_config.use_ssl || false + }, + mikrotik_commands: + [ + %MikrotikCommand{ + command: "/export", + args: %{"file" => backup_filename, "compact" => "true"} + } + ] ++ + read_commands ++ + [ + %MikrotikCommand{ + command: "/file/remove", + args: %{"numbers" => "#{backup_filename}.rsc"} + } + ] + } + end + + @doc """ + Builds a `CheckProto` protobuf message from a monitoring check. + """ + @spec build_check_protobuf(map()) :: CheckProto.t() + def build_check_protobuf(check) do + config = check.config || %{} + + base_fields = [ + id: check.id, + check_type: check.check_type, + interval_seconds: check.interval_seconds, + timeout_ms: check.timeout_ms + ] + + struct!(CheckProto, base_fields ++ check_type_config(check.check_type, config)) + end + + defp check_type_config("http", config) do + [ + http: %HttpCheckConfig{ + url: config["url"] || "", + method: config["method"] || "GET", + expected_status: config["expected_status"] || 200, + verify_ssl: config["verify_ssl"] != false, + regex: config["regex"] || "", + follow_redirects: config["follow_redirects"] != false + } + ] + end + + defp check_type_config("tcp", config) do + [ + tcp: %TcpCheckConfig{ + host: config["host"] || "", + port: config["port"] || 0, + send: config["send"] || "", + expect: config["expect"] || "" + } + ] + end + + defp check_type_config("dns", config) do + [ + dns: %DnsCheckConfig{ + hostname: config["hostname"] || "", + server: config["server"] || "", + record_type: config["record_type"] || "A", + expected: config["expected"] || "" + } + ] + end + + defp check_type_config("ssl", config) do + [ + ssl: %SslCheckConfig{ + host: config["host"] || "", + port: config["port"] || 443, + warning_days: config["warning_days"] || 30 + } + ] + end + + defp check_type_config(_, _config), do: [] + + @doc """ + Lists enabled checks for an agent. + """ + @spec list_checks_for_agent(Ecto.UUID.t()) :: [map()] + def list_checks_for_agent(agent_token_id) do + Monitoring.list_checks_for_agent(agent_token_id, enabled: true) + end + + @doc """ + Builds a `CheckList` protobuf message from a list of checks. + """ + @spec build_check_list([map()]) :: CheckList.t() + def build_check_list(checks) do + %CheckList{checks: Enum.map(checks, &build_check_protobuf/1)} + end +end diff --git a/lib/towerops_web/channels/agent_channel/subscriptions.ex b/lib/towerops_web/channels/agent_channel/subscriptions.ex new file mode 100644 index 00000000..3f5aea4f --- /dev/null +++ b/lib/towerops_web/channels/agent_channel/subscriptions.ex @@ -0,0 +1,92 @@ +defmodule ToweropsWeb.AgentChannel.Subscriptions do + @moduledoc """ + PubSub subscription management for agent channels. + + Handles all PubSub topic subscriptions and event broadcasting for agent + lifecycle events (connection, disconnection, heartbeat). + """ + + @doc """ + Subscribes the current process to all PubSub topics relevant to the given + agent token. + + Topics subscribed: + - `agent:{id}:assignments` — device assignment changes + - `agent:{id}:discovery` — discovery requests + - `agent:{id}:backup` — backup requests + - `agent:{id}:credential_test` — credential test requests + - `agent:{id}:live_poll` — live poll requests + - `checks:agent:{id}` — check changes + - `agent:{id}:lifecycle` — token lifecycle events + - `agent:{id}:latency_probe` — latency probes (cloud pollers only) + """ + @spec subscribe_all(map()) :: :ok + def subscribe_all(agent_token) do + id = agent_token.id + + _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{id}:assignments") + _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{id}:discovery") + _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{id}:backup") + _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{id}:credential_test") + _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{id}:live_poll") + _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "checks:agent:#{id}") + _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{id}:lifecycle") + + subscribe_latency_if_cloud_poller(agent_token) + + :ok + end + + # Cloud pollers additionally subscribe to latency probe requests + defp subscribe_latency_if_cloud_poller(%{is_cloud_poller: true} = agent_token) do + _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token.id}:latency_probe") + :ok + end + + defp subscribe_latency_if_cloud_poller(_agent_token), do: :ok + + @doc """ + Broadcasts an agent connection event to the `agents:health` topic. + """ + @spec broadcast_connection(Ecto.UUID.t(), Ecto.UUID.t()) :: :ok + def broadcast_connection(agent_token_id, organization_id) do + _ = + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "agents:health", + {:agent_connected, agent_token_id, organization_id} + ) + + :ok + end + + @doc """ + Broadcasts an agent disconnection event to the `agents:health` topic. + """ + @spec broadcast_disconnection(Ecto.UUID.t(), Ecto.UUID.t()) :: :ok + def broadcast_disconnection(agent_token_id, organization_id) do + _ = + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "agents:health", + {:agent_disconnected, agent_token_id, organization_id} + ) + + :ok + end + + @doc """ + Broadcasts an agent heartbeat event to the `agents:health` topic. + """ + @spec broadcast_heartbeat(Ecto.UUID.t(), Ecto.UUID.t()) :: :ok + def broadcast_heartbeat(agent_token_id, organization_id) do + _ = + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "agents:health", + {:agent_heartbeat, agent_token_id, organization_id} + ) + + :ok + end +end diff --git a/lib/towerops_web/live/help_live/index.ex b/lib/towerops_web/live/help_live/index.ex index a6673073..cbd59619 100644 --- a/lib/towerops_web/live/help_live/index.ex +++ b/lib/towerops_web/live/help_live/index.ex @@ -6,6 +6,21 @@ defmodule ToweropsWeb.HelpLive.Index do alias Towerops.Accounts.Scope alias Towerops.HTTP alias Towerops.Organizations + alias ToweropsWeb.HelpLive.Sections.About + alias ToweropsWeb.HelpLive.Sections.Agents + alias ToweropsWeb.HelpLive.Sections.ApiTokens + alias ToweropsWeb.HelpLive.Sections.CloudPollers + alias ToweropsWeb.HelpLive.Sections.GettingStarted + alias ToweropsWeb.HelpLive.Sections.GraphqlApi + alias ToweropsWeb.HelpLive.Sections.Graphs + alias ToweropsWeb.HelpLive.Sections.Insights + alias ToweropsWeb.HelpLive.Sections.Integrations + alias ToweropsWeb.HelpLive.Sections.Mikrotik + alias ToweropsWeb.HelpLive.Sections.NetworkMap + alias ToweropsWeb.HelpLive.Sections.NotFound + alias ToweropsWeb.HelpLive.Sections.RestApi + alias ToweropsWeb.HelpLive.Sections.Settings + alias ToweropsWeb.HelpLive.Sections.Sites @impl true def mount(_params, session, socket) do @@ -118,20 +133,6 @@ defmodule ToweropsWeb.HelpLive.Index do end end - slot :inner_block, required: true - - defp code(assigns) do - ~H""" - - {render_slot(@inner_block)} - - """ - end - - attr :active_section, :string, required: true - attr :generated_password, :string, default: nil - attr :password_generating, :boolean, default: false - defp help_content(assigns) do ~H"""
- Towerops is a modern network monitoring and management platform designed to provide - comprehensive visibility into your network infrastructure. It came out of a need to simplify - network monitoring and alerting while running a wireless ISP. -
- -- Our promise to you: -
- Think of Towerops as combining the best of LibreNMS, Icinga2/Nagios, and PagerDuty into - a single, unified platform. You get deep network device monitoring with SNMP auto-discovery, - flexible health checks and alerting, and intelligent incident management—all with a modern - interface that makes complex monitoring workflows simple and accessible. -
- -- Towerops is not and has no plans to be a replacement for a full WISP/network billing system. -
- -- Like LibreNMS, discover and monitor network devices via SNMP with support for - multi-vendor equipment, interface statistics, and topology mapping. -
-- Like Icinga2/Nagios, configure flexible monitoring checks with customizable thresholds - and alert conditions for any metric or device state. -
-- Like PagerDuty, manage incidents with intelligent alerting, escalation policies, - and notification routing to keep your team informed. -
-- Ready to get started? Check out the - <.link - patch={~p"/help?section=getting-started"} - class="underline hover:text-blue-900 dark:hover:text-blue-200" - > - Getting Started - - guide to begin monitoring your network infrastructure. -
-- Towerops is a network monitoring platform that helps you keep track of your network devices, - monitor their health, and get alerts when issues occur. -
- -- Sites represent physical or logical locations where your network devices are located. - If you manage multiple locations, start by creating a site for your office, data center, - or any location where you have equipment. -
-- Navigate to - <.code>Sites - → - <.code>Add Site -
-- Sites are disabled by default and can be enabled later in - <.link - patch={~p"/help?section=settings"} - class="underline hover:text-gray-700 dark:hover:text-gray-300" - > - Organization Settings - - → General tab. -
-- Add network devices like routers, switches, access points, or servers. You'll need the device's - IP address or hostname, and SNMP community string (for SNMP-enabled devices). -
-- Navigate to - <.code>Devices - → - <.code>Add Device -
-- Click on any device to view real-time metrics including CPU usage, memory, temperature, - interface traffic, and more. Explore the different tabs to see network topology, port status, - and historical performance data. -
-- For devices behind firewalls or in remote locations, you can deploy a remote poller - to monitor devices locally without exposing them to the internet. See the - <.link - patch={~p"/help?section=agents"} - class="underline hover:text-blue-900 dark:hover:text-blue-200" - > - Remote Pollers - - section for more information. -
-- Sites are an optional organizational feature that lets you group devices by physical or logical - locations such as offices, datacenters, customer locations, or network zones. Sites are disabled - by default to keep things simple for single-location deployments. -
- -- Consider enabling sites if you: -
- -- You can skip enabling sites if you: -
- -- Sites are optional and disabled by default. You can start without sites and enable them - later if your needs change. When sites are disabled, all devices belong directly to - your organization. -
-- When sites are enabled, devices can be organized into sites, and credentials (SNMP communities, - usernames, passwords) can be set at three levels: -
- -- Default credentials that apply to all devices across all sites in your organization. -
-- Override organization defaults for all devices at a specific site. Useful when different - locations have different network configurations. -
-- Override organization or site credentials for a specific device. Useful for devices with - unique configurations. -
-- Go to - <.code>Settings - → - <.code>Organization - and scroll to the "Site Organization" section. -
-- Check the box for "Use sites to organize devices" and save your settings. -
-- Navigate to - <.code>Sites - → - <.code>Add Site - to create your first site. Give it a descriptive name like "Main Office" or "Dallas Datacenter". -
-- When creating or editing devices, you can now assign them to specific sites. The site - selector will appear in the device form. -
-- If you disable sites after enabling them, all devices will be removed from their sites - and assigned directly to the organization. Site assignments will be lost, but devices - will remain in your organization and continue to be monitored. -
-- If you have publicly reachable devices, they will be automatically polled from one of our cloud pollers unless overridden by your settings. - Our cloud infrastructure monitors your devices without requiring any additional setup or agent installation. -
- -| - Location - | -- IPv4 Address - | -- IPv6 Address - | -
|---|---|---|
| - DFW (Dallas-Fort Worth) - | -- 144.202.64.79 - | -- 2001:19f0:6401:0af2:5400:05ff:fee7:1bfb - | -
- If your devices are behind a firewall, make sure to allow SNMP traffic (UDP port 161) and ICMP (for ping monitoring) from these IP addresses. - For devices not accessible from the public internet, consider using a - <.link - patch={~p"/help?section=agents"} - class="underline hover:text-blue-900 dark:hover:text-blue-200" - > - Remote Poller - - instead. -
-- Remote pollers are lightweight Docker containers that run on your network to monitor devices - that aren't accessible from the public internet — behind firewalls, on private networks, or - at remote locations. They use minimal resources (1 CPU core, 512 MB RAM) and auto-update - themselves via Watchtower. Just deploy a - <.code>docker-compose.yml - and you're done. -
- -- Remote pollers can run on any system that supports Docker Compose, including: -
- -- Minimum requirements: - 1 CPU core, 512 MB RAM, and network access to your devices -
-- Navigate to the - <.code>Agents - page in Towerops and create a new agent token. This token authenticates your remote poller with the Towerops platform. -
-- When you create a new agent on the - <.code>Agents - page, Towerops will show you a ready-to-use - <.code>docker-compose.yml - with your agent token pre-filled. It looks like this: -
-<%= raw(~S[services:
- towerops-agent:
- image: codeberg.org/towerops-agent/towerops-agent:latest
- container_name: towerops-agent
- restart: unless-stopped
- environment:
- - TOWEROPS_API_URL=https://app.towerops.net/
- - TOWEROPS_AGENT_TOKEN=your-token-here
- labels:
- - "com.centurylinklabs.watchtower.enable=true"
- - "com.centurylinklabs.watchtower.scope=towerops"
-
- watchtower:
- image: containrrr/watchtower:latest
- container_name: towerops-watchtower
- restart: unless-stopped
- environment:
- - WATCHTOWER_POLL_INTERVAL=43200
- - WATCHTOWER_LABEL_ENABLE=true
- - WATCHTOWER_SCOPE=towerops
- - WATCHTOWER_CLEANUP=true
- volumes:
- - /var/run/docker.sock:/var/run/docker.sock]) %>
- - The agent is lightweight — it uses minimal CPU and memory. Watchtower is included - to automatically update the agent every 12 hours, so you never have to think about it. -
-- Copy the - <.code>docker-compose.yml - to your server and run: -
-docker compose up -d
- - That's it. The agent will connect to Towerops within seconds and start polling your local devices. - You'll see it come online on the Agents page. -
-- You can assign devices to your remote poller in several ways: -
-- Devices assigned to a remote poller will be monitored from your local network instead - of from the cloud. -
-- Remote pollers use secure WebSocket connections to communicate with Towerops. - Your agent token is encrypted and should be kept confidential. The remote poller - only needs outbound HTTPS access (port 443) to connect to Towerops - no inbound - ports need to be opened on your firewall. -
-- You can view the status of your remote pollers on the Agents page. The page shows: -
- -- If a remote poller goes offline, devices assigned to it will not be monitored until - the poller comes back online. Consider setting up monitoring alerts for your remote - pollers to be notified if they disconnect. -
-- Towerops provides powerful visualization capabilities for all monitored metrics. Every sensor, - interface, and metric can be viewed as a time-series graph with multiple time ranges and a - real-time live polling mode for instant feedback. -
- -- Graphs are available throughout Towerops wherever metrics are displayed: -
- -- All graphs support multiple time ranges for analyzing trends at different scales: -
- -- Recent activity with high detail -
-- Half-day trends and patterns -
-- Business day overview -
-- Full day of activity -
-- Weekly trends and patterns -
-- Monthly overview and capacity planning -
-- Live mode provides real-time sensor monitoring with data updating every second. This is perfect for: -
- -- Navigate to any device and click on a metric graph (CPU, Memory, Temperature, Traffic, etc.) -
-- The Live button has a distinctive green gradient style and will pulse when active -
-- The graph will start updating every second with fresh data. A pulsing green indicator - shows that live polling is active. -
-- Click any other time range button to stop live polling and view historical data -
-- Real-time CPU load and utilization -
-- RAM utilization percentage -
-- Device and component temperatures -
-- Power supply voltages -
-- Disk usage and capacity -
-- Sessions, connections, and counts -
-- Live mode is currently only available for sensor metrics (CPU, memory, temperature, etc.). - Interface traffic graphs use historical data only, as traffic calculations require - comparing multiple SNMP polls over time. -
-- When viewing aggregate metrics (like "Temperature" for all sensors), the graph - automatically displays all sensors of that type with different colors for easy comparison. -
-- Historical graphs (non-live) display the maximum and minimum values for the selected - time range at the bottom of the chart for quick reference. -
-- Interface traffic graphs show outbound traffic as positive values (above zero) and - inbound traffic as negative values (below zero) for easy visualization of bidirectional flow. -
-- Traffic graphs automatically scale units (bps, Kbps, Mbps, Gbps) based on the data - range for optimal readability. Hover over data points to see exact values. -
-- Live polling makes direct SNMP requests every second. While this provides instant - feedback, keeping many live graphs open simultaneously may impact device performance. - Use live mode for troubleshooting and testing, then switch back to historical ranges - for routine monitoring. -
-- Organization Settings is the central hub for configuring your Towerops organization. Access it - from the main navigation under <.code>Organization Settings. The settings page uses a - tabbed interface to organize different configuration areas. -
- -- The General tab lets you manage basic organization properties: -
- -- Configure organization-wide SNMP defaults used for device polling: -
- -- SNMP credentials follow a hierarchy: Device > Site > Organization. - Device-level settings take the highest priority, followed by site-level, then - organization-level defaults. This lets you set sensible defaults while overriding - for specific locations or devices. -
-- Use the Force Apply - button to push - the organization's SNMP settings to all devices, overriding any device or site-level - customizations. -
- -- Configure organization-wide SSH credentials for MikroTik device monitoring. This tab is - only accessible to superusers. See the - <.link - patch={~p"/help?section=mikrotik"} - class="underline hover:text-blue-900 dark:hover:text-blue-200 text-blue-700 dark:text-blue-300" - > - MikroTik - - section for detailed setup instructions. -
- -- Manage remote poller (agent) assignments for your organization: -
- -- Use the Force Apply - button to push - the default agent assignment to all devices, overriding any device or site-level agent - selections. -
- -- Connect Towerops with third-party services. See the - <.link - patch={~p"/help?section=integrations"} - class="underline hover:text-blue-900 dark:hover:text-blue-200 text-blue-700 dark:text-blue-300" - > - Integrations - - section for full details on available integrations and how to configure them. -
-- Integrations allow you to connect Towerops with third-party services to enrich your - monitoring data, sync subscriber information, and streamline your workflow. Configure - integrations from - <.code>Organization Settings - → - <.code>Integrations - tab. -
- -- - Preseem - - is a Quality of Experience (QoE) monitoring platform designed for WISPs and broadband providers. - The Preseem integration syncs subscriber and access point QoE data into Towerops, giving you - a unified view of network health alongside device monitoring. -
- -- Log in to your Preseem dashboard and generate an API key from your account settings. -
-- Navigate to - <.code>Organization Settings - → - <.code>Integrations - tab. - Enter your Preseem API key and configure the sync interval. -
-- Use the - Test Connection - button - to verify your API key is valid and Towerops can reach the Preseem API. -
-- Preseem data is synced automatically on a periodic schedule based on your configured - sync interval. You can also trigger a manual sync at any time from the integration settings. -
-- - Gaiia - - is a billing and subscriber management platform. The Gaiia integration syncs subscriber data, - service plans, and entity mappings into Towerops, enabling you to correlate network issues with - specific customers and services. -
- -- Navigate to - <.code>Organization Settings - → - <.code>Integrations - tab - and enter your Gaiia API credentials. -
-- Configure a webhook in your Gaiia account pointing to the webhook URL provided - in the integration settings. Webhooks enable real-time updates when subscriber - data changes in Gaiia. -
-- After the initial sync, review the entity mapping to ensure Gaiia subscribers are - correctly matched to Towerops devices. Use the reconciliation tools to resolve - any mismatches. -
-- Gaiia data is synced through two mechanisms: automatic periodic syncs pull the full - dataset on a schedule, while real-time webhooks push individual changes as they happen - in Gaiia. This ensures your data stays current without excessive API calls. -
-- - PagerDuty - - is an incident management and on-call alerting platform. The PagerDuty integration provides - 2-way alert sync — when a device goes down in Towerops, a PagerDuty incident is automatically - triggered. Acknowledging or resolving the alert in Towerops updates the PagerDuty incident as well. -
- -- In PagerDuty, go to - <.code>Services - → select your service → - <.code>Integrations - tab → - <.code>Add Integration - → choose <.code>Events API v2. Copy the - <.code>Integration Key - (routing key). -
-- Navigate to - <.code>Organization Settings - → - <.code>Integrations - tab → click - <.code>Configure - on PagerDuty. Paste your integration key and test the connection. -
-- Unlike other integrations, PagerDuty is event-driven — there is no periodic sync. - Alerts are sent to PagerDuty in real time as they occur. No polling or sync interval is needed. -
-- Every integration includes a Test Connection button that validates your credentials - and confirms Towerops can communicate with the external service before saving. -
-- The Integrations tab shows the last sync time, sync status, and any errors for - each configured integration. -
-- Towerops supports read-only monitoring of MikroTik RouterOS devices via SSH. - In addition to SNMP polling, SSH access allows Towerops to retrieve detailed system information - and create configuration backups of your MikroTik devices for disaster recovery and audit purposes. -
- -- Towerops currently operates in read-only mode for MikroTik devices. Configuration changes, - reboots, and other administrative actions are not supported. This ensures your device - configurations remain unchanged by monitoring operations. -
-- Both SNMP polling and SSH connections to MikroTik devices can be performed from either - Towerops cloud infrastructure or your remote poller (agent). For publicly accessible devices, - cloud polling is the simplest option. For devices on private networks or when you prefer - to keep SSH credentials on your local network, deploy a remote poller. The device assignment - determines which poller handles both SNMP and SSH operations. -
-- For security purposes, it is strongly recommended to create a dedicated read-only user account - for Towerops rather than using an administrator account. This follows the principle of least - privilege and minimizes potential security risks. Even though Towerops only performs read-only - operations, limiting the account permissions provides defense in depth. -
- -- Connect to your MikroTik device via SSH or terminal and execute the following commands to - create a read-only user named <.code>towerops: -
- -- Step 1: Create a new user group with read-only permissions -
-
- /user group add name=readonly policy=ssh,read,test,api
-
-
-
- - Step 2: Create the monitoring user with a strong password -
-
- /user add name=towerops password={if @generated_password,
- do: @generated_password,
- else: "YOUR_STRONG_PASSWORD"} group=readonly
-
- <%= if @generated_password do %>
-
-
- <% end %>
- - ⚠️ This truly random password from random.org will only be shown once! -
- <% end %> -- Step 3: Verify the user was created successfully -
-
- /user print detail where name=towerops
-
-
-
- - The - <.code>readonly - group includes the following permissions. Note that while these - permissions are granted to the user account, Towerops only performs read operations and does - not make any configuration changes: -
- -| - Permission - | -- Description - | -
|---|---|
| - ssh - | -- Allow SSH access to the device - | -
| - read - | -- Allow viewing configuration and status (read-only) - | -
| - test - | -- Allow executing diagnostic commands (ping, traceroute, etc.) - | -
| - api - | -- Allow API access for automated monitoring - | -
- After creating the read-only user, configure SSH credentials in Towerops: -
- -- Navigate to - <.code>Organization Settings - → - <.code>MikroTik tab - to set default - SSH credentials for all MikroTik devices in your organization. -
-- Navigate to - <.code>Sites - → select a site → - <.code>Edit - to override - credentials for all devices at a specific location. -
-- When editing a MikroTik device, you can specify unique SSH credentials that override - organization and site defaults. -
-- Network Insights provides proactive observations about your network health, gathered automatically from all connected data sources. -
- -- Towerops continuously analyzes data from SNMP polling, Preseem, Gaiia, and other integrations to surface actionable findings. Insights are categorized by source, urgency, and type so you can focus on what matters. -
- -- Use the filter bar to narrow insights by status (active/dismissed), source (Preseem, Gaiia, SNMP, system), and urgency level. Each insight shows affected devices as clickable links so you can drill directly into the device detail page. -
-- Dismiss insights you've reviewed to keep the active list clean. Bulk actions let you select multiple insights at once. -
- -- Tip: - Insights run on a nightly schedule. Connect your Gaiia and Preseem integrations in - <.link navigate={~p"/help?section=integrations"} class="underline"> - Organization Settings - - to get the most comprehensive insights. -
-- The Network Map provides a geographic view of your sites on an interactive map. Sites with latitude and longitude coordinates are displayed as markers that you can click to view details. -
- -- Each site can have an address and/or latitude/longitude coordinates. You can set these in two ways: -
-
- Address-to-coordinate conversion requires a Google Maps Geocoding API key. Your administrator sets this as the
-
- GOOGLE_MAPS_API_KEY
-
- environment variable on the server. The map display itself uses OpenStreetMap and requires no API key.
-
- API tokens allow external applications and scripts to authenticate with the Towerops REST and GraphQL APIs. Each token is scoped to an organization and can be managed from your user settings. -
- -
- Include the token in the
-
- Authorization
-
- header:
-
Authorization: Bearer your-api-token-here
- - See also: - <.link navigate={~p"/help?section=rest-api"} class="underline">REST API - and - <.link navigate={~p"/help?section=graphql-api"} class="underline"> - GraphQL API - - for endpoint documentation. -
-
- The Towerops REST API provides programmatic access to your monitoring data. All endpoints are under
-
- /api/v1/
-
- and require a valid API token.
-
<%= raw("curl -H \"Authorization: Bearer YOUR_TOKEN\" \\\n https://app.towerops.net/api/v1/devices") %>
-
- All responses use
-
- {raw("{\"data\": ...}")}
-
- format. Results are scoped to the organization associated with your API token.
-
- Full reference: - REST API Documentation → - — complete endpoint reference with request/response examples. -
-
- The GraphQL API lets you query exactly the data you need in a single request. The endpoint is
-
- POST /api/graphql
-
- and uses the same API token authentication as the REST API.
-
<%= raw("curl -X POST https://app.towerops.net/api/graphql \\\n -H \"Authorization: Bearer YOUR_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"query\": \"{ devices { id name ipAddress status } }\"}'") %>
- - Full reference: - - GraphQL API Documentation → - - — complete schema reference with query examples and variable usage. -
-- The requested help section could not be found. -
-+ Towerops is a modern network monitoring and management platform designed to provide + comprehensive visibility into your network infrastructure. It came out of a need to simplify + network monitoring and alerting while running a wireless ISP. +
+ ++ Our promise to you: +
+ Think of Towerops as combining the best of LibreNMS, Icinga2/Nagios, and PagerDuty into + a single, unified platform. You get deep network device monitoring with SNMP auto-discovery, + flexible health checks and alerting, and intelligent incident management—all with a modern + interface that makes complex monitoring workflows simple and accessible. +
+ ++ Towerops is not and has no plans to be a replacement for a full WISP/network billing system. +
+ ++ Like LibreNMS, discover and monitor network devices via SNMP with support for + multi-vendor equipment, interface statistics, and topology mapping. +
++ Like Icinga2/Nagios, configure flexible monitoring checks with customizable thresholds + and alert conditions for any metric or device state. +
++ Like PagerDuty, manage incidents with intelligent alerting, escalation policies, + and notification routing to keep your team informed. +
++ Ready to get started? Check out the + <.link + patch={~p"/help?section=getting-started"} + class="underline hover:text-blue-900 dark:hover:text-blue-200" + > + Getting Started + + guide to begin monitoring your network infrastructure. +
++ Remote pollers are lightweight Docker containers that run on your network to monitor devices + that aren't accessible from the public internet — behind firewalls, on private networks, or + at remote locations. They use minimal resources (1 CPU core, 512 MB RAM) and auto-update + themselves via Watchtower. Just deploy a + <.code>docker-compose.yml + and you're done. +
+ ++ Remote pollers can run on any system that supports Docker Compose, including: +
+ ++ Minimum requirements: + 1 CPU core, 512 MB RAM, and network access to your devices +
++ Navigate to the + <.code>Agents + page in Towerops and create a new agent token. This token authenticates your remote poller with the Towerops platform. +
++ When you create a new agent on the + <.code>Agents + page, Towerops will show you a ready-to-use + <.code>docker-compose.yml + with your agent token pre-filled. It looks like this: +
+<%= raw(~S[services:
+ towerops-agent:
+ image: codeberg.org/towerops-agent/towerops-agent:latest
+ container_name: towerops-agent
+ restart: unless-stopped
+ environment:
+ - TOWEROPS_API_URL=https://app.towerops.net/
+ - TOWEROPS_AGENT_TOKEN=your-token-here
+ labels:
+ - "com.centurylinklabs.watchtower.enable=true"
+ - "com.centurylinklabs.watchtower.scope=towerops"
+
+ watchtower:
+ image: containrrr/watchtower:latest
+ container_name: towerops-watchtower
+ restart: unless-stopped
+ environment:
+ - WATCHTOWER_POLL_INTERVAL=43200
+ - WATCHTOWER_LABEL_ENABLE=true
+ - WATCHTOWER_SCOPE=towerops
+ - WATCHTOWER_CLEANUP=true
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock]) %>
+ + The agent is lightweight — it uses minimal CPU and memory. Watchtower is included + to automatically update the agent every 12 hours, so you never have to think about it. +
++ Copy the + <.code>docker-compose.yml + to your server and run: +
+docker compose up -d
+ + That's it. The agent will connect to Towerops within seconds and start polling your local devices. + You'll see it come online on the Agents page. +
++ You can assign devices to your remote poller in several ways: +
++ Devices assigned to a remote poller will be monitored from your local network instead + of from the cloud. +
++ Remote pollers use secure WebSocket connections to communicate with Towerops. + Your agent token is encrypted and should be kept confidential. The remote poller + only needs outbound HTTPS access (port 443) to connect to Towerops - no inbound + ports need to be opened on your firewall. +
++ You can view the status of your remote pollers on the Agents page. The page shows: +
+ ++ If a remote poller goes offline, devices assigned to it will not be monitored until + the poller comes back online. Consider setting up monitoring alerts for your remote + pollers to be notified if they disconnect. +
++ API tokens allow external applications and scripts to authenticate with the Towerops REST and GraphQL APIs. Each token is scoped to an organization and can be managed from your user settings. +
+ +
+ Include the token in the
+
+ Authorization
+
+ header:
+
Authorization: Bearer your-api-token-here
+ + See also: + <.link navigate={~p"/help?section=rest-api"} class="underline">REST API + and + <.link navigate={~p"/help?section=graphql-api"} class="underline"> + GraphQL API + + for endpoint documentation. +
++ If you have publicly reachable devices, they will be automatically polled from one of our cloud pollers unless overridden by your settings. + Our cloud infrastructure monitors your devices without requiring any additional setup or agent installation. +
+ +| + Location + | ++ IPv4 Address + | ++ IPv6 Address + | +
|---|---|---|
| + DFW (Dallas-Fort Worth) + | ++ 144.202.64.79 + | ++ 2001:19f0:6401:0af2:5400:05ff:fee7:1bfb + | +
+ If your devices are behind a firewall, make sure to allow SNMP traffic (UDP port 161) and ICMP (for ping monitoring) from these IP addresses. + For devices not accessible from the public internet, consider using a + <.link + patch={~p"/help?section=agents"} + class="underline hover:text-blue-900 dark:hover:text-blue-200" + > + Remote Poller + + instead. +
++ Towerops is a network monitoring platform that helps you keep track of your network devices, + monitor their health, and get alerts when issues occur. +
+ ++ Sites represent physical or logical locations where your network devices are located. + If you manage multiple locations, start by creating a site for your office, data center, + or any location where you have equipment. +
++ Navigate to + <.code>Sites + → + <.code>Add Site +
++ Sites are disabled by default and can be enabled later in + <.link + patch={~p"/help?section=settings"} + class="underline hover:text-gray-700 dark:hover:text-gray-300" + > + Organization Settings + + → General tab. +
++ Add network devices like routers, switches, access points, or servers. You'll need the device's + IP address or hostname, and SNMP community string (for SNMP-enabled devices). +
++ Navigate to + <.code>Devices + → + <.code>Add Device +
++ Click on any device to view real-time metrics including CPU usage, memory, temperature, + interface traffic, and more. Explore the different tabs to see network topology, port status, + and historical performance data. +
++ For devices behind firewalls or in remote locations, you can deploy a remote poller + to monitor devices locally without exposing them to the internet. See the + <.link + patch={~p"/help?section=agents"} + class="underline hover:text-blue-900 dark:hover:text-blue-200" + > + Remote Pollers + + section for more information. +
+
+ The GraphQL API lets you query exactly the data you need in a single request. The endpoint is
+
+ POST /api/graphql
+
+ and uses the same API token authentication as the REST API.
+
<%= raw("curl -X POST https://app.towerops.net/api/graphql \\\n -H \"Authorization: Bearer YOUR_TOKEN\" \\\n -H \"Content-Type: application/json\" \\\n -d '{\"query\": \"{ devices { id name ipAddress status } }\"}'") %>
+ + Full reference: + + GraphQL API Documentation → + + — complete schema reference with query examples and variable usage. +
++ Towerops provides powerful visualization capabilities for all monitored metrics. Every sensor, + interface, and metric can be viewed as a time-series graph with multiple time ranges and a + real-time live polling mode for instant feedback. +
+ ++ Graphs are available throughout Towerops wherever metrics are displayed: +
+ ++ All graphs support multiple time ranges for analyzing trends at different scales: +
+ +Recent activity with high detail
+Half-day trends and patterns
+Business day overview
+Full day of activity
+Weekly trends and patterns
++ Monthly overview and capacity planning +
++ Live mode provides real-time sensor monitoring with data updating every second. This is perfect for: +
+ ++ Navigate to any device and click on a metric graph (CPU, Memory, Temperature, Traffic, etc.) +
++ The Live button has a distinctive green gradient style and will pulse when active +
++ The graph will start updating every second with fresh data. A pulsing green indicator + shows that live polling is active. +
++ Click any other time range button to stop live polling and view historical data +
+Real-time CPU load and utilization
+RAM utilization percentage
+Device and component temperatures
+Power supply voltages
+Disk usage and capacity
+Sessions, connections, and counts
++ Live mode is currently only available for sensor metrics (CPU, memory, temperature, etc.). + Interface traffic graphs use historical data only, as traffic calculations require + comparing multiple SNMP polls over time. +
++ When viewing aggregate metrics (like "Temperature" for all sensors), the graph + automatically displays all sensors of that type with different colors for easy comparison. +
++ Historical graphs (non-live) display the maximum and minimum values for the selected + time range at the bottom of the chart for quick reference. +
++ Interface traffic graphs show outbound traffic as positive values (above zero) and + inbound traffic as negative values (below zero) for easy visualization of bidirectional flow. +
++ Traffic graphs automatically scale units (bps, Kbps, Mbps, Gbps) based on the data + range for optimal readability. Hover over data points to see exact values. +
++ Live polling makes direct SNMP requests every second. While this provides instant + feedback, keeping many live graphs open simultaneously may impact device performance. + Use live mode for troubleshooting and testing, then switch back to historical ranges + for routine monitoring. +
++ Network Insights provides proactive observations about your network health, gathered automatically from all connected data sources. +
+ ++ Towerops continuously analyzes data from SNMP polling, Preseem, Gaiia, and other integrations to surface actionable findings. Insights are categorized by source, urgency, and type so you can focus on what matters. +
+ ++ Use the filter bar to narrow insights by status (active/dismissed), source (Preseem, Gaiia, SNMP, system), and urgency level. Each insight shows affected devices as clickable links so you can drill directly into the device detail page. +
++ Dismiss insights you've reviewed to keep the active list clean. Bulk actions let you select multiple insights at once. +
+ ++ Tip: + Insights run on a nightly schedule. Connect your Gaiia and Preseem integrations in + <.link navigate={~p"/help?section=integrations"} class="underline"> + Organization Settings + + to get the most comprehensive insights. +
++ Integrations allow you to connect Towerops with third-party services to enrich your + monitoring data, sync subscriber information, and streamline your workflow. Configure + integrations from + <.code>Organization Settings + → + <.code>Integrations + tab. +
+ ++ + Preseem + + is a Quality of Experience (QoE) monitoring platform designed for WISPs and broadband providers. + The Preseem integration syncs subscriber and access point QoE data into Towerops, giving you + a unified view of network health alongside device monitoring. +
+ ++ Log in to your Preseem dashboard and generate an API key from your account settings. +
++ Navigate to + <.code>Organization Settings + → + <.code>Integrations + tab. + Enter your Preseem API key and configure the sync interval. +
++ Use the Test Connection button + to verify your API key is valid and Towerops can reach the Preseem API. +
++ Preseem data is synced automatically on a periodic schedule based on your configured + sync interval. You can also trigger a manual sync at any time from the integration settings. +
++ + Gaiia + + is a billing and subscriber management platform. The Gaiia integration syncs subscriber data, + service plans, and entity mappings into Towerops, enabling you to correlate network issues with + specific customers and services. +
+ ++ Navigate to + <.code>Organization Settings + → + <.code>Integrations + tab + and enter your Gaiia API credentials. +
++ Configure a webhook in your Gaiia account pointing to the webhook URL provided + in the integration settings. Webhooks enable real-time updates when subscriber + data changes in Gaiia. +
++ After the initial sync, review the entity mapping to ensure Gaiia subscribers are + correctly matched to Towerops devices. Use the reconciliation tools to resolve + any mismatches. +
++ Gaiia data is synced through two mechanisms: automatic periodic syncs pull the full + dataset on a schedule, while real-time webhooks push individual changes as they happen + in Gaiia. This ensures your data stays current without excessive API calls. +
++ + PagerDuty + + is an incident management and on-call alerting platform. The PagerDuty integration provides + 2-way alert sync — when a device goes down in Towerops, a PagerDuty incident is automatically + triggered. Acknowledging or resolving the alert in Towerops updates the PagerDuty incident as well. +
+ ++ In PagerDuty, go to + <.code>Services + → select your service → + <.code>Integrations + tab → + <.code>Add Integration + → choose <.code>Events API v2. Copy the + <.code>Integration Key + (routing key). +
++ Navigate to + <.code>Organization Settings + → + <.code>Integrations + tab → click + <.code>Configure + on PagerDuty. Paste your integration key and test the connection. +
++ Unlike other integrations, PagerDuty is event-driven — there is no periodic sync. + Alerts are sent to PagerDuty in real time as they occur. No polling or sync interval is needed. +
++ Every integration includes a Test Connection button that validates your credentials + and confirms Towerops can communicate with the external service before saving. +
++ The Integrations tab shows the last sync time, sync status, and any errors for + each configured integration. +
++ Towerops supports read-only monitoring of MikroTik RouterOS devices via SSH. + In addition to SNMP polling, SSH access allows Towerops to retrieve detailed system information + and create configuration backups of your MikroTik devices for disaster recovery and audit purposes. +
+ ++ Towerops currently operates in read-only mode for MikroTik devices. Configuration changes, + reboots, and other administrative actions are not supported. This ensures your device + configurations remain unchanged by monitoring operations. +
++ Both SNMP polling and SSH connections to MikroTik devices can be performed from either + Towerops cloud infrastructure or your remote poller (agent). For publicly accessible devices, + cloud polling is the simplest option. For devices on private networks or when you prefer + to keep SSH credentials on your local network, deploy a remote poller. The device assignment + determines which poller handles both SNMP and SSH operations. +
++ For security purposes, it is strongly recommended to create a dedicated read-only user account + for Towerops rather than using an administrator account. This follows the principle of least + privilege and minimizes potential security risks. Even though Towerops only performs read-only + operations, limiting the account permissions provides defense in depth. +
+ ++ Connect to your MikroTik device via SSH or terminal and execute the following commands to + create a read-only user named <.code>towerops: +
+ ++ Step 1: Create a new user group with read-only permissions +
+
+ /user group add name=readonly policy=ssh,read,test,api
+
+
+
+ + Step 2: Create the monitoring user with a strong password +
+
+ /user add name=towerops password={if @generated_password,
+ do: @generated_password,
+ else: "YOUR_STRONG_PASSWORD"} group=readonly
+
+ <%= if @generated_password do %>
+
+
+ <% end %>
+ + ⚠️ This truly random password from random.org will only be shown once! +
+ <% end %> ++ Step 3: Verify the user was created successfully +
+
+ /user print detail where name=towerops
+
+
+
+ + The + <.code>readonly + group includes the following permissions. Note that while these + permissions are granted to the user account, Towerops only performs read operations and does + not make any configuration changes: +
+ +| + Permission + | ++ Description + | +
|---|---|
| + ssh + | ++ Allow SSH access to the device + | +
| + read + | ++ Allow viewing configuration and status (read-only) + | +
| + test + | ++ Allow executing diagnostic commands (ping, traceroute, etc.) + | +
| + api + | ++ Allow API access for automated monitoring + | +
+ After creating the read-only user, configure SSH credentials in Towerops: +
+ ++ Navigate to + <.code>Organization Settings + → + <.code>MikroTik tab + to set default + SSH credentials for all MikroTik devices in your organization. +
++ Navigate to + <.code>Sites + → select a site → + <.code>Edit + to override + credentials for all devices at a specific location. +
++ When editing a MikroTik device, you can specify unique SSH credentials that override + organization and site defaults. +
++ The Network Map provides a geographic view of your sites on an interactive map. Sites with latitude and longitude coordinates are displayed as markers that you can click to view details. +
+ ++ Each site can have an address and/or latitude/longitude coordinates. You can set these in two ways: +
+
+ Address-to-coordinate conversion requires a Google Maps Geocoding API key. Your administrator sets this as the
+
+ GOOGLE_MAPS_API_KEY
+
+ environment variable on the server. The map display itself uses OpenStreetMap and requires no API key.
+
+ The requested help section could not be found. +
+
+ The Towerops REST API provides programmatic access to your monitoring data. All endpoints are under
+
+ /api/v1/
+
+ and require a valid API token.
+
<%= raw("curl -H \"Authorization: Bearer YOUR_TOKEN\" \\\n https://app.towerops.net/api/v1/devices") %>
+
+ All responses use
+
+ {raw("{\"data\": ...}")}
+
+ format. Results are scoped to the organization associated with your API token.
+
+ Full reference: + REST API Documentation → + — complete endpoint reference with request/response examples. +
++ Organization Settings is the central hub for configuring your Towerops organization. Access it + from the main navigation under <.code>Organization Settings. The settings page uses a + tabbed interface to organize different configuration areas. +
+ ++ The General tab lets you manage basic organization properties: +
+ ++ Configure organization-wide SNMP defaults used for device polling: +
+ ++ SNMP credentials follow a hierarchy: Device > Site > Organization. + Device-level settings take the highest priority, followed by site-level, then + organization-level defaults. This lets you set sensible defaults while overriding + for specific locations or devices. +
++ Use the Force Apply button to push + the organization's SNMP settings to all devices, overriding any device or site-level + customizations. +
+ ++ Configure organization-wide SSH credentials for MikroTik device monitoring. This tab is + only accessible to superusers. See the + <.link + patch={~p"/help?section=mikrotik"} + class="underline hover:text-blue-900 dark:hover:text-blue-200 text-blue-700 dark:text-blue-300" + > + MikroTik + + section for detailed setup instructions. +
+ ++ Manage remote poller (agent) assignments for your organization: +
+ ++ Use the Force Apply button to push + the default agent assignment to all devices, overriding any device or site-level agent + selections. +
+ ++ Connect Towerops with third-party services. See the + <.link + patch={~p"/help?section=integrations"} + class="underline hover:text-blue-900 dark:hover:text-blue-200 text-blue-700 dark:text-blue-300" + > + Integrations + + section for full details on available integrations and how to configure them. +
++ Sites are an optional organizational feature that lets you group devices by physical or logical + locations such as offices, datacenters, customer locations, or network zones. Sites are disabled + by default to keep things simple for single-location deployments. +
+ ++ Consider enabling sites if you: +
+ ++ You can skip enabling sites if you: +
+ ++ Sites are optional and disabled by default. You can start without sites and enable them + later if your needs change. When sites are disabled, all devices belong directly to + your organization. +
++ When sites are enabled, devices can be organized into sites, and credentials (SNMP communities, + usernames, passwords) can be set at three levels: +
+ ++ Default credentials that apply to all devices across all sites in your organization. +
++ Override organization defaults for all devices at a specific site. Useful when different + locations have different network configurations. +
++ Override organization or site credentials for a specific device. Useful for devices with + unique configurations. +
++ Go to + <.code>Settings + → + <.code>Organization + and scroll to the "Site Organization" section. +
++ Check the box for "Use sites to organize devices" and save your settings. +
++ Navigate to + <.code>Sites + → + <.code>Add Site + to create your first site. Give it a descriptive name like "Main Office" or "Dallas Datacenter". +
++ When creating or editing devices, you can now assign them to specific sites. The site + selector will appear in the device form. +
++ If you disable sites after enabling them, all devices will be removed from their sites + and assigned directly to the organization. Site assignments will be lost, but devices + will remain in your organization and continue to be monitored. +
+