diff --git a/lib/towerops/monitoring.ex b/lib/towerops/monitoring.ex
index eec7fbac..ec71d9b0 100644
--- a/lib/towerops/monitoring.ex
+++ b/lib/towerops/monitoring.ex
@@ -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
diff --git a/lib/towerops_web/live/device_live/show.ex b/lib/towerops_web/live/device_live/show.ex
index 85b2e65f..6a3069a1 100644
--- a/lib/towerops_web/live/device_live/show.ex
+++ b/lib/towerops_web/live/device_live/show.ex
@@ -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
diff --git a/lib/towerops_web/live/device_live/show.html.heex b/lib/towerops_web/live/device_live/show.html.heex
index 785cbb12..a81e6411 100644
--- a/lib/towerops_web/live/device_live/show.html.heex
+++ b/lib/towerops_web/live/device_live/show.html.heex
@@ -211,6 +211,36 @@
+
+ <%= if @latency_chart_data do %>
+
+ <.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"
+ >
+
+
+ Ping Latency
+
+ <.icon name="hero-arrow-right" class="h-4 w-4 text-zinc-400" />
+
+
+
+
+ <% end %>
<%= if @traffic_chart_data do %>
diff --git a/lib/towerops_web/live/graph_live/show.ex b/lib/towerops_web/live/graph_live/show.ex
index 0db6483b..cc0ed1e2 100644
--- a/lib/towerops_web/live/graph_live/show.ex
+++ b/lib/towerops_web/live/graph_live/show.ex
@@ -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
diff --git a/test/towerops/monitoring_test.exs b/test/towerops/monitoring_test.exs
index a2b06fbe..1bd2e917 100644
--- a/test/towerops/monitoring_test.exs
+++ b/test/towerops/monitoring_test.exs
@@ -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