add remote agent setup

This commit is contained in:
Graham McIntire 2026-01-09 13:15:31 -06:00
parent 4f924ba9a7
commit cf77949527
No known key found for this signature in database
22 changed files with 1915 additions and 3 deletions

256
lib/towerops/agents.ex Normal file
View file

@ -0,0 +1,256 @@
defmodule Towerops.Agents do
@moduledoc """
The Agents context for managing remote SNMP polling agents.
This context provides functions for creating and managing agent authentication tokens,
assigning equipment to agents, and verifying agent credentials.
"""
import Ecto.Query, warn: false
alias Towerops.Agents.AgentAssignment
alias Towerops.Agents.AgentToken
alias Towerops.Repo
## Token management
@doc """
Creates a new agent token for an organization.
Returns {:ok, agent_token, token_string} where token_string is the plain token
that should be shown to the user ONCE and never stored.
## Examples
iex> create_agent_token(org_id, "Datacenter Agent")
{:ok, %AgentToken{}, "abc123..."}
iex> create_agent_token(nil, "")
{:error, %Ecto.Changeset{}}
"""
def create_agent_token(organization_id, name) do
{token, changeset} = AgentToken.build_token(organization_id, name)
case Repo.insert(changeset) do
{:ok, agent_token} -> {:ok, agent_token, token}
{:error, changeset} -> {:error, changeset}
end
end
@doc """
Lists all agent tokens for an organization.
Returns a list of agent tokens ordered by creation date (newest first).
## Examples
iex> list_organization_agent_tokens(org_id)
[%AgentToken{}, ...]
"""
def list_organization_agent_tokens(organization_id) do
AgentToken
|> where([t], t.organization_id == ^organization_id)
|> order_by([t], desc: t.inserted_at)
|> Repo.all()
end
@doc """
Gets a single agent token by ID.
Raises `Ecto.NoResultsError` if the token does not exist.
## Examples
iex> get_agent_token!(id)
%AgentToken{}
iex> get_agent_token!("nonexistent")
** (Ecto.NoResultsError)
"""
def get_agent_token!(id), do: Repo.get!(AgentToken, id)
@doc """
Updates the heartbeat timestamp and metadata for an agent token.
This is called automatically when an agent makes an API request.
## Examples
iex> update_agent_token_heartbeat(token_id, "192.168.1.1", %{version: "0.1.0"})
{1, nil}
"""
def update_agent_token_heartbeat(agent_token_id, ip_address, metadata \\ %{}) do
now = DateTime.truncate(DateTime.utc_now(), :second)
AgentToken
|> where([t], t.id == ^agent_token_id)
|> Repo.update_all(
set: [
last_seen_at: now,
last_ip: ip_address,
metadata: metadata
]
)
end
@doc """
Verifies an agent token and returns the agent token record if valid.
Returns {:ok, agent_token} if the token is valid and enabled,
otherwise {:error, :invalid_token}.
## Examples
iex> verify_agent_token("valid_token")
{:ok, %AgentToken{}}
iex> verify_agent_token("invalid_token")
{:error, :invalid_token}
"""
def verify_agent_token(encoded_token) do
with {:ok, token_hash} <- AgentToken.verify_token(encoded_token),
%AgentToken{} = agent_token <-
Repo.get_by(AgentToken, token_hash: token_hash, enabled: true) do
{:ok, agent_token}
else
_ -> {:error, :invalid_token}
end
end
@doc """
Revokes an agent token by setting enabled to false.
## Examples
iex> revoke_agent_token(id)
{:ok, %AgentToken{enabled: false}}
"""
def revoke_agent_token(id) do
AgentToken
|> Repo.get!(id)
|> Ecto.Changeset.change(enabled: false)
|> Repo.update()
end
## Assignment management
@doc """
Assigns equipment to an agent.
Creates an agent assignment that links equipment to an agent token.
Equipment can only be assigned to one agent at a time due to unique constraint.
## Examples
iex> assign_equipment_to_agent(agent_id, equipment_id)
{:ok, %AgentAssignment{}}
iex> assign_equipment_to_agent(agent_id, already_assigned_equipment_id)
{:error, %Ecto.Changeset{}}
"""
def assign_equipment_to_agent(agent_token_id, equipment_id) do
%AgentAssignment{}
|> AgentAssignment.changeset(%{
agent_token_id: agent_token_id,
equipment_id: equipment_id
})
|> Repo.insert()
end
@doc """
Removes the agent assignment for a piece of equipment.
## Examples
iex> unassign_equipment(equipment_id)
{1, nil}
"""
def unassign_equipment(equipment_id) do
AgentAssignment
|> where([a], a.equipment_id == ^equipment_id)
|> Repo.delete_all()
end
@doc """
Lists all equipment assigned to an agent.
Returns equipment records with preloaded site and SNMP device associations.
## Examples
iex> list_agent_equipment(agent_token_id)
[%Equipment{site: %Site{}, snmp_device: %Device{sensors: [...], interfaces: [...]}}]
"""
def list_agent_equipment(agent_token_id) do
Repo.all(
from(e in Towerops.Equipment.Equipment,
join: a in AgentAssignment,
on: a.equipment_id == e.id,
where: a.agent_token_id == ^agent_token_id and a.enabled == true,
preload: [:site, snmp_device: [:sensors, :interfaces]]
)
)
end
@doc """
Gets the agent assignment for a piece of equipment.
Returns the assignment record if it exists, nil otherwise.
## Examples
iex> get_equipment_assignment(equipment_id)
%AgentAssignment{}
iex> get_equipment_assignment(unassigned_equipment_id)
nil
"""
def get_equipment_assignment(equipment_id) do
Repo.get_by(AgentAssignment, equipment_id: equipment_id)
end
@doc """
Updates or creates an equipment assignment.
If agent_token_id is nil, removes any existing assignment.
Otherwise, creates or updates the assignment.
## Examples
iex> update_equipment_assignment(equipment_id, agent_token_id)
{:ok, %AgentAssignment{}}
iex> update_equipment_assignment(equipment_id, nil)
{:ok, nil}
"""
def update_equipment_assignment(equipment_id, nil) do
# Remove assignment if setting to nil
unassign_equipment(equipment_id)
{:ok, nil}
end
def update_equipment_assignment(equipment_id, agent_token_id) do
case get_equipment_assignment(equipment_id) do
nil ->
# Create new assignment
assign_equipment_to_agent(agent_token_id, equipment_id)
existing ->
# Update existing assignment
existing
|> AgentAssignment.changeset(%{agent_token_id: agent_token_id})
|> Repo.update()
end
end
end

View file

@ -0,0 +1,40 @@
defmodule Towerops.Agents.AgentAssignment do
@moduledoc """
Schema for assigning equipment to remote agents.
Each equipment can only be assigned to one agent at a time (unique constraint on equipment_id).
This ensures that polling is not duplicated across multiple agents.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "agent_assignments" do
field :enabled, :boolean, default: true
belongs_to :agent_token, Towerops.Agents.AgentToken
belongs_to :equipment, Towerops.Equipment.Equipment
timestamps(type: :utc_datetime)
end
@doc """
Changeset for creating and updating agent assignments.
## Examples
iex> changeset(%AgentAssignment{}, %{agent_token_id: token_id, equipment_id: equip_id})
%Ecto.Changeset{valid?: true}
"""
def changeset(assignment, attrs) do
assignment
|> cast(attrs, [:agent_token_id, :equipment_id, :enabled])
|> validate_required([:agent_token_id, :equipment_id])
|> unique_constraint([:equipment_id])
|> foreign_key_constraint(:agent_token_id)
|> foreign_key_constraint(:equipment_id)
end
end

View file

@ -0,0 +1,89 @@
defmodule Towerops.Agents.AgentToken do
@moduledoc """
Schema for agent authentication tokens.
Agent tokens are used to authenticate remote SNMP polling agents that run on customer networks.
Tokens are generated with cryptographically secure random bytes, and only the SHA256 hash is
stored in the database for security.
"""
use Ecto.Schema
import Ecto.Changeset
@hash_algorithm :sha256
@rand_size 32
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "agent_tokens" do
field :token_hash, :binary
field :name, :string
field :last_seen_at, :utc_datetime
field :last_ip, :string
field :enabled, :boolean, default: true
field :metadata, :map, default: %{}
belongs_to :organization, Towerops.Organizations.Organization
timestamps(type: :utc_datetime)
end
@doc """
Builds a new agent token with secure random bytes.
Returns a tuple of {encoded_token, changeset}. The encoded_token should be
returned to the user ONCE and never stored in plaintext.
## Examples
iex> {token, changeset} = AgentToken.build_token(org_id, "My Agent")
iex> is_binary(token)
true
"""
def build_token(organization_id, name) do
token = :crypto.strong_rand_bytes(@rand_size)
encoded_token = Base.url_encode64(token, padding: false)
hashed_token = :crypto.hash(@hash_algorithm, token)
changeset =
%__MODULE__{}
|> cast(
%{
organization_id: organization_id,
name: name,
token_hash: hashed_token
},
[:organization_id, :name, :token_hash]
)
|> validate_required([:organization_id, :name, :token_hash])
{encoded_token, changeset}
end
@doc """
Verifies an encoded token by hashing it.
Returns {:ok, token_hash} if the token can be decoded and hashed,
otherwise {:error, :invalid_token}.
## Examples
iex> AgentToken.verify_token("valid_base64_token")
{:ok, <<hashed_bytes>>}
iex> AgentToken.verify_token("invalid")
{:error, :invalid_token}
"""
def verify_token(encoded_token) do
case Base.url_decode64(encoded_token, padding: false) do
{:ok, token} ->
hashed = :crypto.hash(@hash_algorithm, token)
{:ok, hashed}
_ ->
{:error, :invalid_token}
end
end
end

View file

@ -10,6 +10,8 @@ defmodule Towerops.Organizations.Organization do
field :name, :string
field :slug, :string
belongs_to :default_agent_token, Towerops.Agents.AgentToken
has_many :memberships, Towerops.Organizations.Membership
has_many :users, through: [:memberships, :user]
has_many :invitations, Towerops.Organizations.Invitation
@ -20,12 +22,13 @@ defmodule Towerops.Organizations.Organization do
@doc false
def changeset(organization, attrs) do
organization
|> cast(attrs, [:name])
|> cast(attrs, [:name, :default_agent_token_id])
|> validate_required([:name])
|> validate_length(:name, min: 2, max: 100)
|> generate_slug()
|> validate_required([:slug])
|> unique_constraint(:slug)
|> foreign_key_constraint(:default_agent_token_id)
end
defp generate_slug(changeset) do

View file

@ -0,0 +1,159 @@
defmodule ToweropsWeb.Api.AgentController do
@moduledoc """
API controller for remote agent communication.
Provides endpoints for agents to:
- Fetch polling configuration
- Submit metrics
- Send heartbeats
"""
use ToweropsWeb, :controller
alias Towerops.Agents
alias Towerops.Snmp
@doc """
GET /api/v1/agent/config
Returns polling configuration for all equipment assigned to the authenticated agent.
"""
def get_config(conn, _params) do
agent_token = conn.assigns.current_agent_token
equipment_list = Agents.list_agent_equipment(agent_token.id)
config = %{
version: "1.0",
poll_interval_seconds: 60,
equipment: Enum.map(equipment_list, &build_equipment_config/1)
}
json(conn, config)
end
@doc """
POST /api/v1/agent/metrics
Accepts a batch of metrics from the agent and processes them asynchronously.
"""
def submit_metrics(conn, %{"metrics" => metrics}) when is_list(metrics) do
agent_token = conn.assigns.current_agent_token
Task.start(fn ->
process_metrics(agent_token, metrics)
end)
json(conn, %{status: "accepted", received: length(metrics)})
end
@doc """
POST /api/v1/agent/heartbeat
Updates the agent's last_seen_at timestamp and metadata.
"""
def heartbeat(conn, params) do
agent_token = conn.assigns.current_agent_token
ip = to_string(:inet_parse.ntoa(conn.remote_ip))
metadata = %{
"version" => Map.get(params, "version"),
"hostname" => Map.get(params, "hostname"),
"uptime_seconds" => Map.get(params, "uptime_seconds")
}
# Update synchronously in the heartbeat endpoint
fn ->
Agents.update_agent_token_heartbeat(agent_token.id, ip, metadata)
end
|> Task.async()
|> Task.await()
json(conn, %{status: "ok"})
end
# Private helpers
defp build_equipment_config(equipment) do
device = equipment.snmp_device
%{
id: equipment.id,
name: equipment.name,
ip_address: equipment.ip_address,
snmp: %{
enabled: equipment.snmp_enabled,
version: equipment.snmp_version,
community: equipment.snmp_community,
port: equipment.snmp_port || 161
},
poll_interval_seconds: equipment.check_interval_seconds || 60,
sensors: build_sensor_config(device),
interfaces: build_interface_config(device)
}
end
defp build_sensor_config(nil), do: []
defp build_sensor_config(device) do
Enum.map(device.sensors, fn sensor ->
%{
id: sensor.id,
type: sensor.sensor_type,
oid: sensor.sensor_oid,
divisor: sensor.sensor_divisor,
unit: sensor.sensor_unit,
metadata: sensor.metadata
}
end)
end
defp build_interface_config(nil), do: []
defp build_interface_config(device) do
Enum.map(device.interfaces, fn interface ->
%{
id: interface.id,
if_index: interface.if_index,
if_name: interface.if_name
}
end)
end
defp process_metrics(_agent_token, metrics) do
Enum.each(metrics, fn metric ->
case metric do
%{"type" => "sensor_reading"} = m ->
Snmp.create_sensor_reading(%{
sensor_id: m["sensor_id"],
value: m["value"],
status: m["status"] || "ok",
checked_at: parse_timestamp(m["timestamp"])
})
%{"type" => "interface_stat"} = m ->
Snmp.create_interface_stat(%{
interface_id: m["interface_id"],
if_in_octets: m["if_in_octets"],
if_out_octets: m["if_out_octets"],
if_in_errors: m["if_in_errors"],
if_out_errors: m["if_out_errors"],
if_in_discards: m["if_in_discards"],
if_out_discards: m["if_out_discards"],
checked_at: parse_timestamp(m["timestamp"])
})
_ ->
:ok
end
end)
end
defp parse_timestamp(nil), do: DateTime.truncate(DateTime.utc_now(), :second)
defp parse_timestamp(ts) when is_binary(ts) do
case DateTime.from_iso8601(ts) do
{:ok, dt, _} -> DateTime.truncate(dt, :second)
_ -> DateTime.truncate(DateTime.utc_now(), :second)
end
end
end

View file

@ -0,0 +1,119 @@
defmodule ToweropsWeb.AgentLive.Index do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Agents
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_organization
agent_tokens = Agents.list_organization_agent_tokens(organization.id)
{:ok,
socket
|> assign(:page_title, "Remote Agents")
|> assign(:agent_tokens, agent_tokens)
|> assign(:new_token, nil)
|> assign(:show_token_modal, false)
|> assign(:agent_form, to_form(%{"name" => ""}))}
end
@impl true
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
@impl true
def handle_event("create_agent", params, socket) do
name =
case params do
%{"agent_form" => %{"name" => n}} -> n
%{"name" => n} -> n
end
organization = socket.assigns.current_organization
case Agents.create_agent_token(organization.id, name) do
{:ok, agent_token, token} ->
agent_tokens = Agents.list_organization_agent_tokens(organization.id)
{:noreply,
socket
|> assign(:agent_tokens, agent_tokens)
|> assign(:new_token, %{agent_token: agent_token, token: token})
|> assign(:show_token_modal, true)
|> assign(:agent_form, to_form(%{"name" => ""}))
|> put_flash(:info, "Agent created successfully")}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Failed to create agent")}
end
end
@impl true
def handle_event("close_token_modal", _params, socket) do
{:noreply,
socket
|> assign(:show_token_modal, false)
|> assign(:new_token, nil)}
end
@impl true
def handle_event("revoke_agent", %{"id" => id}, socket) do
case Agents.revoke_agent_token(id) do
{:ok, _} ->
organization = socket.assigns.current_organization
agent_tokens = Agents.list_organization_agent_tokens(organization.id)
{:noreply,
socket
|> assign(:agent_tokens, agent_tokens)
|> put_flash(:info, "Agent revoked successfully")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to revoke agent")}
end
end
defp apply_action(socket, :index, _params) do
socket
end
defp agent_status(agent_token) do
case agent_token.last_seen_at do
nil ->
{:never, "Never connected"}
last_seen ->
seconds_ago = DateTime.diff(DateTime.utc_now(), last_seen, :second)
cond do
seconds_ago < 120 -> {:online, "Online"}
seconds_ago < 300 -> {:warning, "Warning"}
true -> {:offline, "Offline"}
end
end
end
defp status_badge_class(status) do
case status do
:online -> "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300"
:warning -> "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300"
:offline -> "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300"
:never -> "bg-zinc-100 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-300"
end
end
defp format_last_seen(nil), do: "Never"
defp format_last_seen(datetime) do
seconds_ago = DateTime.diff(DateTime.utc_now(), datetime, :second)
cond do
seconds_ago < 60 -> "#{seconds_ago}s ago"
seconds_ago < 3600 -> "#{div(seconds_ago, 60)}m ago"
seconds_ago < 86_400 -> "#{div(seconds_ago, 3600)}h ago"
true -> "#{div(seconds_ago, 86_400)}d ago"
end
end
end

View file

@ -0,0 +1,236 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
current_organization={@current_organization}
active_page="agents"
>
<.header>
{@page_title}
<:subtitle>Manage remote agents for local SNMP polling</:subtitle>
<:actions>
<.button phx-click={JS.show(to: "#new-agent-form")} variant="primary">
<.icon name="hero-plus" class="h-5 w-5" /> New Agent
</.button>
</:actions>
</.header>
<!-- New Agent Form -->
<div id="new-agent-form" class="hidden mt-6">
<div class="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg p-6">
<h3 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100 mb-4">
Create New Agent
</h3>
<.form for={@agent_form} phx-submit="create_agent">
<div class="flex gap-4 items-end">
<div class="flex-1">
<.input
field={@agent_form[:name]}
type="text"
label="Agent Name"
placeholder="e.g., Datacenter A"
required
/>
</div>
<.button type="submit" variant="primary">
Create Agent
</.button>
<button
type="button"
phx-click={JS.hide(to: "#new-agent-form")}
class="px-3 py-2 text-sm font-semibold text-zinc-700 dark:text-zinc-300 hover:text-zinc-900 dark:hover:text-zinc-100"
>
Cancel
</button>
</div>
</.form>
</div>
</div>
<%= if @agent_tokens == [] do %>
<div class="text-center py-16">
<.icon name="hero-server" class="mx-auto h-12 w-12 text-zinc-400 dark:text-zinc-600" />
<h3 class="mt-4 text-lg font-semibold text-zinc-900 dark:text-zinc-100">No agents</h3>
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
Get started by creating your first remote agent.
</p>
<div class="mt-6">
<.button phx-click={JS.show(to: "#new-agent-form")} variant="primary">
<.icon name="hero-plus" class="h-5 w-5" /> New Agent
</.button>
</div>
</div>
<% else %>
<div class="mt-6">
<.table id="agents-table" rows={@agent_tokens}>
<:col :let={agent} label="Name">
<div class="font-medium text-zinc-900 dark:text-zinc-100">{agent.name}</div>
<%= if agent.metadata["version"] do %>
<div class="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">
v{agent.metadata["version"]}
</div>
<% end %>
</:col>
<:col :let={agent} label="Status">
<% {status, label} = agent_status(agent) %>
<span class={"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium #{status_badge_class(status)}"}>
{label}
</span>
</:col>
<:col :let={agent} label="Last Seen">
<div class="text-sm text-zinc-600 dark:text-zinc-400">
{format_last_seen(agent.last_seen_at)}
</div>
<%= if agent.last_ip do %>
<div class="text-xs text-zinc-500 dark:text-zinc-500 mt-0.5">
{agent.last_ip}
</div>
<% end %>
</:col>
<:col :let={agent} label="Metadata">
<%= if agent.metadata["hostname"] do %>
<div class="text-sm text-zinc-600 dark:text-zinc-400">
<.icon name="hero-computer-desktop" class="h-4 w-4 inline" />
{agent.metadata["hostname"]}
</div>
<% end %>
</:col>
<:col :let={agent} label="Created">
<div class="text-sm text-zinc-600 dark:text-zinc-400">
{Calendar.strftime(agent.inserted_at, "%b %d, %Y")}
</div>
</:col>
<:action :let={agent}>
<%= if agent.enabled do %>
<button
type="button"
phx-click="revoke_agent"
phx-value-id={agent.id}
data-confirm="Are you sure you want to revoke this agent? It will no longer be able to authenticate."
class="text-sm font-medium text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
>
Revoke
</button>
<% else %>
<span class="text-xs text-zinc-500 dark:text-zinc-400">Revoked</span>
<% end %>
</:action>
</.table>
</div>
<% end %>
<!-- Token Display Modal -->
<%= if @show_token_modal && @new_token do %>
<div
id="token-modal"
class="fixed inset-0 z-50 overflow-y-auto"
phx-click="close_token_modal"
phx-mounted={JS.show()}
>
<div class="flex min-h-screen items-center justify-center p-4">
<div class="fixed inset-0 bg-zinc-500/75 dark:bg-zinc-950/75 transition-opacity"></div>
<div class="relative bg-white dark:bg-zinc-900 rounded-lg shadow-xl max-w-2xl w-full">
<div class="p-6">
<div class="flex items-start justify-between mb-4">
<div>
<h3 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
Agent Created Successfully
</h3>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Copy this token now - it will never be shown again!
</p>
</div>
<button
type="button"
phx-click="close_token_modal"
class="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300"
>
<.icon name="hero-x-mark" class="h-6 w-6" />
</button>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Agent Name
</label>
<div class="text-base font-mono text-zinc-900 dark:text-zinc-100">
{@new_token.agent_token.name}
</div>
</div>
<div>
<label class="block text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-2">
Authentication Token
</label>
<div class="relative">
<input
type="text"
readonly
value={@new_token.token}
class="w-full font-mono text-sm bg-zinc-50 dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-700 rounded px-3 py-2 pr-24 text-zinc-900 dark:text-zinc-100"
id="agent-token-input"
/>
<button
type="button"
phx-click={
JS.dispatch("phx:copy", to: "#agent-token-input")
|> JS.push("token_copied")
}
class="absolute right-2 top-1/2 -translate-y-1/2 px-3 py-1 text-xs font-medium text-zinc-700 dark:text-zinc-300 bg-zinc-100 dark:bg-zinc-800 border border-zinc-300 dark:border-zinc-700 rounded hover:bg-zinc-200 dark:hover:bg-zinc-700"
>
<.icon name="hero-clipboard" class="h-4 w-4" />
</button>
</div>
</div>
<div class="bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg p-4">
<h4 class="text-sm font-medium text-zinc-900 dark:text-zinc-100 mb-2">
Docker Compose Example
</h4>
<pre class="text-xs font-mono text-zinc-600 dark:text-zinc-400 overflow-x-auto"><code>version: '3.8'
services:
towerops-agent:
image: towerops/agent:latest
restart: unless-stopped
environment:
- TOWEROPS_API_URL={url(@socket, ~p"/")}
- TOWEROPS_AGENT_TOKEN={@new_token.token}
volumes:
- ./data:/data</code></pre>
<button
type="button"
phx-click={JS.dispatch("phx:copy", to: "#docker-compose-example")}
class="mt-2 text-xs text-blue-600 dark:text-blue-400 hover:underline"
>
Copy to clipboard
</button>
<textarea id="docker-compose-example" class="sr-only">version: '3.8'
services:
towerops-agent:
image: towerops/agent:latest
restart: unless-stopped
environment:
- TOWEROPS_API_URL={url(@socket, ~p"/")}
- TOWEROPS_AGENT_TOKEN={@new_token.token}
volumes:
- ./data:/data</textarea>
</div>
</div>
<div class="mt-6 flex justify-end">
<.button phx-click="close_token_modal" variant="primary">
I've Saved the Token
</.button>
</div>
</div>
</div>
</div>
</div>
<% end %>
</Layouts.authenticated>

View file

@ -2,6 +2,7 @@ defmodule ToweropsWeb.EquipmentLive.Form do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Agents
alias Towerops.Equipment
alias Towerops.Equipment.Equipment, as: EquipmentSchema
alias Towerops.Sites
@ -11,6 +12,7 @@ defmodule ToweropsWeb.EquipmentLive.Form do
def mount(params, _session, socket) do
organization = socket.assigns.current_organization
sites = Sites.list_organization_sites(organization.id)
agents = Agents.list_organization_agent_tokens(organization.id)
# Redirect to sites page if no sites exist
if Enum.empty?(sites) do
@ -23,6 +25,7 @@ defmodule ToweropsWeb.EquipmentLive.Form do
socket
|> assign(:organization, organization)
|> assign(:available_sites, sites)
|> assign(:available_agents, agents)
|> assign(:preselected_site_id, params["site_id"])
|> assign(:snmp_test_result, nil)}
end
@ -68,11 +71,26 @@ defmodule ToweropsWeb.EquipmentLive.Form do
defp apply_action(socket, :edit, %{"id" => id}) do
equipment = Equipment.get_equipment!(id)
changeset = Equipment.change_equipment(equipment)
organization = socket.assigns.organization
# Get current agent assignment if any
current_assignment = Agents.get_equipment_assignment(equipment.id)
agent_token_id =
if current_assignment do
current_assignment.agent_token_id
else
# Use organization default if no explicit assignment
organization.default_agent_token_id
end
# Add agent_token_id to the changeset data
equipment_with_agent = Map.put(equipment, :agent_token_id, agent_token_id)
changeset = Equipment.change_equipment(equipment, %{})
socket
|> assign(:page_title, "Edit Equipment")
|> assign(:equipment, equipment)
|> assign(:equipment, equipment_with_agent)
|> assign(:form, to_form(changeset))
end
@ -130,8 +148,15 @@ defmodule ToweropsWeb.EquipmentLive.Form do
end
defp save_equipment(socket, :new, equipment_params) do
# Extract agent_token_id before creating equipment
agent_token_id = Map.get(equipment_params, "agent_token_id")
equipment_params = Map.delete(equipment_params, "agent_token_id")
case Equipment.create_equipment(equipment_params) do
{:ok, equipment} ->
# Handle agent assignment after equipment creation
handle_agent_assignment(equipment.id, agent_token_id)
flash_message = handle_equipment_creation(equipment)
{:noreply,
@ -147,8 +172,15 @@ defmodule ToweropsWeb.EquipmentLive.Form do
defp save_equipment(socket, :edit, equipment_params) do
old_equipment = socket.assigns.equipment
# Extract agent_token_id before updating equipment
agent_token_id = Map.get(equipment_params, "agent_token_id")
equipment_params = Map.delete(equipment_params, "agent_token_id")
case Equipment.update_equipment(old_equipment, equipment_params) do
{:ok, equipment} ->
# Handle agent assignment after equipment update
handle_agent_assignment(equipment.id, agent_token_id)
flash_message = handle_equipment_update(old_equipment, equipment)
{:noreply,
@ -276,4 +308,20 @@ defmodule ToweropsWeb.EquipmentLive.Form do
{:error, _} -> {:error, "Invalid IP address format"}
end
end
defp handle_agent_assignment(equipment_id, agent_token_id) when is_binary(agent_token_id) do
# Only assign if agent_token_id is not empty
if agent_token_id == "" do
Agents.update_equipment_assignment(equipment_id, nil)
else
Agents.update_equipment_assignment(equipment_id, agent_token_id)
end
end
defp handle_agent_assignment(equipment_id, nil) do
# No agent selected, remove any assignment
Agents.update_equipment_assignment(equipment_id, nil)
end
defp handle_agent_assignment(_equipment_id, _), do: :ok
end

View file

@ -53,6 +53,19 @@
<.input field={@form[:monitoring_enabled]} type="checkbox" label="Enable Monitoring" />
<%= if @available_agents != [] do %>
<.input
field={@form[:agent_token_id]}
type="select"
label="Remote Agent (optional)"
prompt="No agent - poll from server"
options={Enum.map(@available_agents, &{&1.name, &1.id})}
/>
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
Assign this equipment to a remote agent for local SNMP polling. Leave empty to poll from the Towerops server.
</p>
<% end %>
<div class="mt-8 border-t pt-6">
<h3 class="text-lg font-medium mb-4">SNMP Configuration</h3>
<p class="text-sm text-gray-600 mb-4">

View file

@ -0,0 +1,46 @@
defmodule ToweropsWeb.Org.SettingsLive do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Agents
alias Towerops.Organizations
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_organization
available_agents = Agents.list_organization_agent_tokens(organization.id)
changeset = Organizations.change_organization(organization)
{:ok,
socket
|> assign(:organization, organization)
|> assign(:available_agents, available_agents)
|> assign(:form, to_form(changeset))}
end
@impl true
def handle_event("validate", %{"organization" => org_params}, socket) do
changeset =
socket.assigns.organization
|> Organizations.change_organization(org_params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, :form, to_form(changeset))}
end
@impl true
def handle_event("save", %{"organization" => org_params}, socket) do
case Organizations.update_organization(socket.assigns.organization, org_params) do
{:ok, organization} ->
{:noreply,
socket
|> assign(:organization, organization)
|> put_flash(:info, "Organization settings updated successfully")
|> push_navigate(to: ~p"/orgs/#{organization.slug}")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
end

View file

@ -0,0 +1,67 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
current_organization={@organization}
>
<div class="mb-4">
<.link
navigate={~p"/orgs/#{@organization.slug}"}
class="inline-flex items-center gap-1 text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
<.icon name="hero-arrow-left" class="h-4 w-4" /> Back to Dashboard
</.link>
</div>
<.header>
Organization Settings
<:subtitle>
Manage your organization settings and defaults
</:subtitle>
</.header>
<div class="max-w-2xl">
<.form for={@form} id="organization-form" phx-change="validate" phx-submit="save">
<.input field={@form[:name]} type="text" label="Organization Name" required />
<%= if @available_agents != [] do %>
<div class="mt-6 border-t pt-6">
<h3 class="text-lg font-medium mb-4">Default Agent</h3>
<p class="text-sm text-zinc-600 dark:text-zinc-400 mb-4">
Select a default agent for SNMP polling. This will be used for all equipment unless overridden at the equipment level.
</p>
<.input
field={@form[:default_agent_token_id]}
type="select"
label="Default Remote Agent"
prompt="No default agent - poll from server"
options={Enum.map(@available_agents, &{&1.name, &1.id})}
/>
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
You can override this setting for individual equipment in their settings.
</p>
</div>
<% else %>
<div class="mt-6 border-t pt-6">
<h3 class="text-lg font-medium mb-2">Default Agent</h3>
<p class="text-sm text-zinc-600 dark:text-zinc-400 mb-4">
No agents configured yet.
<.link
navigate={~p"/orgs/#{@organization.slug}/agents"}
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
Create an agent
</.link>
to enable remote polling.
</p>
</div>
<% end %>
<div class="flex gap-3 mt-6">
<.button phx-disable-with="Saving..." variant="primary">Save Settings</.button>
<.button navigate={~p"/orgs/#{@organization.slug}"}>Cancel</.button>
</div>
</.form>
</div>
</Layouts.authenticated>

View file

@ -0,0 +1,60 @@
defmodule ToweropsWeb.Plugs.AgentAuth do
@moduledoc """
Plug for authenticating remote agent API requests.
Validates Bearer tokens in the Authorization header and assigns the agent token
to the connection. Also updates the agent's last_seen_at timestamp asynchronously.
"""
import Phoenix.Controller
import Plug.Conn
alias Towerops.Agents
@doc """
Initializes the plug with options.
"""
def init(opts), do: opts
@doc """
Validates the agent token from the Authorization header.
If valid, assigns :current_agent_token to the connection and updates heartbeat.
If invalid or missing, returns 401 Unauthorized and halts the connection.
"""
def call(conn, _opts) do
case get_req_header(conn, "authorization") do
["Bearer " <> token] ->
case Agents.verify_agent_token(token) do
{:ok, agent_token} ->
conn
|> assign(:current_agent_token, agent_token)
|> update_heartbeat(agent_token)
{:error, _} ->
unauthorized(conn)
end
_ ->
unauthorized(conn)
end
end
defp update_heartbeat(conn, agent_token) do
ip = to_string(:inet_parse.ntoa(conn.remote_ip))
Task.start(fn ->
Agents.update_agent_token_heartbeat(agent_token.id, ip)
end)
conn
end
defp unauthorized(conn) do
conn
|> put_status(:unauthorized)
|> put_view(json: ToweropsWeb.ErrorJSON)
|> render(:"401")
|> halt()
end
end

View file

@ -18,6 +18,11 @@ defmodule ToweropsWeb.Router do
plug :accepts, ["json"]
end
pipeline :agent_api do
plug :accepts, ["json"]
plug ToweropsWeb.Plugs.AgentAuth
end
# Health check endpoint for Kubernetes probes (no authentication required)
scope "/", ToweropsWeb do
get "/health", HealthController, :index
@ -29,6 +34,15 @@ defmodule ToweropsWeb.Router do
get "/", PageController, :home
end
# Agent API routes
scope "/api/v1/agent", ToweropsWeb.Api do
pipe_through :agent_api
get "/config", AgentController, :get_config
post "/metrics", AgentController, :submit_metrics
post "/heartbeat", AgentController, :heartbeat
end
# WebAuthn API routes
scope "/api/webauthn", ToweropsWeb do
pipe_through [:browser]
@ -131,6 +145,7 @@ defmodule ToweropsWeb.Router do
pipe_through [:browser, :require_authenticated_user, :load_current_organization]
live "/", DashboardLive, :index
live "/settings", Org.SettingsLive, :index
# Site routes
live "/sites", SiteLive.Index, :index
@ -147,6 +162,9 @@ defmodule ToweropsWeb.Router do
# Alert routes
live "/alerts", AlertLive.Index, :index
# Agent routes
live "/agents", AgentLive.Index, :index
end
end
end

View file

@ -0,0 +1,25 @@
defmodule Towerops.Repo.Migrations.CreateAgentTokens do
use Ecto.Migration
def change do
create table(:agent_tokens, primary_key: false) do
add :id, :binary_id, primary_key: true
add :token_hash, :binary, null: false
add :name, :string, null: false
add :last_seen_at, :utc_datetime
add :last_ip, :string
add :enabled, :boolean, default: true, null: false
add :metadata, :map, default: %{}, null: false
add :organization_id,
references(:organizations, type: :binary_id, on_delete: :delete_all),
null: false
timestamps(type: :utc_datetime)
end
create index(:agent_tokens, [:token_hash])
create index(:agent_tokens, [:organization_id])
create index(:agent_tokens, [:last_seen_at])
end
end

View file

@ -0,0 +1,24 @@
defmodule Towerops.Repo.Migrations.CreateAgentAssignments do
use Ecto.Migration
def change do
create table(:agent_assignments, primary_key: false) do
add :id, :binary_id, primary_key: true
add :enabled, :boolean, default: true, null: false
add :agent_token_id,
references(:agent_tokens, type: :binary_id, on_delete: :delete_all),
null: false
add :equipment_id,
references(:equipment, type: :binary_id, on_delete: :delete_all),
null: false
timestamps(type: :utc_datetime)
end
create index(:agent_assignments, [:agent_token_id])
create unique_index(:agent_assignments, [:equipment_id])
create index(:agent_assignments, [:enabled])
end
end

View file

@ -0,0 +1,12 @@
defmodule Towerops.Repo.Migrations.AddDefaultAgentToOrganizations do
use Ecto.Migration
def change do
alter table(:organizations) do
add :default_agent_token_id,
references(:agent_tokens, type: :binary_id, on_delete: :nilify_all)
end
create index(:organizations, [:default_agent_token_id])
end
end

View file

@ -0,0 +1,19 @@
defmodule Towerops.AgentsFixtures do
@moduledoc """
This module defines test helpers for creating
entities via the `Towerops.Agents` context.
"""
alias Towerops.Agents
def unique_agent_name, do: "Test Agent #{System.unique_integer()}"
def agent_token_fixture(organization_id, name \\ nil) do
name = name || unique_agent_name()
Agents.create_agent_token(organization_id, name)
end
def agent_assignment_fixture(agent_token_id, equipment_id) do
Agents.assign_equipment_to_agent(agent_token_id, equipment_id)
end
end

View file

@ -0,0 +1,24 @@
defmodule Towerops.OrganizationsFixtures do
@moduledoc """
This module defines test helpers for creating
entities via the `Towerops.Organizations` context.
"""
alias Towerops.Organizations
def unique_organization_name, do: "Test Org #{System.unique_integer()}"
def valid_organization_attributes(attrs \\ %{}) do
Enum.into(attrs, %{
name: unique_organization_name()
})
end
def organization_fixture(user_id, attrs \\ %{}) do
attrs = valid_organization_attributes(attrs)
{:ok, organization} = Organizations.create_organization(attrs, user_id)
organization
end
end

View file

@ -0,0 +1,225 @@
defmodule Towerops.AgentsTest do
use Towerops.DataCase
import Towerops.AccountsFixtures
alias Towerops.Agents
alias Towerops.Agents.AgentAssignment
alias Towerops.Agents.AgentToken
setup do
user = user_fixture()
{:ok, organization} =
Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
%{organization: organization, user: user}
end
describe "create_agent_token/2" do
test "creates agent token with valid organization and name", %{organization: org} do
assert {:ok, agent_token, token} = Agents.create_agent_token(org.id, "Test Agent")
assert is_binary(token)
assert byte_size(token) > 0
assert agent_token.organization_id == org.id
assert agent_token.name == "Test Agent"
assert agent_token.enabled == true
assert is_binary(agent_token.token_hash)
end
test "returns error with invalid data" do
assert {:error, changeset} = Agents.create_agent_token(nil, "")
refute changeset.valid?
end
end
describe "list_organization_agent_tokens/1" do
test "returns all agent tokens for organization", %{organization: org1, user: user} do
{:ok, org2} = Towerops.Organizations.create_organization(%{name: "Test Org 2"}, user.id)
{:ok, token1, _} = Agents.create_agent_token(org1.id, "Agent 1")
{:ok, token2, _} = Agents.create_agent_token(org1.id, "Agent 2")
{:ok, _token3, _} = Agents.create_agent_token(org2.id, "Agent 3")
tokens = Agents.list_organization_agent_tokens(org1.id)
assert length(tokens) == 2
assert Enum.any?(tokens, &(&1.id == token1.id))
assert Enum.any?(tokens, &(&1.id == token2.id))
end
test "returns empty list for organization with no tokens", %{organization: org} do
assert Agents.list_organization_agent_tokens(org.id) == []
end
end
describe "verify_agent_token/1" do
test "verifies valid token", %{organization: org} do
{:ok, agent_token, token} = Agents.create_agent_token(org.id, "Test Agent")
assert {:ok, verified_token} = Agents.verify_agent_token(token)
assert verified_token.id == agent_token.id
end
test "fails for invalid token" do
assert {:error, :invalid_token} = Agents.verify_agent_token("invalid_token")
end
test "fails for revoked token", %{organization: org} do
{:ok, agent_token, token} = Agents.create_agent_token(org.id, "Test Agent")
{:ok, _} = Agents.revoke_agent_token(agent_token.id)
assert {:error, :invalid_token} = Agents.verify_agent_token(token)
end
end
describe "revoke_agent_token/1" do
test "disables agent token", %{organization: org} do
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent")
assert {:ok, revoked_token} = Agents.revoke_agent_token(agent_token.id)
assert revoked_token.enabled == false
end
end
describe "update_agent_token_heartbeat/3" do
test "updates last_seen_at and metadata", %{organization: org} do
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent")
metadata = %{version: "0.1.0", hostname: "test-host"}
{1, _} = Agents.update_agent_token_heartbeat(agent_token.id, "192.168.1.1", metadata)
updated_token = Repo.get!(AgentToken, agent_token.id)
assert updated_token.last_ip == "192.168.1.1"
assert updated_token.metadata["version"] == "0.1.0"
assert updated_token.metadata["hostname"] == "test-host"
assert updated_token.last_seen_at
end
end
describe "assign_equipment_to_agent/2" do
setup %{organization: org} do
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: org.id
})
{:ok, equipment} =
Towerops.Equipment.create_equipment(%{
name: "Test Equipment",
ip_address: "192.168.1.1",
site_id: site.id
})
%{site: site, equipment: equipment}
end
test "assigns equipment to agent", %{organization: org, equipment: equipment} do
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent")
assert {:ok, assignment} =
Agents.assign_equipment_to_agent(agent_token.id, equipment.id)
assert assignment.agent_token_id == agent_token.id
assert assignment.equipment_id == equipment.id
assert assignment.enabled == true
end
test "prevents assigning equipment to multiple agents", %{
organization: org,
equipment: equipment
} do
{:ok, agent1, _} = Agents.create_agent_token(org.id, "Agent 1")
{:ok, agent2, _} = Agents.create_agent_token(org.id, "Agent 2")
{:ok, _} = Agents.assign_equipment_to_agent(agent1.id, equipment.id)
assert {:error, changeset} = Agents.assign_equipment_to_agent(agent2.id, equipment.id)
assert "has already been taken" in errors_on(changeset).equipment_id
end
end
describe "unassign_equipment/1" do
setup %{organization: org} do
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: org.id
})
{:ok, equipment} =
Towerops.Equipment.create_equipment(%{
name: "Test Equipment",
ip_address: "192.168.1.1",
site_id: site.id
})
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent")
%{site: site, equipment: equipment, agent_token: agent_token}
end
test "removes equipment assignment", %{agent_token: agent_token, equipment: equipment} do
{:ok, _assignment} = Agents.assign_equipment_to_agent(agent_token.id, equipment.id)
{1, _} = Agents.unassign_equipment(equipment.id)
assert Repo.get_by(AgentAssignment, equipment_id: equipment.id) == nil
end
end
describe "list_agent_equipment/1" do
setup %{organization: org} do
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: org.id
})
{:ok, equipment1} =
Towerops.Equipment.create_equipment(%{
name: "Test Equipment 1",
ip_address: "192.168.1.1",
site_id: site.id
})
{:ok, equipment2} =
Towerops.Equipment.create_equipment(%{
name: "Test Equipment 2",
ip_address: "192.168.1.2",
site_id: site.id
})
{:ok, equipment3} =
Towerops.Equipment.create_equipment(%{
name: "Test Equipment 3",
ip_address: "192.168.1.3",
site_id: site.id
})
%{site: site, equipment1: equipment1, equipment2: equipment2, equipment3: equipment3}
end
test "returns equipment assigned to agent with preloads", %{
organization: org,
equipment1: equipment1,
equipment2: equipment2,
equipment3: equipment3
} do
{:ok, agent_token, _token} = Agents.create_agent_token(org.id, "Test Agent")
{:ok, _} = Agents.assign_equipment_to_agent(agent_token.id, equipment1.id)
{:ok, _} = Agents.assign_equipment_to_agent(agent_token.id, equipment2.id)
equipment_list = Agents.list_agent_equipment(agent_token.id)
assert length(equipment_list) == 2
assert Enum.any?(equipment_list, &(&1.id == equipment1.id))
assert Enum.any?(equipment_list, &(&1.id == equipment2.id))
refute Enum.any?(equipment_list, &(&1.id == equipment3.id))
# Check preloads
equipment = hd(equipment_list)
assert Ecto.assoc_loaded?(equipment.site)
end
end
end

View file

@ -0,0 +1,201 @@
defmodule ToweropsWeb.Api.AgentControllerTest do
use ToweropsWeb.ConnCase
import Towerops.AccountsFixtures
alias Towerops.Agents
alias Towerops.Repo
alias Towerops.Snmp.Device
alias Towerops.Snmp.Interface
alias Towerops.Snmp.Sensor
setup do
user = user_fixture()
{:ok, organization} =
Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, agent_token, token} = Agents.create_agent_token(organization.id, "Test Agent")
%{organization: organization, agent_token: agent_token, token: token}
end
describe "GET /api/v1/agent/config" do
test "requires authentication", %{conn: conn} do
conn = get(conn, ~p"/api/v1/agent/config")
assert json_response(conn, 401)
end
test "returns config for authenticated agent with no equipment", %{conn: conn, token: token} do
conn =
conn
|> put_req_header("authorization", "Bearer #{token}")
|> get(~p"/api/v1/agent/config")
assert %{
"version" => "1.0",
"poll_interval_seconds" => 60,
"equipment" => []
} = json_response(conn, 200)
end
test "returns config with assigned equipment", %{
conn: conn,
token: token,
organization: org,
agent_token: agent_token
} do
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: org.id
})
{:ok, equipment} =
Towerops.Equipment.create_equipment(%{
name: "Test Router",
ip_address: "192.168.1.1",
snmp_enabled: true,
snmp_version: "2c",
snmp_community: "public",
snmp_port: 161,
check_interval_seconds: 120,
site_id: site.id
})
device =
%Device{}
|> Device.changeset(%{
equipment_id: equipment.id,
sys_name: "Test Device",
sys_descr: "Test Description"
})
|> Repo.insert!()
sensor =
%Sensor{}
|> Sensor.changeset(%{
snmp_device_id: device.id,
sensor_type: "temperature",
sensor_index: "1",
sensor_oid: "1.2.3.4"
})
|> Repo.insert!()
interface =
%Interface{}
|> Interface.changeset(%{
snmp_device_id: device.id,
if_index: 1,
if_name: "GigabitEthernet0/1"
})
|> Repo.insert!()
{:ok, _assignment} = Agents.assign_equipment_to_agent(agent_token.id, equipment.id)
conn =
conn
|> put_req_header("authorization", "Bearer #{token}")
|> get(~p"/api/v1/agent/config")
response = json_response(conn, 200)
assert %{
"version" => "1.0",
"poll_interval_seconds" => 60,
"equipment" => [equipment_config]
} = response
assert equipment_config["id"] == equipment.id
assert equipment_config["name"] == "Test Router"
assert equipment_config["ip_address"] == "192.168.1.1"
assert equipment_config["snmp"]["enabled"] == true
assert equipment_config["snmp"]["version"] == "2c"
assert equipment_config["snmp"]["community"] == "public"
assert equipment_config["snmp"]["port"] == 161
assert equipment_config["poll_interval_seconds"] == 120
assert length(equipment_config["sensors"]) == 1
sensor_config = hd(equipment_config["sensors"])
assert sensor_config["id"] == sensor.id
assert sensor_config["type"] == "temperature"
assert sensor_config["oid"] == "1.2.3.4"
assert length(equipment_config["interfaces"]) == 1
interface_config = hd(equipment_config["interfaces"])
assert interface_config["id"] == interface.id
assert interface_config["if_index"] == 1
assert interface_config["if_name"] == "GigabitEthernet0/1"
end
end
describe "POST /api/v1/agent/metrics" do
test "requires authentication", %{conn: conn} do
conn = post(conn, ~p"/api/v1/agent/metrics", %{"metrics" => []})
assert json_response(conn, 401)
end
test "accepts valid metrics", %{conn: conn, token: token} do
metrics = [
%{
"type" => "sensor_reading",
"sensor_id" => Ecto.UUID.generate(),
"value" => 45.5,
"status" => "ok",
"timestamp" => DateTime.to_iso8601(DateTime.utc_now())
},
%{
"type" => "interface_stat",
"interface_id" => Ecto.UUID.generate(),
"if_in_octets" => 1_234_567,
"if_out_octets" => 9_876_543,
"if_in_errors" => 0,
"if_out_errors" => 0,
"if_in_discards" => 0,
"if_out_discards" => 0,
"timestamp" => DateTime.to_iso8601(DateTime.utc_now())
}
]
conn =
conn
|> put_req_header("authorization", "Bearer #{token}")
|> post(~p"/api/v1/agent/metrics", %{"metrics" => metrics})
assert %{"status" => "accepted", "received" => 2} = json_response(conn, 200)
end
end
describe "POST /api/v1/agent/heartbeat" do
test "requires authentication", %{conn: conn} do
conn = post(conn, ~p"/api/v1/agent/heartbeat", %{})
assert json_response(conn, 401)
end
test "updates heartbeat with metadata", %{
conn: conn,
token: token,
agent_token: agent_token
} do
conn =
conn
|> put_req_header("authorization", "Bearer #{token}")
|> post(~p"/api/v1/agent/heartbeat", %{
"version" => "0.1.0",
"hostname" => "test-agent",
"uptime_seconds" => 3600
})
assert %{"status" => "ok"} = json_response(conn, 200)
# Give the async task time to complete
Process.sleep(100)
updated_token = Repo.get!(Agents.AgentToken, agent_token.id)
assert updated_token.last_seen_at
assert updated_token.metadata["version"] == "0.1.0"
assert updated_token.metadata["hostname"] == "test-agent"
assert updated_token.metadata["uptime_seconds"] == 3600
end
end
end

View file

@ -0,0 +1,88 @@
defmodule ToweropsWeb.AgentLiveTest do
use ToweropsWeb.ConnCase
import Phoenix.LiveViewTest
alias Towerops.Agents
setup :register_and_log_in_user
setup %{user: user} do
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
%{organization: organization}
end
describe "Index" do
test "lists all agent tokens", %{conn: conn, organization: organization} do
{:ok, _agent_token, _token} = Agents.create_agent_token(organization.id, "Agent 1")
{:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/agents")
assert html =~ "Remote Agents"
assert html =~ "Agent 1"
end
test "displays empty state when no agents", %{conn: conn, organization: organization} do
{:ok, _view, html} = live(conn, ~p"/orgs/#{organization.slug}/agents")
assert html =~ "No agents"
assert html =~ "Get started by creating your first remote agent"
end
test "creates new agent and shows token modal", %{conn: conn, organization: organization} do
{:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/agents")
html =
view
|> form("#new-agent-form form", %{name: "Test Agent"})
|> render_submit()
assert html =~ "Agent Created Successfully"
assert html =~ "Test Agent"
assert html =~ "Copy this token now"
end
test "revokes agent", %{conn: conn, organization: organization} do
{:ok, agent_token, _token} = Agents.create_agent_token(organization.id, "Agent 1")
{:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/agents")
html =
view
|> element("button", "Revoke")
|> render_click()
assert html =~ "Revoked"
# Verify agent was actually revoked
updated_token = Towerops.Repo.get!(Agents.AgentToken, agent_token.id)
refute updated_token.enabled
end
test "closes token modal", %{conn: conn, organization: organization} do
{:ok, view, _html} = live(conn, ~p"/orgs/#{organization.slug}/agents")
# Create agent to show modal
view
|> form("#new-agent-form form", %{name: "Test Agent"})
|> render_submit()
# Close modal
html =
view
|> element("button", "I've Saved the Token")
|> render_click()
refute html =~ "Agent Created Successfully"
end
test "requires authentication", %{organization: organization} do
conn = build_conn()
{:error, redirect} = live(conn, ~p"/orgs/#{organization.slug}/agents")
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
end
end
end

View file

@ -0,0 +1,140 @@
defmodule ToweropsWeb.Org.SettingsLiveTest do
use ToweropsWeb.ConnCase
import Phoenix.LiveViewTest
import Towerops.AccountsFixtures
import Towerops.AgentsFixtures
import Towerops.OrganizationsFixtures
setup do
user = user_fixture()
organization = organization_fixture(user.id)
%{user: user, organization: organization}
end
describe "Settings page" do
test "displays organization settings form", %{conn: conn, user: user, organization: org} do
{:ok, _view, html} =
conn
|> log_in_user(user)
|> live(~p"/orgs/#{org.slug}/settings")
assert html =~ "Organization Settings"
assert html =~ "Organization Name"
assert html =~ org.name
end
test "shows no agents message when no agents exist", %{
conn: conn,
user: user,
organization: org
} do
{:ok, _view, html} =
conn
|> log_in_user(user)
|> live(~p"/orgs/#{org.slug}/settings")
assert html =~ "No agents configured yet"
assert html =~ "Create an agent"
end
test "shows default agent dropdown when agents exist", %{
conn: conn,
user: user,
organization: org
} do
{:ok, agent_token, _token} = agent_token_fixture(org.id, "Test Agent")
{:ok, _view, html} =
conn
|> log_in_user(user)
|> live(~p"/orgs/#{org.slug}/settings")
assert html =~ "Default Remote Agent"
assert html =~ agent_token.name
assert html =~ "No default agent - poll from server"
end
test "updates organization name successfully", %{conn: conn, user: user, organization: org} do
{:ok, view, _html} =
conn
|> log_in_user(user)
|> live(~p"/orgs/#{org.slug}/settings")
assert view
|> form("#organization-form", organization: %{name: "Updated Name"})
|> render_submit()
assert_redirected(view, ~p"/orgs/#{org.slug}")
# Verify the organization was actually updated
updated_org = Towerops.Organizations.get_organization!(org.id)
assert updated_org.name == "Updated Name"
end
test "sets default agent for organization", %{conn: conn, user: user, organization: org} do
{:ok, agent_token, _token} = agent_token_fixture(org.id, "Test Agent")
{:ok, view, _html} =
conn
|> log_in_user(user)
|> live(~p"/orgs/#{org.slug}/settings")
assert view
|> form("#organization-form", organization: %{default_agent_token_id: agent_token.id})
|> render_submit()
assert_redirected(view, ~p"/orgs/#{org.slug}")
# Verify the default agent was set
updated_org = Towerops.Organizations.get_organization!(org.id)
assert updated_org.default_agent_token_id == agent_token.id
end
test "clears default agent for organization", %{conn: conn, user: user, organization: org} do
{:ok, agent_token, _token} = agent_token_fixture(org.id, "Test Agent")
# First set a default agent
{:ok, org} =
Towerops.Organizations.update_organization(org, %{
default_agent_token_id: agent_token.id
})
{:ok, view, _html} =
conn
|> log_in_user(user)
|> live(~p"/orgs/#{org.slug}/settings")
# Now clear it
assert view
|> form("#organization-form", organization: %{default_agent_token_id: ""})
|> render_submit()
assert_redirected(view, ~p"/orgs/#{org.slug}")
# Verify the default agent was cleared
updated_org = Towerops.Organizations.get_organization!(org.id)
assert updated_org.default_agent_token_id == nil
end
test "validates organization name", %{conn: conn, user: user, organization: org} do
{:ok, view, _html} =
conn
|> log_in_user(user)
|> live(~p"/orgs/#{org.slug}/settings")
html =
view
|> form("#organization-form", organization: %{name: ""})
|> render_change()
assert html =~ "can&#39;t be blank"
end
test "requires authentication", %{conn: conn, organization: org} do
assert {:error, {:redirect, %{to: "/users/log-in"}}} =
live(conn, ~p"/orgs/#{org.slug}/settings")
end
end
end