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:
parent
43a9ff01a6
commit
3068f944fb
3 changed files with 256 additions and 51 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue