more tests

This commit is contained in:
Graham McIntire 2025-12-21 17:29:01 -06:00
parent d88cf9dddc
commit fd5c6b3d72
No known key found for this signature in database
17 changed files with 1423 additions and 21 deletions

View file

@ -336,19 +336,35 @@ end
- Compression policy: Compress chunks older than 7 days
- Continuous aggregates: Hourly and daily rollups for dashboard performance
### Stage 4: Alerting
### Stage 4: Alerting ✓ COMPLETE
**Goal**: Alert generation and management
**Success Criteria**:
- Alerts created on status changes
- Users can view alert history
- Users can acknowledge alerts
- ✓ Alerts created on status changes
- ✓ Users can view alert history
- ✓ Users can acknowledge alerts
- ✓ Real-time alert notifications via PubSub
**Tasks**:
1. Create alerts schema
2. Build alert creation logic in monitoring workers
3. Create alert LiveView pages
4. Add alert acknowledgment
5. Alert notifications in UI
1. ✓ Create alerts schema
2. ✓ Build alert creation logic in monitoring workers
3. ✓ Create alert LiveView pages
4. ✓ Add alert acknowledgment
5. ✓ Alert notifications in UI
6. ✓ Dashboard integration with active alerts
**Completed**: 2025-12-21
**Files Created**:
- `priv/repo/migrations/*_create_alerts.exs` - Alerts table migration
- `lib/towerops/alerts/alert.ex` - Alert schema
- `lib/towerops/alerts.ex` - Alerts context
- `lib/towerops_web/live/alert_live/index.ex` - Alerts listing page
- `lib/towerops_web/live/alert_live/index.html.heex` - Alerts UI
**Files Modified**:
- `lib/towerops/monitoring/equipment_monitor.ex` - Alert creation on status changes
- `lib/towerops_web/live/dashboard_live.ex` - Active alerts display
- `lib/towerops_web/live/dashboard_live.html.heex` - Dashboard UI updates
- `lib/towerops_web/router.ex` - Alert routes
### Stage 5: Polish & Production
**Goal**: Production-ready application
@ -514,13 +530,23 @@ Based on decisions:
---
## Status: Stage 3 Complete - Ready for Stage 4 (Alerting)
## Status: Stage 4 Complete - Ready for Stage 5 (Polish & Production)
**Completed Stages**:
- ✓ Stage 1: Foundation & Authentication (2025-12-21)
- ✓ Stage 2: Sites & Equipment Management (2025-12-21)
- ✓ Stage 3: Monitoring System (2025-12-21)
- ✓ Stage 3: Monitoring System with TimescaleDB (2025-12-21)
- ✓ Stage 4: Alerting (2025-12-21)
**Current Status**: All tests passing (94/94)
**Features Implemented**:
- Multi-tenant organizations with role-based access
- Hierarchical site management
- Equipment monitoring with ping checks
- TimescaleDB time-series optimization (production-ready)
- Automatic alerting on status changes
- Real-time dashboard with LiveView
- Alert acknowledgment system
Last updated: 2025-12-21

123
lib/towerops/alerts.ex Normal file
View file

@ -0,0 +1,123 @@
defmodule Towerops.Alerts do
@moduledoc """
The Alerts context.
"""
import Ecto.Query
alias Towerops.Alerts.Alert
alias Towerops.Repo
@doc """
Creates an alert for equipment status change.
"""
def create_alert(attrs) do
%Alert{}
|> Alert.changeset(attrs)
|> Repo.insert()
end
@doc """
Returns the list of alerts for an equipment.
"""
def list_equipment_alerts(equipment_id, limit \\ 100) do
Repo.all(
from(a in Alert,
where: a.equipment_id == ^equipment_id,
order_by: [desc: a.triggered_at],
limit: ^limit,
preload: [:equipment, :acknowledged_by]
)
)
end
@doc """
Returns the list of active (unresolved) alerts for an organization.
"""
def list_organization_active_alerts(organization_id) do
Repo.all(
from(a in Alert,
join: e in assoc(a, :equipment),
join: s in assoc(e, :site),
where: s.organization_id == ^organization_id,
where: is_nil(a.resolved_at),
order_by: [desc: a.triggered_at],
preload: [equipment: {e, site: s}, acknowledged_by: :acknowledged_by]
)
)
end
@doc """
Returns the list of all alerts for an organization.
"""
def list_organization_alerts(organization_id, limit \\ 100) do
Repo.all(
from(a in Alert,
join: e in assoc(a, :equipment),
join: s in assoc(e, :site),
where: s.organization_id == ^organization_id,
order_by: [desc: a.triggered_at],
limit: ^limit,
preload: [equipment: {e, site: s}, acknowledged_by: :acknowledged_by]
)
)
end
@doc """
Gets a single alert.
"""
def get_alert!(id) do
Alert
|> Repo.get!(id)
|> Repo.preload([:equipment, :acknowledged_by])
end
@doc """
Acknowledges an alert.
"""
def acknowledge_alert(alert, user_id) do
alert
|> Alert.changeset(%{
acknowledged_at: DateTime.utc_now(),
acknowledged_by_id: user_id
})
|> Repo.update()
end
@doc """
Resolves an alert.
"""
def resolve_alert(alert) do
alert
|> Alert.changeset(%{resolved_at: DateTime.utc_now()})
|> Repo.update()
end
@doc """
Checks if there's an active alert of the same type for the equipment.
"""
def has_active_alert?(equipment_id, alert_type) do
Repo.exists?(
from(a in Alert,
where: a.equipment_id == ^equipment_id,
where: a.alert_type == ^alert_type,
where: is_nil(a.resolved_at)
)
)
end
@doc """
Gets the latest active alert of a specific type for equipment.
"""
def get_active_alert(equipment_id, alert_type) do
Repo.one(
from(a in Alert,
where: a.equipment_id == ^equipment_id,
where: a.alert_type == ^alert_type,
where: is_nil(a.resolved_at),
order_by: [desc: a.triggered_at],
limit: 1
)
)
end
end

View file

@ -0,0 +1,38 @@
defmodule Towerops.Alerts.Alert do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "alerts" do
field :alert_type, Ecto.Enum, values: [:equipment_down, :equipment_up]
field :triggered_at, :utc_datetime
field :acknowledged_at, :utc_datetime
field :resolved_at, :utc_datetime
field :message, :string
belongs_to :equipment, Towerops.Equipment.Equipment
belongs_to :acknowledged_by, Towerops.Accounts.User
timestamps(type: :utc_datetime)
end
@doc false
def changeset(alert, attrs) do
alert
|> cast(attrs, [
:equipment_id,
:alert_type,
:triggered_at,
:acknowledged_at,
:acknowledged_by_id,
:resolved_at,
:message
])
|> validate_required([:equipment_id, :alert_type, :triggered_at])
|> foreign_key_constraint(:equipment_id)
|> foreign_key_constraint(:acknowledged_by_id)
end
end

View file

@ -93,7 +93,7 @@ defmodule Towerops.Equipment do
Updates the status of equipment.
"""
def update_equipment_status(%EquipmentSchema{} = equipment, new_status) do
now = DateTime.utc_now()
now = DateTime.truncate(DateTime.utc_now(), :second)
changes = %{
status: new_status,

View file

@ -4,6 +4,7 @@ defmodule Towerops.Monitoring.EquipmentMonitor do
"""
use GenServer
alias Towerops.Alerts
alias Towerops.Equipment
alias Towerops.Monitoring
alias Towerops.Monitoring.Ping
@ -76,8 +77,15 @@ defmodule Towerops.Monitoring.EquipmentMonitor do
# Update equipment status if it changed
new_status = if status == :success, do: :up, else: :down
old_status = equipment.status
Equipment.update_equipment_status(equipment, new_status)
# Create alerts if status changed
if old_status != new_status do
handle_status_change(equipment_id, old_status, new_status)
end
# Broadcast status change via PubSub
Phoenix.PubSub.broadcast(
Towerops.PubSub,
@ -90,6 +98,58 @@ defmodule Towerops.Monitoring.EquipmentMonitor do
end
end
defp handle_status_change(equipment_id, old_status, new_status) do
now = DateTime.utc_now()
case {old_status, new_status} do
{_, :down} ->
# Equipment went down - create alert if one doesn't exist
if !Alerts.has_active_alert?(equipment_id, :equipment_down) do
Alerts.create_alert(%{
equipment_id: equipment_id,
alert_type: :equipment_down,
triggered_at: now,
message: "Equipment is not responding to ping"
})
# Broadcast alert
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"alerts:new",
{:new_alert, equipment_id, :equipment_down}
)
end
{_, :up} ->
# Equipment came back up - create recovery alert and resolve down alert
Alerts.create_alert(%{
equipment_id: equipment_id,
alert_type: :equipment_up,
triggered_at: now,
message: "Equipment is now responding to ping"
})
# Resolve any active equipment_down alerts
case Alerts.get_active_alert(equipment_id, :equipment_down) do
nil ->
:ok
alert ->
Alerts.resolve_alert(alert)
end
# Broadcast recovery
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"alerts:resolved",
{:alert_resolved, equipment_id, :equipment_down}
)
_ ->
:ok
end
end
defp schedule_next_check(interval_seconds) do
Process.send_after(self(), :check_equipment, interval_seconds * 1000)
end

View file

@ -0,0 +1,73 @@
defmodule ToweropsWeb.AlertLive.Index do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Alerts
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_organization
# Subscribe to alert events
if connected?(socket) do
Phoenix.PubSub.subscribe(Towerops.PubSub, "alerts:new")
Phoenix.PubSub.subscribe(Towerops.PubSub, "alerts:resolved")
end
{:ok,
socket
|> assign(:page_title, "Alerts")
|> assign(:filter, "all")
|> load_alerts(organization.id)}
end
@impl true
def handle_params(params, _url, socket) do
filter = Map.get(params, "filter", "all")
{:noreply,
socket
|> assign(:filter, filter)
|> load_alerts(socket.assigns.current_organization.id)}
end
@impl true
def handle_event("acknowledge", %{"id" => id}, socket) do
alert = Alerts.get_alert!(id)
user_id = socket.assigns.current_user.id
case Alerts.acknowledge_alert(alert, user_id) do
{:ok, _alert} ->
{:noreply,
socket
|> put_flash(:info, "Alert acknowledged")
|> load_alerts(socket.assigns.current_organization.id)}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Unable to acknowledge alert")}
end
end
@impl true
def handle_info({:new_alert, _equipment_id, _alert_type}, socket) do
{:noreply, load_alerts(socket, socket.assigns.current_organization.id)}
end
@impl true
def handle_info({:alert_resolved, _equipment_id, _alert_type}, socket) do
{:noreply, load_alerts(socket, socket.assigns.current_organization.id)}
end
defp load_alerts(socket, organization_id) do
alerts =
case socket.assigns.filter do
"active" ->
Alerts.list_organization_active_alerts(organization_id)
_ ->
Alerts.list_organization_alerts(organization_id, 100)
end
assign(socket, :alerts, alerts)
end
end

View file

@ -0,0 +1,140 @@
<.header>
Alerts
<:subtitle>Monitor and acknowledge system alerts</:subtitle>
</.header>
<div class="mt-6">
<div class="tabs tabs-boxed">
<.link
patch={~p"/orgs/#{@current_organization.slug}/alerts"}
class={["tab", @filter == "all" && "tab-active"]}
>
All Alerts
</.link>
<.link
patch={~p"/orgs/#{@current_organization.slug}/alerts?filter=active"}
class={["tab", @filter == "active" && "tab-active"]}
>
Active Alerts
</.link>
</div>
</div>
<div class="mt-8">
<%= if Enum.empty?(@alerts) do %>
<div class="text-center py-12">
<.icon name="hero-bell-slash" class="w-12 h-12 mx-auto text-base-content/40" />
<h3 class="mt-2 text-sm font-semibold text-base-content/80">No alerts</h3>
<p class="mt-1 text-sm text-base-content/60">
<%= if @filter == "active" do %>
There are no active alerts for this organization.
<% else %>
No alerts have been triggered yet.
<% end %>
</p>
</div>
<% else %>
<div class="space-y-4">
<%= for alert <- @alerts do %>
<div class={[
"card bg-base-200",
alert.resolved_at && "opacity-60",
alert.alert_type == :equipment_down && is_nil(alert.resolved_at) &&
"border-l-4 border-error"
]}>
<div class="card-body">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-3">
<span class={[
"badge badge-lg",
alert.alert_type == :equipment_down && "badge-error",
alert.alert_type == :equipment_up && "badge-success"
]}>
<%= case alert.alert_type do %>
<% :equipment_down -> %>
<.icon name="hero-exclamation-triangle" class="w-4 h-4 mr-1" />
Equipment Down
<% :equipment_up -> %>
<.icon name="hero-check-circle" class="w-4 h-4 mr-1" />
Equipment Recovered
<% end %>
</span>
<%= if alert.resolved_at do %>
<span class="badge badge-ghost">Resolved</span>
<% end %>
<%= if alert.acknowledged_at do %>
<span class="badge badge-info badge-sm">Acknowledged</span>
<% end %>
</div>
<h3 class="font-semibold mt-3">
<.link
navigate={
~p"/orgs/#{@current_organization.slug}/equipment/#{alert.equipment.id}"
}
class="link link-hover"
>
{alert.equipment.name}
</.link>
</h3>
<p class="text-sm text-base-content/70 mt-1">{alert.message}</p>
<div class="text-xs text-base-content/50 mt-2 space-y-1">
<div>
<strong>Triggered:</strong>
{Calendar.strftime(alert.triggered_at, "%Y-%m-%d %H:%M:%S UTC")}
</div>
<%= if alert.acknowledged_at do %>
<div>
<strong>Acknowledged:</strong>
{Calendar.strftime(alert.acknowledged_at, "%Y-%m-%d %H:%M:%S UTC")}
<%= if alert.acknowledged_by do %>
by {alert.acknowledged_by.email}
<% end %>
</div>
<% end %>
<%= if alert.resolved_at do %>
<div>
<strong>Resolved:</strong>
{Calendar.strftime(alert.resolved_at, "%Y-%m-%d %H:%M:%S UTC")}
</div>
<% end %>
<div>
<strong>Site:</strong>
<.link
navigate={
~p"/orgs/#{@current_organization.slug}/sites/#{alert.equipment.site.id}"
}
class="link link-hover"
>
{alert.equipment.site.name}
</.link>
</div>
</div>
</div>
<div class="flex gap-2">
<%= if is_nil(alert.acknowledged_at) && is_nil(alert.resolved_at) do %>
<.button
phx-click="acknowledge"
phx-value-id={alert.id}
class="btn-sm btn-primary"
>
<.icon name="hero-check" class="w-4 h-4" /> Acknowledge
</.button>
<% end %>
</div>
</div>
</div>
</div>
<% end %>
</div>
<% end %>
</div>

View file

@ -2,10 +2,52 @@ defmodule ToweropsWeb.DashboardLive do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Alerts
alias Towerops.Equipment
alias Towerops.Sites
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_organization
{:ok, assign(socket, :page_title, organization.name)}
# Subscribe to real-time alert updates
if connected?(socket) do
Phoenix.PubSub.subscribe(Towerops.PubSub, "alerts:new")
Phoenix.PubSub.subscribe(Towerops.PubSub, "alerts:resolved")
end
{:ok,
socket
|> assign(:page_title, organization.name)
|> load_dashboard_data(organization.id)}
end
@impl true
def handle_info({:new_alert, _equipment_id, _alert_type}, socket) do
{:noreply, load_dashboard_data(socket, socket.assigns.current_organization.id)}
end
@impl true
def handle_info({:alert_resolved, _equipment_id, _alert_type}, socket) do
{:noreply, load_dashboard_data(socket, socket.assigns.current_organization.id)}
end
defp load_dashboard_data(socket, organization_id) do
active_alerts = Alerts.list_organization_active_alerts(organization_id)
sites_count = length(Sites.list_organization_sites(organization_id))
equipment = Equipment.list_organization_equipment(organization_id)
equipment_by_status =
equipment
|> Enum.group_by(& &1.status)
|> Map.new(fn {status, equip} -> {status, length(equip)} end)
socket
|> assign(:active_alerts, active_alerts)
|> assign(:sites_count, sites_count)
|> assign(:equipment_count, length(equipment))
|> assign(:equipment_up, Map.get(equipment_by_status, :up, 0))
|> assign(:equipment_down, Map.get(equipment_by_status, :down, 0))
|> assign(:equipment_unknown, Map.get(equipment_by_status, :unknown, 0))
end
end

View file

@ -3,11 +3,11 @@
<:subtitle>Welcome to {@current_organization.name}</:subtitle>
</.header>
<div class="mt-8 grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<div class="mt-8 grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<div class="card bg-base-100 shadow">
<div class="card-body">
<h3 class="card-title text-base">Sites</h3>
<p class="text-3xl font-bold">0</p>
<p class="text-3xl font-bold">{@sites_count}</p>
<p class="text-sm text-base-content/60">Total sites</p>
</div>
</div>
@ -15,20 +15,94 @@
<div class="card bg-base-100 shadow">
<div class="card-body">
<h3 class="card-title text-base">Equipment</h3>
<p class="text-3xl font-bold">0</p>
<p class="text-sm text-base-content/60">Total equipment</p>
<p class="text-3xl font-bold">{@equipment_count}</p>
<div class="text-xs text-base-content/60 mt-2">
<div class="flex items-center gap-2">
<span class="badge badge-success badge-xs"></span>
{@equipment_up} Up
</div>
<div class="flex items-center gap-2">
<span class="badge badge-error badge-xs"></span>
{@equipment_down} Down
</div>
<div class="flex items-center gap-2">
<span class="badge badge-ghost badge-xs"></span>
{@equipment_unknown} Unknown
</div>
</div>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h3 class="card-title text-base">Active Alerts</h3>
<p class="text-3xl font-bold text-error">0</p>
<p class="text-sm text-base-content/60">Unacknowledged alerts</p>
<p class={[
"text-3xl font-bold",
length(@active_alerts) > 0 && "text-error"
]}>
{length(@active_alerts)}
</p>
<p class="text-sm text-base-content/60">Requires attention</p>
</div>
</div>
<div class="card bg-base-100 shadow">
<div class="card-body">
<h3 class="card-title text-base">System Status</h3>
<p class={[
"text-xl font-semibold mt-2",
@equipment_down == 0 && "text-success",
@equipment_down > 0 && "text-warning"
]}>
<%= if @equipment_down == 0 do %>
<.icon name="hero-check-circle" class="w-6 h-6" /> All Systems Operational
<% else %>
<.icon name="hero-exclamation-triangle" class="w-6 h-6" /> {@equipment_down} Equipment Down
<% end %>
</p>
</div>
</div>
</div>
<%= if length(@active_alerts) > 0 do %>
<div class="mt-8">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold">Active Alerts</h2>
<.link navigate={~p"/orgs/#{@current_organization.slug}/alerts"} class="link link-primary">
View All Alerts →
</.link>
</div>
<div class="space-y-3">
<%= for alert <- Enum.take(@active_alerts, 5) do %>
<div class="alert alert-error">
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
<div class="flex-1">
<h3 class="font-semibold">
<.link
navigate={~p"/orgs/#{@current_organization.slug}/equipment/#{alert.equipment.id}"}
class="link link-hover"
>
{alert.equipment.name}
</.link>
</h3>
<p class="text-sm">{alert.message}</p>
</div>
<span class="text-xs text-base-content/70">
{Calendar.strftime(alert.triggered_at, "%H:%M")}
</span>
</div>
<% end %>
<%= if length(@active_alerts) > 5 do %>
<div class="text-center text-sm text-base-content/60">
+ {length(@active_alerts) - 5} more alerts
</div>
<% end %>
</div>
</div>
<% end %>
<div class="mt-8">
<h2 class="text-xl font-semibold mb-4">Quick Actions</h2>
<div class="flex flex-wrap gap-4">
@ -38,8 +112,8 @@
<.link navigate={~p"/orgs/#{@current_organization.slug}/equipment"} class="btn btn-primary">
<.icon name="hero-server" class="w-5 h-5" /> Manage Equipment
</.link>
<.link navigate={~p"/orgs/#{@current_organization.slug}/settings"} class="btn">
<.icon name="hero-cog-6-tooth" class="w-5 h-5" /> Settings
<.link navigate={~p"/orgs/#{@current_organization.slug}/alerts"} class="btn">
<.icon name="hero-bell" class="w-5 h-5" /> View Alerts
</.link>
</div>
</div>

View file

@ -96,5 +96,8 @@ defmodule ToweropsWeb.Router do
live "/equipment/new", EquipmentLive.Form, :new
live "/equipment/:id", EquipmentLive.Show, :show
live "/equipment/:id/edit", EquipmentLive.Form, :edit
# Alert routes
live "/alerts", AlertLive.Index, :index
end
end

View file

@ -0,0 +1,27 @@
defmodule Towerops.Repo.Migrations.CreateAlerts do
use Ecto.Migration
def change do
create table(:alerts, primary_key: false) do
add :id, :binary_id, primary_key: true
add :equipment_id, references(:equipment, type: :binary_id, on_delete: :delete_all),
null: false
add :alert_type, :string, null: false
add :triggered_at, :utc_datetime, null: false
add :acknowledged_at, :utc_datetime
add :acknowledged_by_id, references(:users, type: :binary_id, on_delete: :nilify_all)
add :resolved_at, :utc_datetime
add :message, :text
timestamps(type: :utc_datetime)
end
create index(:alerts, [:equipment_id])
create index(:alerts, [:triggered_at])
create index(:alerts, [:alert_type])
create index(:alerts, [:acknowledged_at])
create index(:alerts, [:resolved_at])
end
end

View file

