Compare commits

...

10 commits

Author SHA1 Message Date
dfb3dfd9e8 update prometheus ip in doc
Some checks failed
Production Deployment / Run ExUnit Tests (push) Failing after 2m4s
Production Deployment / Build and Push Docker Image (push) Has been skipped
2026-07-22 11:01:44 -05:00
3bf21c746f refactor: upgrade to Elixir 1.20, extract GraphLive.Polling, async subscriber impact
- Bump elixir requirement from ~> 1.19 to ~> 1.20 for gradual typing
- Extract SNMP polling functions from graph_live/show.ex (1492→1118 lines)
  into new GraphLive.Polling module (363 lines) — 374 lines net reduction
- Defer subscriber impact API call via start_async/handle_async in
  device_live/show.ex, avoiding synchronous Gaiia API call in handle_params
- Add terminate/2 to graph_live/show.ex for live_poll timer cleanup
- Fix .tool-versions to match installed Erlang 27.3.4.2
- Relax AGENTS.md dialyzer policy: allow ignore file for deps/vendored code
2026-07-22 11:01:00 -05:00
Graham McInitre
24eaccd7a8 chore: track .envrc, gitignore .envrc.local for secrets
.envrc contains shared direnv/Nix setup (no secrets). Local overrides
and secrets belong in .envrc.local, which is now gitignored.
2026-07-22 08:37:17 -05:00
Graham McInitre
bff35368b0 Revert "feat: add beacon monitors with user and admin management"
This reverts commit 88fae5d752.
2026-07-22 08:25:04 -05:00
Graham McInitre
88fae5d752 feat: add beacon monitors with user and admin management
Backend:
- Migration: beacon_monitors table (binary_id PK, org/user FK, check_type, target_url, config, monitoring fields)
- Schema: BeaconMonitor with changeset (pattern-matched port validation, check_type inclusion)
- Query: BeaconMonitorQuery — composable scopes (by org, by user, by type, enabled, ordered, preloaded)
- Context: Towerops.Beacons — CRUD, toggle, record_check_result (36/36 tests pass)

Frontend:
- User LiveView: /beacons — stream-based list, create/edit modals, toggle/delete actions
- Admin LiveView: /admin/beacons — cross-org view with preloads (20/20 tests pass)
- Form component: LiveComponent modal with validation
- Helpers: check_type badges, status indicators, response time formatting
- Router: user routes + admin route configured
- Nav: sidebar + mobile links added
- 30+ gettext translations

