Add maintenance windows and weather overlay (backend)
Maintenance Windows: - maintenance_windows table with org/site/device scoping - Full CRUD context (Towerops.Maintenance) - device_in_maintenance?/1 checks device → site → org hierarchy - Alert suppression in DeviceMonitorWorker (skips alerts during maintenance) - maintenance_badge/1 helper for UI display Weather Overlay: - weather_observations + weather_alerts tables - OpenWeatherMap free API client (lat/lon based) - WeatherSyncWorker (Oban, 15min interval, respects rate limits) - Observation parsing from OWM response format - Correlation helpers: severe_weather_near?/2, weather_at/2 - Severity classification tuned for WISP ops (wind, rain, ice thresholds) - Per-org latest observations query for dashboard - 30-day automatic cleanup Both features are backend-only — no UI yet.
This commit is contained in:
parent
33f4b5dc16
commit
5b786aac08
13 changed files with 871 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
162
lib/towerops/maintenance.ex
Normal file
162
lib/towerops/maintenance.ex
Normal file
|
|
@ -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
|
||||
65
lib/towerops/maintenance/maintenance_window.ex
Normal file
65
lib/towerops/maintenance/maintenance_window.ex
Normal file
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
235
lib/towerops/weather.ex
Normal file
235
lib/towerops/weather.ex
Normal file
|
|
@ -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
|
||||
33
lib/towerops/weather/alert.ex
Normal file
33
lib/towerops/weather/alert.ex
Normal file
|
|
@ -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
|
||||
77
lib/towerops/weather/client.ex
Normal file
77
lib/towerops/weather/client.ex
Normal file
|
|
@ -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
|
||||
110
lib/towerops/weather/observation.ex
Normal file
110
lib/towerops/weather/observation.ex
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
67
lib/towerops/workers/weather_sync_worker.ex
Normal file
67
lib/towerops/workers/weather_sync_worker.ex
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Add table
Reference in a new issue