Add TimescaleDB aggregate tests, implement on-demand calculation

- Updated Monitoring module to calculate hourly/daily stats on-demand from monitoring_checks table
- Removed TimescaleDB continuous aggregate dependency (disabled due to licensing)
- Enabled all previously skipped TimescaleDB tests with proper test data
- Fixed UUID encoding for SQL queries using Ecto.UUID.dump!
- Fixed Decimal handling in uptime percentage calculation
- Added test for get_uptime_percentage with nil checks
- Monitoring module now at 100% coverage
This commit is contained in:
Graham McIntire 2026-01-13 08:09:12 -06:00
parent 43a9ff01a6
commit 3068f944fb
No known key found for this signature in database
3 changed files with 256 additions and 51 deletions

View file

@ -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

View file

@ -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

View file

@ -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