Verification: compile ✓, format ✓, credo ✓, 56/56 new tests pass
2026-07-22 08:15:03 -05:00
Graham McInitre
5b91735b14 refactor: extract timeout helpers and replace conditionals in snmp/discovery.ex
- 12 identical *_with_timeout wrappers → 2 generic helpers (with_timeout/3, with_deferred_timeout/3)
- Removed commented-out code (# alias Towerops.Topology, # Auto-inference disabled)
- All if/case/cond replaced with pattern-matched function clauses (20+ refactors)
- Long lines fixed for credo compliance
- 11/11 tests pass, 0 credo issues
2026-07-21 17:08:03 -05:00
Graham McInitre
9d4a5f6d81 refactor: comprehensive simplification and functional programming improvements
- help_live/index.ex: 2,642→173 lines, 15 section modules + sidebar extracted
  MikroTik now real section, dead if false removed
- agent_channel.ex: 2,506→1,751 lines, 3 helper modules extracted
  (heartbeat/subscriptions/job_builder), decode_and_process/4 eliminates
  8 repeated handle_in patterns, guard-based size checks
- antenna_catalog.ex: 1,174→47 lines, 107 specs → priv/antennas/catalog.json
- topology.ex: unbounded query → batched loading (100/batch),
  all guard errors fixed, zero if/case/cond conditionals
- proto: decoder_macros.ex + field_specs.ex infrastructure for
  macro-generated protobuf decoders
2026-07-21 17:03:47 -05:00
Graham McInitre
5b8f83671e update deps 2026-07-20 09:45:05 -05:00
Graham McInitre
368e96f8c3 fix: remove towerops_nif from auto-compilers list
The Mix.Tasks.Compile.ToweropsNif module is part of the project source
and isn't on the code path when Mix first resolves compilers in a fresh
build — causes 'could not be found' in CI. NIF compilation is handled by:
- nix/shell.nix shellHook for dev (make -C c_src)
- nix/build.nix preBuild for CI/prod (injects towerops-nif package .so)
Run 'mix compile.towerops_nif' manually when changing C source in dev.
2026-07-16 09:49:43 -05:00
Graham McInitre
b0b085a2af fix: skip NIF compilation in prod — NIF is pre-built by Nix
The prod build runs MIX_ENV=prod without make/gcc/net-snmp. The NIF
is pre-built by the towerops-nif Nix package and injected via
build.nix preBuild. Skip the mix compiler task in prod to avoid
'could not be found' CI errors.
2026-07-16 09:42:40 -05:00
38 changed files with 6388 additions and 4900 deletions

7
.envrc Normal file
View file

@ -0,0 +1,7 @@
# shellcheck shell=bash
# shellcheck disable=SC1091
use flake . --impure
dotenv_if_exists .env.local

3
.gitignore vendored
View file

@ -60,7 +60,8 @@ npm-debug.log
/priv/plts/
# Environment variables (used by direnv and Kustomize secretGenerator)
.envrc
# .envrc is tracked — put local secrets in .envrc.local
.envrc.local
k8s/.envrc
/towerops-agent/

View file

@ -1,4 +1,3 @@
erlang 29.0.3
elixir 1.20.2-otp-29
#elixir 1.20.0-rc.1-otp-28
erlang 27.3.4.2
elixir 1.20.2-otp-27
nodejs 22.22.2

View file

@ -38,7 +38,7 @@ This approach improves:
- Each time you write or update a unit test run them with `mix test` and ensure they pass
- **IMPORTANT**: When you complete a task run `mix test --cover` and ensure coverage is above the threshold
- **IMPORTANT**: When you complete a task run `mix credo --strict` to check for code quality issues and fix them
- **IMPORTANT**: Run `mix dialyzer` for static type analysis — never suppress warnings, fix root causes
- **IMPORTANT**: Run `mix dialyzer` for static type analysis — use `.dialyzer_ignore.exs` only for dependency PLT gaps, vendored code, and external language artifacts (Gleam, NIFs). Never suppress warnings from project source code; fix those at the root cause. If legacy warnings are too numerous for a single PR, add them to the ignore file temporarily with a TODO comment and scheduled cleanup date.
- **IMPORTANT**: Run `mix precommit` before committing: compiles with warnings as errors, formats, runs tests
### Security guidelines

View file

@ -102,7 +102,7 @@ config :towerops, Towerops.Mailer, adapter: Swoosh.Adapters.Local
# Metrics are served via PromEx.Plug through the main app's Bandit server
# (no separate Cowboy instance on port 9568). The Pod template in
# k8s/deployment.yaml should carry `prometheus.io/scrape` annotations pointing
# at the main app port with `/metrics` path so Prometheus (10.0.15.31)
# at the main app port with `/metrics` path so Prometheus (10.0.19.31)
# discovers it via the apiserver-proxy `kubernetes-pods` job.
config :towerops, Towerops.PromEx,
manual_metrics_start_delay: :no_delay,

View file

@ -1,12 +1,28 @@
defmodule Mix.Tasks.Compile.ToweropsNif do
@moduledoc """
Compiles the towerops_nif C NIF.
In dev/test, runs `make -C c_src` to build `priv/towerops_nif.so`.
In prod, the NIF is pre-built by Nix and injected via build.nix preBuild,
so this task is a no-op.
"""
use Mix.Task.Compiler
@impl true
def run(_args) do
if skip_compilation?() do
{:ok, []}
else
do_compile()
end
end
defp skip_compilation? do
Mix.env() == :prod or is_nil(System.find_executable("make"))
end
defp do_compile do
{result, _exit_code} = System.cmd("make", [], cd: "c_src", stderr_to_stdout: true, into: IO.stream())
case result do
@ -25,8 +41,12 @@ defmodule Mix.Tasks.Compile.ToweropsNif do
@impl true
def clean do
if skip_compilation?() do
:ok
else
{_result, _exit_code} = System.cmd("make", ["clean"], cd: "c_src", stderr_to_stdout: true)
:ok
end
rescue
_ -> :ok
end

View file

@ -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

View file

@ -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

View file

@ -37,7 +37,6 @@ defmodule Towerops.Snmp.Discovery do
alias Towerops.Snmp.Storage
alias Towerops.Snmp.Transceiver
alias Towerops.Snmp.Vlan
# alias Towerops.Topology # Commented out - auto-inference disabled
alias Towerops.Workers.DiscoveryWorker
require Logger
@ -100,8 +99,11 @@ defmodule Towerops.Snmp.Discovery do
"""
@spec discover_device(DeviceSchema.t()) :: {:ok, Device.t()} | {:error, term()}
def discover_device(%DeviceSchema{snmp_enabled: false}) do
{:error, :snmp_not_enabled}
end
def discover_device(%DeviceSchema{} = device) do
if device.snmp_enabled do
Logger.info(
"Starting SNMP discovery: #{device.name} (#{device.ip_address})",
device_id: device.id,
@ -111,15 +113,19 @@ defmodule Towerops.Snmp.Discovery do
client_opts = build_client_opts(device)
# Categorize device speed for adaptive timeouts
Logger.info("Categorizing device speed...", device_id: device.id)
device_speed = DeferredDiscovery.categorize_device_speed(client_opts)
timeouts = DeferredDiscovery.timeouts_for_speed(device_speed)
if device_speed == :unresponsive do
discover_with_speed(device, client_opts, device_speed, timeouts)
end
defp discover_with_speed(device, _client_opts, :unresponsive, _timeouts) do
Logger.warning("Device #{device.name} is unresponsive, aborting discovery")
{:error, :device_unresponsive}
else
end
defp discover_with_speed(device, client_opts, device_speed, timeouts) do
Logger.info(
"Device categorized as #{device_speed}, proceeding with discovery",
device_id: device.id,
@ -128,10 +134,6 @@ defmodule Towerops.Snmp.Discovery do
do_discover_device(device, client_opts, timeouts)
end
else
{:error, :snmp_not_enabled}
end
end
@doc """
Discovers a device using pre-built client options.
@ -150,50 +152,49 @@ defmodule Towerops.Snmp.Discovery do
"""
@spec discover_device_with_opts(DeviceSchema.t(), keyword()) ::
{:ok, Device.t()} | {:error, term()}
def discover_device_with_opts(%DeviceSchema{snmp_enabled: false}, _client_opts) do
{:error, :snmp_not_enabled}
end
def discover_device_with_opts(%DeviceSchema{} = device, client_opts) do
if device.snmp_enabled do
Logger.debug("Starting discovery with custom opts for: #{device.name}")
{device_speed, timeouts} = categorize_speed(client_opts)
discover_with_speed(device, client_opts, device_speed, timeouts)
end
# Skip speed categorization for Replay adapter (agent-based discovery)
# Agent data is pre-collected, so network checks are not needed
adapter = Keyword.get(client_opts, :adapter)
defp categorize_speed(client_opts) do
categorize_speed_for_adapter(client_opts, Keyword.get(client_opts, :adapter))
end
{device_speed, timeouts} =
if adapter == Replay do
defp categorize_speed_for_adapter(_client_opts, Replay) do
Logger.debug("Using Replay adapter, skipping speed categorization")
{:fast, DeferredDiscovery.timeouts_for_speed(:fast)}
else
# Categorize device speed for adaptive timeouts (real SNMP)
end
defp categorize_speed_for_adapter(client_opts, _adapter) do
speed = DeferredDiscovery.categorize_device_speed(client_opts)
{speed, DeferredDiscovery.timeouts_for_speed(speed)}
end
if device_speed == :unresponsive do
Logger.warning("Device #{device.name} is unresponsive, aborting discovery")
{:error, :device_unresponsive}
else
do_discover_device(device, client_opts, timeouts)
# Connection test with pattern matching on Replay adapter
defp test_connection(client_opts, device_id) do
test_connection_for_adapter(client_opts, device_id, Keyword.get(client_opts, :adapter))
end
else
{:error, :snmp_not_enabled}
defp test_connection_for_adapter(_client_opts, device_id, Replay) do
Logger.debug("Using Replay adapter, skipping connection test", device_id: device_id)
{:ok, :replay}
end
defp test_connection_for_adapter(client_opts, device_id, _adapter) do
Logger.info("Testing SNMP connection...", device_id: device_id)
Client.test_connection(client_opts)
end
# Internal discovery with adaptive timeouts based on device speed
defp do_discover_device(device, client_opts, timeouts) do
adapter = Keyword.get(client_opts, :adapter)
connection_result = test_connection(client_opts, device.id)
# Skip connection test for Replay adapter (agent-provided data)
connection_result =
if adapter == Replay do
Logger.debug("Using Replay adapter, skipping connection test", device_id: device.id)
{:ok, :replay}
else
Logger.info("Testing SNMP connection...", device_id: device.id)
Client.test_connection(client_opts)
end
# Critical path: connection, system info, device info must succeed
with {:ok, _} <- connection_result,
Logger.info("Discovering system info...", device_id: device.id),
{:ok, system_info} <- discover_system(client_opts),
@ -205,60 +206,76 @@ defmodule Towerops.Snmp.Discovery do
{:ok, device_info} <- build_device_info(client_opts, system_info, profile),
{:ok, interfaces} <- discover_interfaces_with_timeout_safe(client_opts, profile, timeouts, device.id),
{:ok, sensors} <- discover_sensors_with_timeout_safe(client_opts, profile, timeouts, device.id) do
# Secondary discovery: VLANs, IPs, processors, storage (already fall back to [] on timeout)
Logger.info("Discovering VLANs...", device_id: device.id)
{:ok, vlans} = discover_vlans_with_timeout(client_opts, profile, timeouts)
{:ok, vlans} = with_timeout(client_opts, fn -> discover_vlans(client_opts, profile) end, timeouts)
Logger.info("Discovering IP addresses...", device_id: device.id)
{:ok, ip_addresses} = discover_ip_addresses_with_timeout(client_opts, timeouts)
{:ok, ip_addresses} = with_timeout(client_opts, fn -> Base.discover_all_ip_addresses(client_opts) end, timeouts)
_ = log_ip_discovery_results(device, ip_addresses)
Logger.info("Discovering processors...", device_id: device.id)
{:ok, processors} = discover_processors_with_timeout(client_opts, timeouts)
{:ok, processors} = with_timeout(client_opts, fn -> Base.discover_processors(client_opts) end, timeouts)
Logger.info("Discovering storage...", device_id: device.id)
{:ok, storage} = discover_storage_with_timeout(client_opts, timeouts)
{:ok, storage} = with_timeout(client_opts, fn -> Base.discover_storage(client_opts) end, timeouts)
Logger.info("Discovering memory pools...", device_id: device.id)
{:ok, mempools} = discover_mempools_with_timeout(client_opts, timeouts)
{:ok, mempools} = with_timeout(client_opts, fn -> Base.discover_memory_pools(client_opts) end, timeouts)
Logger.info("Discovering entity physical inventory...", device_id: device.id)
{:ok, entity_physical} = discover_entity_physical_with_timeout(client_opts, timeouts)
{:ok, entity_physical} = with_timeout(client_opts, fn -> Base.discover_entity_physical(client_opts) end, timeouts)
Logger.info("Discovering transceivers...", device_id: device.id)
{:ok, transceivers} = discover_transceivers_with_timeout(client_opts, timeouts)
{:ok, transceivers} = with_timeout(client_opts, fn -> Base.discover_transceivers(client_opts) end, timeouts)
Logger.info("Discovering printer supplies...", device_id: device.id)
{:ok, printer_supplies} = discover_printer_supplies_with_timeout(client_opts, timeouts)
{:ok, printer_supplies} =
with_timeout(client_opts, fn -> Base.discover_printer_supplies(client_opts) end, timeouts)
Logger.info("Saving discovery results (including IP addresses and processors)...", device_id: device.id)
Logger.info("Saving discovery results...", device_id: device.id)
case save_discovery_results(device, device_info, interfaces, sensors, vlans, ip_addresses, processors) do
{:ok, discovered_device} ->
# Post-save discovery: neighbors, ARP (best-effort)
Logger.info("Syncing storage...", device_id: device.id)
_ = sync_storage(discovered_device, storage)
save_result = save_discovery_results(device, device_info, interfaces, sensors, vlans, ip_addresses, processors)
Logger.info("Syncing memory pools...", device_id: device.id)
_ = sync_mempools(discovered_device, mempools)
post_data = %{
storage: storage,
mempools: mempools,
entity_physical: entity_physical,
transceivers: transceivers,
printer_supplies: printer_supplies
}
Logger.info("Syncing entity physical inventory...", device_id: device.id)
_ = sync_entity_physical(discovered_device, entity_physical)
complete_discovery(device, client_opts, timeouts, save_result, post_data)
else
{:error, reason} = error ->
Logger.error("SNMP discovery failed for #{device.name}: #{inspect(reason)}")
error
end
end
Logger.info("Syncing transceivers...", device_id: device.id)
_ = sync_transceivers(discovered_device, transceivers)
defp complete_discovery(_device, _client_opts, _timeouts, {:error, _} = error, _post_data) do
error
end
Logger.info("Syncing printer supplies...", device_id: device.id)
_ = sync_printer_supplies(discovered_device, printer_supplies)
defp complete_discovery(device, client_opts, timeouts, {:ok, discovered_device}, post_data) do
_ = sync_storage(discovered_device, post_data.storage)
_ = sync_mempools(discovered_device, post_data.mempools)
_ = sync_entity_physical(discovered_device, post_data.entity_physical)
_ = sync_transceivers(discovered_device, post_data.transceivers)
_ = sync_printer_supplies(discovered_device, post_data.printer_supplies)
Logger.info("Discovering neighbors...", device_id: device.id)
{:ok, neighbors} = discover_neighbors_with_timeout(client_opts, discovered_device.interfaces, timeouts)
Logger.info("Saving neighbors...", device_id: device.id)
neighbors_fn = fn -> NeighborDiscovery.discover_neighbors(client_opts, discovered_device.interfaces) end
{:ok, neighbors} = with_deferred_timeout(client_opts, neighbors_fn, timeouts)
_ = save_neighbors(discovered_device.device_id, neighbors)
Logger.info("Discovering ARP entries...", device_id: device.id)
{:ok, arp_entries} = discover_arp_with_timeout(client_opts, timeouts)
arp_fn = fn -> ArpDiscovery.discover_arp_table(client_opts) end
{:ok, arp_entries} = with_deferred_timeout(client_opts, arp_fn, timeouts)
_ = save_arp_entries(discovered_device.device_id, arp_entries, discovered_device.interfaces)
_ = update_device_discovery_time(device)
@ -267,16 +284,10 @@ defmodule Towerops.Snmp.Discovery do
is_rediscovery = device.last_discovery_at != nil
_ = log_discovery_event(device, discovered_device, is_rediscovery)
# Create monitoring checks for discovered entities
Logger.info("Creating monitoring checks from discovered entities...", device_id: device.id)
{:ok, check_counts} = Towerops.Snmp.create_checks_from_discovery(device, discovered_device)
log_check_creation_results(device.id, check_counts)
# Auto-inference disabled - device type is now manual-only
# Logger.info("Inferring device role...", device_id: device.id)
# _ = Topology.maybe_update_device_role(device)
_ =
Phoenix.PubSub.broadcast(
Towerops.PubSub,
@ -285,52 +296,30 @@ defmodule Towerops.Snmp.Discovery do
)
{:ok, discovered_device}
{:error, reason} ->
Logger.error("Failed to save discovery results for #{device.name}: #{inspect(reason)}")
{:error, reason}
end
else
{:error, reason} = error ->
Logger.error("SNMP discovery failed for #{device.name}: #{inspect(reason)}")
error
end
end
# Interface discovery with timeout - returns error on timeout to preserve existing data
defp discover_interfaces_with_timeout(client_opts, profile, timeouts) do
DeferredDiscovery.slow_check(
client_opts,
fn -> discover_interfaces(client_opts, profile) end,
timeout: timeouts[:slow],
default: {:error, :interface_discovery_timeout}
)
end
# Generic timeout wrapper for slow discovery operations (falls back to [] on timeout)
defp with_timeout(client_opts, discovery_fn, timeouts),
do: DeferredDiscovery.slow_check(client_opts, discovery_fn, timeout: timeouts[:slow], default: [])
# Sensor discovery with timeout - returns error on timeout to preserve existing data
defp discover_sensors_with_timeout(client_opts, profile, timeouts) do
DeferredDiscovery.slow_check(
client_opts,
fn -> discover_sensors(client_opts, profile) end,
timeout: timeouts[:slow],
default: {:error, :sensor_discovery_timeout}
)
end
# Timeout wrapper for deferred discovery operations (neighbors, ARP)
defp with_deferred_timeout(client_opts, discovery_fn, timeouts),
do: DeferredDiscovery.slow_check(client_opts, discovery_fn, timeout: timeouts[:deferred], default: [])
# Safe wrapper for interface discovery - aborts discovery on timeout to prevent data loss
defp discover_interfaces_with_timeout_safe(client_opts, profile, timeouts, device_id) do
Logger.info("Discovering interfaces...", device_id: device_id)
case discover_interfaces_with_timeout(client_opts, profile, timeouts) do
{:ok, ifaces} ->
{:ok, ifaces}
case DeferredDiscovery.slow_check(
client_opts,
fn -> discover_interfaces(client_opts, profile) end,
timeout: timeouts[:slow],
default: {:error, :interface_discovery_timeout}
) do
{:ok, _ifaces} = ok -> ok
{:error, :interface_discovery_timeout} ->
Logger.error(
"Interface discovery timed out - aborting to prevent data loss",
device_id: device_id
)
Logger.error("Interface discovery timed out - aborting to prevent data loss", device_id: device_id)
{:error, :interface_discovery_timeout}
end
end
@ -339,116 +328,20 @@ defmodule Towerops.Snmp.Discovery do
defp discover_sensors_with_timeout_safe(client_opts, profile, timeouts, device_id) do
Logger.info("Discovering sensors...", device_id: device_id)
case discover_sensors_with_timeout(client_opts, profile, timeouts) do
{:ok, sensors} ->
{:ok, sensors}
case DeferredDiscovery.slow_check(
client_opts,
fn -> discover_sensors(client_opts, profile) end,
timeout: timeouts[:slow],
default: {:error, :sensor_discovery_timeout}
) do
{:ok, _sensors} = ok -> ok
{:error, :sensor_discovery_timeout} ->
Logger.error(
"Sensor discovery timed out - aborting to prevent data loss",
device_id: device_id
)
Logger.error("Sensor discovery timed out - aborting to prevent data loss", device_id: device_id)
{:error, :sensor_discovery_timeout}
end
end
# Neighbor discovery with timeout - falls back to empty list on timeout
defp discover_neighbors_with_timeout(client_opts, interfaces, timeouts) do
DeferredDiscovery.slow_check(
client_opts,
fn -> NeighborDiscovery.discover_neighbors(client_opts, interfaces) end,
timeout: timeouts[:deferred],
default: []
)
end
# ARP table discovery with timeout - falls back to empty list on timeout
defp discover_arp_with_timeout(client_opts, timeouts) do
DeferredDiscovery.slow_check(
client_opts,
fn -> ArpDiscovery.discover_arp_table(client_opts) end,
timeout: timeouts[:deferred],
default: []
)
end
# VLAN discovery with timeout - falls back to empty list on timeout
defp discover_vlans_with_timeout(client_opts, profile, timeouts) do
DeferredDiscovery.slow_check(
client_opts,
fn -> discover_vlans(client_opts, profile) end,
timeout: timeouts[:slow],
default: []
)
end
# IP address discovery with timeout - falls back to empty list on timeout
defp discover_ip_addresses_with_timeout(client_opts, timeouts) do
DeferredDiscovery.slow_check(
client_opts,
fn -> Base.discover_all_ip_addresses(client_opts) end,
timeout: timeouts[:slow],
default: []
)
end
# Processor discovery with timeout - falls back to empty list on timeout
defp discover_processors_with_timeout(client_opts, timeouts) do
DeferredDiscovery.slow_check(
client_opts,
fn -> Base.discover_processors(client_opts) end,
timeout: timeouts[:slow],
default: []
)
end
# Storage discovery with timeout - falls back to empty list on timeout
defp discover_storage_with_timeout(client_opts, timeouts) do
DeferredDiscovery.slow_check(
client_opts,
fn -> Base.discover_storage(client_opts) end,
timeout: timeouts[:slow],
default: []
)
end
defp discover_mempools_with_timeout(client_opts, timeouts) do
DeferredDiscovery.slow_check(
client_opts,
fn -> Base.discover_memory_pools(client_opts) end,
timeout: timeouts[:slow],
default: []
)
end
defp discover_entity_physical_with_timeout(client_opts, timeouts) do
DeferredDiscovery.slow_check(
client_opts,
fn -> Base.discover_entity_physical(client_opts) end,
timeout: timeouts[:slow],
default: []
)
end
defp discover_transceivers_with_timeout(client_opts, timeouts) do
DeferredDiscovery.slow_check(
client_opts,
fn -> Base.discover_transceivers(client_opts) end,
timeout: timeouts[:slow],
default: []
)
end
defp discover_printer_supplies_with_timeout(client_opts, timeouts) do
DeferredDiscovery.slow_check(
client_opts,
fn -> Base.discover_printer_supplies(client_opts) end,
timeout: timeouts[:slow],
default: []
)
end
@doc """
Enqueues Oban discovery jobs for all SNMP-enabled devices in an organization.
@ -652,18 +545,21 @@ defmodule Towerops.Snmp.Discovery do
device_id: device_id
)
if check_counts.errors != [] do
Logger.warning(
"Failed to create #{length(check_counts.errors)} checks for device #{device_id}",
device_id: device_id,
errors: check_counts.errors
)
log_check_errors(device_id, check_counts.errors)
end
defp log_check_errors(_device_id, []), do: :ok
defp log_check_errors(device_id, errors) do
Logger.warning(
"Failed to create #{length(errors)} checks for device #{device_id}",
device_id: device_id,
errors: errors
)
end
@spec build_client_opts(DeviceSchema.t()) :: Client.connection_opts()
defp build_client_opts(device) do
# Get SNMP config with hierarchical fallback (device -> site -> organization)
snmp_config = Devices.get_snmp_config(device)
base_opts = [
@ -672,8 +568,10 @@ defmodule Towerops.Snmp.Discovery do
port: device.snmp_port || 161
]
# Add version-specific credentials
if snmp_config.version == "3" do
add_credentials(base_opts, snmp_config, device)
end
defp add_credentials(base_opts, %{version: "3"} = _snmp_config, device) do
v3_config = Devices.get_snmpv3_config(device)
base_opts ++
@ -685,9 +583,10 @@ defmodule Towerops.Snmp.Discovery do
priv_protocol: v3_config.priv_protocol,
priv_password: v3_config.priv_password
]
else
base_opts ++ [community: snmp_config.community]
end
defp add_credentials(base_opts, snmp_config, _device) do
base_opts ++ [community: snmp_config.community]
end
@spec discover_system(Client.connection_opts()) :: {:ok, system_info()} | {:error, term()}
@ -768,37 +667,15 @@ defmodule Towerops.Snmp.Discovery do
"build_device_info: system_info has _raw_discovery_data: #{inspect(Map.has_key?(system_info, :_raw_discovery_data))}"
)
# Get profile-specific system info if profile has discover_system_info/1
enriched_system_info =
if function_exported?(profile, :discover_system_info, 1) do
case profile.discover_system_info(client_opts) do
{:ok, profile_info} ->
Logger.debug("Enriched system_info with profile-specific data")
Map.merge(system_info, profile_info)
has_sys_info = function_exported?(profile, :discover_system_info, 1)
{:error, _reason} ->
Logger.warning("Failed to get profile-specific system info, using base info")
system_info
end
else
system_info
end
enriched_system_info = enrich_with_profile_info(client_opts, system_info, profile, has_sys_info)
# Let the hard-coded profile identify the device
identified_info = profile.identify_device(enriched_system_info)
# Collect raw debug data if profile supports it
identified_info_with_debug =
if function_exported?(profile, :collect_raw_debug_data, 1) do
raw_data = profile.collect_raw_debug_data(client_opts)
Logger.debug("Collected raw debug data: #{map_size(raw_data)} keys")
has_collect_debug = function_exported?(profile, :collect_raw_debug_data, 1)
identified_info
|> Map.put(:_raw_discovery_data, raw_data)
|> Map.put(:_last_discovery_at, DateTime.utc_now())
else
identified_info
end
identified_info_with_debug = maybe_attach_debug_data(client_opts, identified_info, profile, has_collect_debug)
Logger.debug(
"build_device_info: identified_info has _raw_discovery_data: #{inspect(Map.has_key?(identified_info_with_debug, :_raw_discovery_data))}"
@ -808,10 +685,34 @@ defmodule Towerops.Snmp.Discovery do
rescue
_error ->
Logger.error("Failed to identify device with profile, falling back to Base profile")
# Fall back to Base profile identification to ensure manufacturer/model are set
{:ok, Base.identify_device(system_info)}
end
defp enrich_with_profile_info(_client_opts, system_info, _profile, false), do: system_info
defp enrich_with_profile_info(client_opts, system_info, profile, true) do
case profile.discover_system_info(client_opts) do
{:ok, profile_info} ->
Logger.debug("Enriched system_info with profile-specific data")
Map.merge(system_info, profile_info)
{:error, _reason} ->
Logger.warning("Failed to get profile-specific system info, using base info")
system_info
end
end
defp maybe_attach_debug_data(_client_opts, identified_info, _profile, false), do: identified_info
defp maybe_attach_debug_data(client_opts, identified_info, profile, true) do
raw_data = profile.collect_raw_debug_data(client_opts)
Logger.debug("Collected raw debug data: #{map_size(raw_data)} keys")
identified_info
|> Map.put(:_raw_discovery_data, raw_data)
|> Map.put(:_last_discovery_at, DateTime.utc_now())
end
@spec discover_interfaces(Client.connection_opts(), profile()) ::
{:ok, [interface_data()]}
defp discover_interfaces(client_opts, {:yaml, profile}) do
@ -857,17 +758,20 @@ defmodule Towerops.Snmp.Discovery do
end
defp discover_vlans(client_opts, profile) when is_atom(profile) do
if function_exported?(profile, :discover_vlans, 1) do
discover_vlans_for_profile(client_opts, profile, function_exported?(profile, :discover_vlans, 1))
end
defp discover_vlans_for_profile(client_opts, profile, true) do
{:ok, vlans} = profile.discover_vlans(client_opts)
Logger.debug("Discovered #{length(vlans)} VLANs")
{:ok, vlans}
else
# Fall back to Base profile for VLAN discovery
end
defp discover_vlans_for_profile(client_opts, _profile, false) do
{:ok, vlans} = Base.discover_vlans(client_opts)
Logger.debug("Discovered #{length(vlans)} VLANs using Base profile")
{:ok, vlans}
end
end
@spec save_discovery_results(
DeviceSchema.t(),
@ -914,17 +818,21 @@ defmodule Towerops.Snmp.Discovery do
duration = System.monotonic_time(:millisecond) - start
if duration > 5_000 do
log_slow_transaction(duration, device.id, interfaces, sensors)
result
end
defp log_slow_transaction(duration, _device_id, _interfaces, _sensors) when duration <= 5_000, do: :ok
defp log_slow_transaction(duration, device_id, interfaces, sensors) do
Logger.warning("Discovery transaction took #{duration}ms",
device_id: device.id,
device_id: device_id,
interface_count: length(interfaces),
sensor_count: length(sensors)
)
end
result
end
# Prepares device_info for database save by sanitizing raw_discovery_data
# This is done OUTSIDE the transaction to avoid DB timeout on large data
defp prepare_device_info_for_save(device_info, device_id) do
@ -952,26 +860,20 @@ defmodule Towerops.Snmp.Discovery do
@spec upsert_device(DeviceSchema.t(), map()) :: Device.t()
defp upsert_device(device, device_attrs) do
Logger.info("Upserting SNMP device for #{device.name}")
do_upsert_device(device_attrs, Repo.get_by(Device, device_id: device.id))
end
case Repo.get_by(Device, device_id: device.id) do
nil ->
defp do_upsert_device(device_attrs, nil) do
snmp_device =
%Device{}
|> Device.changeset(device_attrs)
|> Repo.insert!()
# Log initial firmware version (no old version)
if snmp_device.firmware_version do
Firmware.log_firmware_change(
snmp_device.id,
nil,
snmp_device.firmware_version
)
log_initial_firmware(snmp_device)
snmp_device
end
snmp_device
existing_device ->
defp do_upsert_device(device_attrs, existing_device) do
old_version = existing_device.firmware_version
new_version = device_attrs[:firmware_version]
@ -980,17 +882,24 @@ defmodule Towerops.Snmp.Discovery do
|> Device.changeset(device_attrs)
|> Repo.update!()
# Detect and log firmware version changes
if version_changed?(old_version, new_version) do
Firmware.log_firmware_change(
snmp_device.id,
old_version,
new_version
)
end
log_firmware_if_changed(snmp_device, old_version, new_version)
snmp_device
end
defp log_initial_firmware(%{firmware_version: fw} = snmp_device) when not is_nil(fw) do
Firmware.log_firmware_change(snmp_device.id, nil, fw)
end
defp log_initial_firmware(_snmp_device), do: :ok
defp log_firmware_if_changed(snmp_device, old_version, new_version) do
log_firmware_when_changed(snmp_device, old_version, new_version, version_changed?(old_version, new_version))
end
defp log_firmware_when_changed(_snmp_device, _old, _new, false), do: :ok
defp log_firmware_when_changed(snmp_device, old, new, true) do
Firmware.log_firmware_change(snmp_device.id, old, new)
end
defp version_changed?(nil, new_version) when is_binary(new_version), do: false
@ -1187,12 +1096,20 @@ defmodule Towerops.Snmp.Discovery do
Logger.debug("Deleted #{MapSet.size(removed_keys)} removed IP addresses")
end
# Upsert each discovered IP address
Enum.each(discovered_ip_addresses, fn ip_data ->
upsert_ip_address(ip_data, if_index_to_interface, existing_ip_addresses)
end)
if Application.get_env(:towerops, :env) == :dev do
log_sync_ip_summary(device, discovered_ip_addresses, removed_keys, Application.get_env(:towerops, :env) == :dev)
:ok
end
defp log_sync_ip_summary(_device, _discovered, _removed_keys, false) do
Logger.debug("Synced IP addresses for device")
end
defp log_sync_ip_summary(device, discovered_ip_addresses, removed_keys, true) do
ipv4_count = Enum.count(discovered_ip_addresses, &(&1.ip_type == "ipv4"))
ipv6_count = Enum.count(discovered_ip_addresses, &(&1.ip_type == "ipv6"))
removed_count = MapSet.size(removed_keys)
@ -1201,20 +1118,17 @@ defmodule Towerops.Snmp.Discovery do
"Synced IP addresses: #{ipv4_count} IPv4, #{ipv6_count} IPv6, removed #{removed_count}",
device_id: device.device_id
)
else
Logger.debug("Synced #{length(discovered_ip_addresses)} IP addresses for device")
end
:ok
end
defp upsert_ip_address(ip_data, if_index_to_interface, _existing_ip_addresses) do
interface_id = Map.get(if_index_to_interface, ip_data.if_index)
upsert_ip_with_iface(ip_data, Map.get(if_index_to_interface, ip_data.if_index))
end
if interface_id do
defp upsert_ip_with_iface(_ip_data, nil), do: :ok
defp upsert_ip_with_iface(ip_data, interface_id) do
ip_attrs = Map.put(ip_data, :snmp_interface_id, interface_id)
# Use on_conflict to handle race conditions gracefully
%IpAddress{}
|> IpAddress.changeset(ip_attrs)
|> Repo.insert(
@ -1222,7 +1136,6 @@ defmodule Towerops.Snmp.Discovery do
conflict_target: [:snmp_interface_id, :ip_address]
)
end
end
@type snmp_device_ref :: Device.t() | %{required(:id) => String.t()}
@spec sync_processors(snmp_device_ref(), [map()]) :: :ok
@ -1312,10 +1225,7 @@ defmodule Towerops.Snmp.Discovery do
end
end)
if invalid_count > 0 do
Logger.warning("Dropped #{invalid_count} storage entries with malformed storage_index")
end
log_invalid_storage_entries(invalid_count)
discovered_indices = MapSet.new(valid_storage, & &1.storage_index)
# Delete storage that no longer exists on the device
@ -1354,6 +1264,12 @@ defmodule Towerops.Snmp.Discovery do
:ok
end
defp log_invalid_storage_entries(0), do: :ok
defp log_invalid_storage_entries(count) do
Logger.warning("Dropped #{count} storage entries with malformed storage_index")
end
# Normalize storage index to integer (Base.discover_storage may return string from OID parsing).
# Returns nil for malformed values — OID suffixes come from SNMP responses and must not be trusted.
defp normalize_storage_index(index) when is_integer(index), do: index
@ -1622,29 +1538,27 @@ defmodule Towerops.Snmp.Discovery do
{:ok, synced}
end
# Resolve parent_id from parent_index for all entities on a device
defp resolve_entity_parent_relationships(snmp_device_id) do
# Get all entities for this device
entities = Repo.all(from(e in EntityPhysical, where: e.snmp_device_id == ^snmp_device_id))
# Build index map: entity_index -> id
index_to_id = Map.new(entities, &{&1.entity_index, &1.id})
# Update each entity's parent_id based on parent_index
Enum.each(entities, fn entity ->
parent_id =
if entity.parent_index && entity.parent_index != "0" do
Map.get(index_to_id, entity.parent_index)
parent_id = resolve_parent_id(index_to_id, entity)
update_parent_if_changed(entity, parent_id)
end)
end
# Only update if parent_id changed
if entity.parent_id != parent_id do
defp resolve_parent_id(_index_to_id, %{parent_index: idx}) when is_nil(idx), do: nil
defp resolve_parent_id(_index_to_id, %{parent_index: "0"}), do: nil
defp resolve_parent_id(index_to_id, %{parent_index: idx}), do: Map.get(index_to_id, idx)
defp update_parent_if_changed(%{parent_id: existing} = _entity, existing), do: :ok
defp update_parent_if_changed(entity, parent_id) do
entity
|> Ecto.Changeset.change(parent_id: parent_id)
|> Repo.update!()
end
end)
end
@spec update_device_discovery_time(DeviceSchema.t()) ::
{:ok, DeviceSchema.t()} | {:error, Ecto.Changeset.t()}
@ -1690,18 +1604,28 @@ defmodule Towerops.Snmp.Discovery do
@spec update_device_name_from_snmp(DeviceSchema.t(), system_info()) ::
{:ok, DeviceSchema.t()} | {:error, term()}
defp update_device_name_from_snmp(%{name: name} = device, _system_info)
when is_binary(name) and name != "" do
Logger.info("Device name already set or no sysName, skipping update", device_id: device.id)
{:ok, device}
end
defp update_device_name_from_snmp(device, system_info) do
# Only update device name if it's currently empty and we got a sysName from SNMP
name_empty? = is_nil(device.name) or device.name == ""
do_update_device_name(device, system_info, Map.has_key?(system_info, :sys_name))
end
if name_empty? and Map.has_key?(system_info, :sys_name) do
sys_name = Map.get(system_info, :sys_name)
defp do_update_device_name(device, _system_info, false) do
Logger.info("Device name already set or no sysName, skipping update", device_id: device.id)
{:ok, device}
end
if sys_name && sys_name != "" do
Logger.info(
"Updating device name from SNMP sysName: #{sys_name}",
device_id: device.id
)
defp do_update_device_name(device, %{sys_name: sys_name}, true) when is_nil(sys_name) or sys_name == "" do
Logger.info("sysName empty, keeping device name unchanged", device_id: device.id)
{:ok, device}
end
defp do_update_device_name(device, %{sys_name: sys_name}, true) do
Logger.info("Updating device name from SNMP sysName: #{sys_name}", device_id: device.id)
result =
device
@ -1710,18 +1634,16 @@ defmodule Towerops.Snmp.Discovery do
Logger.info("Device name update completed", device_id: device.id)
result
else
Logger.info("sysName empty, keeping device name unchanged", device_id: device.id)
{:ok, device}
end
else
Logger.info("Device name already set or no sysName, skipping update", device_id: device.id)
{:ok, device}
end
end
defp log_ip_discovery_results(device, ip_addresses) do
if Application.get_env(:towerops, :env) == :dev do
log_ip_if_dev_env(device, ip_addresses, Application.get_env(:towerops, :env) == :dev)
:ok
end
defp log_ip_if_dev_env(_device, _ip_addresses, false), do: :ok
defp log_ip_if_dev_env(device, ip_addresses, true) do
ipv4_count = Enum.count(ip_addresses, &(&1.ip_type == "ipv4"))
ipv6_count = Enum.count(ip_addresses, &(&1.ip_type == "ipv6"))
@ -1731,24 +1653,20 @@ defmodule Towerops.Snmp.Discovery do
)
end
:ok
defp interface_count(%Ecto.Association.NotLoaded{}), do: 0
defp interface_count(interfaces) when is_list(interfaces), do: length(interfaces)
defp log_discovery_event(device, discovered_device, true) do
log_discovery_event_common(device, discovered_device, "device_rediscovered", "Device rediscovered")
end
defp log_discovery_event(device, discovered_device, is_rediscovery) do
event_type = if is_rediscovery, do: "device_rediscovered", else: "device_discovered"
message =
if is_rediscovery do
"Device rediscovered: #{device.name} (#{discovered_device.manufacturer} #{discovered_device.model})"
else
"Device discovered: #{device.name} (#{discovered_device.manufacturer} #{discovered_device.model})"
defp log_discovery_event(device, discovered_device, false) do
log_discovery_event_common(device, discovered_device, "device_discovered", "Device discovered")
end
interface_count =
case discovered_device.interfaces do
%Ecto.Association.NotLoaded{} -> 0
interfaces when is_list(interfaces) -> length(interfaces)
end
defp log_discovery_event_common(device, discovered_device, event_type, event_label) do
message = "#{event_label}: #{device.name} (#{discovered_device.manufacturer} #{discovered_device.model})"
interface_count = interface_count(discovered_device.interfaces)
metadata = %{
manufacturer: discovered_device.manufacturer,
@ -1819,13 +1737,7 @@ defmodule Towerops.Snmp.Discovery do
end
defp sanitize_for_json(binary) when is_binary(binary) do
if String.printable?(binary) do
binary
else
# Convert non-printable binary to base64 with marker
# Use Elixir.Base to avoid conflict with Towerops.Snmp.Profiles.Base alias
%{"_binary_base64" => Elixir.Base.encode64(binary)}
end
sanitize_binary(binary, String.printable?(binary))
end
defp sanitize_for_json(tuple) when is_tuple(tuple) do
@ -1836,6 +1748,12 @@ defmodule Towerops.Snmp.Discovery do
defp sanitize_for_json(other), do: other
defp sanitize_binary(binary, true), do: binary
defp sanitize_binary(binary, false) do
%{"_binary_base64" => Elixir.Base.encode64(binary)}
end
# Deduplicate sensors by OID - when multiple sensors share the same OID,
# prefer the one with a proper description (not containing "MIB::").
# Normalizes OIDs by stripping leading dots so ".1.3.6.1..." and "1.3.6.1..."

View file

@ -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,20 +92,18 @@ defmodule Towerops.Topology do
defp map_macs_to_ids(devices) do
devices
|> Enum.flat_map(fn device ->
case device.snmp_device do
nil ->
[]
|> Enum.flat_map(&device_mac_entries/1)
|> Map.new()
end
sd ->
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
end)
|> Map.new()
|> Enum.map(&{&1, device_id})
end
@doc """
@ -286,16 +295,19 @@ defmodule Towerops.Topology do
evidence_attrs = Map.get(attrs, :evidence, [])
link_attrs = Map.delete(attrs, :evidence)
existing = find_existing_link(link_attrs)
link_attrs
|> find_existing_link()
|> upsert_or_create_link(link_attrs)
|> insert_evidence(evidence_attrs)
end
result =
case existing do
nil ->
defp upsert_or_create_link(nil, link_attrs) do
%DeviceLink{}
|> DeviceLink.changeset(link_attrs)
|> Repo.insert()
end
link ->
defp upsert_or_create_link(link, link_attrs) do
merged_confidence = merge_confidence(link.confidence, link_attrs.confidence)
link
@ -308,11 +320,12 @@ defmodule Towerops.Topology do
|> Repo.update()
end
with {:ok, link} <- result do
defp insert_evidence({:ok, link}, evidence_attrs) do
_ = insert_link_evidence_batch(link.id, evidence_attrs)
{:ok, Repo.reload!(link)}
end
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)
@ -324,17 +337,29 @@ 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 =
{valid, invalid}
end
defp build_evidence_rows({valid, _invalid}, link_id, now) do
Enum.map(valid, fn ev ->
%{
id: Ecto.UUID.generate(),
@ -346,12 +371,10 @@ defmodule Towerops.Topology do
updated_at: now
}
end)
end
case rows do
[] -> :ok
_ -> Repo.insert_all(DeviceLinkEvidence, rows)
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
@ -372,30 +395,32 @@ 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)
device.id
|> collect_all_evidence(lookup)
|> process_collected_evidence(device.id, lookup, now, organization_id)
end
if Enum.empty?(all_evidence) do
{:ok, :unchanged}
else
grouped = group_evidence_by_remote(all_evidence)
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
results =
grouped
|> Enum.map(&upsert_grouped_evidence(&1, device.id, lookup, now))
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)
has_changes = Enum.any?(results, &Result.ok?/1)
broadcast_if_changed(has_changes, organization_id)
end
# Auto-inference disabled - device type is now manual-only
# maybe_update_device_role(device)
if has_changes do
_ =
defp broadcast_if_changed(true, organization_id) do
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"topology:#{organization_id}",
@ -403,12 +428,10 @@ defmodule Towerops.Topology do
)
{:ok, :changed}
else
{:ok, :unchanged}
end
end
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,11 +633,22 @@ 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
suffix
|> find_discovered_link(organization_id)
|> discovered_node_detail(node_id)
end
link ->
def get_node_detail("site_" <> _rest, _organization_id), do: nil
def get_node_detail(device_id, organization_id) do
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"])
%{
@ -631,19 +665,15 @@ defmodule Towerops.Topology do
%{
device_id: link.source_device_id,
device_name: link.source_device.name,
interface: if(link.source_interface, do: link.source_interface.if_name),
interface: interface_name(link.source_interface),
confidence: link.confidence,
link_type: link.link_type
}
]
}
end
end
def get_node_detail("site_" <> _rest, _organization_id), do: nil
def get_node_detail(device_id, organization_id) do
device =
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,
@ -652,12 +682,11 @@ defmodule Towerops.Topology do
preload: [site: s, snmp_device: sd]
)
)
end
case device do
nil ->
nil
defp managed_node_detail(nil, _device_id), do: nil
device ->
defp managed_node_detail(device, device_id) do
connections =
device_id
|> list_connected_devices()
@ -678,16 +707,24 @@ defmodule Towerops.Topology do
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),
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: if(device_rf, do: device_rf[:snr]),
snr: extract_rf_snr(device_rf),
is_wireless: device.device_role in ~w(access_point backhaul cpe backhaul_radio)
}
end
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 =
@ -708,8 +745,14 @@ 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} ->
link_id
|> Ecto.UUID.cast()
|> lookup_anonymous_link(organization_id)
end
defp find_discovered_link_by_anonymous_id(_suffix, _organization_id), do: nil
defp lookup_anonymous_link({:ok, uuid}, organization_id) do
Repo.one(
from l in DeviceLink,
join: d in Device,
@ -719,26 +762,23 @@ defmodule Towerops.Topology do
where: l.id == ^uuid,
preload: [:source_device, :source_interface, :target_interface]
)
:error ->
nil
end
end
defp find_discovered_link_by_anonymous_id(_suffix, _organization_id), do: nil
defp lookup_anonymous_link(:error, _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}
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,9 +999,13 @@ defmodule Towerops.Topology do
name: remote_identity.remote_name
})
if matched_id == device_id do
:skip
else
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,
@ -974,20 +1027,16 @@ defmodule Towerops.Topology do
end)
})
end
end
defp build_link_metadata(evidence_list) do
capabilities =
evidence_list
|> Enum.flat_map(fn ev -> ev[:remote_capabilities] || [] end)
|> Enum.uniq()
|> wrap_capabilities()
end
if Enum.empty?(capabilities) do
%{}
else
%{"remote_capabilities" => capabilities}
end
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)
@ -1018,43 +1067,41 @@ 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
link_attrs
|> 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)
)
else
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
link_attrs
|> existing_link_query(base_query)
|> Repo.one()
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)
case remote_name do
name when is_binary(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 == ^name
l.discovered_remote_name == ^remote_name
)
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
end
defp existing_link_query(%{discovered_remote_name: remote_name}, base_query) when is_binary(remote_name) do
from(l in base_query,
@ -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)}
_ ->
{ev.source_interface_id, remote_mac, remote_ip, remote_name}
key_identity = remote_mac || remote_ip || remote_name
group_by_identity(key_identity, ev, 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}} ->
device_id
|> Lldp.discover_neighbors()
|> handle_discovery_result(device_id)
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}
{:error, reason} = error ->
Logger.error("Failed to discover LLDP neighbors for device #{device_id}: #{inspect(reason)}")
error
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,17 +1264,22 @@ defmodule Towerops.Topology do
last_seen_at: now
}
case Repo.get_by(DeviceNeighbor,
DeviceNeighbor
|> Repo.get_by(
device_id: device_id,
local_port: neighbor.local_port,
neighbor_name: neighbor.neighbor_name
) do
nil ->
)
|> insert_or_update_neighbor(attrs)
end
defp insert_or_update_neighbor(nil, attrs) do
%DeviceNeighbor{}
|> DeviceNeighbor.changeset(attrs)
|> Repo.insert()
end
existing ->
defp insert_or_update_neighbor(existing, attrs) do
existing
|> DeviceNeighbor.changeset(
Map.take(attrs, [
@ -1233,17 +1292,16 @@ defmodule Towerops.Topology do
)
|> Repo.update()
end
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)
match_device_by_neighbor(discovering_device, neighbor)
end
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
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,
@ -1252,65 +1310,57 @@ defmodule Towerops.Topology do
d.ip_address in ^neighbor.management_addresses
)
case Repo.one(query) do
nil -> nil
device -> device.id
end
end
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([]), do: %{}
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)}
)
)
|> 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
end
@doc """
Compute RF link stats for edges. Uses SNR sensors on devices to
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([]), do: %{}
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}
)
)
results
|> Repo.all()
|> Enum.group_by(fn {device_id, _val, _descr} -> device_id end)
|> Map.new(&compute_device_snr_stats/1)
end
end
defp compute_device_snr_stats({device_id, entries}) do
vals = Enum.map(entries, fn {_, val, _} -> val end)
@ -1322,14 +1372,17 @@ 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)
avg_signal
|> signal_to_float()
|> classify_by_signal()
end
cond do
avg >= -65 -> "good"
avg >= -75 -> "degraded"
true -> "critical"
end
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"
@ -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,22 +1475,18 @@ 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]
defp get_edge_utilization(edge, :source, interface_utilizations) do
lookup_utilization(edge[:source_interface_id], interface_utilizations)
end
case interface_id do
nil -> nil
id -> Map.get(interface_utilizations, id)
end
defp get_edge_utilization(edge, :target, interface_utilizations) do
lookup_utilization(edge[:target_interface_id], interface_utilizations)
end
defp combine_interface_utilizations(source_util, target_util) do
case {source_util, target_util} do
{nil, nil} ->
defp lookup_utilization(nil, _interface_utilizations), do: nil
defp lookup_utilization(id, interface_utilizations), do: Map.get(interface_utilizations, id)
defp combine_interface_utilizations(nil, nil) do
%{
utilization_pct: nil,
utilization_level: nil,
@ -1444,29 +1494,28 @@ defmodule Towerops.Topology do
capacity_bps: nil,
utilization_text: nil
}
end
{util, nil} ->
process_single_utilization(util)
defp combine_interface_utilizations(util, nil), do: process_single_utilization(util)
defp combine_interface_utilizations(nil, util), do: process_single_utilization(util)
{nil, util} ->
process_single_utilization(util)
{source, target} ->
# Use the higher utilization of the two interfaces
defp combine_interface_utilizations(source, target) do
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
pick_higher_utilization(source_data, target_data)
end
defp process_single_utilization(util_data) do
case util_data.utilization_data do
nil ->
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,
@ -1474,8 +1523,9 @@ defmodule Towerops.Topology do
capacity_bps: util_data.capacity_bps,
utilization_text: nil
}
end
util ->
defp process_single_utilization(%{utilization_data: util} = util_data) do
utilization_pct = Float.round(util.utilization_pct, 1)
level = classify_utilization_level(utilization_pct)
@ -1488,7 +1538,6 @@ defmodule Towerops.Topology do
interface_name: util_data.interface_name
}
end
end
defp classify_utilization_level(pct) when pct < 30, do: :low
defp classify_utilization_level(pct) when pct < 60, do: :medium

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -52,7 +52,8 @@ defmodule ToweropsWeb.DeviceLive.Show do
socket
|> assign(:show_check_form, false)
|> assign(:edit_check, nil)
|> assign(:subscribed, false)}
|> assign(:subscribed, false)
|> assign(:subscriber_impact, nil)}
end
@impl true
@ -292,6 +293,15 @@ defmodule ToweropsWeb.DeviceLive.Show do
def handle_info(_msg, socket), do: {:noreply, socket}
@impl true
def handle_async(:subscriber_impact, {:ok, impact}, socket) do
{:noreply, assign(socket, :subscriber_impact, impact)}
end
def handle_async(:subscriber_impact, {:exit, _reason}, socket) do
{:noreply, assign(socket, :subscriber_impact, %{subscriber_count: 0, mrr: nil, accounts: []})}
end
# Private functions
defp maybe_subscribe_and_schedule_refresh(socket, device_id) do
@ -398,22 +408,15 @@ defmodule ToweropsWeb.DeviceLive.Show do
|> assign(:snmp_device, snmp_data.device)
|> assign(:snmp_interfaces, snmp_data.interfaces)
|> assign(:snmp_sensors, snmp_data.sensors)
|> assign_subscriber_impact()
|> assign(:can_view_financials, can_view_financials?(socket))
end
end
defp assign_subscriber_impact(socket) do
device = socket.assigns.device
impact =
|> start_async(:subscriber_impact, fn ->
try do
Towerops.Gaiia.get_device_impact(device.id)
rescue
_ -> %{subscriber_count: 0, mrr: nil, accounts: []}
end
assign(socket, :subscriber_impact, impact)
end)
end
end
defp can_view_financials?(socket) do

View file

@ -0,0 +1,363 @@
defmodule ToweropsWeb.GraphLive.Polling do
@moduledoc """
SNMP polling functions for live-mode graph data collection.
Extracted from `ToweropsWeb.GraphLive.Show` to keep the LiveView module
focused on socket lifecycle, assigns management, and UI state transitions.
"""
alias Towerops.Devices
alias Towerops.Snmp
require Logger
@max_live_points 300
@doc """
Build SNMP client options from a device struct.
"""
def build_snmp_client_opts(device) do
snmp_config = Devices.get_snmp_config(device)
[
ip: device.ip_address,
community: snmp_config.community,
version: snmp_config.version,
port: device.snmp_port,
timeout: 3_000
]
end
@doc """
Poll sensors for live mode, dispatching based on sensor type.
"""
def poll_sensors(assigns, client_opts, timestamp_ms) do
if assigns.sensor_type == "traffic" do
poll_traffic(assigns, client_opts, timestamp_ms)
else
poll_regular_sensors(assigns, client_opts, timestamp_ms)
end
end
@doc """
Poll regular (non-traffic) sensors in parallel via Task.async_stream.
"""
def poll_regular_sensors(assigns, client_opts, timestamp_ms) do
sensors = get_sensors_for_live_mode(assigns)
sensors
|> Task.async_stream(&poll_sensor_to_data_point(&1, client_opts, timestamp_ms),
max_concurrency: 10,
timeout: 4_000,
on_timeout: :kill_task
)
|> Enum.reduce([], fn
{:ok, nil}, acc -> acc
{:ok, point}, acc -> [point | acc]
{:exit, _reason}, acc -> acc
end)
end
@doc """
Poll traffic stats (used from non-state-updating path).
Requires two readings to calculate BPS; uses `previous_interface_stats` from assigns.
"""
def poll_traffic(assigns, client_opts, timestamp_ms) do
device = Snmp.get_device_with_associations(assigns.device_id)
if is_nil(device) || Enum.empty?(device.interfaces) do
[]
else
current_stats = poll_interface_stats_parallel(device.interfaces, client_opts)
if assigns.previous_interface_stats do
calculate_traffic_bps_from_stats(
assigns.previous_interface_stats,
current_stats,
timestamp_ms
)
else
[]
end
end
end
@doc """
Poll traffic with socket state update.
Returns `{data_points, updated_stats}` for the caller to assign back.
"""
def poll_traffic_with_state(assigns, client_opts, timestamp_ms) do
device = Snmp.get_device_with_associations(assigns.device_id)
if is_nil(device) || Enum.empty?(device.interfaces) do
{[], nil}
else
current_stats = poll_interface_stats_parallel(device.interfaces, client_opts)
data_points =
if assigns.previous_interface_stats do
calculate_traffic_bps_from_stats(
assigns.previous_interface_stats,
current_stats,
timestamp_ms
)
else
[]
end
{data_points, current_stats}
end
end
@doc """
Request live poll from agent via PubSub and wait for response.
"""
def request_agent_live_poll(device_id, agent_token_id, assigns) do
reply_topic = "live_poll_reply:#{:erlang.unique_integer([:positive])}"
:ok = Phoenix.PubSub.subscribe(Towerops.PubSub, reply_topic)
sensors = get_sensors_for_live_mode(assigns)
sensor_oids = Enum.map(sensors, & &1.sensor_oid)
_ =
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"agent:#{agent_token_id}:live_poll",
{:live_poll_requested, device_id, sensor_oids, reply_topic}
)
receive do
{:live_poll_result, oid_values} ->
process_live_poll_oids(sensors, oid_values)
after
3_000 ->
Logger.warning("Live poll timeout waiting for agent response")
[]
end
end
@doc """
Get sensors for the current live mode graph view.
"""
def get_sensors_for_live_mode(%{sensor_id: sensor_id}) when not is_nil(sensor_id) do
case Snmp.get_sensor(sensor_id) do
nil -> []
sensor -> [sensor]
end
end
def get_sensors_for_live_mode(%{sensor_type: sensor_type, device_id: device_id}) do
sensor_types = get_sensor_types_for_live_mode(sensor_type)
device = Snmp.get_device_with_associations(device_id)
if is_nil(sensor_types) || is_nil(device) do
[]
else
device.sensors
|> Enum.filter(&(&1.sensor_type in sensor_types))
|> Enum.sort_by(& &1.sensor_index)
end
end
@doc """
Update the rolling live data buffer with new points.
"""
def update_buffer(buffer, current_count, new_points, max_points \\ @max_live_points)
def update_buffer(buffer, current_count, new_points, max_points) do
updated_buffer =
Enum.reduce(new_points, buffer, fn point, acc ->
sensor_buffer = Map.get(acc, point.sensor_id, [])
new_buffer = [
%{x: point.timestamp, y: point.value} | sensor_buffer
]
trimmed_buffer =
if current_count >= max_points do
Enum.take(new_buffer, max_points)
else
new_buffer
end
Map.put(acc, point.sensor_id, trimmed_buffer)
end)
new_count = min(current_count + 1, max_points)
{updated_buffer, new_count}
end
# ── Private helpers ──
defp poll_sensor_to_data_point(sensor, client_opts, timestamp_ms) do
case poll_single_sensor(sensor, client_opts) do
{:ok, value} ->
%{
sensor_id: sensor.id,
label: sensor.sensor_descr,
value: Float.round(value, 1),
timestamp: timestamp_ms
}
{:error, _reason} ->
nil
end
end
defp poll_single_sensor(sensor, client_opts) do
case Snmp.Client.get(client_opts, sensor.sensor_oid) do
{:ok, raw_value} when is_number(raw_value) ->
value = raw_value / sensor.sensor_divisor
{:ok, value}
{:ok, _non_numeric} ->
{:error, :non_numeric}
{:error, reason} ->
{:error, reason}
end
end
defp poll_interface_stats_parallel(interfaces, client_opts) do
interfaces
|> Task.async_stream(&poll_single_interface_octets(&1, client_opts),
max_concurrency: 10,
timeout: 4_000,
on_timeout: :kill_task
)
|> Enum.reduce([], fn
{:ok, nil}, acc -> acc
{:ok, stat}, acc -> [stat | acc]
{:exit, _}, acc -> acc
end)
end
defp poll_single_interface_octets(interface, client_opts) do
hc_opts =
case Keyword.get(client_opts, :version) do
v when v in ["1", :v1] -> Keyword.put(client_opts, :version, "2c")
_ -> client_opts
end
in_octets_oid = "1.3.6.1.2.1.31.1.1.1.6.#{interface.if_index}"
out_octets_oid = "1.3.6.1.2.1.31.1.1.1.10.#{interface.if_index}"
with {:ok, in_octets} <- Snmp.Client.get(hc_opts, in_octets_oid),
{:ok, out_octets} <- Snmp.Client.get(hc_opts, out_octets_oid),
true <- is_number(in_octets) and is_number(out_octets) do
build_interface_stat(interface, in_octets, out_octets)
else
_ -> poll_single_interface_octets_32bit(interface, client_opts)
end
end
defp poll_single_interface_octets_32bit(interface, client_opts) do
in_octets_oid = "1.3.6.1.2.1.2.2.1.10.#{interface.if_index}"
out_octets_oid = "1.3.6.1.2.1.2.2.1.16.#{interface.if_index}"
with {:ok, in_octets} <- Snmp.Client.get(client_opts, in_octets_oid),
{:ok, out_octets} <- Snmp.Client.get(client_opts, out_octets_oid),
true <- is_number(in_octets) and is_number(out_octets) do
build_interface_stat(interface, in_octets, out_octets)
else
_ -> nil
end
end
defp build_interface_stat(interface, in_octets, out_octets) do
%{
interface_id: interface.id,
interface_name: interface.if_name || interface.if_descr,
if_in_octets: in_octets,
if_out_octets: out_octets,
polled_at: DateTime.utc_now()
}
end
defp calculate_traffic_bps_from_stats(previous_stats, current_stats, timestamp_ms) do
previous_map = Map.new(previous_stats, &{&1.interface_id, &1})
bps_per_interface =
Enum.flat_map(current_stats, fn current ->
calculate_interface_bps_if_previous_exists(current, previous_map)
end)
total_in = Enum.reduce(bps_per_interface, 0.0, fn x, acc -> acc + x.in_bps end)
total_out = Enum.reduce(bps_per_interface, 0.0, fn x, acc -> acc + x.out_bps end)
[
%{
sensor_id: "traffic_in",
label: "Inbound",
value: Float.round(total_in, 2),
timestamp: timestamp_ms
},
%{
sensor_id: "traffic_out",
label: "Outbound",
value: Float.round(total_out, 2),
timestamp: timestamp_ms
}
]
end
defp calculate_interface_bps_if_previous_exists(current, previous_map) do
case Map.get(previous_map, current.interface_id) do
nil ->
[]
previous ->
time_diff = current.polled_at |> DateTime.diff(previous.polled_at, :second) |> max(1)
in_bps = calculate_live_bps(current.if_in_octets, previous.if_in_octets, time_diff)
out_bps = calculate_live_bps(current.if_out_octets, previous.if_out_octets, time_diff)
[%{interface_id: current.interface_id, in_bps: in_bps, out_bps: out_bps}]
end
end
defp calculate_live_bps(current_octets, previous_octets, time_diff) do
if current_octets && previous_octets do
Towerops.Capacity.calculate_bps(previous_octets, current_octets, time_diff)
else
0.0
end
end
defp process_live_poll_oids(sensors, oid_values) do
timestamp_ms = DateTime.to_unix(DateTime.utc_now(), :millisecond)
sensors
|> Enum.map(&build_live_poll_data_point(&1, oid_values, timestamp_ms))
|> Enum.reject(&is_nil/1)
end
defp build_live_poll_data_point(sensor, oid_values, timestamp_ms) do
with value when not is_nil(value) <- Map.get(oid_values, sensor.sensor_oid),
parsed_value when not is_nil(parsed_value) <- parse_sensor_value(value) do
final_value = parsed_value / sensor.sensor_divisor
%{
sensor_id: sensor.id,
label: sensor.sensor_descr,
value: Float.round(final_value, 1),
timestamp: timestamp_ms
}
else
_ -> nil
end
end
defp parse_sensor_value(value) do
case Towerops.Numeric.parse_float(value) do
{:ok, f} -> f
:error -> nil
end
end
defp get_sensor_types_for_live_mode("processors"), do: ["cpu_load"]
defp get_sensor_types_for_live_mode("memory"), do: ["memory_usage"]
defp get_sensor_types_for_live_mode("storage"), do: ["disk_usage"]
defp get_sensor_types_for_live_mode("temperature"), do: ["temperature", "cpu_temperature", "celsius"]
defp get_sensor_types_for_live_mode("voltage"), do: ["voltage", "volts"]
defp get_sensor_types_for_live_mode("traffic"), do: nil
defp get_sensor_types_for_live_mode(sensor_type), do: [sensor_type]
end

