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