1. Sites Management
- Sites schema with hierarchical parent-child relationships
- CRUD operations for sites
- Sites context with helper functions
- LiveView pages:
- /orgs/:slug/sites - List all sites
- /orgs/:slug/sites/new - Create new site
- /orgs/:slug/sites/:id - View site details
- /orgs/:slug/sites/:id/edit - Edit site
- Support for site locations and descriptions
- Site tree builder for hierarchy visualization
2. Equipment Management
- Equipment schema with monitoring fields
- IP address validation (IPv4 & IPv6)
- Equipment status tracking (up/down/unknown)
- Customizable check intervals per equipment
- Equipment context with CRUD operations
- LiveView pages:
- /orgs/:slug/equipment - List all equipment
- /orgs/:slug/equipment/new - Add equipment
- /orgs/:slug/equipment/:id - View equipment details
- /orgs/:slug/equipment/:id/edit - Edit equipment
- Equipment can be added from site pages
- Status badges and last checked timestamps
3. Database Schema
- sites table with self-referencing parent_site_id
- equipment table with status tracking
- All migrations run successfully
- Proper indexes on foreign keys and status
4. Features Implemented
- ✅ IP address validation using :inet.parse_address
- ✅ Site hierarchy with parent-child relationships
- ✅ Equipment linked to sites
- ✅ Monitoring enabled/disabled per equipment
- ✅ Customizable check intervals (30s - 3600s)
- ✅ Status tracking (up/down/unknown)
- ✅ Timestamps for last check and last status change
- ✅ Organization-scoped data (users only see their org's data)
This commit is contained in:
parent
c52f313e2d
commit
1bd72dac78
19 changed files with 1121 additions and 0 deletions
102
lib/towerops/equipment.ex
Normal file
102
lib/towerops/equipment.ex
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
defmodule Towerops.Equipment do
|
||||
@moduledoc """
|
||||
The Equipment context.
|
||||
"""
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Towerops.Equipment.Equipment, as: EquipmentSchema
|
||||
alias Towerops.Repo
|
||||
|
||||
@doc """
|
||||
Returns the list of equipment for a site.
|
||||
"""
|
||||
def list_site_equipment(site_id) do
|
||||
Repo.all(from(e in EquipmentSchema, where: e.site_id == ^site_id, order_by: [asc: e.name]))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the list of all equipment for an organization (via sites).
|
||||
"""
|
||||
def list_organization_equipment(organization_id) do
|
||||
Repo.all(
|
||||
from(e in EquipmentSchema,
|
||||
join: s in assoc(e, :site),
|
||||
where: s.organization_id == ^organization_id,
|
||||
order_by: [asc: e.name],
|
||||
preload: [site: s]
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single equipment.
|
||||
"""
|
||||
def get_equipment!(id) do
|
||||
EquipmentSchema
|
||||
|> Repo.get!(id)
|
||||
|> Repo.preload([:site])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single equipment belonging to a specific site.
|
||||
"""
|
||||
def get_site_equipment!(site_id, equipment_id) do
|
||||
Repo.one!(from(e in EquipmentSchema, where: e.id == ^equipment_id, where: e.site_id == ^site_id, preload: [:site]))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates equipment.
|
||||
"""
|
||||
def create_equipment(attrs) do
|
||||
%EquipmentSchema{}
|
||||
|> EquipmentSchema.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates equipment.
|
||||
"""
|
||||
def update_equipment(%EquipmentSchema{} = equipment, attrs) do
|
||||
equipment
|
||||
|> EquipmentSchema.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes equipment.
|
||||
"""
|
||||
def delete_equipment(%EquipmentSchema{} = equipment) do
|
||||
Repo.delete(equipment)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for tracking equipment changes.
|
||||
"""
|
||||
def change_equipment(%EquipmentSchema{} = equipment, attrs \\ %{}) do
|
||||
EquipmentSchema.changeset(equipment, attrs)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the status of equipment.
|
||||
"""
|
||||
def update_equipment_status(%EquipmentSchema{} = equipment, new_status) do
|
||||
now = DateTime.utc_now()
|
||||
|
||||
changes = %{
|
||||
status: new_status,
|
||||
last_checked_at: now
|
||||
}
|
||||
|
||||
changes =
|
||||
if equipment.status == new_status do
|
||||
changes
|
||||
else
|
||||
Map.put(changes, :last_status_change_at, now)
|
||||
end
|
||||
|
||||
equipment
|
||||
|> Ecto.Changeset.change(changes)
|
||||
|> Repo.update()
|
||||
end
|
||||
end
|
||||
64
lib/towerops/equipment/equipment.ex
Normal file
64
lib/towerops/equipment/equipment.ex
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
defmodule Towerops.Equipment.Equipment do
|
||||
@moduledoc false
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "equipment" do
|
||||
field :name, :string
|
||||
field :ip_address, :string
|
||||
field :description, :string
|
||||
field :status, Ecto.Enum, values: [:up, :down, :unknown], default: :unknown
|
||||
field :last_checked_at, :utc_datetime
|
||||
field :last_status_change_at, :utc_datetime
|
||||
field :monitoring_enabled, :boolean, default: true
|
||||
field :check_interval_seconds, :integer, default: 300
|
||||
|
||||
belongs_to :site, Towerops.Sites.Site
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(equipment, attrs) do
|
||||
equipment
|
||||
|> cast(attrs, [
|
||||
:name,
|
||||
:ip_address,
|
||||
:description,
|
||||
:site_id,
|
||||
:monitoring_enabled,
|
||||
:check_interval_seconds
|
||||
])
|
||||
|> validate_required([:name, :ip_address, :site_id])
|
||||
|> validate_length(:name, min: 2, max: 200)
|
||||
|> validate_length(:description, max: 1000)
|
||||
|> validate_ip_address()
|
||||
|> validate_number(:check_interval_seconds, greater_than: 0, less_than_or_equal_to: 3600)
|
||||
|> foreign_key_constraint(:site_id)
|
||||
end
|
||||
|
||||
defp validate_ip_address(changeset) do
|
||||
case get_change(changeset, :ip_address) do
|
||||
nil ->
|
||||
changeset
|
||||
|
||||
ip_string ->
|
||||
case parse_ip(ip_string) do
|
||||
{:ok, _} ->
|
||||
changeset
|
||||
|
||||
{:error, _} ->
|
||||
add_error(changeset, :ip_address, "must be a valid IPv4 or IPv6 address")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_ip(ip_string) do
|
||||
ip_string
|
||||
|> String.to_charlist()
|
||||
|> :inet.parse_address()
|
||||
end
|
||||
end
|
||||
114
lib/towerops/sites.ex
Normal file
114
lib/towerops/sites.ex
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
defmodule Towerops.Sites do
|
||||
@moduledoc """
|
||||
The Sites context.
|
||||
"""
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Towerops.Repo
|
||||
alias Towerops.Sites.Site
|
||||
|
||||
@doc """
|
||||
Returns the list of sites for an organization.
|
||||
"""
|
||||
def list_organization_sites(organization_id) do
|
||||
Repo.all(
|
||||
from(s in Site, where: s.organization_id == ^organization_id, order_by: [asc: s.name], preload: [:parent_site])
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the list of root sites (sites without a parent) for an organization.
|
||||
"""
|
||||
def list_root_sites(organization_id) do
|
||||
Repo.all(
|
||||
from(s in Site,
|
||||
where: s.organization_id == ^organization_id,
|
||||
where: is_nil(s.parent_site_id),
|
||||
order_by: [asc: s.name]
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the list of child sites for a parent site.
|
||||
"""
|
||||
def list_child_sites(parent_site_id) do
|
||||
Repo.all(from(s in Site, where: s.parent_site_id == ^parent_site_id, order_by: [asc: s.name]))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single site.
|
||||
"""
|
||||
def get_site!(id) do
|
||||
Site
|
||||
|> Repo.get!(id)
|
||||
|> Repo.preload([:parent_site, :child_sites, :equipment])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single site belonging to an organization.
|
||||
"""
|
||||
def get_organization_site!(organization_id, site_id) do
|
||||
Repo.one!(
|
||||
from(s in Site,
|
||||
where: s.id == ^site_id,
|
||||
where: s.organization_id == ^organization_id,
|
||||
preload: [:parent_site, :child_sites, :equipment]
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a site.
|
||||
"""
|
||||
def create_site(attrs) do
|
||||
%Site{}
|
||||
|> Site.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a site.
|
||||
"""
|
||||
def update_site(%Site{} = site, attrs) do
|
||||
site
|
||||
|> Site.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a site.
|
||||
"""
|
||||
def delete_site(%Site{} = site) do
|
||||
Repo.delete(site)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for tracking site changes.
|
||||
"""
|
||||
def change_site(%Site{} = site, attrs \\ %{}) do
|
||||
Site.changeset(site, attrs)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Builds a hierarchical tree structure of sites.
|
||||
"""
|
||||
def build_site_tree(organization_id) do
|
||||
sites = list_organization_sites(organization_id)
|
||||
root_sites = Enum.filter(sites, &is_nil(&1.parent_site_id))
|
||||
|
||||
Enum.map(root_sites, fn root ->
|
||||
build_tree_node(root, sites)
|
||||
end)
|
||||
end
|
||||
|
||||
defp build_tree_node(site, all_sites) do
|
||||
children =
|
||||
all_sites
|
||||
|> Enum.filter(&(&1.parent_site_id == site.id))
|
||||
|> Enum.map(&build_tree_node(&1, all_sites))
|
||||
|
||||
%{site: site, children: children}
|
||||
end
|
||||
end
|
||||
47
lib/towerops/sites/site.ex
Normal file
47
lib/towerops/sites/site.ex
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
defmodule Towerops.Sites.Site do
|
||||
@moduledoc false
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Towerops.Sites.Site
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "sites" do
|
||||
field :name, :string
|
||||
field :description, :string
|
||||
field :location, :string
|
||||
|
||||
belongs_to :organization, Towerops.Organizations.Organization
|
||||
belongs_to :parent_site, Site
|
||||
has_many :child_sites, Site, foreign_key: :parent_site_id
|
||||
has_many :equipment, Towerops.Equipment.Equipment
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(site, attrs) do
|
||||
site
|
||||
|> cast(attrs, [:name, :description, :location, :organization_id, :parent_site_id])
|
||||
|> validate_required([:name, :organization_id])
|
||||
|> validate_length(:name, min: 2, max: 200)
|
||||
|> validate_length(:description, max: 1000)
|
||||
|> validate_length(:location, max: 200)
|
||||
|> foreign_key_constraint(:organization_id)
|
||||
|> foreign_key_constraint(:parent_site_id)
|
||||
|> validate_not_circular_parent()
|
||||
end
|
||||
|
||||
defp validate_not_circular_parent(changeset) do
|
||||
parent_site_id = get_change(changeset, :parent_site_id)
|
||||
site_id = get_field(changeset, :id)
|
||||
|
||||
if parent_site_id && site_id && parent_site_id == site_id do
|
||||
add_error(changeset, :parent_site_id, "cannot be the same as the site itself")
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
end
|
||||
94
lib/towerops_web/live/equipment_live/form.ex
Normal file
94
lib/towerops_web/live/equipment_live/form.ex
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
defmodule ToweropsWeb.EquipmentLive.Form do
|
||||
@moduledoc false
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.Equipment
|
||||
alias Towerops.Equipment.Equipment, as: EquipmentSchema
|
||||
alias Towerops.Sites
|
||||
|
||||
@impl true
|
||||
def mount(params, _session, socket) do
|
||||
organization = socket.assigns.current_organization
|
||||
sites = Sites.list_organization_sites(organization.id)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:organization, organization)
|
||||
|> assign(:available_sites, sites)
|
||||
|> assign(:preselected_site_id, params["site_id"])}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
|
||||
end
|
||||
|
||||
defp apply_action(socket, :new, _params) do
|
||||
equipment_attrs = %{monitoring_enabled: true, check_interval_seconds: 300}
|
||||
|
||||
equipment_attrs =
|
||||
if socket.assigns.preselected_site_id do
|
||||
Map.put(equipment_attrs, :site_id, socket.assigns.preselected_site_id)
|
||||
else
|
||||
equipment_attrs
|
||||
end
|
||||
|
||||
changeset = Equipment.change_equipment(%EquipmentSchema{}, equipment_attrs)
|
||||
|
||||
socket
|
||||
|> assign(:page_title, "New Equipment")
|
||||
|> assign(:equipment, %EquipmentSchema{})
|
||||
|> assign(:form, to_form(changeset))
|
||||
end
|
||||
|
||||
defp apply_action(socket, :edit, %{"id" => id}) do
|
||||
equipment = Equipment.get_equipment!(id)
|
||||
changeset = Equipment.change_equipment(equipment)
|
||||
|
||||
socket
|
||||
|> assign(:page_title, "Edit Equipment")
|
||||
|> assign(:equipment, equipment)
|
||||
|> assign(:form, to_form(changeset))
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"equipment" => equipment_params}, socket) do
|
||||
changeset =
|
||||
socket.assigns.equipment
|
||||
|> Equipment.change_equipment(equipment_params)
|
||||
|> Map.put(:action, :validate)
|
||||
|
||||
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save", %{"equipment" => equipment_params}, socket) do
|
||||
save_equipment(socket, socket.assigns.live_action, equipment_params)
|
||||
end
|
||||
|
||||
defp save_equipment(socket, :new, equipment_params) do
|
||||
case Equipment.create_equipment(equipment_params) do
|
||||
{:ok, equipment} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Equipment created successfully")
|
||||
|> push_navigate(to: ~p"/orgs/#{socket.assigns.organization.slug}/equipment/#{equipment.id}")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||
end
|
||||
end
|
||||
|
||||
defp save_equipment(socket, :edit, equipment_params) do
|
||||
case Equipment.update_equipment(socket.assigns.equipment, equipment_params) do
|
||||
{:ok, equipment} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Equipment updated successfully")
|
||||
|> push_navigate(to: ~p"/orgs/#{socket.assigns.organization.slug}/equipment/#{equipment.id}")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||
end
|
||||
end
|
||||
end
|
||||
42
lib/towerops_web/live/equipment_live/form.html.heex
Normal file
42
lib/towerops_web/live/equipment_live/form.html.heex
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>
|
||||
{if @live_action == :new, do: "Add new equipment to monitor", else: "Update equipment details"}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<div class="max-w-2xl mt-8">
|
||||
<.form for={@form} id="equipment-form" phx-change="validate" phx-submit="save">
|
||||
<div class="space-y-4">
|
||||
<.input field={@form[:name]} type="text" label="Equipment Name" required />
|
||||
|
||||
<.input field={@form[:ip_address]} type="text" label="IP Address" required />
|
||||
|
||||
<.input
|
||||
field={@form[:site_id]}
|
||||
type="select"
|
||||
label="Site"
|
||||
required
|
||||
prompt="Select a site"
|
||||
options={Enum.map(@available_sites, &{&1.name, &1.id})}
|
||||
/>
|
||||
|
||||
<.input field={@form[:description]} type="textarea" label="Description" />
|
||||
|
||||
<.input
|
||||
field={@form[:check_interval_seconds]}
|
||||
type="number"
|
||||
label="Check Interval (seconds)"
|
||||
min="30"
|
||||
max="3600"
|
||||
/>
|
||||
|
||||
<.input field={@form[:monitoring_enabled]} type="checkbox" label="Enable Monitoring" />
|
||||
|
||||
<div class="flex gap-2">
|
||||
<.button phx-disable-with="Saving...">Save Equipment</.button>
|
||||
<.link navigate={~p"/orgs/#{@organization.slug}/equipment"} class="btn">Cancel</.link>
|
||||
</div>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
26
lib/towerops_web/live/equipment_live/index.ex
Normal file
26
lib/towerops_web/live/equipment_live/index.ex
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
defmodule ToweropsWeb.EquipmentLive.Index do
|
||||
@moduledoc false
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.Equipment
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
organization = socket.assigns.current_organization
|
||||
equipment = Equipment.list_organization_equipment(organization.id)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Equipment")
|
||||
|> assign(:equipment, equipment)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
|
||||
end
|
||||
|
||||
defp apply_action(socket, :index, _params) do
|
||||
socket
|
||||
end
|
||||
end
|
||||
84
lib/towerops_web/live/equipment_live/index.html.heex
Normal file
84
lib/towerops_web/live/equipment_live/index.html.heex
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>Monitor and manage all your network equipment</:subtitle>
|
||||
<:actions>
|
||||
<.link
|
||||
navigate={~p"/orgs/#{@current_organization.slug}/equipment/new"}
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<.icon name="hero-plus" class="w-5 h-5" /> New Equipment
|
||||
</.link>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<div class="mt-8">
|
||||
<%= if @equipment == [] do %>
|
||||
<div class="text-center py-12">
|
||||
<.icon name="hero-server" class="mx-auto w-12 h-12 text-base-content/40" />
|
||||
<h3 class="mt-2 text-sm font-semibold text-base-content">No equipment</h3>
|
||||
<p class="mt-1 text-sm text-base-content/60">Get started by adding your first equipment.</p>
|
||||
<div class="mt-6">
|
||||
<.link
|
||||
navigate={~p"/orgs/#{@current_organization.slug}/equipment/new"}
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<.icon name="hero-plus" class="w-5 h-5" /> New Equipment
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>IP Address</th>
|
||||
<th>Site</th>
|
||||
<th>Status</th>
|
||||
<th>Last Checked</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr :for={eq <- @equipment}>
|
||||
<td class="font-medium">{eq.name}</td>
|
||||
<td class="font-mono text-sm">{eq.ip_address}</td>
|
||||
<td>
|
||||
<.link
|
||||
navigate={~p"/orgs/#{@current_organization.slug}/sites/#{eq.site.id}"}
|
||||
class="link link-primary"
|
||||
>
|
||||
{eq.site.name}
|
||||
</.link>
|
||||
</td>
|
||||
<td>
|
||||
<span class={[
|
||||
"badge",
|
||||
eq.status == :up && "badge-success",
|
||||
eq.status == :down && "badge-error",
|
||||
eq.status == :unknown && "badge-ghost"
|
||||
]}>
|
||||
{eq.status |> to_string() |> String.upcase()}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-sm text-base-content/60">
|
||||
<%= if eq.last_checked_at do %>
|
||||
{Calendar.strftime(eq.last_checked_at, "%Y-%m-%d %H:%M")}
|
||||
<% else %>
|
||||
Never
|
||||
<% end %>
|
||||
</td>
|
||||
<td>
|
||||
<.link
|
||||
navigate={~p"/orgs/#{@current_organization.slug}/equipment/#{eq.id}"}
|
||||
class="btn btn-xs"
|
||||
>
|
||||
View
|
||||
</.link>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
35
lib/towerops_web/live/equipment_live/show.ex
Normal file
35
lib/towerops_web/live/equipment_live/show.ex
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
defmodule ToweropsWeb.EquipmentLive.Show do
|
||||
@moduledoc false
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.Equipment
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(%{"id" => id}, _, socket) do
|
||||
equipment = Equipment.get_equipment!(id)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:page_title, equipment.name)
|
||||
|> assign(:equipment, equipment)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", _params, socket) do
|
||||
case Equipment.delete_equipment(socket.assigns.equipment) do
|
||||
{:ok, _} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Equipment deleted successfully")
|
||||
|> push_navigate(to: ~p"/orgs/#{socket.assigns.current_organization.slug}/equipment")}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Unable to delete equipment")}
|
||||
end
|
||||
end
|
||||
end
|
||||
103
lib/towerops_web/live/equipment_live/show.html.heex
Normal file
103
lib/towerops_web/live/equipment_live/show.html.heex
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>{@equipment.ip_address}</:subtitle>
|
||||
<:actions>
|
||||
<.link
|
||||
navigate={~p"/orgs/#{@current_organization.slug}/equipment/#{@equipment.id}/edit"}
|
||||
class="btn btn-sm"
|
||||
>
|
||||
<.icon name="hero-pencil" class="w-4 h-4" /> Edit
|
||||
</.link>
|
||||
<.button phx-click="delete" data-confirm="Are you sure?" class="btn-error btn-sm">
|
||||
<.icon name="hero-trash" class="w-4 h-4" /> Delete
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<div class="mt-8 grid gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-4">Equipment Details</h3>
|
||||
<dl class="space-y-3">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-base-content/60">Status</dt>
|
||||
<dd class="mt-1">
|
||||
<span class={[
|
||||
"badge badge-lg",
|
||||
@equipment.status == :up && "badge-success",
|
||||
@equipment.status == :down && "badge-error",
|
||||
@equipment.status == :unknown && "badge-ghost"
|
||||
]}>
|
||||
{@equipment.status |> to_string() |> String.upcase()}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-base-content/60">Site</dt>
|
||||
<dd class="mt-1">
|
||||
<.link
|
||||
navigate={~p"/orgs/#{@current_organization.slug}/sites/#{@equipment.site.id}"}
|
||||
class="link link-primary"
|
||||
>
|
||||
{@equipment.site.name}
|
||||
</.link>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-base-content/60">IP Address</dt>
|
||||
<dd class="mt-1 font-mono">{@equipment.ip_address}</dd>
|
||||
</div>
|
||||
|
||||
<%= if @equipment.description do %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-base-content/60">Description</dt>
|
||||
<dd class="mt-1">{@equipment.description}</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-base-content/60">Monitoring</dt>
|
||||
<dd class="mt-1">
|
||||
<%= if @equipment.monitoring_enabled do %>
|
||||
<span class="badge badge-success">Enabled</span>
|
||||
<% else %>
|
||||
<span class="badge badge-ghost">Disabled</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-base-content/60">Check Interval</dt>
|
||||
<dd class="mt-1">{@equipment.check_interval_seconds} seconds</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-4">Monitoring History</h3>
|
||||
<dl class="space-y-3">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-base-content/60">Last Checked</dt>
|
||||
<dd class="mt-1">
|
||||
<%= if @equipment.last_checked_at do %>
|
||||
{Calendar.strftime(@equipment.last_checked_at, "%Y-%m-%d %H:%M:%S UTC")}
|
||||
<% else %>
|
||||
<span class="text-base-content/40">Never</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-base-content/60">Last Status Change</dt>
|
||||
<dd class="mt-1">
|
||||
<%= if @equipment.last_status_change_at do %>
|
||||
{Calendar.strftime(@equipment.last_status_change_at, "%Y-%m-%d %H:%M:%S UTC")}
|
||||
<% else %>
|
||||
<span class="text-base-content/40">Never</span>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
89
lib/towerops_web/live/site_live/form.ex
Normal file
89
lib/towerops_web/live/site_live/form.ex
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
defmodule ToweropsWeb.SiteLive.Form do
|
||||
@moduledoc false
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.Sites
|
||||
alias Towerops.Sites.Site
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
organization = socket.assigns.current_organization
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:organization, organization)
|
||||
|> assign(:available_parent_sites, Sites.list_organization_sites(organization.id))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
|
||||
end
|
||||
|
||||
defp apply_action(socket, :new, _params) do
|
||||
changeset = Sites.change_site(%Site{organization_id: socket.assigns.organization.id})
|
||||
|
||||
socket
|
||||
|> assign(:page_title, "New Site")
|
||||
|> assign(:site, %Site{})
|
||||
|> assign(:form, to_form(changeset))
|
||||
end
|
||||
|
||||
defp apply_action(socket, :edit, %{"id" => id}) do
|
||||
site = Sites.get_organization_site!(socket.assigns.organization.id, id)
|
||||
changeset = Sites.change_site(site)
|
||||
|
||||
# Filter out the current site and its descendants from parent options
|
||||
available_parent_sites =
|
||||
Enum.reject(socket.assigns.available_parent_sites, fn s -> s.id == site.id end)
|
||||
|
||||
socket
|
||||
|> assign(:page_title, "Edit Site")
|
||||
|> assign(:site, site)
|
||||
|> assign(:form, to_form(changeset))
|
||||
|> assign(:available_parent_sites, available_parent_sites)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"site" => site_params}, socket) do
|
||||
changeset =
|
||||
socket.assigns.site
|
||||
|> Sites.change_site(site_params)
|
||||
|> Map.put(:action, :validate)
|
||||
|
||||
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save", %{"site" => site_params}, socket) do
|
||||
save_site(socket, socket.assigns.live_action, site_params)
|
||||
end
|
||||
|
||||
defp save_site(socket, :new, site_params) do
|
||||
site_params = Map.put(site_params, "organization_id", socket.assigns.organization.id)
|
||||
|
||||
case Sites.create_site(site_params) do
|
||||
{:ok, _site} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Site created successfully")
|
||||
|> push_navigate(to: ~p"/orgs/#{socket.assigns.organization.slug}/sites")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||
end
|
||||
end
|
||||
|
||||
defp save_site(socket, :edit, site_params) do
|
||||
case Sites.update_site(socket.assigns.site, site_params) do
|
||||
{:ok, _site} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Site updated successfully")
|
||||
|> push_navigate(to: ~p"/orgs/#{socket.assigns.organization.slug}/sites")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||
end
|
||||
end
|
||||
end
|
||||
33
lib/towerops_web/live/site_live/form.html.heex
Normal file
33
lib/towerops_web/live/site_live/form.html.heex
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>
|
||||
{if @live_action == :new,
|
||||
do: "Add a new site to your organization",
|
||||
else: "Update site details"}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<div class="max-w-2xl mt-8">
|
||||
<.form for={@form} id="site-form" phx-change="validate" phx-submit="save">
|
||||
<div class="space-y-4">
|
||||
<.input field={@form[:name]} type="text" label="Site Name" required />
|
||||
|
||||
<.input
|
||||
field={@form[:parent_site_id]}
|
||||
type="select"
|
||||
label="Parent Site (optional)"
|
||||
prompt="No parent site"
|
||||
options={Enum.map(@available_parent_sites, &{&1.name, &1.id})}
|
||||
/>
|
||||
|
||||
<.input field={@form[:location]} type="text" label="Location" />
|
||||
|
||||
<.input field={@form[:description]} type="textarea" label="Description" />
|
||||
|
||||
<div class="flex gap-2">
|
||||
<.button phx-disable-with="Saving...">Save Site</.button>
|
||||
<.link navigate={~p"/orgs/#{@organization.slug}/sites"} class="btn">Cancel</.link>
|
||||
</div>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
28
lib/towerops_web/live/site_live/index.ex
Normal file
28
lib/towerops_web/live/site_live/index.ex
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
defmodule ToweropsWeb.SiteLive.Index do
|
||||
@moduledoc false
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.Sites
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
organization = socket.assigns.current_organization
|
||||
sites = Sites.list_organization_sites(organization.id)
|
||||
site_tree = Sites.build_site_tree(organization.id)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Sites")
|
||||
|> assign(:sites, sites)
|
||||
|> assign(:site_tree, site_tree)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
|
||||
end
|
||||
|
||||
defp apply_action(socket, :index, _params) do
|
||||
assign(socket, :page_title, "Sites")
|
||||
end
|
||||
end
|
||||
56
lib/towerops_web/live/site_live/index.html.heex
Normal file
56
lib/towerops_web/live/site_live/index.html.heex
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>Manage your site locations and hierarchy</:subtitle>
|
||||
<:actions>
|
||||
<.link
|
||||
navigate={~p"/orgs/#{@current_organization.slug}/sites/new"}
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<.icon name="hero-plus" class="w-5 h-5" /> New Site
|
||||
</.link>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<div class="mt-8">
|
||||
<%= if @sites == [] do %>
|
||||
<div class="text-center py-12">
|
||||
<.icon name="hero-building-office" class="mx-auto w-12 h-12 text-base-content/40" />
|
||||
<h3 class="mt-2 text-sm font-semibold text-base-content">No sites</h3>
|
||||
<p class="mt-1 text-sm text-base-content/60">Get started by creating your first site.</p>
|
||||
<div class="mt-6">
|
||||
<.link
|
||||
navigate={~p"/orgs/#{@current_organization.slug}/sites/new"}
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<.icon name="hero-plus" class="w-5 h-5" /> New Site
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div :for={site <- @sites} class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">{site.name}</h3>
|
||||
<%= if site.location do %>
|
||||
<p class="text-sm text-base-content/60">
|
||||
<.icon name="hero-map-pin" class="w-4 h-4 inline" /> {site.location}
|
||||
</p>
|
||||
<% end %>
|
||||
<%= if site.parent_site do %>
|
||||
<p class="text-xs text-base-content/40">
|
||||
Parent: {site.parent_site.name}
|
||||
</p>
|
||||
<% end %>
|
||||
<div class="card-actions justify-end mt-2">
|
||||
<.link
|
||||
navigate={~p"/orgs/#{@current_organization.slug}/sites/#{site.id}"}
|
||||
class="btn btn-sm"
|
||||
>
|
||||
View
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
39
lib/towerops_web/live/site_live/show.ex
Normal file
39
lib/towerops_web/live/site_live/show.ex
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
defmodule ToweropsWeb.SiteLive.Show do
|
||||
@moduledoc false
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.Equipment
|
||||
alias Towerops.Sites
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(%{"id" => id}, _, socket) do
|
||||
organization = socket.assigns.current_organization
|
||||
site = Sites.get_organization_site!(organization.id, id)
|
||||
equipment = Equipment.list_site_equipment(site.id)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:page_title, site.name)
|
||||
|> assign(:site, site)
|
||||
|> assign(:equipment, equipment)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", _params, socket) do
|
||||
case Sites.delete_site(socket.assigns.site) do
|
||||
{:ok, _} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Site deleted successfully")
|
||||
|> push_navigate(to: ~p"/orgs/#{socket.assigns.current_organization.slug}/sites")}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Unable to delete site")}
|
||||
end
|
||||
end
|
||||
end
|
||||
108
lib/towerops_web/live/site_live/show.html.heex
Normal file
108
lib/towerops_web/live/site_live/show.html.heex
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>{@site.location}</:subtitle>
|
||||
<:actions>
|
||||
<.link
|
||||
navigate={~p"/orgs/#{@current_organization.slug}/sites/#{@site.id}/edit"}
|
||||
class="btn btn-sm"
|
||||
>
|
||||
<.icon name="hero-pencil" class="w-4 h-4" /> Edit
|
||||
</.link>
|
||||
<.button phx-click="delete" data-confirm="Are you sure?" class="btn-error btn-sm">
|
||||
<.icon name="hero-trash" class="w-4 h-4" /> Delete
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<div class="mt-8 grid gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-4">Site Details</h3>
|
||||
<dl class="space-y-2">
|
||||
<%= if @site.parent_site do %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-base-content/60">Parent Site</dt>
|
||||
<dd class="mt-1">
|
||||
<.link
|
||||
navigate={~p"/orgs/#{@current_organization.slug}/sites/#{@site.parent_site.id}"}
|
||||
class="link link-primary"
|
||||
>
|
||||
{@site.parent_site.name}
|
||||
</.link>
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if @site.location do %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-base-content/60">Location</dt>
|
||||
<dd class="mt-1">{@site.location}</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if @site.description do %>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-base-content/60">Description</dt>
|
||||
<dd class="mt-1">{@site.description}</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
</dl>
|
||||
|
||||
<%= if @site.child_sites != [] do %>
|
||||
<div class="mt-6">
|
||||
<h4 class="text-sm font-semibold mb-2">Child Sites</h4>
|
||||
<ul class="space-y-1">
|
||||
<li :for={child <- @site.child_sites}>
|
||||
<.link
|
||||
navigate={~p"/orgs/#{@current_organization.slug}/sites/#{child.id}"}
|
||||
class="link link-primary text-sm"
|
||||
>
|
||||
{child.name}
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold">Equipment</h3>
|
||||
<.link
|
||||
navigate={~p"/orgs/#{@current_organization.slug}/equipment/new?site_id=#{@site.id}"}
|
||||
class="btn btn-sm btn-primary"
|
||||
>
|
||||
<.icon name="hero-plus" class="w-4 h-4" /> Add Equipment
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<%= if @equipment == [] do %>
|
||||
<p class="text-sm text-base-content/60">No equipment at this site yet.</p>
|
||||
<% else %>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
:for={eq <- @equipment}
|
||||
class="flex items-center justify-between p-3 bg-base-200 rounded"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium">{eq.name}</p>
|
||||
<p class="text-sm text-base-content/60">{eq.ip_address}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class={[
|
||||
"badge badge-sm",
|
||||
eq.status == :up && "badge-success",
|
||||
eq.status == :down && "badge-error",
|
||||
eq.status == :unknown && "badge-ghost"
|
||||
]}>
|
||||
{eq.status |> to_string() |> String.upcase()}
|
||||
</span>
|
||||
<.link
|
||||
navigate={~p"/orgs/#{@current_organization.slug}/equipment/#{eq.id}"}
|
||||
class="btn btn-xs"
|
||||
>
|
||||
View
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -84,5 +84,17 @@ defmodule ToweropsWeb.Router do
|
|||
pipe_through [:browser, :require_authenticated_user, :load_current_organization]
|
||||
|
||||
live "/", DashboardLive, :index
|
||||
|
||||
# Site routes
|
||||
live "/sites", SiteLive.Index, :index
|
||||
live "/sites/new", SiteLive.Form, :new
|
||||
live "/sites/:id", SiteLive.Show, :show
|
||||
live "/sites/:id/edit", SiteLive.Form, :edit
|
||||
|
||||
# Equipment routes
|
||||
live "/equipment", EquipmentLive.Index, :index
|
||||
live "/equipment/new", EquipmentLive.Form, :new
|
||||
live "/equipment/:id", EquipmentLive.Show, :show
|
||||
live "/equipment/:id/edit", EquipmentLive.Form, :edit
|
||||
end
|
||||
end
|
||||
|
|
|
|||
22
priv/repo/migrations/20251221193428_create_sites.exs
Normal file
22
priv/repo/migrations/20251221193428_create_sites.exs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
defmodule Towerops.Repo.Migrations.CreateSites do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:sites, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
|
||||
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
add :parent_site_id, references(:sites, type: :binary_id, on_delete: :delete_all)
|
||||
add :name, :string, null: false
|
||||
add :description, :text
|
||||
add :location, :string
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create index(:sites, [:organization_id])
|
||||
create index(:sites, [:parent_site_id])
|
||||
end
|
||||
end
|
||||
23
priv/repo/migrations/20251221193436_create_equipment.exs
Normal file
23
priv/repo/migrations/20251221193436_create_equipment.exs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
defmodule Towerops.Repo.Migrations.CreateEquipment do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:equipment, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :site_id, references(:sites, type: :binary_id, on_delete: :delete_all), null: false
|
||||
add :name, :string, null: false
|
||||
add :ip_address, :string, null: false
|
||||
add :description, :text
|
||||
add :status, :string, null: false, default: "unknown"
|
||||
add :last_checked_at, :utc_datetime
|
||||
add :last_status_change_at, :utc_datetime
|
||||
add :monitoring_enabled, :boolean, default: true, null: false
|
||||
add :check_interval_seconds, :integer, default: 300, null: false
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create index(:equipment, [:site_id])
|
||||
create index(:equipment, [:status])
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue