feat: add latency graph for ICMP ping monitoring
- Add Monitoring.get_latency_data/2 to query successful checks with response times - Support 'latency' sensor type in GraphLive.Show with configurable time ranges - Add latency chart to device show page alongside other metric charts - Add comprehensive tests for get_latency_data/2 (limit, since, failed checks) - All 813 tests passing with no Credo warnings
This commit is contained in:
parent
f0f5aca491
commit
55a0d88978
5 changed files with 251 additions and 0 deletions
|
|
@ -122,4 +122,34 @@ defmodule Towerops.Monitoring do
|
|||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets latency data for graphing ping response times.
|
||||
|
||||
Returns successful monitoring checks with response times for the specified time range.
|
||||
Failed checks are excluded as they have no latency data.
|
||||
|
||||
## Options
|
||||
* `:since` - Only return checks after this datetime
|
||||
* `:limit` - Maximum number of checks to return (default: 2880)
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_latency_data(device_id, since: DateTime.utc_now() |> DateTime.add(-24, :hour), limit: 100)
|
||||
[%Check{response_time_ms: 50, checked_at: ~U[2025-12-21 12:00:00Z]}, ...]
|
||||
|
||||
"""
|
||||
def get_latency_data(device_id, opts \\ []) do
|
||||
since = Keyword.get(opts, :since, DateTime.add(DateTime.utc_now(), -24, :hour))
|
||||
limit = Keyword.get(opts, :limit, 2880)
|
||||
|
||||
Check
|
||||
|> where([c], c.device_id == ^device_id)
|
||||
|> where([c], c.status == :success)
|
||||
|> where([c], c.checked_at >= ^since)
|
||||
|> where([c], not is_nil(c.response_time_ms))
|
||||
|> order_by([c], desc: c.checked_at)
|
||||
|> limit(^limit)
|
||||
|> Repo.all()
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
snmp_data = load_snmp_data(device_id)
|
||||
events = Devices.list_devices_events(device_id, 100)
|
||||
neighbors = Snmp.list_neighbors_with_equipment(device_id)
|
||||
latency_chart_data = load_latency_chart_data(device_id)
|
||||
cpu_chart_data = load_sensor_chart_data(device_id, ["cpu_load"])
|
||||
memory_chart_data = load_sensor_chart_data(device_id, ["memory_usage"])
|
||||
storage_chart_data = load_sensor_chart_data(device_id, ["disk_usage"])
|
||||
|
|
@ -110,6 +111,7 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
|> assign(:snmp_sensors, snmp_data.sensors)
|
||||
|> assign(:neighbors, neighbors)
|
||||
|> assign(:events, events)
|
||||
|> assign(:latency_chart_data, latency_chart_data)
|
||||
|> assign(:cpu_chart_data, cpu_chart_data)
|
||||
|> assign(:memory_chart_data, memory_chart_data)
|
||||
|> assign(:storage_chart_data, storage_chart_data)
|
||||
|
|
@ -317,6 +319,33 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
}
|
||||
end
|
||||
|
||||
defp load_latency_chart_data(device_id) do
|
||||
twenty_four_hours_ago = DateTime.add(DateTime.utc_now(), -24, :hour)
|
||||
|
||||
checks =
|
||||
device_id
|
||||
|> Monitoring.get_latency_data(since: twenty_four_hours_ago, limit: 1000)
|
||||
|> Enum.reverse()
|
||||
|
||||
if Enum.empty?(checks) do
|
||||
nil
|
||||
else
|
||||
dataset = %{
|
||||
label: "Ping Latency",
|
||||
data: Enum.map(checks, &latency_check_to_data_point/1)
|
||||
}
|
||||
|
||||
Jason.encode!(%{datasets: [dataset]})
|
||||
end
|
||||
end
|
||||
|
||||
defp latency_check_to_data_point(check) do
|
||||
%{
|
||||
x: DateTime.to_unix(check.checked_at, :millisecond),
|
||||
y: check.response_time_ms
|
||||
}
|
||||
end
|
||||
|
||||
defp load_overall_traffic_chart_data(device_id) do
|
||||
case Snmp.get_device_with_associations(device_id) do
|
||||
nil -> nil
|
||||
|
|
|
|||
|
|
@ -211,6 +211,36 @@
|
|||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Ping Latency Chart -->
|
||||
<%= if @latency_chart_data do %>
|
||||
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
|
||||
<.link
|
||||
navigate={
|
||||
~p"/orgs/#{@current_organization.slug}/devices/#{@device.id}/graph/latency"
|
||||
}
|
||||
class="block px-4 py-3 border-b border-zinc-200 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-750 transition-colors"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Ping Latency
|
||||
</h3>
|
||||
<.icon name="hero-arrow-right" class="h-4 w-4 text-zinc-400" />
|
||||
</div>
|
||||
</.link>
|
||||
<div class="p-4">
|
||||
<div
|
||||
id="latency-chart"
|
||||
phx-hook="SensorChart"
|
||||
data-chart={@latency_chart_data}
|
||||
data-unit="ms"
|
||||
data-auto-scale="true"
|
||||
style="height: 300px;"
|
||||
>
|
||||
<canvas></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<!-- Overall Traffic Chart -->
|
||||
<%= if @traffic_chart_data do %>
|
||||
<div class="bg-white dark:bg-zinc-800 rounded-lg border border-zinc-200 dark:border-zinc-700">
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ defmodule ToweropsWeb.GraphLive.Show do
|
|||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.Devices
|
||||
alias Towerops.Monitoring
|
||||
alias Towerops.Snmp
|
||||
|
||||
@impl true
|
||||
|
|
@ -64,6 +65,9 @@ defmodule ToweropsWeb.GraphLive.Show do
|
|||
|
||||
{chart_data, title_suffix} =
|
||||
cond do
|
||||
sensor_type == "latency" ->
|
||||
{load_latency_chart_data(device_id, range), nil}
|
||||
|
||||
sensor_type == "traffic" && interface_id ->
|
||||
{load_interface_traffic_chart_data(interface_id, range), get_interface_name(interface_id)}
|
||||
|
||||
|
|
@ -97,6 +101,7 @@ defmodule ToweropsWeb.GraphLive.Show do
|
|||
defp get_sensor_types_for_chart("traffic"), do: nil
|
||||
defp get_sensor_types_for_chart(_), do: []
|
||||
|
||||
defp get_chart_config("latency"), do: {"Ping Latency", "ms", true}
|
||||
defp get_chart_config("processors"), do: {"Processor Usage", "%", false}
|
||||
defp get_chart_config("memory"), do: {"Memory Usage", "%", false}
|
||||
defp get_chart_config("storage"), do: {"Storage Usage", "%", false}
|
||||
|
|
@ -187,6 +192,34 @@ defmodule ToweropsWeb.GraphLive.Show do
|
|||
end
|
||||
end
|
||||
|
||||
defp load_latency_chart_data(device_id, range) do
|
||||
since = get_datetime_from_range(range)
|
||||
limit = get_limit_for_range(range)
|
||||
|
||||
checks =
|
||||
device_id
|
||||
|> Monitoring.get_latency_data(since: since, limit: limit)
|
||||
|> Enum.reverse()
|
||||
|
||||
if Enum.empty?(checks) do
|
||||
nil
|
||||
else
|
||||
dataset = %{
|
||||
label: "Ping Latency",
|
||||
data: Enum.map(checks, &latency_check_to_chart_point/1)
|
||||
}
|
||||
|
||||
Jason.encode!(%{datasets: [dataset]})
|
||||
end
|
||||
end
|
||||
|
||||
defp latency_check_to_chart_point(check) do
|
||||
%{
|
||||
x: DateTime.to_unix(check.checked_at, :millisecond),
|
||||
y: check.response_time_ms
|
||||
}
|
||||
end
|
||||
|
||||
defp build_traffic_chart_json(device, range) do
|
||||
if Enum.empty?(device.interfaces), do: nil, else: build_traffic_datasets(device, range)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -284,6 +284,135 @@ defmodule Towerops.MonitoringTest do
|
|||
result = Monitoring.get_uptime_percentage(device.id)
|
||||
assert is_nil(result)
|
||||
end
|
||||
|
||||
test "get_latency_data/2 returns latency checks for graphing", %{device: device} do
|
||||
base_time = ~U[2025-12-21 12:00:00Z]
|
||||
|
||||
# Create successful checks with varying latency
|
||||
{:ok, _} =
|
||||
Monitoring.create_check(%{
|
||||
device_id: device.id,
|
||||
status: :success,
|
||||
response_time_ms: 50,
|
||||
checked_at: base_time
|
||||
})
|
||||
|
||||
{:ok, _} =
|
||||
Monitoring.create_check(%{
|
||||
device_id: device.id,
|
||||
status: :success,
|
||||
response_time_ms: 75,
|
||||
checked_at: DateTime.add(base_time, 60, :second)
|
||||
})
|
||||
|
||||
{:ok, _} =
|
||||
Monitoring.create_check(%{
|
||||
device_id: device.id,
|
||||
status: :success,
|
||||
response_time_ms: 100,
|
||||
checked_at: DateTime.add(base_time, 120, :second)
|
||||
})
|
||||
|
||||
# Create a failed check (should be excluded)
|
||||
{:ok, _} =
|
||||
Monitoring.create_check(%{
|
||||
device_id: device.id,
|
||||
status: :failure,
|
||||
response_time_ms: nil,
|
||||
checked_at: DateTime.add(base_time, 180, :second)
|
||||
})
|
||||
|
||||
since = DateTime.add(base_time, -3600, :second)
|
||||
result = Monitoring.get_latency_data(device.id, since: since, limit: 100)
|
||||
|
||||
assert length(result) == 3
|
||||
# Results should be ordered by checked_at descending
|
||||
assert Enum.at(result, 0).response_time_ms == 100
|
||||
assert Enum.at(result, 1).response_time_ms == 75
|
||||
assert Enum.at(result, 2).response_time_ms == 50
|
||||
end
|
||||
|
||||
test "get_latency_data/2 respects limit option", %{device: device} do
|
||||
base_time = ~U[2025-12-21 12:00:00Z]
|
||||
|
||||
# Create 5 checks
|
||||
for i <- 1..5 do
|
||||
{:ok, _} =
|
||||
Monitoring.create_check(%{
|
||||
device_id: device.id,
|
||||
status: :success,
|
||||
response_time_ms: 50 + i,
|
||||
checked_at: DateTime.add(base_time, i * 60, :second)
|
||||
})
|
||||
end
|
||||
|
||||
since = DateTime.add(base_time, -3600, :second)
|
||||
result = Monitoring.get_latency_data(device.id, since: since, limit: 3)
|
||||
|
||||
assert length(result) == 3
|
||||
end
|
||||
|
||||
test "get_latency_data/2 respects since option", %{device: device} do
|
||||
base_time = ~U[2025-12-21 12:00:00Z]
|
||||
|
||||
# Create check before cutoff
|
||||
{:ok, _} =
|
||||
Monitoring.create_check(%{
|
||||
device_id: device.id,
|
||||
status: :success,
|
||||
response_time_ms: 50,
|
||||
checked_at: DateTime.add(base_time, -7200, :second)
|
||||
})
|
||||
|
||||
# Create check after cutoff
|
||||
{:ok, _} =
|
||||
Monitoring.create_check(%{
|
||||
device_id: device.id,
|
||||
status: :success,
|
||||
response_time_ms: 75,
|
||||
checked_at: base_time
|
||||
})
|
||||
|
||||
since = DateTime.add(base_time, -3600, :second)
|
||||
result = Monitoring.get_latency_data(device.id, since: since, limit: 100)
|
||||
|
||||
# Should only include the check after the since timestamp
|
||||
assert length(result) == 1
|
||||
assert hd(result).response_time_ms == 75
|
||||
end
|
||||
|
||||
test "get_latency_data/2 returns empty list when no checks exist", %{device: device} do
|
||||
since = DateTime.add(DateTime.utc_now(), -3600, :second)
|
||||
result = Monitoring.get_latency_data(device.id, since: since, limit: 100)
|
||||
|
||||
assert result == []
|
||||
end
|
||||
|
||||
test "get_latency_data/2 excludes failed checks", %{device: device} do
|
||||
base_time = ~U[2025-12-21 12:00:00Z]
|
||||
|
||||
# Create only failed checks
|
||||
{:ok, _} =
|
||||
Monitoring.create_check(%{
|
||||
device_id: device.id,
|
||||
status: :failure,
|
||||
response_time_ms: nil,
|
||||
checked_at: base_time
|
||||
})
|
||||
|
||||
{:ok, _} =
|
||||
Monitoring.create_check(%{
|
||||
device_id: device.id,
|
||||
status: :failure,
|
||||
response_time_ms: nil,
|
||||
checked_at: DateTime.add(base_time, 60, :second)
|
||||
})
|
||||
|
||||
since = DateTime.add(base_time, -3600, :second)
|
||||
result = Monitoring.get_latency_data(device.id, since: since, limit: 100)
|
||||
|
||||
assert result == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "ping" do
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue