From a4e08b2aca6f282df9d5e42509c281856b665114 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Tue, 16 Jun 2026 15:29:22 -0500 Subject: [PATCH] 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) --- config/config.exs | 27 +-- config/dev.exs | 10 +- config/prod.exs | 10 +- config/runtime.exs | 13 +- lib/towerops/accounts/browser_session.ex | 7 +- lib/towerops/accounts/login_attempt.ex | 7 +- lib/towerops/accounts/policy_version.ex | 6 +- lib/towerops/accounts/user.ex | 6 +- lib/towerops/accounts/user_consent.ex | 6 +- lib/towerops/accounts/user_recovery_code.ex | 6 +- lib/towerops/accounts/user_token.ex | 4 +- lib/towerops/accounts/user_totp_device.ex | 6 +- lib/towerops/admin/audit_log.ex | 7 +- lib/towerops/agents/agent_assignment.ex | 6 +- lib/towerops/agents/agent_token.ex | 6 +- lib/towerops/alerts/alert.ex | 6 +- lib/towerops/alerts/alert_storm.ex | 6 +- lib/towerops/alerts/notification_digest.ex | 6 +- lib/towerops/alerts/site_outage.ex | 6 +- lib/towerops/api_tokens/api_token.ex | 7 +- lib/towerops/application.ex | 2 +- .../config_changes/config_change_event.ex | 7 +- lib/towerops/coverages/coverage.ex | 6 +- lib/towerops/devices/backup_request.ex | 7 +- lib/towerops/devices/device.ex | 6 +- .../devices/device_firmware_history.ex | 7 +- lib/towerops/devices/event.ex | 7 +- lib/towerops/devices/firmware_release.ex | 7 +- lib/towerops/devices/mikrotik_backup.ex | 7 +- lib/towerops/error_tracker_ignorer.ex | 2 +- lib/towerops/gaiia/account.ex | 22 +-- lib/towerops/gaiia/base_schema.ex | 56 ++++++ lib/towerops/gaiia/billing_subscription.ex | 22 +-- lib/towerops/gaiia/device_subscriber_link.ex | 7 +- lib/towerops/gaiia/inventory_item.ex | 21 +- lib/towerops/gaiia/network_site.ex | 21 +- lib/towerops/geoip/block.ex | 5 +- lib/towerops/integrations/integration.ex | 7 +- lib/towerops/lidar/sync_log.ex | 6 +- lib/towerops/lidar/tile.ex | 7 +- lib/towerops/llm/usage.ex | 6 +- lib/towerops/log_filter.ex | 46 ----- lib/towerops/log_filters.ex | 185 ++++++++++++++++++ lib/towerops/logger_filters.ex | 84 -------- .../maintenance/maintenance_window.ex | 6 +- lib/towerops/mikrotik/event.ex | 7 +- lib/towerops/mikrotik/ip_assignment.ex | 7 +- .../mobile_sessions/mobile_session.ex | 7 +- .../mobile_sessions/qr_login_token.ex | 7 +- lib/towerops/monitoring/check.ex | 7 +- lib/towerops/monitoring/check_result.ex | 7 +- lib/towerops/monitoring/monitoring_check.ex | 7 +- lib/towerops/on_call/changeset_helpers.ex | 25 +++ lib/towerops/on_call/escalation_policy.ex | 9 +- lib/towerops/on_call/escalation_rule.ex | 9 +- lib/towerops/on_call/escalation_target.ex | 9 +- lib/towerops/on_call/incident.ex | 9 +- lib/towerops/on_call/layer.ex | 9 +- lib/towerops/on_call/layer_member.ex | 9 +- lib/towerops/on_call/notification.ex | 9 +- lib/towerops/on_call/override.ex | 9 +- lib/towerops/on_call/schedule.ex | 10 +- lib/towerops/organizations/invitation.ex | 6 +- lib/towerops/organizations/membership.ex | 6 +- lib/towerops/organizations/organization.ex | 6 +- lib/towerops/preseem/access_point.ex | 7 +- lib/towerops/preseem/device_baseline.ex | 7 +- lib/towerops/preseem/fleet_profile.ex | 7 +- lib/towerops/preseem/insight.ex | 7 +- lib/towerops/preseem/subscriber_metric.ex | 7 +- lib/towerops/preseem/sync_log.ex | 33 +--- lib/towerops/profiles/device_oid.ex | 7 +- lib/towerops/profiles/device_profile.ex | 7 +- lib/towerops/profiles/sensor_oid.ex | 7 +- lib/towerops/reports/report.ex | 7 +- lib/towerops/schema.ex | 36 ++++ lib/towerops/security/ip_block.ex | 7 +- lib/towerops/security/ip_whitelist.ex | 7 +- lib/towerops/settings/application_setting.ex | 6 +- lib/towerops/sites/site.ex | 6 +- lib/towerops/snmp/arp_entry.ex | 6 +- lib/towerops/snmp/device.ex | 6 +- lib/towerops/snmp/entity_physical.ex | 6 +- lib/towerops/snmp/entity_physical_reading.ex | 23 +-- lib/towerops/snmp/interface.ex | 6 +- lib/towerops/snmp/interface_stat.ex | 34 ++-- lib/towerops/snmp/ip_address.ex | 6 +- lib/towerops/snmp/mac_address.ex | 6 +- lib/towerops/snmp/memory_pool.ex | 6 +- lib/towerops/snmp/mempool.ex | 6 +- lib/towerops/snmp/mempool_reading.ex | 16 +- lib/towerops/snmp/neighbor.ex | 6 +- lib/towerops/snmp/physical_entity.ex | 6 +- lib/towerops/snmp/printer_supply.ex | 7 +- lib/towerops/snmp/processor.ex | 6 +- lib/towerops/snmp/processor_reading.ex | 16 +- lib/towerops/snmp/reading.ex | 68 +++++++ lib/towerops/snmp/sensor.ex | 6 +- lib/towerops/snmp/sensor_reading.ex | 16 +- lib/towerops/snmp/state_sensor.ex | 6 +- lib/towerops/snmp/storage.ex | 6 +- lib/towerops/snmp/storage_reading.ex | 13 +- lib/towerops/snmp/transceiver.ex | 6 +- lib/towerops/snmp/transceiver_reading.ex | 27 +-- lib/towerops/snmp/vlan.ex | 6 +- lib/towerops/snmp/wireless_client.ex | 6 +- lib/towerops/snmp/wireless_client_reading.ex | 6 +- lib/towerops/snmp/wireless_neighbor_scan.ex | 7 +- lib/towerops/status_pages/status_incident.ex | 7 +- .../status_pages/status_page_component.ex | 7 +- .../status_pages/status_page_config.ex | 7 +- lib/towerops/sync_log.ex | 58 ++++++ lib/towerops/topology/device_link.ex | 6 +- lib/towerops/topology/device_link_evidence.ex | 6 +- lib/towerops/topology/device_neighbor.ex | 7 +- lib/towerops/uisp/sync_log.ex | 33 +--- lib/towerops/weather/alert.ex | 6 +- lib/towerops/weather/observation.ex | 6 +- .../controllers/api/v1/checks_controller.ex | 144 +++++--------- .../controllers/api/v1/devices_controller.ex | 104 ++++------ .../api/v1/escalation_policies_controller.ex | 131 +++++-------- .../api/v1/maintenance_windows_controller.ex | 97 ++++----- .../controllers/api/v1/resource_controller.ex | 131 +++++++++++++ .../api/v1/schedules_controller.ex | 140 +++++-------- .../controllers/api/v1/sites_controller.ex | 82 ++------ .../helpers/connection_helpers.ex | 40 ++++ .../live/org/integrations_live.ex | 26 +-- lib/towerops_web/live/org/settings_live.ex | 24 +-- lib/towerops_web/telemetry_filter.ex | 57 ------ test/towerops/log_filter_test.exs | 32 +-- test/towerops/logger_filters_test.exs | 32 +-- .../api/mobile_controller_test.exs | 13 +- .../live/onboarding_live_test.exs | 4 +- test/towerops_web/telemetry_filter_test.exs | 14 +- 134 files changed, 1082 insertions(+), 1516 deletions(-) create mode 100644 lib/towerops/gaiia/base_schema.ex delete mode 100644 lib/towerops/log_filter.ex create mode 100644 lib/towerops/log_filters.ex delete mode 100644 lib/towerops/logger_filters.ex create mode 100644 lib/towerops/on_call/changeset_helpers.ex create mode 100644 lib/towerops/schema.ex create mode 100644 lib/towerops/snmp/reading.ex create mode 100644 lib/towerops/sync_log.ex create mode 100644 lib/towerops_web/controllers/api/v1/resource_controller.ex create mode 100644 lib/towerops_web/helpers/connection_helpers.ex delete mode 100644 lib/towerops_web/telemetry_filter.ex diff --git a/config/config.exs b/config/config.exs index a8d0afe8..665dca4d 100644 --- a/config/config.exs +++ b/config/config.exs @@ -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 diff --git a/config/dev.exs b/config/dev.exs index 7b7512d8..a79a8d03 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -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" diff --git a/config/prod.exs b/config/prod.exs index da7abea9..5d601cf9 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -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, [] } ] diff --git a/config/runtime.exs b/config/runtime.exs index 4407a9b2..5c9f6bb4 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -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 diff --git a/lib/towerops/accounts/browser_session.ex b/lib/towerops/accounts/browser_session.ex index d4e0a8db..17859c34 100644 --- a/lib/towerops/accounts/browser_session.ex +++ b/lib/towerops/accounts/browser_session.ex @@ -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 diff --git a/lib/towerops/accounts/login_attempt.ex b/lib/towerops/accounts/login_attempt.ex index 6fcfe35b..2b51070d 100644 --- a/lib/towerops/accounts/login_attempt.ex +++ b/lib/towerops/accounts/login_attempt.ex @@ -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) diff --git a/lib/towerops/accounts/policy_version.ex b/lib/towerops/accounts/policy_version.ex index 9c1f24eb..1b326b55 100644 --- a/lib/towerops/accounts/policy_version.ex +++ b/lib/towerops/accounts/policy_version.ex @@ -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 diff --git a/lib/towerops/accounts/user.ex b/lib/towerops/accounts/user.ex index 915016b5..11799c59 100644 --- a/lib/towerops/accounts/user.ex +++ b/lib/towerops/accounts/user.ex @@ -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 diff --git a/lib/towerops/accounts/user_consent.ex b/lib/towerops/accounts/user_consent.ex index afb0f168..4a81d414 100644 --- a/lib/towerops/accounts/user_consent.ex +++ b/lib/towerops/accounts/user_consent.ex @@ -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 diff --git a/lib/towerops/accounts/user_recovery_code.ex b/lib/towerops/accounts/user_recovery_code.ex index 2b0209e4..05e0bb35 100644 --- a/lib/towerops/accounts/user_recovery_code.ex +++ b/lib/towerops/accounts/user_recovery_code.ex @@ -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 diff --git a/lib/towerops/accounts/user_token.ex b/lib/towerops/accounts/user_token.ex index 67b3172f..644ae21d 100644 --- a/lib/towerops/accounts/user_token.ex +++ b/lib/towerops/accounts/user_token.ex @@ -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 diff --git a/lib/towerops/accounts/user_totp_device.ex b/lib/towerops/accounts/user_totp_device.ex index f2cd0634..e7cac833 100644 --- a/lib/towerops/accounts/user_totp_device.ex +++ b/lib/towerops/accounts/user_totp_device.ex @@ -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 diff --git a/lib/towerops/admin/audit_log.ex b/lib/towerops/admin/audit_log.ex index e1b6cdd8..b20b806c 100644 --- a/lib/towerops/admin/audit_log.ex +++ b/lib/towerops/admin/audit_log.ex @@ -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 diff --git a/lib/towerops/agents/agent_assignment.ex b/lib/towerops/agents/agent_assignment.ex index 5041caf4..c45b423e 100644 --- a/lib/towerops/agents/agent_assignment.ex +++ b/lib/towerops/agents/agent_assignment.ex @@ -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 diff --git a/lib/towerops/agents/agent_token.ex b/lib/towerops/agents/agent_token.ex index 0aaed9d1..eb65c3b1 100644 --- a/lib/towerops/agents/agent_token.ex +++ b/lib/towerops/agents/agent_token.ex @@ -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 diff --git a/lib/towerops/alerts/alert.ex b/lib/towerops/alerts/alert.ex index 1c89e90f..315f8866 100644 --- a/lib/towerops/alerts/alert.ex +++ b/lib/towerops/alerts/alert.ex @@ -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. diff --git a/lib/towerops/alerts/alert_storm.ex b/lib/towerops/alerts/alert_storm.ex index ec5e4ec6..a97acede 100644 --- a/lib/towerops/alerts/alert_storm.ex +++ b/lib/towerops/alerts/alert_storm.ex @@ -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 diff --git a/lib/towerops/alerts/notification_digest.ex b/lib/towerops/alerts/notification_digest.ex index 3b56e208..c9fb9f4c 100644 --- a/lib/towerops/alerts/notification_digest.ex +++ b/lib/towerops/alerts/notification_digest.ex @@ -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 diff --git a/lib/towerops/alerts/site_outage.ex b/lib/towerops/alerts/site_outage.ex index 211a22d9..133443ba 100644 --- a/lib/towerops/alerts/site_outage.ex +++ b/lib/towerops/alerts/site_outage.ex @@ -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 diff --git a/lib/towerops/api_tokens/api_token.ex b/lib/towerops/api_tokens/api_token.ex index 24546ce8..6468c5b0 100644 --- a/lib/towerops/api_tokens/api_token.ex +++ b/lib/towerops/api_tokens/api_token.ex @@ -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 diff --git a/lib/towerops/application.ex b/lib/towerops/application.ex index b3c82903..f4fd069a 100644 --- a/lib/towerops/application.ex +++ b/lib/towerops/application.ex @@ -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 diff --git a/lib/towerops/config_changes/config_change_event.ex b/lib/towerops/config_changes/config_change_event.ex index d2fbbf66..3b8e9074 100644 --- a/lib/towerops/config_changes/config_change_event.ex +++ b/lib/towerops/config_changes/config_change_event.ex @@ -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 diff --git a/lib/towerops/coverages/coverage.ex b/lib/towerops/coverages/coverage.ex index 7e1eb073..2f2e28d9 100644 --- a/lib/towerops/coverages/coverage.ex +++ b/lib/towerops/coverages/coverage.ex @@ -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 diff --git a/lib/towerops/devices/backup_request.ex b/lib/towerops/devices/backup_request.ex index 32c1dbb9..c70fa923 100644 --- a/lib/towerops/devices/backup_request.ex +++ b/lib/towerops/devices/backup_request.ex @@ -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 diff --git a/lib/towerops/devices/device.ex b/lib/towerops/devices/device.ex index fedc0952..ee600fac 100644 --- a/lib/towerops/devices/device.ex +++ b/lib/towerops/devices/device.ex @@ -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 diff --git a/lib/towerops/devices/device_firmware_history.ex b/lib/towerops/devices/device_firmware_history.ex index 0de8294f..b8d08638 100644 --- a/lib/towerops/devices/device_firmware_history.ex +++ b/lib/towerops/devices/device_firmware_history.ex @@ -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 diff --git a/lib/towerops/devices/event.ex b/lib/towerops/devices/event.ex index 83ad4e7a..5b9487c2 100644 --- a/lib/towerops/devices/event.ex +++ b/lib/towerops/devices/event.ex @@ -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 diff --git a/lib/towerops/devices/firmware_release.ex b/lib/towerops/devices/firmware_release.ex index 7dda37a9..fc0572d5 100644 --- a/lib/towerops/devices/firmware_release.ex +++ b/lib/towerops/devices/firmware_release.ex @@ -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 diff --git a/lib/towerops/devices/mikrotik_backup.ex b/lib/towerops/devices/mikrotik_backup.ex index 05b9c9f5..c75538ee 100644 --- a/lib/towerops/devices/mikrotik_backup.ex +++ b/lib/towerops/devices/mikrotik_backup.ex @@ -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 diff --git a/lib/towerops/error_tracker_ignorer.ex b/lib/towerops/error_tracker_ignorer.ex index c1c3e2ce..ad92dd86 100644 --- a/lib/towerops/error_tracker_ignorer.ex +++ b/lib/towerops/error_tracker_ignorer.ex @@ -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 diff --git a/lib/towerops/gaiia/account.ex b/lib/towerops/gaiia/account.ex index 6a37ce32..3cf02cfa 100644 --- a/lib/towerops/gaiia/account.ex +++ b/lib/towerops/gaiia/account.ex @@ -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 diff --git a/lib/towerops/gaiia/base_schema.ex b/lib/towerops/gaiia/base_schema.ex new file mode 100644 index 00000000..d0ea7f41 --- /dev/null +++ b/lib/towerops/gaiia/base_schema.ex @@ -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 diff --git a/lib/towerops/gaiia/billing_subscription.ex b/lib/towerops/gaiia/billing_subscription.ex index 70e8e45f..cca5581a 100644 --- a/lib/towerops/gaiia/billing_subscription.ex +++ b/lib/towerops/gaiia/billing_subscription.ex @@ -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 diff --git a/lib/towerops/gaiia/device_subscriber_link.ex b/lib/towerops/gaiia/device_subscriber_link.ex index 3cf1376f..5ea8bf9b 100644 --- a/lib/towerops/gaiia/device_subscriber_link.ex +++ b/lib/towerops/gaiia/device_subscriber_link.ex @@ -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 diff --git a/lib/towerops/gaiia/inventory_item.ex b/lib/towerops/gaiia/inventory_item.ex index b47b508d..18ee3b85 100644 --- a/lib/towerops/gaiia/inventory_item.ex +++ b/lib/towerops/gaiia/inventory_item.ex @@ -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 diff --git a/lib/towerops/gaiia/network_site.ex b/lib/towerops/gaiia/network_site.ex index 1a564e9f..a69c7c0b 100644 --- a/lib/towerops/gaiia/network_site.ex +++ b/lib/towerops/gaiia/network_site.ex @@ -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 diff --git a/lib/towerops/geoip/block.ex b/lib/towerops/geoip/block.ex index 3fa04f5b..78573261 100644 --- a/lib/towerops/geoip/block.ex +++ b/lib/towerops/geoip/block.ex @@ -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 diff --git a/lib/towerops/integrations/integration.ex b/lib/towerops/integrations/integration.ex index 90109ae8..179ac05a 100644 --- a/lib/towerops/integrations/integration.ex +++ b/lib/towerops/integrations/integration.ex @@ -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) diff --git a/lib/towerops/lidar/sync_log.ex b/lib/towerops/lidar/sync_log.ex index ac3ac967..244460df 100644 --- a/lib/towerops/lidar/sync_log.ex +++ b/lib/towerops/lidar/sync_log.ex @@ -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) diff --git a/lib/towerops/lidar/tile.ex b/lib/towerops/lidar/tile.ex index 8720e4aa..0bab907f 100644 --- a/lib/towerops/lidar/tile.ex +++ b/lib/towerops/lidar/tile.ex @@ -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) diff --git a/lib/towerops/llm/usage.ex b/lib/towerops/llm/usage.ex index a10190c6..66bd664e 100644 --- a/lib/towerops/llm/usage.ex +++ b/lib/towerops/llm/usage.ex @@ -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 diff --git a/lib/towerops/log_filter.ex b/lib/towerops/log_filter.ex deleted file mode 100644 index c7e08a66..00000000 --- a/lib/towerops/log_filter.ex +++ /dev/null @@ -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 diff --git a/lib/towerops/log_filters.ex b/lib/towerops/log_filters.ex new file mode 100644 index 00000000..6bc9d6e6 --- /dev/null +++ b/lib/towerops/log_filters.ex @@ -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 diff --git a/lib/towerops/logger_filters.ex b/lib/towerops/logger_filters.ex deleted file mode 100644 index a29b9cb7..00000000 --- a/lib/towerops/logger_filters.ex +++ /dev/null @@ -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 diff --git a/lib/towerops/maintenance/maintenance_window.ex b/lib/towerops/maintenance/maintenance_window.ex index 7ab4a78f..e0962d7f 100644 --- a/lib/towerops/maintenance/maintenance_window.ex +++ b/lib/towerops/maintenance/maintenance_window.ex @@ -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 diff --git a/lib/towerops/mikrotik/event.ex b/lib/towerops/mikrotik/event.ex index 0070ea26..9af0e82c 100644 --- a/lib/towerops/mikrotik/event.ex +++ b/lib/towerops/mikrotik/event.ex @@ -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) diff --git a/lib/towerops/mikrotik/ip_assignment.ex b/lib/towerops/mikrotik/ip_assignment.ex index a2e5601a..6a18d2f4 100644 --- a/lib/towerops/mikrotik/ip_assignment.ex +++ b/lib/towerops/mikrotik/ip_assignment.ex @@ -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) diff --git a/lib/towerops/mobile_sessions/mobile_session.ex b/lib/towerops/mobile_sessions/mobile_session.ex index 6570af0c..5b08b422 100644 --- a/lib/towerops/mobile_sessions/mobile_session.ex +++ b/lib/towerops/mobile_sessions/mobile_session.ex @@ -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 diff --git a/lib/towerops/mobile_sessions/qr_login_token.ex b/lib/towerops/mobile_sessions/qr_login_token.ex index 46a1c33a..9bcffa71 100644 --- a/lib/towerops/mobile_sessions/qr_login_token.ex +++ b/lib/towerops/mobile_sessions/qr_login_token.ex @@ -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 diff --git a/lib/towerops/monitoring/check.ex b/lib/towerops/monitoring/check.ex index 3943b996..17f58811 100644 --- a/lib/towerops/monitoring/check.ex +++ b/lib/towerops/monitoring/check.ex @@ -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 diff --git a/lib/towerops/monitoring/check_result.ex b/lib/towerops/monitoring/check_result.ex index 66c5b35d..66e63bc3 100644 --- a/lib/towerops/monitoring/check_result.ex +++ b/lib/towerops/monitoring/check_result.ex @@ -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 diff --git a/lib/towerops/monitoring/monitoring_check.ex b/lib/towerops/monitoring/monitoring_check.ex index f226a11b..395d8e4b 100644 --- a/lib/towerops/monitoring/monitoring_check.ex +++ b/lib/towerops/monitoring/monitoring_check.ex @@ -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 diff --git a/lib/towerops/on_call/changeset_helpers.ex b/lib/towerops/on_call/changeset_helpers.ex new file mode 100644 index 00000000..815bb103 --- /dev/null +++ b/lib/towerops/on_call/changeset_helpers.ex @@ -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 diff --git a/lib/towerops/on_call/escalation_policy.ex b/lib/towerops/on_call/escalation_policy.ex index 5847f6d1..087a85f8 100644 --- a/lib/towerops/on_call/escalation_policy.ex +++ b/lib/towerops/on_call/escalation_policy.ex @@ -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) diff --git a/lib/towerops/on_call/escalation_rule.ex b/lib/towerops/on_call/escalation_rule.ex index 2eb03644..45937975 100644 --- a/lib/towerops/on_call/escalation_rule.ex +++ b/lib/towerops/on_call/escalation_rule.ex @@ -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) diff --git a/lib/towerops/on_call/escalation_target.ex b/lib/towerops/on_call/escalation_target.ex index ab1890a9..9e123d48 100644 --- a/lib/towerops/on_call/escalation_target.ex +++ b/lib/towerops/on_call/escalation_target.ex @@ -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) diff --git a/lib/towerops/on_call/incident.ex b/lib/towerops/on_call/incident.ex index f8d1bbd1..9de019c3 100644 --- a/lib/towerops/on_call/incident.ex +++ b/lib/towerops/on_call/incident.ex @@ -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) diff --git a/lib/towerops/on_call/layer.ex b/lib/towerops/on_call/layer.ex index 56160dbb..9c6a96a3 100644 --- a/lib/towerops/on_call/layer.ex +++ b/lib/towerops/on_call/layer.ex @@ -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) diff --git a/lib/towerops/on_call/layer_member.ex b/lib/towerops/on_call/layer_member.ex index fd64560c..c2f87ed9 100644 --- a/lib/towerops/on_call/layer_member.ex +++ b/lib/towerops/on_call/layer_member.ex @@ -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) diff --git a/lib/towerops/on_call/notification.ex b/lib/towerops/on_call/notification.ex index 5e6700db..c24cd098 100644 --- a/lib/towerops/on_call/notification.ex +++ b/lib/towerops/on_call/notification.ex @@ -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) diff --git a/lib/towerops/on_call/override.ex b/lib/towerops/on_call/override.ex index 9bbdcab0..391cab06 100644 --- a/lib/towerops/on_call/override.ex +++ b/lib/towerops/on_call/override.ex @@ -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) diff --git a/lib/towerops/on_call/schedule.ex b/lib/towerops/on_call/schedule.ex index 7623cbd0..6736bdd2 100644 --- a/lib/towerops/on_call/schedule.ex +++ b/lib/towerops/on_call/schedule.ex @@ -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 diff --git a/lib/towerops/organizations/invitation.ex b/lib/towerops/organizations/invitation.ex index 061b6f95..39edf0df 100644 --- a/lib/towerops/organizations/invitation.ex +++ b/lib/towerops/organizations/invitation.ex @@ -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] diff --git a/lib/towerops/organizations/membership.ex b/lib/towerops/organizations/membership.ex index 74155f0f..54e5566b 100644 --- a/lib/towerops/organizations/membership.ex +++ b/lib/towerops/organizations/membership.ex @@ -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 diff --git a/lib/towerops/organizations/organization.ex b/lib/towerops/organizations/organization.ex index 15de4b9a..453290f6 100644 --- a/lib/towerops/organizations/organization.ex +++ b/lib/towerops/organizations/organization.ex @@ -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 diff --git a/lib/towerops/preseem/access_point.ex b/lib/towerops/preseem/access_point.ex index 4a56cea0..67a77c0a 100644 --- a/lib/towerops/preseem/access_point.ex +++ b/lib/towerops/preseem/access_point.ex @@ -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) diff --git a/lib/towerops/preseem/device_baseline.ex b/lib/towerops/preseem/device_baseline.ex index 62d001ab..3f2b345b 100644 --- a/lib/towerops/preseem/device_baseline.ex +++ b/lib/towerops/preseem/device_baseline.ex @@ -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) diff --git a/lib/towerops/preseem/fleet_profile.ex b/lib/towerops/preseem/fleet_profile.ex index fe6c1a81..baabb879 100644 --- a/lib/towerops/preseem/fleet_profile.ex +++ b/lib/towerops/preseem/fleet_profile.ex @@ -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 diff --git a/lib/towerops/preseem/insight.ex b/lib/towerops/preseem/insight.ex index 2309e9a3..abab807d 100644 --- a/lib/towerops/preseem/insight.ex +++ b/lib/towerops/preseem/insight.ex @@ -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) diff --git a/lib/towerops/preseem/subscriber_metric.ex b/lib/towerops/preseem/subscriber_metric.ex index b00f9f3f..9cae1b6c 100644 --- a/lib/towerops/preseem/subscriber_metric.ex +++ b/lib/towerops/preseem/subscriber_metric.ex @@ -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 diff --git a/lib/towerops/preseem/sync_log.ex b/lib/towerops/preseem/sync_log.ex index ca2dee7b..19a6249b 100644 --- a/lib/towerops/preseem/sync_log.ex +++ b/lib/towerops/preseem/sync_log.ex @@ -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 diff --git a/lib/towerops/profiles/device_oid.ex b/lib/towerops/profiles/device_oid.ex index 9ee2ed2f..953d8ffa 100644 --- a/lib/towerops/profiles/device_oid.ex +++ b/lib/towerops/profiles/device_oid.ex @@ -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 diff --git a/lib/towerops/profiles/device_profile.ex b/lib/towerops/profiles/device_profile.ex index f77e7fe0..d8c6e10e 100644 --- a/lib/towerops/profiles/device_profile.ex +++ b/lib/towerops/profiles/device_profile.ex @@ -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 diff --git a/lib/towerops/profiles/sensor_oid.ex b/lib/towerops/profiles/sensor_oid.ex index 3bdb5458..9b35de15 100644 --- a/lib/towerops/profiles/sensor_oid.ex +++ b/lib/towerops/profiles/sensor_oid.ex @@ -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 diff --git a/lib/towerops/reports/report.ex b/lib/towerops/reports/report.ex index 96a12655..9b5db681 100644 --- a/lib/towerops/reports/report.ex +++ b/lib/towerops/reports/report.ex @@ -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) diff --git a/lib/towerops/schema.ex b/lib/towerops/schema.ex new file mode 100644 index 00000000..ec35f420 --- /dev/null +++ b/lib/towerops/schema.ex @@ -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 diff --git a/lib/towerops/security/ip_block.ex b/lib/towerops/security/ip_block.ex index 4e079b25..dbe82668 100644 --- a/lib/towerops/security/ip_block.ex +++ b/lib/towerops/security/ip_block.ex @@ -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 diff --git a/lib/towerops/security/ip_whitelist.ex b/lib/towerops/security/ip_whitelist.ex index 8cc93d11..1df88422 100644 --- a/lib/towerops/security/ip_whitelist.ex +++ b/lib/towerops/security/ip_whitelist.ex @@ -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 diff --git a/lib/towerops/settings/application_setting.ex b/lib/towerops/settings/application_setting.ex index 88079c5d..ee2d1dde 100644 --- a/lib/towerops/settings/application_setting.ex +++ b/lib/towerops/settings/application_setting.ex @@ -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 diff --git a/lib/towerops/sites/site.ex b/lib/towerops/sites/site.ex index bfa358ad..4ff939bb 100644 --- a/lib/towerops/sites/site.ex +++ b/lib/towerops/sites/site.ex @@ -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 diff --git a/lib/towerops/snmp/arp_entry.ex b/lib/towerops/snmp/arp_entry.ex index 7a01c5d4..9bd97afb 100644 --- a/lib/towerops/snmp/arp_entry.ex +++ b/lib/towerops/snmp/arp_entry.ex @@ -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 diff --git a/lib/towerops/snmp/device.ex b/lib/towerops/snmp/device.ex index e82397bb..4d3c8d2e 100644 --- a/lib/towerops/snmp/device.ex +++ b/lib/towerops/snmp/device.ex @@ -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 diff --git a/lib/towerops/snmp/entity_physical.ex b/lib/towerops/snmp/entity_physical.ex index b3414098..a78ac2a6 100644 --- a/lib/towerops/snmp/entity_physical.ex +++ b/lib/towerops/snmp/entity_physical.ex @@ -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 diff --git a/lib/towerops/snmp/entity_physical_reading.ex b/lib/towerops/snmp/entity_physical_reading.ex index 22555925..0eccb377 100644 --- a/lib/towerops/snmp/entity_physical_reading.ex +++ b/lib/towerops/snmp/entity_physical_reading.ex @@ -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 diff --git a/lib/towerops/snmp/interface.ex b/lib/towerops/snmp/interface.ex index 3e65e536..23751250 100644 --- a/lib/towerops/snmp/interface.ex +++ b/lib/towerops/snmp/interface.ex @@ -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 diff --git a/lib/towerops/snmp/interface_stat.ex b/lib/towerops/snmp/interface_stat.ex index 88e749a6..0aad8460 100644 --- a/lib/towerops/snmp/interface_stat.ex +++ b/lib/towerops/snmp/interface_stat.ex @@ -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 diff --git a/lib/towerops/snmp/ip_address.ex b/lib/towerops/snmp/ip_address.ex index 58a19dcb..5648c5b4 100644 --- a/lib/towerops/snmp/ip_address.ex +++ b/lib/towerops/snmp/ip_address.ex @@ -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 diff --git a/lib/towerops/snmp/mac_address.ex b/lib/towerops/snmp/mac_address.ex index e69eeab0..8aa70262 100644 --- a/lib/towerops/snmp/mac_address.ex +++ b/lib/towerops/snmp/mac_address.ex @@ -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 diff --git a/lib/towerops/snmp/memory_pool.ex b/lib/towerops/snmp/memory_pool.ex index ddac0c4e..5a1b8c81 100644 --- a/lib/towerops/snmp/memory_pool.ex +++ b/lib/towerops/snmp/memory_pool.ex @@ -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 diff --git a/lib/towerops/snmp/mempool.ex b/lib/towerops/snmp/mempool.ex index 964edb6f..15b51af9 100644 --- a/lib/towerops/snmp/mempool.ex +++ b/lib/towerops/snmp/mempool.ex @@ -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 diff --git a/lib/towerops/snmp/mempool_reading.ex b/lib/towerops/snmp/mempool_reading.ex index 8f348631..9289d6a9 100644 --- a/lib/towerops/snmp/mempool_reading.ex +++ b/lib/towerops/snmp/mempool_reading.ex @@ -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 diff --git a/lib/towerops/snmp/neighbor.ex b/lib/towerops/snmp/neighbor.ex index 06ca7bea..51e1b4bd 100644 --- a/lib/towerops/snmp/neighbor.ex +++ b/lib/towerops/snmp/neighbor.ex @@ -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 diff --git a/lib/towerops/snmp/physical_entity.ex b/lib/towerops/snmp/physical_entity.ex index bdcd56ab..2da721e3 100644 --- a/lib/towerops/snmp/physical_entity.ex +++ b/lib/towerops/snmp/physical_entity.ex @@ -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 diff --git a/lib/towerops/snmp/printer_supply.ex b/lib/towerops/snmp/printer_supply.ex index f02f2f73..002884e6 100644 --- a/lib/towerops/snmp/printer_supply.ex +++ b/lib/towerops/snmp/printer_supply.ex @@ -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 diff --git a/lib/towerops/snmp/processor.ex b/lib/towerops/snmp/processor.ex index 34e7586e..de190bc5 100644 --- a/lib/towerops/snmp/processor.ex +++ b/lib/towerops/snmp/processor.ex @@ -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 diff --git a/lib/towerops/snmp/processor_reading.ex b/lib/towerops/snmp/processor_reading.ex index e38b83ae..947199d1 100644 --- a/lib/towerops/snmp/processor_reading.ex +++ b/lib/towerops/snmp/processor_reading.ex @@ -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 diff --git a/lib/towerops/snmp/reading.ex b/lib/towerops/snmp/reading.ex new file mode 100644 index 00000000..000beee9 --- /dev/null +++ b/lib/towerops/snmp/reading.ex @@ -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 diff --git a/lib/towerops/snmp/sensor.ex b/lib/towerops/snmp/sensor.ex index 5cda0190..e4e52384 100644 --- a/lib/towerops/snmp/sensor.ex +++ b/lib/towerops/snmp/sensor.ex @@ -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 diff --git a/lib/towerops/snmp/sensor_reading.ex b/lib/towerops/snmp/sensor_reading.ex index 4df599c9..ff317f1c 100644 --- a/lib/towerops/snmp/sensor_reading.ex +++ b/lib/towerops/snmp/sensor_reading.ex @@ -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 diff --git a/lib/towerops/snmp/state_sensor.ex b/lib/towerops/snmp/state_sensor.ex index 4aaa0830..55c03edc 100644 --- a/lib/towerops/snmp/state_sensor.ex +++ b/lib/towerops/snmp/state_sensor.ex @@ -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 diff --git a/lib/towerops/snmp/storage.ex b/lib/towerops/snmp/storage.ex index 19d49ec7..6a170d51 100644 --- a/lib/towerops/snmp/storage.ex +++ b/lib/towerops/snmp/storage.ex @@ -10,17 +10,13 @@ defmodule Towerops.Snmp.Storage do Tracks total and used space for capacity monitoring. """ - use Ecto.Schema - - import Ecto.Changeset + use Towerops.Schema alias Ecto.Association.NotLoaded alias Towerops.Snmp.Device @valid_storage_types ~w(other ram virtual_memory fixed_disk removable_disk floppy_disk compact_disc ram_disk flash_memory network_disk) - @primary_key {:id, :binary_id, autogenerate: true} - @foreign_key_type :binary_id schema "snmp_storage" do field :storage_index, :integer field :storage_type, :string diff --git a/lib/towerops/snmp/storage_reading.ex b/lib/towerops/snmp/storage_reading.ex index e63fe8b4..029074e4 100644 --- a/lib/towerops/snmp/storage_reading.ex +++ b/lib/towerops/snmp/storage_reading.ex @@ -5,14 +5,10 @@ defmodule Towerops.Snmp.StorageReading do Stores individual storage readings (used/total bytes, usage percent, timestamp) collected during SNMP polling cycles. """ - use Ecto.Schema - - import Ecto.Changeset + use Towerops.Snmp.Reading alias Towerops.Snmp.Storage - @primary_key {:id, :binary_id, autogenerate: true} - @foreign_key_type :binary_id schema "snmp_storage_readings" do field :used_bytes, :integer field :total_bytes, :integer @@ -21,7 +17,7 @@ defmodule Towerops.Snmp.StorageReading do belongs_to :storage, Storage - timestamps(type: :utc_datetime, updated_at: false) + timestamps() end @type t :: %__MODULE__{ @@ -37,9 +33,6 @@ defmodule Towerops.Snmp.StorageReading do @doc false def changeset(reading, attrs) do - reading - |> cast(attrs, [:storage_id, :used_bytes, :total_bytes, :usage_percent, :checked_at]) - |> validate_required([:storage_id, :checked_at]) - |> foreign_key_constraint(:storage_id) + reading_changeset(reading, attrs, parent_key: :storage_id, data_fields: [:used_bytes, :total_bytes, :usage_percent]) end end diff --git a/lib/towerops/snmp/transceiver.ex b/lib/towerops/snmp/transceiver.ex index 8f31e65e..ef264448 100644 --- a/lib/towerops/snmp/transceiver.ex +++ b/lib/towerops/snmp/transceiver.ex @@ -6,17 +6,13 @@ defmodule Towerops.Snmp.Transceiver do Includes vendor details, optical specifications, and DOM (Digital Optical Monitoring) capabilities. Can be linked to entity_physical for hardware inventory integration. """ - use Ecto.Schema - - import Ecto.Changeset + use Towerops.Schema alias Ecto.Association.NotLoaded alias Towerops.Snmp.Device alias Towerops.Snmp.EntityPhysical alias Towerops.Snmp.TransceiverReading - @primary_key {:id, :binary_id, autogenerate: true} - @foreign_key_type :binary_id schema "snmp_transceivers" do field :port_index, :string field :transceiver_type, :string diff --git a/lib/towerops/snmp/transceiver_reading.ex b/lib/towerops/snmp/transceiver_reading.ex index 5b7df119..88359a27 100644 --- a/lib/towerops/snmp/transceiver_reading.ex +++ b/lib/towerops/snmp/transceiver_reading.ex @@ -6,15 +6,13 @@ defmodule Towerops.Snmp.TransceiverReading do Includes standard DOM parameters: Rx/Tx power, bias current, temperature, voltage. Readings are typically polled every 60 seconds for transceivers with DOM support. """ - use Ecto.Schema - - import Ecto.Changeset + use Towerops.Snmp.Reading alias Ecto.Association.NotLoaded alias Towerops.Snmp.Transceiver - @primary_key {:id, :binary_id, autogenerate: true} - @foreign_key_type :binary_id + @timestamps_opts [type: :utc_datetime] + schema "snmp_transceiver_readings" do field :rx_power_dbm, :float field :tx_power_dbm, :float @@ -25,7 +23,7 @@ defmodule Towerops.Snmp.TransceiverReading do belongs_to :transceiver, Transceiver - timestamps(type: :utc_datetime) + timestamps() end @type t :: %__MODULE__{ @@ -44,17 +42,10 @@ defmodule Towerops.Snmp.TransceiverReading do @doc false def changeset(reading, attrs) do - reading - |> cast(attrs, [ - :transceiver_id, - :rx_power_dbm, - :tx_power_dbm, - :bias_current_ma, - :temperature_celsius, - :voltage_v, - :measured_at - ]) - |> validate_required([:transceiver_id, :measured_at]) - |> foreign_key_constraint(:transceiver_id) + reading_changeset(reading, attrs, + parent_key: :transceiver_id, + data_fields: [:rx_power_dbm, :tx_power_dbm, :bias_current_ma, :temperature_celsius, :voltage_v], + timestamp_field: :measured_at + ) end end diff --git a/lib/towerops/snmp/vlan.ex b/lib/towerops/snmp/vlan.ex index 3250ae30..d4429783 100644 --- a/lib/towerops/snmp/vlan.ex +++ b/lib/towerops/snmp/vlan.ex @@ -10,17 +10,13 @@ defmodule Towerops.Snmp.Vlan do - active: VLAN is operational - suspended: VLAN is administratively suspended """ - use Ecto.Schema - - import Ecto.Changeset + use Towerops.Schema alias Ecto.Association.NotLoaded alias Towerops.Snmp.Device @valid_statuses ~w(active suspended) - @primary_key {:id, :binary_id, autogenerate: true} - @foreign_key_type :binary_id schema "snmp_vlans" do field :vlan_id, :integer field :vlan_name, :string diff --git a/lib/towerops/snmp/wireless_client.ex b/lib/towerops/snmp/wireless_client.ex index 601086e5..e64260e1 100644 --- a/lib/towerops/snmp/wireless_client.ex +++ b/lib/towerops/snmp/wireless_client.ex @@ -5,14 +5,10 @@ defmodule Towerops.Snmp.WirelessClient do Stores the connected subscriber radios (CPEs/SMs) discovered via vendor-specific SNMP wireless registration/client tables. """ - 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 "wireless_clients" do field :mac_address, :string field :ip_address, :string diff --git a/lib/towerops/snmp/wireless_client_reading.ex b/lib/towerops/snmp/wireless_client_reading.ex index f4a64f99..e429f174 100644 --- a/lib/towerops/snmp/wireless_client_reading.ex +++ b/lib/towerops/snmp/wireless_client_reading.ex @@ -6,17 +6,13 @@ defmodule Towerops.Snmp.WirelessClientReading do collected during SNMP polling cycles. Stored in TimescaleDB hypertable for historical trend analysis and proactive alerting. """ - use Ecto.Schema - - import Ecto.Changeset + use Towerops.Schema alias Ecto.Association.NotLoaded alias Towerops.Organizations.Organization alias Towerops.Snmp.Device alias Towerops.Snmp.WirelessClient - @primary_key {:id, :binary_id, autogenerate: true} - @foreign_key_type :binary_id schema "wireless_client_readings" do field :checked_at, :utc_datetime field :mac_address, :string diff --git a/lib/towerops/snmp/wireless_neighbor_scan.ex b/lib/towerops/snmp/wireless_neighbor_scan.ex index 351c4407..cda96e88 100644 --- a/lib/towerops/snmp/wireless_neighbor_scan.ex +++ b/lib/towerops/snmp/wireless_neighbor_scan.ex @@ -12,15 +12,10 @@ defmodule Towerops.Snmp.WirelessNeighborScan do query time, so we can index it cheaply if needed later). """ - 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 "wireless_neighbor_scans" do field :bssid, :string field :ssid, :string diff --git a/lib/towerops/status_pages/status_incident.ex b/lib/towerops/status_pages/status_incident.ex index fad5825b..60467b67 100644 --- a/lib/towerops/status_pages/status_incident.ex +++ b/lib/towerops/status_pages/status_incident.ex @@ -2,12 +2,7 @@ defmodule Towerops.StatusPages.StatusIncident do @moduledoc """ A status page incident — created manually or auto-generated from alerts. """ - use Ecto.Schema - - import Ecto.Changeset - - @primary_key {:id, :binary_id, autogenerate: true} - @foreign_key_type :binary_id + use Towerops.Schema @valid_severities ~w(minor major critical) @valid_statuses ~w(investigating identified monitoring resolved) diff --git a/lib/towerops/status_pages/status_page_component.ex b/lib/towerops/status_pages/status_page_component.ex index 74bc6a76..f5941bea 100644 --- a/lib/towerops/status_pages/status_page_component.ex +++ b/lib/towerops/status_pages/status_page_component.ex @@ -3,12 +3,7 @@ defmodule Towerops.StatusPages.StatusPageComponent do A service component displayed on the public status page. Maps device groups to customer-facing service names. """ - use Ecto.Schema - - import Ecto.Changeset - - @primary_key {:id, :binary_id, autogenerate: true} - @foreign_key_type :binary_id + use Towerops.Schema @valid_statuses ~w(operational degraded partial_outage major_outage maintenance) diff --git a/lib/towerops/status_pages/status_page_config.ex b/lib/towerops/status_pages/status_page_config.ex index fd952f28..4e51a922 100644 --- a/lib/towerops/status_pages/status_page_config.ex +++ b/lib/towerops/status_pages/status_page_config.ex @@ -3,12 +3,7 @@ defmodule Towerops.StatusPages.StatusPageConfig do Configuration for a white-label customer status page. One-to-one with organization. """ - use Ecto.Schema - - import Ecto.Changeset - - @primary_key {:id, :binary_id, autogenerate: true} - @foreign_key_type :binary_id + use Towerops.Schema schema "status_page_configs" do field :enabled, :boolean, default: false diff --git a/lib/towerops/sync_log.ex b/lib/towerops/sync_log.ex new file mode 100644 index 00000000..9c2ebdf7 --- /dev/null +++ b/lib/towerops/sync_log.ex @@ -0,0 +1,58 @@ +defmodule Towerops.SyncLog do + @moduledoc """ + Shared schema and changeset logic for sync audit logs. + + ## Usage + + defmodule Towerops.Uisp.SyncLog do + use Towerops.SyncLog, table_name: "uisp_sync_logs" + + @valid_statuses ~w(success partial failed) + + def changeset(sync_log, attrs) do + sync_log_changeset(sync_log, attrs, @valid_statuses) + end + end + """ + + defmacro __using__(opts) do + table_name = Keyword.fetch!(opts, :table_name) + + quote do + use Towerops.Schema + + import Towerops.SyncLog, only: [sync_log_changeset: 3] + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + schema unquote(table_name) 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 + end + end + + def sync_log_changeset(struct, attrs, valid_statuses) do + struct + |> Ecto.Changeset.cast(attrs, [ + :organization_id, + :integration_id, + :status, + :records_synced, + :errors, + :duration_ms, + :inserted_at + ]) + |> Ecto.Changeset.validate_required([:organization_id, :integration_id, :status, :inserted_at]) + |> Ecto.Changeset.validate_inclusion(:status, valid_statuses) + |> Ecto.Changeset.foreign_key_constraint(:organization_id) + |> Ecto.Changeset.foreign_key_constraint(:integration_id) + end +end diff --git a/lib/towerops/topology/device_link.ex b/lib/towerops/topology/device_link.ex index 11535747..18f051e0 100644 --- a/lib/towerops/topology/device_link.ex +++ b/lib/towerops/topology/device_link.ex @@ -6,9 +6,7 @@ defmodule Towerops.Topology.DeviceLink do through LLDP, CDP, MAC table analysis, ARP inference, wireless associations, or manual configuration. """ - use Ecto.Schema - - import Ecto.Changeset + use Towerops.Schema alias Towerops.Devices.Device alias Towerops.Snmp.Interface @@ -18,8 +16,6 @@ defmodule Towerops.Topology.DeviceLink do @valid_link_types ~w(lldp cdp mac_match wireless_association arp_inference manual) - @primary_key {:id, :binary_id, autogenerate: true} - @foreign_key_type :binary_id schema "device_links" do field :link_type, :string field :confidence, :float, default: 0.5 diff --git a/lib/towerops/topology/device_link_evidence.ex b/lib/towerops/topology/device_link_evidence.ex index a05f0633..17239789 100644 --- a/lib/towerops/topology/device_link_evidence.ex +++ b/lib/towerops/topology/device_link_evidence.ex @@ -6,16 +6,12 @@ defmodule Towerops.Topology.DeviceLinkEvidence do CDP neighbor, MAC on interface, ARP entry, etc.) that contributed to the existence or confidence of a device link. """ - use Ecto.Schema - - import Ecto.Changeset + use Towerops.Schema alias Towerops.Topology.DeviceLink @valid_evidence_types ~w(lldp_neighbor cdp_neighbor mac_on_interface arp_entry wireless_registration subnet_match) - @primary_key {:id, :binary_id, autogenerate: true} - @foreign_key_type :binary_id schema "device_link_evidence" do field :evidence_type, :string field :evidence_data, :map, default: %{} diff --git a/lib/towerops/topology/device_neighbor.ex b/lib/towerops/topology/device_neighbor.ex index 09e2f7e2..d441641d 100644 --- a/lib/towerops/topology/device_neighbor.ex +++ b/lib/towerops/topology/device_neighbor.ex @@ -6,16 +6,11 @@ defmodule Towerops.Topology.DeviceNeighbor do and represents a physical connection between two devices. The relationship includes port information and management addresses for recursive discovery. """ - use Ecto.Schema - - import Ecto.Changeset + use Towerops.Schema alias Ecto.Association.NotLoaded alias Towerops.Devices.Device - @primary_key {:id, :binary_id, autogenerate: true} - @foreign_key_type :binary_id - schema "device_neighbors" do belongs_to :device, Device belongs_to :neighbor_device, Device diff --git a/lib/towerops/uisp/sync_log.ex b/lib/towerops/uisp/sync_log.ex index 4804b69b..a9c94b47 100644 --- a/lib/towerops/uisp/sync_log.ex +++ b/lib/towerops/uisp/sync_log.ex @@ -2,40 +2,11 @@ defmodule Towerops.Uisp.SyncLog do @moduledoc """ Audit log for UISP 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: "uisp_sync_logs" @valid_statuses ~w(success partial failed) - schema "uisp_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 diff --git a/lib/towerops/weather/alert.ex b/lib/towerops/weather/alert.ex index 4959cbc8..9b46472e 100644 --- a/lib/towerops/weather/alert.ex +++ b/lib/towerops/weather/alert.ex @@ -2,14 +2,10 @@ defmodule Towerops.Weather.Alert do @moduledoc """ Severe weather alert associated with a site. """ - use Ecto.Schema - - import Ecto.Changeset + use Towerops.Schema 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 diff --git a/lib/towerops/weather/observation.ex b/lib/towerops/weather/observation.ex index db26b16c..f9c0d9f4 100644 --- a/lib/towerops/weather/observation.ex +++ b/lib/towerops/weather/observation.ex @@ -5,14 +5,10 @@ defmodule Towerops.Weather.Observation do Stores current weather conditions fetched from OpenWeatherMap, tied to a site's geographic coordinates. """ - use Ecto.Schema - - import Ecto.Changeset + use Towerops.Schema 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 diff --git a/lib/towerops_web/controllers/api/v1/checks_controller.ex b/lib/towerops_web/controllers/api/v1/checks_controller.ex index 0b11467b..657f7b3c 100644 --- a/lib/towerops_web/controllers/api/v1/checks_controller.ex +++ b/lib/towerops_web/controllers/api/v1/checks_controller.ex @@ -8,12 +8,11 @@ defmodule ToweropsWeb.Api.V1.ChecksController do """ use ToweropsWeb, :controller - import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1] - alias Towerops.Devices.Device alias Towerops.Monitoring alias Towerops.Monitoring.Check alias ToweropsWeb.Api.ParamFilter + alias ToweropsWeb.Api.V1.ResourceController, as: Resources alias ToweropsWeb.ScopedResource @service_check_types ~w(http tcp dns ping) @@ -60,8 +59,6 @@ defmodule ToweropsWeb.Api.V1.ChecksController do }) not device_in_org?(check_params["device_id"], organization_id) -> - # Reject up-front so an attacker can't attach a check to a device in - # another organization by guessing its ID. conn |> put_status(:forbidden) |> json(%{error: "Access denied to the referenced device"}) @@ -77,9 +74,56 @@ defmodule ToweropsWeb.Api.V1.ChecksController do end def create(conn, _params) do - conn - |> put_status(:bad_request) - |> json(%{error: "Missing 'check' parameter"}) + Resources.missing_param(conn, "check") + end + + @doc """ + GET /api/v1/checks/:id + + Gets a single check by ID. + """ + def show(conn, %{"id" => id}) do + organization_id = conn.assigns.current_organization_id + + Resources.fetch_and_render(conn, Check, id, organization_id, &format_check/1, "Check not found") + end + + @doc """ + PATCH /api/v1/checks/:id + + Updates a check. + """ + def update(conn, %{"id" => id, "check" => check_params}) do + organization_id = conn.assigns.current_organization_id + + Resources.update_and_render( + conn, + Check, + id, + organization_id, + %{ + context: Monitoring, + update_fn: :update_check, + params: ParamFilter.strip_sensitive(check_params), + format_fn: &format_check/1, + not_found_msg: "Check not found" + } + ) + end + + def update(conn, _params) do + Resources.missing_param(conn, "check") + end + + @doc """ + DELETE /api/v1/checks/:id + + Deletes a check and cancels any scheduled jobs. + """ + def delete(conn, %{"id" => id}) do + organization_id = conn.assigns.current_organization_id + + Resources.delete_and_render(conn, Check, id, organization_id, Monitoring, :delete_check, "Check not found") end defp create_and_respond(conn, attrs) do @@ -92,94 +136,10 @@ defmodule ToweropsWeb.Api.V1.ChecksController do |> json(format_check(check)) {:error, %Ecto.Changeset{} = changeset} -> - conn - |> put_status(:unprocessable_entity) - |> json(%{errors: translate_errors(changeset)}) + Resources.unprocessable_entity(conn, changeset) end end - @doc """ - GET /api/v1/checks/:id - - Gets a single check by ID. - """ - def show(conn, %{"id" => id}) do - organization_id = conn.assigns.current_organization_id - - case ScopedResource.fetch(Check, id, organization_id) do - {:ok, check} -> - json(conn, format_check(check)) - - {:error, :not_found} -> - conn - |> put_status(:not_found) - |> json(%{error: "Check not found"}) - end - end - - @doc """ - PATCH /api/v1/checks/:id - - Updates a check. - """ - def update(conn, %{"id" => id, "check" => check_params}) do - organization_id = conn.assigns.current_organization_id - - case ScopedResource.fetch(Check, id, organization_id) do - {:ok, check} -> - case Monitoring.update_check(check, ParamFilter.strip_sensitive(check_params)) do - {:ok, updated_check} -> - json(conn, format_check(updated_check)) - - {:error, %Ecto.Changeset{} = changeset} -> - conn - |> put_status(:unprocessable_entity) - |> json(%{errors: translate_errors(changeset)}) - end - - {:error, :not_found} -> - conn - |> put_status(:not_found) - |> json(%{error: "Check not found"}) - end - end - - def update(conn, _params) do - conn - |> put_status(:bad_request) - |> json(%{error: "Missing 'check' parameter"}) - end - - @doc """ - DELETE /api/v1/checks/:id - - Deletes a check and cancels any scheduled jobs. - """ - def delete(conn, %{"id" => id}) do - organization_id = conn.assigns.current_organization_id - - case ScopedResource.fetch(Check, id, organization_id) do - {:ok, check} -> - case Monitoring.delete_check(check) do - {:ok, _check} -> - json(conn, %{success: true}) - - {:error, %Ecto.Changeset{} = changeset} -> - conn - |> put_status(:unprocessable_entity) - |> json(%{errors: translate_errors(changeset)}) - end - - {:error, :not_found} -> - conn - |> put_status(:not_found) - |> json(%{error: "Check not found"}) - end - end - - # Verify that an inbound device_id refers to a device in the caller's - # organization. Returns true when device_id is missing (the changeset - # validation will then reject it as required). defp device_in_org?(nil, _organization_id), do: true defp device_in_org?(device_id, organization_id) do diff --git a/lib/towerops_web/controllers/api/v1/devices_controller.ex b/lib/towerops_web/controllers/api/v1/devices_controller.ex index 1457170b..63320f40 100644 --- a/lib/towerops_web/controllers/api/v1/devices_controller.ex +++ b/lib/towerops_web/controllers/api/v1/devices_controller.ex @@ -7,13 +7,12 @@ defmodule ToweropsWeb.Api.V1.DevicesController do """ use ToweropsWeb, :controller - import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1] - alias Towerops.Devices alias Towerops.Devices.Device alias Towerops.Sites.Site alias Towerops.Workers.DiscoveryWorker alias ToweropsWeb.Api.ParamFilter + alias ToweropsWeb.Api.V1.ResourceController, as: Resources alias ToweropsWeb.ScopedResource @doc """ @@ -58,24 +57,23 @@ defmodule ToweropsWeb.Api.V1.DevicesController do Request body: { "device": { - "site_id": "uuid", # optional - if not provided, device is assigned directly to organization - "organization_id": "uuid", # optional - defaults to authenticated organization if not provided + "site_id": "uuid", + "organization_id": "uuid", "name": "Core Router", "ip_address": "192.168.1.1", - "description": "Main router", # optional - "monitoring_enabled": true, # optional, default true - "snmp_enabled": true, # optional, default true - "snmp_version": "2c", # optional, default "2c" (can be "1", "2c", or "3") - "snmp_community": "public", # optional, inherits from site/org if not set - "snmp_port": 161, # optional, default 161 - "device_role": "router", # optional, one of: server, switch, router, access_point, backhaul, other (default: "other") - # SNMPv3 fields (only used when snmp_version is "3"): - "snmpv3_security_level": "authPriv", # optional, one of: noAuthNoPriv, authNoPriv, authPriv - "snmpv3_username": "snmpuser", # optional - "snmpv3_auth_protocol": "SHA-256", # optional, one of: MD5, SHA, SHA-224, SHA-256, SHA-384, SHA-512 - "snmpv3_auth_password": "authpass", # optional - "snmpv3_priv_protocol": "AES", # optional, one of: DES, AES, AES-192, AES-256 - "snmpv3_priv_password": "privpass" # optional + "description": "Main router", + "monitoring_enabled": true, + "snmp_enabled": true, + "snmp_version": "2c", + "snmp_community": "public", + "snmp_port": 161, + "device_role": "router", + "snmpv3_security_level": "authPriv", + "snmpv3_username": "snmpuser", + "snmpv3_auth_protocol": "SHA-256", + "snmpv3_auth_password": "authpass", + "snmpv3_priv_protocol": "AES", + "snmpv3_priv_password": "privpass" } } @@ -112,9 +110,7 @@ defmodule ToweropsWeb.Api.V1.DevicesController do end def create(conn, _params) do - conn - |> put_status(:bad_request) - |> json(%{error: "Missing 'device' parameter"}) + Resources.missing_param(conn, "device") end @doc """ @@ -140,15 +136,7 @@ defmodule ToweropsWeb.Api.V1.DevicesController do def show(conn, %{"id" => id}) do organization_id = conn.assigns.current_organization_id - case ScopedResource.fetch(Device, id, organization_id) do - {:ok, device} -> - json(conn, format_device_details(device)) - - {:error, :not_found} -> - conn - |> put_status(:not_found) - |> json(%{error: "Device not found"}) - end + Resources.fetch_and_render(conn, Device, id, organization_id, &format_device_details/1, "Device not found") end @doc """ @@ -175,29 +163,23 @@ defmodule ToweropsWeb.Api.V1.DevicesController do def update(conn, %{"id" => id, "device" => device_params}) do organization_id = conn.assigns.current_organization_id - case ScopedResource.fetch(Device, id, organization_id) do - {:ok, device} -> - case Devices.update_device(device, ParamFilter.strip_sensitive(device_params)) do - {:ok, updated_device} -> - json(conn, format_device_details(updated_device)) - - {:error, %Ecto.Changeset{} = changeset} -> - conn - |> put_status(:unprocessable_entity) - |> json(%{errors: translate_errors(changeset)}) - end - - {:error, :not_found} -> - conn - |> put_status(:not_found) - |> json(%{error: "Device not found"}) - end + Resources.update_and_render( + conn, + Device, + id, + organization_id, + %{ + context: Devices, + update_fn: :update_device, + params: ParamFilter.strip_sensitive(device_params), + format_fn: &format_device_details/1, + not_found_msg: "Device not found" + } + ) end def update(conn, _params) do - conn - |> put_status(:bad_request) - |> json(%{error: "Missing 'device' parameter"}) + Resources.missing_param(conn, "device") end @doc """ @@ -213,23 +195,7 @@ defmodule ToweropsWeb.Api.V1.DevicesController do def delete(conn, %{"id" => id}) do organization_id = conn.assigns.current_organization_id - case ScopedResource.fetch(Device, id, organization_id) do - {:ok, device} -> - case Devices.delete_device(device) do - {:ok, _device} -> - json(conn, %{success: true}) - - {:error, %Ecto.Changeset{} = changeset} -> - conn - |> put_status(:unprocessable_entity) - |> json(%{errors: translate_errors(changeset)}) - end - - {:error, :not_found} -> - conn - |> put_status(:not_found) - |> json(%{error: "Device not found"}) - end + Resources.delete_and_render(conn, Device, id, organization_id, Devices, :delete_device, "Device not found") end defp default_organization_id(device_params, organization_id) do @@ -248,9 +214,7 @@ defmodule ToweropsWeb.Api.V1.DevicesController do |> json(format_device(device)) {:error, %Ecto.Changeset{} = changeset} -> - conn - |> put_status(:unprocessable_entity) - |> json(%{errors: translate_errors(changeset)}) + Resources.unprocessable_entity(conn, changeset) end end diff --git a/lib/towerops_web/controllers/api/v1/escalation_policies_controller.ex b/lib/towerops_web/controllers/api/v1/escalation_policies_controller.ex index e5056d7a..bb311649 100644 --- a/lib/towerops_web/controllers/api/v1/escalation_policies_controller.ex +++ b/lib/towerops_web/controllers/api/v1/escalation_policies_controller.ex @@ -7,14 +7,13 @@ defmodule ToweropsWeb.Api.V1.EscalationPoliciesController do """ use ToweropsWeb, :controller - import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1] - alias Towerops.OnCall alias Towerops.OnCall.EscalationPolicy alias Towerops.OnCall.EscalationRule alias Towerops.OnCall.EscalationTarget alias Towerops.Repo alias ToweropsWeb.Api.ParamFilter + alias ToweropsWeb.Api.V1.ResourceController, as: Resources alias ToweropsWeb.ScopedResource require Logger @@ -42,90 +41,64 @@ defmodule ToweropsWeb.Api.V1.EscalationPoliciesController do |> ParamFilter.strip_sensitive() |> Map.put("organization_id", organization_id) - case OnCall.create_escalation_policy(attrs) do - {:ok, policy} -> - conn - |> put_status(:created) - |> json(format_policy(policy)) - - {:error, %Ecto.Changeset{} = changeset} -> - conn - |> put_status(:unprocessable_entity) - |> json(%{errors: translate_errors(changeset)}) - end + Resources.created_or_error(conn, OnCall.create_escalation_policy(attrs), &format_policy/1) end def create(conn, _params) do - conn - |> put_status(:bad_request) - |> json(%{error: "Missing 'escalation_policy' parameter"}) + Resources.missing_param(conn, "escalation_policy") end @doc "GET /api/v1/escalation_policies/:id" def show(conn, %{"id" => id}) do organization_id = conn.assigns.current_organization_id - case ScopedResource.fetch_preload(EscalationPolicy, id, organization_id, rules: :targets) do - {:ok, policy} -> - json(conn, format_policy_detail(policy)) - - {:error, :not_found} -> - conn - |> put_status(:not_found) - |> json(%{error: "Escalation policy not found"}) - end + Resources.fetch_and_render( + conn, + EscalationPolicy, + id, + organization_id, + [rules: :targets], + &format_policy_detail/1, + "Escalation policy not found" + ) end @doc "PATCH /api/v1/escalation_policies/:id" def update(conn, %{"id" => id, "escalation_policy" => policy_params}) do organization_id = conn.assigns.current_organization_id - case ScopedResource.fetch(EscalationPolicy, id, organization_id) do - {:ok, policy} -> - case OnCall.update_escalation_policy(policy, ParamFilter.strip_sensitive(policy_params)) do - {:ok, updated} -> - json(conn, format_policy(updated)) - - {:error, %Ecto.Changeset{} = changeset} -> - conn - |> put_status(:unprocessable_entity) - |> json(%{errors: translate_errors(changeset)}) - end - - {:error, :not_found} -> - conn - |> put_status(:not_found) - |> json(%{error: "Escalation policy not found"}) - end + Resources.update_and_render( + conn, + EscalationPolicy, + id, + organization_id, + %{ + context: OnCall, + update_fn: :update_escalation_policy, + params: ParamFilter.strip_sensitive(policy_params), + format_fn: &format_policy/1, + not_found_msg: "Escalation policy not found" + } + ) end def update(conn, _params) do - conn - |> put_status(:bad_request) - |> json(%{error: "Missing 'escalation_policy' parameter"}) + Resources.missing_param(conn, "escalation_policy") end @doc "DELETE /api/v1/escalation_policies/:id" def delete(conn, %{"id" => id}) do organization_id = conn.assigns.current_organization_id - case ScopedResource.fetch(EscalationPolicy, id, organization_id) do - {:ok, policy} -> - case OnCall.delete_escalation_policy(policy) do - {:ok, _} -> - json(conn, %{success: true}) - - {:error, %Ecto.Changeset{} = changeset} -> - conn - |> put_status(:unprocessable_entity) - |> json(%{errors: translate_errors(changeset)}) - end - - {:error, :not_found} -> - conn - |> put_status(:not_found) - |> json(%{error: "Escalation policy not found"}) - end + Resources.delete_and_render( + conn, + EscalationPolicy, + id, + organization_id, + OnCall, + :delete_escalation_policy, + "Escalation policy not found" + ) end # --- Rules --- @@ -145,15 +118,11 @@ defmodule ToweropsWeb.Api.V1.EscalationPoliciesController do |> json(format_rule(rule)) {:error, %Ecto.Changeset{} = changeset} -> - conn - |> put_status(:unprocessable_entity) - |> json(%{errors: translate_errors(changeset)}) + Resources.unprocessable_entity(conn, changeset) end {:error, :not_found} -> - conn - |> put_status(:not_found) - |> json(%{error: "Escalation policy not found"}) + Resources.not_found(conn, "Escalation policy not found") end end @@ -168,16 +137,14 @@ defmodule ToweropsWeb.Api.V1.EscalationPoliciesController do json(conn, format_rule(updated)) {:error, %Ecto.Changeset{} = changeset} -> - conn - |> put_status(:unprocessable_entity) - |> json(%{errors: translate_errors(changeset)}) + Resources.unprocessable_entity(conn, changeset) end else {:error, :not_found} -> - conn |> put_status(:not_found) |> json(%{error: "Escalation policy not found"}) + Resources.not_found(conn, "Escalation policy not found") {:error, :rule_not_found} -> - conn |> put_status(:not_found) |> json(%{error: "Rule not found"}) + Resources.not_found(conn, "Rule not found") end end @@ -191,10 +158,10 @@ defmodule ToweropsWeb.Api.V1.EscalationPoliciesController do json(conn, %{success: true}) else {:error, :not_found} -> - conn |> put_status(:not_found) |> json(%{error: "Escalation policy not found"}) + Resources.not_found(conn, "Escalation policy not found") {:error, :rule_not_found} -> - conn |> put_status(:not_found) |> json(%{error: "Rule not found"}) + Resources.not_found(conn, "Rule not found") end end @@ -215,16 +182,14 @@ defmodule ToweropsWeb.Api.V1.EscalationPoliciesController do |> json(format_target(target)) {:error, %Ecto.Changeset{} = changeset} -> - conn - |> put_status(:unprocessable_entity) - |> json(%{errors: translate_errors(changeset)}) + Resources.unprocessable_entity(conn, changeset) end else {:error, :not_found} -> - conn |> put_status(:not_found) |> json(%{error: "Escalation policy not found"}) + Resources.not_found(conn, "Escalation policy not found") {:error, :rule_not_found} -> - conn |> put_status(:not_found) |> json(%{error: "Rule not found"}) + Resources.not_found(conn, "Rule not found") end end @@ -245,13 +210,13 @@ defmodule ToweropsWeb.Api.V1.EscalationPoliciesController do end else {:error, :not_found} -> - conn |> put_status(:not_found) |> json(%{error: "Escalation policy not found"}) + Resources.not_found(conn, "Escalation policy not found") {:error, :rule_not_found} -> - conn |> put_status(:not_found) |> json(%{error: "Rule not found"}) + Resources.not_found(conn, "Rule not found") {:error, :target_not_found} -> - conn |> put_status(:not_found) |> json(%{error: "Target not found"}) + Resources.not_found(conn, "Target not found") end end diff --git a/lib/towerops_web/controllers/api/v1/maintenance_windows_controller.ex b/lib/towerops_web/controllers/api/v1/maintenance_windows_controller.ex index 9ab2ba28..0ca5dd27 100644 --- a/lib/towerops_web/controllers/api/v1/maintenance_windows_controller.ex +++ b/lib/towerops_web/controllers/api/v1/maintenance_windows_controller.ex @@ -7,11 +7,9 @@ defmodule ToweropsWeb.Api.V1.MaintenanceWindowsController do """ use ToweropsWeb, :controller - import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1] - alias Towerops.Maintenance alias Towerops.Maintenance.MaintenanceWindow - alias ToweropsWeb.ScopedResource + alias ToweropsWeb.Api.V1.ResourceController, as: Resources @doc "GET /api/v1/maintenance_windows" def index(conn, params) do @@ -44,90 +42,63 @@ defmodule ToweropsWeb.Api.V1.MaintenanceWindowsController do |> Map.put("organization_id", organization_id) |> Map.put("created_by_id", user_id) - case Maintenance.create_window(attrs) do - {:ok, window} -> - conn - |> put_status(:created) - |> json(format_window(window)) - - {:error, %Ecto.Changeset{} = changeset} -> - conn - |> put_status(:unprocessable_entity) - |> json(%{errors: translate_errors(changeset)}) - end + Resources.created_or_error(conn, Maintenance.create_window(attrs), &format_window/1) end def create(conn, _params) do - conn - |> put_status(:bad_request) - |> json(%{error: "Missing 'maintenance_window' parameter"}) + Resources.missing_param(conn, "maintenance_window") end @doc "GET /api/v1/maintenance_windows/:id" def show(conn, %{"id" => id}) do organization_id = conn.assigns.current_organization_id - case ScopedResource.fetch(MaintenanceWindow, id, organization_id) do - {:ok, window} -> - json(conn, format_window(window)) - - {:error, :not_found} -> - conn - |> put_status(:not_found) - |> json(%{error: "Maintenance window not found"}) - end + Resources.fetch_and_render( + conn, + MaintenanceWindow, + id, + organization_id, + &format_window/1, + "Maintenance window not found" + ) end @doc "PATCH /api/v1/maintenance_windows/:id" def update(conn, %{"id" => id, "maintenance_window" => window_params}) do organization_id = conn.assigns.current_organization_id - case ScopedResource.fetch(MaintenanceWindow, id, organization_id) do - {:ok, window} -> - case Maintenance.update_window(window, window_params) do - {:ok, updated} -> - json(conn, format_window(updated)) - - {:error, %Ecto.Changeset{} = changeset} -> - conn - |> put_status(:unprocessable_entity) - |> json(%{errors: translate_errors(changeset)}) - end - - {:error, :not_found} -> - conn - |> put_status(:not_found) - |> json(%{error: "Maintenance window not found"}) - end + Resources.update_and_render( + conn, + MaintenanceWindow, + id, + organization_id, + %{ + context: Maintenance, + update_fn: :update_window, + params: window_params, + format_fn: &format_window/1, + not_found_msg: "Maintenance window not found" + } + ) end def update(conn, _params) do - conn - |> put_status(:bad_request) - |> json(%{error: "Missing 'maintenance_window' parameter"}) + Resources.missing_param(conn, "maintenance_window") end @doc "DELETE /api/v1/maintenance_windows/:id" def delete(conn, %{"id" => id}) do organization_id = conn.assigns.current_organization_id - case ScopedResource.fetch(MaintenanceWindow, id, organization_id) do - {:ok, window} -> - case Maintenance.delete_window(window) do - {:ok, _} -> - json(conn, %{success: true}) - - {:error, %Ecto.Changeset{} = changeset} -> - conn - |> put_status(:unprocessable_entity) - |> json(%{errors: translate_errors(changeset)}) - end - - {:error, :not_found} -> - conn - |> put_status(:not_found) - |> json(%{error: "Maintenance window not found"}) - end + Resources.delete_and_render( + conn, + MaintenanceWindow, + id, + organization_id, + Maintenance, + :delete_window, + "Maintenance window not found" + ) end defp format_window(window) do diff --git a/lib/towerops_web/controllers/api/v1/resource_controller.ex b/lib/towerops_web/controllers/api/v1/resource_controller.ex new file mode 100644 index 00000000..f19517ae --- /dev/null +++ b/lib/towerops_web/controllers/api/v1/resource_controller.ex @@ -0,0 +1,131 @@ +defmodule ToweropsWeb.Api.V1.ResourceController do + @moduledoc """ + Shared helpers for standard API v1 CRUD resource operations. + + Eliminates the repetitive `ScopedResource.fetch` + case + changeset + error handling pattern that appears in every resource controller. + + ## Usage + + defmodule ToweropsWeb.Api.V1.SitesController do + use ToweropsWeb, :controller + + alias ToweropsWeb.Api.V1.ResourceController, as: Resources + + def show(conn, %{"id" => id}) do + org_id = conn.assigns.current_organization_id + Resources.fetch_and_render(conn, Site, id, org_id, &format_site/1, "Site not found") + end + end + """ + + alias Phoenix.Controller + alias Plug.Conn + alias ToweropsWeb.Api.ErrorHelpers + alias ToweropsWeb.ScopedResource + + @doc """ + Fetch a resource by id and org scope, render JSON or 404. + + ## Examples + + Resources.fetch_and_render(conn, Site, id, org_id, &format_site/1, "Site not found") + """ + def fetch_and_render(conn, schema, id, org_id, format_fn, not_found_msg) do + case ScopedResource.fetch(schema, id, org_id) do + {:ok, resource} -> + Controller.json(conn, format_fn.(resource)) + + {:error, :not_found} -> + not_found(conn, not_found_msg) + end + end + + @doc """ + Fetch a resource by id and org scope with preloads, render JSON or 404. + """ + def fetch_and_render(conn, schema, id, org_id, preloads, format_fn, not_found_msg) do + case ScopedResource.fetch_preload(schema, id, org_id, preloads) do + {:ok, resource} -> + Controller.json(conn, format_fn.(resource)) + + {:error, :not_found} -> + not_found(conn, not_found_msg) + end + end + + def update_and_render(conn, schema, id, org_id, opts) do + context = opts[:context] + update_fn = opts[:update_fn] + params = opts[:params] + format_fn = opts[:format_fn] + not_found_msg = opts[:not_found_msg] + + case ScopedResource.fetch(schema, id, org_id) do + {:ok, resource} -> + case apply(context, update_fn, [resource, params]) do + {:ok, updated} -> + Controller.json(conn, format_fn.(updated)) + + {:error, %Ecto.Changeset{} = changeset} -> + unprocessable_entity(conn, changeset) + end + + {:error, :not_found} -> + not_found(conn, not_found_msg) + end + end + + @doc """ + Fetch a resource, apply a delete function, render success or errors. + """ + def delete_and_render(conn, schema, id, org_id, context, delete_fn, not_found_msg) do + case ScopedResource.fetch(schema, id, org_id) do + {:ok, resource} -> + case apply(context, delete_fn, [resource]) do + {:ok, _} -> + Controller.json(conn, %{success: true}) + + {:error, %Ecto.Changeset{} = changeset} -> + unprocessable_entity(conn, changeset) + end + + {:error, :not_found} -> + not_found(conn, not_found_msg) + end + end + + @doc """ + Render 201 created with formatted resource, or 422 with changeset errors. + """ + def created_or_error(conn, {:ok, resource}, format_fn) do + conn + |> Conn.put_status(:created) + |> Controller.json(format_fn.(resource)) + end + + def created_or_error(conn, {:error, %Ecto.Changeset{} = changeset}, _format_fn) do + unprocessable_entity(conn, changeset) + end + + @doc "Render 422 with changeset errors." + def unprocessable_entity(conn, changeset) do + conn + |> Conn.put_status(:unprocessable_entity) + |> Controller.json(%{errors: ErrorHelpers.translate_errors(changeset)}) + end + + @doc "Render 404 JSON error." + def not_found(conn, message) do + conn + |> Conn.put_status(:not_found) + |> Controller.json(%{error: message}) + end + + @doc "Render 400 for missing required parameter." + def missing_param(conn, param_name) do + conn + |> Conn.put_status(:bad_request) + |> Controller.json(%{error: "Missing '#{param_name}' parameter"}) + end +end diff --git a/lib/towerops_web/controllers/api/v1/schedules_controller.ex b/lib/towerops_web/controllers/api/v1/schedules_controller.ex index 22b8ae85..c238aa99 100644 --- a/lib/towerops_web/controllers/api/v1/schedules_controller.ex +++ b/lib/towerops_web/controllers/api/v1/schedules_controller.ex @@ -7,14 +7,13 @@ defmodule ToweropsWeb.Api.V1.SchedulesController do """ use ToweropsWeb, :controller - import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1] - alias Towerops.OnCall alias Towerops.OnCall.Layer alias Towerops.OnCall.LayerMember alias Towerops.OnCall.Override alias Towerops.OnCall.Schedule alias Towerops.Repo + alias ToweropsWeb.Api.V1.ResourceController, as: Resources alias ToweropsWeb.ScopedResource require Logger @@ -38,93 +37,56 @@ defmodule ToweropsWeb.Api.V1.SchedulesController do organization_id = conn.assigns.current_organization_id attrs = Map.put(schedule_params, "organization_id", organization_id) - case OnCall.create_schedule(attrs) do - {:ok, schedule} -> - conn - |> put_status(:created) - |> json(format_schedule(schedule)) - - {:error, %Ecto.Changeset{} = changeset} -> - conn - |> put_status(:unprocessable_entity) - |> json(%{errors: translate_errors(changeset)}) - end + Resources.created_or_error(conn, OnCall.create_schedule(attrs), &format_schedule/1) end def create(conn, _params) do - conn - |> put_status(:bad_request) - |> json(%{error: "Missing 'schedule' parameter"}) + Resources.missing_param(conn, "schedule") end @doc "GET /api/v1/schedules/:id" def show(conn, %{"id" => id}) do organization_id = conn.assigns.current_organization_id - case ScopedResource.fetch_preload(Schedule, id, organization_id, - overrides: :user, - layers: [members: :user] - ) do - {:ok, schedule} -> - json(conn, format_schedule_detail(schedule)) - - {:error, :not_found} -> - conn - |> put_status(:not_found) - |> json(%{error: "Schedule not found"}) - end + Resources.fetch_and_render( + conn, + Schedule, + id, + organization_id, + [overrides: :user, layers: [members: :user]], + &format_schedule_detail/1, + "Schedule not found" + ) end @doc "PATCH /api/v1/schedules/:id" def update(conn, %{"id" => id, "schedule" => schedule_params}) do organization_id = conn.assigns.current_organization_id - case ScopedResource.fetch(Schedule, id, organization_id) do - {:ok, schedule} -> - case OnCall.update_schedule(schedule, schedule_params) do - {:ok, updated} -> - json(conn, format_schedule(updated)) - - {:error, %Ecto.Changeset{} = changeset} -> - conn - |> put_status(:unprocessable_entity) - |> json(%{errors: translate_errors(changeset)}) - end - - {:error, :not_found} -> - conn - |> put_status(:not_found) - |> json(%{error: "Schedule not found"}) - end + Resources.update_and_render( + conn, + Schedule, + id, + organization_id, + %{ + context: OnCall, + update_fn: :update_schedule, + params: schedule_params, + format_fn: &format_schedule/1, + not_found_msg: "Schedule not found" + } + ) end def update(conn, _params) do - conn - |> put_status(:bad_request) - |> json(%{error: "Missing 'schedule' parameter"}) + Resources.missing_param(conn, "schedule") end @doc "DELETE /api/v1/schedules/:id" def delete(conn, %{"id" => id}) do organization_id = conn.assigns.current_organization_id - case ScopedResource.fetch(Schedule, id, organization_id) do - {:ok, schedule} -> - case OnCall.delete_schedule(schedule) do - {:ok, _} -> - json(conn, %{success: true}) - - {:error, %Ecto.Changeset{} = changeset} -> - conn - |> put_status(:unprocessable_entity) - |> json(%{errors: translate_errors(changeset)}) - end - - {:error, :not_found} -> - conn - |> put_status(:not_found) - |> json(%{error: "Schedule not found"}) - end + Resources.delete_and_render(conn, Schedule, id, organization_id, OnCall, :delete_schedule, "Schedule not found") end # --- On-Call Resolution --- @@ -139,9 +101,7 @@ defmodule ToweropsWeb.Api.V1.SchedulesController do json(conn, %{on_call: format_user(user)}) {:error, :not_found} -> - conn - |> put_status(:not_found) - |> json(%{error: "Schedule not found"}) + Resources.not_found(conn, "Schedule not found") end end @@ -162,15 +122,11 @@ defmodule ToweropsWeb.Api.V1.SchedulesController do |> json(format_layer(layer)) {:error, %Ecto.Changeset{} = changeset} -> - conn - |> put_status(:unprocessable_entity) - |> json(%{errors: translate_errors(changeset)}) + Resources.unprocessable_entity(conn, changeset) end {:error, :not_found} -> - conn - |> put_status(:not_found) - |> json(%{error: "Schedule not found"}) + Resources.not_found(conn, "Schedule not found") end end @@ -185,16 +141,14 @@ defmodule ToweropsWeb.Api.V1.SchedulesController do json(conn, format_layer(updated)) {:error, %Ecto.Changeset{} = changeset} -> - conn - |> put_status(:unprocessable_entity) - |> json(%{errors: translate_errors(changeset)}) + Resources.unprocessable_entity(conn, changeset) end else {:error, :not_found} -> - conn |> put_status(:not_found) |> json(%{error: "Schedule not found"}) + Resources.not_found(conn, "Schedule not found") {:error, :layer_not_found} -> - conn |> put_status(:not_found) |> json(%{error: "Layer not found"}) + Resources.not_found(conn, "Layer not found") end end @@ -208,10 +162,10 @@ defmodule ToweropsWeb.Api.V1.SchedulesController do json(conn, %{success: true}) else {:error, :not_found} -> - conn |> put_status(:not_found) |> json(%{error: "Schedule not found"}) + Resources.not_found(conn, "Schedule not found") {:error, :layer_not_found} -> - conn |> put_status(:not_found) |> json(%{error: "Layer not found"}) + Resources.not_found(conn, "Layer not found") end end @@ -232,16 +186,14 @@ defmodule ToweropsWeb.Api.V1.SchedulesController do |> json(format_member(member)) {:error, %Ecto.Changeset{} = changeset} -> - conn - |> put_status(:unprocessable_entity) - |> json(%{errors: translate_errors(changeset)}) + Resources.unprocessable_entity(conn, changeset) end else {:error, :not_found} -> - conn |> put_status(:not_found) |> json(%{error: "Schedule not found"}) + Resources.not_found(conn, "Schedule not found") {:error, :layer_not_found} -> - conn |> put_status(:not_found) |> json(%{error: "Layer not found"}) + Resources.not_found(conn, "Layer not found") end end @@ -256,13 +208,13 @@ defmodule ToweropsWeb.Api.V1.SchedulesController do json(conn, %{success: true}) else {:error, :not_found} -> - conn |> put_status(:not_found) |> json(%{error: "Schedule not found"}) + Resources.not_found(conn, "Schedule not found") {:error, :layer_not_found} -> - conn |> put_status(:not_found) |> json(%{error: "Layer not found"}) + Resources.not_found(conn, "Layer not found") {:error, :member_not_found} -> - conn |> put_status(:not_found) |> json(%{error: "Member not found"}) + Resources.not_found(conn, "Member not found") end end @@ -283,13 +235,11 @@ defmodule ToweropsWeb.Api.V1.SchedulesController do |> json(format_override(override)) {:error, %Ecto.Changeset{} = changeset} -> - conn - |> put_status(:unprocessable_entity) - |> json(%{errors: translate_errors(changeset)}) + Resources.unprocessable_entity(conn, changeset) end {:error, :not_found} -> - conn |> put_status(:not_found) |> json(%{error: "Schedule not found"}) + Resources.not_found(conn, "Schedule not found") end end @@ -309,10 +259,10 @@ defmodule ToweropsWeb.Api.V1.SchedulesController do end else {:error, :not_found} -> - conn |> put_status(:not_found) |> json(%{error: "Schedule not found"}) + Resources.not_found(conn, "Schedule not found") {:error, :override_not_found} -> - conn |> put_status(:not_found) |> json(%{error: "Override not found"}) + Resources.not_found(conn, "Override not found") end end diff --git a/lib/towerops_web/controllers/api/v1/sites_controller.ex b/lib/towerops_web/controllers/api/v1/sites_controller.ex index 15e9bf69..2fe152c8 100644 --- a/lib/towerops_web/controllers/api/v1/sites_controller.ex +++ b/lib/towerops_web/controllers/api/v1/sites_controller.ex @@ -7,12 +7,10 @@ defmodule ToweropsWeb.Api.V1.SitesController do """ use ToweropsWeb, :controller - import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1] - alias Towerops.Sites alias Towerops.Sites.Site alias ToweropsWeb.Api.ParamFilter - alias ToweropsWeb.ScopedResource + alias ToweropsWeb.Api.V1.ResourceController, as: Resources @doc """ GET /api/v1/sites @@ -56,23 +54,11 @@ defmodule ToweropsWeb.Api.V1.SitesController do |> ParamFilter.strip_sensitive() |> Map.put("organization_id", organization_id) - case Sites.create_site(attrs) do - {:ok, site} -> - conn - |> put_status(:created) - |> json(format_site(site)) - - {:error, %Ecto.Changeset{} = changeset} -> - conn - |> put_status(:unprocessable_entity) - |> json(%{errors: translate_errors(changeset)}) - end + Resources.created_or_error(conn, Sites.create_site(attrs), &format_site/1) end def create(conn, _params) do - conn - |> put_status(:bad_request) - |> json(%{error: "Missing 'site' parameter"}) + Resources.missing_param(conn, "site") end @doc """ @@ -83,15 +69,7 @@ defmodule ToweropsWeb.Api.V1.SitesController do def show(conn, %{"id" => id}) do organization_id = conn.assigns.current_organization_id - case ScopedResource.fetch(Site, id, organization_id) do - {:ok, site} -> - json(conn, format_site(site)) - - {:error, :not_found} -> - conn - |> put_status(:not_found) - |> json(%{error: "Site not found"}) - end + Resources.fetch_and_render(conn, Site, id, organization_id, &format_site/1, "Site not found") end @doc """ @@ -102,29 +80,23 @@ defmodule ToweropsWeb.Api.V1.SitesController do def update(conn, %{"id" => id, "site" => site_params}) do organization_id = conn.assigns.current_organization_id - case ScopedResource.fetch(Site, id, organization_id) do - {:ok, site} -> - case Sites.update_site(site, ParamFilter.strip_sensitive(site_params)) do - {:ok, updated_site} -> - json(conn, format_site(updated_site)) - - {:error, %Ecto.Changeset{} = changeset} -> - conn - |> put_status(:unprocessable_entity) - |> json(%{errors: translate_errors(changeset)}) - end - - {:error, :not_found} -> - conn - |> put_status(:not_found) - |> json(%{error: "Site not found"}) - end + Resources.update_and_render( + conn, + Site, + id, + organization_id, + %{ + context: Sites, + update_fn: :update_site, + params: ParamFilter.strip_sensitive(site_params), + format_fn: &format_site/1, + not_found_msg: "Site not found" + } + ) end def update(conn, _params) do - conn - |> put_status(:bad_request) - |> json(%{error: "Missing 'site' parameter"}) + Resources.missing_param(conn, "site") end @doc """ @@ -135,23 +107,7 @@ defmodule ToweropsWeb.Api.V1.SitesController do def delete(conn, %{"id" => id}) do organization_id = conn.assigns.current_organization_id - case ScopedResource.fetch(Site, id, organization_id) do - {:ok, site} -> - case Sites.delete_site(site) do - {:ok, _site} -> - json(conn, %{success: true}) - - {:error, %Ecto.Changeset{} = changeset} -> - conn - |> put_status(:unprocessable_entity) - |> json(%{errors: translate_errors(changeset)}) - end - - {:error, :not_found} -> - conn - |> put_status(:not_found) - |> json(%{error: "Site not found"}) - end + Resources.delete_and_render(conn, Site, id, organization_id, Sites, :delete_site, "Site not found") end defp format_site(site) do diff --git a/lib/towerops_web/helpers/connection_helpers.ex b/lib/towerops_web/helpers/connection_helpers.ex new file mode 100644 index 00000000..41899b2f --- /dev/null +++ b/lib/towerops_web/helpers/connection_helpers.ex @@ -0,0 +1,40 @@ +defmodule ToweropsWeb.Helpers.ConnectionHelpers do + @moduledoc """ + Shared helpers for testing integration/API connections. + + Provides a unified `format_connection_result/1` function that + translates raw connection test results into user-facing messages. + """ + + require Logger + + @doc """ + Formats a connection test result into a user-facing message tuple. + + Returns `{:ok, message}` for successes or `{:error, message}` for failures. + """ + def format_connection_result({:ok, _body}), do: {:ok, "Connection successful"} + + def format_connection_result({:error, :unauthorized}), do: {:error, "Invalid API key"} + + def format_connection_result({:error, :forbidden}), do: {:error, "Access forbidden"} + + def format_connection_result({:error, {:unexpected_status, status}}), + do: {:error, "API returned unexpected status: #{status}"} + + def format_connection_result({:error, %{reason: :timeout}}), do: {:error, "Connection timeout - unable to reach API"} + + def format_connection_result({:error, %{reason: :econnrefused}}), + do: {:error, "Connection refused - check API endpoint"} + + def format_connection_result({:error, %{reason: :nxdomain}}), do: {:error, "DNS lookup failed - check API endpoint"} + + def format_connection_result({:error, {:graphql_errors, errors}}), + do: {:error, "API query failed: #{inspect(Enum.map(errors, & &1["message"]))}"} + + # Never expose internal error details to users + def format_connection_result({:error, reason}) do + Logger.warning("Integration connection test failed: #{inspect(reason)}") + {:error, "Connection failed - please check your API credentials and try again"} + end +end diff --git a/lib/towerops_web/live/org/integrations_live.ex b/lib/towerops_web/live/org/integrations_live.ex index 4730817e..0607a5b0 100644 --- a/lib/towerops_web/live/org/integrations_live.ex +++ b/lib/towerops_web/live/org/integrations_live.ex @@ -11,8 +11,7 @@ defmodule ToweropsWeb.Org.IntegrationsLive do alias Towerops.Splynx.Client, as: SplynxClient alias Towerops.Uisp.Client, as: UispClient alias Towerops.Visp.Client, as: VispClient - - require Logger + alias ToweropsWeb.Helpers.ConnectionHelpers @provider_categories [ %{ @@ -518,26 +517,5 @@ defmodule ToweropsWeb.Org.IntegrationsLive do defp blank?(""), do: true defp blank?(_), do: false - defp format_connection_result({:ok, _body}), do: {:ok, "Connection successful"} - defp format_connection_result({:error, :unauthorized}), do: {:error, "Invalid API key"} - defp format_connection_result({:error, :forbidden}), do: {:error, "Access forbidden"} - - defp format_connection_result({:error, {:unexpected_status, status}}), - do: {:error, "API returned unexpected status: #{status}"} - - defp format_connection_result({:error, %{reason: :timeout}}), do: {:error, "Connection timeout - unable to reach API"} - - defp format_connection_result({:error, %{reason: :econnrefused}}), - do: {:error, "Connection refused - check API endpoint"} - - defp format_connection_result({:error, %{reason: :nxdomain}}), do: {:error, "DNS lookup failed - check API endpoint"} - - defp format_connection_result({:error, {:graphql_errors, errors}}), - do: {:error, "API query failed: #{inspect(Enum.map(errors, & &1["message"]))}"} - - # Never expose internal error details to users - defp format_connection_result({:error, reason}) do - Logger.warning("Integration connection test failed: #{inspect(reason)}") - {:error, "Connection failed - please check your API credentials and try again"} - end + defp format_connection_result(result), do: ConnectionHelpers.format_connection_result(result) end diff --git a/lib/towerops_web/live/org/settings_live.ex b/lib/towerops_web/live/org/settings_live.ex index 6141d938..af694a1e 100644 --- a/lib/towerops_web/live/org/settings_live.ex +++ b/lib/towerops_web/live/org/settings_live.ex @@ -15,6 +15,7 @@ defmodule ToweropsWeb.Org.SettingsLive do alias Towerops.Sonar.Client, as: SonarClient alias Towerops.Splynx.Client, as: SplynxClient alias Towerops.Visp.Client, as: VispClient + alias ToweropsWeb.Helpers.ConnectionHelpers require Logger @@ -756,28 +757,7 @@ defmodule ToweropsWeb.Org.SettingsLive do |> Enum.map_join(", ", fn {field, msgs} -> "#{field} #{Enum.join(msgs, ", ")}" end) end - @doc false - def format_connection_result({:ok, _body}), do: {:ok, "Connection successful"} - def format_connection_result({:error, :unauthorized}), do: {:error, "Invalid API key"} - def format_connection_result({:error, :forbidden}), do: {:error, "Access forbidden"} - - def format_connection_result({:error, {:unexpected_status, status}}), - do: {:error, "API returned unexpected status: #{status}"} - - def format_connection_result({:error, %{reason: :timeout}}), do: {:error, "Connection timeout - unable to reach API"} - - def format_connection_result({:error, %{reason: :econnrefused}}), - do: {:error, "Connection refused - check API endpoint"} - - def format_connection_result({:error, %{reason: :nxdomain}}), do: {:error, "DNS lookup failed - check API endpoint"} - - def format_connection_result({:error, {:graphql_errors, errors}}), - do: {:error, "API query failed: #{inspect(Enum.map(errors, & &1["message"]))}"} - - def format_connection_result({:error, reason}) do - Logger.warning("Integration connection test failed: #{inspect(reason)}") - {:error, "Connection failed - please check your API credentials and try again"} - end + defdelegate format_connection_result(result), to: ConnectionHelpers @doc false def default_netbox_config do diff --git a/lib/towerops_web/telemetry_filter.ex b/lib/towerops_web/telemetry_filter.ex deleted file mode 100644 index 25d5fcd0..00000000 --- a/lib/towerops_web/telemetry_filter.ex +++ /dev/null @@ -1,57 +0,0 @@ -defmodule ToweropsWeb.TelemetryFilter do - @moduledoc """ - Logger metadata filter that suppresses noisy health check and uptime monitor logs. - - Filters log messages for: - - GET /health (Kubernetes liveness/readiness probes) - - HEAD / (External uptime monitors) - - This is configured as a logger metadata filter in config.exs. - """ - - require Logger - - @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 diff --git a/test/towerops/log_filter_test.exs b/test/towerops/log_filter_test.exs index 597e1748..b9edb7d7 100644 --- a/test/towerops/log_filter_test.exs +++ b/test/towerops/log_filter_test.exs @@ -1,7 +1,7 @@ defmodule Towerops.LogFilterTest do use ExUnit.Case, async: true - alias Towerops.LogFilter + alias Towerops.LogFilters describe "filter_bandit_errors/2" do test "stops Bandit invalid HTTP version string errors" do @@ -10,7 +10,7 @@ defmodule Towerops.LogFilterTest do msg: {:string, "** (Bandit.HTTPError) Invalid HTTP version"} } - assert LogFilter.filter_bandit_errors(log_event, []) == :stop + assert LogFilters.filter_bandit_errors(log_event, []) == :stop end test "stops Bandit HTTP errors with additional context" do @@ -19,7 +19,7 @@ defmodule Towerops.LogFilterTest do msg: {:string, "Some context Bandit.HTTPError: Invalid HTTP version details"} } - assert LogFilter.filter_bandit_errors(log_event, []) == :stop + assert LogFilters.filter_bandit_errors(log_event, []) == :stop end test "ignores Bandit errors without 'Invalid HTTP version'" do @@ -28,7 +28,7 @@ defmodule Towerops.LogFilterTest do msg: {:string, "** (Bandit.HTTPError) Some other error"} } - assert LogFilter.filter_bandit_errors(log_event, []) == :ignore + assert LogFilters.filter_bandit_errors(log_event, []) == :ignore end test "stops error reports containing Bandit.HTTPError" do @@ -43,7 +43,7 @@ defmodule Towerops.LogFilterTest do } } - assert LogFilter.filter_bandit_errors(log_event, []) == :stop + assert LogFilters.filter_bandit_errors(log_event, []) == :stop end test "ignores error reports without Bandit.HTTPError" do @@ -58,7 +58,7 @@ defmodule Towerops.LogFilterTest do } } - assert LogFilter.filter_bandit_errors(log_event, []) == :ignore + assert LogFilters.filter_bandit_errors(log_event, []) == :ignore end test "ignores error reports with non-binary message" do @@ -73,7 +73,7 @@ defmodule Towerops.LogFilterTest do } } - assert LogFilter.filter_bandit_errors(log_event, []) == :ignore + assert LogFilters.filter_bandit_errors(log_event, []) == :ignore end test "ignores error reports with no message key" do @@ -88,7 +88,7 @@ defmodule Towerops.LogFilterTest do } } - assert LogFilter.filter_bandit_errors(log_event, []) == :ignore + assert LogFilters.filter_bandit_errors(log_event, []) == :ignore end test "ignores non-error level logs" do @@ -97,7 +97,7 @@ defmodule Towerops.LogFilterTest do msg: {:string, "** (Bandit.HTTPError) Invalid HTTP version"} } - assert LogFilter.filter_bandit_errors(log_event, []) == :ignore + assert LogFilters.filter_bandit_errors(log_event, []) == :ignore end test "ignores non-string messages" do @@ -106,7 +106,7 @@ defmodule Towerops.LogFilterTest do msg: {:atom, :some_error} } - assert LogFilter.filter_bandit_errors(log_event, []) == :ignore + assert LogFilters.filter_bandit_errors(log_event, []) == :ignore end test "ignores logs with unexpected structure" do @@ -115,13 +115,13 @@ defmodule Towerops.LogFilterTest do # Missing :msg key } - assert LogFilter.filter_bandit_errors(log_event, []) == :ignore + assert LogFilters.filter_bandit_errors(log_event, []) == :ignore end test "ignores empty map" do log_event = %{} - assert LogFilter.filter_bandit_errors(log_event, []) == :ignore + assert LogFilters.filter_bandit_errors(log_event, []) == :ignore end test "ignores warning level with Bandit error" do @@ -130,7 +130,7 @@ defmodule Towerops.LogFilterTest do msg: {:string, "** (Bandit.HTTPError) Invalid HTTP version"} } - assert LogFilter.filter_bandit_errors(log_event, []) == :ignore + assert LogFilters.filter_bandit_errors(log_event, []) == :ignore end test "stops error with Bandit.HTTPError and Invalid HTTP version (case sensitive)" do @@ -139,7 +139,7 @@ defmodule Towerops.LogFilterTest do msg: {:string, "Bandit.HTTPError: Invalid HTTP version"} } - assert LogFilter.filter_bandit_errors(log_event, []) == :stop + assert LogFilters.filter_bandit_errors(log_event, []) == :stop end test "ignores if only 'Bandit.HTTPError' present without 'Invalid HTTP version'" do @@ -148,7 +148,7 @@ defmodule Towerops.LogFilterTest do msg: {:string, "Bandit.HTTPError occurred"} } - assert LogFilter.filter_bandit_errors(log_event, []) == :ignore + assert LogFilters.filter_bandit_errors(log_event, []) == :ignore end test "ignores if only 'Invalid HTTP version' present without 'Bandit.HTTPError'" do @@ -157,7 +157,7 @@ defmodule Towerops.LogFilterTest do msg: {:string, "Invalid HTTP version detected"} } - assert LogFilter.filter_bandit_errors(log_event, []) == :ignore + assert LogFilters.filter_bandit_errors(log_event, []) == :ignore end end end diff --git a/test/towerops/logger_filters_test.exs b/test/towerops/logger_filters_test.exs index e4f7f866..5fd91dbe 100644 --- a/test/towerops/logger_filters_test.exs +++ b/test/towerops/logger_filters_test.exs @@ -1,12 +1,12 @@ defmodule Towerops.LoggerFiltersTest do use ExUnit.Case, async: true - alias Towerops.LoggerFilters + alias Towerops.LogFilters describe "drop_oban_shutdown/2" do test "stops on Oban source shutdown messages" do log = %{meta: %{source: :oban}, msg: {:string, "process EXIT :shutdown"}} - assert :stop = LoggerFilters.drop_oban_shutdown(log, []) + assert :stop = LogFilters.drop_oban_shutdown(log, []) end test "stops on Oban queue shutdown messages" do @@ -15,7 +15,7 @@ defmodule Towerops.LoggerFiltersTest do msg: {:string, "Oban.Queue received unexpected message: {:EXIT, :shutdown}"} } - assert :stop = LoggerFilters.drop_oban_shutdown(log, []) + assert :stop = LogFilters.drop_oban_shutdown(log, []) end test "stops on Oban.Plugins shutdown messages" do @@ -24,68 +24,68 @@ defmodule Towerops.LoggerFiltersTest do msg: {:string, "Oban.Plugins received unexpected message: {:EXIT, :shutdown}"} } - assert :stop = LoggerFilters.drop_oban_shutdown(log, []) + assert :stop = LogFilters.drop_oban_shutdown(log, []) end test "ignores non-Oban messages" do log = %{meta: %{source: :phoenix}, msg: {:string, "request completed"}} - assert :ignore = LoggerFilters.drop_oban_shutdown(log, []) + assert :ignore = LogFilters.drop_oban_shutdown(log, []) end test "ignores Oban source messages without EXIT:shutdown" do log = %{meta: %{source: :oban}, msg: {:string, "job completed successfully"}} - assert :ignore = LoggerFilters.drop_oban_shutdown(log, []) + assert :ignore = LogFilters.drop_oban_shutdown(log, []) end test "ignores messages without matching meta shape" do - assert :ignore = LoggerFilters.drop_oban_shutdown(%{}, []) + assert :ignore = LogFilters.drop_oban_shutdown(%{}, []) end test "ignores messages with non-string msg" do log = %{meta: %{source: :oban}, msg: :not_a_string} - assert :ignore = LoggerFilters.drop_oban_shutdown(log, []) + assert :ignore = LogFilters.drop_oban_shutdown(log, []) end end describe "drop_snmp_mib_errors/2" do test "stops on SNMP MIB error messages" do log = %{msg: {:string, "Cannot find module (FOO): At line 1 in (none)"}} - assert :stop = LoggerFilters.drop_snmp_mib_errors(log, []) + assert :stop = LogFilters.drop_snmp_mib_errors(log, []) end test "ignores non-MIB error messages" do log = %{msg: {:string, "some other error"}} - assert :ignore = LoggerFilters.drop_snmp_mib_errors(log, []) + assert :ignore = LogFilters.drop_snmp_mib_errors(log, []) end test "ignores messages without matching meta shape" do - assert :ignore = LoggerFilters.drop_snmp_mib_errors(%{}, []) + assert :ignore = LogFilters.drop_snmp_mib_errors(%{}, []) end end describe "drop_shutdown_errors/2" do test "stops on port_died messages" do log = %{msg: {:string, "port_died: normal"}} - assert :stop = LoggerFilters.drop_shutdown_errors(log, []) + assert :stop = LogFilters.drop_shutdown_errors(log, []) end test "stops on write_failed epipe messages" do log = %{msg: {:string, "write_failed: device epipe"}} - assert :stop = LoggerFilters.drop_shutdown_errors(log, []) + assert :stop = LogFilters.drop_shutdown_errors(log, []) end test "ignores non-shutdown messages" do log = %{msg: {:string, "normal operation message"}} - assert :ignore = LoggerFilters.drop_shutdown_errors(log, []) + assert :ignore = LogFilters.drop_shutdown_errors(log, []) end test "ignores write_failed without epipe" do log = %{msg: {:string, "write_failed: something else"}} - assert :ignore = LoggerFilters.drop_shutdown_errors(log, []) + assert :ignore = LogFilters.drop_shutdown_errors(log, []) end test "ignores messages without matching meta shape" do - assert :ignore = LoggerFilters.drop_shutdown_errors(%{}, []) + assert :ignore = LogFilters.drop_shutdown_errors(%{}, []) end end end diff --git a/test/towerops_web/controllers/api/mobile_controller_test.exs b/test/towerops_web/controllers/api/mobile_controller_test.exs index abd08327..d4957663 100644 --- a/test/towerops_web/controllers/api/mobile_controller_test.exs +++ b/test/towerops_web/controllers/api/mobile_controller_test.exs @@ -215,7 +215,18 @@ defmodule ToweropsWeb.Api.MobileControllerTest do assert body["site"]["id"] == site.id assert body["site"]["name"] == "Detail Site" assert body["interfaces"] != [] - assert body["sensors"] == [] + + assert [ + %{ + "id" => _sensor_id, + "name" => "CPU Temperature", + "type" => "temperature", + "unit" => nil, + "current_value" => nil, + "status" => nil + } + ] = body["sensors"] + assert body["uptime"] =~ "h" end diff --git a/test/towerops_web/live/onboarding_live_test.exs b/test/towerops_web/live/onboarding_live_test.exs index a2e30882..a7c0ab80 100644 --- a/test/towerops_web/live/onboarding_live_test.exs +++ b/test/towerops_web/live/onboarding_live_test.exs @@ -227,8 +227,8 @@ defmodule ToweropsWeb.OnboardingLiveTest do socket ) - assert %{assigns: %{step: :agent, agent_token: agent_token}} = socket - assert is_binary(agent_token) and agent_token != "" + assert %{assigns: %{step: :agent, agent_token_value: agent_token_value}} = socket + assert is_binary(agent_token_value) and agent_token_value != "" end end diff --git a/test/towerops_web/telemetry_filter_test.exs b/test/towerops_web/telemetry_filter_test.exs index 21ac596d..6da62301 100644 --- a/test/towerops_web/telemetry_filter_test.exs +++ b/test/towerops_web/telemetry_filter_test.exs @@ -1,40 +1,40 @@ defmodule ToweropsWeb.TelemetryFilterTest do use ExUnit.Case, async: true - alias ToweropsWeb.TelemetryFilter + alias Towerops.LogFilters describe "filter_health_checks/2" do test "stops logs for health check request paths" do log_event = {:info, self(), {Logger, "request completed", {{2026, 3, 10}, {12, 0, 0, 0}}, request_path: "/health"}} - assert :stop = TelemetryFilter.filter_health_checks(log_event, []) + assert :stop = LogFilters.filter_health_checks(log_event, []) end test "stops logs when phoenix logging is disabled" do log_event = {:info, self(), {Logger, "request completed", {{2026, 3, 10}, {12, 0, 0, 0}}, phoenix_log: false}} - assert :stop = TelemetryFilter.filter_health_checks(log_event, []) + assert :stop = LogFilters.filter_health_checks(log_event, []) end test "stops logs for matching message text" do log_event = {:info, self(), {Logger, "HEAD / responded 200", {{2026, 3, 10}, {12, 0, 0, 0}}, []}} - assert :stop = TelemetryFilter.filter_health_checks(log_event, []) + assert :stop = LogFilters.filter_health_checks(log_event, []) end test "ignores unrelated log events" do log_event = {:info, self(), {Logger, "GET /devices responded 200", {{2026, 3, 10}, {12, 0, 0, 0}}, request_path: "/devices"}} - assert :ignore = TelemetryFilter.filter_health_checks(log_event, []) + assert :ignore = LogFilters.filter_health_checks(log_event, []) end test "ignores unexpected log event shapes" do - assert :ignore = TelemetryFilter.filter_health_checks(:unexpected, []) + assert :ignore = LogFilters.filter_health_checks(:unexpected, []) end end test "attach/0 returns ok" do - assert :ok = TelemetryFilter.attach() + assert :ok = LogFilters.attach() end end