From 1bd72dac782433cc16b4b2f897572d995587ca04 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 21 Dec 2025 13:39:19 -0600 Subject: [PATCH] 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 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lib/towerops/equipment.ex | 102 ++++++++++++++++ lib/towerops/equipment/equipment.ex | 64 ++++++++++ lib/towerops/sites.ex | 114 ++++++++++++++++++ lib/towerops/sites/site.ex | 47 ++++++++ lib/towerops_web/live/equipment_live/form.ex | 94 +++++++++++++++ .../live/equipment_live/form.html.heex | 42 +++++++ lib/towerops_web/live/equipment_live/index.ex | 26 ++++ .../live/equipment_live/index.html.heex | 84 +++++++++++++ lib/towerops_web/live/equipment_live/show.ex | 35 ++++++ .../live/equipment_live/show.html.heex | 103 ++++++++++++++++ lib/towerops_web/live/site_live/form.ex | 89 ++++++++++++++ .../live/site_live/form.html.heex | 33 +++++ lib/towerops_web/live/site_live/index.ex | 28 +++++ .../live/site_live/index.html.heex | 56 +++++++++ lib/towerops_web/live/site_live/show.ex | 39 ++++++ .../live/site_live/show.html.heex | 108 +++++++++++++++++ lib/towerops_web/router.ex | 12 ++ .../20251221193428_create_sites.exs | 22 ++++ .../20251221193436_create_equipment.exs | 23 ++++ 19 files changed, 1121 insertions(+) create mode 100644 lib/towerops/equipment.ex create mode 100644 lib/towerops/equipment/equipment.ex create mode 100644 lib/towerops/sites.ex create mode 100644 lib/towerops/sites/site.ex create mode 100644 lib/towerops_web/live/equipment_live/form.ex create mode 100644 lib/towerops_web/live/equipment_live/form.html.heex create mode 100644 lib/towerops_web/live/equipment_live/index.ex create mode 100644 lib/towerops_web/live/equipment_live/index.html.heex create mode 100644 lib/towerops_web/live/equipment_live/show.ex create mode 100644 lib/towerops_web/live/equipment_live/show.html.heex create mode 100644 lib/towerops_web/live/site_live/form.ex create mode 100644 lib/towerops_web/live/site_live/form.html.heex create mode 100644 lib/towerops_web/live/site_live/index.ex create mode 100644 lib/towerops_web/live/site_live/index.html.heex create mode 100644 lib/towerops_web/live/site_live/show.ex create mode 100644 lib/towerops_web/live/site_live/show.html.heex create mode 100644 priv/repo/migrations/20251221193428_create_sites.exs create mode 100644 priv/repo/migrations/20251221193436_create_equipment.exs 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 %> +
+ + + + + + + + + + + + + + + + + + + + + +
NameIP AddressSiteStatusLast 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