diff --git a/assets/js/app.ts b/assets/js/app.ts index 1dfaf8ee..dcce2f82 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -931,6 +931,129 @@ const MikrotikPortSync = { } } +// Sites Map hook for Leaflet.js geographic visualization +const SitesMap = { + map: null as any, + markers: null as any, + + mounted(this: any) { + // Wait for Leaflet to load + if (typeof L === 'undefined') { + setTimeout(() => this.mounted(), 100) + return + } + + const sitesData = JSON.parse(this.el.dataset.sites || '[]') + this.initializeLeaflet(sitesData) + + // Listen for site clicks from the map + this.handleEvent("site_clicked", (payload: any) => { + // Push event back to LiveView + this.pushEvent("site_clicked", payload) + }) + }, + + updated(this: any) { + if (this.map) { + const sitesData = JSON.parse(this.el.dataset.sites || '[]') + this.updateSites(sitesData) + } + }, + + destroyed(this: any) { + if (this.map) { + this.map.remove() + this.map = null + this.markers = null + } + }, + + initializeLeaflet(this: any, sites: any[]) { + // Initialize the map + this.map = L.map(this.el, { + zoomControl: true, + scrollWheelZoom: true + }) + + // Add OpenStreetMap tiles + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + maxZoom: 18 + }).addTo(this.map) + + this.updateSites(sites) + }, + + updateSites(this: any, sites: any[]) { + if (!this.map) return + + // Clear existing markers + if (this.markers) { + this.map.removeLayer(this.markers) + } + + // Create marker cluster group + this.markers = L.layerGroup() + + if (sites.length === 0) { + // Default view if no sites + this.map.setView([39.8283, -98.5795], 4) // Center on USA + this.markers.addTo(this.map) + return + } + + // Add markers for each site + const bounds = L.latLngBounds([]) + + sites.forEach((site: any) => { + if (site.latitude && site.longitude) { + const marker = L.marker([site.latitude, site.longitude]) + + // Create popup content + let popupContent = `
+

${site.name}

` + + if (site.description) { + popupContent += `

${site.description}

` + } + + if (site.address) { + popupContent += `

${site.address}

` + } + + popupContent += `

+ ${site.latitude.toFixed(4)}, ${site.longitude.toFixed(4)} +

` + + if (site.device_count > 0) { + popupContent += `

+ ${site.device_count} device${site.device_count !== 1 ? 's' : ''} +

