From 2fd195e6b3581774508c4330567b1e4be49914bd Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 12 Feb 2026 16:52:05 -0600 Subject: [PATCH 01/20] add encrypted map ecto type for integration credentials --- lib/towerops/ecto_types/encrypted_map.ex | 21 ++++++++++ .../ecto_types/encrypted_map_test.exs | 42 +++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 lib/towerops/ecto_types/encrypted_map.ex create mode 100644 test/towerops/ecto_types/encrypted_map_test.exs diff --git a/lib/towerops/ecto_types/encrypted_map.ex b/lib/towerops/ecto_types/encrypted_map.ex new file mode 100644 index 00000000..db2edb0b --- /dev/null +++ b/lib/towerops/ecto_types/encrypted_map.ex @@ -0,0 +1,21 @@ +defmodule Towerops.Encrypted.Map do + @moduledoc """ + Encrypted map field using Cloak vault. + + Used for storing sensitive configuration like API credentials as encrypted JSON. + Data is encrypted at rest using AES-256-GCM. + + ## Usage + + field :credentials, Towerops.Encrypted.Map + + ## Database Schema + + The database column must be `:binary`: + + add :credentials, :binary + + Note: Atom keys become string keys after decryption. + """ + use Cloak.Ecto.Map, vault: Towerops.Vault +end diff --git a/test/towerops/ecto_types/encrypted_map_test.exs b/test/towerops/ecto_types/encrypted_map_test.exs new file mode 100644 index 00000000..6595ed8e --- /dev/null +++ b/test/towerops/ecto_types/encrypted_map_test.exs @@ -0,0 +1,42 @@ +defmodule Towerops.Encrypted.MapTest do + use Towerops.DataCase, async: true + + alias Towerops.Encrypted.Map, as: EncryptedMap + + describe "cast/1" do + test "casts a valid map" do + assert {:ok, %{"key" => "value"}} = EncryptedMap.cast(%{"key" => "value"}) + end + + test "casts atom-keyed maps" do + assert {:ok, %{key: "value"}} = EncryptedMap.cast(%{key: "value"}) + end + + test "rejects non-map values" do + assert :error = EncryptedMap.cast("not a map") + assert :error = EncryptedMap.cast(123) + end + + test "casts nil" do + assert {:ok, nil} = EncryptedMap.cast(nil) + end + end + + describe "dump/1 and load/1 round-trip" do + test "encrypts and decrypts a map" do + original = %{"api_key" => "secret123", "base_url" => "https://api.preseem.com"} + + assert {:ok, encrypted} = EncryptedMap.dump(original) + assert is_binary(encrypted) + refute encrypted == Jason.encode!(original) + + assert {:ok, decrypted} = EncryptedMap.load(encrypted) + assert decrypted == original + end + + test "handles nil values" do + assert {:ok, nil} = EncryptedMap.dump(nil) + assert {:ok, nil} = EncryptedMap.load(nil) + end + end +end From 39f0ffe253df7ecdf3b3696098a26b36b538b14c Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 12 Feb 2026 16:54:14 -0600 Subject: [PATCH 02/20] add integrations schema with encrypted credentials --- lib/towerops/integrations/integration.ex | 54 +++++++++ .../20260212225249_create_integrations.exs | 24 ++++ .../integrations/integration_test.exs | 103 ++++++++++++++++++ 3 files changed, 181 insertions(+) create mode 100644 lib/towerops/integrations/integration.ex create mode 100644 priv/repo/migrations/20260212225249_create_integrations.exs create mode 100644 test/towerops/integrations/integration_test.exs diff --git a/lib/towerops/integrations/integration.ex b/lib/towerops/integrations/integration.ex new file mode 100644 index 00000000..3fc4feca --- /dev/null +++ b/lib/towerops/integrations/integration.ex @@ -0,0 +1,54 @@ +defmodule Towerops.Integrations.Integration do + @moduledoc """ + Schema for per-organization third-party integrations. + + Each organization can have one integration per provider. Credentials + are encrypted at rest using Cloak AES-256-GCM. + """ + use Ecto.Schema + + import Ecto.Changeset + + alias Towerops.Encrypted + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + @valid_providers ~w(preseem) + @valid_sync_statuses ~w(never success partial failed) + + schema "integrations" do + field :provider, :string + field :enabled, :boolean, default: false + field :credentials, Encrypted.Map + field :sync_interval_minutes, :integer, default: 10 + field :last_synced_at, :utc_datetime + field :last_sync_status, :string, default: "never" + + belongs_to :organization, Towerops.Organizations.Organization + + timestamps(type: :utc_datetime) + end + + def changeset(integration, attrs) do + integration + |> cast(attrs, [ + :organization_id, + :provider, + :enabled, + :credentials, + :sync_interval_minutes, + :last_synced_at, + :last_sync_status + ]) + |> validate_required([:organization_id, :provider]) + |> validate_inclusion(:provider, @valid_providers) + |> validate_inclusion(:last_sync_status, @valid_sync_statuses) + |> validate_number(:sync_interval_minutes, greater_than: 0) + |> unique_constraint([:organization_id, :provider], + error_key: :provider, + message: "has already been taken" + ) + |> foreign_key_constraint(:organization_id) + end +end diff --git a/priv/repo/migrations/20260212225249_create_integrations.exs b/priv/repo/migrations/20260212225249_create_integrations.exs new file mode 100644 index 00000000..e977e39e --- /dev/null +++ b/priv/repo/migrations/20260212225249_create_integrations.exs @@ -0,0 +1,24 @@ +defmodule Towerops.Repo.Migrations.CreateIntegrations do + use Ecto.Migration + + def change do + create table(:integrations, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all), + null: false + + add :provider, :string, null: false + add :enabled, :boolean, default: false, null: false + add :credentials, :binary + add :sync_interval_minutes, :integer, default: 10 + add :last_synced_at, :utc_datetime + add :last_sync_status, :string, default: "never" + + timestamps(type: :utc_datetime) + end + + create unique_index(:integrations, [:organization_id, :provider]) + create index(:integrations, [:organization_id]) + end +end diff --git a/test/towerops/integrations/integration_test.exs b/test/towerops/integrations/integration_test.exs new file mode 100644 index 00000000..353144f6 --- /dev/null +++ b/test/towerops/integrations/integration_test.exs @@ -0,0 +1,103 @@ +defmodule Towerops.Integrations.IntegrationTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Integrations.Integration + + setup do + user = user_fixture() + organization = organization_fixture(user.id) + %{organization: organization} + end + + describe "changeset/2" do + test "valid changeset with required fields", %{organization: org} do + attrs = %{ + organization_id: org.id, + provider: "preseem", + enabled: true, + credentials: %{"api_key" => "test-key-123"} + } + + changeset = Integration.changeset(%Integration{}, attrs) + assert changeset.valid? + end + + test "requires organization_id" do + attrs = %{provider: "preseem"} + changeset = Integration.changeset(%Integration{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).organization_id + end + + test "requires provider" do + attrs = %{organization_id: Ecto.UUID.generate()} + changeset = Integration.changeset(%Integration{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).provider + end + + test "validates provider is a known value" do + attrs = %{organization_id: Ecto.UUID.generate(), provider: "unknown_provider"} + changeset = Integration.changeset(%Integration{}, attrs) + refute changeset.valid? + assert "is invalid" in errors_on(changeset).provider + end + + test "validates sync_interval_minutes is positive" do + attrs = %{ + organization_id: Ecto.UUID.generate(), + provider: "preseem", + sync_interval_minutes: 0 + } + + changeset = Integration.changeset(%Integration{}, attrs) + refute changeset.valid? + assert "must be greater than 0" in errors_on(changeset).sync_interval_minutes + end + + test "validates last_sync_status is a known value" do + attrs = %{ + organization_id: Ecto.UUID.generate(), + provider: "preseem", + last_sync_status: "bogus" + } + + changeset = Integration.changeset(%Integration{}, attrs) + refute changeset.valid? + assert "is invalid" in errors_on(changeset).last_sync_status + end + + test "enforces unique constraint on org + provider", %{organization: org} do + attrs = %{organization_id: org.id, provider: "preseem"} + {:ok, _} = %Integration{} |> Integration.changeset(attrs) |> Repo.insert() + assert {:error, changeset} = %Integration{} |> Integration.changeset(attrs) |> Repo.insert() + assert "has already been taken" in errors_on(changeset).provider + end + end + + describe "credentials encryption" do + test "credentials are encrypted at rest", %{organization: org} do + attrs = %{ + organization_id: org.id, + provider: "preseem", + credentials: %{"api_key" => "super-secret-key"} + } + + {:ok, integration} = %Integration{} |> Integration.changeset(attrs) |> Repo.insert() + reloaded = Repo.get!(Integration, integration.id) + assert reloaded.credentials == %{"api_key" => "super-secret-key"} + + raw = + Repo.one( + from i in "integrations", + where: i.id == type(^integration.id, :binary_id), + select: i.credentials + ) + + refute raw == Jason.encode!(attrs.credentials) + end + end +end From e302b8e9c4e9f92173a82d15daaa5288e353aae0 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 12 Feb 2026 16:55:57 -0600 Subject: [PATCH 03/20] add integrations context with CRUD operations --- lib/towerops/integrations.ex | 62 +++++++++++++ test/towerops/integrations_test.exs | 137 ++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 lib/towerops/integrations.ex create mode 100644 test/towerops/integrations_test.exs diff --git a/lib/towerops/integrations.ex b/lib/towerops/integrations.ex new file mode 100644 index 00000000..a025c2ec --- /dev/null +++ b/lib/towerops/integrations.ex @@ -0,0 +1,62 @@ +defmodule Towerops.Integrations do + @moduledoc """ + Context for managing third-party integrations per organization. + """ + import Ecto.Query + + alias Towerops.Integrations.Integration + alias Towerops.Repo + + def list_integrations(organization_id) do + Integration + |> where(organization_id: ^organization_id) + |> order_by(:provider) + |> Repo.all() + end + + def get_integration(organization_id, provider) do + case Repo.get_by(Integration, organization_id: organization_id, provider: provider) do + nil -> {:error, :not_found} + integration -> {:ok, integration} + end + end + + def get_integration!(organization_id, provider) do + Repo.get_by!(Integration, organization_id: organization_id, provider: provider) + end + + def create_integration(organization_id, attrs) do + %Integration{} + |> Integration.changeset(Map.put(attrs, :organization_id, organization_id)) + |> Repo.insert() + end + + def update_integration(%Integration{} = integration, attrs) do + integration + |> Integration.changeset(attrs) + |> Repo.update() + end + + def update_sync_status(%Integration{} = integration, status) do + integration + |> Integration.changeset(%{ + last_sync_status: status, + last_synced_at: DateTime.truncate(DateTime.utc_now(), :second) + }) + |> Repo.update() + end + + def delete_integration(%Integration{} = integration) do + Repo.delete(integration) + end + + def change_integration(%Integration{} = integration, attrs \\ %{}) do + Integration.changeset(integration, attrs) + end + + def list_enabled_integrations(provider) do + Integration + |> where(provider: ^provider, enabled: true) + |> Repo.all() + end +end diff --git a/test/towerops/integrations_test.exs b/test/towerops/integrations_test.exs new file mode 100644 index 00000000..c3869a11 --- /dev/null +++ b/test/towerops/integrations_test.exs @@ -0,0 +1,137 @@ +defmodule Towerops.IntegrationsTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Integrations + alias Towerops.Integrations.Integration + + setup do + user = user_fixture() + organization = organization_fixture(user.id) + %{organization: organization} + end + + describe "list_integrations/1" do + test "returns all integrations for an organization", %{organization: org} do + {:ok, _} = Integrations.create_integration(org.id, %{provider: "preseem"}) + integrations = Integrations.list_integrations(org.id) + assert length(integrations) == 1 + end + + test "does not return integrations from other orgs", %{organization: org} do + user2 = user_fixture() + org2 = organization_fixture(user2.id) + {:ok, _} = Integrations.create_integration(org2.id, %{provider: "preseem"}) + assert Integrations.list_integrations(org.id) == [] + end + end + + describe "get_integration/2" do + test "returns integration by org and provider", %{organization: org} do + {:ok, created} = Integrations.create_integration(org.id, %{provider: "preseem"}) + assert {:ok, fetched} = Integrations.get_integration(org.id, "preseem") + assert fetched.id == created.id + end + + test "returns error when not found", %{organization: org} do + assert {:error, :not_found} = Integrations.get_integration(org.id, "preseem") + end + end + + describe "get_integration!/2" do + test "returns integration by org and provider", %{organization: org} do + {:ok, created} = Integrations.create_integration(org.id, %{provider: "preseem"}) + fetched = Integrations.get_integration!(org.id, "preseem") + assert fetched.id == created.id + end + + test "raises when not found", %{organization: org} do + assert_raise Ecto.NoResultsError, fn -> + Integrations.get_integration!(org.id, "preseem") + end + end + end + + describe "create_integration/2" do + test "creates with valid attrs", %{organization: org} do + attrs = %{provider: "preseem", credentials: %{"api_key" => "test-123"}, enabled: true} + assert {:ok, %Integration{} = integration} = Integrations.create_integration(org.id, attrs) + assert integration.provider == "preseem" + assert integration.credentials == %{"api_key" => "test-123"} + assert integration.enabled == true + assert integration.organization_id == org.id + end + + test "rejects duplicate provider per org", %{organization: org} do + {:ok, _} = Integrations.create_integration(org.id, %{provider: "preseem"}) + assert {:error, changeset} = Integrations.create_integration(org.id, %{provider: "preseem"}) + assert "has already been taken" in errors_on(changeset).provider + end + end + + describe "update_integration/2" do + test "updates credentials", %{organization: org} do + {:ok, integration} = + Integrations.create_integration(org.id, %{ + provider: "preseem", + credentials: %{"api_key" => "old-key"} + }) + + assert {:ok, updated} = + Integrations.update_integration(integration, %{credentials: %{"api_key" => "new-key"}}) + + assert updated.credentials == %{"api_key" => "new-key"} + end + + test "updates enabled flag", %{organization: org} do + {:ok, integration} = + Integrations.create_integration(org.id, %{provider: "preseem", enabled: false}) + + assert {:ok, updated} = Integrations.update_integration(integration, %{enabled: true}) + assert updated.enabled == true + end + end + + describe "update_sync_status/2" do + test "updates last_synced_at and last_sync_status", %{organization: org} do + {:ok, integration} = Integrations.create_integration(org.id, %{provider: "preseem"}) + assert integration.last_sync_status == "never" + assert integration.last_synced_at == nil + + assert {:ok, updated} = Integrations.update_sync_status(integration, "success") + assert updated.last_sync_status == "success" + assert updated.last_synced_at + end + end + + describe "delete_integration/1" do + test "deletes the integration", %{organization: org} do + {:ok, integration} = Integrations.create_integration(org.id, %{provider: "preseem"}) + assert {:ok, _} = Integrations.delete_integration(integration) + assert {:error, :not_found} = Integrations.get_integration(org.id, "preseem") + end + end + + describe "change_integration/2" do + test "returns a changeset", %{organization: org} do + {:ok, integration} = Integrations.create_integration(org.id, %{provider: "preseem"}) + assert %Ecto.Changeset{} = Integrations.change_integration(integration) + end + end + + describe "list_enabled_integrations/1" do + test "returns only enabled integrations for a provider", %{organization: org} do + {:ok, _} = Integrations.create_integration(org.id, %{provider: "preseem", enabled: true}) + + user2 = user_fixture() + org2 = organization_fixture(user2.id) + {:ok, _} = Integrations.create_integration(org2.id, %{provider: "preseem", enabled: false}) + + enabled = Integrations.list_enabled_integrations("preseem") + assert length(enabled) == 1 + assert hd(enabled).organization_id == org.id + end + end +end From 06f822dd50e49e8f579c160744a86e235a7e7b23 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 12 Feb 2026 16:58:16 -0600 Subject: [PATCH 04/20] add preseem API client with test connection support --- lib/towerops/preseem/client.ex | 103 ++++++++++++++++++++++++++ test/towerops/preseem/client_test.exs | 78 +++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 lib/towerops/preseem/client.ex create mode 100644 test/towerops/preseem/client_test.exs diff --git a/lib/towerops/preseem/client.ex b/lib/towerops/preseem/client.ex new file mode 100644 index 00000000..f8317d8b --- /dev/null +++ b/lib/towerops/preseem/client.ex @@ -0,0 +1,103 @@ +defmodule Towerops.Preseem.Client do + @moduledoc """ + HTTP client for the Preseem Model API. + + Uses Req with built-in test support via `Req.Test`. + """ + + require Logger + + @default_base_url "https://apidocs.preseem.com/model/v1" + + @doc """ + Tests the connection to the Preseem API by fetching account info. + + Returns `{:ok, body}` on success, `{:error, reason}` on failure. + """ + def test_connection(api_key, opts \\ []) do + base_url = Keyword.get(opts, :base_url, @default_base_url) + + case request(:get, "#{base_url}/account", api_key) do + {:ok, %{status: status, body: body}} when status in 200..299 -> + {:ok, body} + + {:ok, %{status: 401}} -> + {:error, :unauthorized} + + {:ok, %{status: 403}} -> + {:error, :forbidden} + + {:ok, %{status: status, body: body}} -> + Logger.warning("Preseem API unexpected status #{status}: #{inspect(body)}") + {:error, {:unexpected_status, status}} + + {:error, reason} -> + Logger.error("Preseem API connection error: #{inspect(reason)}") + {:error, reason} + end + end + + @doc """ + Lists all access points from the Preseem API. + + Returns `{:ok, [access_point]}` on success. + """ + def list_access_points(api_key, opts \\ []) do + base_url = Keyword.get(opts, :base_url, @default_base_url) + + case request(:get, "#{base_url}/access_points", api_key) do + {:ok, %{status: status, body: %{"data" => data}}} when status in 200..299 -> + {:ok, data} + + {:ok, %{status: 401}} -> + {:error, :unauthorized} + + {:ok, %{status: 403}} -> + {:error, :forbidden} + + {:ok, %{status: status, body: body}} -> + {:error, {:unexpected_status, status, body}} + + {:error, reason} -> + {:error, reason} + end + end + + @doc """ + Fetches metrics for a specific access point. + + Returns `{:ok, metrics_map}` on success. + """ + def get_access_point_metrics(api_key, ap_id, opts \\ []) do + base_url = Keyword.get(opts, :base_url, @default_base_url) + + case request(:get, "#{base_url}/access_points/#{ap_id}/metrics", api_key) do + {:ok, %{status: status, body: %{"data" => data}}} when status in 200..299 -> + {:ok, data} + + {:ok, %{status: 401}} -> + {:error, :unauthorized} + + {:ok, %{status: 403}} -> + {:error, :forbidden} + + {:ok, %{status: status, body: body}} -> + {:error, {:unexpected_status, status, body}} + + {:error, reason} -> + {:error, reason} + end + end + + defp request(method, url, api_key) do + Req.request( + method: method, + url: url, + headers: [{"authorization", "Bearer #{api_key}"}, {"accept", "application/json"}], + plug: {Req.Test, __MODULE__} + ) + rescue + exception -> + {:error, Exception.message(exception)} + end +end diff --git a/test/towerops/preseem/client_test.exs b/test/towerops/preseem/client_test.exs new file mode 100644 index 00000000..4c3a773a --- /dev/null +++ b/test/towerops/preseem/client_test.exs @@ -0,0 +1,78 @@ +defmodule Towerops.Preseem.ClientTest do + use ExUnit.Case, async: true + + alias Towerops.Preseem.Client + + describe "test_connection/1" do + test "returns ok when API responds with 200" do + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, %{"status" => "ok", "account" => "test-isp"}) + end) + + assert {:ok, %{"status" => "ok"}} = Client.test_connection("valid-api-key") + end + + test "returns error for 401 unauthorized" do + Req.Test.stub(Client, fn conn -> + conn + |> Plug.Conn.put_status(401) + |> Req.Test.json(%{"error" => "unauthorized"}) + end) + + assert {:error, :unauthorized} = Client.test_connection("bad-key") + end + + test "returns error for 403 forbidden" do + Req.Test.stub(Client, fn conn -> + conn + |> Plug.Conn.put_status(403) + |> Req.Test.json(%{"error" => "forbidden"}) + end) + + assert {:error, :forbidden} = Client.test_connection("restricted-key") + end + end + + describe "list_access_points/1" do + test "returns list of access points on success" do + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, %{ + "data" => [ + %{"id" => "ap-1", "name" => "Tower1-AP1", "ip" => "10.0.0.1"}, + %{"id" => "ap-2", "name" => "Tower1-AP2", "ip" => "10.0.0.2"} + ] + }) + end) + + assert {:ok, [ap1, ap2]} = Client.list_access_points("valid-key") + assert ap1["id"] == "ap-1" + assert ap2["id"] == "ap-2" + end + + test "returns error for 401" do + Req.Test.stub(Client, fn conn -> + conn |> Plug.Conn.put_status(401) |> Req.Test.json(%{"error" => "unauthorized"}) + end) + + assert {:error, :unauthorized} = Client.list_access_points("bad-key") + end + end + + describe "get_access_point_metrics/2" do + test "returns metrics for an AP" do + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, %{ + "data" => %{ + "qoe_score" => 85.5, + "capacity_score" => 72.0, + "subscriber_count" => 42, + "p95_latency" => 18.3 + } + }) + end) + + assert {:ok, metrics} = Client.get_access_point_metrics("valid-key", "ap-1") + assert metrics["qoe_score"] == 85.5 + end + end +end From 9ab0380c87656e3231eef0e36d8565dc33ed66cb Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 12 Feb 2026 17:02:26 -0600 Subject: [PATCH 05/20] add integrations settings page with preseem configuration --- .../live/org/integrations_live.ex | 200 ++++++++++++++++++ .../live/org/integrations_live.html.heex | 199 +++++++++++++++++ .../live/org/settings_live.html.heex | 8 + lib/towerops_web/router.ex | 1 + .../live/org/integrations_live_test.exs | 82 +++++++ 5 files changed, 490 insertions(+) create mode 100644 lib/towerops_web/live/org/integrations_live.ex create mode 100644 lib/towerops_web/live/org/integrations_live.html.heex create mode 100644 test/towerops_web/live/org/integrations_live_test.exs diff --git a/lib/towerops_web/live/org/integrations_live.ex b/lib/towerops_web/live/org/integrations_live.ex new file mode 100644 index 00000000..3a6346ad --- /dev/null +++ b/lib/towerops_web/live/org/integrations_live.ex @@ -0,0 +1,200 @@ +defmodule ToweropsWeb.Org.IntegrationsLive do + @moduledoc false + use ToweropsWeb, :live_view + + alias Towerops.Integrations + alias Towerops.Integrations.Integration + alias Towerops.Preseem.Client, as: PreseemClient + + @providers [ + %{ + id: "preseem", + name: "Preseem", + description: "QoE monitoring and subscriber experience analytics for wireless ISPs.", + icon: "hero-signal" + } + ] + + @impl true + def mount(_params, _session, socket) do + organization = socket.assigns.current_scope.organization + integrations = load_integrations(organization.id) + + {:ok, + socket + |> assign(:organization, organization) + |> assign(:providers, @providers) + |> assign(:integrations, integrations) + |> assign(:configuring, nil) + |> assign(:form, nil) + |> assign(:test_result, nil)} + end + + @impl true + def handle_event("configure", %{"provider" => provider}, socket) do + integration = Map.get(socket.assigns.integrations, provider) + + form = + case integration do + nil -> + %Integration{} + |> Integrations.change_integration(%{provider: provider}) + |> to_form() + + existing -> + existing + |> Integrations.change_integration(%{}) + |> to_form() + end + + {:noreply, + socket + |> assign(:configuring, provider) + |> assign(:form, form) + |> assign(:test_result, nil)} + end + + @impl true + def handle_event("close_config", _params, socket) do + {:noreply, + socket + |> assign(:configuring, nil) + |> assign(:form, nil) + |> assign(:test_result, nil)} + end + + @impl true + def handle_event("validate", %{"integration" => params}, socket) do + integration = get_current_integration(socket) + + changeset = + integration + |> Integrations.change_integration(normalize_params(params)) + |> Map.put(:action, :validate) + + {:noreply, assign(socket, :form, to_form(changeset))} + end + + @impl true + def handle_event("save", %{"integration" => params}, socket) do + organization = socket.assigns.organization + provider = socket.assigns.configuring + existing = Map.get(socket.assigns.integrations, provider) + attrs = normalize_params(params) + + result = + case existing do + nil -> + Integrations.create_integration(organization.id, Map.put(attrs, :provider, provider)) + + integration -> + Integrations.update_integration(integration, attrs) + end + + case result do + {:ok, _integration} -> + integrations = load_integrations(organization.id) + + {:noreply, + socket + |> assign(:integrations, integrations) + |> assign(:configuring, nil) + |> assign(:form, nil) + |> assign(:test_result, nil) + |> put_flash(:info, "Integration saved successfully")} + + {:error, changeset} -> + {:noreply, assign(socket, :form, to_form(changeset))} + end + end + + @impl true + def handle_event("test_connection", _params, socket) do + api_key = extract_api_key(socket) + + if api_key == "" or is_nil(api_key) do + {:noreply, assign(socket, :test_result, {:error, "Please enter an API key first"})} + else + case PreseemClient.test_connection(api_key) do + {:ok, _body} -> + {:noreply, assign(socket, :test_result, {:ok, "Connection successful"})} + + {:error, :unauthorized} -> + {:noreply, assign(socket, :test_result, {:error, "Invalid API key"})} + + {:error, :forbidden} -> + {:noreply, assign(socket, :test_result, {:error, "Access forbidden"})} + + {:error, reason} -> + {:noreply, assign(socket, :test_result, {:error, "Connection failed: #{inspect(reason)}"})} + end + end + end + + @impl true + def handle_event("toggle_enabled", %{"provider" => provider}, socket) do + case Map.get(socket.assigns.integrations, provider) do + nil -> + {:noreply, socket} + + integration -> + case Integrations.update_integration(integration, %{enabled: !integration.enabled}) do + {:ok, _updated} -> + integrations = load_integrations(socket.assigns.organization.id) + + {:noreply, + socket + |> assign(:integrations, integrations) + |> put_flash(:info, "Integration updated")} + + {:error, _changeset} -> + {:noreply, put_flash(socket, :error, "Failed to update integration")} + end + end + end + + defp load_integrations(organization_id) do + organization_id + |> Integrations.list_integrations() + |> Map.new(fn integration -> {integration.provider, integration} end) + end + + defp get_current_integration(socket) do + provider = socket.assigns.configuring + + case Map.get(socket.assigns.integrations, provider) do + nil -> %Integration{} + integration -> integration + end + end + + defp normalize_params(params) do + api_key = Map.get(params, "api_key", "") + base_url = Map.get(params, "base_url", "") + + credentials = + then(%{"api_key" => api_key}, fn creds -> + if base_url == "", do: creds, else: Map.put(creds, "base_url", base_url) + end) + + %{credentials: credentials} + end + + defp get_credential(nil, _key), do: "" + + defp get_credential(%Integration{credentials: credentials}, key) when is_map(credentials) do + Map.get(credentials, key, "") + end + + defp get_credential(_integration, _key), do: "" + + defp extract_api_key(socket) do + changeset = socket.assigns.form.source + data = Ecto.Changeset.apply_changes(changeset) + + case data.credentials do + %{"api_key" => key} -> key + _ -> nil + end + end +end diff --git a/lib/towerops_web/live/org/integrations_live.html.heex b/lib/towerops_web/live/org/integrations_live.html.heex new file mode 100644 index 00000000..765a03a8 --- /dev/null +++ b/lib/towerops_web/live/org/integrations_live.html.heex @@ -0,0 +1,199 @@ + +
+
+ <.link + navigate={~p"/orgs/#{@organization.slug}/settings"} + class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white" + > + <.icon name="hero-arrow-left" class="h-4 w-4" /> Back to Settings + +
+

+ Integrations +

+

+ Connect third-party services to enhance your monitoring capabilities. +

+
+ +
+
+
+
+
+ <.icon name={provider.icon} class="h-6 w-6 text-indigo-600 dark:text-indigo-400" /> +
+
+

+ {provider.name} +

+

+ {provider.description} +

+ + <%= if integration = @integrations[provider.id] do %> +
+ + {if integration.enabled, do: "Enabled", else: "Disabled"} + + + <%= if integration.last_synced_at do %> + + Last synced: {Calendar.strftime( + integration.last_synced_at, + "%Y-%m-%d %H:%M UTC" + )} + + <% end %> + + <%= if integration.last_sync_status && integration.last_sync_status != "never" do %> + + "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" + + "partial" -> + "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400" + + "failed" -> + "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400" + + _ -> + "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400" + end + ]}> + {String.capitalize(integration.last_sync_status)} + + <% end %> +
+ <% end %> +
+
+ +
+ <%= if integration = @integrations[provider.id] do %> + + <% end %> + + +
+
+ + <%= if @configuring == provider.id do %> +
+ <.form + for={@form} + id={"#{provider.id}-form"} + phx-change="validate" + phx-submit="save" + > +
+ <.input + field={@form[:api_key]} + type="password" + label="API Key" + placeholder="Enter your Preseem API key" + autocomplete="off" + value={get_credential(@integrations[provider.id], "api_key")} + /> + + <.input + field={@form[:base_url]} + type="text" + label="Base URL (optional)" + placeholder="https://apidocs.preseem.com/model/v1" + value={get_credential(@integrations[provider.id], "base_url")} + /> + + <%= if @test_result do %> +
"bg-green-50 dark:bg-green-900/20" + {:error, _} -> "bg-red-50 dark:bg-red-900/20" + end + ]}> +

"text-green-800 dark:text-green-200" + {:error, _} -> "text-red-800 dark:text-red-200" + end + ]}> + {elem(@test_result, 1)} +

+
+ <% end %> + +
+ + +
+ + +
+
+
+ +
+ <% end %> +
+
+
diff --git a/lib/towerops_web/live/org/settings_live.html.heex b/lib/towerops_web/live/org/settings_live.html.heex index 83c96647..fd51a558 100644 --- a/lib/towerops_web/live/org/settings_live.html.heex +++ b/lib/towerops_web/live/org/settings_live.html.heex @@ -17,6 +17,14 @@

Manage organization defaults for SNMP, agents, and MikroTik API configuration

+
+ <.link + navigate={~p"/orgs/#{@organization.slug}/settings/integrations"} + class="inline-flex items-center gap-2 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:ring-white/10 dark:hover:bg-white/20" + > + <.icon name="hero-puzzle-piece" class="h-4 w-4" /> Integrations + +
<.form diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index 4f61a60a..cd77ce0c 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -309,6 +309,7 @@ defmodule ToweropsWeb.Router do pipe_through [:browser, :require_authenticated_user, :load_current_organization] live "/settings", Org.SettingsLive, :index + live "/settings/integrations", Org.IntegrationsLive, :index end end diff --git a/test/towerops_web/live/org/integrations_live_test.exs b/test/towerops_web/live/org/integrations_live_test.exs new file mode 100644 index 00000000..0e94d330 --- /dev/null +++ b/test/towerops_web/live/org/integrations_live_test.exs @@ -0,0 +1,82 @@ +defmodule ToweropsWeb.Org.IntegrationsLiveTest do + use ToweropsWeb.ConnCase + + import Phoenix.LiveViewTest + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Preseem.Client, as: PreseemClient + + setup do + user = user_fixture() + organization = organization_fixture(user.id) + %{user: user, organization: organization} + end + + describe "index" do + test "renders integrations page", %{conn: conn, user: user, organization: org} do + {:ok, _view, html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations") + + assert html =~ "Integrations" + assert html =~ "Preseem" + end + + test "shows preseem description", %{conn: conn, user: user, organization: org} do + {:ok, _view, html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations") + + assert html =~ "QoE monitoring" + end + + test "can open preseem configuration", %{conn: conn, user: user, organization: org} do + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations") + + html = view |> element("#configure-preseem") |> render_click() + assert html =~ "API Key" + end + + test "can save preseem credentials", %{conn: conn, user: user, organization: org} do + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations") + + view |> element("#configure-preseem") |> render_click() + + view + |> form("#preseem-form", %{integration: %{api_key: "test-key-123"}}) + |> render_submit() + + assert {:ok, integration} = Towerops.Integrations.get_integration(org.id, "preseem") + assert integration.credentials["api_key"] == "test-key-123" + end + + test "can test preseem connection successfully", %{conn: conn, user: user, organization: org} do + Req.Test.stub(PreseemClient, fn conn -> + Req.Test.json(conn, %{"status" => "ok"}) + end) + + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations") + + view |> element("#configure-preseem") |> render_click() + + view + |> form("#preseem-form", %{integration: %{api_key: "test-key"}}) + |> render_change() + + html = view |> element("#test-connection") |> render_click() + assert html =~ "Connection successful" + end + end +end From 9f3eb554766974148c6bd20f3ba285f574387683 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 12 Feb 2026 17:03:00 -0600 Subject: [PATCH 06/20] add integrations test fixture --- .../support/fixtures/integrations_fixtures.ex | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 test/support/fixtures/integrations_fixtures.ex diff --git a/test/support/fixtures/integrations_fixtures.ex b/test/support/fixtures/integrations_fixtures.ex new file mode 100644 index 00000000..0f1a536e --- /dev/null +++ b/test/support/fixtures/integrations_fixtures.ex @@ -0,0 +1,19 @@ +defmodule Towerops.IntegrationsFixtures do + @moduledoc """ + Test helpers for creating integration entities. + """ + + alias Towerops.Integrations + + def integration_fixture(organization_id, attrs \\ %{}) do + attrs = + Enum.into(attrs, %{ + provider: "preseem", + enabled: true, + credentials: %{"api_key" => "test-key-#{System.unique_integer()}"} + }) + + {:ok, integration} = Integrations.create_integration(organization_id, attrs) + integration + end +end From 5f17d5aed14dc040f636bb466ba87f455072e792 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 12 Feb 2026 17:11:36 -0600 Subject: [PATCH 07/20] add preseem access points schema and migration --- lib/towerops/preseem/access_point.ex | 65 +++++++++++++++ ...212231026_create_preseem_access_points.exs | 35 ++++++++ test/towerops/preseem/access_point_test.exs | 82 +++++++++++++++++++ 3 files changed, 182 insertions(+) create mode 100644 lib/towerops/preseem/access_point.ex create mode 100644 priv/repo/migrations/20260212231026_create_preseem_access_points.exs create mode 100644 test/towerops/preseem/access_point_test.exs diff --git a/lib/towerops/preseem/access_point.ex b/lib/towerops/preseem/access_point.ex new file mode 100644 index 00000000..4a56cea0 --- /dev/null +++ b/lib/towerops/preseem/access_point.ex @@ -0,0 +1,65 @@ +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 + + @valid_match_confidences ~w(auto_mac auto_ip auto_hostname manual ambiguous unmatched) + + schema "preseem_access_points" do + field :preseem_id, :string + field :name, :string + field :mac_address, :string + field :ip_address, :string + field :model, :string + field :firmware, :string + field :capacity_score, :float + field :qoe_score, :float + field :rf_score, :float + field :busy_hours, :integer + field :airtime_utilization, :float + field :subscriber_count, :integer + field :match_confidence, :string, default: "unmatched" + field :raw_data, :map + + belongs_to :organization, Towerops.Organizations.Organization + belongs_to :device, Towerops.Devices.Device + + timestamps(type: :utc_datetime) + end + + def changeset(access_point, attrs) do + access_point + |> cast(attrs, [ + :organization_id, + :preseem_id, + :name, + :mac_address, + :ip_address, + :model, + :firmware, + :capacity_score, + :qoe_score, + :rf_score, + :busy_hours, + :airtime_utilization, + :subscriber_count, + :device_id, + :match_confidence, + :raw_data + ]) + |> validate_required([:organization_id, :preseem_id]) + |> validate_inclusion(:match_confidence, @valid_match_confidences) + |> unique_constraint([:organization_id, :preseem_id], + error_key: :preseem_id, + message: "has already been taken" + ) + |> foreign_key_constraint(:organization_id) + |> foreign_key_constraint(:device_id) + end +end diff --git a/priv/repo/migrations/20260212231026_create_preseem_access_points.exs b/priv/repo/migrations/20260212231026_create_preseem_access_points.exs new file mode 100644 index 00000000..6769bd31 --- /dev/null +++ b/priv/repo/migrations/20260212231026_create_preseem_access_points.exs @@ -0,0 +1,35 @@ +defmodule Towerops.Repo.Migrations.CreatePreseemAccessPoints do + use Ecto.Migration + + def change do + create table(:preseem_access_points, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all), + null: false + + add :preseem_id, :string, null: false + add :name, :string + add :mac_address, :string + add :ip_address, :string + add :model, :string + add :firmware, :string + add :capacity_score, :float + add :qoe_score, :float + add :rf_score, :float + add :busy_hours, :integer + add :airtime_utilization, :float + add :subscriber_count, :integer + add :device_id, references(:devices, type: :binary_id, on_delete: :nilify_all) + add :match_confidence, :string, default: "unmatched" + add :raw_data, :map + + timestamps(type: :utc_datetime) + end + + create unique_index(:preseem_access_points, [:organization_id, :preseem_id]) + create index(:preseem_access_points, [:organization_id]) + create index(:preseem_access_points, [:device_id]) + create index(:preseem_access_points, [:match_confidence]) + end +end diff --git a/test/towerops/preseem/access_point_test.exs b/test/towerops/preseem/access_point_test.exs new file mode 100644 index 00000000..d22cd8f3 --- /dev/null +++ b/test/towerops/preseem/access_point_test.exs @@ -0,0 +1,82 @@ +defmodule Towerops.Preseem.AccessPointTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Preseem.AccessPoint + + setup do + user = user_fixture() + org = organization_fixture(user.id) + %{organization: org} + end + + describe "changeset/2" do + test "valid changeset with required fields", %{organization: org} do + attrs = %{organization_id: org.id, preseem_id: "ap-123", name: "Tower1-AP1"} + changeset = AccessPoint.changeset(%AccessPoint{}, attrs) + assert changeset.valid? + end + + test "requires organization_id" do + changeset = AccessPoint.changeset(%AccessPoint{}, %{preseem_id: "ap-1"}) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).organization_id + end + + test "requires preseem_id" do + changeset = AccessPoint.changeset(%AccessPoint{}, %{organization_id: Ecto.UUID.generate()}) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).preseem_id + end + + test "validates match_confidence is a known value" do + attrs = %{organization_id: Ecto.UUID.generate(), preseem_id: "ap-1", match_confidence: "bogus"} + changeset = AccessPoint.changeset(%AccessPoint{}, attrs) + refute changeset.valid? + assert "is invalid" in errors_on(changeset).match_confidence + end + + test "accepts all valid match_confidence values" do + for confidence <- ~w(auto_mac auto_ip auto_hostname manual ambiguous unmatched) do + attrs = %{organization_id: Ecto.UUID.generate(), preseem_id: "ap-1", match_confidence: confidence} + changeset = AccessPoint.changeset(%AccessPoint{}, attrs) + assert changeset.valid?, "Expected #{confidence} to be valid" + end + end + + test "enforces unique preseem_id per org", %{organization: org} do + attrs = %{organization_id: org.id, preseem_id: "ap-123"} + {:ok, _} = %AccessPoint{} |> AccessPoint.changeset(attrs) |> Repo.insert() + assert {:error, changeset} = %AccessPoint{} |> AccessPoint.changeset(attrs) |> Repo.insert() + assert "has already been taken" in errors_on(changeset).preseem_id + end + + test "stores and retrieves raw_data", %{organization: org} do + raw = %{"extra_field" => "value", "nested" => %{"key" => 42}} + attrs = %{organization_id: org.id, preseem_id: "ap-raw", raw_data: raw} + {:ok, ap} = %AccessPoint{} |> AccessPoint.changeset(attrs) |> Repo.insert() + reloaded = Repo.get!(AccessPoint, ap.id) + assert reloaded.raw_data == raw + end + + test "stores numeric scores", %{organization: org} do + attrs = %{ + organization_id: org.id, + preseem_id: "ap-scores", + qoe_score: 85.5, + capacity_score: 72.0, + rf_score: 90.1, + busy_hours: 4, + airtime_utilization: 65.3, + subscriber_count: 42 + } + + {:ok, ap} = %AccessPoint{} |> AccessPoint.changeset(attrs) |> Repo.insert() + reloaded = Repo.get!(AccessPoint, ap.id) + assert reloaded.qoe_score == 85.5 + assert reloaded.subscriber_count == 42 + end + end +end From 26e19b00f77243a000b2dd0f7924d9c1fdbf2763 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 12 Feb 2026 17:12:19 -0600 Subject: [PATCH 08/20] add preseem sync logs schema and migration --- lib/towerops/preseem/sync_log.ex | 41 +++++++++ ...0260212231109_create_preseem_sync_logs.exs | 26 ++++++ test/towerops/preseem/sync_log_test.exs | 84 +++++++++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 lib/towerops/preseem/sync_log.ex create mode 100644 priv/repo/migrations/20260212231109_create_preseem_sync_logs.exs create mode 100644 test/towerops/preseem/sync_log_test.exs diff --git a/lib/towerops/preseem/sync_log.ex b/lib/towerops/preseem/sync_log.ex new file mode 100644 index 00000000..ca2dee7b --- /dev/null +++ b/lib/towerops/preseem/sync_log.ex @@ -0,0 +1,41 @@ +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 + + @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) + end +end diff --git a/priv/repo/migrations/20260212231109_create_preseem_sync_logs.exs b/priv/repo/migrations/20260212231109_create_preseem_sync_logs.exs new file mode 100644 index 00000000..c2598a62 --- /dev/null +++ b/priv/repo/migrations/20260212231109_create_preseem_sync_logs.exs @@ -0,0 +1,26 @@ +defmodule Towerops.Repo.Migrations.CreatePreseemSyncLogs do + use Ecto.Migration + + def change do + create table(:preseem_sync_logs, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all), + null: false + + add :integration_id, references(:integrations, type: :binary_id, on_delete: :delete_all), + null: false + + add :status, :string, null: false + add :records_synced, :integer, default: 0 + add :errors, :map + add :duration_ms, :integer + + add :inserted_at, :utc_datetime, null: false + end + + create index(:preseem_sync_logs, [:organization_id]) + create index(:preseem_sync_logs, [:integration_id]) + create index(:preseem_sync_logs, [:inserted_at]) + end +end diff --git a/test/towerops/preseem/sync_log_test.exs b/test/towerops/preseem/sync_log_test.exs new file mode 100644 index 00000000..31692682 --- /dev/null +++ b/test/towerops/preseem/sync_log_test.exs @@ -0,0 +1,84 @@ +defmodule Towerops.Preseem.SyncLogTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.IntegrationsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Preseem.SyncLog + + setup do + user = user_fixture() + org = organization_fixture(user.id) + integration = integration_fixture(org.id) + %{organization: org, integration: integration} + end + + describe "changeset/2" do + test "valid changeset", %{organization: org, integration: integration} do + attrs = %{ + organization_id: org.id, + integration_id: integration.id, + status: "success", + records_synced: 42, + duration_ms: 1500, + inserted_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + changeset = SyncLog.changeset(%SyncLog{}, attrs) + assert changeset.valid? + end + + test "requires organization_id and integration_id" do + attrs = %{status: "success", inserted_at: DateTime.truncate(DateTime.utc_now(), :second)} + changeset = SyncLog.changeset(%SyncLog{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).organization_id + assert "can't be blank" in errors_on(changeset).integration_id + end + + test "requires status" do + attrs = %{ + organization_id: Ecto.UUID.generate(), + integration_id: Ecto.UUID.generate(), + inserted_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + changeset = SyncLog.changeset(%SyncLog{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).status + end + + test "validates status values" do + base = %{ + organization_id: Ecto.UUID.generate(), + integration_id: Ecto.UUID.generate(), + inserted_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + for status <- ~w(success partial failed) do + changeset = SyncLog.changeset(%SyncLog{}, Map.put(base, :status, status)) + assert changeset.valid?, "Expected #{status} to be valid" + end + + changeset = SyncLog.changeset(%SyncLog{}, Map.put(base, :status, "bogus")) + refute changeset.valid? + end + + test "stores error details", %{organization: org, integration: integration} do + errors = %{"message" => "API timeout", "code" => 504} + + attrs = %{ + organization_id: org.id, + integration_id: integration.id, + status: "failed", + errors: errors, + inserted_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + {:ok, log} = %SyncLog{} |> SyncLog.changeset(attrs) |> Repo.insert() + reloaded = Repo.get!(SyncLog, log.id) + assert reloaded.errors == errors + end + end +end From 5118b7eb567b23aeaa518e29e8077ce32683c0e8 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 12 Feb 2026 17:12:53 -0600 Subject: [PATCH 09/20] add preseem subscriber metrics schema and migration --- lib/towerops/preseem/subscriber_metric.ex | 39 +++++++++++ ...1052_create_preseem_subscriber_metrics.exs | 24 +++++++ .../preseem/subscriber_metric_test.exs | 69 +++++++++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 lib/towerops/preseem/subscriber_metric.ex create mode 100644 priv/repo/migrations/20260212231052_create_preseem_subscriber_metrics.exs create mode 100644 test/towerops/preseem/subscriber_metric_test.exs diff --git a/lib/towerops/preseem/subscriber_metric.ex b/lib/towerops/preseem/subscriber_metric.ex new file mode 100644 index 00000000..b00f9f3f --- /dev/null +++ b/lib/towerops/preseem/subscriber_metric.ex @@ -0,0 +1,39 @@ +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 + + schema "preseem_subscriber_metrics" do + field :avg_latency, :float + field :avg_jitter, :float + field :avg_loss, :float + field :avg_throughput, :float + field :p95_latency, :float + field :subscriber_count, :integer + field :recorded_at, :utc_datetime + + belongs_to :preseem_access_point, Towerops.Preseem.AccessPoint + end + + def changeset(metric, attrs) do + metric + |> cast(attrs, [ + :preseem_access_point_id, + :avg_latency, + :avg_jitter, + :avg_loss, + :avg_throughput, + :p95_latency, + :subscriber_count, + :recorded_at + ]) + |> validate_required([:preseem_access_point_id, :recorded_at]) + |> foreign_key_constraint(:preseem_access_point_id) + end +end diff --git a/priv/repo/migrations/20260212231052_create_preseem_subscriber_metrics.exs b/priv/repo/migrations/20260212231052_create_preseem_subscriber_metrics.exs new file mode 100644 index 00000000..6ec2114d --- /dev/null +++ b/priv/repo/migrations/20260212231052_create_preseem_subscriber_metrics.exs @@ -0,0 +1,24 @@ +defmodule Towerops.Repo.Migrations.CreatePreseemSubscriberMetrics do + use Ecto.Migration + + def change do + create table(:preseem_subscriber_metrics, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :preseem_access_point_id, + references(:preseem_access_points, type: :binary_id, on_delete: :delete_all), + null: false + + add :avg_latency, :float + add :avg_jitter, :float + add :avg_loss, :float + add :avg_throughput, :float + add :p95_latency, :float + add :subscriber_count, :integer + add :recorded_at, :utc_datetime, null: false + end + + create index(:preseem_subscriber_metrics, [:preseem_access_point_id]) + create index(:preseem_subscriber_metrics, [:recorded_at]) + end +end diff --git a/test/towerops/preseem/subscriber_metric_test.exs b/test/towerops/preseem/subscriber_metric_test.exs new file mode 100644 index 00000000..44813ec3 --- /dev/null +++ b/test/towerops/preseem/subscriber_metric_test.exs @@ -0,0 +1,69 @@ +defmodule Towerops.Preseem.SubscriberMetricTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Preseem.AccessPoint + alias Towerops.Preseem.SubscriberMetric + + setup do + user = user_fixture() + org = organization_fixture(user.id) + + {:ok, ap} = + %AccessPoint{} + |> AccessPoint.changeset(%{organization_id: org.id, preseem_id: "ap-1", name: "Test AP"}) + |> Repo.insert() + + %{access_point: ap} + end + + describe "changeset/2" do + test "valid changeset", %{access_point: ap} do + attrs = %{ + preseem_access_point_id: ap.id, + avg_latency: 12.5, + avg_jitter: 3.2, + avg_loss: 0.5, + avg_throughput: 45.8, + p95_latency: 28.3, + subscriber_count: 42, + recorded_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + changeset = SubscriberMetric.changeset(%SubscriberMetric{}, attrs) + assert changeset.valid? + end + + test "requires preseem_access_point_id" do + attrs = %{recorded_at: DateTime.truncate(DateTime.utc_now(), :second)} + changeset = SubscriberMetric.changeset(%SubscriberMetric{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).preseem_access_point_id + end + + test "requires recorded_at" do + attrs = %{preseem_access_point_id: Ecto.UUID.generate()} + changeset = SubscriberMetric.changeset(%SubscriberMetric{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).recorded_at + end + + test "persists and reloads metrics", %{access_point: ap} do + attrs = %{ + preseem_access_point_id: ap.id, + avg_latency: 12.5, + p95_latency: 28.3, + subscriber_count: 42, + recorded_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + {:ok, metric} = %SubscriberMetric{} |> SubscriberMetric.changeset(attrs) |> Repo.insert() + reloaded = Repo.get!(SubscriberMetric, metric.id) + assert reloaded.avg_latency == 12.5 + assert reloaded.p95_latency == 28.3 + assert reloaded.subscriber_count == 42 + end + end +end From bf03a5a9a1ba4c43014397a55090c47c148f527e Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 12 Feb 2026 17:52:26 -0600 Subject: [PATCH 10/20] add preseem sync logic for AP upsert and metrics --- lib/towerops/preseem/sync.ex | 179 +++++++++++++++++++ test/towerops/preseem/sync_test.exs | 267 ++++++++++++++++++++++++++++ 2 files changed, 446 insertions(+) create mode 100644 lib/towerops/preseem/sync.ex create mode 100644 test/towerops/preseem/sync_test.exs diff --git a/lib/towerops/preseem/sync.ex b/lib/towerops/preseem/sync.ex new file mode 100644 index 00000000..ffcdb138 --- /dev/null +++ b/lib/towerops/preseem/sync.ex @@ -0,0 +1,179 @@ +defmodule Towerops.Preseem.Sync do + @moduledoc """ + Orchestrates syncing data from the Preseem API into the local database. + + Pulls access points (and optional metrics) from Preseem, upserts them + into `preseem_access_points`, and logs each sync operation. + """ + + alias Towerops.Integrations + alias Towerops.Preseem.AccessPoint + alias Towerops.Preseem.Client + alias Towerops.Preseem.DeviceMatcher + alias Towerops.Preseem.SubscriberMetric + alias Towerops.Preseem.SyncLog + alias Towerops.Repo + + require Logger + + @doc """ + Main entry point: syncs all access points for the given integration. + + Fetches APs from the Preseem API, upserts them, optionally inserts + metrics, writes a sync log, and updates the integration's sync status. + + Returns `{:ok, %{synced: count, matched: count}}` or `{:error, reason}`. + """ + def sync_organization(%Integrations.Integration{} = integration) do + start_time = System.monotonic_time(:millisecond) + api_key = integration.credentials["api_key"] + org_id = integration.organization_id + + opts = + case integration.credentials["base_url"] do + nil -> [] + base_url -> [base_url: base_url] + end + + case Client.list_access_points(api_key, opts) do + {:ok, ap_list} -> + handle_successful_sync(integration, org_id, ap_list, start_time) + + {:error, reason} -> + handle_failed_sync(integration, reason, start_time) + end + end + + @doc """ + Upserts a single access point from raw API response data. + + Maps API fields to schema fields and uses the unique constraint on + `[:organization_id, :preseem_id]` for conflict resolution. + """ + def upsert_access_point(organization_id, ap_data) do + attrs = %{ + organization_id: organization_id, + preseem_id: ap_data["id"], + name: ap_data["name"], + mac_address: ap_data["mac_address"], + ip_address: ap_data["ip_address"], + model: ap_data["model"], + firmware: ap_data["firmware"], + capacity_score: ap_data["capacity_score"], + qoe_score: ap_data["qoe_score"], + rf_score: ap_data["rf_score"], + busy_hours: ap_data["busy_hours"], + airtime_utilization: ap_data["airtime_utilization"], + subscriber_count: ap_data["subscriber_count"], + raw_data: ap_data + } + + %AccessPoint{} + |> AccessPoint.changeset(attrs) + |> Repo.insert( + on_conflict: + {:replace_all_except, [:id, :organization_id, :preseem_id, :device_id, :match_confidence, :inserted_at]}, + conflict_target: [:organization_id, :preseem_id], + returning: true + ) + end + + @doc """ + Inserts a subscriber metric record for the given access point. + """ + def insert_metrics(access_point_id, metrics_data) do + recorded_at = + case metrics_data["recorded_at"] do + nil -> DateTime.truncate(DateTime.utc_now(), :second) + dt_string -> parse_datetime(dt_string) + end + + attrs = %{ + preseem_access_point_id: access_point_id, + avg_latency: metrics_data["avg_latency"], + avg_jitter: metrics_data["avg_jitter"], + avg_loss: metrics_data["avg_loss"], + avg_throughput: metrics_data["avg_throughput"], + p95_latency: metrics_data["p95_latency"], + subscriber_count: metrics_data["subscriber_count"], + recorded_at: recorded_at + } + + %SubscriberMetric{} + |> SubscriberMetric.changeset(attrs) + |> Repo.insert() + end + + defp handle_successful_sync(integration, org_id, ap_list, start_time) do + synced_count = + Enum.reduce(ap_list, 0, fn ap_data, count -> + case upsert_access_point(org_id, ap_data) do + {:ok, ap} -> + maybe_insert_metrics(ap, ap_data) + count + 1 + + {:error, changeset} -> + Logger.warning("Failed to upsert Preseem AP: #{inspect(changeset.errors)}") + count + end + end) + + matched_count = run_device_matching(org_id) + duration_ms = System.monotonic_time(:millisecond) - start_time + + status = if synced_count == length(ap_list), do: "success", else: "partial" + + log_sync(integration, status, synced_count, %{}, duration_ms) + Integrations.update_sync_status(integration, status) + + {:ok, %{synced: synced_count, matched: matched_count}} + end + + defp handle_failed_sync(integration, reason, start_time) do + duration_ms = System.monotonic_time(:millisecond) - start_time + + log_sync(integration, "failed", 0, %{error: inspect(reason)}, duration_ms) + Integrations.update_sync_status(integration, "failed") + + {:error, reason} + end + + defp maybe_insert_metrics(ap, ap_data) do + case ap_data["metrics"] do + nil -> :ok + metrics when is_map(metrics) -> insert_metrics(ap.id, metrics) + end + end + + defp run_device_matching(org_id) do + if Code.ensure_loaded?(DeviceMatcher) do + case DeviceMatcher.match_unmatched(org_id) do + {:ok, count} -> count + _ -> 0 + end + else + 0 + end + end + + defp log_sync(integration, status, records_synced, errors, duration_ms) do + %SyncLog{} + |> SyncLog.changeset(%{ + organization_id: integration.organization_id, + integration_id: integration.id, + status: status, + records_synced: records_synced, + errors: errors, + duration_ms: duration_ms, + inserted_at: DateTime.truncate(DateTime.utc_now(), :second) + }) + |> Repo.insert() + end + + defp parse_datetime(dt_string) when is_binary(dt_string) do + case DateTime.from_iso8601(dt_string) do + {:ok, dt, _offset} -> DateTime.truncate(dt, :second) + _ -> DateTime.truncate(DateTime.utc_now(), :second) + end + end +end diff --git a/test/towerops/preseem/sync_test.exs b/test/towerops/preseem/sync_test.exs new file mode 100644 index 00000000..cb6cde36 --- /dev/null +++ b/test/towerops/preseem/sync_test.exs @@ -0,0 +1,267 @@ +defmodule Towerops.Preseem.SyncTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.IntegrationsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Integrations.Integration + alias Towerops.Preseem.AccessPoint + alias Towerops.Preseem.Client + alias Towerops.Preseem.SubscriberMetric + alias Towerops.Preseem.Sync + alias Towerops.Preseem.SyncLog + + setup do + user = user_fixture() + org = organization_fixture(user.id) + integration = integration_fixture(org.id) + + %{org: org, integration: integration} + end + + describe "upsert_access_point/2" do + test "creates a new access point from API data", %{org: org} do + ap_data = %{ + "id" => "preseem-ap-100", + "name" => "Tower1-AP1", + "mac_address" => "AA:BB:CC:DD:EE:FF", + "ip_address" => "10.0.1.1", + "model" => "AF60", + "firmware" => "4.3.2", + "capacity_score" => 85.0, + "qoe_score" => 92.5, + "rf_score" => 78.0, + "busy_hours" => 6, + "airtime_utilization" => 45.2, + "subscriber_count" => 30 + } + + assert {:ok, ap} = Sync.upsert_access_point(org.id, ap_data) + assert ap.preseem_id == "preseem-ap-100" + assert ap.name == "Tower1-AP1" + assert ap.mac_address == "AA:BB:CC:DD:EE:FF" + assert ap.ip_address == "10.0.1.1" + assert ap.model == "AF60" + assert ap.firmware == "4.3.2" + assert ap.capacity_score == 85.0 + assert ap.qoe_score == 92.5 + assert ap.rf_score == 78.0 + assert ap.busy_hours == 6 + assert ap.airtime_utilization == 45.2 + assert ap.subscriber_count == 30 + assert ap.organization_id == org.id + assert ap.match_confidence == "unmatched" + assert ap.raw_data == ap_data + end + + test "upserts an existing access point", %{org: org} do + ap_data = %{ + "id" => "preseem-ap-200", + "name" => "Tower1-AP2", + "mac_address" => "11:22:33:44:55:66", + "ip_address" => "10.0.1.2", + "model" => "AF60", + "firmware" => "4.3.1", + "capacity_score" => 70.0, + "qoe_score" => 80.0, + "rf_score" => 65.0, + "busy_hours" => 4, + "airtime_utilization" => 30.0, + "subscriber_count" => 20 + } + + assert {:ok, original} = Sync.upsert_access_point(org.id, ap_data) + + updated_data = + Map.merge(ap_data, %{ + "name" => "Tower1-AP2-Updated", + "firmware" => "4.3.2", + "qoe_score" => 88.0, + "subscriber_count" => 25 + }) + + assert {:ok, updated} = Sync.upsert_access_point(org.id, updated_data) + assert updated.id == original.id + assert updated.name == "Tower1-AP2-Updated" + assert updated.firmware == "4.3.2" + assert updated.qoe_score == 88.0 + assert updated.subscriber_count == 25 + end + + test "stores full API response as raw_data", %{org: org} do + ap_data = %{ + "id" => "preseem-ap-300", + "name" => "Tower1-AP3", + "extra_field" => "some_value", + "nested" => %{"key" => "value"} + } + + assert {:ok, ap} = Sync.upsert_access_point(org.id, ap_data) + assert ap.raw_data == ap_data + end + end + + describe "insert_metrics/2" do + test "creates a subscriber metric record", %{org: org} do + {:ok, ap} = + Sync.upsert_access_point(org.id, %{ + "id" => "preseem-ap-metrics", + "name" => "Metrics AP" + }) + + metrics_data = %{ + "avg_latency" => 12.5, + "avg_jitter" => 3.2, + "avg_loss" => 0.5, + "avg_throughput" => 150.0, + "p95_latency" => 25.0, + "subscriber_count" => 42, + "recorded_at" => "2026-02-12T10:00:00Z" + } + + assert {:ok, metric} = Sync.insert_metrics(ap.id, metrics_data) + assert metric.preseem_access_point_id == ap.id + assert metric.avg_latency == 12.5 + assert metric.avg_jitter == 3.2 + assert metric.avg_loss == 0.5 + assert metric.avg_throughput == 150.0 + assert metric.p95_latency == 25.0 + assert metric.subscriber_count == 42 + assert metric.recorded_at == ~U[2026-02-12 10:00:00Z] + end + end + + describe "sync_organization/1" do + test "syncs access points from API and creates sync log", %{ + org: org, + integration: integration + } do + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, %{ + "data" => [ + %{ + "id" => "ap-sync-1", + "name" => "Sync AP 1", + "mac_address" => "AA:BB:CC:11:22:33", + "ip_address" => "10.0.0.1", + "model" => "AF60", + "firmware" => "4.3.2", + "capacity_score" => 85.0, + "qoe_score" => 90.0, + "rf_score" => 75.0, + "busy_hours" => 5, + "airtime_utilization" => 40.0, + "subscriber_count" => 25 + }, + %{ + "id" => "ap-sync-2", + "name" => "Sync AP 2", + "mac_address" => "AA:BB:CC:44:55:66", + "ip_address" => "10.0.0.2", + "model" => "LTU-Rocket", + "firmware" => "2.6.1", + "capacity_score" => 70.0, + "qoe_score" => 82.0, + "rf_score" => 68.0, + "busy_hours" => 3, + "airtime_utilization" => 35.0, + "subscriber_count" => 18 + } + ] + }) + end) + + assert {:ok, result} = Sync.sync_organization(integration) + assert result.synced == 2 + + # Verify APs were created + aps = Repo.all(from ap in AccessPoint, where: ap.organization_id == ^org.id) + assert length(aps) == 2 + assert Enum.any?(aps, &(&1.preseem_id == "ap-sync-1")) + assert Enum.any?(aps, &(&1.preseem_id == "ap-sync-2")) + + # Verify sync log was written + [log] = Repo.all(from sl in SyncLog, where: sl.integration_id == ^integration.id) + assert log.status == "success" + assert log.records_synced == 2 + assert log.organization_id == org.id + assert log.duration_ms >= 0 + + # Verify integration sync status was updated + updated_integration = Repo.get!(Integration, integration.id) + assert updated_integration.last_sync_status == "success" + assert updated_integration.last_synced_at + end + + test "handles API errors gracefully", %{integration: integration} do + Req.Test.stub(Client, fn conn -> + conn + |> Plug.Conn.put_status(401) + |> Req.Test.json(%{"error" => "unauthorized"}) + end) + + assert {:error, :unauthorized} = Sync.sync_organization(integration) + + # Verify sync log records the failure + [log] = Repo.all(from sl in SyncLog, where: sl.integration_id == ^integration.id) + assert log.status == "failed" + assert log.records_synced == 0 + + # Verify integration sync status was updated to failed + updated_integration = Repo.get!(Integration, integration.id) + assert updated_integration.last_sync_status == "failed" + end + + test "syncs access points with metrics when present", %{ + org: org, + integration: integration + } do + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, %{ + "data" => [ + %{ + "id" => "ap-with-metrics", + "name" => "Metrics AP", + "ip_address" => "10.0.0.5", + "metrics" => %{ + "avg_latency" => 15.0, + "avg_jitter" => 2.5, + "avg_loss" => 0.3, + "avg_throughput" => 200.0, + "p95_latency" => 30.0, + "subscriber_count" => 50, + "recorded_at" => "2026-02-12T12:00:00Z" + } + } + ] + }) + end) + + assert {:ok, result} = Sync.sync_organization(integration) + assert result.synced == 1 + + # Verify metrics were inserted + [ap] = Repo.all(from ap in AccessPoint, where: ap.organization_id == ^org.id) + metrics = Repo.all(from m in SubscriberMetric, where: m.preseem_access_point_id == ^ap.id) + assert length(metrics) == 1 + assert hd(metrics).avg_latency == 15.0 + end + + test "uses base_url from integration credentials", %{integration: integration} do + # Update integration with a custom base_url + integration + |> Ecto.Changeset.change(%{ + credentials: %{"api_key" => "test-key", "base_url" => "https://custom.preseem.com/v1"} + }) + |> Repo.update!() + + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, %{"data" => []}) + end) + + assert {:ok, result} = Sync.sync_organization(Repo.reload!(integration)) + assert result.synced == 0 + end + end +end From 78040c0df787f52c59327121045be48f70943dd4 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 12 Feb 2026 17:52:54 -0600 Subject: [PATCH 11/20] add device matcher for preseem AP to towerops device linking --- lib/towerops/preseem/device_matcher.ex | 154 +++++++++++ test/towerops/preseem/device_matcher_test.exs | 252 ++++++++++++++++++ 2 files changed, 406 insertions(+) create mode 100644 lib/towerops/preseem/device_matcher.ex create mode 100644 test/towerops/preseem/device_matcher_test.exs diff --git a/lib/towerops/preseem/device_matcher.ex b/lib/towerops/preseem/device_matcher.ex new file mode 100644 index 00000000..884b2a36 --- /dev/null +++ b/lib/towerops/preseem/device_matcher.ex @@ -0,0 +1,154 @@ +defmodule Towerops.Preseem.DeviceMatcher do + @moduledoc """ + Attempts to match Preseem access points to Towerops devices. + + Matching strategy (ordered by confidence): + 1. MAC address (through interface -> snmp_device -> device) + 2. IP address (direct device match) + 3. Hostname (normalized name comparison) + """ + + import Ecto.Query + + alias Towerops.Devices.Device + alias Towerops.Preseem.AccessPoint + alias Towerops.Repo + alias Towerops.Snmp.Device, as: SnmpDevice + alias Towerops.Snmp.Interface + + @doc """ + Finds all unmatched access points for an organization and tries to match each one. + + Returns `{:ok, matched_count}` where matched_count is the number of APs + that were successfully matched to a device. + """ + def match_unmatched(organization_id) do + unmatched_aps = + AccessPoint + |> where(organization_id: ^organization_id, match_confidence: "unmatched") + |> Repo.all() + + matched_count = + Enum.count(unmatched_aps, fn ap -> + case match_access_point(ap) do + {:ok, matched_ap} -> matched_ap.device_id != nil + _ -> false + end + end) + + {:ok, matched_count} + end + + @doc """ + Attempts to match a single access point against devices in the same organization. + + Skips APs with `match_confidence == "manual"` (user manually linked). + Returns `{:ok, access_point}` with updated match_confidence and device_id. + """ + def match_access_point(%AccessPoint{match_confidence: "manual"} = ap) do + {:ok, ap} + end + + def match_access_point(%AccessPoint{} = ap) do + org_id = ap.organization_id + + result = + try_mac_match(ap, org_id) || + try_ip_match(ap, org_id) || + try_hostname_match(ap, org_id) + + case result do + {:single, device_id, confidence} -> + update_match(ap, device_id, confidence) + + :ambiguous -> + update_match(ap, nil, "ambiguous") + + nil -> + {:ok, ap} + end + end + + defp try_mac_match(%AccessPoint{mac_address: nil}, _org_id), do: nil + defp try_mac_match(%AccessPoint{mac_address: ""}, _org_id), do: nil + + defp try_mac_match(%AccessPoint{mac_address: mac}, org_id) do + normalized_mac = normalize_mac(mac) + + # Query interface MAC addresses through interface -> snmp_device -> device chain, + # then filter in Elixir since DB stores MACs in various formats + device_ids = + Interface + |> join(:inner, [i], sd in SnmpDevice, on: i.snmp_device_id == sd.id) + |> join(:inner, [_i, sd], d in Device, on: sd.device_id == d.id) + |> where([_i, _sd, d], d.organization_id == ^org_id) + |> where([i, _sd, _d], not is_nil(i.if_phys_address)) + |> select([i, _sd, d], {d.id, i.if_phys_address}) + |> Repo.all() + |> Enum.filter(fn {_id, phys_addr} -> normalize_mac(phys_addr) == normalized_mac end) + |> Enum.map(fn {id, _} -> id end) + |> Enum.uniq() + + evaluate_matches(device_ids, "auto_mac") + end + + defp try_ip_match(%AccessPoint{ip_address: nil}, _org_id), do: nil + defp try_ip_match(%AccessPoint{ip_address: ""}, _org_id), do: nil + + defp try_ip_match(%AccessPoint{ip_address: ip}, org_id) do + device_ids = + Device + |> where(organization_id: ^org_id, ip_address: ^ip) + |> select([d], d.id) + |> Repo.all() + + evaluate_matches(device_ids, "auto_ip") + end + + defp try_hostname_match(%AccessPoint{name: nil}, _org_id), do: nil + defp try_hostname_match(%AccessPoint{name: ""}, _org_id), do: nil + + defp try_hostname_match(%AccessPoint{name: ap_name}, org_id) do + normalized_ap_name = normalize_hostname(ap_name) + + device_ids = + Device + |> where(organization_id: ^org_id) + |> where([d], not is_nil(d.name)) + |> select([d], {d.id, d.name}) + |> Repo.all() + |> Enum.filter(fn {_id, name} -> normalize_hostname(name) == normalized_ap_name end) + |> Enum.map(fn {id, _} -> id end) + + evaluate_matches(device_ids, "auto_hostname") + end + + defp evaluate_matches([], _confidence), do: nil + defp evaluate_matches([device_id], confidence), do: {:single, device_id, confidence} + defp evaluate_matches([_ | _], _confidence), do: :ambiguous + + defp update_match(ap, device_id, confidence) do + ap + |> AccessPoint.changeset(%{device_id: device_id, match_confidence: confidence}) + |> Repo.update() + end + + @doc false + def normalize_mac(mac) when is_binary(mac) do + mac + |> String.downcase() + |> String.replace(~r/[:\-\.]/, "") + end + + def normalize_mac(_), do: "" + + @doc false + def normalize_hostname(name) when is_binary(name) do + name + |> String.downcase() + |> String.split(".") + |> List.first() + end + + def normalize_hostname(_), do: "" +end diff --git a/test/towerops/preseem/device_matcher_test.exs b/test/towerops/preseem/device_matcher_test.exs new file mode 100644 index 00000000..567b49b6 --- /dev/null +++ b/test/towerops/preseem/device_matcher_test.exs @@ -0,0 +1,252 @@ +defmodule Towerops.Preseem.DeviceMatcherTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.DevicesFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Preseem.AccessPoint + alias Towerops.Preseem.DeviceMatcher + alias Towerops.Preseem.Sync + alias Towerops.Snmp.Device, as: SnmpDevice + alias Towerops.Snmp.Interface + + setup do + user = user_fixture() + org = organization_fixture(user.id) + + %{org: org} + end + + defp create_device_with_mac(org, ip_address, mac_address, opts \\ []) do + name = Keyword.get(opts, :name, "Device #{System.unique_integer([:positive])}") + + device = device_fixture(%{organization_id: org.id, ip_address: ip_address, name: name}) + + snmp_device = + %SnmpDevice{} + |> SnmpDevice.changeset(%{device_id: device.id, sys_name: name}) + |> Repo.insert!() + + _interface = + %Interface{} + |> Interface.changeset(%{ + snmp_device_id: snmp_device.id, + if_index: 1, + if_name: "eth0", + if_phys_address: mac_address + }) + |> Repo.insert!() + + device + end + + defp create_preseem_ap(org_id, attrs) do + ap_data = + Map.merge( + %{ + "id" => "preseem-#{System.unique_integer([:positive])}", + "name" => "Test AP" + }, + attrs + ) + + {:ok, ap} = Sync.upsert_access_point(org_id, ap_data) + ap + end + + describe "match_access_point/1" do + test "matches by MAC address through interface -> snmp_device -> device", %{org: org} do + _device = create_device_with_mac(org, "10.0.0.1", "aa:bb:cc:dd:ee:ff") + + ap = + create_preseem_ap(org.id, %{ + "id" => "ap-mac-match", + "name" => "MAC Match AP", + "mac_address" => "AA:BB:CC:DD:EE:FF" + }) + + assert {:ok, matched_ap} = DeviceMatcher.match_access_point(ap) + assert matched_ap.match_confidence == "auto_mac" + assert matched_ap.device_id + end + + test "normalizes MAC addresses for matching (strips separators)", %{org: org} do + _device = create_device_with_mac(org, "10.0.0.2", "11:22:33:44:55:66") + + ap = + create_preseem_ap(org.id, %{ + "id" => "ap-mac-normalize", + "name" => "MAC Normalize AP", + "mac_address" => "11-22-33-44-55-66" + }) + + assert {:ok, matched_ap} = DeviceMatcher.match_access_point(ap) + assert matched_ap.match_confidence == "auto_mac" + end + + test "matches by IP address when MAC does not match", %{org: org} do + _device = device_fixture(%{organization_id: org.id, ip_address: "10.0.0.50", name: "IP Device"}) + + ap = + create_preseem_ap(org.id, %{ + "id" => "ap-ip-match", + "name" => "IP Match AP", + "mac_address" => "FF:FF:FF:FF:FF:FF", + "ip_address" => "10.0.0.50" + }) + + assert {:ok, matched_ap} = DeviceMatcher.match_access_point(ap) + assert matched_ap.match_confidence == "auto_ip" + assert matched_ap.device_id + end + + test "matches by hostname when MAC and IP do not match", %{org: org} do + _device = + device_fixture(%{ + organization_id: org.id, + ip_address: "192.168.99.99", + name: "tower1-sector-a" + }) + + ap = + create_preseem_ap(org.id, %{ + "id" => "ap-hostname-match", + "name" => "Tower1-Sector-A.local", + "mac_address" => "FF:FF:FF:FF:FF:FE", + "ip_address" => "172.16.0.99" + }) + + assert {:ok, matched_ap} = DeviceMatcher.match_access_point(ap) + assert matched_ap.match_confidence == "auto_hostname" + assert matched_ap.device_id + end + + test "returns ambiguous when multiple devices match by MAC", %{org: org} do + mac = "aa:aa:aa:aa:aa:aa" + _device1 = create_device_with_mac(org, "10.0.1.1", mac, name: "Device A") + _device2 = create_device_with_mac(org, "10.0.1.2", mac, name: "Device B") + + ap = + create_preseem_ap(org.id, %{ + "id" => "ap-ambiguous-mac", + "name" => "Ambiguous MAC AP", + "mac_address" => "AA:AA:AA:AA:AA:AA" + }) + + assert {:ok, matched_ap} = DeviceMatcher.match_access_point(ap) + assert matched_ap.match_confidence == "ambiguous" + assert matched_ap.device_id == nil + end + + test "returns ambiguous when multiple devices match by IP", %{org: org} do + _device1 = + device_fixture(%{organization_id: org.id, ip_address: "10.0.2.1", name: "IP Device 1"}) + + _device2 = + device_fixture(%{organization_id: org.id, ip_address: "10.0.2.1", name: "IP Device 2"}) + + ap = + create_preseem_ap(org.id, %{ + "id" => "ap-ambiguous-ip", + "name" => "Ambiguous IP AP", + "ip_address" => "10.0.2.1" + }) + + assert {:ok, matched_ap} = DeviceMatcher.match_access_point(ap) + assert matched_ap.match_confidence == "ambiguous" + assert matched_ap.device_id == nil + end + + test "skips manually matched access points", %{org: org} do + device = device_fixture(%{organization_id: org.id, ip_address: "10.0.3.1", name: "Manual Device"}) + + ap = + create_preseem_ap(org.id, %{ + "id" => "ap-manual", + "name" => "Manual AP", + "ip_address" => "10.0.3.1" + }) + + # Manually set the match + ap + |> AccessPoint.changeset(%{device_id: device.id, match_confidence: "manual"}) + |> Repo.update!() + + ap = Repo.get!(AccessPoint, ap.id) + + assert {:ok, unchanged_ap} = DeviceMatcher.match_access_point(ap) + assert unchanged_ap.match_confidence == "manual" + assert unchanged_ap.device_id == device.id + end + + test "returns unmatched when no devices match", %{org: org} do + ap = + create_preseem_ap(org.id, %{ + "id" => "ap-no-match", + "name" => "Unmatched AP", + "mac_address" => "00:00:00:00:00:01", + "ip_address" => "172.31.255.255" + }) + + assert {:ok, unmatched_ap} = DeviceMatcher.match_access_point(ap) + assert unmatched_ap.match_confidence == "unmatched" + assert unmatched_ap.device_id == nil + end + end + + describe "match_unmatched/1" do + test "processes all unmatched APs in an organization", %{org: org} do + _device1 = device_fixture(%{organization_id: org.id, ip_address: "10.0.10.1", name: "Bulk Device 1"}) + _device2 = device_fixture(%{organization_id: org.id, ip_address: "10.0.10.2", name: "Bulk Device 2"}) + + _ap1 = + create_preseem_ap(org.id, %{ + "id" => "ap-bulk-1", + "name" => "Bulk AP 1", + "ip_address" => "10.0.10.1" + }) + + _ap2 = + create_preseem_ap(org.id, %{ + "id" => "ap-bulk-2", + "name" => "Bulk AP 2", + "ip_address" => "10.0.10.2" + }) + + _ap3 = + create_preseem_ap(org.id, %{ + "id" => "ap-bulk-3", + "name" => "Bulk AP 3 No Match", + "ip_address" => "172.16.0.100" + }) + + assert {:ok, matched_count} = DeviceMatcher.match_unmatched(org.id) + assert matched_count == 2 + + # Verify the matched APs have device_ids + aps = Repo.all(from ap in AccessPoint, where: ap.organization_id == ^org.id, order_by: :preseem_id) + matched_aps = Enum.filter(aps, &(&1.device_id != nil)) + assert length(matched_aps) == 2 + end + + test "does not reprocess already matched APs", %{org: org} do + device = device_fixture(%{organization_id: org.id, ip_address: "10.0.11.1", name: "Already Matched"}) + + ap = + create_preseem_ap(org.id, %{ + "id" => "ap-already-matched", + "name" => "Already Matched AP", + "ip_address" => "10.0.11.1" + }) + + # Manually match it first + ap + |> AccessPoint.changeset(%{device_id: device.id, match_confidence: "manual"}) + |> Repo.update!() + + assert {:ok, matched_count} = DeviceMatcher.match_unmatched(org.id) + assert matched_count == 0 + end + end +end From b04d25e6079c7eb32cbd21765a1c4a2420176f83 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 12 Feb 2026 17:56:52 -0600 Subject: [PATCH 12/20] add preseem sync worker oban cron job --- config/dev.exs | 4 +- config/runtime.exs | 4 +- lib/towerops/workers/preseem_sync_worker.ex | 61 +++++++++ .../workers/preseem_sync_worker_test.exs | 125 ++++++++++++++++++ 4 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 lib/towerops/workers/preseem_sync_worker.ex create mode 100644 test/towerops/workers/preseem_sync_worker_test.exs diff --git a/config/dev.exs b/config/dev.exs index c0b48381..6dcea6b0 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -79,7 +79,9 @@ config :towerops, Oban, # Delete expired IP bans every hour {"0 * * * *", Towerops.Workers.ExpiredBanCleanupWorker}, # Delete stale violation records daily at 2 AM - {"0 2 * * *", Towerops.Workers.StaleViolationCleanupWorker} + {"0 2 * * *", Towerops.Workers.StaleViolationCleanupWorker}, + # Sync Preseem data every 10 minutes + {"*/10 * * * *", Towerops.Workers.PreseemSyncWorker} ]}, # Automatically delete completed jobs after 60 seconds {Oban.Plugins.Pruner, max_age: 60}, diff --git a/config/runtime.exs b/config/runtime.exs index 10ac33ac..520fabf2 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -192,7 +192,9 @@ if config_env() == :prod do # Delete expired IP bans every hour {"0 * * * *", Towerops.Workers.ExpiredBanCleanupWorker}, # Delete stale violation records daily at 2 AM - {"0 2 * * *", Towerops.Workers.StaleViolationCleanupWorker} + {"0 2 * * *", Towerops.Workers.StaleViolationCleanupWorker}, + # Sync Preseem data every 10 minutes + {"*/10 * * * *", Towerops.Workers.PreseemSyncWorker} ]}, # Automatically delete completed jobs after 60 seconds {Oban.Plugins.Pruner, max_age: 60}, diff --git a/lib/towerops/workers/preseem_sync_worker.ex b/lib/towerops/workers/preseem_sync_worker.ex new file mode 100644 index 00000000..13944fb0 --- /dev/null +++ b/lib/towerops/workers/preseem_sync_worker.ex @@ -0,0 +1,61 @@ +defmodule Towerops.Workers.PreseemSyncWorker do + @moduledoc """ + Oban cron worker that syncs Preseem data for all enabled integrations. + + Runs every 10 minutes. For each org with an enabled Preseem integration, + checks if enough time has elapsed since the last sync (based on the + integration's sync_interval_minutes), then calls Preseem.Sync.sync_organization/1. + """ + use Oban.Worker, queue: :maintenance + + alias Towerops.Integrations + alias Towerops.Preseem.Sync + + require Logger + + @impl Oban.Worker + def perform(%Oban.Job{}) do + integrations = Integrations.list_enabled_integrations("preseem") + + results = + Enum.map(integrations, fn integration -> + if should_sync?(integration) do + case Sync.sync_organization(integration) do + {:ok, result} -> + Logger.info("Preseem sync completed for org #{integration.organization_id}: #{inspect(result)}") + + {:ok, result} + + {:error, reason} -> + Logger.error("Preseem sync failed for org #{integration.organization_id}: #{inspect(reason)}") + + {:error, reason} + end + else + :skipped + end + end) + + synced = Enum.count(results, &match?({:ok, _}, &1)) + failed = Enum.count(results, &match?({:error, _}, &1)) + skipped = Enum.count(results, &(&1 == :skipped)) + + if synced > 0 or failed > 0 do + Logger.info("Preseem sync batch: #{synced} synced, #{failed} failed, #{skipped} skipped") + end + + :ok + end + + defp should_sync?(integration) do + case integration.last_synced_at do + nil -> + true + + last_synced_at -> + interval_seconds = (integration.sync_interval_minutes || 10) * 60 + elapsed = DateTime.diff(DateTime.utc_now(), last_synced_at, :second) + elapsed >= interval_seconds + end + end +end diff --git a/test/towerops/workers/preseem_sync_worker_test.exs b/test/towerops/workers/preseem_sync_worker_test.exs new file mode 100644 index 00000000..3f14c8f7 --- /dev/null +++ b/test/towerops/workers/preseem_sync_worker_test.exs @@ -0,0 +1,125 @@ +defmodule Towerops.Workers.PreseemSyncWorkerTest do + use Towerops.DataCase, async: true + use Oban.Testing, repo: Towerops.Repo + + import Towerops.AccountsFixtures + import Towerops.IntegrationsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Preseem.Client + alias Towerops.Workers.PreseemSyncWorker + + setup do + user = user_fixture() + org = organization_fixture(user.id) + %{org: org} + end + + describe "perform/1" do + test "syncs enabled preseem integrations", %{org: org} do + integration = integration_fixture(org.id) + + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, %{ + "data" => [ + %{ + "id" => "ap-1", + "name" => "Test AP", + "mac_address" => "AA:BB:CC:DD:EE:FF", + "ip_address" => "10.0.0.1" + } + ] + }) + end) + + assert :ok = perform_job(PreseemSyncWorker, %{}) + + updated = Repo.reload!(integration) + assert updated.last_synced_at + assert updated.last_sync_status in ["success", "partial"] + end + + test "skips integrations synced recently", %{org: org} do + _integration = + integration_fixture(org.id, %{ + last_synced_at: DateTime.truncate(DateTime.utc_now(), :second), + last_sync_status: "success" + }) + + # No Client stub needed - should skip without calling API + assert :ok = perform_job(PreseemSyncWorker, %{}) + end + + test "handles no enabled integrations" do + assert :ok = perform_job(PreseemSyncWorker, %{}) + end + + test "handles API errors without crashing", %{org: org} do + _integration = integration_fixture(org.id) + + Req.Test.stub(Client, fn conn -> + conn + |> Plug.Conn.put_status(401) + |> Req.Test.json(%{"error" => "unauthorized"}) + end) + + assert :ok = perform_job(PreseemSyncWorker, %{}) + end + + test "respects sync_interval_minutes", %{org: org} do + # Last synced 5 minutes ago with a 10-minute interval -> should skip + five_minutes_ago = + DateTime.utc_now() + |> DateTime.add(-5, :minute) + |> DateTime.truncate(:second) + + _integration = + integration_fixture(org.id, %{ + sync_interval_minutes: 10, + last_synced_at: five_minutes_ago, + last_sync_status: "success" + }) + + # No Client stub needed - should skip without calling API + assert :ok = perform_job(PreseemSyncWorker, %{}) + end + + test "syncs when sync interval has elapsed", %{org: org} do + # Last synced 15 minutes ago with a 10-minute interval -> should sync + fifteen_minutes_ago = + DateTime.utc_now() + |> DateTime.add(-15, :minute) + |> DateTime.truncate(:second) + + integration = + integration_fixture(org.id, %{ + sync_interval_minutes: 10, + last_synced_at: fifteen_minutes_ago, + last_sync_status: "success" + }) + + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, %{"data" => []}) + end) + + assert :ok = perform_job(PreseemSyncWorker, %{}) + + updated = Repo.reload!(integration) + assert DateTime.after?(updated.last_synced_at, fifteen_minutes_ago) + end + + test "syncs integrations that have never been synced", %{org: org} do + integration = integration_fixture(org.id) + assert integration.last_synced_at == nil + + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, %{"data" => []}) + end) + + assert :ok = perform_job(PreseemSyncWorker, %{}) + + updated = Repo.reload!(integration) + assert updated.last_synced_at + end + end +end From 59cf53144fde39ec5fa8e2bfa28ff638c0227b61 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 12 Feb 2026 18:05:26 -0600 Subject: [PATCH 13/20] add preseem context and device detail preseem tab --- lib/towerops/preseem.ex | 70 ++++++ lib/towerops_web/live/device_live/show.ex | 19 ++ .../live/device_live/show.html.heex | 201 ++++++++++++++++++ test/towerops/preseem_test.exs | 174 +++++++++++++++ 4 files changed, 464 insertions(+) create mode 100644 lib/towerops/preseem.ex create mode 100644 test/towerops/preseem_test.exs diff --git a/lib/towerops/preseem.ex b/lib/towerops/preseem.ex new file mode 100644 index 00000000..c6ee02fb --- /dev/null +++ b/lib/towerops/preseem.ex @@ -0,0 +1,70 @@ +defmodule Towerops.Preseem do + @moduledoc """ + Context for querying Preseem integration data. + """ + + import Ecto.Query + + alias Towerops.Preseem.AccessPoint + alias Towerops.Preseem.SubscriberMetric + alias Towerops.Repo + + @doc "Get the Preseem AP linked to a specific device, or nil." + def get_access_point_for_device(device_id) do + Repo.get_by(AccessPoint, device_id: device_id) + end + + @doc "List all Preseem APs for an organization." + def list_access_points(organization_id) do + AccessPoint + |> where(organization_id: ^organization_id) + |> order_by(:name) + |> Repo.all() + end + + @doc "List unmatched or ambiguous Preseem APs for an organization." + def list_unmatched_access_points(organization_id) do + AccessPoint + |> where(organization_id: ^organization_id) + |> where([ap], ap.match_confidence in ["unmatched", "ambiguous"]) + |> order_by(:name) + |> Repo.all() + end + + @doc "List recent subscriber metrics for a Preseem AP. Default limit 100." + def list_subscriber_metrics(access_point_id, opts \\ []) do + limit = Keyword.get(opts, :limit, 100) + + SubscriberMetric + |> where(preseem_access_point_id: ^access_point_id) + |> order_by(desc: :recorded_at) + |> limit(^limit) + |> Repo.all() + end + + @doc "Link a Preseem AP to a device manually." + def link_access_point(access_point_id, device_id) do + case Repo.get(AccessPoint, access_point_id) do + nil -> + {:error, :not_found} + + ap -> + ap + |> AccessPoint.changeset(%{device_id: device_id, match_confidence: "manual"}) + |> Repo.update() + end + end + + @doc "Unlink a Preseem AP from its device." + def unlink_access_point(access_point_id) do + case Repo.get(AccessPoint, access_point_id) do + nil -> + {:error, :not_found} + + ap -> + ap + |> AccessPoint.changeset(%{device_id: nil, match_confidence: "unmatched"}) + |> Repo.update() + end + end +end diff --git a/lib/towerops_web/live/device_live/show.ex b/lib/towerops_web/live/device_live/show.ex index f7b8863b..9a93f179 100644 --- a/lib/towerops_web/live/device_live/show.ex +++ b/lib/towerops_web/live/device_live/show.ex @@ -233,6 +233,7 @@ defmodule ToweropsWeb.DeviceLive.Show do |> assign_ports_data() |> assign_logs_data(device_id) |> assign_backups_data(device_id) + |> assign_preseem_data() end # -- Selective reload helpers (used by handle_info handlers) -- @@ -271,6 +272,7 @@ defmodule ToweropsWeb.DeviceLive.Show do "ports" -> assign_ports_data(socket) "logs" -> assign_logs_data(socket, device_id) "backups" -> assign_backups_data(socket, device_id) + "preseem" -> assign_preseem_data(socket) # neighbors, arp, mac, vlans, ip_addresses, debug use data from tab_nav/base _ -> socket end @@ -429,6 +431,23 @@ defmodule ToweropsWeb.DeviceLive.Show do |> assign(:selected_backup_ids, MapSet.new()) end + # Preseem tab data. + defp assign_preseem_data(socket) do + device = socket.assigns.device + preseem_ap = Towerops.Preseem.get_access_point_for_device(device.id) + + metrics = + if preseem_ap do + Towerops.Preseem.list_subscriber_metrics(preseem_ap.id, limit: 50) + else + [] + end + + socket + |> assign(:preseem_access_point, preseem_ap) + |> assign(:preseem_metrics, metrics) + end + defp get_available_firmware(nil), do: nil defp get_available_firmware(snmp_device) do diff --git a/lib/towerops_web/live/device_live/show.html.heex b/lib/towerops_web/live/device_live/show.html.heex index 986557f0..a0c643d8 100644 --- a/lib/towerops_web/live/device_live/show.html.heex +++ b/lib/towerops_web/live/device_live/show.html.heex @@ -228,6 +228,25 @@ <% end %> + <%= if @preseem_access_point do %> + <.link + patch={~p"/devices/#{@device.id}?tab=preseem"} + class={[ + "whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm", + if(@active_tab == "preseem", + do: "border-blue-500 text-blue-600 dark:text-blue-400", + else: + "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300" + ) + ]} + > + + <.icon name="hero-signal" class="h-3.5 w-3.5 text-purple-500 dark:text-purple-400" /> + Preseem + + + <% end %> + <.link patch={~p"/devices/#{@device.id}?tab=checks"} class={[ @@ -2184,6 +2203,188 @@ + <% "preseem" -> %> + <%= if @preseem_access_point do %> +
+ <%!-- Score cards --%> +
+
+
QoE Score
+
+ {if @preseem_access_point.qoe_score, + do: Float.round(@preseem_access_point.qoe_score, 1), + else: "---"} +
+
+
+
+ Capacity Score +
+
+ {if @preseem_access_point.capacity_score, + do: Float.round(@preseem_access_point.capacity_score, 1), + else: "---"} +
+
+
+
RF Score
+
+ {if @preseem_access_point.rf_score, + do: Float.round(@preseem_access_point.rf_score, 1), + else: "---"} +
+
+
+ + <%!-- Details card --%> +
+
+

