add remote agent setup
This commit is contained in:
parent
4f924ba9a7
commit
cf77949527
22 changed files with 1915 additions and 3 deletions
256
lib/towerops/agents.ex
Normal file
256
lib/towerops/agents.ex
Normal 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
|
||||
40
lib/towerops/agents/agent_assignment.ex
Normal file
40
lib/towerops/agents/agent_assignment.ex
Normal 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
|
||||
89
lib/towerops/agents/agent_token.ex
Normal file
89
lib/towerops/agents/agent_token.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
159
lib/towerops_web/controllers/api/agent_controller.ex
Normal file
159
lib/towerops_web/controllers/api/agent_controller.ex
Normal 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
|
||||
119
lib/towerops_web/live/agent_live/index.ex
Normal file
119
lib/towerops_web/live/agent_live/index.ex
Normal 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
|
||||
236
lib/towerops_web/live/agent_live/index.html.heex
Normal file
236
lib/towerops_web/live/agent_live/index.html.heex
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
46
lib/towerops_web/live/org/settings_live.ex
Normal file
46
lib/towerops_web/live/org/settings_live.ex
Normal 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
|
||||
67
lib/towerops_web/live/org/settings_live.html.heex
Normal file
67
lib/towerops_web/live/org/settings_live.html.heex
Normal 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>
|
||||
60
lib/towerops_web/plugs/agent_auth.ex
Normal file
60
lib/towerops_web/plugs/agent_auth.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
25
priv/repo/migrations/20260109184838_create_agent_tokens.exs
Normal file
25
priv/repo/migrations/20260109184838_create_agent_tokens.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
19
test/support/fixtures/agents_fixtures.ex
Normal file
19
test/support/fixtures/agents_fixtures.ex
Normal 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
|
||||
24
test/support/fixtures/organizations_fixtures.ex
Normal file
24
test/support/fixtures/organizations_fixtures.ex
Normal 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
|
||||
225
test/towerops/agents_test.exs
Normal file
225
test/towerops/agents_test.exs
Normal 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
|
||||
201
test/towerops_web/controllers/api/agent_controller_test.exs
Normal file
201
test/towerops_web/controllers/api/agent_controller_test.exs
Normal 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
|
||||
88
test/towerops_web/live/agent_live_test.exs
Normal file
88
test/towerops_web/live/agent_live_test.exs
Normal 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
|
||||
140
test/towerops_web/live/org/settings_live_test.exs
Normal file
140
test/towerops_web/live/org/settings_live_test.exs
Normal 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'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
|
||||
Loading…
Add table
Reference in a new issue