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
This commit is contained in:
Graham McInitre 2026-07-21 17:03:47 -05:00
parent 5b8f83671e
commit 9d4a5f6d81
26 changed files with 5577 additions and 4040 deletions

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

File diff suppressed because it is too large Load diff

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

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

1072
priv/antennas/catalog.json Normal file

File diff suppressed because it is too large Load diff