Major codebase cleanup: DRY schemas, remove dead config, consolidate modules

- Created Towerops.Schema base macro (95+ schemas updated to use it)
- Created Towerops.Snmp.Reading macro (7 reading schemas consolidated)
- Created Towerops.Gaiia.BaseSchema macro (4 Gaiia schemas consolidated)
- Created Towerops.SyncLog shared module (UISP + Preseem merge)
- Created Towerops.LogFilters (3 log filter modules merged into 1)
- Created Towerops.OnCall.ChangesetHelpers (9 on-call schemas simplified)
- Created API v1 ResourceController shared helpers (7 controllers)
- Extracted ConnectionHelpers.format_connection_result (2 LiveViews)
- Removed 5 dead config keys (scopes, mib_dirs, stripe_meter_id, etc.)
- Fixed 2 pre-existing broken tests
- All 13,219 tests pass, Credo clean (0 issues)
This commit is contained in:
Graham McIntire 2026-06-16 15:29:22 -05:00
parent d1403c8069
commit a4e08b2aca
134 changed files with 1082 additions and 1516 deletions

View file

@ -46,9 +46,9 @@ config :logger, :default_handler,
filters: [
# Suppress HTTP/0.9 and other invalid protocol errors from Bandit
# These are typically from port scanners and automated bots
bandit_invalid_http: {&Towerops.LogFilter.filter_bandit_errors/2, []},
bandit_invalid_http: {&Towerops.LogFilters.filter_bandit_errors/2, []},
# Suppress benign port_died and write_failed errors during K8s pod shutdown
shutdown_errors: {&Towerops.LoggerFilters.drop_shutdown_errors/2, []}
shutdown_errors: {&Towerops.LogFilters.drop_shutdown_errors/2, []}
]
# Register protobuf MIME type for agent API
@ -138,29 +138,6 @@ config :towerops, ToweropsWeb.Endpoint,
# to "/data/coverage" (the shared NFS mount) in config/prod.exs so all
# replicas can serve the rasters and pod restarts don't lose them.
config :towerops, :coverage_storage_dir, {:towerops, "priv/static/coverage"}
config :towerops, :live_view_signing_salt, "Uh1ABfdI"
# SNMP MIB directories for production (in Docker image)
# MIB files are included in the release at /app/priv/mibs
# MibTranslator automatically expands these to include subdirectories
# Override in dev.exs for local development paths
config :towerops, :mib_dirs, [
"/app/priv/mibs",
"/usr/share/snmp/mibs"
]
config :towerops, :scopes,
user: [
default: true,
module: Towerops.Accounts.Scope,
assign_key: :current_scope,
access_path: [:user, :id],
schema_key: :user_id,
schema_type: :binary_id,
schema_table: :users,
test_data_fixture: Towerops.AccountsFixtures,
test_setup_helper: :register_and_log_in_user
]
# Agent Docker Image
# Override this in runtime.exs or environment-specific config

View file

@ -218,13 +218,6 @@ config :towerops, :env, :dev
# Configure SNMP MIB directories for development
# Point to local priv/mibs subdirectories where MIB files are stored
# Note: The root mibs/ directory only contains symlinks to LibreNMS repo
# SNMP MIB directories for development
# MibTranslator automatically expands these to include subdirectories
config :towerops, :mib_dirs, [
Path.join([File.cwd!(), "priv", "mibs"]),
"/usr/share/snmp/mibs"
]
# Disable rate limiting in development
config :towerops, :rate_limiting_enabled, false
config :towerops, :session_encryption_salt, "dev-encryption-salt-stable"
@ -250,5 +243,4 @@ config :towerops, dev_routes: true
config :towerops,
stripe_secret_key: System.get_env("STRIPE_SECRET_KEY", "sk_test_use_your_own_key"),
stripe_webhook_secret: "whsec_dev_fake_secret",
stripe_price_id: "price_1T81XBS77kvnTfgyPlw1jF8N",
stripe_meter_id: "mtr_test_61UHPU067veEZ7bhl41S77kvnTfgyODY"
stripe_price_id: "price_1T81XBS77kvnTfgyPlw1jF8N"

View file

@ -1,22 +1,18 @@
import Config
# Temporarily disable Honeybadger in production
config :honeybadger,
exclude_envs: [:prod]
# Filter out harmless Oban shutdown messages, repetitive SNMP MIB errors, and noisy health checks
config :logger, :default_handler,
filters: [
drop_oban_shutdown: {
&Towerops.LoggerFilters.drop_oban_shutdown/2,
&Towerops.LogFilters.drop_oban_shutdown/2,
[]
},
drop_snmp_mib_errors: {
&Towerops.LoggerFilters.drop_snmp_mib_errors/2,
&Towerops.LogFilters.drop_snmp_mib_errors/2,
[]
},
filter_health_checks: {
&ToweropsWeb.TelemetryFilter.filter_health_checks/2,
&Towerops.LogFilters.filter_health_checks/2,
[]
}
]

View file

@ -503,13 +503,6 @@ if config_env() == :prod do
This is the Stripe price ID for the metered billing plan.
"""
stripe_meter_id =
System.get_env("STRIPE_METER_ID") ||
raise """
environment variable STRIPE_METER_ID is missing.
This is the Stripe billing meter ID for usage reporting.
"""
# Configure ErrorTrackerNotifier for email alerts on exceptions
config :error_tracker_notifier,
notification_type: :email,
@ -520,8 +513,7 @@ if config_env() == :prod do
config :towerops,
stripe_secret_key: stripe_secret_key,
stripe_webhook_secret: stripe_webhook_secret,
stripe_price_id: stripe_price_id,
stripe_meter_id: stripe_meter_id
stripe_price_id: stripe_price_id
end
# Test environment Stripe configuration
@ -529,6 +521,5 @@ if config_env() == :test do
config :towerops,
stripe_secret_key: "sk_test_fake",
stripe_webhook_secret: "whsec_test_fake",
stripe_price_id: "price_test_fake",
stripe_meter_id: "meter_test_fake"
stripe_price_id: "price_test_fake"
end

View file

@ -16,12 +16,7 @@ defmodule Towerops.Accounts.BrowserSession do
- Automatically cleaned up after expires_at timestamp
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
use Towerops.Schema
schema "browser_sessions" do
# Device/browser metadata

View file

@ -9,12 +9,7 @@ defmodule Towerops.Accounts.LoginAttempt do
- Data retention: 365 days for active records, 90 days for anonymized records
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
use Towerops.Schema
@valid_methods ~w(password magic_link mobile_qr)
@valid_failure_reasons ~w(invalid_credentials invalid_totp invalid_magic_link account_disabled)

View file

@ -5,12 +5,8 @@ defmodule Towerops.Accounts.PolicyVersion do
Stores the full text of each policy version with effective dates to maintain
a complete audit trail of policy changes for GDPR compliance.
"""
use Ecto.Schema
use Towerops.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "policy_versions" do
field :policy_type, :string
field :version, :string

View file

@ -7,16 +7,12 @@ defmodule Towerops.Accounts.User do
- TOTP (Time-based One-Time Password) two-factor authentication
- Session tokens
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Ecto.Association.NotLoaded
alias Towerops.Organizations.Membership
alias Towerops.Organizations.Organization
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "users" do
field :email, :string
field :password, :string, virtual: true, redact: true

View file

@ -5,14 +5,10 @@ defmodule Towerops.Accounts.UserConsent do
Stores consent records with versioning to comply with GDPR and privacy regulations.
Allows users to withdraw consent at any time.
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Towerops.Accounts.User
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "user_consents" do
belongs_to :user, User

View file

@ -5,14 +5,10 @@ defmodule Towerops.Accounts.UserRecoveryCode do
Recovery codes are single-use backup codes that can be used to access
an account if all TOTP devices are lost. Codes are stored as SHA-256 hashes.
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Towerops.Accounts.User
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "user_recovery_codes" do
field :code_hash, :string
field :used_at, :utc_datetime

View file

