diff --git a/lib/towerops/equipment.ex b/lib/towerops/equipment.ex
new file mode 100644
index 00000000..b1681370
--- /dev/null
+++ b/lib/towerops/equipment.ex
@@ -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
diff --git a/lib/towerops/equipment/equipment.ex b/lib/towerops/equipment/equipment.ex
new file mode 100644
index 00000000..981331b0
--- /dev/null
+++ b/lib/towerops/equipment/equipment.ex
@@ -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
diff --git a/lib/towerops/sites.ex b/lib/towerops/sites.ex
new file mode 100644
index 00000000..13c5064b
--- /dev/null
+++ b/lib/towerops/sites.ex
@@ -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
diff --git a/lib/towerops/sites/site.ex b/lib/towerops/sites/site.ex
new file mode 100644
index 00000000..8a9d155e
--- /dev/null
+++ b/lib/towerops/sites/site.ex
@@ -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
diff --git a/lib/towerops_web/live/equipment_live/form.ex b/lib/towerops_web/live/equipment_live/form.ex
new file mode 100644
index 00000000..1b482d62
--- /dev/null
+++ b/lib/towerops_web/live/equipment_live/form.ex
@@ -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
diff --git a/lib/towerops_web/live/equipment_live/form.html.heex b/lib/towerops_web/live/equipment_live/form.html.heex
new file mode 100644
index 00000000..05d093fd
--- /dev/null
+++ b/lib/towerops_web/live/equipment_live/form.html.heex
@@ -0,0 +1,42 @@
+<.header>
+ {@page_title}
+ <:subtitle>
+ {if @live_action == :new, do: "Add new equipment to monitor", else: "Update equipment details"}
+
+
+
+
+ <.form for={@form} id="equipment-form" phx-change="validate" phx-submit="save">
+
+ <.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" />
+
+
+ <.button phx-disable-with="Saving...">Save Equipment
+ <.link navigate={~p"/orgs/#{@organization.slug}/equipment"} class="btn">Cancel
+
+
+
+
diff --git a/lib/towerops_web/live/equipment_live/index.ex b/lib/towerops_web/live/equipment_live/index.ex
new file mode 100644
index 00000000..4ae77a85
--- /dev/null
+++ b/lib/towerops_web/live/equipment_live/index.ex
@@ -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
diff --git a/lib/towerops_web/live/equipment_live/index.html.heex b/lib/towerops_web/live/equipment_live/index.html.heex
new file mode 100644
index 00000000..e5fb2d84
--- /dev/null
+++ b/lib/towerops_web/live/equipment_live/index.html.heex
@@ -0,0 +1,84 @@
+<.header>
+ {@page_title}
+ <:subtitle>Monitor and manage all your network equipment
+ <: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
+
+
+
+
+
+ <%= if @equipment == [] do %>
+
+ <.icon name="hero-server" class="mx-auto w-12 h-12 text-base-content/40" />
+
No equipment
+
Get started by adding your first equipment.
+
+ <.link
+ navigate={~p"/orgs/#{@current_organization.slug}/equipment/new"}
+ class="btn btn-primary"
+ >
+ <.icon name="hero-plus" class="w-5 h-5" /> New Equipment
+
+
+
+ <% else %>
+
+
+
+
+ | Name |
+ IP Address |
+ Site |
+ Status |
+ Last Checked |
+ |
+
+
+
+
+ | {eq.name} |
+ {eq.ip_address} |
+
+ <.link
+ navigate={~p"/orgs/#{@current_organization.slug}/sites/#{eq.site.id}"}
+ class="link link-primary"
+ >
+ {eq.site.name}
+
+ |
+
+
+ {eq.status |> to_string() |> String.upcase()}
+
+ |
+
+ <%= if eq.last_checked_at do %>
+ {Calendar.strftime(eq.last_checked_at, "%Y-%m-%d %H:%M")}
+ <% else %>
+ Never
+ <% end %>
+ |
+
+ <.link
+ navigate={~p"/orgs/#{@current_organization.slug}/equipment/#{eq.id}"}
+ class="btn btn-xs"
+ >
+ View
+
+ |
+
+
+
+
+ <% end %>
+
diff --git a/lib/towerops_web/live/equipment_live/show.ex b/lib/towerops_web/live/equipment_live/show.ex
new file mode 100644
index 00000000..6f1d69e5
--- /dev/null
+++ b/lib/towerops_web/live/equipment_live/show.ex
@@ -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
diff --git a/lib/towerops_web/live/equipment_live/show.html.heex b/lib/towerops_web/live/equipment_live/show.html.heex
new file mode 100644
index 00000000..067db8a3
--- /dev/null
+++ b/lib/towerops_web/live/equipment_live/show.html.heex
@@ -0,0 +1,103 @@
+<.header>
+ {@page_title}
+ <:subtitle>{@equipment.ip_address}
+ <: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
+
+ <.button phx-click="delete" data-confirm="Are you sure?" class="btn-error btn-sm">
+ <.icon name="hero-trash" class="w-4 h-4" /> Delete
+
+
+
+
+
+
+
Equipment Details
+
+
+
- Status
+ -
+
+ {@equipment.status |> to_string() |> String.upcase()}
+
+
+
+
+
+
- Site
+ -
+ <.link
+ navigate={~p"/orgs/#{@current_organization.slug}/sites/#{@equipment.site.id}"}
+ class="link link-primary"
+ >
+ {@equipment.site.name}
+
+
+
+
+
+
- IP Address
+ - {@equipment.ip_address}
+
+
+ <%= if @equipment.description do %>
+
+
- Description
+ - {@equipment.description}
+
+ <% end %>
+
+
+
- Monitoring
+ -
+ <%= if @equipment.monitoring_enabled do %>
+ Enabled
+ <% else %>
+ Disabled
+ <% end %>
+
+
+
+
+
- Check Interval
+ - {@equipment.check_interval_seconds} seconds
+
+
+
+
+
+
Monitoring History
+
+
+
- Last Checked
+ -
+ <%= if @equipment.last_checked_at do %>
+ {Calendar.strftime(@equipment.last_checked_at, "%Y-%m-%d %H:%M:%S UTC")}
+ <% else %>
+ Never
+ <% end %>
+
+
+
+
+
- Last Status Change
+ -
+ <%= if @equipment.last_status_change_at do %>
+ {Calendar.strftime(@equipment.last_status_change_at, "%Y-%m-%d %H:%M:%S UTC")}
+ <% else %>
+ Never
+ <% end %>
+
+
+
+
+
diff --git a/lib/towerops_web/live/site_live/form.ex b/lib/towerops_web/live/site_live/form.ex
new file mode 100644
index 00000000..4792ee96
--- /dev/null
+++ b/lib/towerops_web/live/site_live/form.ex
@@ -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
diff --git a/lib/towerops_web/live/site_live/form.html.heex b/lib/towerops_web/live/site_live/form.html.heex
new file mode 100644
index 00000000..85c87d4a
--- /dev/null
+++ b/lib/towerops_web/live/site_live/form.html.heex
@@ -0,0 +1,33 @@
+<.header>
+ {@page_title}
+ <:subtitle>
+ {if @live_action == :new,
+ do: "Add a new site to your organization",
+ else: "Update site details"}
+
+
+
+
+ <.form for={@form} id="site-form" phx-change="validate" phx-submit="save">
+
+ <.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" />
+
+
+ <.button phx-disable-with="Saving...">Save Site
+ <.link navigate={~p"/orgs/#{@organization.slug}/sites"} class="btn">Cancel
+
+
+
+
diff --git a/lib/towerops_web/live/site_live/index.ex b/lib/towerops_web/live/site_live/index.ex
new file mode 100644
index 00000000..0ad6ac8c
--- /dev/null
+++ b/lib/towerops_web/live/site_live/index.ex
@@ -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
diff --git a/lib/towerops_web/live/site_live/index.html.heex b/lib/towerops_web/live/site_live/index.html.heex
new file mode 100644
index 00000000..a31dd869
--- /dev/null
+++ b/lib/towerops_web/live/site_live/index.html.heex
@@ -0,0 +1,56 @@
+<.header>
+ {@page_title}
+ <:subtitle>Manage your site locations and hierarchy
+ <: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
+
+
+
+
+
+ <%= if @sites == [] do %>
+
+ <.icon name="hero-building-office" class="mx-auto w-12 h-12 text-base-content/40" />
+
No sites
+
Get started by creating your first site.
+
+ <.link
+ navigate={~p"/orgs/#{@current_organization.slug}/sites/new"}
+ class="btn btn-primary"
+ >
+ <.icon name="hero-plus" class="w-5 h-5" /> New Site
+
+
+
+ <% else %>
+
+
+
+
{site.name}
+ <%= if site.location do %>
+
+ <.icon name="hero-map-pin" class="w-4 h-4 inline" /> {site.location}
+
+ <% end %>
+ <%= if site.parent_site do %>
+
+ Parent: {site.parent_site.name}
+
+ <% end %>
+
+ <.link
+ navigate={~p"/orgs/#{@current_organization.slug}/sites/#{site.id}"}
+ class="btn btn-sm"
+ >
+ View
+
+
+
+
+
+ <% end %>
+
diff --git a/lib/towerops_web/live/site_live/show.ex b/lib/towerops_web/live/site_live/show.ex
new file mode 100644
index 00000000..ac3c719d
--- /dev/null
+++ b/lib/towerops_web/live/site_live/show.ex
@@ -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
diff --git a/lib/towerops_web/live/site_live/show.html.heex b/lib/towerops_web/live/site_live/show.html.heex
new file mode 100644
index 00000000..c7603997
--- /dev/null
+++ b/lib/towerops_web/live/site_live/show.html.heex
@@ -0,0 +1,108 @@
+<.header>
+ {@page_title}
+ <:subtitle>{@site.location}
+ <: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
+
+ <.button phx-click="delete" data-confirm="Are you sure?" class="btn-error btn-sm">
+ <.icon name="hero-trash" class="w-4 h-4" /> Delete
+
+
+
+
+
+
+
Site Details
+
+ <%= if @site.parent_site do %>
+
+
- Parent Site
+ -
+ <.link
+ navigate={~p"/orgs/#{@current_organization.slug}/sites/#{@site.parent_site.id}"}
+ class="link link-primary"
+ >
+ {@site.parent_site.name}
+
+
+
+ <% end %>
+ <%= if @site.location do %>
+
+
- Location
+ - {@site.location}
+
+ <% end %>
+ <%= if @site.description do %>
+
+
- Description
+ - {@site.description}
+
+ <% end %>
+
+
+ <%= if @site.child_sites != [] do %>
+
+
Child Sites
+
+ -
+ <.link
+ navigate={~p"/orgs/#{@current_organization.slug}/sites/#{child.id}"}
+ class="link link-primary text-sm"
+ >
+ {child.name}
+
+
+
+
+ <% end %>
+
+
+
+
+
Equipment
+ <.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
+
+
+
+ <%= if @equipment == [] do %>
+
No equipment at this site yet.
+ <% else %>
+
+
+
+
{eq.name}
+
{eq.ip_address}
+
+
+
+ {eq.status |> to_string() |> String.upcase()}
+
+ <.link
+ navigate={~p"/orgs/#{@current_organization.slug}/equipment/#{eq.id}"}
+ class="btn btn-xs"
+ >
+ View
+
+
+
+
+ <% end %>
+
+
diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex
index a058b646..e656db91 100644
--- a/lib/towerops_web/router.ex
+++ b/lib/towerops_web/router.ex
@@ -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
diff --git a/priv/repo/migrations/20251221193428_create_sites.exs b/priv/repo/migrations/20251221193428_create_sites.exs
new file mode 100644
index 00000000..6cb8c1e7
--- /dev/null
+++ b/priv/repo/migrations/20251221193428_create_sites.exs
@@ -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
diff --git a/priv/repo/migrations/20251221193436_create_equipment.exs b/priv/repo/migrations/20251221193436_create_equipment.exs
new file mode 100644
index 00000000..d99f17ae
--- /dev/null
+++ b/priv/repo/migrations/20251221193436_create_equipment.exs
@@ -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