diff --git a/config/dev.exs b/config/dev.exs index b4fb15af..49e3ca53 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -52,7 +52,8 @@ config :towerops, Oban, pollers: 50, # Device monitoring jobs - health checks monitors: 50, - maintenance: 5 + maintenance: 5, + weather: 2 ], plugins: [ # Cron jobs for periodic maintenance tasks diff --git a/config/runtime.exs b/config/runtime.exs index e9572c85..fb11dfd4 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -164,7 +164,8 @@ if config_env() == :prod do monitors: 50, # Service checks - HTTP/TCP/DNS checks: 50, - maintenance: 5 + maintenance: 5, + weather: 2 ], plugins: [ # Cron jobs for periodic maintenance tasks @@ -339,4 +340,9 @@ if config_env() == :prod do # Timezone data is updated through dependency updates instead # This prevents crashes when network timeouts occur during automatic updates config :tzdata, :autoupdate, :disabled + + # OpenWeatherMap API key for site weather data + if owm_key = System.get_env("OPENWEATHERMAP_API_KEY") do + config :towerops, :openweathermap_api_key, owm_key + end end diff --git a/lib/towerops/maintenance.ex b/lib/towerops/maintenance.ex new file mode 100644 index 00000000..f411b1a9 --- /dev/null +++ b/lib/towerops/maintenance.ex @@ -0,0 +1,162 @@ +defmodule Towerops.Maintenance do + @moduledoc """ + Context for managing maintenance windows. + + Maintenance windows suppress alerts for devices, sites, or entire organizations + during scheduled maintenance periods. + """ + + import Ecto.Query + + alias Towerops.Devices.Device + alias Towerops.Maintenance.MaintenanceWindow + alias Towerops.Repo + + @preloads [:organization, :site, :device, :created_by] + + def create_window(attrs) do + %MaintenanceWindow{} + |> MaintenanceWindow.changeset(attrs) + |> Repo.insert() + end + + def update_window(%MaintenanceWindow{} = window, attrs) do + window + |> MaintenanceWindow.changeset(attrs) + |> Repo.update() + end + + def delete_window(%MaintenanceWindow{} = window) do + Repo.delete(window) + end + + def get_window!(id) do + MaintenanceWindow + |> Repo.get!(id) + |> Repo.preload(@preloads) + end + + def list_windows(organization_id, opts \\ []) do + now = DateTime.utc_now() + + MaintenanceWindow + |> where([w], w.organization_id == ^organization_id) + |> apply_filter(opts[:filter], now) + |> order_by([w], desc: w.starts_at) + |> Repo.all() + |> Repo.preload(@preloads) + end + + defp apply_filter(query, :active, now) do + where(query, [w], w.starts_at <= ^now and w.ends_at >= ^now) + end + + defp apply_filter(query, :upcoming, now) do + where(query, [w], w.starts_at > ^now) + end + + defp apply_filter(query, :past, now) do + where(query, [w], w.ends_at < ^now) + end + + defp apply_filter(query, _, _now), do: query + + @doc """ + Checks if a device is currently in a maintenance window. + Checks direct device windows, site windows, and org-wide windows. + """ + def device_in_maintenance?(device_id) do + case active_windows_for_device(device_id) do + [] -> false + _ -> true + end + end + + @doc """ + Checks if a site is currently in a maintenance window. + """ + def site_in_maintenance?(site_id) do + now = DateTime.utc_now() + + MaintenanceWindow + |> where([w], w.site_id == ^site_id and is_nil(w.device_id)) + |> where([w], w.starts_at <= ^now and w.ends_at >= ^now) + |> where([w], w.suppress_alerts == true) + |> Repo.exists?() + end + + @doc """ + Returns all active maintenance windows affecting a device. + Checks: direct device match, site match, and org-wide windows. + """ + def active_windows_for_device(device_id) do + now = DateTime.utc_now() + + device = Repo.get!(Device, device_id) + + active_base = + MaintenanceWindow + |> where([w], w.organization_id == ^device.organization_id) + |> where([w], w.starts_at <= ^now and w.ends_at >= ^now) + |> where([w], w.suppress_alerts == true) + + # Direct device windows + direct = where(active_base, [w], w.device_id == ^device_id) + + # Site windows (if device has a site) + site_query = + if device.site_id do + where(active_base, [w], w.site_id == ^device.site_id and is_nil(w.device_id)) + else + where(active_base, [w], false) + end + + # Org-wide windows + org_wide = where(active_base, [w], is_nil(w.site_id) and is_nil(w.device_id)) + + direct_results = Repo.all(direct) + site_results = Repo.all(site_query) + org_results = Repo.all(org_wide) + + (direct_results ++ site_results ++ org_results) + |> Enum.uniq_by(& &1.id) + end + + @doc """ + Returns all active maintenance windows for an organization. + """ + def active_windows_for_org(organization_id) do + now = DateTime.utc_now() + + MaintenanceWindow + |> where([w], w.organization_id == ^organization_id) + |> where([w], w.starts_at <= ^now and w.ends_at >= ^now) + |> Repo.all() + |> Repo.preload(@preloads) + end + + @doc """ + Returns the name of the active maintenance window for a device or site, or nil. + Accepts a %Device{} or %Site{} struct. + """ + def maintenance_badge(%Device{} = device) do + case active_windows_for_device(device.id) do + [window | _] -> window.name + [] -> nil + end + end + + def maintenance_badge(%Towerops.Sites.Site{} = site) do + now = DateTime.utc_now() + + MaintenanceWindow + |> where([w], w.site_id == ^site.id and is_nil(w.device_id)) + |> where([w], w.starts_at <= ^now and w.ends_at >= ^now) + |> where([w], w.suppress_alerts == true) + |> limit(1) + |> select([w], w.name) + |> Repo.one() + end + + def maintenance_badge(_), do: nil +end diff --git a/lib/towerops/maintenance/maintenance_window.ex b/lib/towerops/maintenance/maintenance_window.ex new file mode 100644 index 00000000..7ab4a78f --- /dev/null +++ b/lib/towerops/maintenance/maintenance_window.ex @@ -0,0 +1,65 @@ +defmodule Towerops.Maintenance.MaintenanceWindow do + @moduledoc """ + Schema for maintenance windows that suppress alerts during scheduled maintenance. + + A window can target: + - A specific device (device_id set) + - All devices at a site (site_id set, device_id nil) + - All devices in an org (both nil) + """ + use Ecto.Schema + + import Ecto.Changeset + + alias Towerops.Accounts.User + alias Towerops.Devices.Device + alias Towerops.Organizations.Organization + alias Towerops.Sites.Site + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "maintenance_windows" do + field :name, :string + field :reason, :string + field :starts_at, :utc_datetime + field :ends_at, :utc_datetime + field :recurring, :boolean, default: false + field :recurrence_rule, :string + field :suppress_alerts, :boolean, default: true + + belongs_to :organization, Organization + belongs_to :site, Site + belongs_to :device, Device + belongs_to :created_by, User + + timestamps() + end + + @required_fields ~w(name starts_at ends_at organization_id created_by_id)a + @optional_fields ~w(reason site_id device_id recurring recurrence_rule suppress_alerts)a + + def changeset(window, attrs) do + window + |> cast(attrs, @required_fields ++ @optional_fields) + |> validate_required(@required_fields) + |> validate_length(:name, max: 255) + |> validate_length(:reason, max: 1000) + |> validate_ends_after_starts() + |> foreign_key_constraint(:organization_id) + |> foreign_key_constraint(:site_id) + |> foreign_key_constraint(:device_id) + |> foreign_key_constraint(:created_by_id) + |> check_constraint(:ends_at, name: :ends_at_after_starts_at, message: "must be after starts_at") + end + + defp validate_ends_after_starts(changeset) do + starts_at = get_field(changeset, :starts_at) + ends_at = get_field(changeset, :ends_at) + + if starts_at && ends_at && DateTime.compare(ends_at, starts_at) != :gt do + add_error(changeset, :ends_at, "must be after starts_at") + else + changeset + end + end +end diff --git a/lib/towerops/sites.ex b/lib/towerops/sites.ex index 15916c30..eb1c140c 100644 --- a/lib/towerops/sites.ex +++ b/lib/towerops/sites.ex @@ -11,6 +11,18 @@ defmodule Towerops.Sites do alias Towerops.Repo alias Towerops.Sites.Site + @doc """ + Lists all sites across all organizations that have geographic coordinates set. + Used by the weather sync worker to fetch weather data. + """ + def list_sites_with_coordinates do + from(s in Site, + where: not is_nil(s.latitude) and not is_nil(s.longitude), + select: s + ) + |> Repo.all() + end + @doc """ Returns the list of sites for an organization. Ordered by custom display_order (if set), then alphabetically by name. diff --git a/lib/towerops/weather.ex b/lib/towerops/weather.ex new file mode 100644 index 00000000..2e2e1fa2 --- /dev/null +++ b/lib/towerops/weather.ex @@ -0,0 +1,235 @@ +defmodule Towerops.Weather do + @moduledoc """ + Weather context for site-correlated weather data. + + Provides current conditions, historical observations, and weather alerts + tied to tower site locations. Used for outage correlation and operational + awareness. + """ + + import Ecto.Query + + alias Towerops.Repo + alias Towerops.Weather.Alert, as: WeatherAlert + alias Towerops.Weather.Client + alias Towerops.Weather.Observation + + require Logger + + # ── Observations ───────────────────────────────────────────────────── + + @doc """ + Records a weather observation for a site. + """ + def create_observation(attrs) do + %Observation{} + |> Observation.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Gets the latest weather observation for a site. + """ + def latest_observation(site_id) do + Observation + |> where(site_id: ^site_id) + |> order_by(desc: :observed_at) + |> limit(1) + |> Repo.one() + end + + @doc """ + Gets the latest weather observation for each site in an organization. + Returns a map of site_id => observation. + """ + def latest_observations_for_org(organization_id) do + # Subquery to get latest observation per site + latest_ids = + from(o in Observation, + join: s in assoc(o, :site), + where: s.organization_id == ^organization_id, + group_by: o.site_id, + select: %{site_id: o.site_id, max_id: max(o.id)} + ) + + from(o in Observation, + join: l in subquery(latest_ids), + on: o.id == l.max_id + ) + |> Repo.all() + |> Map.new(fn obs -> {obs.site_id, obs} end) + end + + @doc """ + Gets weather observations for a site within a time range. + Useful for correlating weather with outage events. + """ + def observations_for_site(site_id, opts \\ []) do + after_time = opts[:after] || DateTime.add(DateTime.utc_now(), -24, :hour) + limit = opts[:limit] || 100 + + Observation + |> where(site_id: ^site_id) + |> where([o], o.observed_at >= ^after_time) + |> order_by(desc: :observed_at) + |> limit(^limit) + |> Repo.all() + end + + @doc """ + Cleans up old weather observations. Keeps the last `keep_days` days. + """ + def cleanup_old_observations(keep_days \\ 30) do + cutoff = DateTime.add(DateTime.utc_now(), -keep_days, :day) + + {count, _} = + from(o in Observation, where: o.observed_at < ^cutoff) + |> Repo.delete_all() + + Logger.info("Cleaned up #{count} old weather observations") + {:ok, count} + end + + # ── Weather Alerts ─────────────────────────────────────────────────── + + @doc """ + Creates or updates a weather alert for a site. + """ + def upsert_alert(attrs) do + %WeatherAlert{} + |> WeatherAlert.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Gets active weather alerts for a site (not yet expired). + """ + def active_alerts_for_site(site_id) do + now = DateTime.utc_now() + + WeatherAlert + |> where(site_id: ^site_id) + |> where([a], is_nil(a.ends_at) or a.ends_at > ^now) + |> order_by(desc: :inserted_at) + |> Repo.all() + end + + @doc """ + Gets active weather alerts for all sites in an organization. + """ + def active_alerts_for_org(organization_id) do + now = DateTime.utc_now() + + from(a in WeatherAlert, + join: s in assoc(a, :site), + where: s.organization_id == ^organization_id, + where: is_nil(a.ends_at) or a.ends_at > ^now, + order_by: [desc: a.inserted_at], + preload: [:site] + ) + |> Repo.all() + end + + # ── Fetching ───────────────────────────────────────────────────────── + + @doc """ + Fetches and stores current weather for a site. + Requires the site to have latitude and longitude set. + """ + def fetch_and_store(site) do + if site.latitude && site.longitude do + case Client.get_current_weather(site.latitude, site.longitude) do + {:ok, data} -> + attrs = Observation.from_owm(site.id, data) + create_observation(attrs) + + {:error, reason} -> + Logger.warning("Failed to fetch weather for site #{site.name}: #{inspect(reason)}") + {:error, reason} + end + else + {:error, :no_coordinates} + end + end + + # ── Correlation Helpers ────────────────────────────────────────────── + + @doc """ + Checks if there was severe weather at a site around a given time. + Returns observations with high wind, heavy rain, or storms. + + Useful for determining if an outage was likely weather-related. + """ + def severe_weather_near?(site_id, time, window_minutes \\ 60) do + start_time = DateTime.add(time, -window_minutes, :minute) + end_time = DateTime.add(time, window_minutes, :minute) + + observations = + Observation + |> where(site_id: ^site_id) + |> where([o], o.observed_at >= ^start_time and o.observed_at <= ^end_time) + |> Repo.all() + + Enum.any?(observations, &severe?/1) + end + + @doc """ + Returns a weather summary for a site at a point in time. + Finds the closest observation within 30 minutes. + """ + def weather_at(site_id, time) do + start_time = DateTime.add(time, -30, :minute) + end_time = DateTime.add(time, 30, :minute) + + Observation + |> where(site_id: ^site_id) + |> where([o], o.observed_at >= ^start_time and o.observed_at <= ^end_time) + |> order_by([o], fragment("ABS(EXTRACT(EPOCH FROM ? - ?))", o.observed_at, ^time)) + |> limit(1) + |> Repo.one() + end + + @doc """ + Determines if an observation represents severe weather conditions. + + Thresholds relevant to WISP operations: + - Wind > 15 m/s (33 mph) — affects radio alignment + - Wind gusts > 20 m/s (45 mph) — risk of hardware damage + - Rain > 10mm/h — signal attenuation on wireless links + - Thunderstorm conditions + - Snow > 5mm/h — ice loading on equipment + """ + def severe?(%Observation{} = obs) do + cond do + (obs.wind_speed_ms || 0) > 15 -> true + (obs.wind_gust_ms || 0) > 20 -> true + (obs.rain_1h_mm || 0) > 10 -> true + (obs.snow_1h_mm || 0) > 5 -> true + obs.condition in ["Thunderstorm"] -> true + true -> false + end + end + + def severe?(_), do: false + + @doc """ + Returns a severity level for weather conditions. + :clear, :mild, :moderate, :severe + """ + def severity(%Observation{} = obs) do + cond do + (obs.wind_gust_ms || 0) > 25 -> :severe + obs.condition in ["Thunderstorm"] -> :severe + (obs.rain_1h_mm || 0) > 20 -> :severe + (obs.wind_speed_ms || 0) > 15 -> :moderate + (obs.wind_gust_ms || 0) > 20 -> :moderate + (obs.rain_1h_mm || 0) > 10 -> :moderate + (obs.snow_1h_mm || 0) > 5 -> :moderate + obs.condition in ["Rain", "Drizzle", "Snow"] -> :mild + (obs.clouds_pct || 0) > 80 -> :mild + true -> :clear + end + end + + def severity(_), do: :clear +end diff --git a/lib/towerops/weather/alert.ex b/lib/towerops/weather/alert.ex new file mode 100644 index 00000000..4959cbc8 --- /dev/null +++ b/lib/towerops/weather/alert.ex @@ -0,0 +1,33 @@ +defmodule Towerops.Weather.Alert do + @moduledoc """ + Severe weather alert associated with a site. + """ + use Ecto.Schema + + import Ecto.Changeset + + alias Towerops.Sites.Site + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "weather_alerts" do + field :event, :string + field :sender, :string + field :description, :string + field :starts_at, :utc_datetime + field :ends_at, :utc_datetime + field :tags, {:array, :string}, default: [] + field :severity, :string + + belongs_to :site, Site + + timestamps(type: :utc_datetime) + end + + def changeset(alert, attrs) do + alert + |> cast(attrs, [:site_id, :event, :sender, :description, :starts_at, :ends_at, :tags, :severity]) + |> validate_required([:site_id, :event]) + |> foreign_key_constraint(:site_id) + end +end diff --git a/lib/towerops/weather/client.ex b/lib/towerops/weather/client.ex new file mode 100644 index 00000000..cfc02ede --- /dev/null +++ b/lib/towerops/weather/client.ex @@ -0,0 +1,77 @@ +defmodule Towerops.Weather.Client do + @moduledoc """ + OpenWeatherMap API client. + + Uses the free Current Weather Data API (2.5) which allows 60 calls/min. + Fetches weather by latitude/longitude — perfect for tower site locations. + """ + + require Logger + + @base_url "https://api.openweathermap.org/data/2.5" + + @doc """ + Fetches current weather for the given coordinates. + + Returns `{:ok, map}` with the raw OWM response or `{:error, reason}`. + """ + def get_current_weather(lat, lon, opts \\ []) do + api_key = opts[:api_key] || api_key() + + if is_nil(api_key) or api_key == "" do + {:error, :no_api_key} + else + url = "#{@base_url}/weather" + + req_opts = + [ + url: url, + params: [ + lat: lat, + lon: lon, + appid: api_key + ] + ] + |> maybe_add_test_plug(opts) + + case Req.get(req_opts) do + {:ok, %Req.Response{status: 200, body: body}} -> + {:ok, body} + + {:ok, %Req.Response{status: 401}} -> + {:error, :invalid_api_key} + + {:ok, %Req.Response{status: 429}} -> + {:error, :rate_limited} + + {:ok, %Req.Response{status: status, body: body}} -> + Logger.warning("OWM API error", status: status, body: inspect(body)) + {:error, {:api_error, status}} + + {:error, reason} -> + Logger.warning("OWM API request failed", error: inspect(reason)) + {:error, reason} + end + end + end + + @doc """ + Tests if the API key is valid by making a minimal request. + """ + def test_connection(api_key) do + # Use a known location (London) for the test + get_current_weather(51.5074, -0.1278, api_key: api_key) + end + + defp api_key do + Application.get_env(:towerops, :openweathermap_api_key) + end + + defp maybe_add_test_plug(req_opts, opts) do + if opts[:plug] do + Keyword.put(req_opts, :plug, opts[:plug]) + else + req_opts + end + end +end diff --git a/lib/towerops/weather/observation.ex b/lib/towerops/weather/observation.ex new file mode 100644 index 00000000..e95a53f9 --- /dev/null +++ b/lib/towerops/weather/observation.ex @@ -0,0 +1,110 @@ +defmodule Towerops.Weather.Observation do + @moduledoc """ + Weather observation for a site at a point in time. + + Stores current weather conditions fetched from OpenWeatherMap, + tied to a site's geographic coordinates. + """ + use Ecto.Schema + + import Ecto.Changeset + + alias Towerops.Sites.Site + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "weather_observations" do + field :condition, :string + field :condition_detail, :string + field :icon, :string + field :temperature_c, :float + field :feels_like_c, :float + field :humidity, :integer + field :pressure_hpa, :integer + field :visibility_m, :integer + + field :wind_speed_ms, :float + field :wind_gust_ms, :float + field :wind_deg, :integer + + field :rain_1h_mm, :float + field :snow_1h_mm, :float + field :clouds_pct, :integer + + field :raw, :map + + field :observed_at, :utc_datetime + + belongs_to :site, Site + + timestamps(type: :utc_datetime) + end + + @cast_fields [ + :site_id, + :condition, + :condition_detail, + :icon, + :temperature_c, + :feels_like_c, + :humidity, + :pressure_hpa, + :visibility_m, + :wind_speed_ms, + :wind_gust_ms, + :wind_deg, + :rain_1h_mm, + :snow_1h_mm, + :clouds_pct, + :raw, + :observed_at + ] + + def changeset(observation, attrs) do + observation + |> cast(attrs, @cast_fields) + |> validate_required([:site_id, :observed_at]) + |> foreign_key_constraint(:site_id) + end + + @doc """ + Parses an OpenWeatherMap current weather API response into observation attrs. + """ + def from_owm(site_id, %{} = data) do + weather = List.first(data["weather"] || []) || %{} + main = data["main"] || %{} + wind = data["wind"] || %{} + rain = data["rain"] || %{} + snow = data["snow"] || %{} + clouds = data["clouds"] || %{} + + observed_at = + case data["dt"] do + ts when is_integer(ts) -> DateTime.from_unix!(ts) + _ -> DateTime.utc_now(:second) + end + + %{ + site_id: site_id, + condition: weather["main"], + condition_detail: weather["description"], + icon: weather["icon"], + temperature_c: kelvin_to_celsius(main["temp"]), + feels_like_c: kelvin_to_celsius(main["feels_like"]), + humidity: main["humidity"], + pressure_hpa: main["pressure"], + visibility_m: data["visibility"], + wind_speed_ms: wind["speed"], + wind_gust_ms: wind["gust"], + wind_deg: wind["deg"], + rain_1h_mm: rain["1h"], + snow_1h_mm: snow["1h"], + clouds_pct: clouds["all"], + raw: data, + observed_at: observed_at + } + end + + defp kelvin_to_celsius(nil), do: nil + defp kelvin_to_celsius(k) when is_number(k), do: Float.round(k - 273.15, 1) +end diff --git a/lib/towerops/workers/device_monitor_worker.ex b/lib/towerops/workers/device_monitor_worker.ex index b7e75aa1..7b0619ed 100644 --- a/lib/towerops/workers/device_monitor_worker.ex +++ b/lib/towerops/workers/device_monitor_worker.ex @@ -17,6 +17,7 @@ defmodule Towerops.Workers.DeviceMonitorWorker do alias Towerops.Agents alias Towerops.Alerts alias Towerops.Devices + alias Towerops.Maintenance alias Towerops.Monitoring alias Towerops.PagerDuty.Notifier alias Towerops.Snmp.Client @@ -196,11 +197,21 @@ defmodule Towerops.Workers.DeviceMonitorWorker do if Alerts.has_active_alert?(device.id, :device_down) do :ok else - create_device_down_alert(device, now) + if Maintenance.device_in_maintenance?(device.id) do + Logger.info("Device #{device.id} (#{device.name}) is down but in maintenance window — suppressing alert") + :ok + else + create_device_down_alert(device, now) + end end end defp create_device_down_alert(device, now) do + if Maintenance.device_in_maintenance?(device.id) do + require Logger + Logger.info("Device #{device.id} (#{device.name}) alert suppressed by maintenance window (safety net)") + :ok + else alert_message = get_down_alert_message(device) {:ok, alert} = @@ -219,6 +230,7 @@ defmodule Towerops.Workers.DeviceMonitorWorker do "alerts:org:#{device.organization_id}:new", {:new_alert, device.id, :device_down} ) + end end defp get_down_alert_message(device) do diff --git a/lib/towerops/workers/weather_sync_worker.ex b/lib/towerops/workers/weather_sync_worker.ex new file mode 100644 index 00000000..783f7abe --- /dev/null +++ b/lib/towerops/workers/weather_sync_worker.ex @@ -0,0 +1,67 @@ +defmodule Towerops.Workers.WeatherSyncWorker do + @moduledoc """ + Oban worker that periodically fetches weather data for all sites with coordinates. + + Runs every 15 minutes. Batches API calls with a small delay between each + to stay well within OpenWeatherMap's free tier (60 calls/min). + + Also cleans up observations older than 30 days on each run. + """ + use Oban.Worker, + queue: :weather, + unique: [period: 600, states: [:available, :scheduled, :executing]] + + alias Towerops.Sites + alias Towerops.Weather + + require Logger + + @impl Oban.Worker + def perform(%Oban.Job{}) do + api_key = Application.get_env(:towerops, :openweathermap_api_key) + + if is_nil(api_key) or api_key == "" do + Logger.debug("Weather sync skipped: no OpenWeatherMap API key configured") + :ok + else + sync_all_sites() + Weather.cleanup_old_observations(30) + schedule_next() + :ok + end + end + + defp sync_all_sites do + sites = Sites.list_sites_with_coordinates() + total = length(sites) + + if total > 0 do + Logger.info("Weather sync: fetching weather for #{total} sites") + + results = + sites + |> Enum.with_index(1) + |> Enum.map(fn {site, idx} -> + # Throttle: ~1 req/sec to stay under 60/min limit + if idx > 1, do: Process.sleep(1_100) + + case Weather.fetch_and_store(site) do + {:ok, _obs} -> + :ok + + {:error, reason} -> + Logger.warning("Weather fetch failed for site #{site.name}: #{inspect(reason)}") + :error + end + end) + + ok_count = Enum.count(results, &(&1 == :ok)) + Logger.info("Weather sync complete: #{ok_count}/#{total} sites updated") + end + end + + defp schedule_next do + # Schedule next run in 15 minutes + %{} |> new(schedule_in: 900) |> Oban.insert() + end +end diff --git a/priv/repo/migrations/20260216155500_create_maintenance_windows.exs b/priv/repo/migrations/20260216155500_create_maintenance_windows.exs new file mode 100644 index 00000000..d6939d28 --- /dev/null +++ b/priv/repo/migrations/20260216155500_create_maintenance_windows.exs @@ -0,0 +1,30 @@ +defmodule Towerops.Repo.Migrations.CreateMaintenanceWindows do + use Ecto.Migration + + def change do + create table(:maintenance_windows, 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 :site_id, references(:sites, type: :binary_id, on_delete: :delete_all) + add :device_id, references(:devices, type: :binary_id, on_delete: :delete_all) + add :name, :string, null: false + add :reason, :string + add :starts_at, :utc_datetime, null: false + add :ends_at, :utc_datetime, null: false + add :created_by_id, references(:users, type: :binary_id, on_delete: :nilify_all), null: false + add :recurring, :boolean, default: false + add :recurrence_rule, :string + add :suppress_alerts, :boolean, default: true + + timestamps() + end + + create index(:maintenance_windows, [:organization_id]) + create index(:maintenance_windows, [:site_id]) + create index(:maintenance_windows, [:device_id]) + create index(:maintenance_windows, [:starts_at]) + create index(:maintenance_windows, [:ends_at]) + + create constraint(:maintenance_windows, :ends_at_after_starts_at, check: "ends_at > starts_at") + end +end diff --git a/priv/repo/migrations/20260216155538_create_weather_observations.exs b/priv/repo/migrations/20260216155538_create_weather_observations.exs new file mode 100644 index 00000000..cd7bb527 --- /dev/null +++ b/priv/repo/migrations/20260216155538_create_weather_observations.exs @@ -0,0 +1,58 @@ +defmodule Towerops.Repo.Migrations.CreateWeatherObservations do + use Ecto.Migration + + def change do + create table(:weather_observations, 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 + + # Current conditions + add :condition, :string + add :condition_detail, :string + add :icon, :string + add :temperature_c, :float + add :feels_like_c, :float + add :humidity, :integer + add :pressure_hpa, :integer + add :visibility_m, :integer + + # Wind + add :wind_speed_ms, :float + add :wind_gust_ms, :float + add :wind_deg, :integer + + # Precipitation + add :rain_1h_mm, :float + add :snow_1h_mm, :float + add :clouds_pct, :integer + + # Raw API response for future use + add :raw, :map + + add :observed_at, :utc_datetime, null: false + timestamps(type: :utc_datetime) + end + + create index(:weather_observations, [:site_id]) + create index(:weather_observations, [:site_id, :observed_at]) + create index(:weather_observations, [:observed_at]) + + # Weather alerts (severe weather warnings) + create table(:weather_alerts, 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 :event, :string, null: false + add :sender, :string + add :description, :text + add :starts_at, :utc_datetime + add :ends_at, :utc_datetime + add :tags, {:array, :string}, default: [] + add :severity, :string + + timestamps(type: :utc_datetime) + end + + create index(:weather_alerts, [:site_id]) + create index(:weather_alerts, [:site_id, :ends_at]) + end +end