@ -5,7 +5,7 @@ defmodule Towerops.Accounts.UserToken do
Manages session tokens, email confirmation tokens, and password reset tokens
with expiration and hashing.
"""
use Ecto.Schema
use Towerops.Schema
import Ecto.Query
@ -21,8 +21,6 @@ defmodule Towerops.Accounts.UserToken do
@change_email_validity_in_days 7
@session_validity_in_days 14
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "users_tokens" do
field :token, :binary
field :context, :string

View file

@ -5,14 +5,10 @@ defmodule Towerops.Accounts.UserTotpDevice do
Allows users to register multiple authenticator apps (phone, tablet, etc.)
for two-factor authentication.
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Towerops.Accounts.User
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "user_totp_devices" do
field :name, :string
field :totp_secret, :binary, redact: true

View file

@ -2,15 +2,10 @@ defmodule Towerops.Admin.AuditLog do
@moduledoc """
Schema for audit logs tracking sensitive operations performed by superusers.
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Towerops.Accounts.User
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "audit_logs" do
field :action, :string
field :metadata, :map

View file

@ -5,16 +5,12 @@ defmodule Towerops.Agents.AgentAssignment do
Each device can only be assigned to one agent at a time (unique constraint on device_id).
This ensures that polling is not duplicated across multiple agents.
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Ecto.Association.NotLoaded
alias Towerops.Agents.AgentToken
alias Towerops.Devices.Device
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "agent_assignments" do
field :enabled, :boolean, default: true

View file

@ -6,17 +6,13 @@ defmodule Towerops.Agents.AgentToken do
Tokens are generated with cryptographically secure random bytes (48 bytes = 384 bits) and
stored in plaintext in the database. Tokens can be retrieved and viewed by authorized users.
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Towerops.Organizations.Organization
# 48 bytes = 384 bits of cryptographically secure randomness
@rand_size 48
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "agent_tokens" do
field :token, :string
field :name, :string

View file

@ -5,9 +5,7 @@ defmodule Towerops.Alerts.Alert do
Tracks device_up, device_down, and monitoring check alerts with
acknowledgement status and auto-resolution.
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Ecto.Association.NotLoaded
alias Towerops.Accounts.User
@ -16,8 +14,6 @@ defmodule Towerops.Alerts.Alert do
alias Towerops.Monitoring.Check
alias Towerops.Organizations.Organization
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "alerts" do
# Alert type: device_down, device_up for device alerts
# For check alerts, uses string like "check_http", "check_ping", etc.

View file

@ -5,14 +5,10 @@ defmodule Towerops.Alerts.AlertStorm do
When > threshold alerts fire within a time window, individual notifications
are suppressed and a single summary notification is sent instead.
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Towerops.Organizations.Organization
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "alert_storms" do
field :started_at, :utc_datetime
field :ended_at, :utc_datetime

View file

@ -5,17 +5,13 @@ defmodule Towerops.Alerts.NotificationDigest do
Tracks how many notifications a user has received within a time window.
When the limit is exceeded, additional alerts are queued for digest delivery.
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Towerops.Accounts.User
alias Towerops.Organizations.Organization
@type t :: %__MODULE__{}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "notification_digests" do
field :window_start, :utc_datetime
field :notification_count, :integer, default: 0

View file

@ -6,9 +6,7 @@ defmodule Towerops.Alerts.SiteOutage do
they are grouped into a single site outage with one notification
instead of N individual notifications.
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Towerops.Alerts.Alert
alias Towerops.Organizations.Organization
@ -16,8 +14,6 @@ defmodule Towerops.Alerts.SiteOutage do
@type t :: %__MODULE__{}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "site_outages" do
field :started_at, :utc_datetime
field :resolved_at, :utc_datetime

View file

@ -8,15 +8,10 @@ defmodule Towerops.ApiTokens.ApiToken do
The actual token value is only shown once when created and is then
hashed for secure storage.
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
@type t :: %__MODULE__{}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "api_tokens" do
field :name, :string
field :token_hash, :string

View file

@ -157,7 +157,7 @@ defmodule Towerops.Application do
# supervisor may not be ready or may already be dying) cannot exit the boot
# process and mask the real error from Supervisor.start_link.
defp post_startup do
ToweropsWeb.TelemetryFilter.attach()
Towerops.LogFilters.attach()
_ =
try do

View file

@ -2,15 +2,10 @@ defmodule Towerops.ConfigChanges.ConfigChangeEvent do
@moduledoc """
Schema for config change events linking MikroTik backup diffs to time windows.
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Towerops.Devices.MikrotikBackup
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "config_change_events" do
field :changed_at, :utc_datetime
field :diff_summary, :string

View file

@ -10,9 +10,7 @@ defmodule Towerops.Coverages.Coverage do
Multiple coverages can hang off a single site typically one per sector
on a multi-sector tower.
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Ecto.Association.NotLoaded
alias Towerops.Coverages.Antenna
@ -23,8 +21,6 @@ defmodule Towerops.Coverages.Coverage do
@statuses ~w(draft queued computing ready failed)
@max_axis_pixels 8_000
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "coverages" do
field :name, :string
field :antenna_slug, :string

View file

@ -2,12 +2,7 @@ defmodule Towerops.Devices.BackupRequest do
@moduledoc """
Schema for MikroTik device backup request tracking.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
use Towerops.Schema
schema "device_backup_requests" do
field :job_id, :string

View file

@ -8,9 +8,7 @@ defmodule Towerops.Devices.Device do
- Agent assignments for remote polling
- Monitoring checks and alerts
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Ecto.Association.NotLoaded
alias Towerops.Agents.AgentAssignment
@ -20,8 +18,6 @@ defmodule Towerops.Devices.Device do
alias Towerops.Snmp.Device, as: SnmpDevice
alias Towerops.Topology.DeviceLink
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "devices" do
field :name, :string
field :ip_address, :string

View file

@ -9,12 +9,7 @@ defmodule Towerops.Devices.DeviceFirmwareHistory do
The `firmware_releases` table stores the latest available versions from vendors.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
use Towerops.Schema
schema "device_firmware_history" do
belongs_to :snmp_device, Towerops.Snmp.Device

View file

@ -2,9 +2,7 @@ defmodule Towerops.Devices.Event do
@moduledoc """
Device event schema for tracking changes and incidents.
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
@type severity :: :info | :warning | :critical
@ -24,9 +22,6 @@ defmodule Towerops.Devices.Event do
| :sensor_value_drop
| :sensor_value_changed
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "device_events" do
field :event_type, :string
field :severity, :string

View file

@ -8,12 +8,7 @@ defmodule Towerops.Devices.FirmwareRelease do
The `device_firmware_history` table tracks when individual devices change versions.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
use Towerops.Schema
schema "firmware_releases" do
field :vendor, :string

View file

@ -2,12 +2,7 @@ defmodule Towerops.Devices.MikrotikBackup do
@moduledoc """
Schema for MikroTik device configuration backups.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
use Towerops.Schema
schema "device_mikrotik_backups" do
field :config_compressed, :binary

View file

@ -3,7 +3,7 @@ defmodule Towerops.ErrorTrackerIgnorer do
Drops benign errors from ErrorTracker that occur during K8s pod shutdown
or transient DB connection cycling.
Matches the same patterns as `Towerops.LoggerFilters.drop_shutdown_errors/2`.
Matches the same patterns as `Towerops.LogFilters.drop_shutdown_errors/2`.
"""
@behaviour ErrorTracker.Ignorer

View file

@ -2,15 +2,11 @@ defmodule Towerops.Gaiia.Account do
@moduledoc """
Schema for subscriber accounts synced from Gaiia API.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
use Towerops.Gaiia.BaseSchema
schema "gaiia_accounts" do
field :gaiia_id, :string
gaiia_base_fields()
field :readable_id, :string
field :name, :string
field :status, :string
@ -18,11 +14,6 @@ defmodule Towerops.Gaiia.Account do
field :address, :map
field :subscription_count, :integer
field :mrr, :decimal
field :raw_data, :map
belongs_to :organization, Towerops.Organizations.Organization
timestamps(type: :utc_datetime)
end
def changeset(account, attrs) do
@ -39,11 +30,6 @@ defmodule Towerops.Gaiia.Account do
:mrr,
:raw_data
])
|> validate_required([:organization_id, :gaiia_id])
|> unique_constraint([:organization_id, :gaiia_id],
error_key: :gaiia_id,
message: "has already been taken"
)
|> foreign_key_constraint(:organization_id)
|> validate_gaiia_uniqueness()
end
end

View file

@ -0,0 +1,56 @@
defmodule Towerops.Gaiia.BaseSchema do
@moduledoc """
Base schema for Gaiia-synced entities.
Provides shared fields (`gaiia_id`, `raw_data`, `organization` belongs_to, timestamps)
and a `validate_gaiia_uniqueness/1` helper.
## Usage
defmodule Towerops.Gaiia.MyEntity do
use Towerops.Gaiia.BaseSchema
schema "gaiia_my_entities" do
gaiia_base_fields()
field :my_field, :string
end
def changeset(struct, attrs) do
struct
|> cast(attrs, [:organization_id, :gaiia_id, :my_field, :raw_data])
|> validate_gaiia_uniqueness()
end
end
"""
defmacro __using__(_opts) do
quote do
use Towerops.Schema
import Towerops.Gaiia.BaseSchema, only: [gaiia_base_fields: 0]
def validate_gaiia_uniqueness(changeset) do
changeset
|> validate_required([:organization_id, :gaiia_id])
|> unique_constraint([:organization_id, :gaiia_id],
error_key: :gaiia_id,
message: "has already been taken"
)
|> foreign_key_constraint(:organization_id)
end
end
end
@doc """
Injects common Gaiia schema fields.
Must be called inside a `schema` block.
"""
defmacro gaiia_base_fields do
quote do
field :gaiia_id, :string
field :raw_data, :map
belongs_to :organization, Towerops.Organizations.Organization
timestamps(type: :utc_datetime)
end
end
end

View file

@ -2,15 +2,11 @@ defmodule Towerops.Gaiia.BillingSubscription do
@moduledoc """
Schema for billing subscriptions synced from Gaiia API.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
use Towerops.Gaiia.BaseSchema
schema "gaiia_billing_subscriptions" do
field :gaiia_id, :string
gaiia_base_fields()
field :account_gaiia_id, :string
field :status, :string
field :product_name, :string
@ -18,11 +14,6 @@ defmodule Towerops.Gaiia.BillingSubscription do
field :currency, :string
field :speed_download, :integer
field :speed_upload, :integer
field :raw_data, :map
belongs_to :organization, Towerops.Organizations.Organization
timestamps(type: :utc_datetime)
end
def changeset(billing_subscription, attrs) do
@ -39,11 +30,6 @@ defmodule Towerops.Gaiia.BillingSubscription do
:speed_upload,
:raw_data
])
|> validate_required([:organization_id, :gaiia_id])
|> unique_constraint([:organization_id, :gaiia_id],
error_key: :gaiia_id,
message: "has already been taken"
)
|> foreign_key_constraint(:organization_id)
|> validate_gaiia_uniqueness()
end
end

View file

@ -5,12 +5,7 @@ defmodule Towerops.Gaiia.DeviceSubscriberLink do
Links a Gaiia subscriber account to a TowerOps device based on
ARP table matching or site-level fallback.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
use Towerops.Schema
schema "device_subscriber_links" do
field :match_method, :string

View file

@ -2,15 +2,11 @@ defmodule Towerops.Gaiia.InventoryItem do
@moduledoc """
Schema for inventory items (deployed equipment) synced from Gaiia API.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
use Towerops.Gaiia.BaseSchema
schema "gaiia_inventory_items" do
field :gaiia_id, :string
gaiia_base_fields()
field :name, :string
field :status, :string
field :serial_number, :string
@ -21,12 +17,8 @@ defmodule Towerops.Gaiia.InventoryItem do
field :category, :string
field :assigned_account_gaiia_id, :string
field :assigned_network_site_gaiia_id, :string
field :raw_data, :map
belongs_to :organization, Towerops.Organizations.Organization
belongs_to :device, Towerops.Devices.Device
timestamps(type: :utc_datetime)
end
def changeset(inventory_item, attrs) do
@ -47,12 +39,7 @@ defmodule Towerops.Gaiia.InventoryItem do
:device_id,
:raw_data
])
|> validate_required([:organization_id, :gaiia_id])
|> unique_constraint([:organization_id, :gaiia_id],
error_key: :gaiia_id,
message: "has already been taken"
)
|> foreign_key_constraint(:organization_id)
|> validate_gaiia_uniqueness()
|> foreign_key_constraint(:device_id)
end
end

View file

@ -2,26 +2,18 @@ defmodule Towerops.Gaiia.NetworkSite do
@moduledoc """
Schema for network sites (towers/POPs) synced from Gaiia API.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
use Towerops.Gaiia.BaseSchema
schema "gaiia_network_sites" do
field :gaiia_id, :string
gaiia_base_fields()
field :name, :string
field :address, :map
field :ip_blocks, {:array, :string}, default: []
field :account_count, :integer
field :total_mrr, :decimal
field :raw_data, :map
belongs_to :organization, Towerops.Organizations.Organization
belongs_to :site, Towerops.Sites.Site
timestamps(type: :utc_datetime)
end
def changeset(network_site, attrs) do
@ -37,12 +29,7 @@ defmodule Towerops.Gaiia.NetworkSite do
:site_id,
:raw_data
])
|> validate_required([:organization_id, :gaiia_id])
|> unique_constraint([:organization_id, :gaiia_id],
error_key: :gaiia_id,
message: "has already been taken"
)
|> foreign_key_constraint(:organization_id)
|> validate_gaiia_uniqueness()
|> foreign_key_constraint(:site_id)
end
end

View file

@ -3,11 +3,8 @@ defmodule Towerops.GeoIP.Block do
Schema for GeoIP IP block data (CIDR ranges mapped to locations).
Maps IP address ranges to geoname_id from MaxMind GeoLite2-City database.
"""
use Ecto.Schema
use Towerops.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
schema "geoip_blocks" do
field :network, :string
field :start_ip_int, :integer

View file

@ -5,15 +5,10 @@ defmodule Towerops.Integrations.Integration do
Each organization can have one integration per provider. Credentials
are encrypted at rest using Cloak AES-256-GCM.
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Towerops.Encrypted
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@valid_providers ~w(preseem gaiia pagerduty netbox sonar splynx visp uisp cn_maestro mikrotik)
@valid_sync_statuses ~w(never success partial failed)

View file

@ -2,11 +2,7 @@ defmodule Towerops.Lidar.SyncLog do
@moduledoc """
Audit log for LIDAR catalog sync operations.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
use Towerops.Schema
@valid_statuses ~w(success partial failed)
@valid_sources ~w(3dep tnris)

View file

@ -5,12 +5,7 @@ defmodule Towerops.Lidar.Tile do
We do not store raster bytes only the public HTTPS URL and footprint
metadata. Reads happen on-demand via GDAL's `/vsicurl/` driver.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
use Towerops.Schema
@valid_sources ~w(3dep tnris)
@valid_availability ~w(available missing error no_cog)

View file

@ -19,16 +19,12 @@ defmodule Towerops.LLM.Usage do
GROUP BY 1, 2, 3, 4
ORDER BY 1 DESC;
"""
use Ecto.Schema
use Towerops.Schema
import Ecto.Changeset
import Ecto.Query
alias Towerops.Repo
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "llm_usage" do
field :day, :date
field :model, :string

View file

@ -1,46 +0,0 @@
defmodule Towerops.LogFilter do
@moduledoc """
Filters out noisy log messages from external sources like port scanners and bots.
"""
@doc """
Filters out Bandit HTTP errors from invalid requests.
This suppresses errors from:
- HTTP/0.9 requests (very old protocol, typically port scanners)
- Malformed HTTP requests from bots
- Invalid protocol versions
These errors are expected when running a public-facing server and don't
indicate application problems.
"""
def filter_bandit_errors(log_event, _opts) do
case log_event do
# Filter Bandit.HTTPError with Invalid HTTP version
%{level: :error, msg: {:string, msg}} when is_binary(msg) ->
filter_string_message(msg)
# Filter other Bandit connection errors
%{level: :error, msg: {:report, %{label: {:error_logger, :error_report}, report: report}}} ->
filter_error_report(report)
# Don't filter anything else
_ ->
:ignore
end
end
defp filter_string_message(msg) do
if bandit_invalid_http_error?(msg), do: :stop, else: :ignore
end
defp filter_error_report(message: msg) when is_binary(msg) do
if String.contains?(msg, "Bandit.HTTPError"), do: :stop, else: :ignore
end
defp filter_error_report(_), do: :ignore
defp bandit_invalid_http_error?(msg) do
String.contains?(msg, "Bandit.HTTPError") and String.contains?(msg, "Invalid HTTP version")
end
end

185
lib/towerops/log_filters.ex Normal file
View file