` + } + + popupContent += `
` + + marker.bindPopup(popupContent) + this.markers.addLayer(marker) + bounds.extend([site.latitude, site.longitude]) + } + }) + + // Add markers to map + this.markers.addTo(this.map) + + // Fit map to show all markers + if (bounds.isValid()) { + this.map.fitBounds(bounds, { padding: [20, 20] }) + } + } +} + const csrfToken = document.querySelector("meta[name='csrf-token']")?.getAttribute("content") if (!csrfToken) { throw new Error('CSRF token meta tag not found') @@ -974,7 +1097,7 @@ const GlobalSearch = { const liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: { _csrf_token: csrfToken, timezone: userTimezone }, - hooks: { ...colocatedHooks, SensorChart, CopyToClipboard, ScrollToTop, AutoDismissFlash, BetaBannerDismiss, NetworkMap, DeviceListReorder, MikrotikPortSync, GlobalSearch, GlobalSearchTrigger }, + hooks: { ...colocatedHooks, SensorChart, CopyToClipboard, ScrollToTop, AutoDismissFlash, BetaBannerDismiss, NetworkMap, SitesMap, DeviceListReorder, MikrotikPortSync, GlobalSearch, GlobalSearchTrigger }, }) // Show progress bar on live navigation and form submits diff --git a/config/runtime.exs b/config/runtime.exs index 6814c384..082623cb 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -22,6 +22,9 @@ end config :towerops, ToweropsWeb.Endpoint, http: [port: String.to_integer(System.get_env("PORT", "4000"))] +# Configure Google Maps API key for geocoding (available in all environments) +config :towerops, google_maps_api_key: System.get_env("GOOGLE_MAPS_API_KEY") + if config_env() == :prod do database_url = System.get_env("DATABASE_URL") || diff --git a/lib/towerops/geocoding.ex b/lib/towerops/geocoding.ex new file mode 100644 index 00000000..ad187da2 --- /dev/null +++ b/lib/towerops/geocoding.ex @@ -0,0 +1,105 @@ +defmodule Towerops.Geocoding do + @moduledoc """ + Service for geocoding addresses using Google Maps Geocoding API. + + Supports both system-wide API keys and organization-specific encrypted API keys. + """ + + require Logger + + @google_geocoding_url "https://maps.googleapis.com/maps/api/geocode/json" + + @doc """ + Geocode an address to latitude/longitude coordinates. + + ## Parameters + + - `address`: The address string to geocode + + ## Returns + + - `{:ok, %{latitude: float, longitude: float, formatted_address: string}}` on success + - `{:error, reason}` on failure + """ + def geocode(address) when is_binary(address) do + with {:ok, api_key} <- get_api_key(), + {:ok, response} <- make_geocoding_request(address, api_key), + {:ok, result} <- parse_geocoding_response(response) do + {:ok, result} + else + {:error, reason} -> {:error, reason} + end + end + + defp get_api_key do + api_key = get_system_api_key() + + if api_key && String.trim(api_key) != "" do + {:ok, String.trim(api_key)} + else + {:error, :no_api_key} + end + end + + defp get_system_api_key do + Application.get_env(:towerops, :google_maps_api_key) || + System.get_env("GOOGLE_MAPS_API_KEY") + end + + defp make_geocoding_request(address, api_key) do + params = %{ + "address" => address, + "key" => api_key + } + + case Req.get(@google_geocoding_url, params: params, timeout: 10_000) do + {:ok, %Req.Response{status: 200, body: body}} -> + {:ok, body} + + {:ok, %Req.Response{status: status_code, body: body}} -> + Logger.warning("Geocoding API returned status #{status_code}: #{inspect(body)}") + {:error, "Geocoding API error (status #{status_code})"} + + {:error, reason} -> + Logger.error("Failed to make geocoding request: #{inspect(reason)}") + {:error, "Network error: #{inspect(reason)}"} + end + end + + defp parse_geocoding_response(%{"status" => "OK", "results" => [result | _]}) do + with %{"geometry" => %{"location" => %{"lat" => lat, "lng" => lng}}} <- result, + %{"formatted_address" => formatted_address} <- result do + {:ok, %{ + latitude: lat, + longitude: lng, + formatted_address: formatted_address + }} + else + _ -> {:error, "Invalid response format from Google Maps API"} + end + end + + defp parse_geocoding_response(%{"status" => "ZERO_RESULTS"}) do + {:error, "No results found for the given address"} + end + + defp parse_geocoding_response(%{"status" => "OVER_QUERY_LIMIT"}) do + {:error, "API quota exceeded. Please check your Google Maps API usage."} + end + + defp parse_geocoding_response(%{"status" => "REQUEST_DENIED", "error_message" => message}) do + {:error, "API request denied: #{message}"} + end + + defp parse_geocoding_response(%{"status" => "INVALID_REQUEST"}) do + {:error, "Invalid request. Please check the address format."} + end + + defp parse_geocoding_response(%{"status" => status}) do + {:error, "Geocoding failed with status: #{status}"} + end + + defp parse_geocoding_response(_response) do + {:error, "Unexpected response format from Google Maps API"} + end +end diff --git a/lib/towerops/sites/site.ex b/lib/towerops/sites/site.ex index cdd63132..bfa358ad 100644 --- a/lib/towerops/sites/site.ex +++ b/lib/towerops/sites/site.ex @@ -23,6 +23,11 @@ defmodule Towerops.Sites.Site do field :location, :string field :display_order, :integer + # Geographic location fields + field :address, :string + field :latitude, :float + field :longitude, :float + # SNMP configuration (overrides organization default) field :snmp_version, :string field :snmp_community, :string @@ -60,6 +65,9 @@ defmodule Towerops.Sites.Site do description: String.t() | nil, location: String.t() | nil, display_order: integer() | nil, + address: String.t() | nil, + latitude: float() | nil, + longitude: float() | nil, snmp_version: String.t() | nil, snmp_community: String.t() | nil, snmp_port: integer() | nil, @@ -96,6 +104,9 @@ defmodule Towerops.Sites.Site do :description, :location, :display_order, + :address, + :latitude, + :longitude, :organization_id, :agent_token_id, :parent_site_id, @@ -120,6 +131,9 @@ defmodule Towerops.Sites.Site do |> validate_length(:name, min: 2, max: 200) |> validate_length(:description, max: 1000) |> validate_length(:location, max: 200) + |> validate_length(:address, max: 500) + |> validate_latitude_range() + |> validate_longitude_range() |> validate_inclusion(:snmp_version, ["1", "2c", "3"], message: "must be 1, 2c, or 3") |> validate_number(:snmp_port, greater_than: 0, less_than: 65_536) |> validate_snmpv3_fields() @@ -236,4 +250,30 @@ defmodule Towerops.Sites.Site do changeset end end + + defp validate_latitude_range(changeset) do + case get_change(changeset, :latitude) do + nil -> + changeset + + latitude when latitude < -90 or latitude > 90 -> + add_error(changeset, :latitude, "must be between -90 and 90") + + _ -> + changeset + end + end + + defp validate_longitude_range(changeset) do + case get_change(changeset, :longitude) do + nil -> + changeset + + longitude when longitude < -180 or longitude > 180 -> + add_error(changeset, :longitude, "must be between -180 and 180") + + _ -> + changeset + end + end end diff --git a/lib/towerops_web/live/map_live/index.ex b/lib/towerops_web/live/map_live/index.ex new file mode 100644 index 00000000..fb91ec50 --- /dev/null +++ b/lib/towerops_web/live/map_live/index.ex @@ -0,0 +1,63 @@ +defmodule ToweropsWeb.MapLive.Index do + @moduledoc """ + Geographic map showing sites with latitude/longitude coordinates using Leaflet.js + """ + use ToweropsWeb, :live_view + + alias Towerops.Sites + + @impl true + def mount(_params, _session, socket) do + organization = socket.assigns.current_scope.organization + + {:ok, + socket + |> assign(:page_title, "Sites Map") + |> assign(:organization, organization) + |> load_sites()} + end + + @impl true + def handle_params(_params, _url, socket) do + {:noreply, socket} + end + + @impl true + def handle_event("site_clicked", %{"site_id" => site_id}, socket) do + # Navigate to site detail page + {:noreply, push_navigate(socket, to: ~p"/sites/#{site_id}")} + end + + @impl true + def handle_event("refresh_map", _params, socket) do + {:noreply, + socket + |> load_sites() + |> put_flash(:info, "Sites map refreshed")} + end + + defp load_sites(socket) do + organization = socket.assigns.organization + sites = Sites.list_organization_sites(organization.id) + + # Filter sites that have geographic coordinates + sites_with_coords = Enum.filter(sites, fn site -> + site.latitude != nil && site.longitude != nil + end) + + # Prepare sites data for the map + sites_data = Enum.map(sites_with_coords, fn site -> + %{ + id: site.id, + name: site.name, + description: site.description, + address: site.address, + latitude: site.latitude, + longitude: site.longitude, + device_count: length(site.device || []) + } + end) + + assign(socket, :sites, sites_data) + end +end diff --git a/lib/towerops_web/live/map_live/index.html.heex b/lib/towerops_web/live/map_live/index.html.heex new file mode 100644 index 00000000..938ca663 --- /dev/null +++ b/lib/towerops_web/live/map_live/index.html.heex @@ -0,0 +1,187 @@ + + <.header> + + Sites Map + + Geographic + + + <:subtitle>Geographic locations of your sites + <:actions> + <.button phx-click="refresh_map"> + <.icon name="hero-arrow-path" class="h-5 w-5" /> Refresh + + <.button navigate={~p"/sites/new"} variant="primary"> + <.icon name="hero-plus" class="h-5 w-5" /> Add Site + + + + +
+
+
+
+
+

+ Geographic Site Locations +

+

+ Click markers to view site details +

+
+
+ + Showing <%= length(@sites) %> sites with coordinates + +
+
+
+ + +
+ +
+
+ <.icon name="hero-arrow-path" class="h-8 w-8 text-gray-400 animate-spin mx-auto" /> +

Loading map...

+
+
+
+
+
+ + +
+ +
+

Statistics

+
+
+
Total Sites
+
+ <%= Sites.count_organization_sites(@organization.id) %> +
+
+
+
Geocoded Sites
+
+ <%= length(@sites) %> +
+
+
+
Coverage
+
+ <%= if Sites.count_organization_sites(@organization.id) > 0 do %> + <%= round(length(@sites) / Sites.count_organization_sites(@organization.id) * 100) %>% + <% else %> + 0% + <% end %> +
+
+
+
+ + +
+
+

+ Sites with Geographic Data +

+
+
+ <%= if length(@sites) > 0 do %> +
+ <%= for site <- @sites do %> +
+
+
+
+

+ <%= site.name %> +

+ <%= if site.device_count > 0 do %> + + <%= site.device_count %> devices + + <% end %> +
+ <%= if site.address do %> +

+ <%= site.address %> +

+ <% end %> + <%= if site.description do %> +

+ <%= site.description %> +

+ <% end %> +

+ <%= Float.round(site.latitude, 4) %>, <%= Float.round(site.longitude, 4) %> +

+
+
+ <.link + navigate={~p"/sites/#{site.id}"} + class="text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300 text-sm font-medium" + > + View → + +
+
+
+ <% end %> +
+ <% else %> +
+ <.icon name="hero-map-pin" class="mx-auto h-12 w-12 text-gray-400" /> +

+ No geocoded sites +

+

+ Add addresses and coordinates to your sites to see them on the map. +

+
+ <.button navigate={~p"/sites"}> + Manage Sites + +
+
+ <% end %> +
+
+
+
+ + \ No newline at end of file diff --git a/lib/towerops_web/live/site_live/form.ex b/lib/towerops_web/live/site_live/form.ex index 1fa1a865..149ada3b 100644 --- a/lib/towerops_web/live/site_live/form.ex +++ b/lib/towerops_web/live/site_live/form.ex @@ -2,6 +2,7 @@ defmodule ToweropsWeb.SiteLive.Form do @moduledoc false use ToweropsWeb, :live_view + alias Phoenix.HTML.Form alias Towerops.Agents alias Towerops.Sites alias Towerops.Sites.Site @@ -79,6 +80,49 @@ defmodule ToweropsWeb.SiteLive.Form do save_site(socket, socket.assigns.live_action, site_params) end + @impl true + def handle_event("geocode", _params, socket) do + address = Form.input_value(socket.assigns.form, :address) + + if address && String.trim(address) != "" do + case Towerops.Geocoding.geocode(address) do + {:ok, %{latitude: lat, longitude: lng, formatted_address: formatted_address}} -> + # Update the form with the geocoded coordinates + updated_params = %{ + "latitude" => lat, + "longitude" => lng, + "address" => formatted_address + } + + changeset = + socket.assigns.site + |> Sites.change_site(updated_params) + |> Map.put(:action, :validate) + + {:noreply, + socket + |> assign(:form, to_form(changeset)) + |> put_flash(:info, "Address geocoded successfully!")} + + {:error, :no_api_key} -> + {:noreply, + put_flash( + socket, + :error, + "Geocoding is not configured. Please contact your administrator to configure the Google Maps API key." + )} + + {:error, reason} when is_binary(reason) -> + {:noreply, put_flash(socket, :error, "Geocoding failed: #{reason}")} + + {:error, _} -> + {:noreply, put_flash(socket, :error, "Geocoding failed. Please check the address and try again.")} + end + else + {:noreply, put_flash(socket, :error, "Please enter an address to geocode")} + end + end + @impl true def handle_event("delete", _params, socket) do case Sites.delete_site(socket.assigns.site) do diff --git a/lib/towerops_web/live/site_live/form.html.heex b/lib/towerops_web/live/site_live/form.html.heex index 21f6ccdc..11342270 100644 --- a/lib/towerops_web/live/site_live/form.html.heex +++ b/lib/towerops_web/live/site_live/form.html.heex @@ -64,6 +64,113 @@ + +
+

+ Geographic Location + (optional) +

+

+ Add geographic coordinates to enable network mapping and spatial analysis. +

+ +
+
+ +
+ +
+
+ +
+ +
+ +
+
+ + + <%= if @form[:latitude].value && @form[:longitude].value do %> +
+ + Map Preview + +
+ Location preview +
+

+ Coordinates: {@form[:latitude].value}, {@form[:longitude].value} +

+
+ <% end %> +
+
+