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

+
+
+ Coordinates: {@form[:latitude].value}, {@form[:longitude].value}
+
+
+ <% end %>
+
+
+