@ -0,0 +1,185 @@
defmodule Towerops.LogFilters do
@moduledoc """
Consolidated log and telemetry filters to reduce noise in production logs.
Combines filters for:
- Invalid HTTP errors from Bandit (port scanners / bots)
- Oban shutdown messages during K8s rolling deployments
- SNMP MIB resolution errors from net-snmp
- Port died / write failed errors during K8s pod termination
- Health check requests (K8s probes / uptime monitors)
"""
require Logger
# ── Bandit / HTTP ──────────────────────────────────────────────────
@doc """
Filters out Bandit HTTP errors from invalid requests.
This suppresses errors from:
- HTTP/0.9 requests (very old protocol, typically port scanners)
- Malformed HTTP requests from bots
- Invalid protocol versions
These errors are expected when running a public-facing server and don't
indicate application problems.
"""
def filter_bandit_errors(log_event, _opts) do
case log_event do
%{level: :error, msg: {:string, msg}} when is_binary(msg) ->
filter_string_message(msg)
%{level: :error, msg: {:report, %{label: {:error_logger, :error_report}, report: report}}} ->
filter_error_report(report)
_ ->
:ignore
end
end
defp filter_string_message(msg) do
if bandit_invalid_http_error?(msg), do: :stop, else: :ignore
end
defp filter_error_report(message: msg) when is_binary(msg) do
if String.contains?(msg, "Bandit.HTTPError"), do: :stop, else: :ignore
end
defp filter_error_report(_), do: :ignore
defp bandit_invalid_http_error?(msg) do
String.contains?(msg, "Bandit.HTTPError") and String.contains?(msg, "Invalid HTTP version")
end
# ── Oban shutdown ─────────────────────────────────────────────────
@doc """
Drops harmless Oban shutdown messages that occur during Kubernetes rolling deployments.
These messages are expected when the old pod gracefully terminates during a deployment,
but Oban's internal processes log them as "unexpected" even though they're normal.
Example messages filtered:
- "Received unexpected message: {:EXIT, #PID<...>, :shutdown}"
- "Oban.Queue.Watchman #PID<...> received unexpected message in handle_info/2: {:EXIT, ...}"
"""
def drop_oban_shutdown(log_event, _opts) do
cond do
oban_source_shutdown?(log_event) -> :stop
oban_queue_shutdown?(log_event) -> :stop
true -> :ignore
end
end
defp oban_source_shutdown?(%{meta: %{source: :oban}, msg: {:string, msg}}) when is_binary(msg) do
String.contains?(msg, "EXIT") and String.contains?(msg, ":shutdown")
end
defp oban_source_shutdown?(_), do: false
defp oban_queue_shutdown?(%{meta: %{error_logger: %{tag: :error_msg}}, msg: {:string, msg}}) when is_binary(msg) do
oban_component?(msg) and shutdown_message?(msg)
end
defp oban_queue_shutdown?(_), do: false
defp oban_component?(msg) do
String.contains?(msg, "Oban.Queue") or String.contains?(msg, "Oban.Plugins")
end
defp shutdown_message?(msg) do
String.contains?(msg, "received unexpected message") and
String.contains?(msg, "EXIT") and
String.contains?(msg, ":shutdown")
end
# ── SNMP MIB errors ───────────────────────────────────────────────
@doc """
Drops repetitive SNMP MIB resolution errors from net-snmp library.
These errors occur when snmptranslate can't find vendor-specific MIB modules.
The errors are logged repeatedly during polling but don't affect functionality
since the system falls back to numeric OIDs.
Example messages filtered:
- "Cannot find module (TRISTAR): At line 1 in (none)"
- "Cannot find module (VENDOR-MIB): At line 1 in (none)"
"""
def drop_snmp_mib_errors(log_event, _opts) do
if snmp_mib_error?(log_event), do: :stop, else: :ignore
end
defp snmp_mib_error?(%{msg: {:string, msg}}) when is_binary(msg) do
String.contains?(msg, "Cannot find module") and String.contains?(msg, "At line 1 in (none)")
end
defp snmp_mib_error?(_), do: false
# ── Shutdown errors (K8s) ─────────────────────────────────────────
@doc """
Drops benign shutdown errors that occur during Kubernetes pod termination.
These errors are expected when the BEAM shuts down inside a container:
- `{:port_died, :normal}` - SNMP polling jobs hold UDP sockets that become invalid
- `{:write_failed, ...} {:device, :epipe}` - Logger writes to stdout after container runtime closes the pipe
"""
def drop_shutdown_errors(log_event, _opts) do
if shutdown_log_message?(log_event), do: :stop, else: :ignore
end
defp shutdown_log_message?(%{msg: {:string, msg}}) when is_binary(msg) do
String.contains?(msg, "port_died") or
(String.contains?(msg, "write_failed") and String.contains?(msg, "epipe"))
end
defp shutdown_log_message?(_), do: false
# ── Health check filter (telemetry) ───────────────────────────────
@doc """
Logger filter function that drops health check requests.
Returns :stop to prevent logging, :ignore to allow logging.
"""
def filter_health_checks(log_event, _opts) do
case log_event do
{_level, _gl, {Logger, msg, _ts, metadata}} ->
if should_filter_log?(msg, metadata), do: :stop, else: :ignore
_ ->
:ignore
end
end
defp should_filter_log?(msg, metadata) do
filter_by_path?(metadata) or filter_by_phoenix_log?(metadata) or filter_by_message?(msg)
end
defp filter_by_path?(metadata) do
Keyword.get(metadata, :request_path) in ["/health", "/health/time"]
end
defp filter_by_phoenix_log?(metadata) do
Keyword.get(metadata, :phoenix_log) == false
end
defp filter_by_message?(msg) when is_binary(msg) do
String.contains?(msg, "GET /health") or
String.contains?(msg, "/health/time") or
String.contains?(msg, "HEAD /")
end
defp filter_by_message?(_), do: false
@doc """
Dummy attach function for compatibility with existing code.
The actual filtering happens via logger configuration, not telemetry.
"""
def attach do
Logger.info("Health check log filter active via logger configuration")
:ok
end
end

View file

@ -1,84 +0,0 @@
defmodule Towerops.LoggerFilters do
@moduledoc """
Custom Logger filters to reduce noise in production logs.
"""
@doc """
Drops harmless Oban shutdown messages that occur during Kubernetes rolling deployments.
These messages are expected when the old pod gracefully terminates during a deployment,
but Oban's internal processes log them as "unexpected" even though they're normal.
Example messages filtered:
- "Received unexpected message: {:EXIT, #PID<...>, :shutdown}"
- "Oban.Queue.Watchman #PID<...> received unexpected message in handle_info/2: {:EXIT, ...}"
"""
def drop_oban_shutdown(log_event, _opts) do
cond do
oban_source_shutdown?(log_event) -> :stop
oban_queue_shutdown?(log_event) -> :stop
true -> :ignore
end
end
defp oban_source_shutdown?(%{meta: %{source: :oban}, msg: {:string, msg}}) when is_binary(msg) do
String.contains?(msg, "EXIT") and String.contains?(msg, ":shutdown")
end
defp oban_source_shutdown?(_), do: false
defp oban_queue_shutdown?(%{meta: %{error_logger: %{tag: :error_msg}}, msg: {:string, msg}}) when is_binary(msg) do
oban_component?(msg) and shutdown_message?(msg)
end
defp oban_queue_shutdown?(_), do: false
defp oban_component?(msg) do
String.contains?(msg, "Oban.Queue") or String.contains?(msg, "Oban.Plugins")
end
defp shutdown_message?(msg) do
String.contains?(msg, "received unexpected message") and
String.contains?(msg, "EXIT") and
String.contains?(msg, ":shutdown")
end
@doc """
Drops repetitive SNMP MIB resolution errors from net-snmp library.
These errors occur when snmptranslate can't find vendor-specific MIB modules.
The errors are logged repeatedly during polling but don't affect functionality
since the system falls back to numeric OIDs.
Example messages filtered:
- "Cannot find module (TRISTAR): At line 1 in (none)"
- "Cannot find module (VENDOR-MIB): At line 1 in (none)"
"""
def drop_snmp_mib_errors(log_event, _opts) do
if snmp_mib_error?(log_event), do: :stop, else: :ignore
end
defp snmp_mib_error?(%{msg: {:string, msg}}) when is_binary(msg) do
String.contains?(msg, "Cannot find module") and String.contains?(msg, "At line 1 in (none)")
end
defp snmp_mib_error?(_), do: false
@doc """
Drops benign shutdown errors that occur during Kubernetes pod termination.
These errors are expected when the BEAM shuts down inside a container:
- `{:port_died, :normal}` - SNMP polling jobs hold UDP sockets that become invalid
- `{:write_failed, ...} {:device, :epipe}` - Logger writes to stdout after container runtime closes the pipe
"""
def drop_shutdown_errors(log_event, _opts) do
if shutdown_log_message?(log_event), do: :stop, else: :ignore
end
defp shutdown_log_message?(%{msg: {:string, msg}}) when is_binary(msg) do
String.contains?(msg, "port_died") or
(String.contains?(msg, "write_failed") and String.contains?(msg, "epipe"))
end
defp shutdown_log_message?(_), do: false
end

