more tests
This commit is contained in:
parent
d88cf9dddc
commit
fd5c6b3d72
17 changed files with 1423 additions and 21 deletions
|
|
@ -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
123
lib/towerops/alerts.ex
Normal 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
|
||||
38
lib/towerops/alerts/alert.ex
Normal file
38
lib/towerops/alerts/alert.ex
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
73
lib/towerops_web/live/alert_live/index.ex
Normal file
73
lib/towerops_web/live/alert_live/index.ex
Normal 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
|
||||
140
lib/towerops_web/live/alert_live/index.html.heex
Normal file
140
lib/towerops_web/live/alert_live/index.html.heex
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
27
priv/repo/migrations/20251221231801_create_alerts.exs
Normal file
27
priv/repo/migrations/20251221231801_create_alerts.exs
Normal 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
|
||||
143
test/towerops/alerts_test.exs
Normal file
143
test/towerops/alerts_test.exs
Normal 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
|
||||
167
test/towerops/equipment_test.exs
Normal file
167
test/towerops/equipment_test.exs
Normal 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
|
||||
176
test/towerops/monitoring/equipment_monitor_test.exs
Normal file
176
test/towerops/monitoring/equipment_monitor_test.exs
Normal 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
|
||||
141
test/towerops/monitoring_test.exs
Normal file
141
test/towerops/monitoring_test.exs
Normal 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
|
||||
55
test/towerops/organizations/policy_test.exs
Normal file
55
test/towerops/organizations/policy_test.exs
Normal 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
|
||||
114
test/towerops/sites_test.exs
Normal file
114
test/towerops/sites_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue