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:
Graham McIntire 2025-12-21 13:39:19 -06:00
parent c52f313e2d
commit 1bd72dac78
No known key found for this signature in database
19 changed files with 1121 additions and 0 deletions

102
lib/towerops/equipment.ex Normal file
View 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

View 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
View 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

View 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

View 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

View 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>

View 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

View 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>

View 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

View 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>

View 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

View 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>

View 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

View 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>

View 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

View 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>

View file

@ -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

View 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

View 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