View file

@ -7,17 +7,13 @@ defmodule Towerops.Maintenance.MaintenanceWindow do
- All devices at a site (site_id set, device_id nil)
- All devices in an org (both nil)
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
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

View file

@ -7,12 +7,7 @@ defmodule Towerops.Mikrotik.Event do
these rows after the fact historical timeline of every IP assignment event.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
use Towerops.Schema
@sources ~w(dhcp pppoe)
@event_types ~w(assign release)

View file

@ -8,12 +8,7 @@ defmodule Towerops.Mikrotik.IpAssignment do
"which customer is using this IP right now?".
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
use Towerops.Schema
@sources ~w(dhcp pppoe)

View file

@ -5,12 +5,7 @@ defmodule Towerops.MobileSessions.MobileSession do
Long-lived authentication tokens for mobile apps. Users can manage
their active mobile sessions from the web interface.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
use Towerops.Schema
schema "mobile_sessions" do
field :token, :string

View file

@ -6,12 +6,7 @@ defmodule Towerops.MobileSessions.QRLoginToken do
in the web interface. Mobile apps scan the QR code and use the token
to create a new mobile session.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
use Towerops.Schema
schema "qr_login_tokens" do
field :token, :string

View file

@ -12,9 +12,7 @@ defmodule Towerops.Monitoring.Check do
- Soft/hard state transitions
- Optional device association (can be standalone)
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Towerops.Agents.AgentToken
alias Towerops.Devices.Device
@ -22,9 +20,6 @@ defmodule Towerops.Monitoring.Check do
@type t :: %__MODULE__{}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@check_types ~w(http tcp dns ssl passive ping snmp_sensor snmp_interface snmp_processor snmp_storage)
schema "checks" do

View file

@ -4,17 +4,12 @@ defmodule Towerops.Monitoring.CheckResult do
Results are time-series data showing check status over time.
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Towerops.Agents.AgentToken
alias Towerops.Monitoring.Check
alias Towerops.Organizations.Organization
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "check_results" do
field :checked_at, :utc_datetime
field :status, :integer

View file

@ -6,16 +6,11 @@ defmodule Towerops.Monitoring.MonitoringCheck do
Each record captures the device status, response time, and which agent
performed the check.
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Towerops.Agents.AgentToken
alias Towerops.Devices.Device
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "monitoring_checks" do
field :status, :string
field :response_time_ms, :float

View file

@ -0,0 +1,25 @@
defmodule Towerops.OnCall.ChangesetHelpers do
@moduledoc """
Shared helpers for on-call changesets.
Eliminates the common `cast` + `validate_required` boilerplate.
"""
@doc """
Casts attrs and validates required fields in one step.
## Examples
changeset
|> required_first(attrs, @required_fields, @optional_fields)
# Without optional fields
changeset
|> required_first(attrs, @required_fields)
"""
def required_first(changeset, attrs, required_fields, optional_fields \\ []) do
changeset
|> Ecto.Changeset.cast(attrs, required_fields ++ optional_fields)
|> Ecto.Changeset.validate_required(required_fields)
end
end

View file

@ -2,15 +2,13 @@ defmodule Towerops.OnCall.EscalationPolicy do
@moduledoc """
Schema for escalation policies that define notification rules.
"""
use Ecto.Schema
use Towerops.Schema
import Ecto.Changeset
import Towerops.OnCall.ChangesetHelpers
alias Towerops.OnCall.EscalationRule
alias Towerops.Organizations.Organization
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@valid_handoff_modes ~w(when_in_use_by_service always never)
schema "escalation_policies" do
@ -31,8 +29,7 @@ defmodule Towerops.OnCall.EscalationPolicy do
def changeset(policy, attrs) do
policy
|> cast(attrs, @required_fields ++ @optional_fields)
|> validate_required(@required_fields)
|> required_first(attrs, @required_fields, @optional_fields)
|> validate_number(:repeat_count, greater_than: 0)
|> validate_inclusion(:handoff_notifications_mode, @valid_handoff_modes)
|> foreign_key_constraint(:organization_id)

View file

@ -2,15 +2,13 @@ defmodule Towerops.OnCall.EscalationRule do
@moduledoc """
Schema for a step within an escalation policy.
"""
use Ecto.Schema
use Towerops.Schema
import Ecto.Changeset
import Towerops.OnCall.ChangesetHelpers
alias Towerops.OnCall.EscalationPolicy
alias Towerops.OnCall.EscalationTarget
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "escalation_rules" do
field :position, :integer, default: 0
field :timeout_minutes, :integer, default: 30
@ -26,8 +24,7 @@ defmodule Towerops.OnCall.EscalationRule do
def changeset(rule, attrs) do
rule
|> cast(attrs, @required_fields)
|> validate_required(@required_fields)
|> required_first(attrs, @required_fields)
|> validate_number(:timeout_minutes, greater_than: 0)
|> validate_number(:position, greater_than_or_equal_to: 0)
|> foreign_key_constraint(:escalation_policy_id)

View file

@ -2,16 +2,14 @@ defmodule Towerops.OnCall.EscalationTarget do
@moduledoc """
Schema for notification targets at each escalation step.
"""
use Ecto.Schema
use Towerops.Schema
import Ecto.Changeset
import Towerops.OnCall.ChangesetHelpers
alias Towerops.Accounts.User
alias Towerops.OnCall.EscalationRule
alias Towerops.OnCall.Schedule
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "escalation_targets" do
field :target_type, :string
@ -29,8 +27,7 @@ defmodule Towerops.OnCall.EscalationTarget do
def changeset(target, attrs) do
target
|> cast(attrs, @required_fields ++ @optional_fields)
|> validate_required(@required_fields)
|> required_first(attrs, @required_fields, @optional_fields)
|> validate_inclusion(:target_type, @valid_target_types)
|> foreign_key_constraint(:escalation_rule_id)
|> foreign_key_constraint(:schedule_id)

View file

@ -2,9 +2,9 @@ defmodule Towerops.OnCall.Incident do
@moduledoc """
Schema for tracking active escalation state for an alert.
"""
use Ecto.Schema
use Towerops.Schema
import Ecto.Changeset
import Towerops.OnCall.ChangesetHelpers
alias Towerops.Accounts.User
alias Towerops.Alerts.Alert
@ -12,8 +12,6 @@ defmodule Towerops.OnCall.Incident do
alias Towerops.OnCall.Notification
alias Towerops.Organizations.Organization
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "on_call_incidents" do
field :status, :string, default: "triggered"
field :current_rule_position, :integer, default: 0
@ -40,8 +38,7 @@ defmodule Towerops.OnCall.Incident do
def changeset(incident, attrs) do
incident
|> cast(attrs, @required_fields ++ @optional_fields)
|> validate_required(@required_fields)
|> required_first(attrs, @required_fields, @optional_fields)
|> validate_inclusion(:status, @valid_statuses)
|> foreign_key_constraint(:alert_id)
|> foreign_key_constraint(:escalation_policy_id)

View file

@ -4,15 +4,13 @@ defmodule Towerops.OnCall.Layer do
Layers stack higher position layers override lower layers where they have coverage.
"""
use Ecto.Schema
use Towerops.Schema
import Ecto.Changeset
import Towerops.OnCall.ChangesetHelpers
alias Towerops.OnCall.LayerMember
alias Towerops.OnCall.Schedule
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "on_call_layers" do
field :name, :string
field :position, :integer, default: 0
@ -39,8 +37,7 @@ defmodule Towerops.OnCall.Layer do
def changeset(layer, attrs) do
layer
|> cast(attrs, @required_fields ++ @optional_fields)
|> validate_required(@required_fields)
|> required_first(attrs, @required_fields, @optional_fields)
|> validate_inclusion(:rotation_type, @valid_rotation_types)
|> validate_inclusion(:restriction_type, @valid_restriction_types)
|> validate_number(:rotation_interval, greater_than: 0)

View file

@ -2,15 +2,13 @@ defmodule Towerops.OnCall.LayerMember do
@moduledoc """
Schema for a user's position in a layer's rotation.
"""
use Ecto.Schema
use Towerops.Schema
import Ecto.Changeset
import Towerops.OnCall.ChangesetHelpers
alias Towerops.Accounts.User
alias Towerops.OnCall.Layer
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "on_call_layer_members" do
field :position, :integer, default: 0
@ -24,8 +22,7 @@ defmodule Towerops.OnCall.LayerMember do
def changeset(member, attrs) do
member
|> cast(attrs, @required_fields)
|> validate_required(@required_fields)
|> required_first(attrs, @required_fields)
|> validate_number(:position, greater_than_or_equal_to: 0)
|> foreign_key_constraint(:layer_id)
|> foreign_key_constraint(:user_id)

View file

@ -2,15 +2,13 @@ defmodule Towerops.OnCall.Notification do
@moduledoc """
Schema for logging sent on-call notifications.
"""
use Ecto.Schema
use Towerops.Schema
import Ecto.Changeset
import Towerops.OnCall.ChangesetHelpers
alias Towerops.Accounts.User
alias Towerops.OnCall.Incident
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "on_call_notifications" do
field :channel, :string, default: "email"
field :sent_at, :utc_datetime
@ -28,8 +26,7 @@ defmodule Towerops.OnCall.Notification do
def changeset(notification, attrs) do
notification
|> cast(attrs, @required_fields ++ @optional_fields)
|> validate_required(@required_fields)
|> required_first(attrs, @required_fields, @optional_fields)
|> validate_inclusion(:channel, ~w(email))
|> validate_inclusion(:status, ~w(sent failed))
|> foreign_key_constraint(:incident_id)

View file

@ -2,15 +2,13 @@ defmodule Towerops.OnCall.Override do
@moduledoc """
Schema for temporary on-call overrides on a schedule.
"""
use Ecto.Schema
use Towerops.Schema
import Ecto.Changeset
import Towerops.OnCall.ChangesetHelpers
alias Towerops.Accounts.User
alias Towerops.OnCall.Schedule
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "on_call_overrides" do
field :start_time, :utc_datetime
field :end_time, :utc_datetime
@ -27,8 +25,7 @@ defmodule Towerops.OnCall.Override do
def changeset(override, attrs) do
override
|> cast(attrs, @required_fields ++ @optional_fields)
|> validate_required(@required_fields)
|> required_first(attrs, @required_fields, @optional_fields)
|> validate_end_after_start()
|> foreign_key_constraint(:schedule_id)
|> foreign_key_constraint(:user_id)

View file

@ -2,18 +2,15 @@ defmodule Towerops.OnCall.Schedule do
@moduledoc """
Schema for on-call schedules containing rotation layers.
"""
use Ecto.Schema
use Towerops.Schema
import Ecto.Changeset
import Towerops.OnCall.ChangesetHelpers
alias Towerops.OnCall.Layer
alias Towerops.OnCall.Override
alias Towerops.Organizations.Organization
@type t :: %__MODULE__{}
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "on_call_schedules" do
field :name, :string
field :description, :string
@ -32,8 +29,7 @@ defmodule Towerops.OnCall.Schedule do
def changeset(schedule, attrs) do
schedule
|> cast(attrs, @required_fields ++ @optional_fields)
|> validate_required(@required_fields)
|> required_first(attrs, @required_fields, @optional_fields)
|> foreign_key_constraint(:organization_id)
end
end

View file

@ -4,16 +4,12 @@ defmodule Towerops.Organizations.Invitation do
Tracks invitation tokens, email, role, expiration, and acceptance status.
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Ecto.Association.NotLoaded
alias Towerops.Accounts.User
alias Towerops.Organizations.Organization
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "organization_invitations" do
field :email, :string
field :role, Ecto.Enum, values: [:admin, :executive, :technician, :member, :viewer]

View file

@ -11,16 +11,12 @@ defmodule Towerops.Organizations.Membership do
- `:technician` Field ops: can manage devices, sites, alerts, but no financial data
- `:viewer` Read-only, no financial data
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Ecto.Association.NotLoaded
alias Towerops.Accounts.User
alias Towerops.Organizations.Organization
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "organization_memberships" do
field :role, Ecto.Enum, values: [:owner, :admin, :executive, :technician, :member, :viewer]
field :is_default, :boolean, default: false

View file

@ -9,9 +9,7 @@ defmodule Towerops.Organizations.Organization do
- Default SNMP configuration
- Default agent token for polling
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Ecto.Association.NotLoaded
alias Towerops.Agents.AgentToken
@ -19,8 +17,6 @@ defmodule Towerops.Organizations.Organization do
alias Towerops.Organizations.Invitation
alias Towerops.Organizations.Membership
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "organizations" do
field :name, :string
field :slug, :string

View file

@ -2,12 +2,7 @@ defmodule Towerops.Preseem.AccessPoint do
@moduledoc """
Schema for access points synced from Preseem API.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
use Towerops.Schema
@valid_match_confidences ~w(auto_mac auto_ip auto_hostname manual ambiguous unmatched)

View file

@ -3,12 +3,7 @@ defmodule Towerops.Preseem.DeviceBaseline do
Rolling 14-day baseline for individual Preseem access points.
Computed nightly to detect deviations from normal behavior.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
use Towerops.Schema
@valid_periods ~w(busy offpeak all)
@valid_metrics ~w(qoe_score capacity_score rf_score p95_latency avg_latency avg_jitter avg_loss avg_throughput subscriber_count airtime_utilization)

View file

@ -3,12 +3,7 @@ defmodule Towerops.Preseem.FleetProfile do
Fleet-wide model performance profiles. Groups devices by manufacturer+model
to identify patterns, capacity ceilings, and firmware correlations.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
use Towerops.Schema
schema "preseem_fleet_profiles" do
field :manufacturer, :string

View file

@ -3,12 +3,7 @@ defmodule Towerops.Preseem.Insight do
Generated insights from Preseem data analysis - deviations, capacity warnings,
firmware recommendations, and performance observations.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
use Towerops.Schema
@valid_types ~w(qoe_degradation capacity_saturation firmware_opportunity model_underperforming subscriber_growth config_drift snmp_cpu_high snmp_memory_high snmp_disk_high device_poll_gap agent_offline firmware_mismatch reconciliation_finding subscriber_growth_trend suspect_config_change backhaul_over_capacity backhaul_near_capacity wireless_signal_weak wireless_snr_low wireless_ap_overloaded wireless_client_missing wireless_coverage_gap ap_frequency_change sector_overload cpe_realign own_fleet_channel_conflict device_overheating upstream_degradation optical_rx_low ai_observation)
@valid_urgencies ~w(critical warning info)

View file

@ -2,12 +2,7 @@ defmodule Towerops.Preseem.SubscriberMetric do
@moduledoc """
Time-series aggregate QoE metrics per Preseem access point.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
use Towerops.Schema
schema "preseem_subscriber_metrics" do
field :avg_latency, :float

View file

@ -2,40 +2,11 @@ defmodule Towerops.Preseem.SyncLog do
@moduledoc """
Audit log for Preseem sync operations.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
use Towerops.SyncLog, table_name: "preseem_sync_logs"
@valid_statuses ~w(success partial failed)
schema "preseem_sync_logs" do
field :status, :string
field :records_synced, :integer, default: 0
field :errors, :map
field :duration_ms, :integer
field :inserted_at, :utc_datetime
belongs_to :organization, Towerops.Organizations.Organization
belongs_to :integration, Towerops.Integrations.Integration
end
def changeset(sync_log, attrs) do
sync_log
|> cast(attrs, [
:organization_id,
:integration_id,
:status,
:records_synced,
:errors,
:duration_ms,
:inserted_at
])
|> validate_required([:organization_id, :integration_id, :status, :inserted_at])
|> validate_inclusion(:status, @valid_statuses)
|> foreign_key_constraint(:organization_id)
|> foreign_key_constraint(:integration_id)
sync_log_changeset(sync_log, attrs, @valid_statuses)
end
end

View file

@ -4,15 +4,10 @@ defmodule Towerops.Profiles.DeviceOid do
Maps device info fields (serial_number, firmware_version, etc.) to MIB names.
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Towerops.Profiles.DeviceProfile
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "profile_device_oids" do
field :field, :string
field :mib_name, :string