@ -0,0 +1,143 @@
defmodule Towerops.AlertsTest do
use Towerops.DataCase
alias Towerops.Alerts
describe "alerts" do
import Towerops.AccountsFixtures
alias Towerops.Alerts.Alert
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
{:ok, equipment} =
Towerops.Equipment.create_equipment(%{
name: "Router 1",
ip_address: "192.168.1.1",
site_id: site.id
})
%{equipment: equipment, organization: organization, user: user}
end
@valid_attrs %{
alert_type: :equipment_down,
triggered_at: ~U[2025-12-21 12:00:00Z],
message: "Equipment is not responding"
}
test "create_alert/1 with valid data creates an alert", %{equipment: equipment} do
attrs = Map.put(@valid_attrs, :equipment_id, equipment.id)
assert {:ok, %Alert{} = alert} = Alerts.create_alert(attrs)
assert alert.alert_type == :equipment_down
assert alert.message == "Equipment is not responding"
end
test "create_alert/1 with invalid data returns error changeset" do
assert {:error, %Ecto.Changeset{}} = Alerts.create_alert(%{})
end
test "list_equipment_alerts/2 returns alerts for equipment", %{equipment: equipment} do
attrs = Map.put(@valid_attrs, :equipment_id, equipment.id)
{:ok, alert} = Alerts.create_alert(attrs)
[result] = Alerts.list_equipment_alerts(equipment.id)
assert result.id == alert.id
end
test "list_organization_active_alerts/1 returns unresolved alerts", %{
equipment: equipment,
organization: organization
} do
attrs = Map.put(@valid_attrs, :equipment_id, equipment.id)
{:ok, alert} = Alerts.create_alert(attrs)
# Create resolved alert
{:ok, resolved_alert} =
Alerts.create_alert(Map.put(attrs, :resolved_at, ~U[2025-12-21 13:00:00Z]))
active = Alerts.list_organization_active_alerts(organization.id)
assert length(active) == 1
assert hd(active).id == alert.id
refute Enum.any?(active, fn a -> a.id == resolved_alert.id end)
end
test "list_organization_alerts/2 returns all alerts", %{
equipment: equipment,
organization: organization
} do
attrs = Map.put(@valid_attrs, :equipment_id, equipment.id)
{:ok, _alert1} = Alerts.create_alert(attrs)
{:ok, _alert2} = Alerts.create_alert(Map.put(attrs, :alert_type, :equipment_up))
alerts = Alerts.list_organization_alerts(organization.id)
assert length(alerts) == 2
end
test "get_alert!/1 returns the alert with given id", %{equipment: equipment} do
attrs = Map.put(@valid_attrs, :equipment_id, equipment.id)
{:ok, alert} = Alerts.create_alert(attrs)
assert Alerts.get_alert!(alert.id).id == alert.id
end
test "acknowledge_alert/2 marks alert as acknowledged", %{equipment: equipment, user: user} do
attrs = Map.put(@valid_attrs, :equipment_id, equipment.id)
{:ok, alert} = Alerts.create_alert(attrs)
assert {:ok, acknowledged} = Alerts.acknowledge_alert(alert, user.id)
assert acknowledged.acknowledged_at
assert acknowledged.acknowledged_by_id == user.id
end
test "resolve_alert/1 marks alert as resolved", %{equipment: equipment} do
attrs = Map.put(@valid_attrs, :equipment_id, equipment.id)
{:ok, alert} = Alerts.create_alert(attrs)
assert {:ok, resolved} = Alerts.resolve_alert(alert)
assert resolved.resolved_at
end
test "has_active_alert?/2 returns true when active alert exists", %{equipment: equipment} do
attrs = Map.put(@valid_attrs, :equipment_id, equipment.id)
{:ok, _alert} = Alerts.create_alert(attrs)
assert Alerts.has_active_alert?(equipment.id, :equipment_down) == true
assert Alerts.has_active_alert?(equipment.id, :equipment_up) == false
end
test "has_active_alert?/2 returns false for resolved alerts", %{equipment: equipment} do
attrs = Map.put(@valid_attrs, :equipment_id, equipment.id)
{:ok, alert} = Alerts.create_alert(attrs)
{:ok, _resolved} = Alerts.resolve_alert(alert)
assert Alerts.has_active_alert?(equipment.id, :equipment_down) == false
end
test "get_active_alert/2 returns active alert of specific type", %{equipment: equipment} do
attrs = Map.put(@valid_attrs, :equipment_id, equipment.id)
{:ok, alert} = Alerts.create_alert(attrs)
found = Alerts.get_active_alert(equipment.id, :equipment_down)
assert found.id == alert.id
end
test "get_active_alert/2 returns nil when no active alert exists", %{equipment: equipment} do
assert Alerts.get_active_alert(equipment.id, :equipment_down) == nil
end
test "get_active_alert/2 returns nil for resolved alerts", %{equipment: equipment} do
attrs = Map.put(@valid_attrs, :equipment_id, equipment.id)
{:ok, alert} = Alerts.create_alert(attrs)
{:ok, _resolved} = Alerts.resolve_alert(alert)
assert Alerts.get_active_alert(equipment.id, :equipment_down) == nil
end
end
end

View file

@ -0,0 +1,167 @@
defmodule Towerops.EquipmentTest do
use Towerops.DataCase
alias Towerops.Equipment
describe "equipment" do
import Towerops.AccountsFixtures
alias Towerops.Equipment.Equipment, as: EquipmentSchema
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
%{organization: organization, site: site, user: user}
end
@valid_attrs %{
name: "Router 1",
ip_address: "192.168.1.1",
description: "Main router",
monitoring_enabled: true,
check_interval_seconds: 300
}
@update_attrs %{
name: "Updated Router",
ip_address: "192.168.1.2",
description: "Updated",
monitoring_enabled: false
}
@invalid_attrs %{name: nil, ip_address: nil}
test "list_site_equipment/1 returns all equipment for a site", %{site: site} do
{:ok, equipment} = Equipment.create_equipment(Map.put(@valid_attrs, :site_id, site.id))
assert Equipment.list_site_equipment(site.id) == [equipment]
end
test "list_organization_equipment/1 returns all equipment for an organization", %{
organization: organization,
site: site
} do
{:ok, equipment} = Equipment.create_equipment(Map.put(@valid_attrs, :site_id, site.id))
result = Equipment.list_organization_equipment(organization.id)
assert length(result) == 1
assert hd(result).id == equipment.id
end
test "list_monitored_equipment/0 returns only equipment with monitoring enabled", %{
site: site
} do
{:ok, monitored} = Equipment.create_equipment(Map.put(@valid_attrs, :site_id, site.id))
{:ok, _not_monitored} =
Equipment.create_equipment(%{
name: "Not Monitored",
ip_address: "192.168.1.3",
site_id: site.id,
monitoring_enabled: false
})
result = Equipment.list_monitored_equipment()
assert length(result) == 1
assert hd(result).id == monitored.id
end
test "get_equipment!/1 returns the equipment with given id", %{site: site} do
{:ok, equipment} = Equipment.create_equipment(Map.put(@valid_attrs, :site_id, site.id))
assert Equipment.get_equipment!(equipment.id).id == equipment.id
end
test "get_site_equipment!/2 returns equipment for specific site", %{site: site} do
{:ok, equipment} = Equipment.create_equipment(Map.put(@valid_attrs, :site_id, site.id))
assert Equipment.get_site_equipment!(site.id, equipment.id).id == equipment.id
end
test "create_equipment/1 with valid data creates equipment", %{site: site} do
attrs = Map.put(@valid_attrs, :site_id, site.id)
assert {:ok, %EquipmentSchema{} = equipment} = Equipment.create_equipment(attrs)
assert equipment.name == "Router 1"
assert equipment.ip_address == "192.168.1.1"
assert equipment.status == :unknown
assert equipment.monitoring_enabled == true
assert equipment.check_interval_seconds == 300
end
test "create_equipment/1 with valid IPv6 address", %{site: site} do
attrs = Map.put(@valid_attrs, :ip_address, "2001:0db8:85a3::8a2e:0370:7334")
attrs = Map.put(attrs, :site_id, site.id)
assert {:ok, %EquipmentSchema{}} = Equipment.create_equipment(attrs)
end
test "create_equipment/1 with invalid IP address returns error", %{site: site} do
attrs = Map.put(@valid_attrs, :ip_address, "invalid-ip")
attrs = Map.put(attrs, :site_id, site.id)
assert {:error, changeset} = Equipment.create_equipment(attrs)
assert "must be a valid IPv4 or IPv6 address" in errors_on(changeset).ip_address
end
test "create_equipment/1 with invalid data returns error changeset" do
assert {:error, %Ecto.Changeset{}} = Equipment.create_equipment(@invalid_attrs)
end
test "update_equipment/2 with valid data updates the equipment", %{site: site} do
{:ok, equipment} = Equipment.create_equipment(Map.put(@valid_attrs, :site_id, site.id))
assert {:ok, %EquipmentSchema{} = equipment} =
Equipment.update_equipment(equipment, @update_attrs)
assert equipment.name == "Updated Router"
assert equipment.ip_address == "192.168.1.2"
assert equipment.monitoring_enabled == false
end
test "update_equipment/2 with invalid data returns error changeset", %{site: site} do
{:ok, equipment} = Equipment.create_equipment(Map.put(@valid_attrs, :site_id, site.id))
assert {:error, %Ecto.Changeset{}} =
Equipment.update_equipment(equipment, @invalid_attrs)
assert Equipment.get_equipment!(equipment.id).name == equipment.name
end
test "delete_equipment/1 deletes the equipment", %{site: site} do
{:ok, equipment} = Equipment.create_equipment(Map.put(@valid_attrs, :site_id, site.id))
assert {:ok, %EquipmentSchema{}} = Equipment.delete_equipment(equipment)
assert_raise Ecto.NoResultsError, fn -> Equipment.get_equipment!(equipment.id) end
end
test "change_equipment/1 returns an equipment changeset", %{site: site} do
{:ok, equipment} = Equipment.create_equipment(Map.put(@valid_attrs, :site_id, site.id))
assert %Ecto.Changeset{} = Equipment.change_equipment(equipment)
end
test "update_equipment_status/2 updates status and timestamps", %{site: site} do
{:ok, equipment} = Equipment.create_equipment(Map.put(@valid_attrs, :site_id, site.id))
assert {:ok, updated} = Equipment.update_equipment_status(equipment, :up)
assert updated.status == :up
assert updated.last_checked_at
assert updated.last_status_change_at
end
test "update_equipment_status/2 only updates last_checked_at if status unchanged", %{
site: site
} do
{:ok, equipment} = Equipment.create_equipment(Map.put(@valid_attrs, :site_id, site.id))
{:ok, equipment} = Equipment.update_equipment_status(equipment, :up)
first_change_at = equipment.last_status_change_at
first_checked_at = equipment.last_checked_at
# Sleep for over a second to ensure timestamps differ
Process.sleep(1100)
{:ok, updated} = Equipment.update_equipment_status(equipment, :up)
# Status change timestamp should not change
assert updated.last_status_change_at == first_change_at
# But checked_at should be updated
assert DateTime.after?(updated.last_checked_at, first_checked_at)
end
end
end

View file

@ -0,0 +1,176 @@
defmodule Towerops.Monitoring.EquipmentMonitorTest do
use Towerops.DataCase, async: false
import Towerops.AccountsFixtures
alias Towerops.Alerts
alias Towerops.Equipment
alias Towerops.Monitoring
alias Towerops.Monitoring.EquipmentMonitor
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
{:ok, equipment} =
Equipment.create_equipment(%{
name: "Router 1",
ip_address: "127.0.0.1",
site_id: site.id,
monitoring_enabled: true,
check_interval_seconds: 1
})
%{equipment: equipment}
end
describe "init/1" do
test "initializes with equipment_id and schedules check if monitoring enabled", %{
equipment: equipment
} do
{:ok, state} = EquipmentMonitor.init(equipment.id)
assert state.equipment_id == equipment.id
end
test "initializes but doesn't schedule if monitoring disabled", %{equipment: equipment} do
{:ok, updated} = Equipment.update_equipment(equipment, %{monitoring_enabled: false})
{:ok, state} = EquipmentMonitor.init(updated.id)
assert state.equipment_id == updated.id
end
end
describe "trigger_check/1" do
test "can trigger an immediate check", %{equipment: equipment} do
# Start the monitor
start_supervised!({EquipmentMonitor, equipment.id})
# Trigger a check
EquipmentMonitor.trigger_check(equipment.id)
# Give it time to process
Process.sleep(100)
# Should have created a monitoring check
checks = Monitoring.list_equipment_checks(equipment.id)
assert length(checks) > 0
end
end
describe "handle_info :check_equipment" do
test "performs check and creates monitoring record", %{equipment: equipment} do
{:ok, state} = EquipmentMonitor.init(equipment.id)
# Simulate the periodic check message
{:noreply, _new_state} = EquipmentMonitor.handle_info(:check_equipment, state)
# Should have created a check
checks = Monitoring.list_equipment_checks(equipment.id)
assert length(checks) == 1
end
end
describe "alert creation" do
setup %{equipment: equipment} do
# Set equipment to up status first
{:ok, equipment} = Equipment.update_equipment_status(equipment, :up)
%{equipment: equipment}
end
test "creates equipment_down alert when equipment goes down", %{equipment: equipment} do
# Update to use an unreachable IP
{:ok, equipment} =
Equipment.update_equipment(equipment, %{ip_address: "192.0.2.1"})
{:ok, state} = EquipmentMonitor.init(equipment.id)
# Trigger check
{:noreply, _state} = EquipmentMonitor.handle_info(:check_equipment, state)
# Should create an alert
Process.sleep(100)
alerts = Alerts.list_equipment_alerts(equipment.id)
assert length(alerts) > 0
alert = hd(alerts)
assert alert.alert_type == :equipment_down
end
test "creates equipment_up alert when equipment recovers", %{equipment: equipment} do
# First set to down
{:ok, equipment} = Equipment.update_equipment_status(equipment, :down)
# Create a down alert
{:ok, _} =
Alerts.create_alert(%{
equipment_id: equipment.id,
alert_type: :equipment_down,
triggered_at: DateTime.truncate(DateTime.utc_now(), :second),
message: "Equipment is down"
})
{:ok, state} = EquipmentMonitor.init(equipment.id)
# Trigger check (127.0.0.1 should be reachable)
{:noreply, _state} = EquipmentMonitor.handle_info(:check_equipment, state)
Process.sleep(100)
# Should create recovery alert
alerts = Alerts.list_equipment_alerts(equipment.id, 10)
up_alerts = Enum.filter(alerts, &(&1.alert_type == :equipment_up))
assert length(up_alerts) > 0
end
test "does not create duplicate down alerts", %{equipment: equipment} do
# Update to unreachable IP
{:ok, equipment} =
Equipment.update_equipment(equipment, %{ip_address: "192.0.2.1"})
{:ok, state} = EquipmentMonitor.init(equipment.id)
# Trigger check twice
{:noreply, _} = EquipmentMonitor.handle_info(:check_equipment, state)
Process.sleep(100)
{:noreply, _} = EquipmentMonitor.handle_info(:check_equipment, state)
Process.sleep(100)
# Should only have one alert
alerts =
equipment.id
|> Alerts.list_equipment_alerts()
|> Enum.filter(&(&1.alert_type == :equipment_down && is_nil(&1.resolved_at)))
assert length(alerts) == 1
end
test "resolves down alert when equipment comes back up", %{equipment: equipment} do
# Set to down first
{:ok, equipment} = Equipment.update_equipment_status(equipment, :down)
# Create down alert
{:ok, down_alert} =
Alerts.create_alert(%{
equipment_id: equipment.id,
alert_type: :equipment_down,
triggered_at: DateTime.truncate(DateTime.utc_now(), :second),
message: "Down"
})
{:ok, state} = EquipmentMonitor.init(equipment.id)
# Check (should succeed for 127.0.0.1)
{:noreply, _} = EquipmentMonitor.handle_info(:check_equipment, state)
Process.sleep(100)
# Down alert should be resolved
updated_alert = Alerts.get_alert!(down_alert.id)
assert updated_alert.resolved_at
end
end
end

View file

@ -0,0 +1,141 @@
defmodule Towerops.MonitoringTest do
use Towerops.DataCase
alias Towerops.Monitoring
describe "monitoring_checks" do
import Towerops.AccountsFixtures
alias Towerops.Monitoring.Check
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Site",
organization_id: organization.id
})
{:ok, equipment} =
Towerops.Equipment.create_equipment(%{
name: "Router 1",
ip_address: "192.168.1.1",
site_id: site.id
})
%{equipment: equipment}
end
@valid_attrs %{
status: :success,
response_time_ms: 50,
checked_at: ~U[2025-12-21 12:00:00Z]
}
test "create_check/1 with valid data creates a check", %{equipment: equipment} do
attrs = Map.put(@valid_attrs, :equipment_id, equipment.id)
assert {:ok, %Check{} = check} = Monitoring.create_check(attrs)
assert check.status == :success
assert check.response_time_ms == 50
end
test "create_check/1 with invalid data returns error changeset" do
assert {:error, %Ecto.Changeset{}} = Monitoring.create_check(%{})
end
test "list_equipment_checks/2 returns checks for equipment", %{equipment: equipment} do
attrs = Map.put(@valid_attrs, :equipment_id, equipment.id)
{:ok, check} = Monitoring.create_check(attrs)
assert Monitoring.list_equipment_checks(equipment.id) == [check]
end
test "list_equipment_checks/2 limits results", %{equipment: equipment} do
# Create 10 checks
for i <- 1..10 do
attrs =
@valid_attrs
|> Map.put(:equipment_id, equipment.id)
|> Map.put(:checked_at, DateTime.add(~U[2025-12-21 12:00:00Z], i, :second))
{:ok, _check} = Monitoring.create_check(attrs)
end
assert length(Monitoring.list_equipment_checks(equipment.id, 5)) == 5
end
test "get_latest_check/1 returns most recent check", %{equipment: equipment} do
attrs1 =
@valid_attrs
|> Map.put(:equipment_id, equipment.id)
|> Map.put(:checked_at, ~U[2025-12-21 12:00:00Z])
attrs2 =
@valid_attrs
|> Map.put(:equipment_id, equipment.id)
|> Map.put(:checked_at, ~U[2025-12-21 13:00:00Z])
{:ok, _check1} = Monitoring.create_check(attrs1)
{:ok, check2} = Monitoring.create_check(attrs2)
latest = Monitoring.get_latest_check(equipment.id)
assert latest.id == check2.id
end
test "get_latest_check/1 returns nil when no checks exist", %{equipment: equipment} do
assert Monitoring.get_latest_check(equipment.id) == nil
end
test "delete_old_checks/1 deletes checks older than date", %{equipment: equipment} do
old_attrs =
@valid_attrs
|> Map.put(:equipment_id, equipment.id)
|> Map.put(:checked_at, ~U[2025-01-01 12:00:00Z])
new_attrs =
@valid_attrs
|> Map.put(:equipment_id, equipment.id)
|> Map.put(:checked_at, ~U[2025-12-21 12:00:00Z])
{:ok, _old_check} = Monitoring.create_check(old_attrs)
{:ok, _new_check} = Monitoring.create_check(new_attrs)
{deleted, _} = Monitoring.delete_old_checks(~U[2025-06-01 00:00:00Z])
assert deleted == 1
assert length(Monitoring.list_equipment_checks(equipment.id)) == 1
end
end
describe "ping" do
alias Towerops.Monitoring.Ping
test "ping/1 returns ok tuple with response time for localhost" do
# Test with localhost which should always be reachable
case Ping.ping("127.0.0.1", 2000) do
{:ok, response_time} ->
assert is_integer(response_time)
assert response_time >= 0
{:error, _reason} ->
# Ping might fail in some test environments
assert true
end
end
test "ping/1 returns error tuple for unreachable host" do
# Use a non-routable IP
case Ping.ping("192.0.2.1", 1000) do
{:error, _reason} -> assert true
{:ok, _} -> assert true
end
end
test "ping/1 with custom timeout" do
result = Ping.ping("127.0.0.1", 5000)
# Result should be either {:ok, time} or {:error, reason}
assert is_tuple(result)
assert elem(result, 0) in [:ok, :error]
end
end
end

View file

@ -0,0 +1,55 @@
defmodule Towerops.Organizations.PolicyTest do
use Towerops.DataCase
alias Towerops.Organizations.Membership
alias Towerops.Organizations.Policy
describe "can?/3" do
test "owner can do everything" do
membership = %Membership{role: :owner}
assert Policy.can?(membership, :delete, :organization) == true
assert Policy.can?(membership, :edit, :organization) == true
assert Policy.can?(membership, :manage, :members) == true
assert Policy.can?(membership, :create, :site) == true
assert Policy.can?(membership, :edit, :equipment) == true
assert Policy.can?(membership, :view, :anything) == true
end
test "admin can do most things except delete organization" do
membership = %Membership{role: :admin}
assert Policy.can?(membership, :delete, :organization) == false
assert Policy.can?(membership, :edit, :organization) == true
assert Policy.can?(membership, :manage, :members) == true
assert Policy.can?(membership, :create, :site) == true
assert Policy.can?(membership, :edit, :equipment) == true
assert Policy.can?(membership, :view, :anything) == true
end
test "member can create and edit sites/equipment but not manage org" do
membership = %Membership{role: :member}
assert Policy.can?(membership, :view, :organization) == true
assert Policy.can?(membership, :list, :organization) == true
assert Policy.can?(membership, :delete, :organization) == false
assert Policy.can?(membership, :create, :site) == true
assert Policy.can?(membership, :edit, :site) == true
assert Policy.can?(membership, :edit, :equipment) == true
assert Policy.can?(membership, :view, :equipment) == true
assert Policy.can?(membership, :delete, :site) == false
end
test "viewer can only view" do
membership = %Membership{role: :viewer}
assert Policy.can?(membership, :delete, :organization) == false
assert Policy.can?(membership, :edit, :organization) == false
assert Policy.can?(membership, :manage, :members) == false
assert Policy.can?(membership, :create, :site) == false
assert Policy.can?(membership, :edit, :equipment) == false
assert Policy.can?(membership, :view, :site) == true
assert Policy.can?(membership, :view, :equipment) == true
end
end
end

View file

@ -0,0 +1,114 @@
defmodule Towerops.SitesTest do
use Towerops.DataCase
alias Towerops.Sites
describe "sites" do
import Towerops.AccountsFixtures
alias Towerops.Sites.Site
setup do
user = user_fixture()
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org"}, user.id)
%{organization: organization, user: user}
end
@valid_attrs %{name: "Test Site", location: "New York", description: "A test site"}
@update_attrs %{name: "Updated Site", location: "Boston", description: "Updated desc"}
@invalid_attrs %{name: nil}
test "list_organization_sites/1 returns all sites for an organization", %{
organization: organization
} do
{:ok, site} = Sites.create_site(Map.put(@valid_attrs, :organization_id, organization.id))
result = Sites.list_organization_sites(organization.id)
assert length(result) == 1
assert hd(result).id == site.id
end
test "get_organization_site!/2 returns the site with given id", %{organization: organization} do
{:ok, site} = Sites.create_site(Map.put(@valid_attrs, :organization_id, organization.id))
assert Sites.get_organization_site!(organization.id, site.id).id == site.id
end
test "create_site/1 with valid data creates a site", %{organization: organization} do
attrs = Map.put(@valid_attrs, :organization_id, organization.id)
assert {:ok, %Site{} = site} = Sites.create_site(attrs)
assert site.name == "Test Site"
assert site.location == "New York"
assert site.description == "A test site"
end
test "create_site/1 with parent_site_id creates hierarchical site", %{
organization: organization
} do
{:ok, parent} = Sites.create_site(Map.put(@valid_attrs, :organization_id, organization.id))
attrs =
@valid_attrs
|> Map.put(:organization_id, organization.id)
|> Map.put(:parent_site_id, parent.id)
|> Map.put(:name, "Child Site")
assert {:ok, %Site{} = child} = Sites.create_site(attrs)
assert child.parent_site_id == parent.id
end
test "create_site/1 with invalid data returns error changeset" do
assert {:error, %Ecto.Changeset{}} = Sites.create_site(@invalid_attrs)
end
test "update_site/2 with valid data updates the site", %{organization: organization} do
{:ok, site} = Sites.create_site(Map.put(@valid_attrs, :organization_id, organization.id))
assert {:ok, %Site{} = site} = Sites.update_site(site, @update_attrs)
assert site.name == "Updated Site"
assert site.location == "Boston"
end
test "update_site/2 with invalid data returns error changeset", %{organization: organization} do
{:ok, site} = Sites.create_site(Map.put(@valid_attrs, :organization_id, organization.id))
assert {:error, %Ecto.Changeset{}} = Sites.update_site(site, @invalid_attrs)
assert Sites.get_organization_site!(organization.id, site.id).name == site.name
end
test "delete_site/1 deletes the site", %{organization: organization} do
{:ok, site} = Sites.create_site(Map.put(@valid_attrs, :organization_id, organization.id))
assert {:ok, %Site{}} = Sites.delete_site(site)
assert_raise Ecto.NoResultsError, fn ->
Sites.get_organization_site!(organization.id, site.id)
end
end
test "change_site/1 returns a site changeset", %{organization: organization} do
{:ok, site} = Sites.create_site(Map.put(@valid_attrs, :organization_id, organization.id))
assert %Ecto.Changeset{} = Sites.change_site(site)
end
test "build_site_tree/1 builds hierarchical tree structure", %{organization: organization} do
{:ok, parent} = Sites.create_site(Map.put(@valid_attrs, :organization_id, organization.id))
{:ok, _child1} =
Sites.create_site(%{
name: "Child 1",
organization_id: organization.id,
parent_site_id: parent.id
})
{:ok, _child2} =
Sites.create_site(%{
name: "Child 2",
organization_id: organization.id,
parent_site_id: parent.id
})
tree = Sites.build_site_tree(organization.id)
assert length(tree) == 1
[root] = tree
assert length(root.children) == 2
end
end
end