View file

@ -7,12 +7,10 @@ defmodule ToweropsWeb.GraphLive.Show do
alias Towerops.Devices.DeviceSchema
alias Towerops.Monitoring
alias Towerops.Snmp
alias ToweropsWeb.GraphLive.Polling
require Logger
# Maximum live data points (5 minutes at 1 second intervals)
@max_live_points 300
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
@ -1062,406 +1060,43 @@ defmodule ToweropsWeb.GraphLive.Show do
now = DateTime.utc_now()
timestamp_ms = DateTime.to_unix(now, :millisecond)
# Check effective agent (device → site → org cascade)
agent_token_id = Agents.get_effective_agent_token(device)
Logger.info("Live poll for device #{device.name}: agent_token_id=#{inspect(agent_token_id)}")
{new_points, updated_socket} =
{new_points, updated_stats} =
if agent_token_id do
# Request agent to poll NOW and wait for result
Logger.info("Requesting live poll from agent")
points = request_agent_live_poll_and_wait(device.id, agent_token_id, socket.assigns)
{points, socket}
points = Polling.request_agent_live_poll(device.id, agent_token_id, socket.assigns)
{points, nil}
else
# Do direct SNMP polling
Logger.info("Using direct SNMP for live polling")
client_opts = build_snmp_client_opts(device)
client_opts = Polling.build_snmp_client_opts(device)
# For traffic graphs, we need to store interface stats for next poll
if socket.assigns.sensor_type == "traffic" do
poll_traffic_with_state_update(socket, client_opts, timestamp_ms)
{points, stats} = Polling.poll_traffic_with_state(socket.assigns, client_opts, timestamp_ms)
{points, stats}
else
points = poll_sensors_for_live_mode(socket.assigns, client_opts, timestamp_ms)
{points, socket}
points = Polling.poll_sensors(socket.assigns, client_opts, timestamp_ms)
{points, nil}
end
end
Logger.info("Live poll fetched #{length(new_points)} data points: #{inspect(new_points)}")
# Update buffer with new points (rolling window)
{updated_buffer, point_count} =
update_live_data_buffer(
updated_socket.assigns.live_data_buffer,
updated_socket.assigns.live_data_points,
Polling.update_buffer(
socket.assigns.live_data_buffer,
socket.assigns.live_data_points,
new_points
)
# Push update to client
updated_socket
socket
|> assign(:live_data_buffer, updated_buffer)
|> assign(:live_data_points, point_count)
|> assign_previous_stats(updated_stats)
|> push_event("live_data_update", %{
timestamp: timestamp_ms,
points: new_points
})
end
# Request live poll from agent and wait for response
defp request_agent_live_poll_and_wait(device_id, agent_token_id, assigns) do
# Generate unique reply topic for this request
reply_topic = "live_poll_reply:#{:erlang.unique_integer([:positive])}"
# Subscribe to reply topic
:ok = Phoenix.PubSub.subscribe(Towerops.PubSub, reply_topic)
# Get sensors we're interested in
sensors = get_sensors_for_live_mode(assigns)
sensor_oids = Enum.map(sensors, & &1.sensor_oid)
# Broadcast live poll request with reply topic and sensor OIDs
_ =
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"agent:#{agent_token_id}:live_poll",
{:live_poll_requested, device_id, sensor_oids, reply_topic}
)
# Wait for response with timeout
receive do
{:live_poll_result, oid_values} ->
# Process the OID values into data points
process_live_poll_oids(sensors, oid_values)
after
3000 ->
Logger.warning("Live poll timeout waiting for agent response")
[]
end
end
# Convert OID values from agent into data points
defp process_live_poll_oids(sensors, oid_values) do
timestamp_ms = DateTime.to_unix(DateTime.utc_now(), :millisecond)
sensors
|> Enum.map(&build_live_poll_data_point(&1, oid_values, timestamp_ms))
|> Enum.reject(&is_nil/1)
end
defp build_live_poll_data_point(sensor, oid_values, timestamp_ms) do
with value when not is_nil(value) <- Map.get(oid_values, sensor.sensor_oid),
parsed_value when not is_nil(parsed_value) <- parse_sensor_value(value) do
final_value = parsed_value / sensor.sensor_divisor
%{
sensor_id: sensor.id,
label: sensor.sensor_descr,
value: Float.round(final_value, 1),
timestamp: timestamp_ms
}
else
_ -> nil
end
end
defp parse_sensor_value(value) do
case Towerops.Numeric.parse_float(value) do
{:ok, f} -> f
:error -> nil
end
end
# Build SNMP client options
defp build_snmp_client_opts(device) do
snmp_config = Devices.get_snmp_config(device)
[
ip: device.ip_address,
community: snmp_config.community,
version: snmp_config.version,
port: device.snmp_port,
# Increased for live polling - devices may be slow to respond
timeout: 3000
]
end
# Poll sensors in parallel
defp poll_sensors_for_live_mode(assigns, client_opts, timestamp_ms) do
# Special handling for traffic graphs
if assigns.sensor_type == "traffic" do
poll_traffic_for_live_mode(assigns, client_opts, timestamp_ms)
else
poll_regular_sensors_for_live_mode(assigns, client_opts, timestamp_ms)
end
end
# Poll regular sensors (non-traffic) in parallel
defp poll_regular_sensors_for_live_mode(assigns, client_opts, timestamp_ms) do
sensors = get_sensors_for_live_mode(assigns)
sensors
|> Task.async_stream(&poll_sensor_to_data_point(&1, client_opts, timestamp_ms),
max_concurrency: 10,
timeout: 4000,
on_timeout: :kill_task
)
|> Enum.reduce([], fn
{:ok, nil}, acc -> acc
{:ok, point}, acc -> [point | acc]
{:exit, _reason}, acc -> acc
end)
end
# Poll a single sensor and convert to data point
defp poll_sensor_to_data_point(sensor, client_opts, timestamp_ms) do
case poll_single_sensor(sensor, client_opts) do
{:ok, value} ->
%{
sensor_id: sensor.id,
label: sensor.sensor_descr,
value: Float.round(value, 1),
timestamp: timestamp_ms
}
{:error, _reason} ->
nil
end
end
# Poll traffic stats and update socket with current stats for next poll
# Returns {data_points, updated_socket}
defp poll_traffic_with_state_update(socket, client_opts, timestamp_ms) do
device = Snmp.get_device_with_associations(socket.assigns.device_id)
if is_nil(device) || Enum.empty?(device.interfaces) do
{[], socket}
else
# Poll all interface stats
current_stats = poll_interface_stats_parallel(device.interfaces, client_opts)
# If we have previous stats, calculate BPS
data_points =
if socket.assigns.previous_interface_stats do
calculate_traffic_bps_from_stats(
socket.assigns.previous_interface_stats,
current_stats,
timestamp_ms
)
else
# First poll - no previous stats, so no BPS to calculate yet
[]
end
# Update socket with current stats for next poll
updated_socket = assign(socket, :previous_interface_stats, current_stats)
{data_points, updated_socket}
end
end
# Poll traffic stats for live mode (legacy - used for non-state-updating path)
# Requires two readings to calculate BPS, so uses previous_interface_stats from socket assigns
defp poll_traffic_for_live_mode(assigns, client_opts, timestamp_ms) do
device = Snmp.get_device_with_associations(assigns.device_id)
if is_nil(device) || Enum.empty?(device.interfaces) do
[]
else
# Poll all interface stats
current_stats = poll_interface_stats_parallel(device.interfaces, client_opts)
# If we have previous stats, calculate BPS
if assigns.previous_interface_stats do
calculate_traffic_bps_from_stats(
assigns.previous_interface_stats,
current_stats,
timestamp_ms
)
else
# First poll - no previous stats, so no BPS to calculate yet
[]
end
end
end
# Poll interface octets for all interfaces in parallel
# Uses 64-bit counters (ifHCInOctets/ifHCOutOctets) for better accuracy on high-speed interfaces
defp poll_interface_stats_parallel(interfaces, client_opts) do
interfaces
|> Task.async_stream(&poll_single_interface_octets(&1, client_opts),
max_concurrency: 10,
timeout: 4000,
on_timeout: :kill_task
)
|> Enum.reduce([], fn
{:ok, nil}, acc -> acc
{:ok, stat}, acc -> [stat | acc]
{:exit, _}, acc -> acc
end)
end
# Poll octets for a single interface (try 64-bit, fall back to 32-bit)
defp poll_single_interface_octets(interface, client_opts) do
# Counter64 (HC) can't be encoded in SNMPv1 — upgrade to v2c for HC queries
hc_opts =
case Keyword.get(client_opts, :version) do
v when v in ["1", :v1] -> Keyword.put(client_opts, :version, "2c")
_ -> client_opts
end
in_octets_oid = "1.3.6.1.2.1.31.1.1.1.6.#{interface.if_index}"
out_octets_oid = "1.3.6.1.2.1.31.1.1.1.10.#{interface.if_index}"
with {:ok, in_octets} <- Snmp.Client.get(hc_opts, in_octets_oid),
{:ok, out_octets} <- Snmp.Client.get(hc_opts, out_octets_oid),
true <- is_number(in_octets) and is_number(out_octets) do
build_interface_stat(interface, in_octets, out_octets)
else
_ -> poll_single_interface_octets_32bit(interface, client_opts)
end
end
# Fall back to 32-bit counters
defp poll_single_interface_octets_32bit(interface, client_opts) do
in_octets_oid = "1.3.6.1.2.1.2.2.1.10.#{interface.if_index}"
out_octets_oid = "1.3.6.1.2.1.2.2.1.16.#{interface.if_index}"
with {:ok, in_octets} <- Snmp.Client.get(client_opts, in_octets_oid),
{:ok, out_octets} <- Snmp.Client.get(client_opts, out_octets_oid),
true <- is_number(in_octets) and is_number(out_octets) do
build_interface_stat(interface, in_octets, out_octets)
else
_ -> nil
end
end
# Build interface stat map
defp build_interface_stat(interface, in_octets, out_octets) do
%{
interface_id: interface.id,
interface_name: interface.if_name || interface.if_descr,
if_in_octets: in_octets,
if_out_octets: out_octets,
polled_at: DateTime.utc_now()
}
end
# Calculate BPS from two sets of interface stats
defp calculate_traffic_bps_from_stats(previous_stats, current_stats, timestamp_ms) do
# Build map of previous stats for quick lookup
previous_map = Map.new(previous_stats, &{&1.interface_id, &1})
# Calculate BPS for each interface that has both previous and current stats
bps_per_interface =
Enum.flat_map(current_stats, fn current ->
calculate_interface_bps_if_previous_exists(current, previous_map)
end)
# Aggregate total traffic across all interfaces
total_in = Enum.reduce(bps_per_interface, 0.0, fn x, acc -> acc + x.in_bps end)
total_out = Enum.reduce(bps_per_interface, 0.0, fn x, acc -> acc + x.out_bps end)
# Return two data points: inbound and outbound
[
%{
sensor_id: "traffic_in",
label: "Inbound",
value: Float.round(total_in, 2),
timestamp: timestamp_ms
},
%{
sensor_id: "traffic_out",
label: "Outbound",
value: Float.round(total_out, 2),
timestamp: timestamp_ms
}
]
end
# Calculate BPS for an interface if previous stat exists
defp calculate_interface_bps_if_previous_exists(current, previous_map) do
case Map.get(previous_map, current.interface_id) do
nil ->
[]
previous ->
time_diff = current.polled_at |> DateTime.diff(previous.polled_at, :second) |> max(1)
in_bps = calculate_live_bps(current.if_in_octets, previous.if_in_octets, time_diff)
out_bps = calculate_live_bps(current.if_out_octets, previous.if_out_octets, time_diff)
[%{interface_id: current.interface_id, in_bps: in_bps, out_bps: out_bps}]
end
end
defp calculate_live_bps(current_octets, previous_octets, time_diff) do
if current_octets && previous_octets do
Towerops.Capacity.calculate_bps(previous_octets, current_octets, time_diff)
else
0.0
end
end
# Get sensors based on graph type
defp get_sensors_for_live_mode(%{sensor_id: sensor_id}) when not is_nil(sensor_id) do
case Snmp.get_sensor(sensor_id) do
nil -> []
sensor -> [sensor]
end
end
defp get_sensors_for_live_mode(%{sensor_type: sensor_type, device_id: device_id}) do
sensor_types = get_sensor_types_for_chart(sensor_type)
device = Snmp.get_device_with_associations(device_id)
# Traffic graphs don't have sensors (return empty list)
if is_nil(sensor_types) || is_nil(device) do
[]
else
device.sensors
|> Enum.filter(&(&1.sensor_type in sensor_types))
|> Enum.sort_by(& &1.sensor_index)
end
end
# Poll single sensor via SNMP
defp poll_single_sensor(sensor, client_opts) do
case Snmp.Client.get(client_opts, sensor.sensor_oid) do
{:ok, raw_value} when is_number(raw_value) ->
value = raw_value / sensor.sensor_divisor
{:ok, value}
{:ok, _non_numeric} ->
{:error, :non_numeric}
{:error, reason} ->
{:error, reason}
end
end
# Update live data buffer with rolling window
defp update_live_data_buffer(buffer, current_count, new_points) do
updated_buffer =
Enum.reduce(new_points, buffer, fn point, acc ->
sensor_buffer = Map.get(acc, point.sensor_id, [])
# Add new point at the front
new_buffer = [
%{x: point.timestamp, y: point.value} | sensor_buffer
]
# Trim to max size
trimmed_buffer =
if current_count >= @max_live_points do
Enum.take(new_buffer, @max_live_points)
else
new_buffer
end
Map.put(acc, point.sensor_id, trimmed_buffer)
end)
new_count = min(current_count + 1, @max_live_points)
{updated_buffer, new_count}
end
defp assign_previous_stats(socket, nil), do: socket
defp assign_previous_stats(socket, stats), do: assign(socket, :previous_interface_stats, stats)
# Cleanup on LiveView termination
@impl true

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,99 @@
defmodule ToweropsWeb.HelpLive.Sections.About do
@moduledoc false
use ToweropsWeb, :html
def render(assigns) do
~H"""
<div class="p-6">
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-4">About Towerops</h2>
<div class="prose prose-sm dark:prose-invert max-w-none">
<p class="text-gray-600 dark:text-gray-400">
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.
</p>
<p class="text-gray-600 dark:text-gray-400">
Our promise to you:
<ul class="list-disc list-inside">
<li>No random marketing emails or sign up for our newsletter popups.</li>
<li>No trackers EVER, no external libraries used.</li>
<li>No ads or sponsored content.</li>
<li>No data collection or sharing.</li>
<li>
Our remote agent is
<a href="https://codeberg.org/towerops-agent/towerops-agent">
fully open source
</a>
and ONLY collects monitoring jobs from the server and WILL NEVER allow us to access any part of your internal network.
</li>
</ul>
</p>
<p class="text-gray-600 dark:text-gray-400 mt-4">
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 managementall with a modern
interface that makes complex monitoring workflows simple and accessible.
</p>
<p class="text-gray-600 dark:text-gray-400 mt-4">
Towerops is not and has no plans to be a replacement for a full WISP/network billing system.
</p>
<div class="mt-6 grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="p-4 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">
Network Monitoring
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">
Like LibreNMS, discover and monitor network devices via SNMP with support for
multi-vendor equipment, interface statistics, and topology mapping.
</p>
</div>
<div class="p-4 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">
Health Checks & Alerting
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">
Like Icinga2/Nagios, configure flexible monitoring checks with customizable thresholds
and alert conditions for any metric or device state.
</p>
</div>
<div class="p-4 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">
Incident Management
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">
Like PagerDuty, manage incidents with intelligent alerting, escalation policies,
and notification routing to keep your team informed.
</p>
</div>
</div>
<div class="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div class="flex gap-3">
<.icon
name="hero-information-circle"
class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
/>
<div>
<p class="text-sm text-blue-800 dark:text-blue-300">
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
</.link>
guide to begin monitoring your network infrastructure.
</p>
</div>
</div>
</div>
</div>
</div>
"""
end
end

View file

@ -0,0 +1,227 @@
defmodule ToweropsWeb.HelpLive.Sections.Agents do
@moduledoc false
use ToweropsWeb, :html
import ToweropsWeb.HelpLive.Sections.Helpers
def render(assigns) do
~H"""
<div class="p-6">
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Remote Pollers</h2>
<div class="prose prose-sm dark:prose-invert max-w-none">
<p class="text-gray-600 dark:text-gray-400">
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</.code>
and you're done.
</p>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6 mb-3">Requirements</h3>
<p class="text-gray-600 dark:text-gray-400">
Remote pollers can run on any system that supports Docker Compose, including:
</p>
<ul class="space-y-2 text-gray-600 dark:text-gray-400 list-disc list-inside">
<li>Linux servers (Ubuntu, Debian, CentOS, RHEL, etc.)</li>
<li>Windows Server with Docker Desktop</li>
<li>macOS with Docker Desktop</li>
<li>Raspberry Pi or other ARM-based systems</li>
<li>Virtual machines (VMware, Hyper-V, Proxmox, etc.)</li>
</ul>
<div class="mt-4 p-4 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg">
<p class="text-sm text-gray-600 dark:text-gray-400">
<strong class="text-gray-900 dark:text-white">Minimum requirements:</strong>
1 CPU core, 512 MB RAM, and network access to your devices
</p>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6 mb-3">Getting Started</h3>
<div class="space-y-6">
<div class="flex gap-4">
<div class="flex-shrink-0">
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 font-semibold">
1
</div>
</div>
<div class="flex-1">
<h4 class="text-base font-semibold text-gray-900 dark:text-white mb-2">
Create an Agent Token
</h4>
<p class="text-gray-600 dark:text-gray-400">
Navigate to the
<.code>Agents</.code>
page in Towerops and create a new agent token. This token authenticates your remote poller with the Towerops platform.
</p>
</div>
</div>
<div class="flex gap-4">
<div class="flex-shrink-0">
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 font-semibold">
2
</div>
</div>
<div class="flex-1">
<h4 class="text-base font-semibold text-gray-900 dark:text-white mb-2">
Create a docker-compose.yml
</h4>
<p class="text-gray-600 dark:text-gray-400">
When you create a new agent on the
<.code>Agents</.code>
page, Towerops will show you a ready-to-use
<.code>docker-compose.yml</.code>
with your agent token pre-filled. It looks like this:
</p>
<div class="mt-3 p-4 bg-black rounded-lg not-prose overflow-x-auto">
<pre class="text-xs text-green-400 font-mono"><code><%= 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]) %></code></pre>
</div>
<p class="text-gray-600 dark:text-gray-400 mt-3">
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.
</p>
</div>
</div>
<div class="flex gap-4">
<div class="flex-shrink-0">
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 font-semibold">
3
</div>
</div>
<div class="flex-1">
<h4 class="text-base font-semibold text-gray-900 dark:text-white mb-2">Deploy It</h4>
<p class="text-gray-600 dark:text-gray-400">
Copy the
<.code>docker-compose.yml</.code>
to your server and run:
</p>
<div class="mt-3 p-4 bg-black rounded-lg not-prose">
<pre class="text-sm text-white font-mono"><code>docker compose up -d</code></pre>
</div>
<p class="text-gray-600 dark:text-gray-400 mt-3">
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.
</p>
</div>
</div>
<div class="flex gap-4">
<div class="flex-shrink-0">
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 font-semibold">
4
</div>
</div>
<div class="flex-1">
<h4 class="text-base font-semibold text-gray-900 dark:text-white mb-2">
Assign Devices to Remote Poller
</h4>
<p class="text-gray-600 dark:text-gray-400">
You can assign devices to your remote poller in several ways:
</p>
<ul class="space-y-2 text-gray-600 dark:text-gray-400 list-disc list-inside mt-2">
<li>
<strong class="text-gray-900 dark:text-white">Per Organization:</strong>
Set a default agent for all devices in your organization
</li>
<li>
<strong class="text-gray-900 dark:text-white">Per Site:</strong>
Set a default agent for all devices at a specific site
</li>
<li>
<strong class="text-gray-900 dark:text-white">Per Device:</strong>
Select the remote poller when creating or editing individual devices
</li>
</ul>
<p class="text-gray-600 dark:text-gray-400 mt-3">
Devices assigned to a remote poller will be monitored from your local network instead
of from the cloud.
</p>
</div>
</div>
</div>
<div class="mt-8 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div class="flex gap-3">
<.icon
name="hero-shield-check"
class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
/>
<div>
<h4 class="text-sm font-semibold text-blue-900 dark:text-blue-300 mb-1">
Security Note
</h4>
<p class="text-sm text-blue-800 dark:text-blue-300">
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.
</p>
</div>
</div>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-8 mb-3">
Managing Remote Pollers
</h3>
<p class="text-gray-600 dark:text-gray-400">
You can view the status of your remote pollers on the Agents page. The page shows:
</p>
<ul class="space-y-2 text-gray-600 dark:text-gray-400 list-disc list-inside mt-3">
<li>Last connection time</li>
<li>Number of devices assigned to each poller</li>
<li>Connection status (online/offline)</li>
</ul>
<div class="mt-6 p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<div class="flex gap-3">
<.icon
name="hero-exclamation-triangle"
class="h-5 w-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5"
/>
<div>
<h4 class="text-sm font-semibold text-yellow-900 dark:text-yellow-300 mb-1">
Important
</h4>
<p class="text-sm text-yellow-800 dark:text-yellow-300">
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.
</p>
</div>
</div>
</div>
</div>
</div>
"""
end
end