View file

@ -4,16 +4,11 @@ defmodule Towerops.Profiles.DeviceProfile do
Profiles define how to identify and discover sensors for different device types.
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Towerops.Profiles.DeviceOid
alias Towerops.Profiles.SensorOid
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "device_profiles" do
field :name, :string
field :vendor, :string

View file

@ -4,15 +4,10 @@ defmodule Towerops.Profiles.SensorOid do
Maps sensor types to MIB names for sensor discovery.
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Towerops.Profiles.DeviceProfile
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "profile_sensor_oids" do
field :sensor_type, :string
field :mib_name, :string

View file

@ -6,12 +6,7 @@ defmodule Towerops.Reports.Report do
Schedule types: one_time, daily, weekly, monthly.
Formats: csv (PDF support planned).
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
use Towerops.Schema
@valid_report_types ~w(uptime_summary alert_history capacity_trends rf_link_health)
@valid_formats ~w(csv)

36
lib/towerops/schema.ex Normal file
View file

@ -0,0 +1,36 @@
defmodule Towerops.Schema do
@moduledoc """
Base schema module for TowerOps.
Provides sensible defaults for all Ecto schemas:
- `binary_id` primary keys
- `binary_id` foreign key type
## Usage
defmodule Towerops.MySchema do
use Towerops.Schema
schema "my_table" do
field :name, :string
timestamps(type: :utc_datetime)
end
def changeset(struct, attrs) do
struct
|> cast(attrs, [:name])
|> validate_required([:name])
end
end
"""
defmacro __using__(_opts) do
quote do
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
end
end
end

View file

@ -11,12 +11,7 @@ defmodule Towerops.Security.IpBlock do
Offense count resets to 1 if 30 days pass without violations.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
use Towerops.Schema
schema "ip_blocks" do
field :ip_address, :string

View file

@ -3,12 +3,7 @@ defmodule Towerops.Security.IpWhitelist do
Schema for tracking IP addresses and CIDR ranges that are exempt from brute force protection.
"""
use Ecto.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
use Towerops.Schema
schema "ip_whitelist" do
field :ip_or_cidr, :string

View file

@ -5,14 +5,10 @@ defmodule Towerops.Settings.ApplicationSetting do
Stores global configuration values that affect the entire application,
regardless of organization. Only accessible by superadmins.
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
require Logger
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "application_settings" do
field :key, :string
field :value, :string

View file

@ -4,9 +4,7 @@ defmodule Towerops.Sites.Site do
Sites contain devices and can have site-level SNMP configuration defaults.
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Ecto.Association.NotLoaded
alias Towerops.Agents.AgentToken
@ -15,8 +13,6 @@ defmodule Towerops.Sites.Site do
alias Towerops.Organizations.Organization
alias Towerops.Sites.Site
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "sites" do
field :name, :string
field :description, :string

View file

@ -4,12 +4,8 @@ defmodule Towerops.Snmp.ArpEntry do
Stores ARP table entries discovered via ipNetToMediaTable (1.3.6.1.2.1.4.22).
"""
use Ecto.Schema
use Towerops.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "snmp_arp_entries" do
field :ip_address, :string
field :mac_address, :string

View file

@ -5,9 +5,7 @@ defmodule Towerops.Snmp.Device do
Stores system details (sysDescr, sysObjectID), vendor information,
and relationships to interfaces, sensors, and neighbors.
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Ecto.Association.NotLoaded
alias Towerops.Devices.Device, as: DeviceSchema
@ -20,8 +18,6 @@ defmodule Towerops.Snmp.Device do
alias Towerops.Snmp.Storage
alias Towerops.Snmp.Transceiver
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "snmp_devices" do
field :sys_descr, :string
field :sys_object_id, :string

View file

@ -6,15 +6,11 @@ defmodule Towerops.Snmp.EntityPhysical do
including chassis, modules, ports, power supplies, fans, and sensors.
Supports hierarchical parent/child relationships for physical containment.
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Ecto.Association.NotLoaded
alias Towerops.Snmp.Device
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "snmp_entity_physical" do
field :entity_index, :string
field :parent_index, :string

View file

