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:
Graham McIntire 2026-02-16 10:01:18 -06:00
parent 33f4b5dc16
commit 5b786aac08
13 changed files with 871 additions and 3 deletions

View file

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

View file

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

View 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

View file

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

View 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

View 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

View 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

View file

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

View 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

View file

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

View file

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