View file

@ -0,0 +1,59 @@
defmodule ToweropsWeb.HelpLive.Sections.ApiTokens do
@moduledoc false
use ToweropsWeb, :html
def render(assigns) do
~H"""
<div class="p-6">
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-4">API Tokens</h2>
<p class="text-gray-600 dark:text-gray-400 mb-4">
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.
</p>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6 mb-3">Creating a Token</h3>
<ol class="list-decimal list-inside text-gray-600 dark:text-gray-400 space-y-2 mb-4">
<li>Go to <strong>User Settings API Tokens</strong></li>
<li>Click <strong>"Create API Token"</strong></li>
<li>Give the token a descriptive name (e.g., "Grafana Integration")</li>
<li>Copy the token immediately it will only be shown once</li>
</ol>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6 mb-3">Using a Token</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">
Include the token in the
<code class="rounded bg-gray-100 px-1.5 py-0.5 text-sm font-mono dark:bg-gray-700">
Authorization
</code>
header:
</p>
<div class="rounded-lg bg-gray-900 p-4 mb-4">
<pre class="text-sm text-green-400 overflow-x-auto"><code>Authorization: Bearer your-api-token-here</code></pre>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6 mb-3">
Security Best Practices
</h3>
<ul class="list-disc list-inside text-gray-600 dark:text-gray-400 space-y-2 mb-4">
<li>
Create separate tokens for each integration don't reuse tokens across services
</li>
<li>Revoke tokens you no longer need</li>
<li>Never commit tokens to version control</li>
<li>Store tokens in environment variables or a secrets manager</li>
</ul>
<div class="mt-6 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4">
<p class="text-sm text-blue-700 dark:text-blue-300">
<strong>See also:</strong>
<.link navigate={~p"/help?section=rest-api"} class="underline">REST API</.link>
and
<.link navigate={~p"/help?section=graphql-api"} class="underline">
GraphQL API
</.link>
for endpoint documentation.
</p>
</div>
</div>
"""
end
end

View file

@ -0,0 +1,79 @@
defmodule ToweropsWeb.HelpLive.Sections.CloudPollers do
@moduledoc false
use ToweropsWeb, :html
def render(assigns) do
~H"""
<div class="p-6">
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Cloud Pollers</h2>
<div class="prose prose-sm dark:prose-invert max-w-none">
<p class="text-gray-600 dark:text-gray-400">
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.
</p>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6 mb-3">
Active Cloud Pollers
</h3>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 overflow-hidden">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-gray-800/75">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Location
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
IPv4 Address
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
IPv6 Address
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
DFW (Dallas-Fort Worth)
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-600 dark:text-gray-400">
144.202.64.79
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-600 dark:text-gray-400">
2001:19f0:6401:0af2:5400:05ff:fee7:1bfb
</td>
</tr>
</tbody>
</table>
</div>
<div class="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div class="flex gap-3">
<.icon
name="hero-information-circle"
class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
/>
<div>
<h4 class="text-sm font-semibold text-blue-900 dark:text-blue-300 mb-1">
Firewall Configuration
</h4>
<p class="text-sm text-blue-800 dark:text-blue-300">
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
</.link>
instead.
</p>
</div>
</div>
</div>
</div>
</div>
"""
end
end

View file

@ -0,0 +1,127 @@
defmodule ToweropsWeb.HelpLive.Sections.GettingStarted do
@moduledoc false
use ToweropsWeb, :html
import ToweropsWeb.HelpLive.Sections.Helpers
def render(assigns) do
~H"""
<div class="p-6">
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-4">
Getting Started with Towerops
</h2>
<div class="prose prose-sm dark:prose-invert max-w-none">
<p class="text-gray-600 dark:text-gray-400">
Towerops is a network monitoring platform that helps you keep track of your network devices,
monitor their health, and get alerts when issues occur.
</p>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6 mb-3">
Quick Start Guide
</h3>
<div class="space-y-6">
<div class="flex gap-4">
<div class="flex-shrink-0">
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 font-semibold">
1
</div>
</div>
<div class="flex-1">
<h4 class="text-base font-semibold text-gray-900 dark:text-white mb-2">
Create a Site (Optional)
</h4>
<p class="text-gray-600 dark:text-gray-400">
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.
</p>
<p class="text-sm text-gray-500 dark:text-gray-500 mt-2">
Navigate to
<.code>Sites</.code>
<.code>Add Site</.code>
</p>
<p class="text-sm text-gray-500 dark:text-gray-500 mt-1">
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
</.link>
General tab.
</p>
</div>
</div>
<div class="flex gap-4">
<div class="flex-shrink-0">
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 font-semibold">
2
</div>
</div>
<div class="flex-1">
<h4 class="text-base font-semibold text-gray-900 dark:text-white mb-2">
Add Your First Device
</h4>
<p class="text-gray-600 dark:text-gray-400">
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).
</p>
<p class="text-sm text-gray-500 dark:text-gray-500 mt-2">
Navigate to
<.code>Devices</.code>
<.code>Add Device</.code>
</p>
</div>
</div>
<div class="flex gap-4">
<div class="flex-shrink-0">
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 font-semibold">
3
</div>
</div>
<div class="flex-1">
<h4 class="text-base font-semibold text-gray-900 dark:text-white mb-2">
View Device Metrics
</h4>
<p class="text-gray-600 dark:text-gray-400">
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.
</p>
</div>
</div>
</div>
<div class="mt-8 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div class="flex gap-3">
<.icon
name="hero-light-bulb"
class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
/>
<div>
<h4 class="text-sm font-semibold text-blue-900 dark:text-blue-300 mb-1">Pro Tip</h4>
<p class="text-sm text-blue-800 dark:text-blue-300">
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
</.link>
section for more information.
</p>
</div>
</div>
</div>
</div>
</div>
"""
end
end

View file

@ -0,0 +1,75 @@
defmodule ToweropsWeb.HelpLive.Sections.GraphqlApi do
@moduledoc false
use ToweropsWeb, :html
def render(assigns) do
~H"""
<div class="p-6">
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-4">GraphQL API</h2>
<p class="text-gray-600 dark:text-gray-400 mb-4">
The GraphQL API lets you query exactly the data you need in a single request. The endpoint is
<code class="rounded bg-gray-100 px-1.5 py-0.5 text-sm font-mono dark:bg-gray-700">
POST /api/graphql
</code>
and uses the same API token authentication as the REST API.
</p>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6 mb-3">Why GraphQL?</h3>
<ul class="list-disc list-inside text-gray-600 dark:text-gray-400 space-y-2 mb-4">
<li>
<strong>Fetch related data in one query</strong>
Get devices with their sites, alerts, and latest metrics in a single request
</li>
<li><strong>No over-fetching</strong> Request only the fields you need</li>
<li>
<strong>Introspection</strong>
The schema is self-documenting; use any GraphQL client to explore available types and fields
</li>
</ul>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6 mb-3">Quick Example</h3>
<div class="rounded-lg bg-gray-900 p-4 mb-4">
<pre class="text-sm text-green-400 overflow-x-auto"><code><%= 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 } }\"}'") %></code></pre>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6 mb-3">Available Queries</h3>
<ul class="list-disc list-inside text-gray-600 dark:text-gray-400 space-y-2 mb-4">
<li>
<strong>devices</strong> List devices with filtering and nested site/alert data
</li>
<li>
<strong>device(id)</strong>
Single device with full detail including sensors and interfaces
</li>
<li><strong>sites</strong> Sites with nested device lists</li>
<li>
<strong>alerts</strong> Active and historical alerts with device context
</li>
<li><strong>agents</strong> Remote poller agents and their assignments</li>
</ul>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6 mb-3">Mutations</h3>
<ul class="list-disc list-inside text-gray-600 dark:text-gray-400 space-y-2 mb-4">
<li>
<strong>createDevice</strong> / <strong>updateDevice</strong> Manage devices
</li>
<li>
<strong>acknowledgeAlert</strong>
/ <strong>resolveAlert</strong>
Alert lifecycle management
</li>
</ul>
<div class="mt-6 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4">
<p class="text-sm text-blue-700 dark:text-blue-300">
<strong>Full reference:</strong>
<a href="/docs/graphql" class="underline font-medium">
GraphQL API Documentation
</a>
complete schema reference with query examples and variable usage.
</p>
</div>
</div>
"""
end
end

View file

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

View file

@ -0,0 +1,14 @@
defmodule ToweropsWeb.HelpLive.Sections.Helpers do
@moduledoc false
use ToweropsWeb, :html
slot :inner_block, required: true
def code(assigns) do
~H"""
<span class="font-mono bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded">
{render_slot(@inner_block)}
</span>
"""
end
end

View file

@ -0,0 +1,61 @@
defmodule ToweropsWeb.HelpLive.Sections.Insights do
@moduledoc false
use ToweropsWeb, :html
def render(assigns) do
~H"""
<div class="p-6">
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Network Insights</h2>
<p class="text-gray-600 dark:text-gray-400 mb-4">
Network Insights provides proactive observations about your network health, gathered automatically from all connected data sources.
</p>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6 mb-3">How It Works</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">
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.
</p>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6 mb-3">Insight Types</h3>
<ul class="list-disc list-inside text-gray-600 dark:text-gray-400 space-y-2 mb-4">
<li>
<strong>Reconciliation Findings</strong>
Devices that exist in Towerops but not in Gaiia (or vice versa), data mismatches between systems
</li>
<li>
<strong>Performance Anomalies</strong>
Devices with unusual metric patterns detected by Preseem or SNMP baselines
</li>
<li>
<strong>Configuration Drift</strong>
Detected changes to device configurations that may affect performance
</li>
<li>
<strong>Capacity Warnings</strong>
Access points or links approaching utilization thresholds
</li>
</ul>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6 mb-3">
Filtering & Management
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">
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.
</p>
<p class="text-gray-600 dark:text-gray-400 mb-4">
Dismiss insights you've reviewed to keep the active list clean. Bulk actions let you select multiple insights at once.
</p>
<div class="mt-6 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4">
<p class="text-sm text-blue-700 dark:text-blue-300">
<strong>Tip:</strong>
Insights run on a nightly schedule. Connect your Gaiia and Preseem integrations in
<.link navigate={~p"/help?section=integrations"} class="underline">
Organization Settings
</.link>
to get the most comprehensive insights.
</p>
</div>
</div>
"""
end
end

View file

@ -0,0 +1,338 @@
defmodule ToweropsWeb.HelpLive.Sections.Integrations do
@moduledoc false
use ToweropsWeb, :html
import ToweropsWeb.HelpLive.Sections.Helpers
def render(assigns) do
~H"""
<div class="p-6">
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Integrations</h2>
<div class="prose prose-sm dark:prose-invert max-w-none">
<p class="text-gray-600 dark:text-gray-400">
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>
<.code>Integrations</.code>
tab.
</p>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6 mb-3">Preseem</h3>
<p class="text-gray-600 dark:text-gray-400">
<a
href="https://preseem.com"
class="underline hover:text-blue-900 dark:hover:text-blue-200 text-blue-700 dark:text-blue-300"
>
Preseem
</a>
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.
</p>
<h4 class="text-base font-semibold text-gray-900 dark:text-white mt-4 mb-2">What It Syncs</h4>
<ul class="space-y-2 text-gray-600 dark:text-gray-400 list-disc list-inside">
<li>Subscriber QoE scores and metrics</li>
<li>Access point performance data</li>
<li>Latency, jitter, and packet loss statistics</li>
</ul>
<h4 class="text-base font-semibold text-gray-900 dark:text-white mt-4 mb-2">Configuration</h4>
<div class="space-y-4">
<div class="flex gap-4">
<div class="flex-shrink-0">
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 font-semibold">
1
</div>
</div>
<div class="flex-1">
<h4 class="text-base font-semibold text-gray-900 dark:text-white mb-2">
Get Your Preseem API Key
</h4>
<p class="text-gray-600 dark:text-gray-400">
Log in to your Preseem dashboard and generate an API key from your account settings.
</p>
</div>
</div>
<div class="flex gap-4">
<div class="flex-shrink-0">
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 font-semibold">
2
</div>
</div>
<div class="flex-1">
<h4 class="text-base font-semibold text-gray-900 dark:text-white mb-2">
Add the Integration
</h4>
<p class="text-gray-600 dark:text-gray-400">
Navigate to
<.code>Organization Settings</.code>
<.code>Integrations</.code>
tab.
Enter your Preseem API key and configure the sync interval.
</p>
</div>
</div>
<div class="flex gap-4">
<div class="flex-shrink-0">
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 font-semibold">
3
</div>
</div>
<div class="flex-1">
<h4 class="text-base font-semibold text-gray-900 dark:text-white mb-2">
Test the Connection
</h4>
<p class="text-gray-600 dark:text-gray-400">
Use the <strong class="text-gray-900 dark:text-white">Test Connection</strong> button
to verify your API key is valid and Towerops can reach the Preseem API.
</p>
</div>
</div>
</div>
<div class="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div class="flex gap-3">
<.icon
name="hero-information-circle"
class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
/>
<div>
<h4 class="text-sm font-semibold text-blue-900 dark:text-blue-300 mb-1">
Sync Schedule
</h4>
<p class="text-sm text-blue-800 dark:text-blue-300">
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.
</p>
</div>
</div>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-8 mb-3">Gaiia</h3>
<p class="text-gray-600 dark:text-gray-400">
<a
href="https://gaiia.com"
class="underline hover:text-blue-900 dark:hover:text-blue-200 text-blue-700 dark:text-blue-300"
>
Gaiia
</a>
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.
</p>
<h4 class="text-base font-semibold text-gray-900 dark:text-white mt-4 mb-2">What It Syncs</h4>
<ul class="space-y-2 text-gray-600 dark:text-gray-400 list-disc list-inside">
<li>Subscriber accounts and contact information</li>
<li>Service plans and subscription status</li>
<li>Entity mappings between Gaiia and Towerops devices</li>
</ul>
<h4 class="text-base font-semibold text-gray-900 dark:text-white mt-4 mb-2">Configuration</h4>
<div class="space-y-4">
<div class="flex gap-4">
<div class="flex-shrink-0">
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 font-semibold">
1
</div>
</div>
<div class="flex-1">
<h4 class="text-base font-semibold text-gray-900 dark:text-white mb-2">
Configure Gaiia Credentials
</h4>
<p class="text-gray-600 dark:text-gray-400">
Navigate to
<.code>Organization Settings</.code>
<.code>Integrations</.code>
tab
and enter your Gaiia API credentials.
</p>
</div>
</div>
<div class="flex gap-4">
<div class="flex-shrink-0">
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 font-semibold">
2
</div>
</div>
<div class="flex-1">
<h4 class="text-base font-semibold text-gray-900 dark:text-white mb-2">
Set Up Webhooks
</h4>
<p class="text-gray-600 dark:text-gray-400">
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.
</p>
</div>
</div>
<div class="flex gap-4">
<div class="flex-shrink-0">
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 font-semibold">
3
</div>
</div>
<div class="flex-1">
<h4 class="text-base font-semibold text-gray-900 dark:text-white mb-2">
Entity Mapping & Reconciliation
</h4>
<p class="text-gray-600 dark:text-gray-400">
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.
</p>
</div>
</div>
</div>
<div class="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div class="flex gap-3">
<.icon
name="hero-information-circle"
class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
/>
<div>
<h4 class="text-sm font-semibold text-blue-900 dark:text-blue-300 mb-1">
Sync Schedule
</h4>
<p class="text-sm text-blue-800 dark:text-blue-300">
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.
</p>
</div>
</div>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-8 mb-3">PagerDuty</h3>
<p class="text-gray-600 dark:text-gray-400">
<a
href="https://www.pagerduty.com"
class="underline hover:text-blue-900 dark:hover:text-blue-200 text-blue-700 dark:text-blue-300"
>
PagerDuty
</a>
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.
</p>
<h4 class="text-base font-semibold text-gray-900 dark:text-white mt-4 mb-2">How It Works</h4>
<ul class="space-y-2 text-gray-600 dark:text-gray-400 list-disc list-inside">
<li>Device goes down PagerDuty incident triggered (critical severity)</li>
<li>Alert acknowledged in Towerops PagerDuty incident acknowledged</li>
<li>Device recovers / alert resolved PagerDuty incident resolved</li>
</ul>
<h4 class="text-base font-semibold text-gray-900 dark:text-white mt-4 mb-2">Configuration</h4>
<div class="space-y-4">
<div class="flex gap-4">
<div class="flex-shrink-0">
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 font-semibold">
1
</div>
</div>
<div class="flex-1">
<h4 class="text-base font-semibold text-gray-900 dark:text-white mb-2">
Create an Events API v2 Integration in PagerDuty
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">
In PagerDuty, go to
<.code>Services</.code>
select your service
<.code>Integrations</.code>
tab
<.code>Add Integration</.code>
choose <.code>Events API v2</.code>. Copy the
<.code>Integration Key</.code>
(routing key).
</p>
</div>
</div>
<div class="flex gap-4">
<div class="flex-shrink-0">
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 font-semibold">
2
</div>
</div>
<div class="flex-1">
<h4 class="text-base font-semibold text-gray-900 dark:text-white mb-2">
Configure in Towerops
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">
Navigate to
<.code>Organization Settings</.code>
<.code>Integrations</.code>
tab click
<.code>Configure</.code>
on PagerDuty. Paste your integration key and test the connection.
</p>
</div>
</div>
</div>
<div class="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div class="flex gap-3">
<.icon
name="hero-information-circle"
class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
/>
<div>
<h4 class="text-sm font-semibold text-blue-900 dark:text-blue-300 mb-1">
Event-Driven
</h4>
<p class="text-sm text-blue-800 dark:text-blue-300">
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.
</p>
</div>
</div>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-8 mb-3">
General Integration Features
</h3>
<div class="mt-4 space-y-3">
<div class="p-4 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">Test Connection</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">
Every integration includes a Test Connection button that validates your credentials
and confirms Towerops can communicate with the external service before saving.
</p>
</div>
<div class="p-4 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">Sync Status</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">
The Integrations tab shows the last sync time, sync status, and any errors for
each configured integration.
</p>
</div>
</div>
</div>
</div>
"""
end
end

View file

@ -0,0 +1,334 @@
defmodule ToweropsWeb.HelpLive.Sections.Mikrotik do
@moduledoc false
use ToweropsWeb, :html
import ToweropsWeb.HelpLive.Sections.Helpers
def render(assigns) do
~H"""
<div class="p-6">
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-4">MikroTik Configuration</h2>
<div class="prose prose-sm dark:prose-invert max-w-none">
<p class="text-gray-600 dark:text-gray-400">
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.
</p>
<div class="mt-4 space-y-4">
<div class="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div class="flex gap-3">
<.icon
name="hero-information-circle"
class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
/>
<div>
<h4 class="text-sm font-semibold text-blue-900 dark:text-blue-300 mb-1">
Read-Only Access
</h4>
<p class="text-sm text-blue-800 dark:text-blue-300">
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.
</p>
</div>
</div>
</div>
<div class="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div class="flex gap-3">
<.icon
name="hero-server"
class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
/>
<div>
<h4 class="text-sm font-semibold text-blue-900 dark:text-blue-300 mb-1">
Cloud or Agent-Based Connections
</h4>
<p class="text-sm text-blue-800 dark:text-blue-300">
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.
</p>
</div>
</div>
</div>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6 mb-3">
Security Best Practices
</h3>
<p class="text-gray-600 dark:text-gray-400">
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.
</p>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6 mb-3">
Creating a Read-Only User
</h3>
<p class="text-gray-600 dark:text-gray-400">
Connect to your MikroTik device via SSH or terminal and execute the following commands to
create a read-only user named <.code>towerops</.code>:
</p>
<div class="mt-4 space-y-3">
<div>
<p class="text-sm text-gray-700 dark:text-gray-300 mb-2 font-medium">
Step 1: Create a new user group with read-only permissions
</p>
<div class="p-4 bg-black rounded-lg not-prose">
<div class="flex items-start gap-2">
<code class="text-sm text-white font-mono break-all flex-1">
/user group add name=readonly policy=ssh,read,test,api
</code>
<button
type="button"
phx-click={JS.dispatch("phx:copy", to: "#step1-command")}
class="flex-shrink-0 p-1 text-blue-400 hover:text-blue-300"
title="Copy to clipboard"
>
<.icon name="hero-clipboard-document" class="h-4 w-4" />
</button>
<input
type="hidden"
id="step1-command"
value="/user group add name=readonly policy=ssh,read,test,api"
/>
</div>
</div>
</div>
<div>
<p class="text-sm text-gray-700 dark:text-gray-300 mb-2 font-medium">
Step 2: Create the monitoring user with a strong password
</p>
<div class="p-4 bg-black rounded-lg not-prose">
<div class="flex items-start gap-2">
<code class="text-sm text-white font-mono break-all flex-1">
/user add name=towerops password={if @generated_password,
do: @generated_password,
else: "YOUR_STRONG_PASSWORD"} group=readonly
</code>
<%= if @generated_password do %>
<button
type="button"
phx-click={JS.dispatch("phx:copy", to: "#generated-password-value")}
class="flex-shrink-0 p-1 text-blue-400 hover:text-blue-300"
title="Copy to clipboard"
>
<.icon name="hero-clipboard-document" class="h-4 w-4" />
</button>
<input type="hidden" id="generated-password-value" value={@generated_password} />
<% end %>
</div>
</div>
<div class="mt-3 flex items-start gap-3">
<.button
type="button"
phx-click="generate_password"
disabled={@password_generating}
>
<%= if @password_generating do %>
<.icon name="hero-arrow-path" class="h-4 w-4 mr-1 animate-spin" /> Generating...
<% else %>
<.icon name="hero-sparkles" class="h-4 w-4 mr-1" /> Regenerate Random Password
<% end %>
</.button>
<%= if @generated_password do %>
<p class="text-xs text-yellow-600 dark:text-yellow-400 font-medium mt-1.5">
This truly random password from random.org will only be shown once!
</p>
<% end %>
</div>
</div>
<div>
<p class="text-sm text-gray-700 dark:text-gray-300 mb-2 font-medium">
Step 3: Verify the user was created successfully
</p>
<div class="p-4 bg-black rounded-lg not-prose">
<div class="flex items-start gap-2">
<code class="text-sm text-white font-mono break-all flex-1">
/user print detail where name=towerops
</code>
<button
type="button"
phx-click={JS.dispatch("phx:copy", to: "#step3-command")}
class="flex-shrink-0 p-1 text-blue-400 hover:text-blue-300"
title="Copy to clipboard"
>
<.icon name="hero-clipboard-document" class="h-4 w-4" />
</button>
<input
type="hidden"
id="step3-command"
value="/user print detail where name=towerops"
/>
</div>
</div>
</div>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-8 mb-3">
Permissions Explained
</h3>
<p class="text-gray-600 dark:text-gray-400">
The
<.code>readonly</.code>
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:
</p>
<div class="mt-4 bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 overflow-hidden">
<table class="min-w-full divide-y divide-gray-200 dark:divide-white/10">
<thead class="bg-gray-50 dark:bg-gray-800/75">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Permission
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Description
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-white/10">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900 dark:text-white">
ssh
</td>
<td class="px-6 py-4 text-sm text-gray-600 dark:text-gray-400">
Allow SSH access to the device
</td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900 dark:text-white">
read
</td>
<td class="px-6 py-4 text-sm text-gray-600 dark:text-gray-400">
Allow viewing configuration and status (read-only)
</td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900 dark:text-white">
test
</td>
<td class="px-6 py-4 text-sm text-gray-600 dark:text-gray-400">
Allow executing diagnostic commands (ping, traceroute, etc.)
</td>
</tr>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900 dark:text-white">
api
</td>
<td class="px-6 py-4 text-sm text-gray-600 dark:text-gray-400">
Allow API access for automated monitoring
</td>
</tr>
</tbody>
</table>
</div>
<div class="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div class="flex gap-3">
<.icon
name="hero-information-circle"
class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
/>
<div>
<h4 class="text-sm font-semibold text-blue-900 dark:text-blue-300 mb-1">
Important Notes
</h4>
<ul class="text-sm text-blue-800 dark:text-blue-300 space-y-1 list-disc list-inside">
<li>The read-only user cannot modify device configuration</li>
<li>No write, reboot, or sensitive permissions are granted</li>
<li>Use a strong, unique password for the monitoring account</li>
<li>Consider restricting SSH access by source IP if possible</li>
</ul>
</div>
</div>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-8 mb-3">
Configuring in Towerops
</h3>
<p class="text-gray-600 dark:text-gray-400">
After creating the read-only user, configure SSH credentials in Towerops:
</p>
<div class="mt-4 space-y-4">
<div class="p-4 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">
Organization-Level Configuration
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">
Navigate to
<.code>Organization Settings</.code>
<.code>MikroTik tab</.code>
to set default
SSH credentials for all MikroTik devices in your organization.
</p>
</div>
<div class="p-4 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">
Site-Level Configuration
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">
Navigate to
<.code>Sites</.code>
select a site
<.code>Edit</.code>
to override
credentials for all devices at a specific location.
</p>
</div>
<div class="p-4 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">
Device-Level Configuration
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">
When editing a MikroTik device, you can specify unique SSH credentials that override
organization and site defaults.
</p>
</div>
</div>
<div class="mt-6 p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<div class="flex gap-3">
<.icon
name="hero-shield-check"
class="h-5 w-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5"
/>
<div>
<h4 class="text-sm font-semibold text-yellow-900 dark:text-yellow-300 mb-1">
Security Recommendations
</h4>
<ul class="text-sm text-yellow-800 dark:text-yellow-300 space-y-1 list-disc list-inside">
<li>Always use SSL/TLS for SSH connections (API-SSL on port 8729)</li>
<li>Store credentials at the organization or site level when possible</li>
<li>Rotate passwords periodically following your security policies</li>
<li>Monitor access logs for unauthorized SSH connection attempts</li>
<li>
Consider using SSH keys instead of passwords (if supported by your setup)
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
"""
end
end

View file

@ -0,0 +1,50 @@
defmodule ToweropsWeb.HelpLive.Sections.NetworkMap do
@moduledoc false
use ToweropsWeb, :html
def render(assigns) do
~H"""
<div class="p-6">
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Network Map</h2>
<p class="text-gray-600 dark:text-gray-400 mb-4">
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.
</p>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6 mb-3">
Adding Location Data
</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">
Each site can have an address and/or latitude/longitude coordinates. You can set these in two ways:
</p>
<ul class="list-disc list-inside text-gray-600 dark:text-gray-400 space-y-2 mb-4">
<li>
<strong>Manual entry</strong> Enter latitude and longitude directly in the site edit form
</li>
<li>
<strong>Geocoding</strong>
Enter a street address and click "Geocode" to automatically look up the coordinates using Google Maps
</li>
</ul>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6 mb-3">Geocoding Setup</h3>
<p class="text-gray-600 dark:text-gray-400 mb-4">
Address-to-coordinate conversion requires a Google Maps Geocoding API key. Your administrator sets this as the
<code class="rounded bg-gray-100 px-1.5 py-0.5 text-sm font-mono dark:bg-gray-700">
GOOGLE_MAPS_API_KEY
</code>
environment variable on the server. The map display itself uses OpenStreetMap and requires no API key.
</p>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6 mb-3">Map Features</h3>
<ul class="list-disc list-inside text-gray-600 dark:text-gray-400 space-y-2 mb-4">
<li>Interactive pan and zoom with OpenStreetMap tiles</li>
<li>
Click any site marker to see site name, device count, and a link to the site detail page
</li>
<li>Auto-fits the map to show all your sites</li>
<li>Summary stats showing total sites, mapped sites, and total devices</li>
</ul>
</div>
"""
end
end

View file

@ -0,0 +1,15 @@
defmodule ToweropsWeb.HelpLive.Sections.NotFound do
@moduledoc false
use ToweropsWeb, :html
def render(assigns) do
~H"""
<div class="p-6">
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Section Not Found</h2>
<p class="text-gray-600 dark:text-gray-400">
The requested help section could not be found.
</p>
</div>
"""
end
end

View file

@ -0,0 +1,64 @@
defmodule ToweropsWeb.HelpLive.Sections.RestApi do
@moduledoc false
use ToweropsWeb, :html
def render(assigns) do
~H"""
<div class="p-6">
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-4">REST API</h2>
<p class="text-gray-600 dark:text-gray-400 mb-4">
The Towerops REST API provides programmatic access to your monitoring data. All endpoints are under
<code class="rounded bg-gray-100 px-1.5 py-0.5 text-sm font-mono dark:bg-gray-700">
/api/v1/
</code>
and require a valid API token.
</p>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6 mb-3">
Available Endpoints
</h3>
<ul class="list-disc list-inside text-gray-600 dark:text-gray-400 space-y-2 mb-4">
<li>
<strong>Devices</strong> List, view, create, and update monitored devices
</li>
<li>
<strong>Alerts</strong> Query active and historical alerts, acknowledge, and resolve
</li>
<li><strong>Sites</strong> Manage sites and their device assignments</li>
<li><strong>Agents</strong> List remote pollers and their status</li>
<li>
<strong>Check Results</strong> Query monitoring check results and metrics
</li>
<li>
<strong>Organization Settings</strong> Read and update SNMP/MikroTik configuration
</li>
<li><strong>Members & Invitations</strong> Manage organization membership</li>
<li><strong>Integrations</strong> Configure Preseem, Gaiia, and PagerDuty</li>
<li>
<strong>Activity Feed</strong> Recent events and changes across the organization
</li>
</ul>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6 mb-3">Quick Example</h3>
<div class="rounded-lg bg-gray-900 p-4 mb-4">
<pre class="text-sm text-green-400 overflow-x-auto"><code><%= raw("curl -H \"Authorization: Bearer YOUR_TOKEN\" \\\n https://app.towerops.net/api/v1/devices") %></code></pre>
</div>
<p class="text-gray-600 dark:text-gray-400 mb-4">
All responses use
<code class="rounded bg-gray-100 px-1.5 py-0.5 text-sm font-mono dark:bg-gray-700">
{raw("&#123;\"data\": ...&#125;")}
</code>
format. Results are scoped to the organization associated with your API token.
</p>
<div class="mt-6 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4">
<p class="text-sm text-blue-700 dark:text-blue-300">
<strong>Full reference:</strong>
<a href="/docs/api" class="underline font-medium">REST API Documentation </a>
complete endpoint reference with request/response examples.
</p>
</div>
</div>
"""
end
end