@ -7,14 +7,12 @@ defmodule Towerops.Snmp.EntityPhysicalReading do
and administrative status (is this component enabled?) for power supplies, fans,
modules, and other physical entities.
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Snmp.Reading
alias Towerops.Snmp.EntityPhysical
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@timestamps_opts [type: :utc_datetime]
schema "snmp_entity_physical_readings" do
field :operational_status, :string
field :admin_status, :string
@ -22,7 +20,7 @@ defmodule Towerops.Snmp.EntityPhysicalReading do
belongs_to :entity_physical, EntityPhysical
timestamps(type: :utc_datetime)
timestamps()
end
@type t :: %__MODULE__{
@ -66,15 +64,12 @@ defmodule Towerops.Snmp.EntityPhysicalReading do
"""
def changeset(reading, attrs) do
reading
|> cast(attrs, [
:entity_physical_id,
:operational_status,
:admin_status,
:measured_at
])
|> validate_required([:entity_physical_id, :measured_at])
|> reading_changeset(attrs,
parent_key: :entity_physical_id,
data_fields: [:operational_status, :admin_status],
timestamp_field: :measured_at
)
|> validate_inclusion(:operational_status, @operational_statuses)
|> validate_inclusion(:admin_status, @admin_statuses)
|> foreign_key_constraint(:entity_physical_id)
end
end

View file

@ -5,9 +5,7 @@ defmodule Towerops.Snmp.Interface do
Stores interface details (name, description, type, speed, MAC address)
and has many interface statistics.
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Ecto.Association.NotLoaded
alias Towerops.Snmp.Device
@ -15,8 +13,6 @@ defmodule Towerops.Snmp.Interface do
alias Towerops.Snmp.IpAddress
alias Towerops.Snmp.Neighbor
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "snmp_interfaces" do
field :if_index, :integer
field :if_name, :string

View file

@ -5,14 +5,10 @@ defmodule Towerops.Snmp.InterfaceStat do
Stores interface counter values (octets, errors, discards) collected
during SNMP polling. Stored in TimescaleDB hypertable.
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Snmp.Reading
alias Towerops.Snmp.Interface
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "snmp_interface_stats" do
field :if_in_octets, :integer
field :if_out_octets, :integer
@ -25,7 +21,7 @@ defmodule Towerops.Snmp.InterfaceStat do
belongs_to :interface, Interface
timestamps(type: :utc_datetime, updated_at: false)
timestamps()
end
@type t :: %__MODULE__{
@ -45,19 +41,17 @@ defmodule Towerops.Snmp.InterfaceStat do
@doc false
def changeset(stat, attrs) do
stat
|> cast(attrs, [
:interface_id,
:if_in_octets,
:if_out_octets,
:if_in_errors,
:if_out_errors,
:if_in_discards,
:if_out_discards,
:checked_at,
:is_hc
])
|> validate_required([:interface_id, :checked_at])
|> foreign_key_constraint(:interface_id)
reading_changeset(stat, attrs,
parent_key: :interface_id,
data_fields: [
:if_in_octets,
:if_out_octets,
:if_in_errors,
:if_out_errors,
:if_in_discards,
:if_out_discards,
:is_hc
]
)
end
end

View file

@ -8,17 +8,13 @@ defmodule Towerops.Snmp.IpAddress do
Each interface can have multiple IP addresses (primary and secondary/alias).
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Ecto.Association.NotLoaded
alias Towerops.Snmp.Interface
@valid_ip_types ~w(ipv4 ipv6)
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "snmp_ip_addresses" do
field :ip_address, :string
field :subnet_mask, :string

View file

@ -6,12 +6,8 @@ defmodule Towerops.Snmp.MacAddress do
dot1dTpFdbTable (1.3.6.1.2.1.17.4.3) and Q-BRIDGE-MIB
dot1qTpFdbTable (1.3.6.1.2.1.17.7.1.2.2).
"""
use Ecto.Schema
use Towerops.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "snmp_mac_addresses" do
field :mac_address, :string
field :vlan_id, :integer

View file

@ -8,17 +8,13 @@ defmodule Towerops.Snmp.MemoryPool do
Tracks total and used memory for capacity monitoring.
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Ecto.Association.NotLoaded
alias Towerops.Snmp.Device
@valid_pool_types ~w(ram swap cache buffer other)
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "snmp_memory_pools" do
field :pool_index, :string
field :pool_name, :string

View file

@ -12,17 +12,13 @@ defmodule Towerops.Snmp.Mempool do
- "cisco_memory" - Cisco memory pools from CISCO-MEMORY-POOL-MIB
- "ucd_memory" - UCD-SNMP-MIB memory statistics
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Ecto.Association.NotLoaded
alias Towerops.Snmp.Device
@valid_mempool_types ~w(hr_memory cisco_memory ucd_memory)
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "snmp_mempools" do
field :mempool_index, :string
field :description, :string

View file

@ -5,14 +5,10 @@ defmodule Towerops.Snmp.MempoolReading do
Stores individual memory pool readings (used/total/free bytes, usage percent, timestamp)
collected during SNMP polling cycles.
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Snmp.Reading
alias Towerops.Snmp.Mempool
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "snmp_mempool_readings" do
field :used_bytes, :integer
field :total_bytes, :integer
@ -22,7 +18,7 @@ defmodule Towerops.Snmp.MempoolReading do
belongs_to :mempool, Mempool
timestamps(type: :utc_datetime, updated_at: false)
timestamps()
end
@type t :: %__MODULE__{
@ -39,9 +35,9 @@ defmodule Towerops.Snmp.MempoolReading do
@doc false
def changeset(reading, attrs) do
reading
|> cast(attrs, [:mempool_id, :used_bytes, :total_bytes, :free_bytes, :usage_percent, :checked_at])
|> validate_required([:mempool_id, :checked_at])
|> foreign_key_constraint(:mempool_id)
reading_changeset(reading, attrs,
parent_key: :mempool_id,
data_fields: [:used_bytes, :total_bytes, :free_bytes, :usage_percent]
)
end
end

View file

@ -5,12 +5,8 @@ defmodule Towerops.Snmp.Neighbor do
Stores neighbor information (chassis ID, port ID, system name)
and links to matched devices in the organization.
"""
use Ecto.Schema
use Towerops.Schema
import Ecto.Changeset
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "snmp_neighbors" do
field :protocol, :string
field :remote_chassis_id, :string

View file

@ -12,17 +12,13 @@ defmodule Towerops.Snmp.PhysicalEntity do
Entities form a parent/child hierarchy via contained_in relationships.
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Ecto.Association.NotLoaded
alias Towerops.Snmp.Device
@valid_entity_classes ~w(other unknown chassis backplane container power fan sensor module port stack cpu memory)
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "snmp_physical_entities" do
field :entity_index, :integer
field :entity_class, :string

View file

@ -1,14 +1,9 @@
defmodule Towerops.Snmp.PrinterSupply do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Towerops.Devices.Device
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "printer_supplies" do
belongs_to :snmp_device, Device

View file

@ -12,17 +12,13 @@ defmodule Towerops.Snmp.Processor do
- "cisco_cpu" - Cisco CPU from CISCO-PROCESS-MIB
- "ucd_cpu" - UCD-SNMP-MIB CPU statistics
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Ecto.Association.NotLoaded
alias Towerops.Snmp.Device
@valid_processor_types ~w(hr_processor cisco_cpu ucd_cpu)
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "snmp_processors" do
field :processor_index, :string
field :description, :string

View file

@ -5,14 +5,10 @@ defmodule Towerops.Snmp.ProcessorReading do
Stores individual processor load readings collected during SNMP polling cycles.
Similar to SensorReading but specifically for processor/CPU load data.
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Snmp.Reading
alias Towerops.Snmp.Processor
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "snmp_processor_readings" do
field :load_percent, :float
field :status, :string
@ -20,7 +16,7 @@ defmodule Towerops.Snmp.ProcessorReading do
belongs_to :processor, Processor
timestamps(type: :utc_datetime, updated_at: false)
timestamps()
end
@type t :: %__MODULE__{
@ -36,9 +32,11 @@ defmodule Towerops.Snmp.ProcessorReading do
@doc false
def changeset(reading, attrs) do
reading
|> cast(attrs, [:processor_id, :load_percent, :status, :checked_at])
|> validate_required([:processor_id, :status, :checked_at])
|> reading_changeset(attrs,
parent_key: :processor_id,
data_fields: [:load_percent, :status],
required_with: [:status]
)
|> validate_inclusion(:status, ["ok", "warning", "critical", "error"])
|> foreign_key_constraint(:processor_id)
end
end

View file

@ -0,0 +1,68 @@
defmodule Towerops.Snmp.Reading do
@moduledoc """
Provides common setup and a `reading_changeset/3` helper for
SNMP time-series reading schemas.
## Usage
defmodule Towerops.Snmp.ProcessorReading do
use Towerops.Snmp.Reading
schema "snmp_processor_readings" do
field :load_percent, :float
field :checked_at, :utc_datetime
belongs_to :processor, Towerops.Snmp.Processor
timestamps()
end
def changeset(reading, attrs) do
reading
|> reading_changeset(attrs,
parent_key: :processor_id,
data_fields: [:load_percent],
)
end
end
"""
import Ecto.Changeset
defmacro __using__(_opts) do
quote do
use Towerops.Schema
import Towerops.Snmp.Reading, only: [reading_changeset: 3]
@timestamps_opts [type: :utc_datetime, updated_at: false]
end
end
@doc """
Builds a changeset for a reading schema, handling common cast,
required validation, and foreign key constraint.
## Options
* `:parent_key` - (required) the foreign key field atom
* `:data_fields` - list of data field atoms to cast (default: `[]`)
* `:timestamp_field` - the timestamp field atom (default: `:checked_at`)
* `:required_with` - additional required field atoms beyond parent_key and
timestamp_field (default: `[]`)
"""
def reading_changeset(struct, attrs, opts) do
parent_key = opts[:parent_key] || raise ":parent_key required"
data_fields = opts[:data_fields] || []
timestamp_field = opts[:timestamp_field] || :checked_at
required_with = opts[:required_with] || []
all_fields = [parent_key | data_fields ++ [timestamp_field]]
required = [parent_key, timestamp_field | required_with]
struct
|> cast(attrs, all_fields)
|> validate_required(required)
|> foreign_key_constraint(parent_key)
end
end

View file

@ -5,16 +5,12 @@ defmodule Towerops.Snmp.Sensor do
Tracks temperature, fan speed, voltage, power, and other sensors
discovered via ENTITY-SENSOR-MIB or vendor-specific MIBs.
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Ecto.Association.NotLoaded
alias Towerops.Snmp.Device
alias Towerops.Snmp.SensorReading
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "snmp_sensors" do
field :sensor_type, :string
field :sensor_index, :string

View file

@ -5,14 +5,10 @@ defmodule Towerops.Snmp.SensorReading do
Stores individual sensor readings (value, status, timestamp) collected
during SNMP polling cycles. Stored in TimescaleDB hypertable.
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Snmp.Reading
alias Towerops.Snmp.Sensor
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "snmp_sensor_readings" do
field :value, :float
field :status, :string
@ -21,7 +17,7 @@ defmodule Towerops.Snmp.SensorReading do
belongs_to :sensor, Sensor
timestamps(type: :utc_datetime, updated_at: false)
timestamps()
end
@type t :: %__MODULE__{
@ -38,11 +34,13 @@ defmodule Towerops.Snmp.SensorReading do
@doc false
def changeset(reading, attrs) do
reading
|> cast(attrs, [:sensor_id, :value, :status, :state_descr, :checked_at])
|> validate_required([:sensor_id, :status, :checked_at])
|> reading_changeset(attrs,
parent_key: :sensor_id,
data_fields: [:value, :status, :state_descr],
required_with: [:status]
)
|> validate_inclusion(:status, ["ok", "warning", "critical", "error"])
|> validate_state_descr()
|> foreign_key_constraint(:sensor_id)
end
# Validate state_descr when present - ensure it's not empty and has reasonable length

View file

@ -12,17 +12,13 @@ defmodule Towerops.Snmp.StateSensor do
- critical: Failed or about to fail
- unknown: State not determined
"""
use Ecto.Schema
import Ecto.Changeset
use Towerops.Schema
alias Ecto.Association.NotLoaded
alias Towerops.Snmp.Device
@valid_statuses ~w(ok warning critical unknown)
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "snmp_state_sensors" do
field :sensor_index, :string
field :sensor_oid, :string

Some files were not shown because too many files have changed in this diff Show more