diff --git a/lib/towerops/monitoring.ex b/lib/towerops/monitoring.ex index 328bac51..32cee7b8 100644 --- a/lib/towerops/monitoring.ex +++ b/lib/towerops/monitoring.ex @@ -41,79 +41,82 @@ defmodule Towerops.Monitoring do @doc """ Gets hourly statistics for equipment over a time range. - Uses TimescaleDB continuous aggregate for performance. + Calculates aggregates on-demand from monitoring_checks. """ def get_hourly_stats(equipment_id, start_time, end_time) do query = """ SELECT - bucket, - total_checks, - successful_checks, - failed_checks, - avg_response_time_ms, - min_response_time_ms, - max_response_time_ms, - ROUND(100.0 * successful_checks / NULLIF(total_checks, 0), 2) as uptime_percentage - FROM monitoring_checks_hourly - WHERE equipment_id = $1 - AND bucket >= $2 - AND bucket <= $3 + date_trunc('hour', checked_at) as bucket, + COUNT(*) as total_checks, + COUNT(*) FILTER (WHERE status = 'success') as successful_checks, + COUNT(*) FILTER (WHERE status IN ('failure', 'timeout')) as failed_checks, + ROUND(AVG(response_time_ms)::numeric, 2) as avg_response_time_ms, + MIN(response_time_ms) as min_response_time_ms, + MAX(response_time_ms) as max_response_time_ms, + ROUND(100.0 * COUNT(*) FILTER (WHERE status = 'success') / NULLIF(COUNT(*), 0), 2) as uptime_percentage + FROM monitoring_checks + WHERE equipment_id = $1::uuid + AND checked_at >= $2 + AND checked_at <= $3 + GROUP BY bucket ORDER BY bucket ASC """ SQL.query!( Repo, query, - [equipment_id, start_time, end_time] + [Ecto.UUID.dump!(equipment_id), start_time, end_time] ) end @doc """ Gets daily statistics for equipment over a time range. - Uses TimescaleDB continuous aggregate for performance. + Calculates aggregates on-demand from monitoring_checks. """ def get_daily_stats(equipment_id, start_time, end_time) do query = """ SELECT - bucket, - total_checks, - successful_checks, - failed_checks, - avg_response_time_ms, - min_response_time_ms, - max_response_time_ms, - uptime_percentage - FROM monitoring_checks_daily - WHERE equipment_id = $1 - AND bucket >= $2 - AND bucket <= $3 + date_trunc('day', checked_at) as bucket, + COUNT(*) as total_checks, + COUNT(*) FILTER (WHERE status = 'success') as successful_checks, + COUNT(*) FILTER (WHERE status IN ('failure', 'timeout')) as failed_checks, + ROUND(AVG(response_time_ms)::numeric, 2) as avg_response_time_ms, + MIN(response_time_ms) as min_response_time_ms, + MAX(response_time_ms) as max_response_time_ms, + ROUND(100.0 * COUNT(*) FILTER (WHERE status = 'success') / NULLIF(COUNT(*), 0), 2) as uptime_percentage + FROM monitoring_checks + WHERE equipment_id = $1::uuid + AND checked_at >= $2 + AND checked_at <= $3 + GROUP BY bucket ORDER BY bucket ASC """ SQL.query!( Repo, query, - [equipment_id, start_time, end_time] + [Ecto.UUID.dump!(equipment_id), start_time, end_time] ) end @doc """ Gets uptime percentage for equipment over the last N days. + Calculates on-demand from monitoring_checks. """ def get_uptime_percentage(equipment_id, days_ago \\ 30) do start_time = DateTime.add(DateTime.utc_now(), -days_ago * 24 * 60 * 60, :second) query = """ SELECT - AVG(uptime_percentage) as avg_uptime - FROM monitoring_checks_daily - WHERE equipment_id = $1 - AND bucket >= $2 + ROUND(100.0 * COUNT(*) FILTER (WHERE status = 'success') / NULLIF(COUNT(*), 0), 2) as uptime_percentage + FROM monitoring_checks + WHERE equipment_id = $1::uuid + AND checked_at >= $2 """ - case SQL.query!(Repo, query, [equipment_id, start_time]) do - %{rows: [[uptime]]} when not is_nil(uptime) -> - Float.round(uptime, 2) + case SQL.query!(Repo, query, [Ecto.UUID.dump!(equipment_id), start_time]) do + %{rows: [[%Decimal{} = uptime]]} -> + uptime |> Decimal.to_float() |> Float.round(2) _ -> nil diff --git a/test/towerops/monitoring_test.exs b/test/towerops/monitoring_test.exs index 184f71ed..f11595ab 100644 --- a/test/towerops/monitoring_test.exs +++ b/test/towerops/monitoring_test.exs @@ -106,40 +106,183 @@ defmodule Towerops.MonitoringTest do assert length(Monitoring.list_equipment_checks(equipment.id)) == 1 end - @tag :skip test "get_hourly_stats/3 returns stats for equipment", %{equipment: equipment} do - start_time = DateTime.add(DateTime.utc_now(), -24 * 60 * 60, :second) - end_time = DateTime.utc_now() + base_time = ~U[2025-12-21 12:00:00Z] + + # Create checks in two different hours + {:ok, _} = + Monitoring.create_check(%{ + equipment_id: equipment.id, + status: :success, + response_time_ms: 50, + checked_at: base_time + }) + + {:ok, _} = + Monitoring.create_check(%{ + equipment_id: equipment.id, + status: :success, + response_time_ms: 100, + checked_at: DateTime.add(base_time, 30 * 60, :second) + }) + + {:ok, _} = + Monitoring.create_check(%{ + equipment_id: equipment.id, + status: :failure, + response_time_ms: nil, + checked_at: DateTime.add(base_time, 3600, :second) + }) + + start_time = DateTime.add(base_time, -3600, :second) + end_time = DateTime.add(base_time, 7200, :second) - # Skip: Requires TimescaleDB continuous aggregate which doesn't exist in test DB result = Monitoring.get_hourly_stats(equipment.id, start_time, end_time) assert %Postgrex.Result{} = result - assert is_list(result.rows) + assert length(result.rows) == 2 + + # First hour should have 2 successful checks + [first_hour | _] = result.rows + [_bucket, total, successful, failed, avg_response, min_response, max_response, uptime] = first_hour + assert total == 2 + assert successful == 2 + assert failed == 0 + assert avg_response == Decimal.new("75.00") + assert min_response == 50 + assert max_response == 100 + assert uptime == Decimal.new("100.00") end - @tag :skip test "get_daily_stats/3 returns stats for equipment", %{equipment: equipment} do - start_time = DateTime.add(DateTime.utc_now(), -30 * 24 * 60 * 60, :second) - end_time = DateTime.utc_now() + day1 = ~U[2025-12-20 12:00:00Z] + day2 = ~U[2025-12-21 12:00:00Z] + + # Create checks on two different days + {:ok, _} = + Monitoring.create_check(%{ + equipment_id: equipment.id, + status: :success, + response_time_ms: 50, + checked_at: day1 + }) + + {:ok, _} = + Monitoring.create_check(%{ + equipment_id: equipment.id, + status: :success, + response_time_ms: 60, + checked_at: DateTime.add(day1, 3600, :second) + }) + + {:ok, _} = + Monitoring.create_check(%{ + equipment_id: equipment.id, + status: :failure, + response_time_ms: nil, + checked_at: day2 + }) + + {:ok, _} = + Monitoring.create_check(%{ + equipment_id: equipment.id, + status: :success, + response_time_ms: 80, + checked_at: DateTime.add(day2, 3600, :second) + }) + + start_time = DateTime.add(day1, -24 * 60 * 60, :second) + end_time = DateTime.add(day2, 24 * 60 * 60, :second) - # Skip: Requires TimescaleDB continuous aggregate which doesn't exist in test DB result = Monitoring.get_daily_stats(equipment.id, start_time, end_time) assert %Postgrex.Result{} = result - assert is_list(result.rows) + assert length(result.rows) == 2 + + # First day should have 2 successful checks + [first_day | _] = result.rows + [_bucket, total, successful, failed, avg_response, _min, _max, uptime] = first_day + assert total == 2 + assert successful == 2 + assert failed == 0 + assert avg_response == Decimal.new("55.00") + assert uptime == Decimal.new("100.00") end - @tag :skip test "get_uptime_percentage/1 returns uptime percentage", %{equipment: equipment} do - # Skip: Requires TimescaleDB continuous aggregate which doesn't exist in test DB + base_time = DateTime.utc_now() + + # Create 3 successful and 1 failed check + {:ok, _} = + Monitoring.create_check(%{ + equipment_id: equipment.id, + status: :success, + response_time_ms: 50, + checked_at: DateTime.add(base_time, -60, :second) + }) + + {:ok, _} = + Monitoring.create_check(%{ + equipment_id: equipment.id, + status: :success, + response_time_ms: 60, + checked_at: DateTime.add(base_time, -120, :second) + }) + + {:ok, _} = + Monitoring.create_check(%{ + equipment_id: equipment.id, + status: :failure, + response_time_ms: nil, + checked_at: DateTime.add(base_time, -180, :second) + }) + + {:ok, _} = + Monitoring.create_check(%{ + equipment_id: equipment.id, + status: :success, + response_time_ms: 70, + checked_at: DateTime.add(base_time, -240, :second) + }) + result = Monitoring.get_uptime_percentage(equipment.id) - assert is_nil(result) or is_float(result) + assert result == 75.0 end - @tag :skip test "get_uptime_percentage/2 accepts custom days parameter", %{equipment: equipment} do - # Skip: Requires TimescaleDB continuous aggregate which doesn't exist in test DB + base_time = DateTime.utc_now() + + # Create checks within the last 7 days + {:ok, _} = + Monitoring.create_check(%{ + equipment_id: equipment.id, + status: :success, + response_time_ms: 50, + checked_at: DateTime.add(base_time, -3 * 24 * 60 * 60, :second) + }) + + {:ok, _} = + Monitoring.create_check(%{ + equipment_id: equipment.id, + status: :success, + response_time_ms: 60, + checked_at: DateTime.add(base_time, -5 * 24 * 60 * 60, :second) + }) + + # Create a check older than 7 days (should not be included) + {:ok, _} = + Monitoring.create_check(%{ + equipment_id: equipment.id, + status: :failure, + response_time_ms: nil, + checked_at: DateTime.add(base_time, -10 * 24 * 60 * 60, :second) + }) + result = Monitoring.get_uptime_percentage(equipment.id, 7) - assert is_nil(result) or is_float(result) + assert result == 100.0 + end + + test "get_uptime_percentage/1 returns nil when no checks exist", %{equipment: equipment} do + result = Monitoring.get_uptime_percentage(equipment.id) + assert is_nil(result) end end diff --git a/test/towerops/organizations/policy_test.exs b/test/towerops/organizations/policy_test.exs index 3b97f6dc..c2fa5093 100644 --- a/test/towerops/organizations/policy_test.exs +++ b/test/towerops/organizations/policy_test.exs @@ -51,5 +51,64 @@ defmodule Towerops.Organizations.PolicyTest do assert Policy.can?(membership, :view, :site) == true assert Policy.can?(membership, :view, :equipment) == true end + + test "viewer can acknowledge alerts" do + membership = %Membership{role: :viewer} + + assert Policy.can?(membership, :acknowledge, :alert) == true + end + + test "viewer cannot perform other actions on alerts" do + membership = %Membership{role: :viewer} + + assert Policy.can?(membership, :create, :alert) == false + assert Policy.can?(membership, :edit, :alert) == false + assert Policy.can?(membership, :delete, :alert) == false + end + + test "nil membership cannot do anything" do + assert Policy.can?(nil, :view, :organization) == false + assert Policy.can?(nil, :edit, :site) == false + assert Policy.can?(nil, :delete, :equipment) == false + end + + test "member cannot manage memberships" do + membership = %Membership{role: :member} + + assert Policy.can?(membership, :create, :membership) == false + assert Policy.can?(membership, :edit, :membership) == false + end + + test "member cannot manage invitations" do + membership = %Membership{role: :member} + + assert Policy.can?(membership, :create, :invitation) == false + assert Policy.can?(membership, :delete, :invitation) == false + end + + test "member can list resources" do + membership = %Membership{role: :member} + + assert Policy.can?(membership, :list, :site) == true + assert Policy.can?(membership, :list, :equipment) == true + assert Policy.can?(membership, :list, :alert) == true + end + + test "member can create and view alerts" do + membership = %Membership{role: :member} + + assert Policy.can?(membership, :create, :alert) == true + assert Policy.can?(membership, :view, :alert) == true + assert Policy.can?(membership, :edit, :alert) == true + end + + test "viewer can list resources" do + membership = %Membership{role: :viewer} + + assert Policy.can?(membership, :list, :site) == true + assert Policy.can?(membership, :list, :equipment) == true + assert Policy.can?(membership, :list, :alert) == true + assert Policy.can?(membership, :list, :organization) == true + end end end