View file

@ -0,0 +1,152 @@
defmodule ToweropsWeb.HelpLive.Sections.Settings do
@moduledoc false
use ToweropsWeb, :html
import ToweropsWeb.HelpLive.Sections.Helpers
def render(assigns) do
~H"""
<div class="p-6">
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Organization Settings</h2>
<div class="prose prose-sm dark:prose-invert max-w-none">
<p class="text-gray-600 dark:text-gray-400">
Organization Settings is the central hub for configuring your Towerops organization. Access it
from the main navigation under <.code>Organization Settings</.code>. The settings page uses a
tabbed interface to organize different configuration areas.
</p>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6 mb-3">General Tab</h3>
<p class="text-gray-600 dark:text-gray-400">
The General tab lets you manage basic organization properties:
</p>
<ul class="space-y-2 text-gray-600 dark:text-gray-400 list-disc list-inside mt-3">
<li>
<strong class="text-gray-900 dark:text-white">Organization Name</strong>
The display name for your organization
</li>
<li>
<strong class="text-gray-900 dark:text-white">Default Organization</strong>
Set this as the default org when logging in
</li>
<li>
<strong class="text-gray-900 dark:text-white">Sites Toggle</strong>
Enable or disable site-based device organization (see the
<.link
patch={~p"/help?section=sites"}
class="underline hover:text-blue-900 dark:hover:text-blue-200 text-blue-700 dark:text-blue-300"
>
Sites
</.link>
section for details)
</li>
</ul>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6 mb-3">SNMP Tab</h3>
<p class="text-gray-600 dark:text-gray-400">
Configure organization-wide SNMP defaults used for device polling:
</p>
<ul class="space-y-2 text-gray-600 dark:text-gray-400 list-disc list-inside mt-3">
<li>
<strong class="text-gray-900 dark:text-white">SNMP Version</strong>
Choose between v1, v2c, or v3
</li>
<li>
<strong class="text-gray-900 dark:text-white">Community String</strong>
The SNMP community string for v1/v2c
</li>
<li>
<strong class="text-gray-900 dark:text-white">SNMPv3 Credentials</strong>
Username, auth protocol, auth passphrase, privacy protocol, and privacy passphrase
</li>
<li>
<strong class="text-gray-900 dark:text-white">Port</strong> SNMP port (default: 161)
</li>
</ul>
<div class="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div class="flex gap-3">
<.icon
name="hero-information-circle"
class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
/>
<div>
<h4 class="text-sm font-semibold text-blue-900 dark:text-blue-300 mb-1">
Credential Hierarchy
</h4>
<p class="text-sm text-blue-800 dark:text-blue-300">
SNMP credentials follow a hierarchy: <strong>Device > Site > Organization</strong>.
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.
</p>
</div>
</div>
</div>
<p class="text-gray-600 dark:text-gray-400 mt-4">
Use the <strong class="text-gray-900 dark:text-white">Force Apply</strong> button to push
the organization's SNMP settings to all devices, overriding any device or site-level
customizations.
</p>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6 mb-3">MikroTik Tab</h3>
<p class="text-gray-600 dark:text-gray-400">
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
</.link>
section for detailed setup instructions.
</p>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6 mb-3">Agents Tab</h3>
<p class="text-gray-600 dark:text-gray-400">
Manage remote poller (agent) assignments for your organization:
</p>
<ul class="space-y-2 text-gray-600 dark:text-gray-400 list-disc list-inside mt-3">
<li>
<strong class="text-gray-900 dark:text-white">Default Agent</strong>
Set a default remote poller for all devices in the organization
</li>
<li>
<strong class="text-gray-900 dark:text-white">Assignment Breakdown</strong>
View how devices are distributed across pollers (cloud vs. remote agents)
</li>
</ul>
<p class="text-gray-600 dark:text-gray-400 mt-4">
Use the <strong class="text-gray-900 dark:text-white">Force Apply</strong> button to push
the default agent assignment to all devices, overriding any device or site-level agent
selections.
</p>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6 mb-3">
Integrations Tab
</h3>
<p class="text-gray-600 dark:text-gray-400">
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
</.link>
section for full details on available integrations and how to configure them.
</p>
</div>
</div>
"""
end
end

View file

@ -0,0 +1,221 @@
defmodule ToweropsWeb.HelpLive.Sections.Sites do
@moduledoc false
use ToweropsWeb, :html
import ToweropsWeb.HelpLive.Sections.Helpers
def render(assigns) do
~H"""
<div class="p-6">
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-4">Sites</h2>
<div class="prose prose-sm dark:prose-invert max-w-none">
<p class="text-gray-600 dark:text-gray-400">
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.
</p>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6 mb-3">
When to Enable Sites
</h3>
<p class="text-gray-600 dark:text-gray-400">
Consider enabling sites if you:
</p>
<ul class="space-y-2 text-gray-600 dark:text-gray-400 list-disc list-inside mt-3">
<li>
<strong class="text-gray-900 dark:text-white">Manage multiple physical locations</strong>
- Offices, datacenters, retail stores, etc.
</li>
<li>
<strong class="text-gray-900 dark:text-white">Want to group credentials by location</strong>
- Different SNMP communities or credentials per site
</li>
<li>
<strong class="text-gray-900 dark:text-white">Need location-based monitoring</strong>
- Assign different remote pollers to different sites
</li>
<li>
<strong class="text-gray-900 dark:text-white">Organize by customer or department</strong>
- Logical groupings for multi-tenant or large networks
</li>
</ul>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-6 mb-3">
When to Skip Sites
</h3>
<p class="text-gray-600 dark:text-gray-400">
You can skip enabling sites if you:
</p>
<ul class="space-y-2 text-gray-600 dark:text-gray-400 list-disc list-inside mt-3">
<li>
<strong class="text-gray-900 dark:text-white">Have a single location</strong>
- All devices in one office or datacenter
</li>
<li>
<strong class="text-gray-900 dark:text-white">Want a simpler setup</strong>
- Devices belong directly to your organization without extra grouping
</li>
<li>
<strong class="text-gray-900 dark:text-white">Don't need location-based credentials</strong>
- Organization-level credentials work for all devices
</li>
</ul>
<div class="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div class="flex gap-3">
<.icon
name="hero-information-circle"
class="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5"
/>
<div>
<h4 class="text-sm font-semibold text-blue-900 dark:text-blue-300 mb-1">Note</h4>
<p class="text-sm text-blue-800 dark:text-blue-300">
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.
</p>
</div>
</div>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-8 mb-3">How Sites Work</h3>
<p class="text-gray-600 dark:text-gray-400">
When sites are enabled, devices can be organized into sites, and credentials (SNMP communities,
usernames, passwords) can be set at three levels:
</p>
<div class="mt-4 space-y-3">
<div class="p-4 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">
1. Organization Level
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">
Default credentials that apply to all devices across all sites in your organization.
</p>
</div>
<div class="p-4 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">
2. Site Level (when sites enabled)
</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">
Override organization defaults for all devices at a specific site. Useful when different
locations have different network configurations.
</p>
</div>
<div class="p-4 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-white/10 rounded-lg">
<h4 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">3. Device Level</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">
Override organization or site credentials for a specific device. Useful for devices with
unique configurations.
</p>
</div>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mt-8 mb-3">Enabling Sites</h3>
<div class="space-y-4">
<div class="flex gap-4">
<div class="flex-shrink-0">
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 font-semibold">
1
</div>
</div>
<div class="flex-1">
<h4 class="text-base font-semibold text-gray-900 dark:text-white mb-2">
Navigate to Organization Settings
</h4>
<p class="text-gray-600 dark:text-gray-400">
Go to
<.code>Settings</.code>
<.code>Organization</.code>
and scroll to the "Site Organization" section.
</p>
</div>
</div>
<div class="flex gap-4">
<div class="flex-shrink-0">
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 font-semibold">
2
</div>
</div>
<div class="flex-1">
<h4 class="text-base font-semibold text-gray-900 dark:text-white mb-2">
Enable the "Use Sites" Toggle
</h4>
<p class="text-gray-600 dark:text-gray-400">
Check the box for "Use sites to organize devices" and save your settings.
</p>
</div>
</div>
<div class="flex gap-4">
<div class="flex-shrink-0">
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 font-semibold">
3
</div>
</div>
<div class="flex-1">
<h4 class="text-base font-semibold text-gray-900 dark:text-white mb-2">
Create Your First Site
</h4>
<p class="text-gray-600 dark:text-gray-400">
Navigate to
<.code>Sites</.code>
<.code>Add Site</.code>
to create your first site. Give it a descriptive name like "Main Office" or "Dallas Datacenter".
</p>
</div>
</div>
<div class="flex gap-4">
<div class="flex-shrink-0">
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 font-semibold">
4
</div>
</div>
<div class="flex-1">
<h4 class="text-base font-semibold text-gray-900 dark:text-white mb-2">
Assign Devices to Sites
</h4>
<p class="text-gray-600 dark:text-gray-400">
When creating or editing devices, you can now assign them to specific sites. The site
selector will appear in the device form.
</p>
</div>
</div>
</div>
<div class="mt-8 p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<div class="flex gap-3">
<.icon
name="hero-exclamation-triangle"
class="h-5 w-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5"
/>
<div>
<h4 class="text-sm font-semibold text-yellow-900 dark:text-yellow-300 mb-1">
Disabling Sites
</h4>
<p class="text-sm text-yellow-800 dark:text-yellow-300">
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.
</p>
</div>
</div>
</div>
</div>
</div>
"""
end
end

View file

@ -0,0 +1,54 @@
defmodule ToweropsWeb.HelpLive.Sidebar do
@moduledoc false
use ToweropsWeb, :html
attr :active_section, :string, required: true
def sidebar(assigns) do
~H"""
<div class="lg:col-span-1">
<nav class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-4 sticky top-4">
<h3 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3">
Sections
</h3>
<ul class="space-y-1">
<li :for={{section_key, label} <- sections()}>
<.link
patch={~p"/help?section=#{section_key}"}
class={[
"block px-3 py-2 rounded-md text-sm font-medium transition-colors",
active_class(section_key, @active_section)
]}
>
{label}
</.link>
</li>
</ul>
</nav>
</div>
"""
end
defp sections do
[
{"about", "About Towerops"},
{"getting-started", "Getting Started"},
{"settings", "Organization Settings"},
{"sites", "Sites"},
{"cloud-pollers", "Cloud Pollers"},
{"agents", "Remote Pollers"},
{"integrations", "Integrations"},
{"graphs", "Graphs & Live Polling"},
{"insights", "Network Insights"},
{"network-map", "Network Map"},
{"api-tokens", "API Tokens"},
{"rest-api", "REST API"},
{"graphql-api", "GraphQL API"},
{"mikrotik", "MikroTik"}
]
end
defp active_class(key, key), do: "bg-blue-50 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300"
defp active_class(_, _), do: "text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800"
end

10
mix.exs
View file

@ -5,12 +5,18 @@ defmodule Towerops.MixProject do
[
app: :towerops,
version: "0.1.0",
elixir: "~> 1.19",
elixir: "~> 1.20",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
aliases: aliases(),
deps: deps(),
compilers: [:towerops_nif, :phoenix_live_view] ++ Mix.compilers(),
# NIF compiled by `make -C c_src` in dev shell (nix/shell.nix) and
# pre-built by `towerops-nif` Nix package in CI/prod. Not an auto-compiler
# because Mix.Tasks.Compile.ToweropsNif is part of the project source and
# isn't available on the code path when Mix first resolves the compilers
# list in a fresh build. Run `mix compile.towerops_nif` manually when
# changing C source between `mix compile` runs in dev.
compilers: [:phoenix_live_view] ++ Mix.compilers(),
prune_code_paths: false,
listeners: [Phoenix.CodeReloader],
dialyzer: dialyzer(),

View file

@ -58,7 +58,6 @@
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"nimble_totp": {:hex, :nimble_totp, "1.0.0", "79753bae6ce59fd7cacdb21501a1dbac249e53a51c4cd22b34fa8438ee067283", [:mix], [], "hexpm", "6ce5e4c068feecdb782e85b18237f86f66541523e6bad123e02ee1adbe48eda9"},
"oban": {:hex, :oban, "2.23.0", "1867d0fa4e8c7685217b02cc2632e3ee86c93da770e9029ff71304d9e62e53d7", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8e5f0cec5abecce78dd08cb14dc5438db90ec3884987b44773ce76fe60dd3f81"},
"octo_fetch": {:hex, :octo_fetch, "0.5.0", "f50701568b9fc752656367f82cc134d5fbefff37c5a0e8ddfcceb02ceee3f5fc", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "6226cc3c14ca948ee9f25fb0446322e5c288e215da9beba7899b6b5f4cd3ccb0"},
"parse_trans": {:hex, :parse_trans, "3.4.2", "c352ddc1a0d5e54f9b1654d45f9c432eef76f9cea371c55ddff769ef688fdb74", [:rebar3], [], "hexpm", "4c25347de3b7c35732d32e69ab43d1ceee0beae3f3b3ade1b59cbd3dd224d9ca"},
"peep": {:hex, :peep, "4.4.0", "4b6289e7258ecf2741041068932455eb8389673bca785623621ddb6935286845", [:mix], [{:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:plug, "~> 1.16", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "f289959a9e5fcf92f5a4becaf4dd0df95c58841368f6ad0574b8ea830a7c1453"},
@ -76,10 +75,10 @@
"poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"},
"postgrex": {:hex, :postgrex, "0.22.3", "bf65941737ee7a9adbe4a64c91080310d11703da343e8ac9188aacb9eb9f6f02", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "f018c13752b2b46e8d35d7e2d84c3276557cbfd880769109021a1d0ee36c1cfe"},
"prom_ex": {:hex, :prom_ex, "1.12.0", "a82cbd5b49964e4d4295ab72a18e007aadd5f4a91630b7c49fad42cc7e49c880", [:mix], [{:absinthe, ">= 1.8.0", [hex: :absinthe, repo: "hexpm", optional: true]}, {:broadway, ">= 1.1.0", [hex: :broadway, repo: "hexpm", optional: true]}, {:ecto, ">= 3.14.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:finch, "~> 0.18", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:oban, ">= 2.10.0", [hex: :oban, repo: "hexpm", optional: true]}, {:octo_fetch, "~> 0.4", [hex: :octo_fetch, repo: "hexpm", optional: false]}, {:peep, "~> 3.0 or ~> 4.0", [hex: :peep, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.7.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, ">= 0.20.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, ">= 1.16.0", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 2.6.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, ">= 1.0.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus_core, "~> 1.2", [hex: :telemetry_metrics_prometheus_core, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.1", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "6357484941489ba2fee64bb1e9f2a1e86309545304f9e90144e3c10e553c958b"},
"quic": {:hex, :quic, "1.7.0", "ebcf105fb16ca4d954c3bbd2e5bcda7650074f3e3a9ca80135dbee2607e0c27e", [:rebar3], [], "hexpm", "d0d304e5fda48754a570b90b103153f780f9926c15f6eabfe1b343e4f61e32df"},
"quic": {:hex, :quic, "1.7.1", "1ac5331739e17928a797d5ff103629a9faa91167481c326d7043b323e3bdc3b1", [:rebar3], [], "hexpm", "9b85784f1d78f5c3adfee5f52c3448cac3b03f5f0403d5dc782231c3d3ad709d"},
"ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"},
"redix": {:hex, :redix, "1.6.0", "694179c7a3c71bffac8848fcbfe16f6f6e2a1e020f00a2ad01bc6484815470d1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b2eccb05e02f21c0c3ca57513e6bacb4dd48e6406dadbd7ff9fbe07bd6745999"},
"req": {:hex, :req, "0.6.2", "b9b2024f35bcf60a92cc8cad2eaaf9d4e7aace463ff74be1afe5986830184413", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cc9cd30a2ddd04989929b887178e1610c940456d962c6c3a52df6146d2eef9bf"},
"req": {:hex, :req, "0.6.3", "7fe5e68792ff0546e45d5919104fa1764a13694cfe3e48c8a0f32ad051ae77e4", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "e85b5c6c990e6c3f52bbba68e6f099118f2b8252825f96c7c3636b97a3de307d"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
"stream_data": {:hex, :stream_data, "1.4.0", "026f929db613aabea6208012ae9b8970d3fd5f88b3bdf26831bc536f98c42036", [:mix], [], "hexpm", "2b0ee3a340dcce1c8cf6302a763ee757d1e01c54d6e16d9069062509d68b1dc9"},
"styler": {:hex, :styler, "1.11.0", "35010d970689a23c2bcc8e97bd8bf7d20e3561d60c49be84654df5c37d051a9c", [:mix], [], "hexpm", "70f36165d0cf238a32b7a456fdef6a9c72e77e657d7ac4a0ace33aeba3f2b8c0"},

1072
priv/antennas/catalog.json Normal file

File diff suppressed because it is too large Load diff