+ Access Point Details +

+
+
+
+
+
Name
+
+ {@preseem_access_point.name || "---"} +
+
+
+
+ Preseem ID +
+
+ {@preseem_access_point.preseem_id} +
+
+
+
+ Subscribers +
+
+ {@preseem_access_point.subscriber_count || "---"} +
+
+
+
+ Busy Hours +
+
+ <%= if @preseem_access_point.busy_hours do %> + {@preseem_access_point.busy_hours} hrs/day + <% else %> + --- + <% end %> +
+
+
+
+ Airtime Utilization +
+
+ <%= if @preseem_access_point.airtime_utilization do %> + {Float.round(@preseem_access_point.airtime_utilization, 1)}% + <% else %> + --- + <% end %> +
+
+
+
+ Match Type +
+
+ {@preseem_access_point.match_confidence} +
+
+
+
+
+ + <%!-- Recent metrics table --%> + <%= if @preseem_metrics != [] do %> +
+
+

+ Recent QoE Metrics +

+
+
+ + + + + + + + + + + + + + <%= for metric <- @preseem_metrics do %> + + + + + + + + + + <% end %> + +
+ Time + + Latency (ms) + + P95 Latency + + Jitter (ms) + + Loss (%) + + Throughput + + Subscribers +
+ {Calendar.strftime(metric.recorded_at, "%Y-%m-%d %H:%M")} + + {if metric.avg_latency, + do: Float.round(metric.avg_latency, 1), + else: "---"} + + {if metric.p95_latency, + do: Float.round(metric.p95_latency, 1), + else: "---"} + + {if metric.avg_jitter, + do: Float.round(metric.avg_jitter, 1), + else: "---"} + + {if metric.avg_loss, do: Float.round(metric.avg_loss, 2), else: "---"} + + {if metric.avg_throughput, + do: "#{Float.round(metric.avg_throughput, 1)} Mbps", + else: "---"} + + {metric.subscriber_count || "---"} +
+
+
+ <% end %> +
+ <% else %> +
+

+ No Preseem data linked to this device. +

+
+ <% end %> <% _ -> %>

Tab not found

diff --git a/test/towerops/preseem_test.exs b/test/towerops/preseem_test.exs new file mode 100644 index 00000000..491effea --- /dev/null +++ b/test/towerops/preseem_test.exs @@ -0,0 +1,174 @@ +defmodule Towerops.PreseemTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.DevicesFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Preseem + alias Towerops.Preseem.AccessPoint + alias Towerops.Preseem.SubscriberMetric + + setup do + user = user_fixture() + org = organization_fixture(user.id) + %{organization: org} + end + + defp insert_access_point!(org, attrs \\ %{}) do + default = %{ + organization_id: org.id, + preseem_id: "ap-#{System.unique_integer([:positive])}", + name: "Test AP" + } + + {:ok, ap} = + %AccessPoint{} + |> AccessPoint.changeset(Map.merge(default, attrs)) + |> Repo.insert() + + ap + end + + defp insert_metric!(ap, attrs) do + default = %{ + preseem_access_point_id: ap.id, + recorded_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + {:ok, metric} = + %SubscriberMetric{} + |> SubscriberMetric.changeset(Map.merge(default, attrs)) + |> Repo.insert() + + metric + end + + describe "get_access_point_for_device/1" do + test "returns nil when no AP linked to device" do + assert is_nil(Preseem.get_access_point_for_device(Ecto.UUID.generate())) + end + + test "returns AP when linked to device", %{organization: org} do + device = device_fixture(%{organization_id: org.id}) + + ap = + insert_access_point!(org, %{ + device_id: device.id, + match_confidence: "auto_ip" + }) + + result = Preseem.get_access_point_for_device(device.id) + assert result.id == ap.id + end + end + + describe "list_access_points/1" do + test "returns all APs for an organization ordered by name", %{organization: org} do + insert_access_point!(org, %{name: "Bravo AP"}) + insert_access_point!(org, %{name: "Alpha AP"}) + + aps = Preseem.list_access_points(org.id) + assert length(aps) == 2 + assert Enum.map(aps, & &1.name) == ["Alpha AP", "Bravo AP"] + end + + test "does not return APs from other organizations", %{organization: org} do + other_user = user_fixture() + other_org = organization_fixture(other_user.id) + + insert_access_point!(org, %{name: "Our AP"}) + insert_access_point!(other_org, %{name: "Their AP"}) + + aps = Preseem.list_access_points(org.id) + assert length(aps) == 1 + assert hd(aps).name == "Our AP" + end + end + + describe "list_unmatched_access_points/1" do + test "returns only unmatched and ambiguous APs", %{organization: org} do + insert_access_point!(org, %{name: "Matched", match_confidence: "auto_mac"}) + insert_access_point!(org, %{name: "Unmatched", match_confidence: "unmatched"}) + insert_access_point!(org, %{name: "Ambiguous", match_confidence: "ambiguous"}) + insert_access_point!(org, %{name: "Manual", match_confidence: "manual"}) + + unmatched = Preseem.list_unmatched_access_points(org.id) + assert length(unmatched) == 2 + names = unmatched |> Enum.map(& &1.name) |> Enum.sort() + assert names == ["Ambiguous", "Unmatched"] + end + end + + describe "list_subscriber_metrics/2" do + test "returns metrics ordered by recorded_at desc", %{organization: org} do + ap = insert_access_point!(org) + now = DateTime.truncate(DateTime.utc_now(), :second) + old = DateTime.add(now, -3600, :second) + + insert_metric!(ap, %{recorded_at: old, avg_latency: 10.0}) + insert_metric!(ap, %{recorded_at: now, avg_latency: 20.0}) + + metrics = Preseem.list_subscriber_metrics(ap.id) + assert length(metrics) == 2 + assert hd(metrics).avg_latency == 20.0 + end + + test "respects limit option", %{organization: org} do + ap = insert_access_point!(org) + now = DateTime.truncate(DateTime.utc_now(), :second) + + for i <- 1..5 do + insert_metric!(ap, %{ + recorded_at: DateTime.add(now, -i, :second), + avg_latency: i * 1.0 + }) + end + + metrics = Preseem.list_subscriber_metrics(ap.id, limit: 2) + assert length(metrics) == 2 + end + + test "defaults to 100 limit", %{organization: org} do + ap = insert_access_point!(org) + # Just verify the function works with default + metrics = Preseem.list_subscriber_metrics(ap.id) + assert is_list(metrics) + end + end + + describe "link_access_point/2" do + test "links AP to device with manual confidence", %{organization: org} do + ap = insert_access_point!(org, %{match_confidence: "unmatched"}) + device = device_fixture(%{organization_id: org.id}) + + assert {:ok, linked} = Preseem.link_access_point(ap.id, device.id) + assert linked.device_id == device.id + assert linked.match_confidence == "manual" + end + + test "returns error for non-existent AP" do + assert {:error, :not_found} = Preseem.link_access_point(Ecto.UUID.generate(), Ecto.UUID.generate()) + end + end + + describe "unlink_access_point/1" do + test "unlinks AP and resets to unmatched", %{organization: org} do + device = device_fixture(%{organization_id: org.id}) + + ap = + insert_access_point!(org, %{ + device_id: device.id, + match_confidence: "manual" + }) + + assert {:ok, unlinked} = Preseem.unlink_access_point(ap.id) + assert is_nil(unlinked.device_id) + assert unlinked.match_confidence == "unmatched" + end + + test "returns error for non-existent AP" do + assert {:error, :not_found} = Preseem.unlink_access_point(Ecto.UUID.generate()) + end + end +end From b9db1d2224be8efee8877cb103db0ae0174f9e4d Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 12 Feb 2026 18:12:27 -0600 Subject: [PATCH 14/20] add preseem devices management page with manual linking --- lib/towerops/devices.ex | 17 + lib/towerops/preseem.ex | 9 + .../live/org/integrations_live.html.heex | 7 + .../live/org/preseem_devices_live.ex | 150 +++++++++ .../live/org/preseem_devices_live.html.heex | 273 ++++++++++++++++ lib/towerops_web/router.ex | 1 + .../live/org/preseem_devices_live_test.exs | 295 ++++++++++++++++++ 7 files changed, 752 insertions(+) create mode 100644 lib/towerops_web/live/org/preseem_devices_live.ex create mode 100644 lib/towerops_web/live/org/preseem_devices_live.html.heex create mode 100644 test/towerops_web/live/org/preseem_devices_live_test.exs diff --git a/lib/towerops/devices.ex b/lib/towerops/devices.ex index 55578d68..3939f7dc 100644 --- a/lib/towerops/devices.ex +++ b/lib/towerops/devices.ex @@ -116,6 +116,23 @@ defmodule Towerops.Devices do ) end + @doc """ + Search devices by name or IP address for an organization. + Returns up to 10 results. Requires at least 2 characters. + """ + @spec search_devices(String.t(), String.t()) :: [DeviceSchema.t()] + def search_devices(organization_id, query) do + search_term = "%#{query}%" + + DeviceSchema + |> where(organization_id: ^organization_id) + |> where([d], ilike(d.name, ^search_term) or ilike(d.ip_address, ^search_term)) + |> order_by(:name) + |> limit(10) + |> Repo.all() + |> Repo.preload(:site) + end + @doc """ Returns a map of device status counts for an organization. diff --git a/lib/towerops/preseem.ex b/lib/towerops/preseem.ex index c6ee02fb..52cee112 100644 --- a/lib/towerops/preseem.ex +++ b/lib/towerops/preseem.ex @@ -31,6 +31,15 @@ defmodule Towerops.Preseem do |> Repo.all() end + @doc "List matched Preseem APs for an organization (everything except unmatched/ambiguous)." + def list_matched_access_points(organization_id) do + AccessPoint + |> where(organization_id: ^organization_id) + |> where([ap], ap.match_confidence not in ["unmatched", "ambiguous"]) + |> order_by(:name) + |> Repo.all() + end + @doc "List recent subscriber metrics for a Preseem AP. Default limit 100." def list_subscriber_metrics(access_point_id, opts \\ []) do limit = Keyword.get(opts, :limit, 100) diff --git a/lib/towerops_web/live/org/integrations_live.html.heex b/lib/towerops_web/live/org/integrations_live.html.heex index 765a03a8..fb556ead 100644 --- a/lib/towerops_web/live/org/integrations_live.html.heex +++ b/lib/towerops_web/live/org/integrations_live.html.heex @@ -78,6 +78,13 @@ {String.capitalize(integration.last_sync_status)} <% end %> + + <.link + navigate={~p"/orgs/#{@organization.slug}/settings/integrations/preseem/devices"} + class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" + > + Manage Devices → +
<% end %> diff --git a/lib/towerops_web/live/org/preseem_devices_live.ex b/lib/towerops_web/live/org/preseem_devices_live.ex new file mode 100644 index 00000000..048a3ef3 --- /dev/null +++ b/lib/towerops_web/live/org/preseem_devices_live.ex @@ -0,0 +1,150 @@ +defmodule ToweropsWeb.Org.PreseemDevicesLive do + @moduledoc false + use ToweropsWeb, :live_view + + alias Towerops.Devices + alias Towerops.Preseem + alias Towerops.Repo + + @impl true + def mount(_params, _session, socket) do + org = socket.assigns.current_scope.organization + + {:ok, + socket + |> assign(:organization, org) + |> assign(:filter, "all") + |> assign(:linking_ap_id, nil) + |> assign(:device_search, "") + |> assign(:search_results, []) + |> load_access_points()} + end + + @impl true + def handle_params(params, _url, socket) do + filter = + case params["filter"] do + f when f in ~w(all unmatched matched) -> f + _ -> "all" + end + + {:noreply, socket |> assign(:filter, filter) |> load_access_points()} + end + + @impl true + def handle_event("filter", %{"filter" => filter}, socket) do + org = socket.assigns.organization + + {:noreply, + push_patch(socket, + to: ~p"/orgs/#{org.slug}/settings/integrations/preseem/devices?filter=#{filter}" + )} + end + + @impl true + def handle_event("start_link", %{"ap-id" => ap_id}, socket) do + {:noreply, + socket + |> assign(:linking_ap_id, ap_id) + |> assign(:device_search, "") + |> assign(:search_results, [])} + end + + @impl true + def handle_event("cancel_link", _params, socket) do + {:noreply, + socket + |> assign(:linking_ap_id, nil) + |> assign(:device_search, "") + |> assign(:search_results, [])} + end + + @impl true + def handle_event("search_devices", %{"query" => query}, socket) do + org = socket.assigns.organization + + results = + if String.length(query) >= 2 do + Devices.search_devices(org.id, query) + else + [] + end + + {:noreply, socket |> assign(:device_search, query) |> assign(:search_results, results)} + end + + @impl true + def handle_event("link_device", %{"ap-id" => ap_id, "device-id" => device_id}, socket) do + case Preseem.link_access_point(ap_id, device_id) do + {:ok, _} -> + {:noreply, + socket + |> assign(:linking_ap_id, nil) + |> assign(:device_search, "") + |> assign(:search_results, []) + |> load_access_points() + |> put_flash(:info, "Device linked successfully")} + + {:error, _} -> + {:noreply, put_flash(socket, :error, "Failed to link device")} + end + end + + @impl true + def handle_event("unlink_device", %{"ap-id" => ap_id}, socket) do + case Preseem.unlink_access_point(ap_id) do + {:ok, _} -> + {:noreply, + socket + |> load_access_points() + |> put_flash(:info, "Device unlinked")} + + {:error, _} -> + {:noreply, put_flash(socket, :error, "Failed to unlink device")} + end + end + + defp match_confidence_classes(confidence) do + case confidence do + "unmatched" -> + "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400" + + "ambiguous" -> + "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400" + + "manual" -> + "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400" + + _ -> + "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400" + end + end + + defp humanize_confidence(confidence) do + case confidence do + "auto_mac" -> "Auto (MAC)" + "auto_ip" -> "Auto (IP)" + "auto_hostname" -> "Auto (Hostname)" + "manual" -> "Manual" + "ambiguous" -> "Ambiguous" + "unmatched" -> "Unmatched" + other -> String.capitalize(other || "unknown") + end + end + + defp load_access_points(socket) do + org_id = socket.assigns.organization.id + filter = socket.assigns.filter + + access_points = + case filter do + "unmatched" -> Preseem.list_unmatched_access_points(org_id) + "matched" -> Preseem.list_matched_access_points(org_id) + _ -> Preseem.list_access_points(org_id) + end + + access_points = Repo.preload(access_points, :device) + + assign(socket, :access_points, access_points) + end +end diff --git a/lib/towerops_web/live/org/preseem_devices_live.html.heex b/lib/towerops_web/live/org/preseem_devices_live.html.heex new file mode 100644 index 00000000..27a4e49b --- /dev/null +++ b/lib/towerops_web/live/org/preseem_devices_live.html.heex @@ -0,0 +1,273 @@ + +
+
+ <.link + navigate={~p"/orgs/#{@organization.slug}/settings/integrations"} + class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white" + > + <.icon name="hero-arrow-left" class="h-4 w-4" /> Back to Integrations + +
+

+ Preseem Devices +

+

+ Manage device matching between Preseem access points and your monitored devices. +

+
+ + <%!-- Filter tabs --%> +
+ +
+ + <%!-- Access points table --%> +
+ <%= if @access_points == [] do %> +
+ <.icon name="hero-signal" class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" /> +

+ No access points found +

+

+ <%= case @filter do %> + <% "unmatched" -> %> + All access points are matched. Nice! + <% "matched" -> %> + No matched access points yet. Sync from Preseem and link devices. + <% _ -> %> + No Preseem access points have been synced yet. Enable the integration and run a sync. + <% end %> +

+
+ <% else %> +
+ + + + + + + + + + + + + + + + + + + + + <%!-- Inline linking row --%> + <%= for ap <- @access_points, ap.id == @linking_ap_id do %> + + + + <% end %> + +
+ Name + + Preseem ID + + Status + + Linked Device + + Actions +
+ {ap.name || "Unnamed"} + + {ap.preseem_id} + + + {humanize_confidence(ap.match_confidence)} + + + <%= if ap.device do %> + <.link + navigate={~p"/devices/#{ap.device.id}"} + class="text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300" + > + {ap.device.name || ap.device.ip_address} + + <% else %> + - + <% end %> + + <%= if ap.device do %> + + <% else %> + + <% end %> +
+
+
+

+ Search for a device to link to "{ap.name}" +

+
+ +
+ + <%= if @search_results != [] do %> +
+
    +
  • + +
  • +
+
+ <% end %> + + <%= if @device_search != "" and String.length(@device_search) >= 2 and @search_results == [] do %> +

+ No devices found matching "{@device_search}" +

+ <% end %> +
+ +
+
+
+ <% end %> +
+
diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index cd77ce0c..af453f29 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -310,6 +310,7 @@ defmodule ToweropsWeb.Router do live "/settings", Org.SettingsLive, :index live "/settings/integrations", Org.IntegrationsLive, :index + live "/settings/integrations/preseem/devices", Org.PreseemDevicesLive, :index end end diff --git a/test/towerops_web/live/org/preseem_devices_live_test.exs b/test/towerops_web/live/org/preseem_devices_live_test.exs new file mode 100644 index 00000000..542e72f1 --- /dev/null +++ b/test/towerops_web/live/org/preseem_devices_live_test.exs @@ -0,0 +1,295 @@ +defmodule ToweropsWeb.Org.PreseemDevicesLiveTest do + use ToweropsWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Towerops.AccountsFixtures + import Towerops.DevicesFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Preseem.AccessPoint + alias Towerops.Repo + + setup do + user = user_fixture() + org = organization_fixture(user.id) + %{user: user, organization: org} + end + + defp insert_access_point!(org, attrs) do + default = %{ + organization_id: org.id, + preseem_id: "ap-#{System.unique_integer([:positive])}", + name: "Test AP" + } + + {:ok, ap} = + %AccessPoint{} + |> AccessPoint.changeset(Map.merge(default, attrs)) + |> Repo.insert() + + ap + end + + describe "index" do + test "renders page with no access points", %{conn: conn, user: user, organization: org} do + {:ok, _view, html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/devices") + + assert html =~ "Preseem Devices" + end + + test "shows empty state message when no APs exist", %{conn: conn, user: user, organization: org} do + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/devices") + + assert has_element?(view, "#empty-state") + end + + test "shows access points in table", %{conn: conn, user: user, organization: org} do + insert_access_point!(org, %{ + name: "Tower 1 AP", + preseem_id: "preseem-123", + ip_address: "10.0.0.1", + mac_address: "AA:BB:CC:DD:EE:FF" + }) + + {:ok, _view, html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/devices") + + assert html =~ "Tower 1 AP" + assert html =~ "preseem-123" + assert html =~ "10.0.0.1" + end + + test "shows match confidence for each AP", %{conn: conn, user: user, organization: org} do + insert_access_point!(org, %{name: "Unmatched AP", match_confidence: "unmatched"}) + + {:ok, _view, html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/devices") + + assert html =~ "Unmatched" + end + + test "shows linked device name for matched APs", %{conn: conn, user: user, organization: org} do + device = device_fixture(%{organization_id: org.id, name: "My Router"}) + + insert_access_point!(org, %{ + name: "Matched AP", + device_id: device.id, + match_confidence: "manual" + }) + + {:ok, _view, html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/devices") + + assert html =~ "My Router" + end + end + + describe "filtering" do + setup %{organization: org} do + unmatched = insert_access_point!(org, %{name: "Unmatched AP", match_confidence: "unmatched"}) + device = device_fixture(%{organization_id: org.id, name: "Linked Device"}) + + matched = + insert_access_point!(org, %{ + name: "Matched AP", + device_id: device.id, + match_confidence: "auto_mac" + }) + + %{unmatched: unmatched, matched: matched, device: device} + end + + test "shows all APs by default", %{conn: conn, user: user, organization: org} do + {:ok, _view, html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/devices") + + assert html =~ "Unmatched AP" + assert html =~ "Matched AP" + end + + test "filters to unmatched only", %{conn: conn, user: user, organization: org} do + {:ok, _view, html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/devices?filter=unmatched") + + assert html =~ "Unmatched AP" + refute html =~ "Matched AP" + end + + test "filters to matched only", %{conn: conn, user: user, organization: org} do + {:ok, _view, html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/devices?filter=matched") + + refute html =~ "Unmatched AP" + assert html =~ "Matched AP" + end + + test "clicking filter tab patches URL", %{conn: conn, user: user, organization: org} do + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/devices") + + view |> element("#filter-unmatched") |> render_click() + + assert_patch(view, ~p"/orgs/#{org.slug}/settings/integrations/preseem/devices?filter=unmatched") + end + end + + describe "linking" do + test "shows link button for unmatched APs", %{conn: conn, user: user, organization: org} do + ap = insert_access_point!(org, %{name: "Unlinked AP", match_confidence: "unmatched"}) + + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/devices") + + assert has_element?(view, "#link-ap-#{ap.id}") + end + + test "clicking link opens search UI", %{conn: conn, user: user, organization: org} do + ap = insert_access_point!(org, %{name: "Unlinked AP", match_confidence: "unmatched"}) + + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/devices") + + view |> element("#link-ap-#{ap.id}") |> render_click() + + assert has_element?(view, "#device-search-input") + end + + test "searching devices returns results", %{conn: conn, user: user, organization: org} do + device = device_fixture(%{organization_id: org.id, name: "My Tower Router"}) + ap = insert_access_point!(org, %{name: "Unlinked AP", match_confidence: "unmatched"}) + + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/devices") + + view |> element("#link-ap-#{ap.id}") |> render_click() + + html = + view + |> element("#device-search-form") + |> render_change(%{query: "Tower"}) + + assert html =~ "My Tower Router" + + # Verify the device fixture was created correctly + assert device.name == "My Tower Router" + end + + test "can link a device to an AP", %{conn: conn, user: user, organization: org} do + device = device_fixture(%{organization_id: org.id, name: "Link Target"}) + ap = insert_access_point!(org, %{name: "Unlinked AP", match_confidence: "unmatched"}) + + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/devices") + + # Open linking UI + view |> element("#link-ap-#{ap.id}") |> render_click() + + # Search for device + view + |> element("#device-search-form") + |> render_change(%{query: "Link Target"}) + + # Click to link + html = + view + |> element("#select-device-#{device.id}") + |> render_click() + + assert html =~ "Device linked successfully" + + # Verify the AP is now linked in the database + updated_ap = Repo.get!(AccessPoint, ap.id) + assert updated_ap.device_id == device.id + assert updated_ap.match_confidence == "manual" + end + + test "cancel closes linking UI", %{conn: conn, user: user, organization: org} do + ap = insert_access_point!(org, %{name: "AP to Cancel", match_confidence: "unmatched"}) + + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/devices") + + # Open linking UI + view |> element("#link-ap-#{ap.id}") |> render_click() + assert has_element?(view, "#device-search-input") + + # Cancel + view |> element("#cancel-link") |> render_click() + refute has_element?(view, "#device-search-input") + end + end + + describe "unlinking" do + test "shows unlink button for matched APs", %{conn: conn, user: user, organization: org} do + device = device_fixture(%{organization_id: org.id}) + + ap = + insert_access_point!(org, %{ + name: "Linked AP", + device_id: device.id, + match_confidence: "manual" + }) + + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/devices") + + assert has_element?(view, "#unlink-ap-#{ap.id}") + end + + test "can unlink a device from an AP", %{conn: conn, user: user, organization: org} do + device = device_fixture(%{organization_id: org.id}) + + ap = + insert_access_point!(org, %{ + name: "Linked AP", + device_id: device.id, + match_confidence: "manual" + }) + + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/devices") + + html = view |> element("#unlink-ap-#{ap.id}") |> render_click() + assert html =~ "Device unlinked" + + # Verify the AP is now unlinked in the database + updated_ap = Repo.get!(AccessPoint, ap.id) + assert is_nil(updated_ap.device_id) + assert updated_ap.match_confidence == "unmatched" + end + end +end From df2ca42404b14ffa965c3feb855b158d324d19cf Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 13 Feb 2026 08:31:15 -0600 Subject: [PATCH 15/20] add preseem device baselines and fleet profiles schemas --- lib/towerops/preseem/device_baseline.ex | 57 +++++ lib/towerops/preseem/fleet_profile.ex | 51 ++++ ...001542_create_preseem_device_baselines.exs | 30 +++ ...13001547_create_preseem_fleet_profiles.exs | 33 +++ .../towerops/preseem/device_baseline_test.exs | 238 ++++++++++++++++++ test/towerops/preseem/fleet_profile_test.exs | 179 +++++++++++++ 6 files changed, 588 insertions(+) create mode 100644 lib/towerops/preseem/device_baseline.ex create mode 100644 lib/towerops/preseem/fleet_profile.ex create mode 100644 priv/repo/migrations/20260213001542_create_preseem_device_baselines.exs create mode 100644 priv/repo/migrations/20260213001547_create_preseem_fleet_profiles.exs create mode 100644 test/towerops/preseem/device_baseline_test.exs create mode 100644 test/towerops/preseem/fleet_profile_test.exs diff --git a/lib/towerops/preseem/device_baseline.ex b/lib/towerops/preseem/device_baseline.ex new file mode 100644 index 00000000..62d001ab --- /dev/null +++ b/lib/towerops/preseem/device_baseline.ex @@ -0,0 +1,57 @@ +defmodule Towerops.Preseem.DeviceBaseline do + @moduledoc """ + 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 + + @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) + + schema "preseem_device_baselines" do + field :metric_name, :string + field :period, :string + field :mean, :float + field :stddev, :float + field :p5, :float + field :p95, :float + field :sample_count, :integer + field :computed_at, :utc_datetime + + belongs_to :preseem_access_point, Towerops.Preseem.AccessPoint + end + + def changeset(baseline, attrs) do + baseline + |> cast(attrs, [ + :preseem_access_point_id, + :metric_name, + :period, + :mean, + :stddev, + :p5, + :p95, + :sample_count, + :computed_at + ]) + |> validate_required([ + :preseem_access_point_id, + :metric_name, + :period, + :mean, + :sample_count, + :computed_at + ]) + |> validate_inclusion(:period, @valid_periods) + |> validate_inclusion(:metric_name, @valid_metrics) + |> unique_constraint([:preseem_access_point_id, :metric_name, :period], + name: :preseem_device_baselines_ap_metric_period_idx + ) + |> foreign_key_constraint(:preseem_access_point_id) + end +end diff --git a/lib/towerops/preseem/fleet_profile.ex b/lib/towerops/preseem/fleet_profile.ex new file mode 100644 index 00000000..fe6c1a81 --- /dev/null +++ b/lib/towerops/preseem/fleet_profile.ex @@ -0,0 +1,51 @@ +defmodule Towerops.Preseem.FleetProfile do + @moduledoc """ + 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 + + schema "preseem_fleet_profiles" do + field :manufacturer, :string + field :model, :string + field :firmware_version, :string + field :device_count, :integer + field :avg_subscribers, :float + field :avg_qoe_score, :float + field :avg_capacity_score, :float + field :capacity_ceiling, :integer + field :avg_busy_hours, :float + field :performance_data, :map + field :computed_at, :utc_datetime + + belongs_to :organization, Towerops.Organizations.Organization + end + + def changeset(profile, attrs) do + profile + |> cast(attrs, [ + :organization_id, + :manufacturer, + :model, + :firmware_version, + :device_count, + :avg_subscribers, + :avg_qoe_score, + :avg_capacity_score, + :capacity_ceiling, + :avg_busy_hours, + :performance_data, + :computed_at + ]) + |> validate_required([:organization_id, :manufacturer, :model, :device_count, :computed_at]) + |> unique_constraint([:organization_id, :manufacturer, :model, :firmware_version], + name: :preseem_fleet_profiles_org_model_fw_idx + ) + |> foreign_key_constraint(:organization_id) + end +end diff --git a/priv/repo/migrations/20260213001542_create_preseem_device_baselines.exs b/priv/repo/migrations/20260213001542_create_preseem_device_baselines.exs new file mode 100644 index 00000000..f5ca6147 --- /dev/null +++ b/priv/repo/migrations/20260213001542_create_preseem_device_baselines.exs @@ -0,0 +1,30 @@ +defmodule Towerops.Repo.Migrations.CreatePreseemDeviceBaselines do + use Ecto.Migration + + def change do + create table(:preseem_device_baselines, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :preseem_access_point_id, + references(:preseem_access_points, type: :binary_id, on_delete: :delete_all), + null: false + + add :metric_name, :string, null: false + add :period, :string, null: false + add :mean, :float + add :stddev, :float + add :p5, :float + add :p95, :float + add :sample_count, :integer + add :computed_at, :utc_datetime, null: false + end + + create index(:preseem_device_baselines, [:preseem_access_point_id]) + + create unique_index( + :preseem_device_baselines, + [:preseem_access_point_id, :metric_name, :period], + name: :preseem_device_baselines_ap_metric_period_idx + ) + end +end diff --git a/priv/repo/migrations/20260213001547_create_preseem_fleet_profiles.exs b/priv/repo/migrations/20260213001547_create_preseem_fleet_profiles.exs new file mode 100644 index 00000000..3cb80cf6 --- /dev/null +++ b/priv/repo/migrations/20260213001547_create_preseem_fleet_profiles.exs @@ -0,0 +1,33 @@ +defmodule Towerops.Repo.Migrations.CreatePreseemFleetProfiles do + use Ecto.Migration + + def change do + create table(:preseem_fleet_profiles, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :organization_id, + references(:organizations, type: :binary_id, on_delete: :delete_all), + null: false + + add :manufacturer, :string, null: false + add :model, :string, null: false + add :firmware_version, :string + add :device_count, :integer, null: false + add :avg_subscribers, :float + add :avg_qoe_score, :float + add :avg_capacity_score, :float + add :capacity_ceiling, :integer + add :avg_busy_hours, :float + add :performance_data, :map + add :computed_at, :utc_datetime, null: false + end + + create index(:preseem_fleet_profiles, [:organization_id]) + + create unique_index( + :preseem_fleet_profiles, + [:organization_id, :manufacturer, :model, :firmware_version], + name: :preseem_fleet_profiles_org_model_fw_idx + ) + end +end diff --git a/test/towerops/preseem/device_baseline_test.exs b/test/towerops/preseem/device_baseline_test.exs new file mode 100644 index 00000000..df7e1438 --- /dev/null +++ b/test/towerops/preseem/device_baseline_test.exs @@ -0,0 +1,238 @@ +defmodule Towerops.Preseem.DeviceBaselineTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Preseem.AccessPoint + alias Towerops.Preseem.DeviceBaseline + + setup do + user = user_fixture() + org = organization_fixture(user.id) + + {:ok, ap} = + %AccessPoint{} + |> AccessPoint.changeset(%{organization_id: org.id, preseem_id: "ap-baseline-1", name: "Baseline AP"}) + |> Repo.insert() + + %{access_point: ap} + end + + describe "changeset/2" do + test "valid changeset with all fields", %{access_point: ap} do + attrs = %{ + preseem_access_point_id: ap.id, + metric_name: "qoe_score", + period: "busy", + mean: 85.5, + stddev: 4.2, + p5: 72.0, + p95: 95.3, + sample_count: 336, + computed_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + changeset = DeviceBaseline.changeset(%DeviceBaseline{}, attrs) + assert changeset.valid? + end + + test "requires preseem_access_point_id" do + attrs = %{ + metric_name: "qoe_score", + period: "busy", + mean: 85.5, + sample_count: 100, + computed_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + changeset = DeviceBaseline.changeset(%DeviceBaseline{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).preseem_access_point_id + end + + test "requires metric_name" do + attrs = %{ + preseem_access_point_id: Ecto.UUID.generate(), + period: "busy", + mean: 85.5, + sample_count: 100, + computed_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + changeset = DeviceBaseline.changeset(%DeviceBaseline{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).metric_name + end + + test "requires period" do + attrs = %{ + preseem_access_point_id: Ecto.UUID.generate(), + metric_name: "qoe_score", + mean: 85.5, + sample_count: 100, + computed_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + changeset = DeviceBaseline.changeset(%DeviceBaseline{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).period + end + + test "requires mean" do + attrs = %{ + preseem_access_point_id: Ecto.UUID.generate(), + metric_name: "qoe_score", + period: "busy", + sample_count: 100, + computed_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + changeset = DeviceBaseline.changeset(%DeviceBaseline{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).mean + end + + test "requires sample_count" do + attrs = %{ + preseem_access_point_id: Ecto.UUID.generate(), + metric_name: "qoe_score", + period: "busy", + mean: 85.5, + computed_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + changeset = DeviceBaseline.changeset(%DeviceBaseline{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).sample_count + end + + test "requires computed_at" do + attrs = %{ + preseem_access_point_id: Ecto.UUID.generate(), + metric_name: "qoe_score", + period: "busy", + mean: 85.5, + sample_count: 100 + } + + changeset = DeviceBaseline.changeset(%DeviceBaseline{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).computed_at + end + + test "validates period inclusion" do + attrs = %{ + preseem_access_point_id: Ecto.UUID.generate(), + metric_name: "qoe_score", + period: "invalid_period", + mean: 85.5, + sample_count: 100, + computed_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + changeset = DeviceBaseline.changeset(%DeviceBaseline{}, attrs) + refute changeset.valid? + assert "is invalid" in errors_on(changeset).period + end + + test "accepts all valid periods" do + for period <- ~w(busy offpeak all) do + attrs = %{ + preseem_access_point_id: Ecto.UUID.generate(), + metric_name: "qoe_score", + period: period, + mean: 85.5, + sample_count: 100, + computed_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + changeset = DeviceBaseline.changeset(%DeviceBaseline{}, attrs) + assert changeset.valid?, "Expected period '#{period}' to be valid" + end + end + + test "validates metric_name inclusion" do + attrs = %{ + preseem_access_point_id: Ecto.UUID.generate(), + metric_name: "invalid_metric", + period: "busy", + mean: 85.5, + sample_count: 100, + computed_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + changeset = DeviceBaseline.changeset(%DeviceBaseline{}, attrs) + refute changeset.valid? + assert "is invalid" in errors_on(changeset).metric_name + end + + test "accepts all valid metric names" do + valid_metrics = + ~w(qoe_score capacity_score rf_score p95_latency avg_latency avg_jitter avg_loss avg_throughput subscriber_count airtime_utilization) + + for metric <- valid_metrics do + attrs = %{ + preseem_access_point_id: Ecto.UUID.generate(), + metric_name: metric, + period: "busy", + mean: 85.5, + sample_count: 100, + computed_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + changeset = DeviceBaseline.changeset(%DeviceBaseline{}, attrs) + assert changeset.valid?, "Expected metric '#{metric}' to be valid" + end + end + + test "persists and reloads from database", %{access_point: ap} do + now = DateTime.truncate(DateTime.utc_now(), :second) + + attrs = %{ + preseem_access_point_id: ap.id, + metric_name: "qoe_score", + period: "busy", + mean: 85.5, + stddev: 4.2, + p5: 72.0, + p95: 95.3, + sample_count: 336, + computed_at: now + } + + {:ok, baseline} = %DeviceBaseline{} |> DeviceBaseline.changeset(attrs) |> Repo.insert() + reloaded = Repo.get!(DeviceBaseline, baseline.id) + + assert reloaded.preseem_access_point_id == ap.id + assert reloaded.metric_name == "qoe_score" + assert reloaded.period == "busy" + assert reloaded.mean == 85.5 + assert reloaded.stddev == 4.2 + assert reloaded.p5 == 72.0 + assert reloaded.p95 == 95.3 + assert reloaded.sample_count == 336 + assert reloaded.computed_at == now + end + + test "enforces unique constraint on [access_point_id, metric_name, period]", %{access_point: ap} do + now = DateTime.truncate(DateTime.utc_now(), :second) + + attrs = %{ + preseem_access_point_id: ap.id, + metric_name: "qoe_score", + period: "busy", + mean: 85.5, + sample_count: 336, + computed_at: now + } + + {:ok, _} = %DeviceBaseline{} |> DeviceBaseline.changeset(attrs) |> Repo.insert() + + assert {:error, changeset} = + %DeviceBaseline{} |> DeviceBaseline.changeset(attrs) |> Repo.insert() + + assert errors_on(changeset).preseem_access_point_id != [] + end + end +end diff --git a/test/towerops/preseem/fleet_profile_test.exs b/test/towerops/preseem/fleet_profile_test.exs new file mode 100644 index 00000000..4c8a6749 --- /dev/null +++ b/test/towerops/preseem/fleet_profile_test.exs @@ -0,0 +1,179 @@ +defmodule Towerops.Preseem.FleetProfileTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Preseem.FleetProfile + + setup do + user = user_fixture() + org = organization_fixture(user.id) + %{organization: org} + end + + describe "changeset/2" do + test "valid changeset with all fields", %{organization: org} do + attrs = %{ + organization_id: org.id, + manufacturer: "Cambium", + model: "ePMP 3000", + firmware_version: "4.7.1", + device_count: 25, + avg_subscribers: 18.5, + avg_qoe_score: 82.3, + avg_capacity_score: 71.0, + capacity_ceiling: 35, + avg_busy_hours: 6.2, + performance_data: %{"p50_qoe" => 84.0, "p10_qoe" => 65.2}, + computed_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + changeset = FleetProfile.changeset(%FleetProfile{}, attrs) + assert changeset.valid? + end + + test "requires organization_id" do + attrs = %{ + manufacturer: "Cambium", + model: "ePMP 3000", + device_count: 25, + computed_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + changeset = FleetProfile.changeset(%FleetProfile{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).organization_id + end + + test "requires manufacturer" do + attrs = %{ + organization_id: Ecto.UUID.generate(), + model: "ePMP 3000", + device_count: 25, + computed_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + changeset = FleetProfile.changeset(%FleetProfile{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).manufacturer + end + + test "requires model" do + attrs = %{ + organization_id: Ecto.UUID.generate(), + manufacturer: "Cambium", + device_count: 25, + computed_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + changeset = FleetProfile.changeset(%FleetProfile{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).model + end + + test "requires device_count" do + attrs = %{ + organization_id: Ecto.UUID.generate(), + manufacturer: "Cambium", + model: "ePMP 3000", + computed_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + changeset = FleetProfile.changeset(%FleetProfile{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).device_count + end + + test "requires computed_at" do + attrs = %{ + organization_id: Ecto.UUID.generate(), + manufacturer: "Cambium", + model: "ePMP 3000", + device_count: 25 + } + + changeset = FleetProfile.changeset(%FleetProfile{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).computed_at + end + + test "persists and reloads from database", %{organization: org} do + now = DateTime.truncate(DateTime.utc_now(), :second) + + attrs = %{ + organization_id: org.id, + manufacturer: "Ubiquiti", + model: "LiteAP AC", + firmware_version: "8.7.11", + device_count: 12, + avg_subscribers: 22.7, + avg_qoe_score: 88.1, + avg_capacity_score: 76.5, + capacity_ceiling: 40, + avg_busy_hours: 5.0, + performance_data: %{"percentiles" => %{"p50" => 85, "p90" => 92}}, + computed_at: now + } + + {:ok, profile} = %FleetProfile{} |> FleetProfile.changeset(attrs) |> Repo.insert() + reloaded = Repo.get!(FleetProfile, profile.id) + + assert reloaded.organization_id == org.id + assert reloaded.manufacturer == "Ubiquiti" + assert reloaded.model == "LiteAP AC" + assert reloaded.firmware_version == "8.7.11" + assert reloaded.device_count == 12 + assert reloaded.avg_subscribers == 22.7 + assert reloaded.avg_qoe_score == 88.1 + assert reloaded.avg_capacity_score == 76.5 + assert reloaded.capacity_ceiling == 40 + assert reloaded.avg_busy_hours == 5.0 + assert reloaded.computed_at == now + end + + test "stores and retrieves performance_data map", %{organization: org} do + performance_data = %{ + "by_firmware" => %{ + "4.7.1" => %{"count" => 10, "avg_qoe" => 85.0}, + "4.6.0" => %{"count" => 5, "avg_qoe" => 78.2} + }, + "capacity_curve" => [ + %{"subscribers" => 10, "avg_qoe" => 92.0}, + %{"subscribers" => 20, "avg_qoe" => 85.0}, + %{"subscribers" => 30, "avg_qoe" => 72.0} + ] + } + + attrs = %{ + organization_id: org.id, + manufacturer: "Cambium", + model: "ePMP 3000", + device_count: 15, + performance_data: performance_data, + computed_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + {:ok, profile} = %FleetProfile{} |> FleetProfile.changeset(attrs) |> Repo.insert() + reloaded = Repo.get!(FleetProfile, profile.id) + + assert reloaded.performance_data == performance_data + end + + test "firmware_version is optional", %{organization: org} do + attrs = %{ + organization_id: org.id, + manufacturer: "Cambium", + model: "ePMP 3000", + device_count: 25, + computed_at: DateTime.truncate(DateTime.utc_now(), :second) + } + + changeset = FleetProfile.changeset(%FleetProfile{}, attrs) + assert changeset.valid? + + {:ok, profile} = Repo.insert(changeset) + assert is_nil(profile.firmware_version) + end + end +end From 6a827a0ed6b0ebc7b3208a55744d6e82bbd2925c Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 13 Feb 2026 08:40:04 -0600 Subject: [PATCH 16/20] add preseem baseline and fleet intelligence computation --- config/dev.exs | 4 +- config/runtime.exs | 4 +- lib/towerops/preseem/baseline.ex | 107 +++++++++ lib/towerops/preseem/fleet_intelligence.ex | 109 ++++++++++ .../workers/preseem_baseline_worker.ex | 36 ++++ test/towerops/preseem/baseline_test.exs | 204 ++++++++++++++++++ .../preseem/fleet_intelligence_test.exs | 187 ++++++++++++++++ .../workers/preseem_baseline_worker_test.exs | 31 +++ 8 files changed, 680 insertions(+), 2 deletions(-) create mode 100644 lib/towerops/preseem/baseline.ex create mode 100644 lib/towerops/preseem/fleet_intelligence.ex create mode 100644 lib/towerops/workers/preseem_baseline_worker.ex create mode 100644 test/towerops/preseem/baseline_test.exs create mode 100644 test/towerops/preseem/fleet_intelligence_test.exs create mode 100644 test/towerops/workers/preseem_baseline_worker_test.exs diff --git a/config/dev.exs b/config/dev.exs index 6dcea6b0..a6f07b85 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -81,7 +81,9 @@ config :towerops, Oban, # Delete stale violation records daily at 2 AM {"0 2 * * *", Towerops.Workers.StaleViolationCleanupWorker}, # Sync Preseem data every 10 minutes - {"*/10 * * * *", Towerops.Workers.PreseemSyncWorker} + {"*/10 * * * *", Towerops.Workers.PreseemSyncWorker}, + # Compute Preseem baselines and fleet profiles nightly at 2:30 AM + {"30 2 * * *", Towerops.Workers.PreseemBaselineWorker} ]}, # Automatically delete completed jobs after 60 seconds {Oban.Plugins.Pruner, max_age: 60}, diff --git a/config/runtime.exs b/config/runtime.exs index 520fabf2..1c76fdfb 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -194,7 +194,9 @@ if config_env() == :prod do # Delete stale violation records daily at 2 AM {"0 2 * * *", Towerops.Workers.StaleViolationCleanupWorker}, # Sync Preseem data every 10 minutes - {"*/10 * * * *", Towerops.Workers.PreseemSyncWorker} + {"*/10 * * * *", Towerops.Workers.PreseemSyncWorker}, + # Compute Preseem baselines and fleet profiles nightly at 2:30 AM + {"30 2 * * *", Towerops.Workers.PreseemBaselineWorker} ]}, # Automatically delete completed jobs after 60 seconds {Oban.Plugins.Pruner, max_age: 60}, diff --git a/lib/towerops/preseem/baseline.ex b/lib/towerops/preseem/baseline.ex new file mode 100644 index 00000000..e9147da9 --- /dev/null +++ b/lib/towerops/preseem/baseline.ex @@ -0,0 +1,107 @@ +defmodule Towerops.Preseem.Baseline do + @moduledoc """ + Computes rolling 14-day baselines for individual Preseem access points. + """ + import Ecto.Query + + alias Towerops.Preseem.AccessPoint + alias Towerops.Preseem.DeviceBaseline + alias Towerops.Preseem.SubscriberMetric + alias Towerops.Repo + + @baseline_days 14 + @metrics ~w(avg_latency avg_jitter avg_loss avg_throughput p95_latency subscriber_count) + + @doc "Compute baselines for all matched APs in an organization." + def compute_baselines(organization_id) do + cutoff = DateTime.add(DateTime.utc_now(), -@baseline_days * 86_400, :second) + now = DateTime.truncate(DateTime.utc_now(), :second) + + matched_aps = + AccessPoint + |> where(organization_id: ^organization_id) + |> where([ap], not is_nil(ap.device_id)) + |> Repo.all() + + count = + Enum.reduce(matched_aps, 0, fn ap, acc -> + metrics = get_metrics_in_window(ap.id, cutoff) + + if length(metrics) >= 3 do + compute_and_upsert_baselines(ap.id, metrics, now) + acc + 1 + else + acc + end + end) + + {:ok, count} + end + + defp get_metrics_in_window(ap_id, cutoff) do + SubscriberMetric + |> where(preseem_access_point_id: ^ap_id) + |> where([m], m.recorded_at >= ^cutoff) + |> order_by(:recorded_at) + |> Repo.all() + end + + defp compute_and_upsert_baselines(ap_id, metrics, now) do + for metric_name <- @metrics do + values = + metrics + |> Enum.map(&Map.get(&1, String.to_existing_atom(metric_name))) + |> Enum.reject(&is_nil/1) + + if length(values) >= 3 do + stats = compute_stats(values) + + upsert_baseline(ap_id, %{ + metric_name: metric_name, + period: "all", + mean: stats.mean, + stddev: stats.stddev, + p5: stats.p5, + p95: stats.p95, + sample_count: length(values), + computed_at: now + }) + end + end + end + + defp compute_stats(values) do + sorted = Enum.sort(values) + n = length(sorted) + mean = Enum.sum(sorted) / n + + variance = + sorted + |> Enum.map(fn v -> (v - mean) * (v - mean) end) + |> Enum.sum() + |> Kernel./(max(n - 1, 1)) + + stddev = :math.sqrt(variance) + p5_idx = max(round(n * 0.05) - 1, 0) + p95_idx = min(round(n * 0.95) - 1, n - 1) + + %{ + mean: Float.round(mean / 1, 4), + stddev: Float.round(stddev / 1, 4), + p5: sorted |> Enum.at(p5_idx) |> to_float(), + p95: sorted |> Enum.at(p95_idx) |> to_float() + } + end + + defp to_float(v) when is_float(v), do: Float.round(v, 4) + defp to_float(v) when is_integer(v), do: v * 1.0 + + defp upsert_baseline(ap_id, attrs) do + %DeviceBaseline{} + |> DeviceBaseline.changeset(Map.put(attrs, :preseem_access_point_id, ap_id)) + |> Repo.insert( + on_conflict: {:replace_all_except, [:id, :preseem_access_point_id]}, + conflict_target: [:preseem_access_point_id, :metric_name, :period] + ) + end +end diff --git a/lib/towerops/preseem/fleet_intelligence.ex b/lib/towerops/preseem/fleet_intelligence.ex new file mode 100644 index 00000000..0d22dc7b --- /dev/null +++ b/lib/towerops/preseem/fleet_intelligence.ex @@ -0,0 +1,109 @@ +defmodule Towerops.Preseem.FleetIntelligence do + @moduledoc """ + Computes fleet-wide model performance profiles by grouping matched devices + by manufacturer and model. + """ + import Ecto.Query + + alias Towerops.Preseem.AccessPoint + alias Towerops.Preseem.FleetProfile + alias Towerops.Repo + + @doc "Compute fleet profiles for all matched APs in an organization." + def compute_fleet_profiles(organization_id) do + now = DateTime.truncate(DateTime.utc_now(), :second) + + matched_aps = + AccessPoint + |> where(organization_id: ^organization_id) + |> where([ap], not is_nil(ap.device_id)) + |> where([ap], not is_nil(ap.model)) + |> Repo.all() + + groups = + Enum.group_by(matched_aps, fn ap -> + {extract_manufacturer(ap.model), ap.model} + end) + + count = + Enum.reduce(groups, 0, fn {{manufacturer, model}, aps}, acc -> + profile_attrs = compute_model_profile(manufacturer, model, aps, now) + upsert_fleet_profile(organization_id, profile_attrs) + acc + 1 + end) + + {:ok, count} + end + + defp extract_manufacturer(model) when is_binary(model) do + case String.split(model, " ", parts: 2) do + [manufacturer, _rest] -> manufacturer + [single] -> single + end + end + + defp compute_model_profile(manufacturer, model, aps, now) do + subscriber_counts = aps |> Enum.map(& &1.subscriber_count) |> Enum.reject(&is_nil/1) + qoe_scores = aps |> Enum.map(& &1.qoe_score) |> Enum.reject(&is_nil/1) + capacity_scores = aps |> Enum.map(& &1.capacity_score) |> Enum.reject(&is_nil/1) + busy_hours = aps |> Enum.map(& &1.busy_hours) |> Enum.reject(&is_nil/1) + + %{ + manufacturer: manufacturer, + model: model, + firmware_version: nil, + device_count: length(aps), + avg_subscribers: safe_avg(subscriber_counts), + avg_qoe_score: safe_avg(qoe_scores), + avg_capacity_score: safe_avg(capacity_scores), + capacity_ceiling: compute_capacity_ceiling(aps), + avg_busy_hours: safe_avg(busy_hours), + performance_data: %{ + "subscriber_range" => %{ + "min" => Enum.min(subscriber_counts, fn -> nil end), + "max" => Enum.max(subscriber_counts, fn -> nil end) + }, + "firmware_distribution" => aps |> Enum.map(& &1.firmware) |> Enum.reject(&is_nil/1) |> Enum.frequencies() + }, + computed_at: now + } + end + + defp safe_avg([]), do: nil + defp safe_avg(values), do: Float.round(Enum.sum(values) / length(values), 2) + + defp compute_capacity_ceiling(aps) do + aps + |> Enum.filter(fn ap -> ap.qoe_score != nil and ap.subscriber_count != nil end) + |> Enum.filter(fn ap -> ap.qoe_score >= 70 end) + |> Enum.map(& &1.subscriber_count) + |> Enum.max(fn -> nil end) + end + + # Use a manual query + update/insert since the unique index includes nullable firmware_version. + # PostgreSQL treats NULLs as distinct in unique indexes, so ON CONFLICT won't match + # rows with NULL firmware_version. Ecto's get_by also rejects nil values. + defp upsert_fleet_profile(organization_id, attrs) do + full_attrs = Map.put(attrs, :organization_id, organization_id) + + existing = + FleetProfile + |> where(organization_id: ^organization_id) + |> where(manufacturer: ^attrs.manufacturer) + |> where(model: ^attrs.model) + |> where([fp], is_nil(fp.firmware_version)) + |> Repo.one() + + case existing do + nil -> + %FleetProfile{} + |> FleetProfile.changeset(full_attrs) + |> Repo.insert!() + + profile -> + profile + |> FleetProfile.changeset(full_attrs) + |> Repo.update!() + end + end +end diff --git a/lib/towerops/workers/preseem_baseline_worker.ex b/lib/towerops/workers/preseem_baseline_worker.ex new file mode 100644 index 00000000..fe48b2bd --- /dev/null +++ b/lib/towerops/workers/preseem_baseline_worker.ex @@ -0,0 +1,36 @@ +defmodule Towerops.Workers.PreseemBaselineWorker do + @moduledoc """ + Nightly Oban cron worker that computes device baselines and fleet profiles + for all organizations with enabled Preseem integrations. + """ + use Oban.Worker, queue: :maintenance + + alias Towerops.Integrations + alias Towerops.Preseem.Baseline + alias Towerops.Preseem.FleetIntelligence + + require Logger + + @impl Oban.Worker + def perform(%Oban.Job{}) do + integrations = Integrations.list_enabled_integrations("preseem") + + Enum.each(integrations, fn integration -> + org_id = integration.organization_id + compute_for_org(org_id) + end) + + :ok + end + + defp compute_for_org(org_id) do + {:ok, baseline_count} = Baseline.compute_baselines(org_id) + Logger.info("Preseem baselines computed for org #{org_id}: #{baseline_count} devices") + + {:ok, profile_count} = FleetIntelligence.compute_fleet_profiles(org_id) + Logger.info("Preseem fleet profiles computed for org #{org_id}: #{profile_count} models") + rescue + error -> + Logger.error("Preseem baseline/fleet computation failed for org #{org_id}: #{inspect(error)}") + end +end diff --git a/test/towerops/preseem/baseline_test.exs b/test/towerops/preseem/baseline_test.exs new file mode 100644 index 00000000..512f9497 --- /dev/null +++ b/test/towerops/preseem/baseline_test.exs @@ -0,0 +1,204 @@ +defmodule Towerops.Preseem.BaselineTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.DevicesFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Preseem.AccessPoint + alias Towerops.Preseem.Baseline + alias Towerops.Preseem.DeviceBaseline + alias Towerops.Preseem.SubscriberMetric + + setup do + user = user_fixture() + org = organization_fixture(user.id) + %{org: org} + end + + describe "compute_baselines/1" do + test "computes baselines for matched APs with sufficient data", %{org: org} do + ap = insert_matched_ap(org) + + for i <- 1..5 do + insert_subscriber_metric(ap.id, %{ + avg_latency: 10.0 + i, + p95_latency: 20.0 + i, + subscriber_count: 40 + i, + recorded_at: hours_ago(i) + }) + end + + assert {:ok, 1} = Baseline.compute_baselines(org.id) + + baselines = Repo.all(DeviceBaseline) + assert length(baselines) > 0 + + latency_baseline = Enum.find(baselines, &(&1.metric_name == "avg_latency")) + assert latency_baseline + assert latency_baseline.sample_count == 5 + assert latency_baseline.period == "all" + assert latency_baseline.mean + assert latency_baseline.stddev + assert latency_baseline.p5 + assert latency_baseline.p95 + end + + test "skips APs with fewer than 3 data points", %{org: org} do + ap = insert_matched_ap(org) + + for i <- 1..2 do + insert_subscriber_metric(ap.id, %{ + avg_latency: 10.0 + i, + recorded_at: hours_ago(i) + }) + end + + assert {:ok, 0} = Baseline.compute_baselines(org.id) + assert Repo.all(DeviceBaseline) == [] + end + + test "skips unmatched APs", %{org: org} do + ap = insert_unmatched_ap(org.id) + + for i <- 1..5 do + insert_subscriber_metric(ap.id, %{ + avg_latency: 10.0 + i, + recorded_at: hours_ago(i) + }) + end + + assert {:ok, 0} = Baseline.compute_baselines(org.id) + end + + test "handles empty organization", %{org: org} do + assert {:ok, 0} = Baseline.compute_baselines(org.id) + end + + test "only includes metrics within the 14-day window", %{org: org} do + ap = insert_matched_ap(org) + + # 3 recent metrics (within window) + for i <- 1..3 do + insert_subscriber_metric(ap.id, %{ + avg_latency: 10.0 + i, + recorded_at: hours_ago(i) + }) + end + + # 2 old metrics (outside 14-day window) + for i <- 1..2 do + insert_subscriber_metric(ap.id, %{ + avg_latency: 100.0 + i, + recorded_at: days_ago(15 + i) + }) + end + + assert {:ok, 1} = Baseline.compute_baselines(org.id) + + latency_baseline = + DeviceBaseline + |> Repo.all() + |> Enum.find(&(&1.metric_name == "avg_latency")) + + # Should only have 3 samples from the recent window + assert latency_baseline.sample_count == 3 + end + + test "upserts baselines on recomputation", %{org: org} do + ap = insert_matched_ap(org) + + for i <- 1..5 do + insert_subscriber_metric(ap.id, %{ + avg_latency: 10.0 + i, + recorded_at: hours_ago(i) + }) + end + + assert {:ok, 1} = Baseline.compute_baselines(org.id) + initial_count = length(Repo.all(DeviceBaseline)) + + # Recompute - should update existing baselines, not create duplicates + assert {:ok, 1} = Baseline.compute_baselines(org.id) + assert length(Repo.all(DeviceBaseline)) == initial_count + end + + test "computes baselines for multiple matched APs", %{org: org} do + for _idx <- 1..3 do + ap = insert_matched_ap(org) + + for i <- 1..4 do + insert_subscriber_metric(ap.id, %{ + avg_latency: 10.0 + i, + recorded_at: hours_ago(i) + }) + end + end + + assert {:ok, 3} = Baseline.compute_baselines(org.id) + end + + test "skips metrics with nil values for a given metric name", %{org: org} do + ap = insert_matched_ap(org) + + # Insert metrics where avg_jitter is nil but avg_latency has values + for i <- 1..5 do + insert_subscriber_metric(ap.id, %{ + avg_latency: 10.0 + i, + avg_jitter: nil, + recorded_at: hours_ago(i) + }) + end + + assert {:ok, 1} = Baseline.compute_baselines(org.id) + + baselines = Repo.all(DeviceBaseline) + latency_baseline = Enum.find(baselines, &(&1.metric_name == "avg_latency")) + jitter_baseline = Enum.find(baselines, &(&1.metric_name == "avg_jitter")) + + assert latency_baseline + # avg_jitter should not have a baseline since all values are nil (< 3 non-nil) + assert jitter_baseline == nil + end + end + + # Helpers + + defp insert_matched_ap(org) do + device = device_fixture(%{organization_id: org.id}) + + %AccessPoint{} + |> AccessPoint.changeset(%{ + organization_id: org.id, + preseem_id: "ap-#{System.unique_integer([:positive])}", + name: "Test AP", + device_id: device.id, + match_confidence: "auto_ip" + }) + |> Repo.insert!() + end + + defp insert_unmatched_ap(org_id) do + %AccessPoint{} + |> AccessPoint.changeset(%{ + organization_id: org_id, + preseem_id: "ap-unmatched-#{System.unique_integer([:positive])}", + name: "Unmatched AP" + }) + |> Repo.insert!() + end + + defp insert_subscriber_metric(ap_id, attrs) do + %SubscriberMetric{} + |> SubscriberMetric.changeset(Map.put(attrs, :preseem_access_point_id, ap_id)) + |> Repo.insert!() + end + + defp hours_ago(n) do + DateTime.utc_now() |> DateTime.add(-n * 3600, :second) |> DateTime.truncate(:second) + end + + defp days_ago(n) do + DateTime.utc_now() |> DateTime.add(-n * 86_400, :second) |> DateTime.truncate(:second) + end +end diff --git a/test/towerops/preseem/fleet_intelligence_test.exs b/test/towerops/preseem/fleet_intelligence_test.exs new file mode 100644 index 00000000..f4fc88df --- /dev/null +++ b/test/towerops/preseem/fleet_intelligence_test.exs @@ -0,0 +1,187 @@ +defmodule Towerops.Preseem.FleetIntelligenceTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.DevicesFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Preseem.AccessPoint + alias Towerops.Preseem.FleetIntelligence + alias Towerops.Preseem.FleetProfile + + setup do + user = user_fixture() + org = organization_fixture(user.id) + %{org: org} + end + + describe "compute_fleet_profiles/1" do + test "computes profiles grouped by model", %{org: org} do + for i <- 1..3 do + insert_matched_ap_with_model(org, "Ubiquiti AF5XHD", %{ + subscriber_count: 30 + i * 5, + qoe_score: 80.0 + i, + capacity_score: 70.0 + i + }) + end + + assert {:ok, 1} = FleetIntelligence.compute_fleet_profiles(org.id) + + profiles = Repo.all(FleetProfile) + assert length(profiles) == 1 + profile = hd(profiles) + assert profile.model == "Ubiquiti AF5XHD" + assert profile.manufacturer == "Ubiquiti" + assert profile.device_count == 3 + assert profile.avg_subscribers + assert profile.avg_qoe_score + assert profile.avg_capacity_score + end + + test "creates separate profiles for different models", %{org: org} do + for _i <- 1..2 do + insert_matched_ap_with_model(org, "Ubiquiti AF5XHD", %{ + subscriber_count: 30, + qoe_score: 80.0 + }) + end + + for _i <- 1..2 do + insert_matched_ap_with_model(org, "MikroTik RB4011", %{ + subscriber_count: 20, + qoe_score: 75.0 + }) + end + + assert {:ok, 2} = FleetIntelligence.compute_fleet_profiles(org.id) + + profiles = Repo.all(FleetProfile) + assert length(profiles) == 2 + + manufacturers = profiles |> Enum.map(& &1.manufacturer) |> Enum.sort() + assert manufacturers == ["MikroTik", "Ubiquiti"] + end + + test "handles empty organization", %{org: org} do + assert {:ok, 0} = FleetIntelligence.compute_fleet_profiles(org.id) + end + + test "skips APs without model", %{org: org} do + device = device_fixture(%{organization_id: org.id}) + + %AccessPoint{} + |> AccessPoint.changeset(%{ + organization_id: org.id, + preseem_id: "ap-no-model-#{System.unique_integer([:positive])}", + name: "No Model", + device_id: device.id, + match_confidence: "auto_ip" + }) + |> Repo.insert!() + + assert {:ok, 0} = FleetIntelligence.compute_fleet_profiles(org.id) + end + + test "skips unmatched APs", %{org: org} do + %AccessPoint{} + |> AccessPoint.changeset(%{ + organization_id: org.id, + preseem_id: "ap-unmatched-#{System.unique_integer([:positive])}", + name: "Unmatched", + model: "Ubiquiti AF5XHD" + }) + |> Repo.insert!() + + assert {:ok, 0} = FleetIntelligence.compute_fleet_profiles(org.id) + end + + test "computes capacity ceiling from APs with good QoE", %{org: org} do + # AP with high subscribers and good QoE - should define ceiling + insert_matched_ap_with_model(org, "Ubiquiti AF5XHD", %{ + subscriber_count: 50, + qoe_score: 85.0 + }) + + # AP with even higher subscribers but poor QoE - excluded from ceiling + insert_matched_ap_with_model(org, "Ubiquiti AF5XHD", %{ + subscriber_count: 80, + qoe_score: 55.0 + }) + + # AP with low subscribers and good QoE + insert_matched_ap_with_model(org, "Ubiquiti AF5XHD", %{ + subscriber_count: 20, + qoe_score: 95.0 + }) + + assert {:ok, 1} = FleetIntelligence.compute_fleet_profiles(org.id) + + profile = Repo.one!(FleetProfile) + # Capacity ceiling should be 50 (highest subscriber count with QoE >= 70) + assert profile.capacity_ceiling == 50 + end + + test "upserts profiles on recomputation", %{org: org} do + for _i <- 1..3 do + insert_matched_ap_with_model(org, "Ubiquiti AF5XHD", %{ + subscriber_count: 30, + qoe_score: 80.0 + }) + end + + assert {:ok, 1} = FleetIntelligence.compute_fleet_profiles(org.id) + assert length(Repo.all(FleetProfile)) == 1 + + # Recompute - should update, not duplicate + assert {:ok, 1} = FleetIntelligence.compute_fleet_profiles(org.id) + assert length(Repo.all(FleetProfile)) == 1 + end + + test "includes performance_data with subscriber range and firmware distribution", %{org: org} do + insert_matched_ap_with_model(org, "Ubiquiti AF5XHD", %{ + subscriber_count: 20, + firmware: "v4.3.1" + }) + + insert_matched_ap_with_model(org, "Ubiquiti AF5XHD", %{ + subscriber_count: 50, + firmware: "v4.3.1" + }) + + insert_matched_ap_with_model(org, "Ubiquiti AF5XHD", %{ + subscriber_count: 35, + firmware: "v4.3.2" + }) + + assert {:ok, 1} = FleetIntelligence.compute_fleet_profiles(org.id) + + profile = Repo.one!(FleetProfile) + assert profile.performance_data["subscriber_range"]["min"] == 20 + assert profile.performance_data["subscriber_range"]["max"] == 50 + assert profile.performance_data["firmware_distribution"] == %{"v4.3.1" => 2, "v4.3.2" => 1} + end + end + + # Helpers + + defp insert_matched_ap_with_model(org, model, extra_attrs) do + device = device_fixture(%{organization_id: org.id}) + + attrs = + Map.merge( + %{ + organization_id: org.id, + preseem_id: "ap-#{System.unique_integer([:positive])}", + name: "AP", + model: model, + device_id: device.id, + match_confidence: "auto_ip" + }, + extra_attrs + ) + + %AccessPoint{} + |> AccessPoint.changeset(attrs) + |> Repo.insert!() + end +end diff --git a/test/towerops/workers/preseem_baseline_worker_test.exs b/test/towerops/workers/preseem_baseline_worker_test.exs new file mode 100644 index 00000000..f2829f2b --- /dev/null +++ b/test/towerops/workers/preseem_baseline_worker_test.exs @@ -0,0 +1,31 @@ +defmodule Towerops.Workers.PreseemBaselineWorkerTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.IntegrationsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Workers.PreseemBaselineWorker + + describe "perform/1" do + test "runs without errors for org with enabled integration" do + user = user_fixture() + org = organization_fixture(user.id) + _integration = integration_fixture(org.id) + + assert :ok = PreseemBaselineWorker.perform(%Oban.Job{}) + end + + test "handles no integrations" do + assert :ok = PreseemBaselineWorker.perform(%Oban.Job{}) + end + + test "skips disabled integrations" do + user = user_fixture() + org = organization_fixture(user.id) + _integration = integration_fixture(org.id, %{enabled: false}) + + assert :ok = PreseemBaselineWorker.perform(%Oban.Job{}) + end + end +end From 9a19b9b0a594853c3356a83f056f9ed6559dd481 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 13 Feb 2026 08:53:01 -0600 Subject: [PATCH 17/20] add preseem insights schema, generation, and queries --- lib/towerops/preseem.ex | 8 + lib/towerops/preseem/insight.ex | 59 +++ lib/towerops/preseem/insights.ex | 251 +++++++++++ .../workers/preseem_baseline_worker.ex | 4 + ...20260213144412_create_preseem_insights.exs | 32 ++ test/towerops/preseem/insight_test.exs | 235 +++++++++++ test/towerops/preseem/insights_test.exs | 391 ++++++++++++++++++ 7 files changed, 980 insertions(+) create mode 100644 lib/towerops/preseem/insight.ex create mode 100644 lib/towerops/preseem/insights.ex create mode 100644 priv/repo/migrations/20260213144412_create_preseem_insights.exs create mode 100644 test/towerops/preseem/insight_test.exs create mode 100644 test/towerops/preseem/insights_test.exs diff --git a/lib/towerops/preseem.ex b/lib/towerops/preseem.ex index 52cee112..43b5ba0a 100644 --- a/lib/towerops/preseem.ex +++ b/lib/towerops/preseem.ex @@ -6,6 +6,7 @@ defmodule Towerops.Preseem do import Ecto.Query alias Towerops.Preseem.AccessPoint + alias Towerops.Preseem.Insights alias Towerops.Preseem.SubscriberMetric alias Towerops.Repo @@ -76,4 +77,11 @@ defmodule Towerops.Preseem do |> Repo.update() end end + + # --- Insight delegations --- + + defdelegate list_insights(organization_id, opts \\ []), to: Insights + defdelegate list_device_insights(device_id), to: Insights + defdelegate dismiss_insight(insight_id), to: Insights + defdelegate dismiss_insights(insight_ids), to: Insights end diff --git a/lib/towerops/preseem/insight.ex b/lib/towerops/preseem/insight.ex new file mode 100644 index 00000000..215f8adf --- /dev/null +++ b/lib/towerops/preseem/insight.ex @@ -0,0 +1,59 @@ +defmodule Towerops.Preseem.Insight do + @moduledoc """ + 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 + + @valid_types ~w(qoe_degradation capacity_saturation firmware_opportunity model_underperforming subscriber_growth config_drift) + @valid_urgencies ~w(critical warning info) + @valid_statuses ~w(active dismissed resolved) + @valid_channels ~w(proactive contextual passive) + + schema "preseem_insights" do + field :type, :string + field :urgency, :string + field :status, :string, default: "active" + field :channel, :string + field :title, :string + field :description, :string + field :metadata, :map + field :dismissed_at, :utc_datetime + + belongs_to :organization, Towerops.Organizations.Organization + belongs_to :preseem_access_point, Towerops.Preseem.AccessPoint + belongs_to :device, Towerops.Devices.Device + + timestamps(type: :utc_datetime) + end + + def changeset(insight, attrs) do + insight + |> cast(attrs, [ + :organization_id, + :preseem_access_point_id, + :device_id, + :type, + :urgency, + :status, + :channel, + :title, + :description, + :metadata, + :dismissed_at + ]) + |> validate_required([:organization_id, :type, :urgency, :channel, :title]) + |> validate_inclusion(:type, @valid_types) + |> validate_inclusion(:urgency, @valid_urgencies) + |> validate_inclusion(:status, @valid_statuses) + |> validate_inclusion(:channel, @valid_channels) + |> foreign_key_constraint(:organization_id) + |> foreign_key_constraint(:preseem_access_point_id) + |> foreign_key_constraint(:device_id) + end +end diff --git a/lib/towerops/preseem/insights.ex b/lib/towerops/preseem/insights.ex new file mode 100644 index 00000000..76d991fe --- /dev/null +++ b/lib/towerops/preseem/insights.ex @@ -0,0 +1,251 @@ +defmodule Towerops.Preseem.Insights do + @moduledoc """ + Generates and manages Preseem insights based on baselines and fleet data. + """ + import Ecto.Query + + alias Towerops.Preseem.AccessPoint + alias Towerops.Preseem.DeviceBaseline + alias Towerops.Preseem.FleetProfile + alias Towerops.Preseem.Insight + alias Towerops.Repo + + # --- Query functions --- + + @doc "List active insights for an organization, ordered by urgency then date." + def list_insights(organization_id, opts \\ []) do + status = Keyword.get(opts, :status, "active") + type = Keyword.get(opts, :type) + urgency = Keyword.get(opts, :urgency) + limit = Keyword.get(opts, :limit, 50) + + query = + Insight + |> where(organization_id: ^organization_id) + |> where(status: ^status) + |> order_by_urgency() + |> limit(^limit) + + query = if type, do: where(query, type: ^type), else: query + query = if urgency, do: where(query, urgency: ^urgency), else: query + + Repo.all(query) + end + + @doc "List active insights for a specific device." + def list_device_insights(device_id) do + Insight + |> where(device_id: ^device_id, status: "active") + |> order_by_urgency() + |> Repo.all() + end + + @doc "Dismiss an insight." + def dismiss_insight(insight_id) do + case Repo.get(Insight, insight_id) do + nil -> + {:error, :not_found} + + insight -> + insight + |> Insight.changeset(%{ + status: "dismissed", + dismissed_at: DateTime.truncate(DateTime.utc_now(), :second) + }) + |> Repo.update() + end + end + + @doc "Bulk dismiss insights by IDs." + def dismiss_insights(insight_ids) when is_list(insight_ids) do + now = DateTime.truncate(DateTime.utc_now(), :second) + + {count, _} = + Insight + |> where([i], i.id in ^insight_ids) + |> where(status: "active") + |> Repo.update_all(set: [status: "dismissed", dismissed_at: now]) + + {:ok, count} + end + + # --- Generation functions --- + + @doc "Generate insights for all matched APs in an organization." + def generate_insights(organization_id) do + matched_aps = + AccessPoint + |> where(organization_id: ^organization_id) + |> where([ap], not is_nil(ap.device_id)) + |> Repo.all() + + fleet_profiles = + FleetProfile + |> where(organization_id: ^organization_id) + |> Repo.all() + + count = + Enum.reduce(matched_aps, 0, fn ap, acc -> + baselines = + DeviceBaseline + |> where(preseem_access_point_id: ^ap.id) + |> Repo.all() + + new_insights = generate_ap_insights(ap, baselines, fleet_profiles) + acc + length(new_insights) + end) + + {:ok, count} + end + + defp generate_ap_insights(ap, baselines, fleet_profiles) do + candidate_insights = + check_qoe_degradation(ap, baselines) ++ + check_capacity_saturation(ap, baselines) ++ + check_model_underperforming(ap, fleet_profiles) + + Enum.flat_map(candidate_insights, fn attrs -> + full_attrs = + Map.merge(attrs, %{ + organization_id: ap.organization_id, + preseem_access_point_id: ap.id, + device_id: ap.device_id + }) + + existing = + Repo.get_by(Insight, + preseem_access_point_id: ap.id, + type: full_attrs.type, + status: "active" + ) + + if existing do + [] + else + case %Insight{} |> Insight.changeset(full_attrs) |> Repo.insert() do + {:ok, insight} -> [insight] + {:error, _} -> [] + end + end + end) + end + + defp check_qoe_degradation(ap, baselines) do + qoe_baseline = Enum.find(baselines, &(&1.metric_name == "qoe_score" and &1.period == "all")) + + cond do + is_nil(qoe_baseline) -> + [] + + is_nil(ap.qoe_score) -> + [] + + ap.qoe_score < qoe_baseline.mean - 2 * qoe_baseline.stddev -> + urgency = + if ap.qoe_score < qoe_baseline.mean - 3 * qoe_baseline.stddev, + do: "critical", + else: "warning" + + [ + %{ + type: "qoe_degradation", + urgency: urgency, + channel: "proactive", + title: "QoE degradation on #{ap.name}", + description: + "QoE score #{Float.round(ap.qoe_score, 1)} is significantly below the " <> + "14-day baseline of #{Float.round(qoe_baseline.mean, 1)} " <> + "(stddev: #{Float.round(qoe_baseline.stddev, 1)}).", + metadata: %{ + "current" => ap.qoe_score, + "baseline_mean" => qoe_baseline.mean, + "baseline_stddev" => qoe_baseline.stddev + } + } + ] + + true -> + [] + end + end + + defp check_capacity_saturation(ap, baselines) do + sub_baseline = + Enum.find(baselines, &(&1.metric_name == "subscriber_count" and &1.period == "all")) + + cond do + is_nil(sub_baseline) -> + [] + + is_nil(ap.subscriber_count) -> + [] + + ap.subscriber_count > sub_baseline.p95 -> + [ + %{ + type: "capacity_saturation", + urgency: "warning", + channel: "proactive", + title: "#{ap.name} approaching capacity", + description: + "Subscriber count #{ap.subscriber_count} exceeds the 95th percentile " <> + "(#{round(sub_baseline.p95)}) of the 14-day baseline.", + metadata: %{ + "current_subscribers" => ap.subscriber_count, + "p95" => sub_baseline.p95 + } + } + ] + + true -> + [] + end + end + + defp check_model_underperforming(ap, fleet_profiles) do + matching_profile = + Enum.find(fleet_profiles, fn fp -> + ap.model != nil and fp.model == ap.model + end) + + cond do + is_nil(matching_profile) -> + [] + + is_nil(ap.qoe_score) or is_nil(matching_profile.avg_qoe_score) -> + [] + + ap.qoe_score < matching_profile.avg_qoe_score * 0.8 -> + [ + %{ + type: "model_underperforming", + urgency: "info", + channel: "passive", + title: "#{ap.name} underperforming fleet average", + description: + "QoE score #{Float.round(ap.qoe_score, 1)} is below the fleet average of " <> + "#{Float.round(matching_profile.avg_qoe_score, 1)} for " <> + "#{matching_profile.model} devices.", + metadata: %{ + "current_qoe" => ap.qoe_score, + "fleet_avg_qoe" => matching_profile.avg_qoe_score, + "model" => matching_profile.model + } + } + ] + + true -> + [] + end + end + + defp order_by_urgency(query) do + order_by(query, [i], [ + fragment( + "CASE ? WHEN 'critical' THEN 0 WHEN 'warning' THEN 1 WHEN 'info' THEN 2 END", + i.urgency + ), + desc: i.inserted_at + ]) + end +end diff --git a/lib/towerops/workers/preseem_baseline_worker.ex b/lib/towerops/workers/preseem_baseline_worker.ex index fe48b2bd..38ca8c84 100644 --- a/lib/towerops/workers/preseem_baseline_worker.ex +++ b/lib/towerops/workers/preseem_baseline_worker.ex @@ -8,6 +8,7 @@ defmodule Towerops.Workers.PreseemBaselineWorker do alias Towerops.Integrations alias Towerops.Preseem.Baseline alias Towerops.Preseem.FleetIntelligence + alias Towerops.Preseem.Insights require Logger @@ -29,6 +30,9 @@ defmodule Towerops.Workers.PreseemBaselineWorker do {:ok, profile_count} = FleetIntelligence.compute_fleet_profiles(org_id) Logger.info("Preseem fleet profiles computed for org #{org_id}: #{profile_count} models") + + {:ok, insight_count} = Insights.generate_insights(org_id) + Logger.info("Preseem insights generated for org #{org_id}: #{insight_count} new insights") rescue error -> Logger.error("Preseem baseline/fleet computation failed for org #{org_id}: #{inspect(error)}") diff --git a/priv/repo/migrations/20260213144412_create_preseem_insights.exs b/priv/repo/migrations/20260213144412_create_preseem_insights.exs new file mode 100644 index 00000000..ed39b011 --- /dev/null +++ b/priv/repo/migrations/20260213144412_create_preseem_insights.exs @@ -0,0 +1,32 @@ +defmodule Towerops.Repo.Migrations.CreatePreseemInsights do + use Ecto.Migration + + def change do + create table(:preseem_insights, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all), + null: false + + add :preseem_access_point_id, + references(:preseem_access_points, type: :binary_id, on_delete: :delete_all) + + add :device_id, references(:devices, type: :binary_id, on_delete: :nilify_all) + add :type, :string, null: false + add :urgency, :string, null: false + add :status, :string, null: false, default: "active" + add :channel, :string, null: false + add :title, :string, null: false + add :description, :text + add :metadata, :map + add :dismissed_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create index(:preseem_insights, [:organization_id]) + create index(:preseem_insights, [:preseem_access_point_id]) + create index(:preseem_insights, [:device_id]) + create index(:preseem_insights, [:organization_id, :status]) + end +end diff --git a/test/towerops/preseem/insight_test.exs b/test/towerops/preseem/insight_test.exs new file mode 100644 index 00000000..83d3eeb6 --- /dev/null +++ b/test/towerops/preseem/insight_test.exs @@ -0,0 +1,235 @@ +defmodule Towerops.Preseem.InsightTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Preseem.AccessPoint + alias Towerops.Preseem.Insight + + setup do + user = user_fixture() + org = organization_fixture(user.id) + + {:ok, ap} = + %AccessPoint{} + |> AccessPoint.changeset(%{organization_id: org.id, preseem_id: "ap-insight-1", name: "Insight AP"}) + |> Repo.insert() + + %{organization: org, access_point: ap} + end + + describe "changeset/2" do + test "valid changeset with all fields", %{organization: org, access_point: ap} do + attrs = %{ + organization_id: org.id, + preseem_access_point_id: ap.id, + type: "qoe_degradation", + urgency: "warning", + status: "active", + channel: "proactive", + title: "QoE degradation on Tower1-AP", + description: "QoE score dropped below baseline.", + metadata: %{"current" => 65.0, "baseline_mean" => 85.0} + } + + changeset = Insight.changeset(%Insight{}, attrs) + assert changeset.valid? + end + + test "requires organization_id" do + attrs = %{type: "qoe_degradation", urgency: "warning", channel: "proactive", title: "Test"} + changeset = Insight.changeset(%Insight{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).organization_id + end + + test "requires type" do + attrs = %{organization_id: Ecto.UUID.generate(), urgency: "warning", channel: "proactive", title: "Test"} + changeset = Insight.changeset(%Insight{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).type + end + + test "requires urgency" do + attrs = %{organization_id: Ecto.UUID.generate(), type: "qoe_degradation", channel: "proactive", title: "Test"} + changeset = Insight.changeset(%Insight{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).urgency + end + + test "requires channel" do + attrs = %{organization_id: Ecto.UUID.generate(), type: "qoe_degradation", urgency: "warning", title: "Test"} + changeset = Insight.changeset(%Insight{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).channel + end + + test "requires title" do + attrs = %{organization_id: Ecto.UUID.generate(), type: "qoe_degradation", urgency: "warning", channel: "proactive"} + changeset = Insight.changeset(%Insight{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).title + end + + test "validates type inclusion" do + attrs = %{ + organization_id: Ecto.UUID.generate(), + type: "invalid_type", + urgency: "warning", + channel: "proactive", + title: "Test" + } + + changeset = Insight.changeset(%Insight{}, attrs) + refute changeset.valid? + assert "is invalid" in errors_on(changeset).type + end + + test "accepts all valid types" do + valid_types = + ~w(qoe_degradation capacity_saturation firmware_opportunity model_underperforming subscriber_growth config_drift) + + for type <- valid_types do + attrs = %{ + organization_id: Ecto.UUID.generate(), + type: type, + urgency: "info", + channel: "passive", + title: "Test insight" + } + + changeset = Insight.changeset(%Insight{}, attrs) + assert changeset.valid?, "Expected type '#{type}' to be valid" + end + end + + test "validates urgency inclusion" do + attrs = %{ + organization_id: Ecto.UUID.generate(), + type: "qoe_degradation", + urgency: "extreme", + channel: "proactive", + title: "Test" + } + + changeset = Insight.changeset(%Insight{}, attrs) + refute changeset.valid? + assert "is invalid" in errors_on(changeset).urgency + end + + test "accepts all valid urgencies" do + for urgency <- ~w(critical warning info) do + attrs = %{ + organization_id: Ecto.UUID.generate(), + type: "qoe_degradation", + urgency: urgency, + channel: "proactive", + title: "Test" + } + + changeset = Insight.changeset(%Insight{}, attrs) + assert changeset.valid?, "Expected urgency '#{urgency}' to be valid" + end + end + + test "validates status inclusion" do + attrs = %{ + organization_id: Ecto.UUID.generate(), + type: "qoe_degradation", + urgency: "warning", + channel: "proactive", + title: "Test", + status: "archived" + } + + changeset = Insight.changeset(%Insight{}, attrs) + refute changeset.valid? + assert "is invalid" in errors_on(changeset).status + end + + test "accepts all valid statuses" do + for status <- ~w(active dismissed resolved) do + attrs = %{ + organization_id: Ecto.UUID.generate(), + type: "qoe_degradation", + urgency: "warning", + channel: "proactive", + title: "Test", + status: status + } + + changeset = Insight.changeset(%Insight{}, attrs) + assert changeset.valid?, "Expected status '#{status}' to be valid" + end + end + + test "validates channel inclusion" do + attrs = %{ + organization_id: Ecto.UUID.generate(), + type: "qoe_degradation", + urgency: "warning", + channel: "email", + title: "Test" + } + + changeset = Insight.changeset(%Insight{}, attrs) + refute changeset.valid? + assert "is invalid" in errors_on(changeset).channel + end + + test "accepts all valid channels" do + for channel <- ~w(proactive contextual passive) do + attrs = %{ + organization_id: Ecto.UUID.generate(), + type: "qoe_degradation", + urgency: "warning", + channel: channel, + title: "Test" + } + + changeset = Insight.changeset(%Insight{}, attrs) + assert changeset.valid?, "Expected channel '#{channel}' to be valid" + end + end + + test "defaults status to active" do + attrs = %{ + organization_id: Ecto.UUID.generate(), + type: "qoe_degradation", + urgency: "warning", + channel: "proactive", + title: "Test" + } + + changeset = Insight.changeset(%Insight{}, attrs) + assert Ecto.Changeset.get_field(changeset, :status) == "active" + end + + test "persists and reloads from database", %{organization: org, access_point: ap} do + attrs = %{ + organization_id: org.id, + preseem_access_point_id: ap.id, + type: "qoe_degradation", + urgency: "critical", + channel: "proactive", + title: "QoE drop on AP", + description: "Detailed explanation", + metadata: %{"current" => 60.0, "baseline_mean" => 85.0} + } + + {:ok, insight} = %Insight{} |> Insight.changeset(attrs) |> Repo.insert() + reloaded = Repo.get!(Insight, insight.id) + + assert reloaded.organization_id == org.id + assert reloaded.preseem_access_point_id == ap.id + assert reloaded.type == "qoe_degradation" + assert reloaded.urgency == "critical" + assert reloaded.status == "active" + assert reloaded.channel == "proactive" + assert reloaded.title == "QoE drop on AP" + assert reloaded.description == "Detailed explanation" + assert reloaded.metadata == %{"current" => 60.0, "baseline_mean" => 85.0} + end + end +end diff --git a/test/towerops/preseem/insights_test.exs b/test/towerops/preseem/insights_test.exs new file mode 100644 index 00000000..6186b86e --- /dev/null +++ b/test/towerops/preseem/insights_test.exs @@ -0,0 +1,391 @@ +defmodule Towerops.Preseem.InsightsTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.DevicesFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Preseem.AccessPoint + alias Towerops.Preseem.DeviceBaseline + alias Towerops.Preseem.FleetProfile + alias Towerops.Preseem.Insight + alias Towerops.Preseem.Insights + + setup do + user = user_fixture() + org = organization_fixture(user.id) + device = device_fixture(%{organization_id: org.id}) + + {:ok, ap} = + %AccessPoint{} + |> AccessPoint.changeset(%{ + organization_id: org.id, + preseem_id: "ap-insights-#{System.unique_integer([:positive])}", + name: "Insights AP", + device_id: device.id, + match_confidence: "auto_ip", + model: "Ubiquiti AF5XHD", + qoe_score: 85.0, + subscriber_count: 30 + }) + |> Repo.insert() + + %{org: org, device: device, access_point: ap} + end + + describe "list_insights/2" do + test "returns active insights ordered by urgency then date", %{org: org, access_point: ap} do + insert_insight(org, ap, %{type: "capacity_saturation", urgency: "info", title: "Info insight"}) + insert_insight(org, ap, %{type: "qoe_degradation", urgency: "critical", title: "Critical insight"}) + insert_insight(org, ap, %{type: "model_underperforming", urgency: "warning", title: "Warning insight"}) + + insights = Insights.list_insights(org.id) + + assert length(insights) == 3 + assert Enum.at(insights, 0).urgency == "critical" + assert Enum.at(insights, 1).urgency == "warning" + assert Enum.at(insights, 2).urgency == "info" + end + + test "filters by status", %{org: org, access_point: ap} do + insert_insight(org, ap, %{type: "qoe_degradation", urgency: "warning", title: "Active"}) + insert_insight(org, ap, %{type: "capacity_saturation", urgency: "info", title: "Dismissed", status: "dismissed"}) + + active = Insights.list_insights(org.id, status: "active") + assert length(active) == 1 + assert hd(active).title == "Active" + + dismissed = Insights.list_insights(org.id, status: "dismissed") + assert length(dismissed) == 1 + assert hd(dismissed).title == "Dismissed" + end + + test "filters by type", %{org: org, access_point: ap} do + insert_insight(org, ap, %{type: "qoe_degradation", urgency: "warning", title: "QoE"}) + insert_insight(org, ap, %{type: "capacity_saturation", urgency: "info", title: "Capacity"}) + + insights = Insights.list_insights(org.id, type: "qoe_degradation") + assert length(insights) == 1 + assert hd(insights).type == "qoe_degradation" + end + + test "filters by urgency", %{org: org, access_point: ap} do + insert_insight(org, ap, %{type: "qoe_degradation", urgency: "critical", title: "Crit"}) + insert_insight(org, ap, %{type: "capacity_saturation", urgency: "info", title: "Info"}) + + insights = Insights.list_insights(org.id, urgency: "critical") + assert length(insights) == 1 + assert hd(insights).urgency == "critical" + end + + test "respects limit", %{org: org, access_point: ap} do + for i <- 1..5 do + insert_insight(org, ap, %{ + type: "qoe_degradation", + urgency: "warning", + title: "Insight #{i}" + }) + end + + insights = Insights.list_insights(org.id, limit: 3) + assert length(insights) == 3 + end + end + + describe "list_device_insights/1" do + test "returns active insights for a specific device", %{org: org, device: device, access_point: ap} do + insert_insight(org, ap, %{ + type: "qoe_degradation", + urgency: "warning", + title: "Device insight", + device_id: device.id + }) + + # Create another device with its own insight + other_device = device_fixture(%{organization_id: org.id}) + + {:ok, other_ap} = + %AccessPoint{} + |> AccessPoint.changeset(%{ + organization_id: org.id, + preseem_id: "ap-other-#{System.unique_integer([:positive])}", + name: "Other AP", + device_id: other_device.id, + match_confidence: "auto_ip" + }) + |> Repo.insert() + + insert_insight(org, other_ap, %{ + type: "capacity_saturation", + urgency: "info", + title: "Other device insight", + device_id: other_device.id + }) + + insights = Insights.list_device_insights(device.id) + assert length(insights) == 1 + assert hd(insights).title == "Device insight" + end + + test "excludes dismissed insights", %{org: org, device: device, access_point: ap} do + insert_insight(org, ap, %{ + type: "qoe_degradation", + urgency: "warning", + title: "Active", + device_id: device.id + }) + + insert_insight(org, ap, %{ + type: "capacity_saturation", + urgency: "info", + title: "Dismissed", + device_id: device.id, + status: "dismissed" + }) + + insights = Insights.list_device_insights(device.id) + assert length(insights) == 1 + assert hd(insights).title == "Active" + end + + test "orders by urgency then date", %{org: org, device: device, access_point: ap} do + insert_insight(org, ap, %{type: "capacity_saturation", urgency: "info", title: "Info", device_id: device.id}) + insert_insight(org, ap, %{type: "qoe_degradation", urgency: "critical", title: "Critical", device_id: device.id}) + + insights = Insights.list_device_insights(device.id) + assert Enum.at(insights, 0).urgency == "critical" + assert Enum.at(insights, 1).urgency == "info" + end + end + + describe "dismiss_insight/1" do + test "marks insight as dismissed with timestamp", %{org: org, access_point: ap} do + {:ok, insight} = insert_insight(org, ap, %{type: "qoe_degradation", urgency: "warning", title: "Dismiss me"}) + + assert {:ok, dismissed} = Insights.dismiss_insight(insight.id) + assert dismissed.status == "dismissed" + assert dismissed.dismissed_at + end + + test "returns error for non-existent insight" do + assert {:error, :not_found} = Insights.dismiss_insight(Ecto.UUID.generate()) + end + end + + describe "dismiss_insights/1" do + test "bulk dismisses multiple insights", %{org: org, access_point: ap} do + {:ok, i1} = insert_insight(org, ap, %{type: "qoe_degradation", urgency: "warning", title: "One"}) + {:ok, i2} = insert_insight(org, ap, %{type: "capacity_saturation", urgency: "info", title: "Two"}) + {:ok, _i3} = insert_insight(org, ap, %{type: "model_underperforming", urgency: "info", title: "Three"}) + + assert {:ok, 2} = Insights.dismiss_insights([i1.id, i2.id]) + + # Verify i1 and i2 are dismissed + assert Repo.get!(Insight, i1.id).status == "dismissed" + assert Repo.get!(Insight, i2.id).status == "dismissed" + + # i3 remains active + remaining = Insights.list_insights(org.id) + assert length(remaining) == 1 + assert hd(remaining).title == "Three" + end + + test "skips already dismissed insights", %{org: org, access_point: ap} do + {:ok, i1} = + insert_insight(org, ap, %{ + type: "qoe_degradation", + urgency: "warning", + title: "Already dismissed", + status: "dismissed" + }) + + assert {:ok, 0} = Insights.dismiss_insights([i1.id]) + end + end + + describe "generate_insights/1" do + test "creates QoE degradation insight when score drops below 2 stddev", %{org: org, access_point: ap} do + # AP has qoe_score 85.0 from setup. Create baseline with mean 90 and stddev 2. + # 85 < 90 - 2*2 = 86, so should trigger warning. + insert_baseline(ap, "qoe_score", "all", %{mean: 90.0, stddev: 2.0, p95: 95.0}) + + assert {:ok, count} = Insights.generate_insights(org.id) + assert count >= 1 + + insights = Insights.list_insights(org.id, type: "qoe_degradation") + assert length(insights) == 1 + + insight = hd(insights) + assert insight.type == "qoe_degradation" + assert insight.urgency == "warning" + assert insight.channel == "proactive" + assert insight.preseem_access_point_id == ap.id + assert insight.device_id == ap.device_id + end + + test "creates critical QoE insight when score drops below 3 stddev", %{org: org} do + device = device_fixture(%{organization_id: org.id}) + + {:ok, ap} = + %AccessPoint{} + |> AccessPoint.changeset(%{ + organization_id: org.id, + preseem_id: "ap-critical-#{System.unique_integer([:positive])}", + name: "Critical AP", + device_id: device.id, + match_confidence: "auto_ip", + model: "Ubiquiti AF5XHD", + qoe_score: 70.0, + subscriber_count: 20 + }) + |> Repo.insert() + + # qoe_score 70 < 90 - 3*5 = 75, so critical + insert_baseline(ap, "qoe_score", "all", %{mean: 90.0, stddev: 5.0, p95: 98.0}) + + assert {:ok, _count} = Insights.generate_insights(org.id) + + insights = Insights.list_insights(org.id, type: "qoe_degradation") + assert length(insights) == 1 + assert hd(insights).urgency == "critical" + end + + test "creates capacity saturation insight when subscribers exceed p95", %{org: org, access_point: ap} do + # AP has subscriber_count 30 from setup. Create baseline with p95 of 25. + insert_baseline(ap, "subscriber_count", "all", %{mean: 20.0, stddev: 3.0, p95: 25.0}) + + assert {:ok, _count} = Insights.generate_insights(org.id) + + insights = Insights.list_insights(org.id, type: "capacity_saturation") + assert length(insights) == 1 + + insight = hd(insights) + assert insight.urgency == "warning" + assert insight.channel == "proactive" + end + + test "creates model_underperforming insight when below fleet average", %{org: org} do + # AP has qoe_score 85.0 and model "Ubiquiti AF5XHD". + # Fleet avg is 120, so 85 < 120 * 0.8 = 96 -> should trigger. + insert_fleet_profile(org, "Ubiquiti AF5XHD", %{avg_qoe_score: 120.0}) + + assert {:ok, _count} = Insights.generate_insights(org.id) + + insights = Insights.list_insights(org.id, type: "model_underperforming") + assert length(insights) == 1 + + insight = hd(insights) + assert insight.urgency == "info" + assert insight.channel == "passive" + end + + test "does not duplicate active insights for same type and AP", %{org: org, access_point: ap} do + insert_baseline(ap, "subscriber_count", "all", %{mean: 20.0, stddev: 3.0, p95: 25.0}) + + assert {:ok, count1} = Insights.generate_insights(org.id) + assert count1 >= 1 + + # Running again should not create duplicates + assert {:ok, 0} = Insights.generate_insights(org.id) + + insights = Insights.list_insights(org.id, type: "capacity_saturation") + assert length(insights) == 1 + end + + test "handles APs with no baselines gracefully", %{org: org} do + # The setup AP has no baselines yet, and there are no fleet profiles + # so generate should succeed with 0 insights + assert {:ok, 0} = Insights.generate_insights(org.id) + end + + test "skips APs without device_id (unmatched)", %{org: org} do + %AccessPoint{} + |> AccessPoint.changeset(%{ + organization_id: org.id, + preseem_id: "ap-unmatched-#{System.unique_integer([:positive])}", + name: "Unmatched AP", + qoe_score: 10.0, + subscriber_count: 100 + }) + |> Repo.insert!() + + # Even with extreme values, unmatched APs should not generate insights + assert {:ok, 0} = Insights.generate_insights(org.id) + end + + test "does not create QoE insight when score is within normal range", %{org: org, access_point: ap} do + # AP qoe_score is 85. Baseline mean 85, stddev 5. 85 >= 85 - 2*5 = 75 -> no insight + insert_baseline(ap, "qoe_score", "all", %{mean: 85.0, stddev: 5.0, p95: 93.0}) + + assert {:ok, 0} = Insights.generate_insights(org.id) + + insights = Insights.list_insights(org.id, type: "qoe_degradation") + assert Enum.empty?(insights) + end + + test "does not create capacity insight when subscribers within p95", %{org: org, access_point: ap} do + # AP subscriber_count is 30. Baseline p95 is 40 -> no insight + insert_baseline(ap, "subscriber_count", "all", %{mean: 25.0, stddev: 5.0, p95: 40.0}) + + assert {:ok, 0} = Insights.generate_insights(org.id) + + insights = Insights.list_insights(org.id, type: "capacity_saturation") + assert Enum.empty?(insights) + end + end + + # Helpers + + defp insert_insight(org, ap, attrs) do + full_attrs = + Map.merge( + %{ + organization_id: org.id, + preseem_access_point_id: ap.id, + channel: "proactive", + status: "active" + }, + attrs + ) + + %Insight{} + |> Insight.changeset(full_attrs) + |> Repo.insert() + end + + defp insert_baseline(ap, metric_name, period, attrs) do + full_attrs = + Map.merge( + %{ + preseem_access_point_id: ap.id, + metric_name: metric_name, + period: period, + sample_count: 336, + computed_at: DateTime.truncate(DateTime.utc_now(), :second) + }, + attrs + ) + + %DeviceBaseline{} + |> DeviceBaseline.changeset(full_attrs) + |> Repo.insert!() + end + + defp insert_fleet_profile(org, model, attrs) do + full_attrs = + Map.merge( + %{ + organization_id: org.id, + manufacturer: model |> String.split(" ") |> hd(), + model: model, + device_count: 10, + computed_at: DateTime.truncate(DateTime.utc_now(), :second) + }, + attrs + ) + + %FleetProfile{} + |> FleetProfile.changeset(full_attrs) + |> Repo.insert!() + end +end From 62b8ec7afdd3d741fd28b3aeea0ba7af50ab7703 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 13 Feb 2026 08:58:19 -0600 Subject: [PATCH 18/20] add contextual insights to device preseem tab --- lib/towerops_web/live/device_live/show.ex | 21 ++++++- .../live/device_live/show.html.heex | 57 +++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/lib/towerops_web/live/device_live/show.ex b/lib/towerops_web/live/device_live/show.ex index 9a93f179..905c69c3 100644 --- a/lib/towerops_web/live/device_live/show.ex +++ b/lib/towerops_web/live/device_live/show.ex @@ -436,16 +436,20 @@ defmodule ToweropsWeb.DeviceLive.Show do device = socket.assigns.device preseem_ap = Towerops.Preseem.get_access_point_for_device(device.id) - metrics = + {metrics, insights} = if preseem_ap do - Towerops.Preseem.list_subscriber_metrics(preseem_ap.id, limit: 50) + { + Towerops.Preseem.list_subscriber_metrics(preseem_ap.id, limit: 50), + Towerops.Preseem.list_device_insights(device.id) + } else - [] + {[], []} end socket |> assign(:preseem_access_point, preseem_ap) |> assign(:preseem_metrics, metrics) + |> assign(:preseem_insights, insights) end defp get_available_firmware(nil), do: nil @@ -1222,6 +1226,17 @@ defmodule ToweropsWeb.DeviceLive.Show do {:noreply, assign(socket, :selected_backup_ids, MapSet.new())} end + @impl true + def handle_event("dismiss_insight", %{"id" => insight_id}, socket) do + case Towerops.Preseem.dismiss_insight(insight_id) do + {:ok, _} -> + {:noreply, assign_preseem_data(socket)} + + {:error, _} -> + {:noreply, put_flash(socket, :error, "Failed to dismiss insight")} + end + end + @impl true def handle_event("delete_backup", %{"id" => backup_id}, socket) do import ToweropsWeb.Permissions diff --git a/lib/towerops_web/live/device_live/show.html.heex b/lib/towerops_web/live/device_live/show.html.heex index a0c643d8..7ce92a16 100644 --- a/lib/towerops_web/live/device_live/show.html.heex +++ b/lib/towerops_web/live/device_live/show.html.heex @@ -2206,6 +2206,63 @@ <% "preseem" -> %> <%= if @preseem_access_point do %>
+ <%!-- Insights section --%> + <%= if @preseem_insights != [] do %> +
+ <%= for insight <- @preseem_insights do %> +
+ "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800" + + "warning" -> + "bg-yellow-50 border-yellow-200 dark:bg-yellow-900/20 dark:border-yellow-800" + + _ -> + "bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800" + end + ]}> +
+
"text-red-500" + "warning" -> "text-yellow-500" + _ -> "text-blue-500" + end + ]}> + <%= case insight.urgency do %> + <% "critical" -> %> + <.icon name="hero-exclamation-triangle" class="h-5 w-5" /> + <% "warning" -> %> + <.icon name="hero-exclamation-circle" class="h-5 w-5" /> + <% _ -> %> + <.icon name="hero-information-circle" class="h-5 w-5" /> + <% end %> +
+
+

+ {insight.title} +

+

+ {insight.description} +

+
+
+ +
+ <% end %> +
+ <% end %> + <%!-- Score cards --%>
From 9ccea8daaf4520ac2e3fe97c82847dfb5419f9f4 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 13 Feb 2026 09:00:34 -0600 Subject: [PATCH 19/20] add network insights feed page with filters and bulk dismiss --- .../live/org/integrations_live.html.heex | 8 + .../live/org/preseem_insights_live.ex | 163 ++++++++++ .../live/org/preseem_insights_live.html.heex | 303 ++++++++++++++++++ lib/towerops_web/router.ex | 1 + .../live/org/preseem_insights_live_test.exs | 226 +++++++++++++ 5 files changed, 701 insertions(+) create mode 100644 lib/towerops_web/live/org/preseem_insights_live.ex create mode 100644 lib/towerops_web/live/org/preseem_insights_live.html.heex create mode 100644 test/towerops_web/live/org/preseem_insights_live_test.exs diff --git a/lib/towerops_web/live/org/integrations_live.html.heex b/lib/towerops_web/live/org/integrations_live.html.heex index fb556ead..dd1e0f38 100644 --- a/lib/towerops_web/live/org/integrations_live.html.heex +++ b/lib/towerops_web/live/org/integrations_live.html.heex @@ -85,6 +85,14 @@ > Manage Devices → + <.link + navigate={ + ~p"/orgs/#{@organization.slug}/settings/integrations/preseem/insights" + } + class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" + > + Network Insights → +
<% end %>
diff --git a/lib/towerops_web/live/org/preseem_insights_live.ex b/lib/towerops_web/live/org/preseem_insights_live.ex new file mode 100644 index 00000000..afd70419 --- /dev/null +++ b/lib/towerops_web/live/org/preseem_insights_live.ex @@ -0,0 +1,163 @@ +defmodule ToweropsWeb.Org.PreseemInsightsLive do + @moduledoc false + use ToweropsWeb, :live_view + + alias Towerops.Preseem + alias Towerops.Repo + + @impl true + def mount(_params, _session, socket) do + org = socket.assigns.current_scope.organization + + {:ok, + socket + |> assign(:organization, org) + |> assign(:selected_ids, MapSet.new())} + end + + @impl true + def handle_params(params, _url, socket) do + filter_type = params["type"] + filter_urgency = params["urgency"] + filter_status = params["status"] || "active" + + {:noreply, + socket + |> assign(:filter_type, filter_type) + |> assign(:filter_urgency, filter_urgency) + |> assign(:filter_status, filter_status) + |> assign(:selected_ids, MapSet.new()) + |> load_insights()} + end + + @impl true + def handle_event("filter", params, socket) do + org = socket.assigns.organization + + query_params = %{} + + query_params = + if params["type"] && params["type"] != "", + do: Map.put(query_params, "type", params["type"]), + else: query_params + + query_params = + if params["urgency"] && params["urgency"] != "", + do: Map.put(query_params, "urgency", params["urgency"]), + else: query_params + + query_params = + if params["status"] && params["status"] != "", + do: Map.put(query_params, "status", params["status"]), + else: query_params + + {:noreply, + push_patch(socket, + to: ~p"/orgs/#{org.slug}/settings/integrations/preseem/insights?#{query_params}" + )} + end + + @impl true + def handle_event("dismiss", %{"id" => id}, socket) do + case Preseem.dismiss_insight(id) do + {:ok, _} -> + {:noreply, + socket + |> load_insights() + |> put_flash(:info, "Insight dismissed")} + + {:error, _} -> + {:noreply, put_flash(socket, :error, "Failed to dismiss")} + end + end + + @impl true + def handle_event("toggle_select", %{"id" => id}, socket) do + selected = socket.assigns.selected_ids + + selected = + if MapSet.member?(selected, id), + do: MapSet.delete(selected, id), + else: MapSet.put(selected, id) + + {:noreply, assign(socket, :selected_ids, selected)} + end + + @impl true + def handle_event("select_all", _params, socket) do + ids = MapSet.new(socket.assigns.insights, & &1.id) + {:noreply, assign(socket, :selected_ids, ids)} + end + + @impl true + def handle_event("deselect_all", _params, socket) do + {:noreply, assign(socket, :selected_ids, MapSet.new())} + end + + @impl true + def handle_event("bulk_dismiss", _params, socket) do + ids = MapSet.to_list(socket.assigns.selected_ids) + + if ids == [] do + {:noreply, socket} + else + case Preseem.dismiss_insights(ids) do + {:ok, count} -> + {:noreply, + socket + |> assign(:selected_ids, MapSet.new()) + |> load_insights() + |> put_flash(:info, "#{count} insight(s) dismissed")} + + {:error, _} -> + {:noreply, put_flash(socket, :error, "Failed to dismiss insights")} + end + end + end + + defp load_insights(socket) do + org_id = socket.assigns.organization.id + opts = [status: socket.assigns.filter_status] + + opts = + if socket.assigns.filter_type, + do: Keyword.put(opts, :type, socket.assigns.filter_type), + else: opts + + opts = + if socket.assigns.filter_urgency, + do: Keyword.put(opts, :urgency, socket.assigns.filter_urgency), + else: opts + + insights = + org_id + |> Preseem.list_insights(opts) + |> Repo.preload([:preseem_access_point, :device]) + + assign(socket, :insights, insights) + end + + defp urgency_classes(urgency) do + case urgency do + "critical" -> + "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400" + + "warning" -> + "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400" + + "info" -> + "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400" + + _ -> + "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400" + end + end + + defp selected?(selected_ids, id) do + MapSet.member?(selected_ids, id) + end + + defp any_selected?(selected_ids) do + MapSet.size(selected_ids) > 0 + end +end diff --git a/lib/towerops_web/live/org/preseem_insights_live.html.heex b/lib/towerops_web/live/org/preseem_insights_live.html.heex new file mode 100644 index 00000000..e43e0b86 --- /dev/null +++ b/lib/towerops_web/live/org/preseem_insights_live.html.heex @@ -0,0 +1,303 @@ + +
+
+ <.link + navigate={~p"/orgs/#{@organization.slug}/settings/integrations"} + class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white" + > + <.icon name="hero-arrow-left" class="h-4 w-4" /> Back to Integrations + +
+
+
+

+ Network Insights +

+

+ Proactive network health observations generated from Preseem data analysis. +

+
+
+
+ + <%!-- Filter bar --%> +
+ <%!-- Status filter tabs --%> + + + <%!-- Urgency filter --%> +
+ Urgency: + <.link + id="filter-urgency-all" + patch={ + ~p"/orgs/#{@organization.slug}/settings/integrations/preseem/insights?#{%{status: @filter_status}}" + } + class={[ + "rounded-md px-2 py-1 text-xs font-medium", + if(is_nil(@filter_urgency), + do: "bg-gray-200 text-gray-800 dark:bg-white/20 dark:text-white", + else: "text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-white/10" + ) + ]} + > + All + + <.link + id="filter-urgency-critical" + patch={ + ~p"/orgs/#{@organization.slug}/settings/integrations/preseem/insights?#{%{status: @filter_status, urgency: "critical"}}" + } + class={[ + "rounded-md px-2 py-1 text-xs font-medium", + if(@filter_urgency == "critical", + do: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400", + else: "text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-white/10" + ) + ]} + > + Critical + + <.link + id="filter-urgency-warning" + patch={ + ~p"/orgs/#{@organization.slug}/settings/integrations/preseem/insights?#{%{status: @filter_status, urgency: "warning"}}" + } + class={[ + "rounded-md px-2 py-1 text-xs font-medium", + if(@filter_urgency == "warning", + do: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400", + else: "text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-white/10" + ) + ]} + > + Warning + + <.link + id="filter-urgency-info" + patch={ + ~p"/orgs/#{@organization.slug}/settings/integrations/preseem/insights?#{%{status: @filter_status, urgency: "info"}}" + } + class={[ + "rounded-md px-2 py-1 text-xs font-medium", + if(@filter_urgency == "info", + do: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400", + else: "text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-white/10" + ) + ]} + > + Info + +
+
+ + <%!-- Bulk actions bar --%> + <%= if any_selected?(@selected_ids) do %> +
+ + {MapSet.size(@selected_ids)} selected + + + +
+ <% end %> + + <%!-- Insights list --%> +
+ <%= if @insights == [] do %> +
+ <.icon + name="hero-light-bulb" + class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" + /> +

+ No insights found +

+

+ <%= cond do %> + <% @filter_status == "dismissed" -> %> + No dismissed insights. + <% @filter_urgency != nil -> %> + No {@filter_urgency} insights right now. + <% true -> %> + No active insights. Your network is looking healthy! + <% end %> +

+
+ <% else %> + <%!-- Select all bar --%> +
+ +
+ +
+
+
+
+ <%!-- Checkbox --%> + + +
+
+ <%!-- Urgency badge --%> + + {insight.urgency} + + + <%!-- Type badge --%> + + {String.replace(insight.type, "_", " ")} + +
+ +

+ {insight.title} +

+ + <%= if insight.description do %> +

+ {insight.description} +

+ <% end %> + +
+ <%!-- Access point --%> + <%= if insight.preseem_access_point do %> + + <.icon name="hero-signal" class="h-3.5 w-3.5" /> + {insight.preseem_access_point.name} + + <% end %> + + <%!-- Linked device --%> + <%= if insight.device do %> + <.link + navigate={~p"/devices/#{insight.device.id}"} + class="flex items-center gap-1 text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300" + > + <.icon name="hero-server" class="h-3.5 w-3.5" /> + {insight.device.name || "Device"} + + <% end %> + + <%!-- Timestamp --%> + + <.icon name="hero-clock" class="h-3.5 w-3.5" /> + {Calendar.strftime(insight.inserted_at, "%Y-%m-%d %H:%M UTC")} + +
+
+
+ + <%!-- Dismiss button --%> + <%= if insight.status == "active" do %> + + <% end %> +
+
+
+ <% end %> +
+
diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index af453f29..c4c2a194 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -311,6 +311,7 @@ defmodule ToweropsWeb.Router do live "/settings", Org.SettingsLive, :index live "/settings/integrations", Org.IntegrationsLive, :index live "/settings/integrations/preseem/devices", Org.PreseemDevicesLive, :index + live "/settings/integrations/preseem/insights", Org.PreseemInsightsLive, :index end end diff --git a/test/towerops_web/live/org/preseem_insights_live_test.exs b/test/towerops_web/live/org/preseem_insights_live_test.exs new file mode 100644 index 00000000..5901b603 --- /dev/null +++ b/test/towerops_web/live/org/preseem_insights_live_test.exs @@ -0,0 +1,226 @@ +defmodule ToweropsWeb.Org.PreseemInsightsLiveTest do + use ToweropsWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Towerops.AccountsFixtures + import Towerops.DevicesFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Preseem.AccessPoint + alias Towerops.Preseem.Insight + alias Towerops.Repo + + setup do + user = user_fixture() + org = organization_fixture(user.id) + %{user: user, organization: org} + end + + defp insert_access_point!(org, attrs \\ %{}) do + default = %{ + organization_id: org.id, + preseem_id: "ap-#{System.unique_integer([:positive])}", + name: "Test AP" + } + + {:ok, ap} = + %AccessPoint{} + |> AccessPoint.changeset(Map.merge(default, attrs)) + |> Repo.insert() + + ap + end + + defp insert_insight!(org, ap, attrs) do + default = %{ + organization_id: org.id, + preseem_access_point_id: ap.id, + type: "qoe_degradation", + urgency: "warning", + channel: "proactive", + title: "Test insight", + description: "Test description" + } + + {:ok, insight} = + %Insight{} + |> Insight.changeset(Map.merge(default, attrs)) + |> Repo.insert() + + insight + end + + describe "index" do + test "renders empty state when no insights exist", %{ + conn: conn, + user: user, + organization: org + } do + {:ok, view, html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/insights") + + assert html =~ "Network Insights" + assert has_element?(view, "#empty-state") + end + + test "shows insights in list", %{conn: conn, user: user, organization: org} do + ap = insert_access_point!(org, %{name: "Tower 1"}) + insert_insight!(org, ap, %{title: "QoE dropping on Tower 1"}) + + {:ok, _view, html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/insights") + + assert html =~ "QoE dropping on Tower 1" + end + + test "shows urgency badges", %{conn: conn, user: user, organization: org} do + ap = insert_access_point!(org) + + insert_insight!(org, ap, %{ + title: "Critical Issue", + urgency: "critical" + }) + + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/insights") + + assert has_element?(view, "#insights-list") + assert render(view) =~ "Critical Issue" + assert render(view) =~ "critical" + end + + test "shows related access point name", %{conn: conn, user: user, organization: org} do + ap = insert_access_point!(org, %{name: "Main Tower AP"}) + insert_insight!(org, ap, %{title: "Some insight"}) + + {:ok, _view, html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/insights") + + assert html =~ "Main Tower AP" + end + + test "links to device page when device_id exists", %{ + conn: conn, + user: user, + organization: org + } do + ap = insert_access_point!(org) + device = device_fixture(%{organization_id: org.id, name: "My Router"}) + + insert_insight!(org, ap, %{ + title: "Device issue", + device_id: device.id + }) + + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/insights") + + assert has_element?(view, "a[href='/devices/#{device.id}']") + end + end + + describe "filtering" do + setup %{organization: org} do + ap = insert_access_point!(org) + + critical = + insert_insight!(org, ap, %{ + title: "Critical Issue", + urgency: "critical" + }) + + info = + insert_insight!(org, ap, %{ + title: "Info Note", + urgency: "info" + }) + + %{ap: ap, critical: critical, info: info} + end + + test "filters by urgency via URL params", %{conn: conn, user: user, organization: org} do + {:ok, _view, html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/insights?urgency=critical") + + assert html =~ "Critical Issue" + refute html =~ "Info Note" + end + + test "filters by status", %{conn: conn, user: user, organization: org, ap: ap} do + insert_insight!(org, ap, %{ + title: "Dismissed One", + status: "dismissed", + dismissed_at: DateTime.truncate(DateTime.utc_now(), :second) + }) + + {:ok, _view, html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/insights?status=dismissed") + + assert html =~ "Dismissed One" + refute html =~ "Critical Issue" + end + end + + describe "dismiss" do + test "dismisses a single insight", %{conn: conn, user: user, organization: org} do + ap = insert_access_point!(org) + insight = insert_insight!(org, ap, %{title: "Dismissable"}) + + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/insights") + + assert render(view) =~ "Dismissable" + + view + |> element("button[phx-click=dismiss][phx-value-id='#{insight.id}']") + |> render_click() + + html = render(view) + assert html =~ "Insight dismissed" + refute html =~ "Dismissable" + end + end + + describe "bulk actions" do + test "select all and bulk dismiss", %{conn: conn, user: user, organization: org} do + ap = insert_access_point!(org) + insert_insight!(org, ap, %{title: "Bulk 1"}) + insert_insight!(org, ap, %{title: "Bulk 2"}) + + {:ok, view, _html} = + conn + |> log_in_user(user) + |> live(~p"/orgs/#{org.slug}/settings/integrations/preseem/insights") + + assert render(view) =~ "Bulk 1" + assert render(view) =~ "Bulk 2" + + # Select all + view |> element("#select-all-btn") |> render_click() + + # Bulk dismiss + view |> element("#bulk-dismiss-btn") |> render_click() + + html = render(view) + assert html =~ "dismissed" + refute html =~ "Bulk 1" + refute html =~ "Bulk 2" + end + end +end From a3270b22dd2920442925c3fd22ffd56b1fe541fb Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 13 Feb 2026 09:01:02 -0600 Subject: [PATCH 20/20] add preseem integration design and stage 1 plan docs --- .../2026-02-12-preseem-integration-design.md | 305 ++++ .../2026-02-12-preseem-stage1-foundation.md | 1518 +++++++++++++++++ 2 files changed, 1823 insertions(+) create mode 100644 docs/plans/2026-02-12-preseem-integration-design.md create mode 100644 docs/plans/2026-02-12-preseem-stage1-foundation.md diff --git a/docs/plans/2026-02-12-preseem-integration-design.md b/docs/plans/2026-02-12-preseem-integration-design.md new file mode 100644 index 00000000..6b74f094 --- /dev/null +++ b/docs/plans/2026-02-12-preseem-integration-design.md @@ -0,0 +1,305 @@ +# Preseem API Integration Design + +## Overview + +Integrate Preseem's QoE (Quality of Experience) data with Towerops' SNMP-based device monitoring to provide network engineers with combined infrastructure health + traffic quality intelligence. + +**Primary persona (Phase 1):** Network engineer — planning capacity, upgrading towers, optimizing RF. + +**Future personas:** NOC technician (alert correlation), ISP owner/manager (investment prioritization). See `TODO.md` at project root. + +## What Preseem Provides + +Preseem sits inline on WISP networks and measures per-subscriber, per-AP, per-tower: +- **QoE metrics**: latency, loss, jitter, throughput (per subscriber and aggregate) +- **AP capacity scores**: airtime utilization, busy hours, subscriber density +- **RF metrics**: RSSI, modulation, frequency, channel width +- **Subscriber data**: plan rates, actual throughput, data usage + +Preseem has a Model API (beta, in production use) and REST APIs. + +## What Towerops Provides + +- SNMP-based device inventory (hardware, firmware, serial numbers) +- 570+ vendor device profiles +- Network topology (LLDP/CDP neighbors, ARP discovery) +- Sensor readings (temperature, voltage, power, signal strength) +- Interface statistics (traffic counters, errors, discards) +- MikroTik configuration tracking + +## The Value of Combining Both + +Towerops knows *about devices* (hardware, config, topology, sensor health). +Preseem knows *about traffic* (QoE, capacity, subscriber experience). + +Together: "This AF5XHD at Tower 3 is running hot (Towerops sensor), serving 45 subscribers (Preseem), with QoE dropping during busy hours (Preseem), and its firmware is 2 versions behind the best-performing instances in your fleet (both)." + +--- + +## Data Model + +### Integration Settings + +`integrations` — Per-organization, generic for future integrations. + +| Column | Type | Notes | +|--------|------|-------| +| id | binary_id | PK | +| organization_id | binary_id | FK to organizations | +| provider | string | "preseem" (future: "splynx", "sonar", etc.) | +| enabled | boolean | default false | +| credentials | encrypted JSONB | API key, base URL (Cloak AES-256-GCM) | +| sync_interval_minutes | integer | default 10 | +| last_synced_at | utc_datetime | nullable | +| last_sync_status | string | "success", "partial", "failed", "never" | +| timestamps | utc_datetime | | + +Unique constraint on `{organization_id, provider}`. + +### Preseem Access Points + +`preseem_access_points` — Central entity synced from Preseem API. + +| Column | Type | Notes | +|--------|------|-------| +| id | binary_id | PK | +| organization_id | binary_id | FK, tenant isolation | +| preseem_id | string | Preseem's identifier, unique per org | +| name | string | AP name from Preseem | +| mac_address | string | for device matching | +| ip_address | string | for device matching | +| model | string | for fleet analysis | +| firmware | string | for fleet analysis | +| capacity_score | float | Preseem computed score | +| qoe_score | float | Preseem computed score | +| rf_score | float | Preseem computed score | +| busy_hours | integer | hours per day at/near capacity | +| airtime_utilization | float | percentage | +| subscriber_count | integer | connected subscribers | +| device_id | binary_id | nullable FK to devices (matched) | +| match_confidence | string | "auto_mac", "auto_ip", "auto_hostname", "manual", "ambiguous", "unmatched" | +| raw_data | map (JSONB) | full Preseem API response | +| timestamps | utc_datetime | | + +Unique constraint on `{organization_id, preseem_id}`. + +### Preseem Subscriber Metrics (Time-Series) + +`preseem_subscriber_metrics` — Aggregate QoE per AP, stored as time-series. + +| Column | Type | Notes | +|--------|------|-------| +| id | binary_id | PK | +| preseem_access_point_id | binary_id | FK | +| avg_latency | float | ms | +| avg_jitter | float | ms | +| avg_loss | float | percentage | +| avg_throughput | float | Mbps | +| p95_latency | float | ms, key QoE indicator | +| subscriber_count | integer | at time of measurement | +| recorded_at | utc_datetime | hypertable partition key | + +### Preseem Sync Logs + +`preseem_sync_logs` — Audit trail per sync operation. + +| Column | Type | Notes | +|--------|------|-------| +| id | binary_id | PK | +| organization_id | binary_id | FK | +| integration_id | binary_id | FK | +| status | string | "success", "partial", "failed" | +| records_synced | integer | | +| errors | map (JSONB) | error details | +| duration_ms | integer | | +| inserted_at | utc_datetime | | + +--- + +## Device Matching Engine + +When sync pulls Preseem APs, a matching pipeline runs against Towerops devices (same org). + +### Match Strategy (ordered by confidence) + +1. **MAC address** — Normalize (lowercase, strip delimiters), match against `devices.mac_address` and `interfaces.mac_address`. Unique match → `:auto_mac`. +2. **IP address** — Match against `devices.ip_address`. Unique match → `:auto_ip`. +3. **Hostname** — Normalize (lowercase, strip domain suffix), match against `devices.name`. Unique match → `:auto_hostname`. +4. **No match** → `:unmatched`. + +### Conflict Handling + +If multiple Towerops devices match a single Preseem AP → `:ambiguous`, surfaced for manual review. + +### Manual Override + +- Link/unlink from integrations page (unmatched/ambiguous list) +- Link/unlink from device detail page +- Manual links stored as `:manual`, never overwritten by auto-matching + +--- + +## Analysis Engine + +### Individual Device Baselines + +For each matched device, compute a rolling 14-day baseline (nightly Oban worker): + +- **QoE baseline**: typical qoe_score, p95_latency, subscriber_count (busy vs off-peak) +- **Capacity baseline**: normal airtime_utilization, busy_hours, throughput ranges +- **RF baseline**: typical rf_score, Towerops SNMP signal sensors + +Stored in `preseem_device_baselines`: +- Per-metric: mean, stddev, p5, p95 +- Separate busy-hour vs off-peak profiles + +When fresh sync data arrives, compare against baseline → flag deviations. + +### Fleet Intelligence + +Aggregate across all matched devices per org, grouped by device model (manufacturer + model from SNMP profile): + +Stored in `preseem_fleet_profiles` keyed by `{organization_id, manufacturer, model}`: + +- **Model performance profiles**: avg subscribers, avg QoE per model +- **Capacity ceilings**: subscriber count where QoE degrades per model +- **Firmware correlation**: QoE/capacity grouped by firmware version +- **Configuration patterns**: correlate config differences with performance (using MikroTik backup tracking) + +Computed nightly. + +--- + +## Insight Generation & Delivery + +### Insight Types + +| Insight | Source | Delivery | +|---------|--------|----------| +| QoE degradation on specific AP | Baseline deviation | Proactive alert | +| AP approaching capacity saturation | Baseline trend + fleet ceiling | Proactive alert | +| Firmware upgrade opportunity | Fleet firmware correlation | Contextual guidance | +| Frequency/interference conflict | Preseem RF + Towerops topology | Contextual guidance | +| Device model underperforming fleet avg | Fleet profile comparison | Passive feed | +| Subscriber growth toward capacity ceiling | Baseline trend + fleet intel | Passive feed | +| Config drift from best-performing peers | Config tracking + fleet correlation | Passive feed | + +### Delivery Channels + +1. **Proactive alerts** — Generated as Towerops monitoring checks. Uses existing alerting infrastructure (soft/hard state transitions, flapping detection, notifications). + +2. **Contextual guidance** — On device detail page, new "Insights" section when device is matched to a Preseem AP. Dismissable recommendation cards. + +3. **Passive insights feed** — New LiveView page under network section. Filterable, sortable list of all insights across the org. + +--- + +## UI Components + +### Org Settings > Integrations + +- List of available integration providers with enable/disable toggle +- Per-integration config panel: + - Preseem: API key field, optional base URL override + - "Test Connection" button (validates API key) + - Sync status, last sync time, record counts + - Sync history log + +### Device Detail > Preseem Tab (when matched) + +- Current QoE score, capacity score, RF score +- Subscriber count and busy hours +- Trend charts (from preseem_subscriber_metrics time-series) +- Contextual insights/recommendations +- Link to Preseem dashboard for this AP + +### Unmatched Devices Page (under Integrations) + +- List of Preseem APs not yet matched to Towerops devices +- Ambiguous matches needing resolution +- Search/select to manually link + +### Network Insights Page + +- All generated insights across the org +- Filter by: type, urgency, device, site +- Sortable by urgency, date, device +- Bulk dismiss + +--- + +## Sync Architecture + +### PreseemSyncWorker (Oban Cron) + +Runs every 10 minutes (configurable per org). For each org with enabled Preseem integration: + +1. Fetch API key from encrypted credentials +2. Pull AP list from Preseem Model API +3. Upsert `preseem_access_points` (by preseem_id) +4. Pull QoE metrics per AP +5. Insert `preseem_subscriber_metrics` time-series records +6. Run device matching pipeline for unmatched/new APs +7. Log sync result to `preseem_sync_logs` + +### PreseemBaselineWorker (Oban Cron, nightly) + +1. Compute individual device baselines from 14-day window +2. Compute fleet profiles grouped by model +3. Compare current data against baselines +4. Generate insights for deviations + +### Preseem API Client + +`Towerops.Preseem.Client` — Uses `:req` (only approved HTTP client). + +- `list_access_points/1` — Pull all APs +- `get_access_point_metrics/2` — QoE metrics for specific AP +- `test_connection/1` — Validate API key + +--- + +## Context Structure + +``` +Towerops.Integrations — Generic integration CRUD (settings, credentials) +Towerops.Preseem — Preseem-specific context +Towerops.Preseem.Client — API client (Req-based) +Towerops.Preseem.Sync — Sync logic (upsert, metrics insert) +Towerops.Preseem.DeviceMatcher — Matching pipeline +Towerops.Preseem.Baseline — Individual device baselining +Towerops.Preseem.FleetIntelligence — Fleet-wide model analysis +Towerops.Preseem.Insights — Insight generation and storage +``` + +--- + +## Implementation Stages + +### Stage 1: Foundation +- `integrations` schema + migration +- Org Settings > Integrations UI (generic) +- Preseem API client with test connection +- Encrypted credential storage + +### Stage 2: Data Sync +- `preseem_access_points` + `preseem_subscriber_metrics` schemas + migrations +- `preseem_sync_logs` schema + migration +- PreseemSyncWorker +- Device matching engine + +### Stage 3: UI — Device Integration +- Preseem tab on device detail page +- Unmatched devices management page +- Manual link/unlink UI + +### Stage 4: Analysis Engine +- `preseem_device_baselines` + `preseem_fleet_profiles` schemas +- PreseemBaselineWorker (nightly) +- Fleet intelligence computation + +### Stage 5: Insights & Guidance +- Insight generation from baselines + fleet data +- Proactive alerts (monitoring check integration) +- Contextual guidance on device pages +- Network insights feed page diff --git a/docs/plans/2026-02-12-preseem-stage1-foundation.md b/docs/plans/2026-02-12-preseem-stage1-foundation.md new file mode 100644 index 00000000..1dc667b0 --- /dev/null +++ b/docs/plans/2026-02-12-preseem-stage1-foundation.md @@ -0,0 +1,1518 @@ +# Preseem Integration Stage 1: Foundation Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build the generic integrations system (schema, context, encrypted credentials, UI) and the Preseem API client with test-connection support. + +**Architecture:** New `Towerops.Integrations` context with per-organization integration records. Credentials encrypted at rest via Cloak (new `Encrypted.Map` type for JSONB). Preseem API client uses `:req`. Settings UI is a new LiveView page at `/orgs/:org_slug/settings/integrations`. + +**Tech Stack:** Phoenix 1.8, Ecto, Cloak AES-256-GCM, Oban, Req, LiveView + +**Working directory:** `/Users/graham/dev/towerops/towerops-web/.worktrees/feature/preseem-integration` + +**Pre-existing test failures:** 19 failures exist on main (Monitoring, SiteLive, AgentChannel). NIF tests are skipped in this worktree. Do not attempt to fix these. + +--- + +### Task 1: Create Encrypted.Map Ecto Type + +**Files:** +- Create: `lib/towerops/ecto_types/encrypted_map.ex` +- Test: `test/towerops/ecto_types/encrypted_map_test.exs` + +**Step 1: Write the failing test** + +```elixir +# test/towerops/ecto_types/encrypted_map_test.exs +defmodule Towerops.Encrypted.MapTest do + use Towerops.DataCase, async: true + + alias Towerops.Encrypted.Map, as: EncryptedMap + + describe "cast/1" do + test "casts a valid map" do + assert {:ok, %{"key" => "value"}} = EncryptedMap.cast(%{"key" => "value"}) + end + + test "casts atom-keyed maps" do + assert {:ok, %{key: "value"}} = EncryptedMap.cast(%{key: "value"}) + end + + test "rejects non-map values" do + assert :error = EncryptedMap.cast("not a map") + assert :error = EncryptedMap.cast(123) + end + + test "casts nil" do + assert {:ok, nil} = EncryptedMap.cast(nil) + end + end + + describe "dump/1 and load/1 round-trip" do + test "encrypts and decrypts a map" do + original = %{"api_key" => "secret123", "base_url" => "https://api.preseem.com"} + + assert {:ok, encrypted} = EncryptedMap.dump(original) + assert is_binary(encrypted) + # Encrypted value should not be the raw JSON + refute encrypted == Jason.encode!(original) + + assert {:ok, decrypted} = EncryptedMap.load(encrypted) + assert decrypted == original + end + + test "handles nil values" do + assert {:ok, nil} = EncryptedMap.dump(nil) + assert {:ok, nil} = EncryptedMap.load(nil) + end + end +end +``` + +**Step 2: Run test to verify it fails** + +Run: `mix test test/towerops/ecto_types/encrypted_map_test.exs` +Expected: Compilation error — module `Towerops.Encrypted.Map` not found + +**Step 3: Write minimal implementation** + +```elixir +# lib/towerops/ecto_types/encrypted_map.ex +defmodule Towerops.Encrypted.Map do + @moduledoc """ + Encrypted map field using Cloak vault. + + Used for storing sensitive configuration like API credentials as encrypted JSON. + Data is encrypted at rest using AES-256-GCM. + + ## Usage + + field :credentials, Towerops.Encrypted.Map + + ## Database Schema + + The database column must be `:binary`: + + add :credentials, :binary + + Note: Atom keys become string keys after decryption. + """ + use Cloak.Ecto.Map, vault: Towerops.Vault +end +``` + +**Step 4: Run test to verify it passes** + +Run: `mix test test/towerops/ecto_types/encrypted_map_test.exs` +Expected: All tests pass + +**Step 5: Commit** + +```bash +git add lib/towerops/ecto_types/encrypted_map.ex test/towerops/ecto_types/encrypted_map_test.exs +git commit -m "add encrypted map ecto type for integration credentials" +``` + +--- + +### Task 2: Create Integrations Schema and Migration + +**Files:** +- Create: `lib/towerops/integrations/integration.ex` +- Create: migration via `mix ecto.gen.migration create_integrations` +- Test: `test/towerops/integrations/integration_test.exs` + +**Step 1: Generate migration** + +Run: `mix ecto.gen.migration create_integrations` + +**Step 2: Write the migration** + +```elixir +defmodule Towerops.Repo.Migrations.CreateIntegrations do + use Ecto.Migration + + def change do + create table(:integrations, primary_key: false) do + add :id, :binary_id, primary_key: true + add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all), + null: false + add :provider, :string, null: false + add :enabled, :boolean, default: false, null: false + add :credentials, :binary + add :sync_interval_minutes, :integer, default: 10 + add :last_synced_at, :utc_datetime + add :last_sync_status, :string, default: "never" + + timestamps(type: :utc_datetime) + end + + create unique_index(:integrations, [:organization_id, :provider]) + create index(:integrations, [:organization_id]) + end +end +``` + +**Step 3: Run migration** + +Run: `mix ecto.migrate` + +**Step 4: Write failing schema test** + +```elixir +# test/towerops/integrations/integration_test.exs +defmodule Towerops.Integrations.IntegrationTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Integrations.Integration + + setup do + user = user_fixture() + organization = organization_fixture(user.id) + %{organization: organization} + end + + describe "changeset/2" do + test "valid changeset with required fields", %{organization: org} do + attrs = %{ + organization_id: org.id, + provider: "preseem", + enabled: true, + credentials: %{"api_key" => "test-key-123"} + } + + changeset = Integration.changeset(%Integration{}, attrs) + assert changeset.valid? + end + + test "requires organization_id" do + attrs = %{provider: "preseem"} + changeset = Integration.changeset(%Integration{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).organization_id + end + + test "requires provider" do + attrs = %{organization_id: Ecto.UUID.generate()} + changeset = Integration.changeset(%Integration{}, attrs) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).provider + end + + test "validates provider is a known value" do + attrs = %{ + organization_id: Ecto.UUID.generate(), + provider: "unknown_provider" + } + + changeset = Integration.changeset(%Integration{}, attrs) + refute changeset.valid? + assert "is invalid" in errors_on(changeset).provider + end + + test "validates sync_interval_minutes is positive" do + attrs = %{ + organization_id: Ecto.UUID.generate(), + provider: "preseem", + sync_interval_minutes: 0 + } + + changeset = Integration.changeset(%Integration{}, attrs) + refute changeset.valid? + assert "must be greater than 0" in errors_on(changeset).sync_interval_minutes + end + + test "validates last_sync_status is a known value" do + attrs = %{ + organization_id: Ecto.UUID.generate(), + provider: "preseem", + last_sync_status: "bogus" + } + + changeset = Integration.changeset(%Integration{}, attrs) + refute changeset.valid? + assert "is invalid" in errors_on(changeset).last_sync_status + end + + test "enforces unique constraint on org + provider", %{organization: org} do + attrs = %{organization_id: org.id, provider: "preseem"} + + {:ok, _} = %Integration{} |> Integration.changeset(attrs) |> Repo.insert() + + assert {:error, changeset} = + %Integration{} |> Integration.changeset(attrs) |> Repo.insert() + + assert "has already been taken" in errors_on(changeset).provider + end + end + + describe "credentials encryption" do + test "credentials are encrypted at rest", %{organization: org} do + attrs = %{ + organization_id: org.id, + provider: "preseem", + credentials: %{"api_key" => "super-secret-key"} + } + + {:ok, integration} = %Integration{} |> Integration.changeset(attrs) |> Repo.insert() + + # Reload from database + reloaded = Repo.get!(Integration, integration.id) + assert reloaded.credentials == %{"api_key" => "super-secret-key"} + + # Verify raw binary in DB is NOT the plain JSON + raw = + Repo.one( + from i in "integrations", + where: i.id == type(^integration.id, :binary_id), + select: i.credentials + ) + + refute raw == Jason.encode!(attrs.credentials) + end + end +end +``` + +**Step 5: Run test to verify it fails** + +Run: `mix test test/towerops/integrations/integration_test.exs` +Expected: Compilation error — module `Towerops.Integrations.Integration` not found + +**Step 6: Write the schema** + +```elixir +# lib/towerops/integrations/integration.ex +defmodule Towerops.Integrations.Integration do + @moduledoc """ + Schema for per-organization third-party integrations. + + Each organization can have one integration per provider (e.g., one Preseem + integration). Credentials are encrypted at rest using Cloak AES-256-GCM. + """ + use Ecto.Schema + import Ecto.Changeset + + alias Towerops.Encrypted + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + @valid_providers ~w(preseem) + @valid_sync_statuses ~w(never success partial failed) + + schema "integrations" do + field :provider, :string + field :enabled, :boolean, default: false + field :credentials, Encrypted.Map + field :sync_interval_minutes, :integer, default: 10 + field :last_synced_at, :utc_datetime + field :last_sync_status, :string, default: "never" + + belongs_to :organization, Towerops.Organizations.Organization + + timestamps(type: :utc_datetime) + end + + def changeset(integration, attrs) do + integration + |> cast(attrs, [ + :organization_id, + :provider, + :enabled, + :credentials, + :sync_interval_minutes, + :last_synced_at, + :last_sync_status + ]) + |> validate_required([:organization_id, :provider]) + |> validate_inclusion(:provider, @valid_providers) + |> validate_inclusion(:last_sync_status, @valid_sync_statuses) + |> validate_number(:sync_interval_minutes, greater_than: 0) + |> unique_constraint([:organization_id, :provider], + error_key: :provider, + message: "has already been taken" + ) + |> foreign_key_constraint(:organization_id) + end +end +``` + +**Step 7: Run test to verify it passes** + +Run: `mix test test/towerops/integrations/integration_test.exs` +Expected: All tests pass + +**Step 8: Commit** + +```bash +git add lib/towerops/integrations/integration.ex test/towerops/integrations/integration_test.exs priv/repo/migrations/*_create_integrations.exs +git commit -m "add integrations schema with encrypted credentials" +``` + +--- + +### Task 3: Create Integrations Context + +**Files:** +- Create: `lib/towerops/integrations.ex` +- Test: `test/towerops/integrations_test.exs` + +**Step 1: Write failing context test** + +```elixir +# test/towerops/integrations_test.exs +defmodule Towerops.IntegrationsTest do + use Towerops.DataCase, async: true + + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Integrations + alias Towerops.Integrations.Integration + + setup do + user = user_fixture() + organization = organization_fixture(user.id) + %{organization: organization} + end + + describe "list_integrations/1" do + test "returns all integrations for an organization", %{organization: org} do + {:ok, _} = Integrations.create_integration(org.id, %{provider: "preseem"}) + integrations = Integrations.list_integrations(org.id) + assert length(integrations) == 1 + end + + test "does not return integrations from other orgs", %{organization: org} do + user2 = user_fixture() + org2 = organization_fixture(user2.id) + {:ok, _} = Integrations.create_integration(org2.id, %{provider: "preseem"}) + + assert Integrations.list_integrations(org.id) == [] + end + end + + describe "get_integration/2" do + test "returns integration by org and provider", %{organization: org} do + {:ok, created} = Integrations.create_integration(org.id, %{provider: "preseem"}) + assert {:ok, fetched} = Integrations.get_integration(org.id, "preseem") + assert fetched.id == created.id + end + + test "returns error when not found", %{organization: org} do + assert {:error, :not_found} = Integrations.get_integration(org.id, "preseem") + end + end + + describe "get_integration!/2" do + test "returns integration by org and provider", %{organization: org} do + {:ok, created} = Integrations.create_integration(org.id, %{provider: "preseem"}) + fetched = Integrations.get_integration!(org.id, "preseem") + assert fetched.id == created.id + end + + test "raises when not found", %{organization: org} do + assert_raise Ecto.NoResultsError, fn -> + Integrations.get_integration!(org.id, "preseem") + end + end + end + + describe "create_integration/2" do + test "creates with valid attrs", %{organization: org} do + attrs = %{ + provider: "preseem", + credentials: %{"api_key" => "test-123"}, + enabled: true + } + + assert {:ok, %Integration{} = integration} = + Integrations.create_integration(org.id, attrs) + + assert integration.provider == "preseem" + assert integration.credentials == %{"api_key" => "test-123"} + assert integration.enabled == true + assert integration.organization_id == org.id + end + + test "rejects duplicate provider per org", %{organization: org} do + {:ok, _} = Integrations.create_integration(org.id, %{provider: "preseem"}) + + assert {:error, changeset} = + Integrations.create_integration(org.id, %{provider: "preseem"}) + + assert "has already been taken" in errors_on(changeset).provider + end + end + + describe "update_integration/2" do + test "updates credentials", %{organization: org} do + {:ok, integration} = + Integrations.create_integration(org.id, %{ + provider: "preseem", + credentials: %{"api_key" => "old-key"} + }) + + assert {:ok, updated} = + Integrations.update_integration(integration, %{ + credentials: %{"api_key" => "new-key"} + }) + + assert updated.credentials == %{"api_key" => "new-key"} + end + + test "updates enabled flag", %{organization: org} do + {:ok, integration} = + Integrations.create_integration(org.id, %{provider: "preseem", enabled: false}) + + assert {:ok, updated} = Integrations.update_integration(integration, %{enabled: true}) + assert updated.enabled == true + end + end + + describe "update_sync_status/3" do + test "updates last_synced_at and last_sync_status", %{organization: org} do + {:ok, integration} = + Integrations.create_integration(org.id, %{provider: "preseem"}) + + assert integration.last_sync_status == "never" + assert integration.last_synced_at == nil + + assert {:ok, updated} = Integrations.update_sync_status(integration, "success") + assert updated.last_sync_status == "success" + assert updated.last_synced_at != nil + end + end + + describe "delete_integration/1" do + test "deletes the integration", %{organization: org} do + {:ok, integration} = + Integrations.create_integration(org.id, %{provider: "preseem"}) + + assert {:ok, _} = Integrations.delete_integration(integration) + assert {:error, :not_found} = Integrations.get_integration(org.id, "preseem") + end + end + + describe "change_integration/2" do + test "returns a changeset", %{organization: org} do + {:ok, integration} = + Integrations.create_integration(org.id, %{provider: "preseem"}) + + assert %Ecto.Changeset{} = Integrations.change_integration(integration) + end + end + + describe "list_enabled_integrations/1" do + test "returns only enabled integrations for a provider", %{organization: org} do + {:ok, _} = + Integrations.create_integration(org.id, %{provider: "preseem", enabled: true}) + + user2 = user_fixture() + org2 = organization_fixture(user2.id) + + {:ok, _} = + Integrations.create_integration(org2.id, %{provider: "preseem", enabled: false}) + + enabled = Integrations.list_enabled_integrations("preseem") + assert length(enabled) == 1 + assert hd(enabled).organization_id == org.id + end + end +end +``` + +**Step 2: Run test to verify it fails** + +Run: `mix test test/towerops/integrations_test.exs` +Expected: Compilation error — module `Towerops.Integrations` not found + +**Step 3: Write the context** + +```elixir +# lib/towerops/integrations.ex +defmodule Towerops.Integrations do + @moduledoc """ + Context for managing third-party integrations per organization. + """ + import Ecto.Query + + alias Towerops.Integrations.Integration + alias Towerops.Repo + + def list_integrations(organization_id) do + Integration + |> where(organization_id: ^organization_id) + |> order_by(:provider) + |> Repo.all() + end + + def get_integration(organization_id, provider) do + case Repo.get_by(Integration, organization_id: organization_id, provider: provider) do + nil -> {:error, :not_found} + integration -> {:ok, integration} + end + end + + def get_integration!(organization_id, provider) do + Repo.get_by!(Integration, organization_id: organization_id, provider: provider) + end + + def create_integration(organization_id, attrs) do + %Integration{} + |> Integration.changeset(Map.put(attrs, :organization_id, organization_id)) + |> Repo.insert() + end + + def update_integration(%Integration{} = integration, attrs) do + integration + |> Integration.changeset(attrs) + |> Repo.update() + end + + def update_sync_status(%Integration{} = integration, status) do + integration + |> Integration.changeset(%{ + last_sync_status: status, + last_synced_at: DateTime.utc_now() |> DateTime.truncate(:second) + }) + |> Repo.update() + end + + def delete_integration(%Integration{} = integration) do + Repo.delete(integration) + end + + def change_integration(%Integration{} = integration, attrs \\ %{}) do + Integration.changeset(integration, attrs) + end + + def list_enabled_integrations(provider) do + Integration + |> where(provider: ^provider, enabled: true) + |> Repo.all() + end +end +``` + +**Step 4: Run test to verify it passes** + +Run: `mix test test/towerops/integrations_test.exs` +Expected: All tests pass + +**Step 5: Commit** + +```bash +git add lib/towerops/integrations.ex test/towerops/integrations_test.exs +git commit -m "add integrations context with CRUD operations" +``` + +--- + +### Task 4: Create Preseem API Client + +**Files:** +- Create: `lib/towerops/preseem/client.ex` +- Test: `test/towerops/preseem/client_test.exs` + +**Step 1: Write failing client test** + +The client calls the Preseem API. For testing, we use `Req.Test` (built-in test adapter for Req). + +```elixir +# test/towerops/preseem/client_test.exs +defmodule Towerops.Preseem.ClientTest do + use ExUnit.Case, async: true + + alias Towerops.Preseem.Client + + describe "test_connection/1" do + test "returns ok when API responds with 200" do + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, %{"status" => "ok", "account" => "test-isp"}) + end) + + assert {:ok, %{"status" => "ok"}} = Client.test_connection("valid-api-key") + end + + test "returns error for 401 unauthorized" do + Req.Test.stub(Client, fn conn -> + conn + |> Plug.Conn.put_status(401) + |> Req.Test.json(%{"error" => "unauthorized"}) + end) + + assert {:error, :unauthorized} = Client.test_connection("bad-key") + end + + test "returns error for 403 forbidden" do + Req.Test.stub(Client, fn conn -> + conn + |> Plug.Conn.put_status(403) + |> Req.Test.json(%{"error" => "forbidden"}) + end) + + assert {:error, :forbidden} = Client.test_connection("restricted-key") + end + + test "returns error for network failure" do + Req.Test.stub(Client, fn _conn -> + raise "connection refused" + end) + + assert {:error, _reason} = Client.test_connection("any-key") + end + end + + describe "list_access_points/1" do + test "returns list of access points on success" do + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, %{ + "data" => [ + %{"id" => "ap-1", "name" => "Tower1-AP1", "ip" => "10.0.0.1"}, + %{"id" => "ap-2", "name" => "Tower1-AP2", "ip" => "10.0.0.2"} + ] + }) + end) + + assert {:ok, [ap1, ap2]} = Client.list_access_points("valid-key") + assert ap1["id"] == "ap-1" + assert ap2["id"] == "ap-2" + end + + test "returns error for 401" do + Req.Test.stub(Client, fn conn -> + conn + |> Plug.Conn.put_status(401) + |> Req.Test.json(%{"error" => "unauthorized"}) + end) + + assert {:error, :unauthorized} = Client.list_access_points("bad-key") + end + end + + describe "get_access_point_metrics/2" do + test "returns metrics for an AP" do + Req.Test.stub(Client, fn conn -> + Req.Test.json(conn, %{ + "data" => %{ + "qoe_score" => 85.5, + "capacity_score" => 72.0, + "subscriber_count" => 42, + "p95_latency" => 18.3 + } + }) + end) + + assert {:ok, metrics} = Client.get_access_point_metrics("valid-key", "ap-1") + assert metrics["qoe_score"] == 85.5 + end + end +end +``` + +**Step 2: Run test to verify it fails** + +Run: `mix test test/towerops/preseem/client_test.exs` +Expected: Compilation error — module `Towerops.Preseem.Client` not found + +**Step 3: Write the client** + +```elixir +# lib/towerops/preseem/client.ex +defmodule Towerops.Preseem.Client do + @moduledoc """ + HTTP client for the Preseem Model API. + + Uses Req with plug-based testing support via `Req.Test`. + """ + require Logger + + @default_base_url "https://apidocs.preseem.com/model/v1" + + def test_connection(api_key, opts \\ []) do + base_url = Keyword.get(opts, :base_url, @default_base_url) + + case request(:get, "#{base_url}/account", api_key) do + {:ok, %{status: status, body: body}} when status in 200..299 -> + {:ok, body} + + {:ok, %{status: 401}} -> + {:error, :unauthorized} + + {:ok, %{status: 403}} -> + {:error, :forbidden} + + {:ok, %{status: status, body: body}} -> + Logger.warning("Preseem API unexpected status #{status}: #{inspect(body)}") + {:error, {:unexpected_status, status}} + + {:error, reason} -> + Logger.error("Preseem API connection error: #{inspect(reason)}") + {:error, reason} + end + end + + def list_access_points(api_key, opts \\ []) do + base_url = Keyword.get(opts, :base_url, @default_base_url) + + case request(:get, "#{base_url}/access_points", api_key) do + {:ok, %{status: status, body: %{"data" => data}}} when status in 200..299 -> + {:ok, data} + + {:ok, %{status: 401}} -> + {:error, :unauthorized} + + {:ok, %{status: 403}} -> + {:error, :forbidden} + + {:ok, %{status: status, body: body}} -> + {:error, {:unexpected_status, status, body}} + + {:error, reason} -> + {:error, reason} + end + end + + def get_access_point_metrics(api_key, ap_id, opts \\ []) do + base_url = Keyword.get(opts, :base_url, @default_base_url) + + case request(:get, "#{base_url}/access_points/#{ap_id}/metrics", api_key) do + {:ok, %{status: status, body: %{"data" => data}}} when status in 200..299 -> + {:ok, data} + + {:ok, %{status: 401}} -> + {:error, :unauthorized} + + {:ok, %{status: 403}} -> + {:error, :forbidden} + + {:ok, %{status: status, body: body}} -> + {:error, {:unexpected_status, status, body}} + + {:error, reason} -> + {:error, reason} + end + end + + defp request(method, url, api_key) do + req = + Req.new( + method: method, + url: url, + headers: [ + {"authorization", "Bearer #{api_key}"}, + {"accept", "application/json"} + ], + plug: {Req.Test, __MODULE__} + ) + + Req.request(req) + rescue + exception -> + {:error, Exception.message(exception)} + end +end +``` + +**Step 4: Run test to verify it passes** + +Run: `mix test test/towerops/preseem/client_test.exs` +Expected: All tests pass + +**Step 5: Commit** + +```bash +git add lib/towerops/preseem/client.ex test/towerops/preseem/client_test.exs +git commit -m "add preseem API client with test connection support" +``` + +--- + +### Task 5: Create Integrations LiveView (Settings Page) + +**Files:** +- Create: `lib/towerops_web/live/org/integrations_live.ex` +- Create: `lib/towerops_web/live/org/integrations_live.html.heex` +- Modify: `lib/towerops_web/router.ex` (add route) +- Modify: `lib/towerops_web/live/org/settings_live.html.heex` (add link) +- Test: `test/towerops_web/live/org/integrations_live_test.exs` + +**Step 1: Add route to router** + +In `lib/towerops_web/router.ex`, find the `:require_authenticated_user_and_organization` live_session block (around line 300-312) and add the integrations route next to the existing settings route: + +```elixir +# Find this block: + scope "/orgs/:org_slug", ToweropsWeb do + pipe_through [:browser, :require_authenticated_user, :load_current_organization] + + live "/settings", Org.SettingsLive, :index + end + +# Change to: + scope "/orgs/:org_slug", ToweropsWeb do + pipe_through [:browser, :require_authenticated_user, :load_current_organization] + + live "/settings", Org.SettingsLive, :index + live "/settings/integrations", Org.IntegrationsLive, :index + end +``` + +**Step 2: Write failing LiveView test** + +```elixir +# test/towerops_web/live/org/integrations_live_test.exs +defmodule ToweropsWeb.Org.IntegrationsLiveTest do + use ToweropsWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + import Towerops.AccountsFixtures + import Towerops.OrganizationsFixtures + + setup %{conn: conn} do + user = user_fixture() + organization = organization_fixture(user.id) + conn = log_in_user(conn, user) + %{conn: conn, user: user, organization: organization} + end + + describe "index" do + test "renders integrations page", %{conn: conn, organization: org} do + {:ok, _view, html} = live(conn, ~p"/orgs/#{org.slug}/settings/integrations") + + assert html =~ "Integrations" + assert html =~ "Preseem" + end + + test "shows preseem as available integration", %{conn: conn, organization: org} do + {:ok, _view, html} = live(conn, ~p"/orgs/#{org.slug}/settings/integrations") + + assert html =~ "Preseem" + assert html =~ "QoE monitoring" + end + + test "can enable preseem integration", %{conn: conn, organization: org} do + {:ok, view, _html} = live(conn, ~p"/orgs/#{org.slug}/settings/integrations") + + html = + view + |> element("#configure-preseem") + |> render_click() + + assert html =~ "API Key" + end + + test "can save preseem credentials", %{conn: conn, organization: org} do + {:ok, view, _html} = live(conn, ~p"/orgs/#{org.slug}/settings/integrations") + + # Open configure panel + view |> element("#configure-preseem") |> render_click() + + # Fill in API key and save + view + |> form("#preseem-form", %{integration: %{api_key: "test-key-123"}}) + |> render_submit() + + # Verify it was saved + assert {:ok, integration} = Towerops.Integrations.get_integration(org.id, "preseem") + assert integration.credentials["api_key"] == "test-key-123" + end + + test "can test preseem connection", %{conn: conn, organization: org} do + Req.Test.stub(Towerops.Preseem.Client, fn conn -> + Req.Test.json(conn, %{"status" => "ok"}) + end) + + {:ok, view, _html} = live(conn, ~p"/orgs/#{org.slug}/settings/integrations") + + view |> element("#configure-preseem") |> render_click() + + view + |> form("#preseem-form", %{integration: %{api_key: "test-key"}}) + |> render_change() + + html = view |> element("#test-connection") |> render_click() + + assert html =~ "Connection successful" + end + + test "shows error for invalid preseem API key", %{conn: conn, organization: org} do + Req.Test.stub(Towerops.Preseem.Client, fn conn -> + conn + |> Plug.Conn.put_status(401) + |> Req.Test.json(%{"error" => "unauthorized"}) + end) + + {:ok, view, _html} = live(conn, ~p"/orgs/#{org.slug}/settings/integrations") + + view |> element("#configure-preseem") |> render_click() + + view + |> form("#preseem-form", %{integration: %{api_key: "bad-key"}}) + |> render_change() + + html = view |> element("#test-connection") |> render_click() + + assert html =~ "unauthorized" or html =~ "Invalid API key" + end + end +end +``` + +**Step 3: Run test to verify it fails** + +Run: `mix test test/towerops_web/live/org/integrations_live_test.exs` +Expected: Compilation error or route not found + +**Step 4: Write the LiveView module** + +```elixir +# lib/towerops_web/live/org/integrations_live.ex +defmodule ToweropsWeb.Org.IntegrationsLive do + @moduledoc false + use ToweropsWeb, :live_view + + alias Towerops.Integrations + alias Towerops.Preseem.Client, as: PreseemClient + + @providers [ + %{ + id: "preseem", + name: "Preseem", + description: "QoE monitoring and AP capacity management for WISPs", + icon: "hero-signal" + } + ] + + @impl true + def mount(_params, _session, socket) do + organization = socket.assigns.current_scope.organization + integrations = Integrations.list_integrations(organization.id) + + integrations_by_provider = + Map.new(integrations, fn i -> {i.provider, i} end) + + {:ok, + socket + |> assign(:organization, organization) + |> assign(:providers, @providers) + |> assign(:integrations, integrations_by_provider) + |> assign(:configuring, nil) + |> assign(:form, nil) + |> assign(:test_result, nil)} + end + + @impl true + def handle_event("configure", %{"provider" => provider}, socket) do + org = socket.assigns.organization + + integration = + case Map.get(socket.assigns.integrations, provider) do + nil -> + %Integrations.Integration{ + organization_id: org.id, + provider: provider + } + + existing -> + existing + end + + form = build_form(integration) + + {:noreply, + socket + |> assign(:configuring, provider) + |> assign(:form, form) + |> assign(:test_result, nil)} + end + + @impl true + def handle_event("close_config", _params, socket) do + {:noreply, + socket + |> assign(:configuring, nil) + |> assign(:form, nil) + |> assign(:test_result, nil)} + end + + @impl true + def handle_event("validate", %{"integration" => params}, socket) do + changeset = + socket.assigns.form.source.data + |> Integrations.change_integration(normalize_params(params)) + |> Map.put(:action, :validate) + + {:noreply, assign(socket, :form, to_form(changeset, as: :integration))} + end + + @impl true + def handle_event("save", %{"integration" => params}, socket) do + org = socket.assigns.organization + provider = socket.assigns.configuring + normalized = normalize_params(params) + + result = + case Map.get(socket.assigns.integrations, provider) do + nil -> + Integrations.create_integration(org.id, Map.put(normalized, :provider, provider)) + + existing -> + Integrations.update_integration(existing, normalized) + end + + case result do + {:ok, integration} -> + integrations = Map.put(socket.assigns.integrations, provider, integration) + + {:noreply, + socket + |> assign(:integrations, integrations) + |> assign(:form, build_form(integration)) + |> put_flash(:info, "Integration saved")} + + {:error, changeset} -> + {:noreply, assign(socket, :form, to_form(changeset, as: :integration))} + end + end + + @impl true + def handle_event("test_connection", _params, socket) do + api_key = get_api_key_from_form(socket) + + if api_key && api_key != "" do + case PreseemClient.test_connection(api_key) do + {:ok, _} -> + {:noreply, assign(socket, :test_result, :success)} + + {:error, :unauthorized} -> + {:noreply, assign(socket, :test_result, {:error, "Invalid API key"})} + + {:error, :forbidden} -> + {:noreply, assign(socket, :test_result, {:error, "API key does not have access"})} + + {:error, reason} -> + {:noreply, + assign(socket, :test_result, {:error, "Connection failed: #{inspect(reason)}"})} + end + else + {:noreply, assign(socket, :test_result, {:error, "Enter an API key first"})} + end + end + + @impl true + def handle_event("toggle_enabled", %{"provider" => provider}, socket) do + case Map.get(socket.assigns.integrations, provider) do + nil -> + {:noreply, socket} + + integration -> + case Integrations.update_integration(integration, %{enabled: !integration.enabled}) do + {:ok, updated} -> + integrations = Map.put(socket.assigns.integrations, provider, updated) + status = if updated.enabled, do: "enabled", else: "disabled" + + {:noreply, + socket + |> assign(:integrations, integrations) + |> put_flash(:info, "Integration #{status}")} + + {:error, _} -> + {:noreply, put_flash(socket, :error, "Failed to update integration")} + end + end + end + + defp build_form(integration) do + integration + |> Integrations.change_integration() + |> to_form(as: :integration) + end + + defp normalize_params(params) do + api_key = Map.get(params, "api_key", "") + base_url = Map.get(params, "base_url", "") + enabled = Map.get(params, "enabled", "false") + + credentials = + %{"api_key" => api_key} + |> then(fn creds -> + if base_url != "", do: Map.put(creds, "base_url", base_url), else: creds + end) + + %{ + credentials: credentials, + enabled: enabled == "true" || enabled == true + } + end + + defp get_api_key_from_form(socket) do + case socket.assigns.form do + nil -> + nil + + form -> + changeset = form.source + data = Ecto.Changeset.apply_changes(changeset) + + case data.credentials do + %{"api_key" => key} -> key + _ -> nil + end + end + end +end +``` + +**Step 5: Write the template** + +```heex +<%!-- lib/towerops_web/live/org/integrations_live.html.heex --%> + +
+
+ <.link + navigate={~p"/orgs/#{@organization.slug}/settings"} + class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white" + > + <.icon name="hero-arrow-left" class="h-4 w-4" /> Back to Settings + +
+

+ Integrations +

+

+ Connect third-party services to enhance monitoring and analytics +

+
+ +
+ <%= for provider <- @providers do %> + <% integration = Map.get(@integrations, provider.id) %> +
+
+
+
+ <.icon name={provider.icon} class="h-6 w-6 text-indigo-600 dark:text-indigo-400" /> +
+
+

+ {provider.name} +

+

+ {provider.description} +

+
+
+ +
+ <%= if integration do %> + + <% end %> + + +
+
+ + <%= if integration do %> +
+ + "bg-green-500" + "partial" -> "bg-yellow-500" + "failed" -> "bg-red-500" + _ -> "bg-gray-400" + end + ]} /> + Last sync: <%= if integration.last_synced_at do %> + {Calendar.strftime(integration.last_synced_at, "%b %d, %Y %H:%M UTC")} + <% else %> + Never + <% end %> + + Status: {integration.last_sync_status} +
+ <% end %> +
+ <% end %> +
+ + <%!-- Configuration Panel --%> + <%= if @configuring == "preseem" do %> + +
+ <% end %> + +``` + +**Step 6: Add link from settings page** + +In `lib/towerops_web/live/org/settings_live.html.heex`, add a link to the integrations page. Find the closing `
` of the page header (around line 20, after the subtitle `

` tag) and add: + +```heex + <%!-- Add after the subtitle

tag, inside the header div --%> +

+ <.link + navigate={~p"/orgs/#{@organization.slug}/settings/integrations"} + class="inline-flex items-center gap-2 rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-xs ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-white/10 dark:text-white dark:ring-white/20 dark:hover:bg-white/20" + > + <.icon name="hero-puzzle-piece" class="h-4 w-4" /> Integrations + +
+``` + +**Step 7: Run test to verify it passes** + +Run: `mix test test/towerops_web/live/org/integrations_live_test.exs` +Expected: All tests pass (some tests may need adjustment based on exact HTML output — iterate until green) + +**Step 8: Commit** + +```bash +git add lib/towerops_web/live/org/integrations_live.ex lib/towerops_web/live/org/integrations_live.html.heex lib/towerops_web/router.ex lib/towerops_web/live/org/settings_live.html.heex test/towerops_web/live/org/integrations_live_test.exs +git commit -m "add integrations settings page with preseem configuration" +``` + +--- + +### Task 6: Create Integrations Test Fixture + +**Files:** +- Create: `test/support/fixtures/integrations_fixtures.ex` + +**Step 1: Write the fixture** + +```elixir +# test/support/fixtures/integrations_fixtures.ex +defmodule Towerops.IntegrationsFixtures do + @moduledoc """ + Test helpers for creating integration entities. + """ + + alias Towerops.Integrations + + def integration_fixture(organization_id, attrs \\ %{}) do + attrs = + Enum.into(attrs, %{ + provider: "preseem", + enabled: true, + credentials: %{"api_key" => "test-key-#{System.unique_integer()}"} + }) + + {:ok, integration} = Integrations.create_integration(organization_id, attrs) + integration + end +end +``` + +**Step 2: Commit** + +```bash +git add test/support/fixtures/integrations_fixtures.ex +git commit -m "add integrations test fixture" +``` + +--- + +### Task 7: Format, Compile Clean, Run Full Test Suite + +**Step 1: Format** + +Run: `mix format` + +**Step 2: Compile with warnings as errors** + +Run: `mix compile --warnings-as-errors` +Expected: Clean compilation, no warnings + +**Step 3: Run full test suite** + +Run: `mix test` +Expected: Same failure count as baseline (19 pre-existing failures). No new failures. + +**Step 4: Fix any issues found** + +If new warnings or failures, fix them before committing. + +**Step 5: Final commit if any format/warning fixes needed** + +```bash +git add -A +git commit -m "fix formatting and compiler warnings" +``` + +--- + +## Verification Checklist + +After all tasks complete, verify: + +- [ ] `mix compile --warnings-as-errors` — clean +- [ ] `mix format --check-formatted` — clean +- [ ] `mix test` — no new failures vs baseline (19 pre-existing) +- [ ] `mix test test/towerops/ecto_types/encrypted_map_test.exs` — passes +- [ ] `mix test test/towerops/integrations/integration_test.exs` — passes +- [ ] `mix test test/towerops/integrations_test.exs` — passes +- [ ] `mix test test/towerops/preseem/client_test.exs` — passes +- [ ] `mix test test/towerops_web/live/org/integrations_live_test.exs` — passes +- [ ] Navigate to `/orgs/:slug/settings` — "Integrations" link visible +- [ ] Navigate to `/orgs/:slug/settings/integrations` — page renders with Preseem card +- [ ] Click Configure → modal opens with API key field +- [ ] Save credentials → reloads with credentials stored +- [ ] Toggle enable/disable → updates integration state