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:
Graham McIntire 2026-01-17 16:02:35 -06:00
parent f0f5aca491
commit 55a0d88978
No known key found for this signature in database
5 changed files with 251 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

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