feat: add geographic location to sites with geocoding, address fields, and Leaflet.js network map

This commit is contained in:
Graham McIntire 2026-02-14 13:06:59 -06:00
parent 2ea849d622
commit 5df5c97c45
10 changed files with 688 additions and 2 deletions

View file

@ -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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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 = `<div class="p-2">
<h3 class="font-semibold text-lg mb-1">${site.name}</h3>`
if (site.description) {
popupContent += `<p class="text-sm text-gray-600 mb-1">${site.description}</p>`
}
if (site.address) {
popupContent += `<p class="text-xs text-gray-500 mb-1">${site.address}</p>`
}
popupContent += `<p class="text-xs text-gray-500 mb-2">
${site.latitude.toFixed(4)}, ${site.longitude.toFixed(4)}
</p>`
if (site.device_count > 0) {
popupContent += `<p class="text-xs text-blue-600 mb-2">
${site.device_count} device${site.device_count !== 1 ? 's' : ''}
</p>`
}
popupContent += `<button
onclick="window.liveSocket.execJS(document.querySelector('#leaflet-map'), 'this.pushEvent(\\'site_clicked\\', {site_id: \\'${site.id}\\'})')"
class="text-xs bg-blue-600 text-white px-2 py-1 rounded hover:bg-blue-700"
>
View Site
</button></div>`
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<HTMLMetaElement>("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

View file

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

105
lib/towerops/geocoding.ex Normal file
View file

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

View file

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

View file

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

View file

@ -0,0 +1,187 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="sites-map"
>
<.header>
<span class="flex items-center gap-2">
Sites Map
<span class="inline-flex items-center rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10 dark:bg-blue-400/10 dark:text-blue-400 dark:ring-blue-400/30">
Geographic
</span>
</span>
<:subtitle>Geographic locations of your sites</:subtitle>
<:actions>
<.button phx-click="refresh_map">
<.icon name="hero-arrow-path" class="h-5 w-5" /> Refresh
</.button>
<.button navigate={~p"/sites/new"} variant="primary">
<.icon name="hero-plus" class="h-5 w-5" /> Add Site
</.button>
</:actions>
</.header>
<div class="mb-6">
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-white/10 overflow-hidden">
<div class="p-4 border-b border-gray-200 dark:border-white/10">
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
Geographic Site Locations
</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Click markers to view site details
</p>
</div>
<div class="flex items-center space-x-2">
<span class="text-xs text-gray-500 dark:text-gray-400">
Showing <%= length(@sites) %> sites with coordinates
</span>
</div>
</div>
</div>
<!-- Map Container -->
<div
id="leaflet-map"
phx-hook="SitesMap"
class="w-full"
style="height: 600px;"
data-sites={Jason.encode!(@sites)}
>
<!-- Loading State -->
<div class="flex items-center justify-center h-full">
<div class="text-center">
<.icon name="hero-arrow-path" class="h-8 w-8 text-gray-400 animate-spin mx-auto" />
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Loading map...</p>
</div>
</div>
</div>
</div>
</div>
<!-- Stats and Site List -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Site Statistics -->
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-white/10 p-6">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Statistics</h3>
<div class="space-y-4">
<div>
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">Total Sites</div>
<div class="text-2xl font-semibold text-gray-900 dark:text-white">
<%= Sites.count_organization_sites(@organization.id) %>
</div>
</div>
<div>
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">Geocoded Sites</div>
<div class="text-2xl font-semibold text-gray-900 dark:text-white">
<%= length(@sites) %>
</div>
</div>
<div>
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">Coverage</div>
<div class="text-lg font-semibold text-gray-900 dark:text-white">
<%= if Sites.count_organization_sites(@organization.id) > 0 do %>
<%= round(length(@sites) / Sites.count_organization_sites(@organization.id) * 100) %>%
<% else %>
0%
<% end %>
</div>
</div>
</div>
</div>
<!-- Sites with Coordinates -->
<div class="lg:col-span-2 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-white/10">
<div class="px-6 py-4 border-b border-gray-200 dark:border-white/10">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
Sites with Geographic Data
</h3>
</div>
<div class="max-h-96 overflow-y-auto">
<%= if length(@sites) > 0 do %>
<div class="divide-y divide-gray-200 dark:divide-white/10">
<%= for site <- @sites do %>
<div class="px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-700/50">
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<div class="flex items-center">
<h4 class="text-sm font-medium text-gray-900 dark:text-white truncate">
<%= site.name %>
</h4>
<%= if site.device_count > 0 do %>
<span class="ml-2 inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900 dark:text-blue-300">
<%= site.device_count %> devices
</span>
<% end %>
</div>
<%= if site.address do %>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
<%= site.address %>
</p>
<% end %>
<%= if site.description do %>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
<%= site.description %>
</p>
<% end %>
<p class="mt-1 text-xs text-gray-400">
<%= Float.round(site.latitude, 4) %>, <%= Float.round(site.longitude, 4) %>
</p>
</div>
<div class="ml-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 →
</.link>
</div>
</div>
</div>
<% end %>
</div>
<% else %>
<div class="px-6 py-8 text-center">
<.icon name="hero-map-pin" class="mx-auto h-12 w-12 text-gray-400" />
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">
No geocoded sites
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Add addresses and coordinates to your sites to see them on the map.
</p>
<div class="mt-4">
<.button navigate={~p"/sites"}>
Manage Sites
</.button>
</div>
</div>
<% end %>
</div>
</div>
</div>
</Layouts.authenticated>
<script>
// Add Leaflet CSS and JS
document.addEventListener("DOMContentLoaded", function() {
if (!document.getElementById("leaflet-css")) {
const css = document.createElement("link");
css.id = "leaflet-css";
css.rel = "stylesheet";
css.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
css.integrity = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=";
css.crossOrigin = "";
document.head.appendChild(css);
}
if (!document.getElementById("leaflet-js")) {
const js = document.createElement("script");
js.id = "leaflet-js";
js.src = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js";
js.integrity = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=";
js.crossOrigin = "";
document.head.appendChild(js);
}
});
</script>

View file

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

View file

@ -64,6 +64,113 @@
</label>
</div>
<!-- Geographic Location Section -->
<div class="mt-6 border-t pt-6">
<h3 class="text-base font-medium mb-4">
Geographic Location
<span class="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Add geographic coordinates to enable network mapping and spatial analysis.
</p>
<div class="space-y-4">
<div>
<label>
<span class="block text-sm font-medium text-gray-900 dark:text-white mb-2">
Address
</span>
<div class="flex gap-2">
<input
type="text"
id={@form[:address].id}
name={@form[:address].name}
value={@form[:address].value}
placeholder="123 Main St, City, State, Country"
class="flex-1 rounded-lg border-0 py-2 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6 dark:bg-gray-800/50 dark:text-white dark:ring-white/10 dark:focus:ring-blue-500"
/>
<button
type="button"
phx-click="geocode"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-800"
>
Geocode
</button>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Enter a full address and click Geocode to automatically fill latitude/longitude
</p>
</label>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label>
<span class="block text-sm font-medium text-gray-900 dark:text-white mb-2">
Latitude
</span>
<input
type="number"
step="any"
id={@form[:latitude].id}
name={@form[:latitude].name}
value={@form[:latitude].value}
placeholder="40.7128"
min="-90"
max="90"
class="block w-full rounded-lg border-0 py-2 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6 dark:bg-gray-800/50 dark:text-white dark:ring-white/10 dark:focus:ring-blue-500"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
-90 to 90
</p>
</label>
</div>
<div>
<label>
<span class="block text-sm font-medium text-gray-900 dark:text-white mb-2">
Longitude
</span>
<input
type="number"
step="any"
id={@form[:longitude].id}
name={@form[:longitude].name}
value={@form[:longitude].value}
placeholder="-74.0060"
min="-180"
max="180"
class="block w-full rounded-lg border-0 py-2 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6 dark:bg-gray-800/50 dark:text-white dark:ring-white/10 dark:focus:ring-blue-500"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
-180 to 180
</p>
</label>
</div>
</div>
<!-- Map Preview -->
<%= if @form[:latitude].value && @form[:longitude].value do %>
<div class="mt-4">
<span class="block text-sm font-medium text-gray-900 dark:text-white mb-2">
Map Preview
</span>
<div class="border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden">
<img
src={"https://api.mapbox.com/styles/v1/mapbox/streets-v11/static/#{@form[:longitude].value},#{@form[:latitude].value},14,0/400x200?access_token=pk.1234"}
alt="Location preview"
class="w-full h-48 object-cover"
onerror="this.src='data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAwIiBoZWlnaHQ9IjIwMCIgdmlld0JveD0iMCAwIDQwMCAyMDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSI0MDAiIGhlaWdodD0iMjAwIiBmaWxsPSIjRjNGNEY2Ii8+Cjx0ZXh0IHg9IjIwMCIgeT0iMTAwIiBmb250LWZhbWlseT0ic2Fucy1zZXJpZiIgZm9udC1zaXplPSIxNiIgZmlsbD0iIzZCNzI4MCIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZG9taW5hbnQtYmFzZWxpbmU9ImNlbnRyYWwiPk1hcCBwcmV2aWV3IG5vdCBhdmFpbGFibGU8L3RleHQ+Cjwvc3ZnPgo='; this.alt='Map preview not available';"
/>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Coordinates: {@form[:latitude].value}, {@form[:longitude].value}
</p>
</div>
<% end %>
</div>
</div>
<div class="mb-4">
<label>
<span class="block text-sm font-medium text-gray-900 dark:text-white mb-2">

View file

@ -412,8 +412,9 @@ defmodule ToweropsWeb.Router do
# Changelog
live "/changelog", ChangelogLive, :index
# Network map route
# Network map routes
live "/network-map", NetworkMapLive, :index
live "/sites-map", MapLive.Index, :index
end
end
end

View file

@ -0,0 +1,13 @@
defmodule Towerops.Repo.Migrations.AddGeographicLocationToSites do
use Ecto.Migration
def change do
alter table(:sites) do
add :address, :string
add :latitude, :float
add :longitude, :float
end
create index(:sites, [:organization_id, :latitude, :longitude])
end
end